diff --git a/app/client/cypress/support/commands.js b/app/client/cypress/support/commands.js
index 60dc17086a..290ea2b3af 100644
--- a/app/client/cypress/support/commands.js
+++ b/app/client/cypress/support/commands.js
@@ -1128,6 +1128,15 @@ Cypress.Commands.add("startServerAndRoutes", () => {
cy.intercept("PUT", "/api/v1/git/discard/app/*").as("discardChanges");
cy.intercept("GET", "/api/v1/libraries/*").as("getLibraries");
featureFlagIntercept({}, false);
+ // Mock empty product alerts so that it does not interfere with tests
+ cy.intercept("GET", "/api/v1/product-alert/alert", {
+ responseMeta: {
+ status: 200,
+ success: true,
+ },
+ data: {},
+ errorDisplay: "",
+ });
});
Cypress.Commands.add("startErrorRoutes", () => {
diff --git a/app/client/src/actions/userActions.ts b/app/client/src/actions/userActions.ts
index e1337fc6a6..f0b654684c 100644
--- a/app/client/src/actions/userActions.ts
+++ b/app/client/src/actions/userActions.ts
@@ -9,6 +9,10 @@ import type {
VerifyTokenRequest,
} from "@appsmith/api/UserApi";
import type { FeatureFlags } from "@appsmith/entities/FeatureFlag";
+import type {
+ ProductAlertConfig,
+ ProductAlertState,
+} from "reducers/uiReducers/usersReducer";
export const logoutUser = (payload?: { redirectURL: string }) => ({
type: ReduxActionTypes.LOGOUT_USER_INIT,
@@ -112,3 +116,22 @@ export const fetchFeatureFlagsError = (error: any) => ({
type: ReduxActionErrorTypes.FETCH_FEATURE_FLAGS_ERROR,
payload: { error, show: false },
});
+
+export const fetchProductAlertInit = () => ({
+ type: ReduxActionTypes.FETCH_PRODUCT_ALERT_INIT,
+});
+
+export const fetchProductAlertSuccess = (productAlert: ProductAlertState) => ({
+ type: ReduxActionTypes.FETCH_PRODUCT_ALERT_SUCCESS,
+ payload: productAlert,
+});
+
+export const fetchProductAlertFailure = (error: any) => ({
+ type: ReduxActionErrorTypes.FETCH_PRODUCT_ALERT_FAILED,
+ payload: { error, show: false },
+});
+
+export const updateProductAlertConfig = (config: ProductAlertConfig) => ({
+ type: ReduxActionTypes.UPDATE_PRODUCT_ALERT_CONFIG,
+ payload: config,
+});
diff --git a/app/client/src/ce/AppRouter.tsx b/app/client/src/ce/AppRouter.tsx
index 879dba287b..2684a9475a 100644
--- a/app/client/src/ce/AppRouter.tsx
+++ b/app/client/src/ce/AppRouter.tsx
@@ -51,7 +51,10 @@ import SettingsLoader from "pages/Settings/loader";
import SignupSuccess from "pages/setup/SignupSuccess";
import type { ERROR_CODES } from "@appsmith/constants/ApiConstants";
import TemplatesListLoader from "pages/Templates/loader";
-import { fetchFeatureFlagsInit } from "actions/userActions";
+import {
+ fetchFeatureFlagsInit,
+ fetchProductAlertInit,
+} from "actions/userActions";
import { getCurrentTenant } from "@appsmith/actions/tenantActions";
import { getDefaultAdminSettingsPath } from "@appsmith/utils/adminSettingsHelpers";
import { getCurrentUser as getCurrentUserSelector } from "selectors/usersSelectors";
@@ -63,6 +66,7 @@ import useBrandingTheme from "utils/hooks/useBrandingTheme";
import RouteChangeListener from "RouteChangeListener";
import { initCurrentPage } from "../actions/initActions";
import Walkthrough from "components/featureWalkthrough";
+import ProductAlertBanner from "components/editorComponents/ProductAlertBanner";
export const SentryRoute = Sentry.withSentryRouting(Route);
@@ -133,10 +137,16 @@ function AppRouter(props: {
getFeatureFlags: () => void;
getCurrentTenant: () => void;
initCurrentPage: () => void;
+ fetchProductAlert: () => void;
safeCrashCode?: ERROR_CODES;
}) {
- const { getCurrentTenant, getCurrentUser, getFeatureFlags, initCurrentPage } =
- props;
+ const {
+ fetchProductAlert,
+ getCurrentTenant,
+ getCurrentUser,
+ getFeatureFlags,
+ initCurrentPage,
+ } = props;
const tenantIsLoading = useSelector(isTenantLoading);
const currentUserIsLoading = useSelector(getCurrentUserLoading);
@@ -145,6 +155,7 @@ function AppRouter(props: {
getFeatureFlags();
getCurrentTenant();
initCurrentPage();
+ fetchProductAlert();
}, []);
useBrandingTheme();
@@ -176,10 +187,13 @@ function AppRouter(props: {
>
) : (
-
-
-
-
+ <>
+
+
+
+
+
+ >
)}
@@ -196,6 +210,7 @@ const mapDispatchToProps = (dispatch: any) => ({
getFeatureFlags: () => dispatch(fetchFeatureFlagsInit()),
getCurrentTenant: () => dispatch(getCurrentTenant(false)),
initCurrentPage: () => dispatch(initCurrentPage()),
+ fetchProductAlert: () => dispatch(fetchProductAlertInit()),
});
export default connect(mapStateToProps, mapDispatchToProps)(AppRouter);
diff --git a/app/client/src/ce/api/UserApi.tsx b/app/client/src/ce/api/UserApi.tsx
index cd0535fd0f..38438b9f6f 100644
--- a/app/client/src/ce/api/UserApi.tsx
+++ b/app/client/src/ce/api/UserApi.tsx
@@ -2,6 +2,7 @@ import type { AxiosPromise } from "axios";
import Api from "api/Api";
import type { ApiResponse } from "api/ApiResponses";
import type { FeatureFlags } from "@appsmith/entities/FeatureFlag";
+import type { ProductAlert } from "../../reducers/uiReducers/usersReducer";
export interface LoginUserRequest {
email: string;
@@ -82,6 +83,7 @@ export interface CreateSuperUserRequest {
export class UserApi extends Api {
static usersURL = "v1/users";
+ static productAlertURL = "v1/product-alert/alert";
static forgotPasswordURL = `${UserApi.usersURL}/forgotPassword`;
static verifyResetPasswordTokenURL = `${UserApi.usersURL}/verifyPasswordResetToken`;
static resetPasswordURL = `${UserApi.usersURL}/resetPassword`;
@@ -217,6 +219,10 @@ export class UserApi extends Api {
): AxiosPromise {
return Api.post(UserApi.sendTestEmailURL, payload);
}
+
+ static getProductAlert(): AxiosPromise> {
+ return Api.get(UserApi.productAlertURL);
+ }
}
export default UserApi;
diff --git a/app/client/src/ce/constants/ReduxActionConstants.tsx b/app/client/src/ce/constants/ReduxActionConstants.tsx
index f982830edd..36c6c8d739 100644
--- a/app/client/src/ce/constants/ReduxActionConstants.tsx
+++ b/app/client/src/ce/constants/ReduxActionConstants.tsx
@@ -843,6 +843,9 @@ const ActionTypes = {
"SET_ONE_CLICK_BINDING_OPTIONS_VISIBILITY",
BUFFERED_ACTION: "BUFFERED_ACTION",
WIDGET_INIT_SUCCESS: "WIDGET_INIT_SUCCESS",
+ FETCH_PRODUCT_ALERT_INIT: "FETCH_PRODUCT_ALERT_INIT",
+ FETCH_PRODUCT_ALERT_SUCCESS: "FETCH_PRODUCT_ALERT_SUCCESS",
+ UPDATE_PRODUCT_ALERT_CONFIG: "UPDATE_PRODUCT_ALERT_CONFIG",
};
export const ReduxActionTypes = {
@@ -1027,6 +1030,7 @@ export const ReduxActionErrorTypes = {
DELETE_NAVIGATION_LOGO_ERROR: "DELETE_NAVIGATION_LOGO_ERROR",
USER_PROFILE_PICTURE_UPLOAD_FAILED: "USER_PROFILE_PICTURE_UPLOAD_FAILED",
USER_IMAGE_INVALID_FILE_CONTENT: "USER_IMAGE_INVALID_FILE_CONTENT",
+ FETCH_PRODUCT_ALERT_FAILED: "FETCH_PRODUCT_ALERT_FAILED",
};
export const ReduxFormActionTypes = {
diff --git a/app/client/src/ce/constants/messages.ts b/app/client/src/ce/constants/messages.ts
index 7cc4ef0cf2..45887147ef 100644
--- a/app/client/src/ce/constants/messages.ts
+++ b/app/client/src/ce/constants/messages.ts
@@ -874,6 +874,8 @@ export const CONFIRM_SSH_KEY = () =>
"Please make sure your SSH key has write access.";
export const READ_DOCUMENTATION = () => "Read documentation";
export const LEARN_MORE = () => "Learn more";
+
+export const I_UNDERSTAND = () => "I understand";
export const GIT_NO_UPDATED_TOOLTIP = () => "No new updates to push";
export const FIND_OR_CREATE_A_BRANCH = () => "Find or create a branch";
diff --git a/app/client/src/ce/sagas/userSagas.tsx b/app/client/src/ce/sagas/userSagas.tsx
index 96a777fb6f..baac439e63 100644
--- a/app/client/src/ce/sagas/userSagas.tsx
+++ b/app/client/src/ce/sagas/userSagas.tsx
@@ -36,6 +36,8 @@ import {
invitedUserSignupSuccess,
fetchFeatureFlagsSuccess,
fetchFeatureFlagsError,
+ fetchProductAlertSuccess,
+ fetchProductAlertFailure,
} from "actions/userActions";
import AnalyticsUtil from "utils/AnalyticsUtil";
import { INVITE_USERS_TO_WORKSPACE_FORM } from "@appsmith/constants/forms";
@@ -79,6 +81,10 @@ import {
UPDATE_USER_DETAILS_FAILED,
} from "@appsmith/constants/messages";
import { createMessage } from "design-system-old/build/constants/messages";
+import type {
+ ProductAlert,
+ ProductAlertConfig,
+} from "reducers/uiReducers/usersReducer";
export function* createUserSaga(
action: ReduxActionWithPromise,
@@ -565,3 +571,55 @@ export function* leaveWorkspaceSaga(
// do nothing as it's already handled globally
}
}
+
+export function* fetchProductAlertSaga() {
+ try {
+ const response: ApiResponse = yield call(
+ UserApi.getProductAlert,
+ );
+ const isValidResponse: boolean = yield validateResponse(response);
+ if (isValidResponse) {
+ const message = response.data;
+ if (message.messageId) {
+ const config = getMessageConfig(message.messageId);
+ yield put(fetchProductAlertSuccess({ message, config }));
+ }
+ } else {
+ yield put(fetchProductAlertFailure(response.data));
+ }
+ } catch (e) {
+ yield put(fetchProductAlertFailure(e));
+ }
+}
+
+export const PRODUCT_ALERT_CONFIG_STORAGE_KEY = "PRODUCT_ALERT_CONFIG";
+export const getMessageConfig = (id: string): ProductAlertConfig => {
+ const storedConfig =
+ localStorage.getItem(PRODUCT_ALERT_CONFIG_STORAGE_KEY) || "{}";
+ const alertConfig: Record =
+ JSON.parse(storedConfig);
+ if (id in alertConfig) {
+ return alertConfig[id];
+ }
+ return {
+ snoozeTill: new Date(),
+ dismissed: false,
+ };
+};
+
+export const setMessageConfig = (id: string, config: ProductAlertConfig) => {
+ const storedConfig =
+ localStorage.getItem(PRODUCT_ALERT_CONFIG_STORAGE_KEY) || "{}";
+ const alertConfig: Record =
+ JSON.parse(storedConfig);
+
+ const updatedConfig: Record = {
+ ...alertConfig,
+ [id]: config,
+ };
+
+ localStorage.setItem(
+ PRODUCT_ALERT_CONFIG_STORAGE_KEY,
+ JSON.stringify(updatedConfig),
+ );
+};
diff --git a/app/client/src/components/editorComponents/ProductAlertBanner.tsx b/app/client/src/components/editorComponents/ProductAlertBanner.tsx
new file mode 100644
index 0000000000..1bd9cf13d9
--- /dev/null
+++ b/app/client/src/components/editorComponents/ProductAlertBanner.tsx
@@ -0,0 +1,152 @@
+import React, { useCallback, useEffect, useState } from "react";
+import { useDispatch, useSelector } from "react-redux";
+import styled from "styled-components";
+import { Callout, Text } from "design-system";
+import type {
+ ProductAlertConfig,
+ ProductAlertState,
+} from "reducers/uiReducers/usersReducer";
+import { setMessageConfig } from "@appsmith/sagas/userSagas";
+import type { CalloutLinkProps } from "design-system/build/Callout/Callout.types";
+import moment from "moment/moment";
+import {
+ createMessage,
+ I_UNDERSTAND,
+ LEARN_MORE,
+} from "@appsmith/constants/messages";
+import { getIsFirstTimeUserOnboardingEnabled } from "selectors/onboardingSelectors";
+import { updateProductAlertConfig } from "actions/userActions";
+import { getIsUserLoggedIn } from "selectors/usersSelectors";
+
+const AlertContainer = styled.div`
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ z-index: 10;
+`;
+
+const AnimationContainer = styled.div`
+ animation-duration: 0.75s;
+ animation-delay: 0.5s;
+ animation-name: animate-slide;
+ animation-timing-function: cubic-bezier(0.26, 0.53, 0.74, 1.48);
+ animation-fill-mode: backwards;
+
+ @keyframes animate-slide {
+ 0% {
+ opacity: 0;
+ transform: translate(0,50px);
+ }
+ 100% {
+ opacity: 1;
+ transform: translate(0,0);
+ }
+`;
+
+const ProductAlertBanner = () => {
+ const dispatch = useDispatch();
+
+ const [isShown, setIsShown] = useState(false);
+ const [dismissed, setDismissed] = useState(false);
+
+ const isSignpostingOverlayOpen = useSelector(
+ getIsFirstTimeUserOnboardingEnabled,
+ );
+ const userIsLoggedIn = useSelector(getIsUserLoggedIn);
+ const { config, message }: ProductAlertState | undefined = useSelector(
+ (state) => state.ui.users.productAlert,
+ );
+
+ const updateConfig = useCallback(
+ (messageId: string, config: ProductAlertConfig) => {
+ dispatch(updateProductAlertConfig(config));
+ setMessageConfig(messageId, config);
+ },
+ [],
+ );
+
+ // Delay showing the message.
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ setIsShown(true);
+ }, 2000);
+ return () => clearTimeout(timer);
+ }, []);
+
+ if (!isShown) return null;
+
+ if (!userIsLoggedIn) return null;
+ if (isSignpostingOverlayOpen) return null;
+
+ if (!message) return null;
+ // If dismissed, it will not be shown
+ if (config && config.dismissed) return null;
+ if (dismissed) return null;
+
+ // If still snoozed, it will not be shown
+ if (config && config.snoozeTill) {
+ const stillSnoozed = moment().isBefore(moment(config.snoozeTill));
+ if (stillSnoozed) {
+ return null;
+ }
+ }
+
+ const links: CalloutLinkProps[] = [];
+
+ if (message.learnMoreLink) {
+ links.push({
+ children: createMessage(LEARN_MORE),
+ onClick: () => {
+ window.open(message.learnMoreLink, "_blank");
+ },
+ });
+ }
+
+ if (message.canDismiss) {
+ links.push({
+ children: createMessage(I_UNDERSTAND),
+ onClick: () => {
+ updateConfig(message.messageId, {
+ dismissed: true,
+ snoozeTill: new Date(),
+ });
+ setDismissed(true);
+ },
+ });
+ }
+
+ return (
+
+
+ 0 || message.canDismiss}
+ kind={"warning"}
+ links={links}
+ onClose={() => {
+ if (message.remindLaterDays) {
+ updateConfig(message.messageId, {
+ dismissed: false,
+ snoozeTill: moment()
+ .add(message.remindLaterDays, "days")
+ .toDate(),
+ });
+ } else if (message.canDismiss) {
+ updateConfig(message.messageId, {
+ dismissed: true,
+ snoozeTill: new Date(),
+ });
+ }
+ setDismissed(true);
+ }}
+ >
+ {message.title}
+
+ {message.message}
+
+
+
+ );
+};
+
+export default ProductAlertBanner;
diff --git a/app/client/src/constants/ProductUpdate.ts b/app/client/src/constants/ProductUpdate.ts
new file mode 100644
index 0000000000..bef8a4824e
--- /dev/null
+++ b/app/client/src/constants/ProductUpdate.ts
@@ -0,0 +1,28 @@
+/***
+ * Product update is used to show a fixed banner to (all) users at the bottom
+ * of the screen. It should only be shown to
+ *
+ */
+
+type ProductUpdate = {
+ id: string; // ID is important for dismissal and remindLater to work
+ enabled: boolean; // Won't be shown till this is true
+ title: string;
+ message: string;
+ learnMoreLink: string;
+ canDismiss: boolean; // Can the user close this message.
+ remindLaterDays?: number; // If the user chooses to remind later, // it will be shown again after these many days
+};
+
+const update: ProductUpdate = {
+ enabled: false,
+ id: "1",
+ title: "Test issue",
+ message:
+ "Something is wrong. Lorem ipsum something something. You need to learn this",
+ learnMoreLink: "https://docs.appsmith.com",
+ canDismiss: true,
+ remindLaterDays: 1,
+};
+
+export default update;
diff --git a/app/client/src/ee/sagas/userSagas.tsx b/app/client/src/ee/sagas/userSagas.tsx
index 07d56160fc..6c0b074aae 100644
--- a/app/client/src/ee/sagas/userSagas.tsx
+++ b/app/client/src/ee/sagas/userSagas.tsx
@@ -16,6 +16,7 @@ import {
leaveWorkspaceSaga,
fetchFeatureFlags,
updateFirstTimeUserOnboardingSage,
+ fetchProductAlertSaga,
} from "ce/sagas/userSagas";
import { ReduxActionTypes } from "@appsmith/constants/ReduxActionConstants";
import { takeLatest, all } from "redux-saga/effects";
@@ -49,6 +50,10 @@ export default function* userSagas() {
takeLatest(ReduxActionTypes.UPLOAD_PROFILE_PHOTO, updatePhoto),
takeLatest(ReduxActionTypes.LEAVE_WORKSPACE_INIT, leaveWorkspaceSaga),
takeLatest(ReduxActionTypes.FETCH_FEATURE_FLAGS_INIT, fetchFeatureFlags),
+ takeLatest(
+ ReduxActionTypes.FETCH_PRODUCT_ALERT_INIT,
+ fetchProductAlertSaga,
+ ),
takeLatest(
ReduxActionTypes.FETCH_USER_DETAILS_SUCCESS,
updateFirstTimeUserOnboardingSage,
diff --git a/app/client/src/reducers/uiReducers/usersReducer.ts b/app/client/src/reducers/uiReducers/usersReducer.ts
index 39ac9929dd..cfef428966 100644
--- a/app/client/src/reducers/uiReducers/usersReducer.ts
+++ b/app/client/src/reducers/uiReducers/usersReducer.ts
@@ -25,6 +25,12 @@ const initialState: UsersReduxState = {
data: DEFAULT_FEATURE_FLAG_VALUE,
isFetched: false,
},
+ productAlert: {
+ config: {
+ dismissed: false,
+ snoozeTill: new Date(),
+ },
+ },
};
const usersReducer = createReducer(initialState, {
@@ -186,6 +192,23 @@ const usersReducer = createReducer(initialState, {
isFetched: true,
},
}),
+ [ReduxActionTypes.FETCH_PRODUCT_ALERT_SUCCESS]: (
+ state: UsersReduxState,
+ action: ReduxAction,
+ ) => ({
+ ...state,
+ productAlert: action.payload,
+ }),
+ [ReduxActionTypes.UPDATE_PRODUCT_ALERT_CONFIG]: (
+ state: UsersReduxState,
+ action: ReduxAction,
+ ): UsersReduxState => ({
+ ...state,
+ productAlert: {
+ ...state.productAlert,
+ config: action.payload,
+ },
+ }),
});
export interface PropertyPanePositionConfig {
@@ -195,6 +218,26 @@ export interface PropertyPanePositionConfig {
top: number;
};
}
+
+export interface ProductAlert {
+ messageId: string;
+ title: string;
+ message: string;
+ canDismiss: boolean;
+ remindLaterDays: number;
+ learnMoreLink?: string;
+}
+
+export interface ProductAlertConfig {
+ dismissed: boolean;
+ snoozeTill: Date;
+}
+
+export interface ProductAlertState {
+ message?: ProductAlert;
+ config: ProductAlertConfig;
+}
+
export interface UsersReduxState {
current?: User;
list: User[];
@@ -210,6 +253,7 @@ export interface UsersReduxState {
isFetched: boolean;
data: FeatureFlags;
};
+ productAlert: ProductAlertState;
}
export default usersReducer;
diff --git a/app/client/src/selectors/usersSelectors.tsx b/app/client/src/selectors/usersSelectors.tsx
index 11419f526e..53fcc78c02 100644
--- a/app/client/src/selectors/usersSelectors.tsx
+++ b/app/client/src/selectors/usersSelectors.tsx
@@ -1,6 +1,7 @@
import type { AppState } from "@appsmith/reducers";
import type { User } from "constants/userConstants";
import type { PropertyPanePositionConfig } from "reducers/uiReducers/usersReducer";
+import { ANONYMOUS_USERNAME } from "constants/userConstants";
export const getCurrentUser = (state: AppState): User | undefined =>
state.ui?.users?.currentUser;
@@ -14,3 +15,6 @@ export const getProppanePreference = (
): PropertyPanePositionConfig | undefined => state.ui.users.propPanePreferences;
export const getFeatureFlagsFetched = (state: AppState) =>
state.ui.users.featureFlag.isFetched;
+
+export const getIsUserLoggedIn = (state: AppState): boolean =>
+ state.ui.users.currentUser?.email !== ANONYMOUS_USERNAME;
diff --git a/app/client/src/utils/hooks/localstorage.tsx b/app/client/src/utils/hooks/localstorage.tsx
index 852e7d67c3..e806bc1559 100644
--- a/app/client/src/utils/hooks/localstorage.tsx
+++ b/app/client/src/utils/hooks/localstorage.tsx
@@ -2,7 +2,7 @@ import { useState } from "react";
import localStorage from "utils/localStorage";
import log from "loglevel";
-export function useLocalStorage(key: string, initialValue: string) {
+export function useLocalStorage(key: string, initialValue?: unknown) {
// State to store our value
// Pass initial state function to useState so logic is only executed once
const [storedValue, setStoredValue] = useState(() => {
diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/constants/ce/UrlCE.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/constants/ce/UrlCE.java
index f3437fe6cf..569c322795 100644
--- a/app/server/appsmith-server/src/main/java/com/appsmith/server/constants/ce/UrlCE.java
+++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/constants/ce/UrlCE.java
@@ -37,6 +37,8 @@ public class UrlCE {
public static final String TENANT_URL = BASE_URL + VERSION + "/tenants";
public static final String CUSTOM_JS_LIB_URL = BASE_URL + VERSION + "/libraries";
+ final public static String PRODUCT_ALERT = BASE_URL + VERSION + "/product-alert";
+
// Sub-paths
public static final String MOCKS = "/mocks";
public static final String RELEASE_ITEMS = "/releaseItems";
diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/ProductFeatureAlertController.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/ProductFeatureAlertController.java
new file mode 100644
index 0000000000..ccfd4ada5c
--- /dev/null
+++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/ProductFeatureAlertController.java
@@ -0,0 +1,16 @@
+package com.appsmith.server.controllers;
+
+import com.appsmith.server.constants.Url;
+import com.appsmith.server.controllers.ce.ProductFeatureAlertControllerCE;
+import com.appsmith.server.services.ProductAlertService;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping(Url.PRODUCT_ALERT)
+public class ProductFeatureAlertController extends ProductFeatureAlertControllerCE {
+
+ public ProductFeatureAlertController(ProductAlertService productAlertService) {
+ super(productAlertService);
+ }
+}
diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/ce/ProductFeatureAlertControllerCE.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/ce/ProductFeatureAlertControllerCE.java
new file mode 100644
index 0000000000..0f60194392
--- /dev/null
+++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/ce/ProductFeatureAlertControllerCE.java
@@ -0,0 +1,35 @@
+package com.appsmith.server.controllers.ce;
+
+import com.appsmith.external.views.Views;
+import com.appsmith.server.constants.Url;
+import com.appsmith.server.dtos.ResponseDTO;
+import com.appsmith.server.dtos.ce.ProductAlertResponseDTO;
+import com.appsmith.server.services.ProductAlertService;
+import com.fasterxml.jackson.annotation.JsonView;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import reactor.core.publisher.Mono;
+
+@RequestMapping(Url.PRODUCT_ALERT)
+public class ProductFeatureAlertControllerCE {
+
+ private final ProductAlertService productAlertService;
+
+ public ProductFeatureAlertControllerCE(ProductAlertService productAlertService) {
+ this.productAlertService = productAlertService;
+ }
+
+ @JsonView(Views.Public.class)
+ @GetMapping("/alert")
+ public Mono> generateCode() {
+ return productAlertService.getSingleApplicableMessage()
+ .map(messages -> {
+ if(messages.size() > 0) {
+ return new ResponseDTO<>(HttpStatus.OK.value(), messages.get(0), null);
+ } else {
+ return new ResponseDTO<>(HttpStatus.OK.value(), new ProductAlertResponseDTO(), null);
+ }
+ });
+ }
+}
diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/ce/ProductAlertMessageApplicabilityContext.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/ce/ProductAlertMessageApplicabilityContext.java
new file mode 100644
index 0000000000..9b1d50022f
--- /dev/null
+++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/ce/ProductAlertMessageApplicabilityContext.java
@@ -0,0 +1,6 @@
+package com.appsmith.server.dtos.ce;
+
+public enum ProductAlertMessageApplicabilityContext {
+ COMMON_CONFIG,
+ STATIC;
+}
diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/ce/ProductAlertResponseDTO.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/ce/ProductAlertResponseDTO.java
new file mode 100644
index 0000000000..98a1dd1cbe
--- /dev/null
+++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/ce/ProductAlertResponseDTO.java
@@ -0,0 +1,38 @@
+package com.appsmith.server.dtos.ce;
+
+import java.util.List;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+import lombok.ToString;
+import org.springframework.data.annotation.Immutable;
+
+@Getter
+@Setter
+@NoArgsConstructor
+@AllArgsConstructor
+@ToString
+@Immutable
+public class ProductAlertResponseDTO implements Comparable {
+ String messageId;
+ String title;
+ String message;
+ String learnMoreLink;
+ Boolean canDismiss;
+ Integer remindLaterDays;
+ ProductAlertMessageApplicabilityContext context;
+ String applicabilityExpression;
+ Integer precedenceIndex;
+
+ @Override
+ public int compareTo(ProductAlertResponseDTO productAlertResponseDTO) {
+ if(this.precedenceIndex < productAlertResponseDTO.getPrecedenceIndex()) {
+ return -1;
+ } else if(this.precedenceIndex > productAlertResponseDTO.getPrecedenceIndex()) {
+ return 1;
+ } else {
+ return 0;
+ }
+ }
+}
diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/exceptions/AppsmithError.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/exceptions/AppsmithError.java
index 14f18bd4d3..83f6449858 100644
--- a/app/server/appsmith-server/src/main/java/com/appsmith/server/exceptions/AppsmithError.java
+++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/exceptions/AppsmithError.java
@@ -898,6 +898,14 @@ public enum AppsmithError {
"Duplicate Configuration",
ErrorType.BAD_REQUEST,
null),
+ INVALID_PROPERTIES_CONFIGURATION(
+ 500,
+ AppsmithErrorCode.INVALID_PROPERTIES_CONFIGURATION.getCode(),
+ "Property configuration is wrong or malformed.",
+ AppsmithErrorAction.DEFAULT,
+ "Invalid application property configuration",
+ ErrorType.INTERNAL_ERROR,
+ null),
;
private final Integer httpErrorCode;
diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/exceptions/AppsmithErrorCode.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/exceptions/AppsmithErrorCode.java
index bc68ea8091..77f22d80b1 100644
--- a/app/server/appsmith-server/src/main/java/com/appsmith/server/exceptions/AppsmithErrorCode.java
+++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/exceptions/AppsmithErrorCode.java
@@ -62,6 +62,7 @@ public enum AppsmithErrorCode {
PLUGIN_EXECUTION_TIMEOUT("AE-APP-5040", "Plugin execution timeout"),
MARKETPLACE_TIMEOUT("AE-APP-5041", "Marketplace timeout"),
GOOGLE_RECAPTCHA_TIMEOUT("AE-APP-5042", "Google recaptcha timeout"),
+ INVALID_PROPERTIES_CONFIGURATION("AE-APP-5043", "Property configuration is wrong or malformed"),
NAME_CLASH_NOT_ALLOWED_IN_REFACTOR("AE-AST-4009", "Name clash not allowed in refactor"),
GENERIC_BAD_REQUEST("AE-BAD-4000", "Generic bad request"),
MALFORMED_REQUEST("AE-BAD-4001", "Malformed request body"),
diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ProductAlertService.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ProductAlertService.java
new file mode 100644
index 0000000000..efb5bc47a0
--- /dev/null
+++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ProductAlertService.java
@@ -0,0 +1,6 @@
+package com.appsmith.server.services;
+
+import com.appsmith.server.services.ce.ProductAlertServiceCE;
+
+public interface ProductAlertService extends ProductAlertServiceCE {
+}
diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ProductAlertServiceImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ProductAlertServiceImpl.java
new file mode 100644
index 0000000000..6edd46c4f3
--- /dev/null
+++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ProductAlertServiceImpl.java
@@ -0,0 +1,15 @@
+package com.appsmith.server.services;
+
+import com.appsmith.server.configurations.CommonConfig;
+import com.appsmith.server.services.ce.ProductAlertServiceCEImpl;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+@Service
+public class ProductAlertServiceImpl extends ProductAlertServiceCEImpl implements ProductAlertService {
+
+ public ProductAlertServiceImpl(@Value("${productalertmessages}") String messageListJSONString, ObjectMapper objectMapper, CommonConfig commonConfig) {
+ super(messageListJSONString, objectMapper, commonConfig);
+ }
+}
diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/ProductAlertServiceCE.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/ProductAlertServiceCE.java
new file mode 100644
index 0000000000..0d5743d797
--- /dev/null
+++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/ProductAlertServiceCE.java
@@ -0,0 +1,9 @@
+package com.appsmith.server.services.ce;
+
+import com.appsmith.server.dtos.ce.ProductAlertResponseDTO;
+import java.util.List;
+import reactor.core.publisher.Mono;
+
+public interface ProductAlertServiceCE {
+ Mono> getSingleApplicableMessage();
+}
diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/ProductAlertServiceCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/ProductAlertServiceCEImpl.java
new file mode 100644
index 0000000000..1a91e07caf
--- /dev/null
+++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/ProductAlertServiceCEImpl.java
@@ -0,0 +1,81 @@
+package com.appsmith.server.services.ce;
+
+import com.appsmith.server.configurations.CommonConfig;
+import com.appsmith.server.dtos.ce.ProductAlertResponseDTO;
+import com.appsmith.server.exceptions.AppsmithError;
+import com.appsmith.server.exceptions.AppsmithException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.PropertySource;
+import org.springframework.expression.EvaluationContext;
+import org.springframework.expression.EvaluationException;
+import org.springframework.expression.Expression;
+import org.springframework.expression.ExpressionParser;
+import org.springframework.expression.spel.standard.SpelExpressionParser;
+import org.springframework.expression.spel.support.StandardEvaluationContext;
+import org.springframework.stereotype.Service;
+import reactor.core.publisher.Mono;
+import reactor.core.scheduler.Scheduler;
+import reactor.core.scheduler.Schedulers;
+
+@Service
+@Slf4j
+@PropertySource("classpath:/productAlerts/productAlertMessages.yml")
+public class ProductAlertServiceCEImpl implements ProductAlertServiceCE {
+ private final String messageListJSONString;
+
+ private final CommonConfig commonConfig;
+
+ private final ObjectMapper mapper;
+
+ private final ProductAlertResponseDTO[] messages;
+
+ private final Scheduler scheduler = Schedulers.boundedElastic();
+
+ public ProductAlertServiceCEImpl(@Value("${productalertmessages}") String messageListJSONString, ObjectMapper objectMapper, CommonConfig commonConfig) {
+ this.messageListJSONString = messageListJSONString;
+ this.commonConfig = commonConfig;
+ this.mapper = objectMapper;
+ try {
+ this.messages = mapper.readValue(messageListJSONString, ProductAlertResponseDTO[].class);
+ } catch (Exception e) {
+ log.error("failed to read product alert properties correctly.", e);
+ throw new AppsmithException(AppsmithError.INVALID_PROPERTIES_CONFIGURATION, "productalertmessages");
+ }
+ }
+
+ public Mono> getSingleApplicableMessage() {
+ return Mono.fromCallable(() -> {
+ List applicableMessages =
+ Arrays.stream(messages).sorted().filter(this::evaluateAlertApplicability).toList();
+ return applicableMessages;
+ }).onErrorResume(error -> {
+ log.error("exception while getting and filtering product alert messages", error);
+ throw new AppsmithException(AppsmithError.INTERNAL_SERVER_ERROR, error.getMessage());
+ })
+ .subscribeOn(scheduler);
+ }
+
+ public Boolean evaluateAlertApplicability(ProductAlertResponseDTO productAlertResponseDTO) {
+ ExpressionParser expressionParser = new SpelExpressionParser();
+ Expression expression = expressionParser.parseExpression(productAlertResponseDTO.getApplicabilityExpression());
+ switch (productAlertResponseDTO.getContext()) {
+ case COMMON_CONFIG:
+ EvaluationContext context = new StandardEvaluationContext(commonConfig);
+ try {
+ return (Boolean) expression.getValue(context);
+ } catch (EvaluationException ee) {
+ log.error("error while evaluating applicability expression");
+ throw ee;
+ }
+ case STATIC:
+ return (Boolean) expression.getValue();
+ default:
+ return true;
+ }
+ }
+}
diff --git a/app/server/appsmith-server/src/main/resources/productAlerts/productAlertMessages.yml b/app/server/appsmith-server/src/main/resources/productAlerts/productAlertMessages.yml
new file mode 100644
index 0000000000..1790f59d67
--- /dev/null
+++ b/app/server/appsmith-server/src/main/resources/productAlerts/productAlertMessages.yml
@@ -0,0 +1,15 @@
+productalertmessages: [\
+ {\
+ "messageId": "1",\
+ "title": "You seem to be using an unsupported MongoDB version.",\
+ "message": "Appsmith now supports only MongoDB versions 5.x or higher. For self-managed MongoDB, upgrade to v5.x \
+ or higher. If you don't have self-managed MongoDB, just upgrade your Appsmith version to the latest. \
+ This is a mandatory checkpoint before you can use your Appsmith instance again.",\
+ "learnMoreLink": "https://www.mongodb.com/docs/manual/release-notes/5.0-upgrade-replica-set",\
+ "canDismiss": true,\
+ "remindLaterDays": 5,\
+ "context": "COMMON_CONFIG",\
+ "applicabilityExpression": "!isCloudHosting",\
+ "precedenceIndex": 1\
+ }\
+]
\ No newline at end of file
diff --git a/app/server/appsmith-server/src/test/java/com/appsmith/server/services/ce/ProductAlertServiceCEImplTest.java b/app/server/appsmith-server/src/test/java/com/appsmith/server/services/ce/ProductAlertServiceCEImplTest.java
new file mode 100644
index 0000000000..6b0cdb9831
--- /dev/null
+++ b/app/server/appsmith-server/src/test/java/com/appsmith/server/services/ce/ProductAlertServiceCEImplTest.java
@@ -0,0 +1,88 @@
+package com.appsmith.server.services.ce;
+
+import com.appsmith.server.configurations.CommonConfig;
+import com.appsmith.server.dtos.ce.ProductAlertResponseDTO;
+import com.appsmith.server.exceptions.AppsmithError;
+import com.appsmith.server.exceptions.AppsmithException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.lang.reflect.Field;
+import java.util.List;
+import lombok.extern.slf4j.Slf4j;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mockito;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.test.context.junit.jupiter.SpringExtension;
+import reactor.core.publisher.Mono;
+import reactor.test.StepVerifier;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@TestPropertySource("classpath:/productAlerts/productAlertMessages.yml")
+@Slf4j
+@ExtendWith(SpringExtension.class)
+public class ProductAlertServiceCEImplTest {
+
+ @Value("${productalertmessages}")
+ String messageListJSONString;
+ ObjectMapper mapper;
+
+ @MockBean
+ CommonConfig commonConfig;
+
+ ProductAlertResponseDTO[] messages = null;
+
+ @BeforeEach
+ public void setup() {
+ try {
+ this.mapper = new ObjectMapper();
+ this.messages = mapper.readValue(messageListJSONString, ProductAlertResponseDTO[].class);
+ } catch (Exception e) {
+ log.error("failed to read product alert properties correctly.", e);
+ throw new AppsmithException(AppsmithError.INVALID_PROPERTIES_CONFIGURATION, "productalertmessages");
+ }
+ }
+
+ @Test
+ public void getSingleApplicableMessage_selfHostedInstance_success() {
+ ProductAlertServiceCE productAlertServiceCE = new ProductAlertServiceCEImpl(messageListJSONString, mapper, commonConfig);
+ Mockito.when(commonConfig.isCloudHosting()).thenReturn(false);
+ Mono> productAlertResponseDTOMono = productAlertServiceCE.getSingleApplicableMessage();
+ StepVerifier
+ .create(productAlertResponseDTOMono)
+ .assertNext(productAlertResponseDTOs -> {
+ assertThat(productAlertResponseDTOs.get(0).getMessageId()).isEqualTo(messages[0].getMessageId());
+ })
+ .verifyComplete();
+ }
+
+ @Test
+ public void getSingleApplicableMessage_cloudInstance_success() {
+ ProductAlertServiceCE productAlertServiceCE = new ProductAlertServiceCEImpl(messageListJSONString, mapper, commonConfig);
+ Mockito.when(commonConfig.isCloudHosting()).thenReturn(true);
+ Mono> productAlertResponseDTOMono = productAlertServiceCE.getSingleApplicableMessage();
+ StepVerifier
+ .create(productAlertResponseDTOMono)
+ .assertNext(productAlertResponseDTOs -> {
+ assertThat(productAlertResponseDTOs.get(0).getMessageId()).isEqualTo(messages[1].getMessageId());
+ })
+ .verifyComplete();
+ }
+
+ @Test
+ public void getSingleApplicableMessage_malformedExpression() throws NoSuchFieldException, IllegalAccessException {
+ ProductAlertServiceCE productAlertServiceCE = new ProductAlertServiceCEImpl(messageListJSONString, mapper, commonConfig);
+ Field messageField = productAlertServiceCE.getClass().getDeclaredField("messages");
+ messageField.setAccessible(true);
+ messages[0].setApplicabilityExpression("invalidExpression");
+ messageField.set(productAlertServiceCE, messages);
+ Mockito.when(commonConfig.isCloudHosting()).thenReturn(true);
+ Mono> productAlertResponseDTOMono = productAlertServiceCE.getSingleApplicableMessage();
+ StepVerifier
+ .create(productAlertResponseDTOMono)
+ .expectError(AppsmithException.class);
+ }
+}
diff --git a/app/server/appsmith-server/src/test/resources/productAlerts/productAlertMessages.yml b/app/server/appsmith-server/src/test/resources/productAlerts/productAlertMessages.yml
new file mode 100644
index 0000000000..b5161c8abd
--- /dev/null
+++ b/app/server/appsmith-server/src/test/resources/productAlerts/productAlertMessages.yml
@@ -0,0 +1,22 @@
+productalertmessages: [\
+ {\
+ "messageId": "1",\
+ "title": "alert1",\
+ "learnMoreLink": "learnmorelink1",\
+ "canDismiss": true,\
+ "remindLaterDays": 5,\
+ "context": "COMMON_CONFIG",\
+ "applicabilityExpression": "!isCloudHosting",\
+ "precedenceIndex": 1\
+ }, \
+ {\
+ "messageId": "2",\
+ "title": "alert2",\
+ "learnMoreLink": "learnmorelink2",\
+ "canDismiss": true,\
+ "remindLaterDays": 5,\
+ "context": "STATIC",\
+ "applicabilityExpression": "true",\
+ "precedenceIndex": 1\
+ }\
+]
\ No newline at end of file