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