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:
parent
3d566f4414
commit
8342d15b03
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
28
app/client/src/constants/ProductUpdate.ts
Normal file
28
app/client/src/constants/ProductUpdate.ts
Normal 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;
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.appsmith.server.dtos.ce;
|
||||
|
||||
public enum ProductAlertMessageApplicabilityContext {
|
||||
COMMON_CONFIG,
|
||||
STATIC;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
package com.appsmith.server.services;
|
||||
|
||||
import com.appsmith.server.services.ce.ProductAlertServiceCE;
|
||||
|
||||
public interface ProductAlertService extends ProductAlertServiceCE {
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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\
|
||||
}\
|
||||
]
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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\
|
||||
}\
|
||||
]
|
||||
Loading…
Reference in New Issue
Block a user