feat: added api to return 1 product message (#24704)

## Description
> Need an api to vend out messages for users alerting them of breaking
changes in upcoming releases.
#### PR fixes following issue(s)
Fixes #23064
#### Type of change
- New feature (non-breaking change which adds functionality)

## Testing
>
#### How Has This Been Tested?
- [x] Manual
- [ ] Jest
- [ ] Cypress
>
>
#### Test Plan
> This should be tested using curl by hitting the api endpoint endpoint
without any context and get a message in return that was configured in a
config file.
## Checklist:
#### Dev activity
- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my own code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] PR is being merged under a feature flag


#### QA activity:
- [ ] [Speedbreak
features](https://github.com/appsmithorg/TestSmith/wiki/Guidelines-for-test-plans#speedbreakers-)
have been covered
- [ ] Test plan covers all impacted features and [areas of
interest](https://github.com/appsmithorg/TestSmith/wiki/Guidelines-for-test-plans#areas-of-interest-)
- [ ] Test plan has been peer reviewed by project stakeholders and other
QA members
- [ ] Manually tested functionality on DP
- [ ] We had an implementation alignment call with stakeholders post QA
Round 2
- [ ] Cypress test cases have been added and approved by SDET/manual QA
- [ ] Added `Test Plan Approved` label after Cypress tests were reviewed
- [ ] Added `Test Plan Approved` label after JUnit tests were reviewed

---------

Co-authored-by: Hetu Nandu <hetunandu@gmail.com>
Co-authored-by: Hetu Nandu <hetu@appsmith.com>
This commit is contained in:
tkAppsmith 2023-07-18 13:03:18 +05:30 committed by GitHub
parent 3d566f4414
commit 8342d15b03
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 700 additions and 8 deletions

View File

@ -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", () => {

View File

@ -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,
});

View File

@ -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: {
<ErrorPage code={props.safeCrashCode} />
</>
) : (
<Walkthrough>
<AppHeader />
<Routes />
</Walkthrough>
<>
<Walkthrough>
<AppHeader />
<Routes />
</Walkthrough>
<ProductAlertBanner />
</>
)}
</Suspense>
</Router>
@ -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);

View File

@ -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<ApiResponse> {
return Api.post(UserApi.sendTestEmailURL, payload);
}
static getProductAlert(): AxiosPromise<ApiResponse<ProductAlert>> {
return Api.get(UserApi.productAlertURL);
}
}
export default UserApi;

View File

@ -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 = {

View File

@ -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";

View File

@ -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<CreateUserRequest>,
@ -565,3 +571,55 @@ export function* leaveWorkspaceSaga(
// do nothing as it's already handled globally
}
}
export function* fetchProductAlertSaga() {
try {
const response: ApiResponse<ProductAlert> = 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<string, ProductAlertConfig> =
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<string, ProductAlertConfig> =
JSON.parse(storedConfig);
const updatedConfig: Record<string, ProductAlertConfig> = {
...alertConfig,
[id]: config,
};
localStorage.setItem(
PRODUCT_ALERT_CONFIG_STORAGE_KEY,
JSON.stringify(updatedConfig),
);
};

View File

@ -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 (
<AlertContainer>
<AnimationContainer>
<Callout
isClosable={message.remindLaterDays > 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);
}}
>
<Text kind={"heading-s"}>{message.title}</Text>
<br />
<span>{message.message}</span>
</Callout>
</AnimationContainer>
</AlertContainer>
);
};
export default ProductAlertBanner;

View File

@ -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;

View File

@ -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,

View File

@ -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<ProductAlert>,
) => ({
...state,
productAlert: action.payload,
}),
[ReduxActionTypes.UPDATE_PRODUCT_ALERT_CONFIG]: (
state: UsersReduxState,
action: ReduxAction<ProductAlertConfig>,
): 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;

View File

@ -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;

View File

@ -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(() => {

View File

@ -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";

View File

@ -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);
}
}

View File

@ -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<ResponseDTO<ProductAlertResponseDTO>> 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);
}
});
}
}

View File

@ -0,0 +1,6 @@
package com.appsmith.server.dtos.ce;
public enum ProductAlertMessageApplicabilityContext {
COMMON_CONFIG,
STATIC;
}

View File

@ -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<ProductAlertResponseDTO> {
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;
}
}
}

View File

@ -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;

View File

@ -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"),

View File

@ -0,0 +1,6 @@
package com.appsmith.server.services;
import com.appsmith.server.services.ce.ProductAlertServiceCE;
public interface ProductAlertService extends ProductAlertServiceCE {
}

View File

@ -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);
}
}

View File

@ -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<List<ProductAlertResponseDTO>> getSingleApplicableMessage();
}

View File

@ -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<List<ProductAlertResponseDTO>> getSingleApplicableMessage() {
return Mono.fromCallable(() -> {
List<ProductAlertResponseDTO> 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;
}
}
}

View File

@ -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\
}\
]

View File

@ -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<java.util.List<ProductAlertResponseDTO>> 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<List<ProductAlertResponseDTO>> 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<List<ProductAlertResponseDTO>> productAlertResponseDTOMono = productAlertServiceCE.getSingleApplicableMessage();
StepVerifier
.create(productAlertResponseDTOMono)
.expectError(AppsmithException.class);
}
}

View File

@ -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\
}\
]