From 2ea0ad032feb80641352bfef8b94c47e6893a826 Mon Sep 17 00:00:00 2001 From: Stacey Levine Date: Thu, 11 Dec 2025 19:06:21 -0500 Subject: [PATCH 01/26] Working on favorites application and virtual workspace --- .../src/ce/actions/applicationActions.ts | 34 +++++++ app/client/src/ce/api/ApplicationApi.tsx | 10 ++ .../src/ce/constants/ReduxActionConstants.tsx | 6 ++ .../src/ce/constants/workspaceConstants.ts | 1 + .../src/ce/pages/Applications/index.tsx | 34 +++++++ .../uiReducers/applicationsReducer.tsx | 40 ++++++++ .../uiReducers/selectedWorkspaceReducer.ts | 37 +++++++ app/client/src/ce/sagas/WorkspaceSagas.ts | 21 ++++ app/client/src/ce/sagas/index.tsx | 2 + .../selectors/selectedWorkspaceSelectors.ts | 11 ++- app/client/src/components/common/Card.tsx | 44 ++++++++- app/client/src/ee/sagas/FavoritesSagas.ts | 98 +++++++++++++++++++ .../src/ee/selectors/favoriteSelectors.ts | 30 ++++++ app/client/src/entities/Application/types.ts | 1 + .../pages/Applications/ApplicationCard.tsx | 11 +++ .../controllers/ce/UserControllerCE.java | 26 +++++ .../com/appsmith/server/domains/UserData.java | 4 + .../ce/CustomUserDataRepositoryCE.java | 2 + .../ce/CustomUserDataRepositoryCEImpl.java | 8 ++ .../server/services/ce/UserDataServiceCE.java | 8 ++ .../services/ce/UserDataServiceCEImpl.java | 84 ++++++++++++++++ 21 files changed, 508 insertions(+), 4 deletions(-) create mode 100644 app/client/src/ee/sagas/FavoritesSagas.ts create mode 100644 app/client/src/ee/selectors/favoriteSelectors.ts diff --git a/app/client/src/ce/actions/applicationActions.ts b/app/client/src/ce/actions/applicationActions.ts index f8d3908688b5..b08362514c6f 100644 --- a/app/client/src/ce/actions/applicationActions.ts +++ b/app/client/src/ce/actions/applicationActions.ts @@ -12,6 +12,7 @@ import { import type { NavigationSetting, ThemeSetting } from "constants/AppConstants"; import type { IconNames } from "@appsmith/ads"; import type { Datasource } from "entities/Datasource"; +import type { ApplicationPayload } from "entities/Application"; export enum ApplicationVersion { DEFAULT = 1, @@ -352,3 +353,36 @@ export const resetAppSlugValidation = () => { type: ReduxActionTypes.RESET_APP_SLUG_VALIDATION, }; }; + +export const toggleFavoriteApplication = (applicationId: string) => ({ + type: ReduxActionTypes.TOGGLE_FAVORITE_APPLICATION_INIT, + payload: { applicationId }, +}); + +export const toggleFavoriteApplicationSuccess = ( + applicationId: string, + isFavorited: boolean, +) => ({ + type: ReduxActionTypes.TOGGLE_FAVORITE_APPLICATION_SUCCESS, + payload: { applicationId, isFavorited }, +}); + +export const toggleFavoriteApplicationError = (applicationId: string) => ({ + type: ReduxActionErrorTypes.TOGGLE_FAVORITE_APPLICATION_ERROR, + payload: { applicationId }, +}); + +export const fetchFavoriteApplications = () => ({ + type: ReduxActionTypes.FETCH_FAVORITE_APPLICATIONS_INIT, +}); + +export const fetchFavoriteApplicationsSuccess = ( + applications: ApplicationPayload[], +) => ({ + type: ReduxActionTypes.FETCH_FAVORITE_APPLICATIONS_SUCCESS, + payload: applications, +}); + +export const fetchFavoriteApplicationsError = () => ({ + type: ReduxActionErrorTypes.FETCH_FAVORITE_APPLICATIONS_ERROR, +}); diff --git a/app/client/src/ce/api/ApplicationApi.tsx b/app/client/src/ce/api/ApplicationApi.tsx index 56c1f2f5a53b..ce9dc75c31f2 100644 --- a/app/client/src/ce/api/ApplicationApi.tsx +++ b/app/client/src/ce/api/ApplicationApi.tsx @@ -564,6 +564,16 @@ export class ApplicationApi extends Api { `${ApplicationApi.baseURL}/${applicationId}/static-url/suggest-app-slug`, ); } + + static async toggleFavoriteApplication( + applicationId: string, + ): Promise> { + return Api.put(`v1/users/applications/${applicationId}/favorite`); + } + + static async getFavoriteApplications(): Promise> { + return Api.get("v1/users/favoriteApplications"); + } } export default ApplicationApi; diff --git a/app/client/src/ce/constants/ReduxActionConstants.tsx b/app/client/src/ce/constants/ReduxActionConstants.tsx index c28f390a9106..731dfdc6fa73 100644 --- a/app/client/src/ce/constants/ReduxActionConstants.tsx +++ b/app/client/src/ce/constants/ReduxActionConstants.tsx @@ -679,6 +679,10 @@ const ApplicationActionTypes = { FORK_APPLICATION_INIT: "FORK_APPLICATION_INIT", FORK_APPLICATION_SUCCESS: "FORK_APPLICATION_SUCCESS", RESET_CURRENT_APPLICATION: "RESET_CURRENT_APPLICATION", + TOGGLE_FAVORITE_APPLICATION_INIT: "TOGGLE_FAVORITE_APPLICATION_INIT", + TOGGLE_FAVORITE_APPLICATION_SUCCESS: "TOGGLE_FAVORITE_APPLICATION_SUCCESS", + FETCH_FAVORITE_APPLICATIONS_INIT: "FETCH_FAVORITE_APPLICATIONS_INIT", + FETCH_FAVORITE_APPLICATIONS_SUCCESS: "FETCH_FAVORITE_APPLICATIONS_SUCCESS", }; const ApplicationActionErrorTypes = { @@ -692,6 +696,8 @@ const ApplicationActionErrorTypes = { FETCH_APP_SLUG_SUGGESTION_ERROR: "FETCH_APP_SLUG_SUGGESTION_ERROR", ENABLE_STATIC_URL_ERROR: "ENABLE_STATIC_URL_ERROR", DISABLE_STATIC_URL_ERROR: "DISABLE_STATIC_URL_ERROR", + TOGGLE_FAVORITE_APPLICATION_ERROR: "TOGGLE_FAVORITE_APPLICATION_ERROR", + FETCH_FAVORITE_APPLICATIONS_ERROR: "FETCH_FAVORITE_APPLICATIONS_ERROR", }; const IDEDebuggerActionTypes = { diff --git a/app/client/src/ce/constants/workspaceConstants.ts b/app/client/src/ce/constants/workspaceConstants.ts index e7e8321ee11c..53e1dfa5967a 100644 --- a/app/client/src/ce/constants/workspaceConstants.ts +++ b/app/client/src/ce/constants/workspaceConstants.ts @@ -13,6 +13,7 @@ export interface Workspace { logoUrl?: string; uploadProgress?: number; userPermissions?: string[]; + isVirtual?: boolean; } export interface WorkspaceUserRoles { diff --git a/app/client/src/ce/pages/Applications/index.tsx b/app/client/src/ce/pages/Applications/index.tsx index c418f4e16984..e50f397ca02a 100644 --- a/app/client/src/ce/pages/Applications/index.tsx +++ b/app/client/src/ce/pages/Applications/index.tsx @@ -35,10 +35,12 @@ import { getIsCreatingApplication, getIsDeletingApplication, } from "ee/selectors/applicationSelectors"; +import { getHasFavorites } from "ee/selectors/favoriteSelectors"; import { Classes as BlueprintClasses } from "@blueprintjs/core"; import { Position } from "@blueprintjs/core/lib/esm/common/position"; import { leaveWorkspace } from "actions/userActions"; import NoSearchImage from "assets/images/NoSearchResult.svg"; +import HeartIconRed from "assets/icons/ads/heart-fill-red.svg"; import CenteredWrapper from "components/designSystems/appsmith/CenteredWrapper"; import { thinScrollbar, @@ -475,6 +477,7 @@ export function WorkspaceMenuItem({ if (!workspace.id) return null; + const isFavoritesWorkspace = workspace.id === "__favorites__"; const hasLogo = workspace?.logoUrl && !imageError; const displayText = isFetchingWorkspaces ? workspace?.name @@ -482,6 +485,24 @@ export function WorkspaceMenuItem({ ? workspace.name.slice(0, 22).concat(" ...") : workspace?.name; + // Use custom component for favorites workspace with heart icon + if (isFavoritesWorkspace && !isFetchingWorkspaces) { + return ( + + + + + {displayText} + + + + ); + } + // Use custom component when there's a logo, otherwise use ListItem if (hasLogo && !isFetchingWorkspaces) { const showTooltip = workspace?.name && workspace.name.length > 22; @@ -1120,6 +1141,7 @@ export const ApplictionsMainPage = (props: any) => { const isFetchingOrganizations = useSelector(getIsFetchingMyOrganizations); const currentOrganizationId = useSelector(activeOrganizationId); const isCloudBillingEnabled = useIsCloudBillingEnabled(); + const hasFavorites = useSelector(getHasFavorites); // TODO: Fix this the next time the file is edited // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -1135,6 +1157,18 @@ export const ApplictionsMainPage = (props: any) => { ) as any; } + // Inject virtual Favorites workspace at the top if user has favorites + if (hasFavorites && !isFetchingWorkspaces) { + const favoritesWorkspace = { + id: "__favorites__", + name: "Favorites", + isVirtual: true, + userPermissions: [], + }; + + workspaces = [favoritesWorkspace, ...workspaces]; + } + const [activeWorkspaceId, setActiveWorkspaceId] = useState< string | undefined >( diff --git a/app/client/src/ce/reducers/uiReducers/applicationsReducer.tsx b/app/client/src/ce/reducers/uiReducers/applicationsReducer.tsx index ee34d42f0725..32e1a5c618fc 100644 --- a/app/client/src/ce/reducers/uiReducers/applicationsReducer.tsx +++ b/app/client/src/ce/reducers/uiReducers/applicationsReducer.tsx @@ -35,6 +35,8 @@ export const initialState: ApplicationsReduxState = { creatingApplication: {}, deletingApplication: false, forkingApplication: false, + favoriteApplicationIds: [], + isFetchingFavorites: false, importingApplication: false, importedApplication: null, isImportAppModalOpen: false, @@ -881,6 +883,42 @@ export const handlers = { isPersistingAppSlug: false, }; }, + [ReduxActionTypes.TOGGLE_FAVORITE_APPLICATION_SUCCESS]: ( + state: ApplicationsReduxState, + action: ReduxAction<{ applicationId: string; isFavorited: boolean }>, + ) => { + const { applicationId, isFavorited } = action.payload; + + return { + ...state, + favoriteApplicationIds: isFavorited + ? [...state.favoriteApplicationIds, applicationId] + : state.favoriteApplicationIds.filter((id) => id !== applicationId), + applicationList: state.applicationList.map((app) => + app.id === applicationId ? { ...app, isFavorited } : app, + ), + }; + }, + [ReduxActionTypes.FETCH_FAVORITE_APPLICATIONS_INIT]: ( + state: ApplicationsReduxState, + ) => ({ + ...state, + isFetchingFavorites: true, + }), + [ReduxActionTypes.FETCH_FAVORITE_APPLICATIONS_SUCCESS]: ( + state: ApplicationsReduxState, + action: ReduxAction, + ) => ({ + ...state, + isFetchingFavorites: false, + favoriteApplicationIds: action.payload.map((app) => app.id), + }), + [ReduxActionErrorTypes.FETCH_FAVORITE_APPLICATIONS_ERROR]: ( + state: ApplicationsReduxState, + ) => ({ + ...state, + isFetchingFavorites: false, + }), }; const applicationsReducer = createReducer(initialState, handlers); @@ -898,6 +936,8 @@ export interface ApplicationsReduxState { createApplicationError?: string; deletingApplication: boolean; forkingApplication: boolean; + favoriteApplicationIds: string[]; + isFetchingFavorites: boolean; currentApplication?: ApplicationPayload; importingApplication: boolean; importedApplication: unknown; diff --git a/app/client/src/ce/reducers/uiReducers/selectedWorkspaceReducer.ts b/app/client/src/ce/reducers/uiReducers/selectedWorkspaceReducer.ts index 0d6aca3bcb3e..7e0e2092e850 100644 --- a/app/client/src/ce/reducers/uiReducers/selectedWorkspaceReducer.ts +++ b/app/client/src/ce/reducers/uiReducers/selectedWorkspaceReducer.ts @@ -59,6 +59,24 @@ export const handlers = { ) => { draftState.loadingStates.isFetchingApplications = false; }, + // Handle favorites workspace - populate applications with favorite apps + [ReduxActionTypes.FETCH_FAVORITE_APPLICATIONS_INIT]: ( + draftState: SelectedWorkspaceReduxState, + ) => { + draftState.loadingStates.isFetchingApplications = true; + }, + [ReduxActionTypes.FETCH_FAVORITE_APPLICATIONS_SUCCESS]: ( + draftState: SelectedWorkspaceReduxState, + action: ReduxAction, + ) => { + draftState.loadingStates.isFetchingApplications = false; + draftState.applications = action.payload; + }, + [ReduxActionErrorTypes.FETCH_FAVORITE_APPLICATIONS_ERROR]: ( + draftState: SelectedWorkspaceReduxState, + ) => { + draftState.loadingStates.isFetchingApplications = false; + }, [ReduxActionTypes.DELETE_APPLICATION_SUCCESS]: ( draftState: SelectedWorkspaceReduxState, action: ReduxAction, @@ -242,6 +260,25 @@ export const handlers = { ) => { draftState.loadingStates.isFetchingCurrentWorkspace = false; }, + [ReduxActionTypes.TOGGLE_FAVORITE_APPLICATION_SUCCESS]: ( + draftState: SelectedWorkspaceReduxState, + action: ReduxAction<{ applicationId: string; isFavorited: boolean }>, + ) => { + const { applicationId, isFavorited } = action.payload; + const isFavoritesWorkspace = draftState.workspace.id === "__favorites__"; + + if (isFavoritesWorkspace && !isFavorited) { + // If we're in the favorites workspace and the app is unfavorited, remove it from the list + draftState.applications = draftState.applications.filter( + (app) => app.id !== applicationId, + ); + } else { + // Otherwise, just update the isFavorited status + draftState.applications = draftState.applications.map((app) => + app.id === applicationId ? { ...app, isFavorited } : app, + ); + } + }, }; const selectedWorkspaceReducer = createImmerReducer(initialState, handlers); diff --git a/app/client/src/ce/sagas/WorkspaceSagas.ts b/app/client/src/ce/sagas/WorkspaceSagas.ts index ffc0da5df0e6..b3dbad51f0be 100644 --- a/app/client/src/ce/sagas/WorkspaceSagas.ts +++ b/app/client/src/ce/sagas/WorkspaceSagas.ts @@ -62,6 +62,10 @@ export function* fetchAllWorkspacesSaga( payload: workspaces, }); + // Fetch user's favorite applications to populate favoriteApplicationIds + // This ensures favorites are shown correctly across all workspaces + yield put({ type: ReduxActionTypes.FETCH_FAVORITE_APPLICATIONS_INIT }); + if (action?.payload?.workspaceId || action?.payload?.fetchEntities) { yield call(fetchEntitiesOfWorkspaceSaga, action); } @@ -82,6 +86,23 @@ export function* fetchEntitiesOfWorkspaceSaga( try { const allWorkspaces: Workspace[] = yield select(getFetchedWorkspaces); const workspaceId = action?.payload?.workspaceId || allWorkspaces[0]?.id; + + // Handle virtual favorites workspace specially + if (workspaceId === "__favorites__") { + yield put({ + type: ReduxActionTypes.SET_CURRENT_WORKSPACE, + payload: { + id: "__favorites__", + name: "Favorites", + isVirtual: true, + userPermissions: [], + }, + }); + yield put({ type: ReduxActionTypes.FETCH_FAVORITE_APPLICATIONS_INIT }); + + return; + } + const activeWorkspace = allWorkspaces.find( (workspace) => workspace.id === workspaceId, ); diff --git a/app/client/src/ce/sagas/index.tsx b/app/client/src/ce/sagas/index.tsx index 985260fb14a9..e5756e99c769 100644 --- a/app/client/src/ce/sagas/index.tsx +++ b/app/client/src/ce/sagas/index.tsx @@ -4,6 +4,7 @@ import SuperUserSagas from "ee/sagas/SuperUserSagas"; import organizationSagas from "ee/sagas/organizationSagas"; import userSagas from "ee/sagas/userSagas"; import workspaceSagas from "ee/sagas/WorkspaceSagas"; +import favoritesSagasListener from "ee/sagas/FavoritesSagas"; import { watchPluginActionExecutionSagas } from "sagas/ActionExecution/PluginActionSaga"; import { watchActionSagas } from "sagas/ActionSagas"; import apiPaneSagas from "sagas/ApiPaneSagas"; @@ -115,4 +116,5 @@ export const sagas = [ gitSagas, gitApplicationSagas, PostEvaluationSagas, + favoritesSagasListener, ]; diff --git a/app/client/src/ce/selectors/selectedWorkspaceSelectors.ts b/app/client/src/ce/selectors/selectedWorkspaceSelectors.ts index 34c4f8f1469c..abff67666a55 100644 --- a/app/client/src/ce/selectors/selectedWorkspaceSelectors.ts +++ b/app/client/src/ce/selectors/selectedWorkspaceSelectors.ts @@ -4,7 +4,16 @@ export const getIsFetchingApplications = (state: DefaultRootState): boolean => state.ui.selectedWorkspace.loadingStates.isFetchingApplications; export const getApplicationsOfWorkspace = (state: DefaultRootState) => { - return state.ui.selectedWorkspace.applications; + const applications = state.ui.selectedWorkspace.applications; + const favoriteApplicationIds = + state.ui.applications.favoriteApplicationIds || []; + + // Compute isFavorited for each application based on favoriteApplicationIds + // This ensures favorites persist when switching between workspaces + return applications.map((app) => ({ + ...app, + isFavorited: favoriteApplicationIds.includes(app.id), + })); }; export const getAllUsersOfWorkspace = (state: DefaultRootState) => diff --git a/app/client/src/components/common/Card.tsx b/app/client/src/components/common/Card.tsx index c3fa28779727..4e6b5041ff47 100644 --- a/app/client/src/components/common/Card.tsx +++ b/app/client/src/components/common/Card.tsx @@ -5,7 +5,7 @@ import { omit } from "lodash"; import { AppIcon, Size, TextType, Text } from "@appsmith/ads-old"; import type { PropsWithChildren } from "react"; import type { HTMLDivProps, ICardProps } from "@blueprintjs/core"; -import { Button, type MenuItemProps } from "@appsmith/ads"; +import { Button, Icon, type MenuItemProps } from "@appsmith/ads"; import GitConnectedBadge from "./GitConnectedBadge"; import { GitCardBadge } from "git"; @@ -32,6 +32,8 @@ type CardProps = PropsWithChildren<{ titleTestId: string; isSelected?: boolean; hasEditPermission?: boolean; + isFavorited?: boolean; + onToggleFavorite?: (e: React.MouseEvent) => void; }>; interface NameWrapperProps { @@ -105,6 +107,27 @@ const CircleAppIcon = styled(AppIcon)` } `; +const FavoriteIconWrapper = styled.div` + position: absolute; + top: 8px; + left: 8px; + z-index: 2; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + background-color: rgba(255, 255, 255, 0.9); + border-radius: 50%; + transition: all 0.2s ease; + + &:hover { + background-color: rgba(255, 255, 255, 1); + transform: scale(1.1); + } +`; + const NameWrapper = styled((props: HTMLDivProps & NameWrapperProps) => (
+ {onToggleFavorite && ( + { + e.stopPropagation(); + onToggleFavorite(e); + }} + > + + + )} {/*@ts-expect-error fix this the next time the file is edited*/} {showOverlay && !isMobile && ( -
+
- {children} + + {children} +
)} diff --git a/app/client/src/ee/sagas/FavoritesSagas.ts b/app/client/src/ee/sagas/FavoritesSagas.ts new file mode 100644 index 000000000000..b01a6986dfb3 --- /dev/null +++ b/app/client/src/ee/sagas/FavoritesSagas.ts @@ -0,0 +1,98 @@ +import { call, put, select, takeLatest } from "redux-saga/effects"; +import type { ReduxAction } from "actions/ReduxActionTypes"; +import { ReduxActionTypes } from "ee/constants/ReduxActionConstants"; +import ApplicationApi from "ee/api/ApplicationApi"; +import { + toggleFavoriteApplicationSuccess, + toggleFavoriteApplicationError, + fetchFavoriteApplicationsSuccess, +} from "ce/actions/applicationActions"; +import { validateResponse } from "sagas/ErrorSagas"; +import { toast } from "@appsmith/ads"; +import { findDefaultPage } from "pages/utils"; + +function* toggleFavoriteApplicationSaga( + action: ReduxAction<{ applicationId: string }>, +) { + const { applicationId } = action.payload; + + try { + // Optimistic update - get current state + const currentFavoriteIds: string[] = yield select( + (state) => state.ui.applications.favoriteApplicationIds, + ); + const isFavorited = currentFavoriteIds.includes(applicationId); + const newIsFavorited = !isFavorited; + + // Immediate UI update (optimistic) + yield put(toggleFavoriteApplicationSuccess(applicationId, newIsFavorited)); + + // API call + const response: unknown = yield call( + ApplicationApi.toggleFavoriteApplication, + applicationId, + ); + const isValidResponse: boolean = yield validateResponse(response); + + if (!isValidResponse) { + // Rollback on error + yield put(toggleFavoriteApplicationSuccess(applicationId, isFavorited)); + yield put(toggleFavoriteApplicationError(applicationId)); + } + } catch (error) { + // Rollback on error + const currentFavoriteIds: string[] = yield select( + (state) => state.ui.applications.favoriteApplicationIds, + ); + const isFavorited = currentFavoriteIds.includes(applicationId); + + yield put(toggleFavoriteApplicationSuccess(applicationId, isFavorited)); + yield put(toggleFavoriteApplicationError(applicationId)); + + toast.show("Failed to update favorite status", { kind: "error" }); + } +} + +function* fetchFavoriteApplicationsSaga() { + try { + const response: unknown = yield call( + ApplicationApi.getFavoriteApplications, + ); + const isValidResponse: boolean = yield validateResponse(response); + + if (isValidResponse) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rawApplications = (response as any).data; + + // Transform applications to include defaultBasePageId (needed for Launch button) + // This matches the transformation done in ApplicationSagas.tsx + const applications = rawApplications.map( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (application: any) => { + const defaultPage = findDefaultPage(application.pages); + + return { + ...application, + defaultPageId: defaultPage?.id, + defaultBasePageId: defaultPage?.baseId, + }; + }, + ); + + yield put(fetchFavoriteApplicationsSuccess(applications)); + } + } catch (error) { + // Silent fail - favorites are not critical + } +} + +export default function* favoritesSagasListener() { + yield takeLatest( + ReduxActionTypes.TOGGLE_FAVORITE_APPLICATION_INIT, + toggleFavoriteApplicationSaga, + ); + yield takeLatest( + ReduxActionTypes.FETCH_FAVORITE_APPLICATIONS_INIT, + fetchFavoriteApplicationsSaga, + ); +} diff --git a/app/client/src/ee/selectors/favoriteSelectors.ts b/app/client/src/ee/selectors/favoriteSelectors.ts new file mode 100644 index 000000000000..2bdbd10fc7e3 --- /dev/null +++ b/app/client/src/ee/selectors/favoriteSelectors.ts @@ -0,0 +1,30 @@ +import { createSelector } from "reselect"; +import type { DefaultRootState } from "react-redux"; +import type { ApplicationPayload } from "entities/Application"; + +export const getFavoriteApplicationIds = (state: DefaultRootState) => + state.ui.applications.favoriteApplicationIds; + +export const getFavoriteApplications = createSelector( + [ + (state: DefaultRootState) => state.ui.applications.applicationList, + getFavoriteApplicationIds, + ], + (allApps: ApplicationPayload[], favoriteIds: string[]) => { + const favoriteApps = allApps + .filter((app: ApplicationPayload) => favoriteIds.includes(app.id)) + .sort((a: ApplicationPayload, b: ApplicationPayload) => + a.name.localeCompare(b.name), + ); // Alphabetical sort + + return favoriteApps; + }, +); + +export const getHasFavorites = createSelector( + [getFavoriteApplicationIds], + (favoriteIds: string[]) => favoriteIds.length > 0, +); + +export const getIsFetchingFavorites = (state: DefaultRootState) => + state.ui.applications.isFetchingFavorites; diff --git a/app/client/src/entities/Application/types.ts b/app/client/src/entities/Application/types.ts index d88e1029e026..d9460656c9db 100644 --- a/app/client/src/entities/Application/types.ts +++ b/app/client/src/entities/Application/types.ts @@ -46,6 +46,7 @@ export interface ApplicationPayload { publishedAppToCommunityTemplate?: boolean; forkedFromTemplateTitle?: string; connectedWorkflowId?: string; + isFavorited?: boolean; staticUrlSettings?: { enabled: boolean; uniqueSlug: string; diff --git a/app/client/src/pages/Applications/ApplicationCard.tsx b/app/client/src/pages/Applications/ApplicationCard.tsx index 4e0bcd81df08..5391b9bd99c5 100644 --- a/app/client/src/pages/Applications/ApplicationCard.tsx +++ b/app/client/src/pages/Applications/ApplicationCard.tsx @@ -50,6 +50,7 @@ import history from "utils/history"; import urlBuilder from "ee/entities/URLRedirect/URLAssembly"; import { toast } from "@appsmith/ads"; import { getCurrentUser } from "actions/authActions"; +import { toggleFavoriteApplication } from "ee/actions/applicationActions"; import Card, { ContextMenuTrigger } from "components/common/Card"; import { generateEditedByText } from "./helpers"; import { noop } from "lodash"; @@ -521,6 +522,14 @@ export function ApplicationCard(props: ApplicationCardProps) { dispatch(getCurrentUser()); }, [setURLParams, viewModeURL, dispatch]); + const handleToggleFavorite = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + dispatch(toggleFavoriteApplication(application.id)); + }, + [application.id, dispatch], + ); + return ( > resendEmailVerification( public Mono verifyEmailVerificationToken(ServerWebExchange exchange) { return service.verifyEmailVerificationToken(exchange); } + + /** + * Toggle favorite status for an application + * @param applicationId Application ID to toggle favorite status for + * @return Updated user data with modified favorites list + */ + @JsonView(Views.Public.class) + @PutMapping("/applications/{applicationId}/favorite") + public Mono> toggleFavoriteApplication(@PathVariable String applicationId) { + return userDataService + .toggleFavoriteApplication(applicationId) + .map(userData -> new ResponseDTO<>(HttpStatus.OK, userData)); + } + + /** + * Get all favorite applications for the current user + * @return List of favorited applications + */ + @JsonView(Views.Public.class) + @GetMapping("/favoriteApplications") + public Mono>> getFavoriteApplications() { + return userDataService + .getFavoriteApplications() + .map(applications -> new ResponseDTO<>(HttpStatus.OK, applications)); + } } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/UserData.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/UserData.java index 6daca7445769..1ffc658cd479 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/UserData.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/UserData.java @@ -63,6 +63,10 @@ public class UserData extends BaseDomain { @JsonView(Views.Public.class) private List recentlyUsedEntityIds; + // List of application IDs favorited by the user + @JsonView(Views.Public.class) + private List favoriteApplicationIds; + // Map of defaultApplicationIds with the GitProfiles. For fallback/default git profile per user default will be the // the key for the map @JsonView(Views.Internal.class) diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/ce/CustomUserDataRepositoryCE.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/ce/CustomUserDataRepositoryCE.java index 7dea190b87f9..0520e4f8e02d 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/ce/CustomUserDataRepositoryCE.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/ce/CustomUserDataRepositoryCE.java @@ -11,4 +11,6 @@ public interface CustomUserDataRepositoryCE extends AppsmithRepository Mono removeEntitiesFromRecentlyUsedList(String userId, String workspaceId); Mono fetchMostRecentlyUsedWorkspaceId(String userId); + + Mono removeApplicationFromFavorites(String applicationId); } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/ce/CustomUserDataRepositoryCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/ce/CustomUserDataRepositoryCEImpl.java index 1feb33de0a6b..e505519e25ef 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/ce/CustomUserDataRepositoryCEImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/ce/CustomUserDataRepositoryCEImpl.java @@ -45,4 +45,12 @@ public Mono fetchMostRecentlyUsedWorkspaceId(String userId) { : recentlyUsedWorkspaceIds.get(0).getWorkspaceId(); }); } + + @Override + public Mono removeApplicationFromFavorites(String applicationId) { + // MongoDB update query to pull applicationId from all users' favoriteApplicationIds arrays + BridgeUpdate update = new BridgeUpdate(); + update.pull(UserData.Fields.favoriteApplicationIds, applicationId); + return queryBuilder().updateAll(update).then(); + } } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserDataServiceCE.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserDataServiceCE.java index 0acc7e55537c..a46770443b08 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserDataServiceCE.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserDataServiceCE.java @@ -1,6 +1,7 @@ package com.appsmith.server.services.ce; import com.appsmith.external.enums.WorkspaceResourceContext; +import com.appsmith.server.domains.Application; import com.appsmith.server.domains.GitProfile; import com.appsmith.server.domains.User; import com.appsmith.server.domains.UserData; @@ -9,6 +10,7 @@ import reactor.core.publisher.Mono; import java.util.Collection; +import java.util.List; import java.util.Map; public interface UserDataServiceCE { @@ -51,4 +53,10 @@ Mono updateLastUsedResourceAndWorkspaceList( Mono removeRecentWorkspaceAndChildEntities(String userId, String workspaceId); Mono getGitProfileForCurrentUser(String defaultApplicationId); + + Mono toggleFavoriteApplication(String applicationId); + + Mono> getFavoriteApplications(); + + Mono removeApplicationFromAllFavorites(String applicationId); } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserDataServiceCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserDataServiceCEImpl.java index ffc30386d891..348a68d739f9 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserDataServiceCEImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserDataServiceCEImpl.java @@ -2,6 +2,7 @@ import com.appsmith.external.enums.WorkspaceResourceContext; import com.appsmith.server.constants.FieldName; +import com.appsmith.server.domains.Application; import com.appsmith.server.domains.Asset; import com.appsmith.server.domains.GitProfile; import com.appsmith.server.domains.User; @@ -34,8 +35,11 @@ import java.util.ArrayList; import java.util.Collection; +import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; import static com.appsmith.server.constants.ce.FieldNameCE.DEFAULT; @@ -62,6 +66,8 @@ public class UserDataServiceCEImpl extends BaseService getGitProfileForCurrentUser(String defaultApplicationId) return authorProfile; }); } + + /** + * Toggle favorite status for an application + * @param applicationId Application ID to toggle + * @return Updated UserData with modified favorites list + */ + @Override + public Mono toggleFavoriteApplication(String applicationId) { + return sessionUserService.getCurrentUser().zipWhen(this::getForUser).flatMap(tuple -> { + User user = tuple.getT1(); + UserData userData = tuple.getT2(); + + List favorites = userData.getFavoriteApplicationIds(); + if (favorites == null) { + favorites = new ArrayList<>(); + } + + // Toggle: remove if exists, add if doesn't + if (favorites.contains(applicationId)) { + favorites.remove(applicationId); + } else { + favorites.add(applicationId); + } + + userData.setFavoriteApplicationIds(favorites); + return repository.save(userData); + }); + } + + /** + * Get all favorite applications for current user + * Filters out deleted applications and applications user no longer has access to + * @return List of favorite applications + */ + @Override + public Mono> getFavoriteApplications() { + return getForCurrentUser().flatMap(userData -> { + List favoriteIds = userData.getFavoriteApplicationIds(); + if (CollectionUtils.isNullOrEmpty(favoriteIds)) { + return Mono.just(Collections.emptyList()); + } + + // Fetch applications by IDs + return applicationRepository.findAllById(favoriteIds).collectList().flatMap(applications -> { + // Clean up favorites list if any apps were not found + if (applications.size() < favoriteIds.size()) { + return cleanupMissingFavorites(userData, applications).thenReturn(applications); + } + return Mono.just(applications); + }); + }); + } + + /** + * Remove missing/inaccessible applications from user's favorites + * @param userData User data containing favorites + * @param validApps List of valid applications that still exist + * @return Updated UserData + */ + private Mono cleanupMissingFavorites(UserData userData, List validApps) { + Set validIds = validApps.stream().map(Application::getId).collect(Collectors.toSet()); + + List cleanedFavorites = userData.getFavoriteApplicationIds().stream() + .filter(validIds::contains) + .collect(Collectors.toList()); + + userData.setFavoriteApplicationIds(cleanedFavorites); + return repository.save(userData); + } + + /** + * Remove application from all users' favorites when app is deleted + * @param applicationId ID of deleted application + */ + @Override + public Mono removeApplicationFromAllFavorites(String applicationId) { + return repository.removeApplicationFromFavorites(applicationId); + } } From 66253595c7f0f35f1f8d5f7ca06eaad414dd121b Mon Sep 17 00:00:00 2001 From: Stacey Levine Date: Thu, 11 Dec 2025 22:28:55 -0500 Subject: [PATCH 02/26] Adding favorites for applications Fixed FavitoristesSagas to put in the generic directory --- app/client/src/ce/sagas/index.tsx | 2 +- app/client/src/{ee => }/sagas/FavoritesSagas.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename app/client/src/{ee => }/sagas/FavoritesSagas.ts (98%) diff --git a/app/client/src/ce/sagas/index.tsx b/app/client/src/ce/sagas/index.tsx index e5756e99c769..3844fa1e985e 100644 --- a/app/client/src/ce/sagas/index.tsx +++ b/app/client/src/ce/sagas/index.tsx @@ -4,7 +4,7 @@ import SuperUserSagas from "ee/sagas/SuperUserSagas"; import organizationSagas from "ee/sagas/organizationSagas"; import userSagas from "ee/sagas/userSagas"; import workspaceSagas from "ee/sagas/WorkspaceSagas"; -import favoritesSagasListener from "ee/sagas/FavoritesSagas"; +import favoritesSagasListener from "sagas/FavoritesSagas"; import { watchPluginActionExecutionSagas } from "sagas/ActionExecution/PluginActionSaga"; import { watchActionSagas } from "sagas/ActionSagas"; import apiPaneSagas from "sagas/ApiPaneSagas"; diff --git a/app/client/src/ee/sagas/FavoritesSagas.ts b/app/client/src/sagas/FavoritesSagas.ts similarity index 98% rename from app/client/src/ee/sagas/FavoritesSagas.ts rename to app/client/src/sagas/FavoritesSagas.ts index b01a6986dfb3..444903d4d049 100644 --- a/app/client/src/ee/sagas/FavoritesSagas.ts +++ b/app/client/src/sagas/FavoritesSagas.ts @@ -6,7 +6,7 @@ import { toggleFavoriteApplicationSuccess, toggleFavoriteApplicationError, fetchFavoriteApplicationsSuccess, -} from "ce/actions/applicationActions"; +} from "ee/actions/applicationActions"; import { validateResponse } from "sagas/ErrorSagas"; import { toast } from "@appsmith/ads"; import { findDefaultPage } from "pages/utils"; From 91b3e9724a1a25b844480ba935b4e30224cea65b Mon Sep 17 00:00:00 2001 From: Stacey Levine Date: Sat, 13 Dec 2025 20:24:24 -0500 Subject: [PATCH 03/26] fix: remove unused ApplicationPayload import from ce/actions/applicationActions --- app/client/src/actions/applicationActions.ts | 38 +++++++++++++++++++ .../src/assets/icons/ads/heart-fill-red.svg | 4 ++ .../src/ce/actions/applicationActions.ts | 34 ----------------- .../src/ce/pages/Applications/index.tsx | 2 +- .../src/ee/actions/applicationActions.ts | 1 + .../pages/Applications/ApplicationCard.tsx | 2 +- app/client/src/sagas/FavoritesSagas.ts | 2 +- .../{ee => }/selectors/favoriteSelectors.ts | 0 8 files changed, 46 insertions(+), 37 deletions(-) create mode 100644 app/client/src/actions/applicationActions.ts create mode 100644 app/client/src/assets/icons/ads/heart-fill-red.svg rename app/client/src/{ee => }/selectors/favoriteSelectors.ts (100%) diff --git a/app/client/src/actions/applicationActions.ts b/app/client/src/actions/applicationActions.ts new file mode 100644 index 000000000000..4a09597db2d5 --- /dev/null +++ b/app/client/src/actions/applicationActions.ts @@ -0,0 +1,38 @@ +import { + ReduxActionErrorTypes, + ReduxActionTypes, +} from "ee/constants/ReduxActionConstants"; +import type { ApplicationPayload } from "entities/Application"; + +export const toggleFavoriteApplication = (applicationId: string) => ({ + type: ReduxActionTypes.TOGGLE_FAVORITE_APPLICATION_INIT, + payload: { applicationId }, +}); + +export const toggleFavoriteApplicationSuccess = ( + applicationId: string, + isFavorited: boolean, +) => ({ + type: ReduxActionTypes.TOGGLE_FAVORITE_APPLICATION_SUCCESS, + payload: { applicationId, isFavorited }, +}); + +export const toggleFavoriteApplicationError = (applicationId: string) => ({ + type: ReduxActionErrorTypes.TOGGLE_FAVORITE_APPLICATION_ERROR, + payload: { applicationId }, +}); + +export const fetchFavoriteApplications = () => ({ + type: ReduxActionTypes.FETCH_FAVORITE_APPLICATIONS_INIT, +}); + +export const fetchFavoriteApplicationsSuccess = ( + applications: ApplicationPayload[], +) => ({ + type: ReduxActionTypes.FETCH_FAVORITE_APPLICATIONS_SUCCESS, + payload: applications, +}); + +export const fetchFavoriteApplicationsError = () => ({ + type: ReduxActionErrorTypes.FETCH_FAVORITE_APPLICATIONS_ERROR, +}); diff --git a/app/client/src/assets/icons/ads/heart-fill-red.svg b/app/client/src/assets/icons/ads/heart-fill-red.svg new file mode 100644 index 000000000000..d6b0b69e8816 --- /dev/null +++ b/app/client/src/assets/icons/ads/heart-fill-red.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/client/src/ce/actions/applicationActions.ts b/app/client/src/ce/actions/applicationActions.ts index b08362514c6f..f8d3908688b5 100644 --- a/app/client/src/ce/actions/applicationActions.ts +++ b/app/client/src/ce/actions/applicationActions.ts @@ -12,7 +12,6 @@ import { import type { NavigationSetting, ThemeSetting } from "constants/AppConstants"; import type { IconNames } from "@appsmith/ads"; import type { Datasource } from "entities/Datasource"; -import type { ApplicationPayload } from "entities/Application"; export enum ApplicationVersion { DEFAULT = 1, @@ -353,36 +352,3 @@ export const resetAppSlugValidation = () => { type: ReduxActionTypes.RESET_APP_SLUG_VALIDATION, }; }; - -export const toggleFavoriteApplication = (applicationId: string) => ({ - type: ReduxActionTypes.TOGGLE_FAVORITE_APPLICATION_INIT, - payload: { applicationId }, -}); - -export const toggleFavoriteApplicationSuccess = ( - applicationId: string, - isFavorited: boolean, -) => ({ - type: ReduxActionTypes.TOGGLE_FAVORITE_APPLICATION_SUCCESS, - payload: { applicationId, isFavorited }, -}); - -export const toggleFavoriteApplicationError = (applicationId: string) => ({ - type: ReduxActionErrorTypes.TOGGLE_FAVORITE_APPLICATION_ERROR, - payload: { applicationId }, -}); - -export const fetchFavoriteApplications = () => ({ - type: ReduxActionTypes.FETCH_FAVORITE_APPLICATIONS_INIT, -}); - -export const fetchFavoriteApplicationsSuccess = ( - applications: ApplicationPayload[], -) => ({ - type: ReduxActionTypes.FETCH_FAVORITE_APPLICATIONS_SUCCESS, - payload: applications, -}); - -export const fetchFavoriteApplicationsError = () => ({ - type: ReduxActionErrorTypes.FETCH_FAVORITE_APPLICATIONS_ERROR, -}); diff --git a/app/client/src/ce/pages/Applications/index.tsx b/app/client/src/ce/pages/Applications/index.tsx index e50f397ca02a..0ad39bc9d502 100644 --- a/app/client/src/ce/pages/Applications/index.tsx +++ b/app/client/src/ce/pages/Applications/index.tsx @@ -35,7 +35,7 @@ import { getIsCreatingApplication, getIsDeletingApplication, } from "ee/selectors/applicationSelectors"; -import { getHasFavorites } from "ee/selectors/favoriteSelectors"; +import { getHasFavorites } from "selectors/favoriteSelectors"; import { Classes as BlueprintClasses } from "@blueprintjs/core"; import { Position } from "@blueprintjs/core/lib/esm/common/position"; import { leaveWorkspace } from "actions/userActions"; diff --git a/app/client/src/ee/actions/applicationActions.ts b/app/client/src/ee/actions/applicationActions.ts index 6a2e05124619..ca7e0b84d698 100644 --- a/app/client/src/ee/actions/applicationActions.ts +++ b/app/client/src/ee/actions/applicationActions.ts @@ -1 +1,2 @@ export * from "ce/actions/applicationActions"; +export * from "actions/applicationActions"; diff --git a/app/client/src/pages/Applications/ApplicationCard.tsx b/app/client/src/pages/Applications/ApplicationCard.tsx index 5391b9bd99c5..39770deaf792 100644 --- a/app/client/src/pages/Applications/ApplicationCard.tsx +++ b/app/client/src/pages/Applications/ApplicationCard.tsx @@ -50,7 +50,7 @@ import history from "utils/history"; import urlBuilder from "ee/entities/URLRedirect/URLAssembly"; import { toast } from "@appsmith/ads"; import { getCurrentUser } from "actions/authActions"; -import { toggleFavoriteApplication } from "ee/actions/applicationActions"; +import { toggleFavoriteApplication } from "actions/applicationActions"; import Card, { ContextMenuTrigger } from "components/common/Card"; import { generateEditedByText } from "./helpers"; import { noop } from "lodash"; diff --git a/app/client/src/sagas/FavoritesSagas.ts b/app/client/src/sagas/FavoritesSagas.ts index 444903d4d049..5697c4814c20 100644 --- a/app/client/src/sagas/FavoritesSagas.ts +++ b/app/client/src/sagas/FavoritesSagas.ts @@ -6,7 +6,7 @@ import { toggleFavoriteApplicationSuccess, toggleFavoriteApplicationError, fetchFavoriteApplicationsSuccess, -} from "ee/actions/applicationActions"; +} from "actions/applicationActions"; import { validateResponse } from "sagas/ErrorSagas"; import { toast } from "@appsmith/ads"; import { findDefaultPage } from "pages/utils"; diff --git a/app/client/src/ee/selectors/favoriteSelectors.ts b/app/client/src/selectors/favoriteSelectors.ts similarity index 100% rename from app/client/src/ee/selectors/favoriteSelectors.ts rename to app/client/src/selectors/favoriteSelectors.ts From 30942807add5a794035200a14d333859ac43d628 Mon Sep 17 00:00:00 2001 From: Stacey Levine Date: Sat, 13 Dec 2025 20:25:43 -0500 Subject: [PATCH 04/26] revert: remove export from ee/actions/applicationActions to avoid pre-push hook blocking The favorite actions are now in src/actions/applicationActions and are imported directly, so we don't need to re-export them from ee/actions/applicationActions.ts --- app/client/src/ee/actions/applicationActions.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/app/client/src/ee/actions/applicationActions.ts b/app/client/src/ee/actions/applicationActions.ts index ca7e0b84d698..6a2e05124619 100644 --- a/app/client/src/ee/actions/applicationActions.ts +++ b/app/client/src/ee/actions/applicationActions.ts @@ -1,2 +1 @@ export * from "ce/actions/applicationActions"; -export * from "actions/applicationActions"; From ad85f62951b23738f86c9b85133f8ff24408fc54 Mon Sep 17 00:00:00 2001 From: Stacey Levine Date: Sun, 14 Dec 2025 16:30:04 -0500 Subject: [PATCH 05/26] Fix favorites limit, correct home page bug, address permissions issue --- .../layouts/components/Header/index.tsx | 5 +- .../services/ce/UserDataServiceCEImpl.java | 46 +++++++++++++++---- 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/app/client/src/pages/AppIDE/layouts/components/Header/index.tsx b/app/client/src/pages/AppIDE/layouts/components/Header/index.tsx index 9b2c64ec1a24..349b98e5bcab 100644 --- a/app/client/src/pages/AppIDE/layouts/components/Header/index.tsx +++ b/app/client/src/pages/AppIDE/layouts/components/Header/index.tsx @@ -162,7 +162,10 @@ const Header = () => { {currentWorkspace.name && ( <> - + {currentWorkspace.name} {"/"} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserDataServiceCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserDataServiceCEImpl.java index 348a68d739f9..5934cad5053c 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserDataServiceCEImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserDataServiceCEImpl.java @@ -1,6 +1,7 @@ package com.appsmith.server.services.ce; import com.appsmith.external.enums.WorkspaceResourceContext; +import com.appsmith.server.acl.AclPermission; import com.appsmith.server.constants.FieldName; import com.appsmith.server.domains.Application; import com.appsmith.server.domains.Asset; @@ -23,6 +24,7 @@ import com.appsmith.server.services.FeatureFlagService; import com.appsmith.server.services.OrganizationService; import com.appsmith.server.services.SessionUserService; +import com.appsmith.server.solutions.ApplicationPermission; import com.appsmith.server.solutions.ReleaseNotesService; import jakarta.validation.Validator; import org.apache.commons.lang3.ObjectUtils; @@ -58,6 +60,8 @@ public class UserDataServiceCEImpl extends BaseService getGitProfileForCurrentUser(String defaultApplicationId) * Toggle favorite status for an application * @param applicationId Application ID to toggle * @return Updated UserData with modified favorites list + * @throws AppsmithException if the maximum favorite limit is reached when trying to add a favorite */ @Override public Mono toggleFavoriteApplication(String applicationId) { @@ -437,6 +450,14 @@ public Mono toggleFavoriteApplication(String applicationId) { if (favorites.contains(applicationId)) { favorites.remove(applicationId); } else { + // Check if adding this favorite would exceed the limit + if (favorites.size() >= MAX_FAVORITE_APPLICATIONS_LIMIT) { + return Mono.error(new AppsmithException( + AppsmithError.INVALID_PARAMETER, + String.format( + "Maximum favorite applications limit (%d) reached. Please remove some favorites before adding new ones.", + MAX_FAVORITE_APPLICATIONS_LIMIT))); + } favorites.add(applicationId); } @@ -458,14 +479,23 @@ public Mono> getFavoriteApplications() { return Mono.just(Collections.emptyList()); } - // Fetch applications by IDs - return applicationRepository.findAllById(favoriteIds).collectList().flatMap(applications -> { - // Clean up favorites list if any apps were not found - if (applications.size() < favoriteIds.size()) { - return cleanupMissingFavorites(userData, applications).thenReturn(applications); - } - return Mono.just(applications); - }); + // Fetch applications by IDs with READ permission to populate userPermissions + // This ensures permissions are properly set so the edit button shows when user has edit permission + AclPermission readPermission = applicationPermission.getReadPermission(); + return applicationRepository + .queryBuilder() + .criteria(Bridge.in(Application.Fields.id, favoriteIds)) + .permission(readPermission) + .all() + .collectList() + .flatMap(applications -> { + // Clean up favorites list if any apps were not found or are inaccessible + if (applications.size() < favoriteIds.size()) { + return cleanupMissingFavorites(userData, applications) + .thenReturn(applications); + } + return Mono.just(applications); + }); }); } From c8e7267d6a35139f0f355da81c07f7e27e0ac240 Mon Sep 17 00:00:00 2001 From: Stacey Levine Date: Mon, 15 Dec 2025 10:54:32 -0500 Subject: [PATCH 06/26] Fix found issues --- .../editorComponents/Debugger/index.tsx | 4 +++- .../hooks/useWidgetSelectionBlockListener.ts | 5 ++++- .../pages/Applications/ApplicationCard.tsx | 19 +++++++++++++++---- app/client/src/sagas/FavoritesSagas.ts | 17 +++++++++++++++++ .../server/services/UserDataServiceImpl.java | 3 +++ 5 files changed, 42 insertions(+), 6 deletions(-) diff --git a/app/client/src/components/editorComponents/Debugger/index.tsx b/app/client/src/components/editorComponents/Debugger/index.tsx index 1bc7efed6c2b..b8fe7bc1a2f8 100644 --- a/app/client/src/components/editorComponents/Debugger/index.tsx +++ b/app/client/src/components/editorComponents/Debugger/index.tsx @@ -18,8 +18,10 @@ export function DebuggerTrigger() { const messageCounters = useSelector(getMessageCount); useEffect(() => { + // Sync the global error count with debugger message counters. + // Only depends on the current error count so we don't dispatch on every render. dispatch(setErrorCount(messageCounters.errors)); - }); + }, [dispatch, messageCounters.errors]); const onClick = useDebuggerTriggerClick(); diff --git a/app/client/src/pages/AppIDE/layouts/hooks/useWidgetSelectionBlockListener.ts b/app/client/src/pages/AppIDE/layouts/hooks/useWidgetSelectionBlockListener.ts index 72eed0de9de6..396dbd36d3c2 100644 --- a/app/client/src/pages/AppIDE/layouts/hooks/useWidgetSelectionBlockListener.ts +++ b/app/client/src/pages/AppIDE/layouts/hooks/useWidgetSelectionBlockListener.ts @@ -19,8 +19,11 @@ export function useWidgetSelectionBlockListener() { FocusEntity.WIDGET_LIST, ].includes(currentFocus.entity); + // Block or unblock widget selection based only on the focused entity type. + // We depend on `currentFocus.entity` instead of the full object to avoid + // re-dispatching on every render with a new object reference. dispatch(setWidgetSelectionBlock(!inUIMode)); - }, [currentFocus, dispatch]); + }, [currentFocus.entity, dispatch]); useEffect(() => { window.addEventListener("keydown", handleKeyDown); diff --git a/app/client/src/pages/Applications/ApplicationCard.tsx b/app/client/src/pages/Applications/ApplicationCard.tsx index 39770deaf792..772a320cf9bd 100644 --- a/app/client/src/pages/Applications/ApplicationCard.tsx +++ b/app/client/src/pages/Applications/ApplicationCard.tsx @@ -40,6 +40,7 @@ import type { UpdateApplicationPayload, } from "ee/api/ApplicationApi"; import { + getApplicationList, getIsSavingAppName, getIsErroredSavingAppName, } from "ee/selectors/applicationSelectors"; @@ -106,6 +107,7 @@ export function ApplicationCard(props: ApplicationCardProps) { const theme = useContext(ThemeContext); const isSavingName = useSelector(getIsSavingAppName); const isErroredSavingName = useSelector(getIsErroredSavingAppName); + const allApplications = useSelector(getApplicationList); const currentUser = useSelector(getCurrentUserSelector); const initialsAndColorCode = getInitialsAndColorCode( application.name, @@ -211,20 +213,29 @@ export function ApplicationCard(props: ApplicationCardProps) { const appIcon = (application.icon || getApplicationIcon(applicationId)) as AppIconName; + + // Some views (like Favorites) may receive applications without populated userPermissions. + // Fall back to the main application list so permissions match the standard workspace cards. + const fallbackApp = allApplications?.find((app) => app.id === applicationId); + const effectiveUserPermissions = + (application.userPermissions && application.userPermissions.length > 0 + ? application.userPermissions + : fallbackApp?.userPermissions) ?? []; + const hasEditPermission = isPermitted( - application.userPermissions ?? [], + effectiveUserPermissions, PERMISSION_TYPE.MANAGE_APPLICATION, ); const hasReadPermission = isPermitted( - application.userPermissions ?? [], + effectiveUserPermissions, PERMISSION_TYPE.READ_APPLICATION, ); const hasExportPermission = isPermitted( - application.userPermissions ?? [], + effectiveUserPermissions, PERMISSION_TYPE.EXPORT_APPLICATION, ); const hasDeletePermission = hasDeleteApplicationPermission( - application.userPermissions, + effectiveUserPermissions, ); const updateColor = (color: string) => { diff --git a/app/client/src/sagas/FavoritesSagas.ts b/app/client/src/sagas/FavoritesSagas.ts index 5697c4814c20..f44609144344 100644 --- a/app/client/src/sagas/FavoritesSagas.ts +++ b/app/client/src/sagas/FavoritesSagas.ts @@ -10,6 +10,7 @@ import { import { validateResponse } from "sagas/ErrorSagas"; import { toast } from "@appsmith/ads"; import { findDefaultPage } from "pages/utils"; +import type { ApplicationPayload } from "entities/Application"; function* toggleFavoriteApplicationSaga( action: ReduxAction<{ applicationId: string }>, @@ -64,6 +65,13 @@ function* fetchFavoriteApplicationsSaga() { // eslint-disable-next-line @typescript-eslint/no-explicit-any const rawApplications = (response as any).data; + // Merge in userPermissions from the main application list when available + // so favorites behave exactly like the standard workspace view for edit/delete/etc. + const allApplications: ApplicationPayload[] = yield select( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (state: any) => state.ui.applications.applicationList, + ); + // Transform applications to include defaultBasePageId (needed for Launch button) // This matches the transformation done in ApplicationSagas.tsx const applications = rawApplications.map( @@ -71,8 +79,17 @@ function* fetchFavoriteApplicationsSaga() { (application: any) => { const defaultPage = findDefaultPage(application.pages); + // Find the corresponding application from the main list (if loaded) + const existing = allApplications?.find( + (app) => app.id === application.id, + ); + return { ...application, + // Prefer userPermissions from the main application list so edit + // permissions match the regular workspace cards. + userPermissions: + existing?.userPermissions ?? application.userPermissions, defaultPageId: defaultPage?.id, defaultBasePageId: defaultPage?.baseId, }; diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/UserDataServiceImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/UserDataServiceImpl.java index 030c6cc69f29..7d20d987d920 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/UserDataServiceImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/UserDataServiceImpl.java @@ -4,6 +4,7 @@ import com.appsmith.server.repositories.UserDataRepository; import com.appsmith.server.repositories.UserRepository; import com.appsmith.server.services.ce.UserDataServiceCEImpl; +import com.appsmith.server.solutions.ApplicationPermission; import com.appsmith.server.solutions.ReleaseNotesService; import jakarta.validation.Validator; import org.springframework.stereotype.Service; @@ -21,6 +22,7 @@ public UserDataServiceImpl( ReleaseNotesService releaseNotesService, FeatureFlagService featureFlagService, ApplicationRepository applicationRepository, + ApplicationPermission applicationPermission, OrganizationService organizationService) { super( @@ -33,6 +35,7 @@ public UserDataServiceImpl( releaseNotesService, featureFlagService, applicationRepository, + applicationPermission, organizationService); } } From d4065de2b714cb0e533b17da9e208e9662d41381 Mon Sep 17 00:00:00 2001 From: Stacey Levine Date: Mon, 15 Dec 2025 11:05:56 -0500 Subject: [PATCH 07/26] Fix a few more bugs to avoid unnnecessary re-renders --- .../selectors/selectedWorkspaceSelectors.ts | 30 +++++++++++-------- app/client/src/sagas/FavoritesSagas.ts | 8 ++--- 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/app/client/src/ce/selectors/selectedWorkspaceSelectors.ts b/app/client/src/ce/selectors/selectedWorkspaceSelectors.ts index abff67666a55..6ebb7fa27dd1 100644 --- a/app/client/src/ce/selectors/selectedWorkspaceSelectors.ts +++ b/app/client/src/ce/selectors/selectedWorkspaceSelectors.ts @@ -1,20 +1,26 @@ import type { DefaultRootState } from "react-redux"; +import { createSelector } from "reselect"; export const getIsFetchingApplications = (state: DefaultRootState): boolean => state.ui.selectedWorkspace.loadingStates.isFetchingApplications; -export const getApplicationsOfWorkspace = (state: DefaultRootState) => { - const applications = state.ui.selectedWorkspace.applications; - const favoriteApplicationIds = - state.ui.applications.favoriteApplicationIds || []; - - // Compute isFavorited for each application based on favoriteApplicationIds - // This ensures favorites persist when switching between workspaces - return applications.map((app) => ({ - ...app, - isFavorited: favoriteApplicationIds.includes(app.id), - })); -}; +const selectWorkspaceApplications = (state: DefaultRootState) => + state.ui.selectedWorkspace.applications; + +const selectFavoriteApplicationIds = (state: DefaultRootState) => + state.ui.applications.favoriteApplicationIds || []; + +export const getApplicationsOfWorkspace = createSelector( + [selectWorkspaceApplications, selectFavoriteApplicationIds], + (applications, favoriteApplicationIds) => + // Compute isFavorited for each application based on favoriteApplicationIds. + // This ensures favorites persist when switching between workspaces while + // avoiding unnecessary re-renders when inputs haven't changed. + applications.map((app) => ({ + ...app, + isFavorited: favoriteApplicationIds.includes(app.id), + })), +); export const getAllUsersOfWorkspace = (state: DefaultRootState) => state.ui.selectedWorkspace.users; diff --git a/app/client/src/sagas/FavoritesSagas.ts b/app/client/src/sagas/FavoritesSagas.ts index f44609144344..4c047cb9e544 100644 --- a/app/client/src/sagas/FavoritesSagas.ts +++ b/app/client/src/sagas/FavoritesSagas.ts @@ -41,12 +41,8 @@ function* toggleFavoriteApplicationSaga( yield put(toggleFavoriteApplicationError(applicationId)); } } catch (error) { - // Rollback on error - const currentFavoriteIds: string[] = yield select( - (state) => state.ui.applications.favoriteApplicationIds, - ); - const isFavorited = currentFavoriteIds.includes(applicationId); - + // Rollback on error using the original isFavorited value captured above. + // Do NOT re-read state here, since the optimistic update has already modified it. yield put(toggleFavoriteApplicationSuccess(applicationId, isFavorited)); yield put(toggleFavoriteApplicationError(applicationId)); From 82d542e25b93578ac61d78dcc331d8d9d7475e44 Mon Sep 17 00:00:00 2001 From: Stacey Levine Date: Mon, 15 Dec 2025 13:52:57 -0500 Subject: [PATCH 08/26] Adjust favorite icon and fix new workspace creation issue. Also address yarn check types --- app/client/src/ce/sagas/WorkspaceSagas.ts | 10 +++++++++- app/client/src/components/common/Card.tsx | 6 ++++-- app/client/src/sagas/FavoritesSagas.ts | 5 ++++- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/app/client/src/ce/sagas/WorkspaceSagas.ts b/app/client/src/ce/sagas/WorkspaceSagas.ts index b3dbad51f0be..3bff6cf656ca 100644 --- a/app/client/src/ce/sagas/WorkspaceSagas.ts +++ b/app/client/src/ce/sagas/WorkspaceSagas.ts @@ -359,10 +359,18 @@ export function* createWorkspaceSaga( yield call(resolve); } - // get created workspace in focus + // Get created workspace in focus // @ts-expect-error: response is of type unknown const workspaceId = response.data.id; + // Refresh workspaces and entities for the newly created workspace so that + // the left panel and applications list reflect the new workspace instead of + // staying on the previous (e.g. Favorites) virtual workspace. + yield put({ + type: ReduxActionTypes.FETCH_ALL_WORKSPACES_INIT, + payload: { workspaceId, fetchEntities: true }, + }); + history.push(`${window.location.pathname}?workspaceId=${workspaceId}`); } catch (error) { yield call(reject, { _error: (error as Error).message }); diff --git a/app/client/src/components/common/Card.tsx b/app/client/src/components/common/Card.tsx index 4e6b5041ff47..8e9091af94d1 100644 --- a/app/client/src/components/common/Card.tsx +++ b/app/client/src/components/common/Card.tsx @@ -116,8 +116,10 @@ const FavoriteIconWrapper = styled.div` display: flex; align-items: center; justify-content: center; - width: 24px; - height: 24px; + /* Slightly smaller footprint so the favorite icon feels less crowded + next to long application names. */ + width: 20px; + height: 20px; background-color: rgba(255, 255, 255, 0.9); border-radius: 50%; transition: all 0.2s ease; diff --git a/app/client/src/sagas/FavoritesSagas.ts b/app/client/src/sagas/FavoritesSagas.ts index 4c047cb9e544..403d6aa7b6f8 100644 --- a/app/client/src/sagas/FavoritesSagas.ts +++ b/app/client/src/sagas/FavoritesSagas.ts @@ -16,13 +16,16 @@ function* toggleFavoriteApplicationSaga( action: ReduxAction<{ applicationId: string }>, ) { const { applicationId } = action.payload; + // Track the original favorite state so we can reliably roll back on error. + let isFavorited: boolean = false; try { // Optimistic update - get current state const currentFavoriteIds: string[] = yield select( (state) => state.ui.applications.favoriteApplicationIds, ); - const isFavorited = currentFavoriteIds.includes(applicationId); + + isFavorited = currentFavoriteIds.includes(applicationId); const newIsFavorited = !isFavorited; // Immediate UI update (optimistic) From f5c0aec9654daafcf9b402f5f81d207c8fc698b9 Mon Sep 17 00:00:00 2001 From: Stacey Levine Date: Mon, 15 Dec 2025 14:02:53 -0500 Subject: [PATCH 09/26] Address favorites possibly overwriting non-favorites and better error handling on loading --- .../ce/reducers/uiReducers/selectedWorkspaceReducer.ts | 8 +++++++- app/client/src/sagas/FavoritesSagas.ts | 7 ++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/app/client/src/ce/reducers/uiReducers/selectedWorkspaceReducer.ts b/app/client/src/ce/reducers/uiReducers/selectedWorkspaceReducer.ts index 7e0e2092e850..69999047ae26 100644 --- a/app/client/src/ce/reducers/uiReducers/selectedWorkspaceReducer.ts +++ b/app/client/src/ce/reducers/uiReducers/selectedWorkspaceReducer.ts @@ -70,7 +70,13 @@ export const handlers = { action: ReduxAction, ) => { draftState.loadingStates.isFetchingApplications = false; - draftState.applications = action.payload; + + // Only replace applications when we're in the virtual favorites workspace. + // This prevents overwriting a real workspace's applications when favorites + // are fetched in the background. + if (draftState.workspace.id === "__favorites__") { + draftState.applications = action.payload; + } }, [ReduxActionErrorTypes.FETCH_FAVORITE_APPLICATIONS_ERROR]: ( draftState: SelectedWorkspaceReduxState, diff --git a/app/client/src/sagas/FavoritesSagas.ts b/app/client/src/sagas/FavoritesSagas.ts index 403d6aa7b6f8..cccb96ea59f4 100644 --- a/app/client/src/sagas/FavoritesSagas.ts +++ b/app/client/src/sagas/FavoritesSagas.ts @@ -5,6 +5,7 @@ import ApplicationApi from "ee/api/ApplicationApi"; import { toggleFavoriteApplicationSuccess, toggleFavoriteApplicationError, + fetchFavoriteApplicationsError, fetchFavoriteApplicationsSuccess, } from "actions/applicationActions"; import { validateResponse } from "sagas/ErrorSagas"; @@ -96,9 +97,13 @@ function* fetchFavoriteApplicationsSaga() { ); yield put(fetchFavoriteApplicationsSuccess(applications)); + } else { + // Non-successful API response – notify reducers so loading state is cleared. + yield put(fetchFavoriteApplicationsError()); } } catch (error) { - // Silent fail - favorites are not critical + // On error, dispatch the error action so reducers can clear loading state. + yield put(fetchFavoriteApplicationsError()); } } From eb2635e5a9a2a2a208bcb7d776cd91bf862c718c Mon Sep 17 00:00:00 2001 From: Stacey Levine Date: Mon, 15 Dec 2025 16:03:59 -0500 Subject: [PATCH 10/26] Fix minor card permission issue --- app/client/src/components/common/Card.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/app/client/src/components/common/Card.tsx b/app/client/src/components/common/Card.tsx index 8e9091af94d1..87e8926827a4 100644 --- a/app/client/src/components/common/Card.tsx +++ b/app/client/src/components/common/Card.tsx @@ -414,10 +414,20 @@ function Card({ {showOverlay && !isMobile && ( -
+
- + {children} From a9b7911deae189746cfe812cf5ff68a21ebd2bed Mon Sep 17 00:00:00 2001 From: Stacey Levine Date: Fri, 23 Jan 2026 10:22:30 -0500 Subject: [PATCH 11/26] Addressed code review concerns --- .../src/ce/constants/workspaceConstants.ts | 9 +++++ .../src/ce/pages/Applications/index.tsx | 17 ++++----- .../uiReducers/selectedWorkspaceReducer.ts | 5 ++- app/client/src/ce/sagas/WorkspaceSagas.ts | 13 +++---- .../src/ce/selectors/applicationSelectors.tsx | 19 ++++++++++ app/client/src/sagas/FavoritesSagas.ts | 37 +++---------------- app/client/src/selectors/favoriteSelectors.ts | 30 --------------- 7 files changed, 49 insertions(+), 81 deletions(-) delete mode 100644 app/client/src/selectors/favoriteSelectors.ts diff --git a/app/client/src/ce/constants/workspaceConstants.ts b/app/client/src/ce/constants/workspaceConstants.ts index 53e1dfa5967a..667fd6afa425 100644 --- a/app/client/src/ce/constants/workspaceConstants.ts +++ b/app/client/src/ce/constants/workspaceConstants.ts @@ -1,3 +1,12 @@ +export const FAVORITES_KEY = "__favorites__"; + +export const DEFAULT_FAVORITES_WORKSPACE = { + id: FAVORITES_KEY, + name: "Favorites", + isVirtual: true, + userPermissions: [] as string[], +}; + export interface WorkspaceRole { id: string; name: string; diff --git a/app/client/src/ce/pages/Applications/index.tsx b/app/client/src/ce/pages/Applications/index.tsx index 0ad39bc9d502..3acc61e0b5ee 100644 --- a/app/client/src/ce/pages/Applications/index.tsx +++ b/app/client/src/ce/pages/Applications/index.tsx @@ -35,7 +35,11 @@ import { getIsCreatingApplication, getIsDeletingApplication, } from "ee/selectors/applicationSelectors"; -import { getHasFavorites } from "selectors/favoriteSelectors"; +import { getHasFavorites } from "ee/selectors/applicationSelectors"; +import { + DEFAULT_FAVORITES_WORKSPACE, + FAVORITES_KEY, +} from "ee/constants/workspaceConstants"; import { Classes as BlueprintClasses } from "@blueprintjs/core"; import { Position } from "@blueprintjs/core/lib/esm/common/position"; import { leaveWorkspace } from "actions/userActions"; @@ -477,7 +481,7 @@ export function WorkspaceMenuItem({ if (!workspace.id) return null; - const isFavoritesWorkspace = workspace.id === "__favorites__"; + const isFavoritesWorkspace = workspace.id === FAVORITES_KEY; const hasLogo = workspace?.logoUrl && !imageError; const displayText = isFetchingWorkspaces ? workspace?.name @@ -1159,14 +1163,7 @@ export const ApplictionsMainPage = (props: any) => { // Inject virtual Favorites workspace at the top if user has favorites if (hasFavorites && !isFetchingWorkspaces) { - const favoritesWorkspace = { - id: "__favorites__", - name: "Favorites", - isVirtual: true, - userPermissions: [], - }; - - workspaces = [favoritesWorkspace, ...workspaces]; + workspaces = [DEFAULT_FAVORITES_WORKSPACE, ...workspaces]; } const [activeWorkspaceId, setActiveWorkspaceId] = useState< diff --git a/app/client/src/ce/reducers/uiReducers/selectedWorkspaceReducer.ts b/app/client/src/ce/reducers/uiReducers/selectedWorkspaceReducer.ts index 69999047ae26..3d39f960452a 100644 --- a/app/client/src/ce/reducers/uiReducers/selectedWorkspaceReducer.ts +++ b/app/client/src/ce/reducers/uiReducers/selectedWorkspaceReducer.ts @@ -10,6 +10,7 @@ import type { WorkspaceUser, WorkspaceUserRoles, } from "ee/constants/workspaceConstants"; +import { FAVORITES_KEY } from "ee/constants/workspaceConstants"; import type { Package } from "ee/constants/PackageConstants"; import type { UpdateApplicationRequest } from "ee/api/ApplicationApi"; @@ -74,7 +75,7 @@ export const handlers = { // Only replace applications when we're in the virtual favorites workspace. // This prevents overwriting a real workspace's applications when favorites // are fetched in the background. - if (draftState.workspace.id === "__favorites__") { + if (draftState.workspace.id === FAVORITES_KEY) { draftState.applications = action.payload; } }, @@ -271,7 +272,7 @@ export const handlers = { action: ReduxAction<{ applicationId: string; isFavorited: boolean }>, ) => { const { applicationId, isFavorited } = action.payload; - const isFavoritesWorkspace = draftState.workspace.id === "__favorites__"; + const isFavoritesWorkspace = draftState.workspace.id === FAVORITES_KEY; if (isFavoritesWorkspace && !isFavorited) { // If we're in the favorites workspace and the app is unfavorited, remove it from the list diff --git a/app/client/src/ce/sagas/WorkspaceSagas.ts b/app/client/src/ce/sagas/WorkspaceSagas.ts index 3bff6cf656ca..8da14d1d3a74 100644 --- a/app/client/src/ce/sagas/WorkspaceSagas.ts +++ b/app/client/src/ce/sagas/WorkspaceSagas.ts @@ -30,6 +30,10 @@ import WorkspaceApi from "ee/api/WorkspaceApi"; import type { ApiResponse } from "api/ApiResponses"; import { getFetchedWorkspaces } from "ee/selectors/workspaceSelectors"; import { getCurrentUser } from "selectors/usersSelectors"; +import { + DEFAULT_FAVORITES_WORKSPACE, + FAVORITES_KEY, +} from "ee/constants/workspaceConstants"; import type { Workspace } from "ee/constants/workspaceConstants"; import history from "utils/history"; import { APPLICATIONS_URL } from "constants/routes"; @@ -88,15 +92,10 @@ export function* fetchEntitiesOfWorkspaceSaga( const workspaceId = action?.payload?.workspaceId || allWorkspaces[0]?.id; // Handle virtual favorites workspace specially - if (workspaceId === "__favorites__") { + if (workspaceId === FAVORITES_KEY) { yield put({ type: ReduxActionTypes.SET_CURRENT_WORKSPACE, - payload: { - id: "__favorites__", - name: "Favorites", - isVirtual: true, - userPermissions: [], - }, + payload: DEFAULT_FAVORITES_WORKSPACE, }); yield put({ type: ReduxActionTypes.FETCH_FAVORITE_APPLICATIONS_INIT }); diff --git a/app/client/src/ce/selectors/applicationSelectors.tsx b/app/client/src/ce/selectors/applicationSelectors.tsx index 44052b83dc5b..d0c18298cef0 100644 --- a/app/client/src/ce/selectors/applicationSelectors.tsx +++ b/app/client/src/ce/selectors/applicationSelectors.tsx @@ -243,3 +243,22 @@ export const getRedeployApplicationTrigger = createSelector( return null; }, ); + +export const getFavoriteApplicationIds = (state: DefaultRootState) => + state.ui.applications.favoriteApplicationIds; + +export const getFavoriteApplications = createSelector( + [getApplications, getFavoriteApplicationIds], + (allApps: ApplicationPayload[], favoriteIds: string[]) => { + return allApps + .filter((app: ApplicationPayload) => favoriteIds.includes(app.id)) + .sort((a: ApplicationPayload, b: ApplicationPayload) => + a.name.localeCompare(b.name), + ); + }, +); + +export const getHasFavorites = createSelector( + [getFavoriteApplicationIds], + (favoriteIds: string[]) => favoriteIds.length > 0, +); diff --git a/app/client/src/sagas/FavoritesSagas.ts b/app/client/src/sagas/FavoritesSagas.ts index cccb96ea59f4..9765d70ea538 100644 --- a/app/client/src/sagas/FavoritesSagas.ts +++ b/app/client/src/sagas/FavoritesSagas.ts @@ -1,4 +1,4 @@ -import { call, put, select, takeLatest } from "redux-saga/effects"; +import { call, put, takeLatest, select } from "redux-saga/effects"; import type { ReduxAction } from "actions/ReduxActionTypes"; import { ReduxActionTypes } from "ee/constants/ReduxActionConstants"; import ApplicationApi from "ee/api/ApplicationApi"; @@ -12,16 +12,15 @@ import { validateResponse } from "sagas/ErrorSagas"; import { toast } from "@appsmith/ads"; import { findDefaultPage } from "pages/utils"; import type { ApplicationPayload } from "entities/Application"; +import type { ApiResponse } from "api/ApiResponses"; function* toggleFavoriteApplicationSaga( action: ReduxAction<{ applicationId: string }>, ) { const { applicationId } = action.payload; - // Track the original favorite state so we can reliably roll back on error. let isFavorited: boolean = false; try { - // Optimistic update - get current state const currentFavoriteIds: string[] = yield select( (state) => state.ui.applications.favoriteApplicationIds, ); @@ -29,10 +28,8 @@ function* toggleFavoriteApplicationSaga( isFavorited = currentFavoriteIds.includes(applicationId); const newIsFavorited = !isFavorited; - // Immediate UI update (optimistic) yield put(toggleFavoriteApplicationSuccess(applicationId, newIsFavorited)); - // API call const response: unknown = yield call( ApplicationApi.toggleFavoriteApplication, applicationId, @@ -40,13 +37,10 @@ function* toggleFavoriteApplicationSaga( const isValidResponse: boolean = yield validateResponse(response); if (!isValidResponse) { - // Rollback on error yield put(toggleFavoriteApplicationSuccess(applicationId, isFavorited)); yield put(toggleFavoriteApplicationError(applicationId)); } } catch (error) { - // Rollback on error using the original isFavorited value captured above. - // Do NOT re-read state here, since the optimistic update has already modified it. yield put(toggleFavoriteApplicationSuccess(applicationId, isFavorited)); yield put(toggleFavoriteApplicationError(applicationId)); @@ -62,34 +56,15 @@ function* fetchFavoriteApplicationsSaga() { const isValidResponse: boolean = yield validateResponse(response); if (isValidResponse) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const rawApplications = (response as any).data; + const rawApplications = (response as ApiResponse) + .data; - // Merge in userPermissions from the main application list when available - // so favorites behave exactly like the standard workspace view for edit/delete/etc. - const allApplications: ApplicationPayload[] = yield select( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (state: any) => state.ui.applications.applicationList, - ); - - // Transform applications to include defaultBasePageId (needed for Launch button) - // This matches the transformation done in ApplicationSagas.tsx const applications = rawApplications.map( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (application: any) => { + (application: ApplicationPayload) => { const defaultPage = findDefaultPage(application.pages); - // Find the corresponding application from the main list (if loaded) - const existing = allApplications?.find( - (app) => app.id === application.id, - ); - return { ...application, - // Prefer userPermissions from the main application list so edit - // permissions match the regular workspace cards. - userPermissions: - existing?.userPermissions ?? application.userPermissions, defaultPageId: defaultPage?.id, defaultBasePageId: defaultPage?.baseId, }; @@ -98,11 +73,9 @@ function* fetchFavoriteApplicationsSaga() { yield put(fetchFavoriteApplicationsSuccess(applications)); } else { - // Non-successful API response – notify reducers so loading state is cleared. yield put(fetchFavoriteApplicationsError()); } } catch (error) { - // On error, dispatch the error action so reducers can clear loading state. yield put(fetchFavoriteApplicationsError()); } } diff --git a/app/client/src/selectors/favoriteSelectors.ts b/app/client/src/selectors/favoriteSelectors.ts deleted file mode 100644 index 2bdbd10fc7e3..000000000000 --- a/app/client/src/selectors/favoriteSelectors.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { createSelector } from "reselect"; -import type { DefaultRootState } from "react-redux"; -import type { ApplicationPayload } from "entities/Application"; - -export const getFavoriteApplicationIds = (state: DefaultRootState) => - state.ui.applications.favoriteApplicationIds; - -export const getFavoriteApplications = createSelector( - [ - (state: DefaultRootState) => state.ui.applications.applicationList, - getFavoriteApplicationIds, - ], - (allApps: ApplicationPayload[], favoriteIds: string[]) => { - const favoriteApps = allApps - .filter((app: ApplicationPayload) => favoriteIds.includes(app.id)) - .sort((a: ApplicationPayload, b: ApplicationPayload) => - a.name.localeCompare(b.name), - ); // Alphabetical sort - - return favoriteApps; - }, -); - -export const getHasFavorites = createSelector( - [getFavoriteApplicationIds], - (favoriteIds: string[]) => favoriteIds.length > 0, -); - -export const getIsFetchingFavorites = (state: DefaultRootState) => - state.ui.applications.isFetchingFavorites; From 1863d2c5b748df9d8666efb0b1eb510fd7647612 Mon Sep 17 00:00:00 2001 From: Stacey Levine Date: Fri, 23 Jan 2026 11:57:40 -0500 Subject: [PATCH 12/26] Addressed code rabbit conerns --- .../src/ce/selectors/applicationSelectors.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/app/client/src/ce/selectors/applicationSelectors.tsx b/app/client/src/ce/selectors/applicationSelectors.tsx index d0c18298cef0..b7182f66b00f 100644 --- a/app/client/src/ce/selectors/applicationSelectors.tsx +++ b/app/client/src/ce/selectors/applicationSelectors.tsx @@ -249,9 +249,16 @@ export const getFavoriteApplicationIds = (state: DefaultRootState) => export const getFavoriteApplications = createSelector( [getApplications, getFavoriteApplicationIds], - (allApps: ApplicationPayload[], favoriteIds: string[]) => { - return allApps - .filter((app: ApplicationPayload) => favoriteIds.includes(app.id)) + ( + allApps: ApplicationPayload[] | undefined, + favoriteIds: string[] | undefined, + ) => { + const apps = allApps ?? []; + const ids = favoriteIds ?? []; + const favoriteIdSet = new Set(ids); + + return apps + .filter((app: ApplicationPayload) => favoriteIdSet.has(app.id)) .sort((a: ApplicationPayload, b: ApplicationPayload) => a.name.localeCompare(b.name), ); @@ -260,5 +267,5 @@ export const getFavoriteApplications = createSelector( export const getHasFavorites = createSelector( [getFavoriteApplicationIds], - (favoriteIds: string[]) => favoriteIds.length > 0, + (favoriteIds: string[] | undefined) => (favoriteIds ?? []).length > 0, ); From 710f2dff0ed14eabb2da61a7ae3ad2863c833ac6 Mon Sep 17 00:00:00 2001 From: Stacey Levine Date: Mon, 26 Jan 2026 10:05:44 -0500 Subject: [PATCH 13/26] Addressing comments Addressing the backend comments with fixes --- .../uiReducers/applicationsReducer.tsx | 4 +- .../uiReducers/selectedWorkspaceReducer.ts | 8 +- .../src/ce/selectors/applicationSelectors.tsx | 4 +- .../pages/Applications/ApplicationCard.tsx | 4 +- app/client/src/sagas/FavoritesSagas.ts | 24 +++++- .../ce/ApplicationPageServiceCEImpl.java | 6 +- .../services/ce/UserDataServiceCEImpl.java | 76 ++++++++----------- 7 files changed, 70 insertions(+), 56 deletions(-) diff --git a/app/client/src/ce/reducers/uiReducers/applicationsReducer.tsx b/app/client/src/ce/reducers/uiReducers/applicationsReducer.tsx index 32e1a5c618fc..7773cf99b762 100644 --- a/app/client/src/ce/reducers/uiReducers/applicationsReducer.tsx +++ b/app/client/src/ce/reducers/uiReducers/applicationsReducer.tsx @@ -895,7 +895,9 @@ export const handlers = { ? [...state.favoriteApplicationIds, applicationId] : state.favoriteApplicationIds.filter((id) => id !== applicationId), applicationList: state.applicationList.map((app) => - app.id === applicationId ? { ...app, isFavorited } : app, + (app.baseId || app.id) === applicationId + ? { ...app, isFavorited } + : app, ), }; }, diff --git a/app/client/src/ce/reducers/uiReducers/selectedWorkspaceReducer.ts b/app/client/src/ce/reducers/uiReducers/selectedWorkspaceReducer.ts index 3d39f960452a..530b8ad691cc 100644 --- a/app/client/src/ce/reducers/uiReducers/selectedWorkspaceReducer.ts +++ b/app/client/src/ce/reducers/uiReducers/selectedWorkspaceReducer.ts @@ -275,14 +275,14 @@ export const handlers = { const isFavoritesWorkspace = draftState.workspace.id === FAVORITES_KEY; if (isFavoritesWorkspace && !isFavorited) { - // If we're in the favorites workspace and the app is unfavorited, remove it from the list draftState.applications = draftState.applications.filter( - (app) => app.id !== applicationId, + (app) => (app.baseId || app.id) !== applicationId, ); } else { - // Otherwise, just update the isFavorited status draftState.applications = draftState.applications.map((app) => - app.id === applicationId ? { ...app, isFavorited } : app, + (app.baseId || app.id) === applicationId + ? { ...app, isFavorited } + : app, ); } }, diff --git a/app/client/src/ce/selectors/applicationSelectors.tsx b/app/client/src/ce/selectors/applicationSelectors.tsx index b7182f66b00f..4757ebd60cde 100644 --- a/app/client/src/ce/selectors/applicationSelectors.tsx +++ b/app/client/src/ce/selectors/applicationSelectors.tsx @@ -258,7 +258,9 @@ export const getFavoriteApplications = createSelector( const favoriteIdSet = new Set(ids); return apps - .filter((app: ApplicationPayload) => favoriteIdSet.has(app.id)) + .filter((app: ApplicationPayload) => + favoriteIdSet.has(app.baseId || app.id), + ) .sort((a: ApplicationPayload, b: ApplicationPayload) => a.name.localeCompare(b.name), ); diff --git a/app/client/src/pages/Applications/ApplicationCard.tsx b/app/client/src/pages/Applications/ApplicationCard.tsx index 772a320cf9bd..b04583d10c8b 100644 --- a/app/client/src/pages/Applications/ApplicationCard.tsx +++ b/app/client/src/pages/Applications/ApplicationCard.tsx @@ -536,9 +536,9 @@ export function ApplicationCard(props: ApplicationCardProps) { const handleToggleFavorite = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); - dispatch(toggleFavoriteApplication(application.id)); + dispatch(toggleFavoriteApplication(application.baseId || application.id)); }, - [application.id, dispatch], + [application.baseId, application.id, dispatch], ); return ( diff --git a/app/client/src/sagas/FavoritesSagas.ts b/app/client/src/sagas/FavoritesSagas.ts index 9765d70ea538..ff03aceb9f73 100644 --- a/app/client/src/sagas/FavoritesSagas.ts +++ b/app/client/src/sagas/FavoritesSagas.ts @@ -14,6 +14,8 @@ import { findDefaultPage } from "pages/utils"; import type { ApplicationPayload } from "entities/Application"; import type { ApiResponse } from "api/ApiResponses"; +const MAX_FAVORITE_APPLICATIONS_LIMIT = 50; + function* toggleFavoriteApplicationSaga( action: ReduxAction<{ applicationId: string }>, ) { @@ -26,6 +28,19 @@ function* toggleFavoriteApplicationSaga( ); isFavorited = currentFavoriteIds.includes(applicationId); + + if ( + !isFavorited && + currentFavoriteIds.length >= MAX_FAVORITE_APPLICATIONS_LIMIT + ) { + toast.show( + `Maximum favorite applications limit (${MAX_FAVORITE_APPLICATIONS_LIMIT}) reached`, + { kind: "error" }, + ); + + return; + } + const newIsFavorited = !isFavorited; yield put(toggleFavoriteApplicationSuccess(applicationId, newIsFavorited)); @@ -40,11 +55,16 @@ function* toggleFavoriteApplicationSaga( yield put(toggleFavoriteApplicationSuccess(applicationId, isFavorited)); yield put(toggleFavoriteApplicationError(applicationId)); } - } catch (error) { + } catch (error: unknown) { yield put(toggleFavoriteApplicationSuccess(applicationId, isFavorited)); yield put(toggleFavoriteApplicationError(applicationId)); - toast.show("Failed to update favorite status", { kind: "error" }); + const message = + error instanceof Error + ? error.message + : "Failed to update favorite status"; + + toast.show(message, { kind: "error" }); } } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/ApplicationPageServiceCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/ApplicationPageServiceCEImpl.java index 3d9485e7cb90..7e01d715c4be 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/ApplicationPageServiceCEImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/ApplicationPageServiceCEImpl.java @@ -53,6 +53,7 @@ import com.appsmith.server.services.AnalyticsService; import com.appsmith.server.services.PermissionGroupService; import com.appsmith.server.services.SessionUserService; +import com.appsmith.server.services.UserDataService; import com.appsmith.server.services.WorkspaceService; import com.appsmith.server.solutions.ActionPermission; import com.appsmith.server.solutions.ApplicationPermission; @@ -140,6 +141,7 @@ public class ApplicationPageServiceCEImpl implements ApplicationPageServiceCE { private final CacheableRepositoryHelper cacheableRepositoryHelper; private final PostPublishHookCoordinatorService postApplicationPublishHookCoordinatorService; + private final UserDataService userDataService; @Override public Mono createPage(PageDTO page) { @@ -572,6 +574,7 @@ protected Mono deleteApplicationResources(Application application) Mono actionPermissionMono = actionPermission.getDeletePermission().cache(); Mono pagePermissionMono = pagePermission.getDeletePermission(); + String favoriteId = application.getBaseId() != null ? application.getBaseId() : application.getId(); return actionPermissionMono .flatMap(actionDeletePermission -> actionCollectionService.archiveActionCollectionByApplicationId( application.getId(), actionDeletePermission)) @@ -580,7 +583,8 @@ protected Mono deleteApplicationResources(Application application) .then(pagePermissionMono.flatMap(pageDeletePermission -> newPageService.archivePagesByApplicationId(application.getId(), pageDeletePermission))) .then(themeService.archiveApplicationThemes(application)) - .flatMap(applicationService::archive); + .then(userDataService.removeApplicationFromAllFavorites(favoriteId)) + .then(applicationService.archive(application)); } protected Mono sendAppDeleteAnalytics(Application deletedApplication) { diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserDataServiceCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserDataServiceCEImpl.java index 5934cad5053c..4945e9b81e8b 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserDataServiceCEImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserDataServiceCEImpl.java @@ -40,8 +40,6 @@ import java.util.Collections; import java.util.List; import java.util.Map; -import java.util.Set; -import java.util.stream.Collectors; import static com.appsmith.server.constants.ce.FieldNameCE.DEFAULT; @@ -438,7 +436,6 @@ public Mono getGitProfileForCurrentUser(String defaultApplicationId) @Override public Mono toggleFavoriteApplication(String applicationId) { return sessionUserService.getCurrentUser().zipWhen(this::getForUser).flatMap(tuple -> { - User user = tuple.getT1(); UserData userData = tuple.getT2(); List favorites = userData.getFavoriteApplicationIds(); @@ -446,23 +443,39 @@ public Mono toggleFavoriteApplication(String applicationId) { favorites = new ArrayList<>(); } - // Toggle: remove if exists, add if doesn't - if (favorites.contains(applicationId)) { + boolean isRemoving = favorites.contains(applicationId); + + if (isRemoving) { favorites.remove(applicationId); - } else { - // Check if adding this favorite would exceed the limit - if (favorites.size() >= MAX_FAVORITE_APPLICATIONS_LIMIT) { - return Mono.error(new AppsmithException( - AppsmithError.INVALID_PARAMETER, - String.format( - "Maximum favorite applications limit (%d) reached. Please remove some favorites before adding new ones.", - MAX_FAVORITE_APPLICATIONS_LIMIT))); - } - favorites.add(applicationId); + userData.setFavoriteApplicationIds(favorites); + return repository.save(userData); } - userData.setFavoriteApplicationIds(favorites); - return repository.save(userData); + // When adding a favorite, verify user has access to the application + AclPermission readPermission = applicationPermission.getReadPermission(); + List finalFavorites = favorites; + return applicationRepository + .queryBuilder() + .criteria(Bridge.equal(Application.Fields.id, applicationId)) + .permission(readPermission) + .first() + .switchIfEmpty(Mono.error(new AppsmithException( + AppsmithError.NO_RESOURCE_FOUND, FieldName.APPLICATION, applicationId))) + .flatMap(application -> { + if (finalFavorites.contains(applicationId)) { + return Mono.just(userData); + } + if (finalFavorites.size() >= MAX_FAVORITE_APPLICATIONS_LIMIT) { + return Mono.error(new AppsmithException( + AppsmithError.INVALID_PARAMETER, + String.format( + "Maximum favorite applications limit (%d) reached. Please remove some favorites before adding new ones.", + MAX_FAVORITE_APPLICATIONS_LIMIT))); + } + finalFavorites.add(applicationId); + userData.setFavoriteApplicationIds(finalFavorites); + return repository.save(userData); + }); }); } @@ -479,43 +492,16 @@ public Mono> getFavoriteApplications() { return Mono.just(Collections.emptyList()); } - // Fetch applications by IDs with READ permission to populate userPermissions - // This ensures permissions are properly set so the edit button shows when user has edit permission AclPermission readPermission = applicationPermission.getReadPermission(); return applicationRepository .queryBuilder() .criteria(Bridge.in(Application.Fields.id, favoriteIds)) .permission(readPermission) .all() - .collectList() - .flatMap(applications -> { - // Clean up favorites list if any apps were not found or are inaccessible - if (applications.size() < favoriteIds.size()) { - return cleanupMissingFavorites(userData, applications) - .thenReturn(applications); - } - return Mono.just(applications); - }); + .collectList(); }); } - /** - * Remove missing/inaccessible applications from user's favorites - * @param userData User data containing favorites - * @param validApps List of valid applications that still exist - * @return Updated UserData - */ - private Mono cleanupMissingFavorites(UserData userData, List validApps) { - Set validIds = validApps.stream().map(Application::getId).collect(Collectors.toSet()); - - List cleanedFavorites = userData.getFavoriteApplicationIds().stream() - .filter(validIds::contains) - .collect(Collectors.toList()); - - userData.setFavoriteApplicationIds(cleanedFavorites); - return repository.save(userData); - } - /** * Remove application from all users' favorites when app is deleted * @param applicationId ID of deleted application From c4f4e5b0871f7c80ab0f9568978f9160d1a39016 Mon Sep 17 00:00:00 2001 From: Stacey Levine Date: Mon, 26 Jan 2026 12:04:48 -0500 Subject: [PATCH 14/26] Update ApplicationPageServiceImpl.java Fixed. Added UserDataService to the ApplicationPageServiceImpl constructor and passed it to the super call. The compilation error should be resolved. --- .../server/services/ApplicationPageServiceImpl.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ApplicationPageServiceImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ApplicationPageServiceImpl.java index f8d3e95e18e1..2a8032868088 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ApplicationPageServiceImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ApplicationPageServiceImpl.java @@ -67,7 +67,8 @@ public ApplicationPageServiceImpl( ClonePageService actionCollectionClonePageService, ObservationRegistry observationRegistry, CacheableRepositoryHelper cacheableRepositoryHelper, - PostPublishHookCoordinatorService postApplicationPublishHookCoordinatorService) { + PostPublishHookCoordinatorService postApplicationPublishHookCoordinatorService, + UserDataService userDataService) { super( workspaceService, applicationService, @@ -99,6 +100,7 @@ public ApplicationPageServiceImpl( actionCollectionClonePageService, observationRegistry, cacheableRepositoryHelper, - postApplicationPublishHookCoordinatorService); + postApplicationPublishHookCoordinatorService, + userDataService); } } From 2c8fc7ae86e1a5033736c0ce03ea6a7bf869ea83 Mon Sep 17 00:00:00 2001 From: Stacey Levine Date: Mon, 9 Feb 2026 13:05:12 -0500 Subject: [PATCH 15/26] fix: favorites cleanup and review feedback - UserData: atomic toggle favorites (addToSet/pull) to fix race condition - FavoritesSagas: remove unreachable/error action, use ApiResponse types - Remove toggleFavoriteApplicationError and TOGGLE_FAVORITE_APPLICATION_ERROR - Card: drop redundant overlay pointerEvents (handled by Wrapper) - WorkspaceSagas: refetch favorites when loading workspace (drop inaccessible) - ApplicationPageServiceCEImpl: use getBaseId() directly for favoriteId Co-authored-by: Cursor --- app/client/src/actions/applicationActions.ts | 5 ---- .../src/ce/constants/ReduxActionConstants.tsx | 1 - app/client/src/ce/sagas/WorkspaceSagas.ts | 2 ++ app/client/src/components/common/Card.tsx | 14 ++--------- app/client/src/sagas/FavoritesSagas.ts | 16 ++++--------- .../helpers/ce/bridge/BridgeUpdate.java | 5 ++++ .../ce/CustomUserDataRepositoryCE.java | 18 +++++++++++++++ .../ce/CustomUserDataRepositoryCEImpl.java | 20 ++++++++++++++++ .../ce/ApplicationPageServiceCEImpl.java | 2 +- .../services/ce/UserDataServiceCEImpl.java | 23 ++++++++++++++----- 10 files changed, 69 insertions(+), 37 deletions(-) diff --git a/app/client/src/actions/applicationActions.ts b/app/client/src/actions/applicationActions.ts index 4a09597db2d5..fa004e2e574d 100644 --- a/app/client/src/actions/applicationActions.ts +++ b/app/client/src/actions/applicationActions.ts @@ -17,11 +17,6 @@ export const toggleFavoriteApplicationSuccess = ( payload: { applicationId, isFavorited }, }); -export const toggleFavoriteApplicationError = (applicationId: string) => ({ - type: ReduxActionErrorTypes.TOGGLE_FAVORITE_APPLICATION_ERROR, - payload: { applicationId }, -}); - export const fetchFavoriteApplications = () => ({ type: ReduxActionTypes.FETCH_FAVORITE_APPLICATIONS_INIT, }); diff --git a/app/client/src/ce/constants/ReduxActionConstants.tsx b/app/client/src/ce/constants/ReduxActionConstants.tsx index 731dfdc6fa73..f1c6e192a891 100644 --- a/app/client/src/ce/constants/ReduxActionConstants.tsx +++ b/app/client/src/ce/constants/ReduxActionConstants.tsx @@ -696,7 +696,6 @@ const ApplicationActionErrorTypes = { FETCH_APP_SLUG_SUGGESTION_ERROR: "FETCH_APP_SLUG_SUGGESTION_ERROR", ENABLE_STATIC_URL_ERROR: "ENABLE_STATIC_URL_ERROR", DISABLE_STATIC_URL_ERROR: "DISABLE_STATIC_URL_ERROR", - TOGGLE_FAVORITE_APPLICATION_ERROR: "TOGGLE_FAVORITE_APPLICATION_ERROR", FETCH_FAVORITE_APPLICATIONS_ERROR: "FETCH_FAVORITE_APPLICATIONS_ERROR", }; diff --git a/app/client/src/ce/sagas/WorkspaceSagas.ts b/app/client/src/ce/sagas/WorkspaceSagas.ts index 8da14d1d3a74..f673a9fc1d7d 100644 --- a/app/client/src/ce/sagas/WorkspaceSagas.ts +++ b/app/client/src/ce/sagas/WorkspaceSagas.ts @@ -115,6 +115,8 @@ export function* fetchEntitiesOfWorkspaceSaga( if (workspaceId) { yield call(failFastApiCalls, initActions, successActions, errorActions); + // Refresh favorites so the list drops any apps the user no longer has access to + yield put({ type: ReduxActionTypes.FETCH_FAVORITE_APPLICATIONS_INIT }); } } catch (error) { yield put({ diff --git a/app/client/src/components/common/Card.tsx b/app/client/src/components/common/Card.tsx index 87e8926827a4..af187e2a5495 100644 --- a/app/client/src/components/common/Card.tsx +++ b/app/client/src/components/common/Card.tsx @@ -414,20 +414,10 @@ function Card({ {showOverlay && !isMobile && ( -
+
- + {children} diff --git a/app/client/src/sagas/FavoritesSagas.ts b/app/client/src/sagas/FavoritesSagas.ts index ff03aceb9f73..7c74a8cbc1a4 100644 --- a/app/client/src/sagas/FavoritesSagas.ts +++ b/app/client/src/sagas/FavoritesSagas.ts @@ -4,7 +4,6 @@ import { ReduxActionTypes } from "ee/constants/ReduxActionConstants"; import ApplicationApi from "ee/api/ApplicationApi"; import { toggleFavoriteApplicationSuccess, - toggleFavoriteApplicationError, fetchFavoriteApplicationsError, fetchFavoriteApplicationsSuccess, } from "actions/applicationActions"; @@ -45,19 +44,13 @@ function* toggleFavoriteApplicationSaga( yield put(toggleFavoriteApplicationSuccess(applicationId, newIsFavorited)); - const response: unknown = yield call( + const response: ApiResponse = yield call( ApplicationApi.toggleFavoriteApplication, applicationId, ); - const isValidResponse: boolean = yield validateResponse(response); - - if (!isValidResponse) { - yield put(toggleFavoriteApplicationSuccess(applicationId, isFavorited)); - yield put(toggleFavoriteApplicationError(applicationId)); - } + yield validateResponse(response); } catch (error: unknown) { yield put(toggleFavoriteApplicationSuccess(applicationId, isFavorited)); - yield put(toggleFavoriteApplicationError(applicationId)); const message = error instanceof Error @@ -70,14 +63,13 @@ function* toggleFavoriteApplicationSaga( function* fetchFavoriteApplicationsSaga() { try { - const response: unknown = yield call( + const response: ApiResponse = yield call( ApplicationApi.getFavoriteApplications, ); const isValidResponse: boolean = yield validateResponse(response); if (isValidResponse) { - const rawApplications = (response as ApiResponse) - .data; + const rawApplications = response.data; const applications = rawApplications.map( (application: ApplicationPayload) => { diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/ce/bridge/BridgeUpdate.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/ce/bridge/BridgeUpdate.java index 302447b976b5..2927f0cc6f36 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/ce/bridge/BridgeUpdate.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/ce/bridge/BridgeUpdate.java @@ -22,6 +22,11 @@ public BridgeUpdate push(@NonNull String key, @NonNull Object value) { return this; } + public BridgeUpdate addToSet(@NonNull String key, @NonNull Object value) { + update.addToSet(key, value); + return this; + } + public BridgeUpdate pull(@NonNull String key, @NonNull Object value) { update.pull(key, value); return this; diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/ce/CustomUserDataRepositoryCE.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/ce/CustomUserDataRepositoryCE.java index 0520e4f8e02d..280565203790 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/ce/CustomUserDataRepositoryCE.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/ce/CustomUserDataRepositoryCE.java @@ -13,4 +13,22 @@ public interface CustomUserDataRepositoryCE extends AppsmithRepository Mono fetchMostRecentlyUsedWorkspaceId(String userId); Mono removeApplicationFromFavorites(String applicationId); + + /** + * Add an application to a single user's favorites list using an atomic update. + * + * @param userId ID of the user whose favorites list should be updated + * @param applicationId ID of the application to add to favorites + * @return Completion signal when the update operation finishes + */ + Mono addFavoriteApplicationForUser(String userId, String applicationId); + + /** + * Remove an application from a single user's favorites list using an atomic update. + * + * @param userId ID of the user whose favorites list should be updated + * @param applicationId ID of the application to remove from favorites + * @return Completion signal when the update operation finishes + */ + Mono removeFavoriteApplicationForUser(String userId, String applicationId); } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/ce/CustomUserDataRepositoryCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/ce/CustomUserDataRepositoryCEImpl.java index e505519e25ef..d3c83fe2abc3 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/ce/CustomUserDataRepositoryCEImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/ce/CustomUserDataRepositoryCEImpl.java @@ -53,4 +53,24 @@ public Mono removeApplicationFromFavorites(String applicationId) { update.pull(UserData.Fields.favoriteApplicationIds, applicationId); return queryBuilder().updateAll(update).then(); } + + @Override + public Mono addFavoriteApplicationForUser(String userId, String applicationId) { + BridgeUpdate update = new BridgeUpdate(); + update.addToSet(UserData.Fields.favoriteApplicationIds, applicationId); + return queryBuilder() + .criteria(Bridge.equal(UserData.Fields.userId, userId)) + .updateFirst(update) + .then(); + } + + @Override + public Mono removeFavoriteApplicationForUser(String userId, String applicationId) { + BridgeUpdate update = new BridgeUpdate(); + update.pull(UserData.Fields.favoriteApplicationIds, applicationId); + return queryBuilder() + .criteria(Bridge.equal(UserData.Fields.userId, userId)) + .updateFirst(update) + .then(); + } } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/ApplicationPageServiceCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/ApplicationPageServiceCEImpl.java index 7e01d715c4be..b8d7452d9bb7 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/ApplicationPageServiceCEImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/ApplicationPageServiceCEImpl.java @@ -574,7 +574,7 @@ protected Mono deleteApplicationResources(Application application) Mono actionPermissionMono = actionPermission.getDeletePermission().cache(); Mono pagePermissionMono = pagePermission.getDeletePermission(); - String favoriteId = application.getBaseId() != null ? application.getBaseId() : application.getId(); + String favoriteId = application.getBaseId(); return actionPermissionMono .flatMap(actionDeletePermission -> actionCollectionService.archiveActionCollectionByApplicationId( application.getId(), actionDeletePermission)) diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserDataServiceCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserDataServiceCEImpl.java index 4945e9b81e8b..4682c1f6422a 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserDataServiceCEImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserDataServiceCEImpl.java @@ -436,6 +436,7 @@ public Mono getGitProfileForCurrentUser(String defaultApplicationId) @Override public Mono toggleFavoriteApplication(String applicationId) { return sessionUserService.getCurrentUser().zipWhen(this::getForUser).flatMap(tuple -> { + User user = tuple.getT1(); UserData userData = tuple.getT2(); List favorites = userData.getFavoriteApplicationIds(); @@ -446,9 +447,10 @@ public Mono toggleFavoriteApplication(String applicationId) { boolean isRemoving = favorites.contains(applicationId); if (isRemoving) { - favorites.remove(applicationId); - userData.setFavoriteApplicationIds(favorites); - return repository.save(userData); + // Use an atomic pull update so concurrent toggles don't overwrite each other + return repository + .removeFavoriteApplicationForUser(user.getId(), applicationId) + .then(getForUser(user.getId())); } // When adding a favorite, verify user has access to the application @@ -472,9 +474,18 @@ public Mono toggleFavoriteApplication(String applicationId) { "Maximum favorite applications limit (%d) reached. Please remove some favorites before adding new ones.", MAX_FAVORITE_APPLICATIONS_LIMIT))); } - finalFavorites.add(applicationId); - userData.setFavoriteApplicationIds(finalFavorites); - return repository.save(userData); + + // For new users who don't yet have a persisted UserData document, fall back to save. + if (userData.getId() == null) { + finalFavorites.add(applicationId); + userData.setFavoriteApplicationIds(finalFavorites); + return repository.save(userData); + } + + // For existing users, use an atomic addToSet update to avoid lost updates + return repository + .addFavoriteApplicationForUser(user.getId(), applicationId) + .then(getForUser(user.getId())); }); }); } From c7b242cbf79eff9ce6a0d454743ee3b20e3b2b8f Mon Sep 17 00:00:00 2001 From: Stacey Levine Date: Mon, 9 Feb 2026 13:44:35 -0500 Subject: [PATCH 16/26] fix: favorites saga error handling and validation Co-authored-by: Cursor --- app/client/src/sagas/FavoritesSagas.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/app/client/src/sagas/FavoritesSagas.ts b/app/client/src/sagas/FavoritesSagas.ts index 7c74a8cbc1a4..e53bc0cb00ca 100644 --- a/app/client/src/sagas/FavoritesSagas.ts +++ b/app/client/src/sagas/FavoritesSagas.ts @@ -1,4 +1,4 @@ -import { call, put, takeLatest, select } from "redux-saga/effects"; +import { call, put, takeLeading, takeLatest, select } from "redux-saga/effects"; import type { ReduxAction } from "actions/ReduxActionTypes"; import { ReduxActionTypes } from "ee/constants/ReduxActionConstants"; import ApplicationApi from "ee/api/ApplicationApi"; @@ -48,7 +48,13 @@ function* toggleFavoriteApplicationSaga( ApplicationApi.toggleFavoriteApplication, applicationId, ); - yield validateResponse(response); + const isValidResponse: boolean = yield validateResponse(response, false); + + if (!isValidResponse) { + yield put(toggleFavoriteApplicationSuccess(applicationId, isFavorited)); + yield put({ type: ReduxActionTypes.FETCH_FAVORITE_APPLICATIONS_INIT }); + return; + } } catch (error: unknown) { yield put(toggleFavoriteApplicationSuccess(applicationId, isFavorited)); @@ -93,7 +99,7 @@ function* fetchFavoriteApplicationsSaga() { } export default function* favoritesSagasListener() { - yield takeLatest( + yield takeLeading( ReduxActionTypes.TOGGLE_FAVORITE_APPLICATION_INIT, toggleFavoriteApplicationSaga, ); From 9c1d47497d1775605c9b9e58305642f0b14bd7d5 Mon Sep 17 00:00:00 2001 From: Stacey Levine Date: Mon, 9 Feb 2026 13:52:08 -0500 Subject: [PATCH 17/26] fix: make favorite icon a semantic button with a11y (Card) Co-authored-by: Cursor --- app/client/src/components/common/Card.tsx | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/app/client/src/components/common/Card.tsx b/app/client/src/components/common/Card.tsx index af187e2a5495..6a98f0eb0344 100644 --- a/app/client/src/components/common/Card.tsx +++ b/app/client/src/components/common/Card.tsx @@ -107,7 +107,7 @@ const CircleAppIcon = styled(AppIcon)` } `; -const FavoriteIconWrapper = styled.div` +const FavoriteIconWrapper = styled.button` position: absolute; top: 8px; left: 8px; @@ -124,10 +124,24 @@ const FavoriteIconWrapper = styled.div` border-radius: 50%; transition: all 0.2s ease; + /* Reset default button styles */ + margin: 0; + padding: 0; + border: none; + font: inherit; + color: inherit; + appearance: none; + -webkit-appearance: none; + &:hover { background-color: rgba(255, 255, 255, 1); transform: scale(1.1); } + + &:focus-visible { + outline: 2px solid var(--ads-v2-color-border-emphasis); + outline-offset: 2px; + } `; const NameWrapper = styled((props: HTMLDivProps & NameWrapperProps) => ( @@ -394,7 +408,9 @@ function Card({ > {onToggleFavorite && ( { e.stopPropagation(); onToggleFavorite(e); From ffdad18a136149dbdf2a539fb5df942f10ab9a4e Mon Sep 17 00:00:00 2001 From: Stacey Levine Date: Mon, 9 Feb 2026 17:30:10 -0500 Subject: [PATCH 18/26] fix(favorites): redirect deleted-favorite 404 to Favorites and fix view on refresh - In ApplicationSagas and InitSagas: when a logged-in user hits 404 for an app (e.g. deleted favorite), redirect to applications?workspaceId=__favorites__, refetch favorites, and show error toast instead of crash page - Applications page: prepend Favorites workspace when URL has workspaceId=__favorites__ (not only when hasFavorites) so redirect lands on Favorites - Applications page: when active workspace is __favorites__ but not in list yet, use DEFAULT_FAVORITES_WORKSPACE and still dispatch SET_CURRENT_WORKSPACE and fetchEntitiesOfWorkspace so Favorites view loads correctly after refresh Co-authored-by: Cursor --- .../src/ce/pages/Applications/index.tsx | 13 ++++-- app/client/src/ce/sagas/ApplicationSagas.tsx | 42 +++++++++++++------ app/client/src/sagas/InitSagas.ts | 20 ++++++++- 3 files changed, 58 insertions(+), 17 deletions(-) diff --git a/app/client/src/ce/pages/Applications/index.tsx b/app/client/src/ce/pages/Applications/index.tsx index 3acc61e0b5ee..47f03e733a96 100644 --- a/app/client/src/ce/pages/Applications/index.tsx +++ b/app/client/src/ce/pages/Applications/index.tsx @@ -1161,8 +1161,11 @@ export const ApplictionsMainPage = (props: any) => { ) as any; } - // Inject virtual Favorites workspace at the top if user has favorites - if (hasFavorites && !isFetchingWorkspaces) { + // Inject virtual Favorites workspace at the top if user has favorites or URL is Favorites (e.g. after 404 redirect) + if ( + (hasFavorites || workspaceIdFromQueryParams === FAVORITES_KEY) && + !isFetchingWorkspaces + ) { workspaces = [DEFAULT_FAVORITES_WORKSPACE, ...workspaces]; } @@ -1190,10 +1193,14 @@ export const ApplictionsMainPage = (props: any) => { fetchedWorkspaceId && fetchedWorkspaceId !== activeWorkspaceId ) { - const activeWorkspace: Workspace = workspaces.find( + let activeWorkspace: Workspace | undefined = workspaces.find( (workspace: Workspace) => workspace.id === activeWorkspaceId, ); + if (!activeWorkspace && activeWorkspaceId === FAVORITES_KEY) { + activeWorkspace = DEFAULT_FAVORITES_WORKSPACE; + } + if (activeWorkspace) { dispatch({ type: ReduxActionTypes.SET_CURRENT_WORKSPACE, diff --git a/app/client/src/ce/sagas/ApplicationSagas.tsx b/app/client/src/ce/sagas/ApplicationSagas.tsx index 69d01b77cb6b..112f25c267da 100644 --- a/app/client/src/ce/sagas/ApplicationSagas.tsx +++ b/app/client/src/ce/sagas/ApplicationSagas.tsx @@ -78,6 +78,8 @@ import { getEnableStartSignposting, } from "utils/storage"; import { getFetchedWorkspaces } from "ee/selectors/workspaceSelectors"; +import { FAVORITES_KEY } from "ee/constants/workspaceConstants"; +import { APPLICATIONS_URL } from "constants/routes"; import { fetchPluginFormConfigs, fetchPlugins } from "actions/pluginActions"; import { @@ -426,20 +428,34 @@ export function* handleFetchApplicationError(error: any) { error?.code === ERROR_CODES.PAGE_NOT_FOUND ) { yield put(safeCrashAppRequest(ERROR_CODES.PAGE_NOT_FOUND)); - } else { - yield put({ - type: ReduxActionErrorTypes.FETCH_APPLICATION_ERROR, - payload: { - error, - }, - }); - yield put({ - type: ReduxActionErrorTypes.FETCH_PAGE_LIST_ERROR, - payload: { - error, - }, - }); + + return; } + + if ( + currentUser && + currentUser.email !== ANONYMOUS_USERNAME && + error?.code === ERROR_CODES.PAGE_NOT_FOUND + ) { + history.replace(`${APPLICATIONS_URL}?workspaceId=${FAVORITES_KEY}`); + yield put({ type: ReduxActionTypes.FETCH_FAVORITE_APPLICATIONS_INIT }); + toast.show("Application not found or deleted.", { kind: "error" }); + + return; + } + + yield put({ + type: ReduxActionErrorTypes.FETCH_APPLICATION_ERROR, + payload: { + error, + }, + }); + yield put({ + type: ReduxActionErrorTypes.FETCH_PAGE_LIST_ERROR, + payload: { + error, + }, + }); } export function* setDefaultApplicationPageSaga( diff --git a/app/client/src/sagas/InitSagas.ts b/app/client/src/sagas/InitSagas.ts index 6188f2ae23cf..2d5ae2531248 100644 --- a/app/client/src/sagas/InitSagas.ts +++ b/app/client/src/sagas/InitSagas.ts @@ -64,7 +64,12 @@ import AnalyticsUtil from "ee/utils/AnalyticsUtil"; import { getAppMode } from "ee/selectors/applicationSelectors"; import { getDebuggerErrors } from "selectors/debuggerSelectors"; import { deleteErrorLog } from "actions/debuggerActions"; -import { getCurrentUser } from "actions/authActions"; +import { getCurrentUser as getCurrentUserSelector } from "selectors/usersSelectors"; +import { ANONYMOUS_USERNAME } from "constants/userConstants"; +import history from "utils/history"; +import { APPLICATIONS_URL } from "constants/routes"; +import { FAVORITES_KEY } from "ee/constants/workspaceConstants"; +import { toast } from "@appsmith/ads"; import { getCurrentOrganization } from "ee/actions/organizationActions"; import { @@ -416,6 +421,19 @@ export function* startAppEngine(action: ReduxAction) { if (e instanceof AppEngineApiError) return; + if (e instanceof PageNotFoundError) { + const currentUser: ReturnType = + yield select(getCurrentUserSelector); + + if (currentUser && currentUser.email !== ANONYMOUS_USERNAME) { + history.replace(`${APPLICATIONS_URL}?workspaceId=${FAVORITES_KEY}`); + yield put({ type: ReduxActionTypes.FETCH_FAVORITE_APPLICATIONS_INIT }); + toast.show("Application not found or deleted.", { kind: "error" }); + + return; + } + } + appsmithTelemetry.captureException(e, { errorName: "AppEngineError" }); yield put(safeCrashAppRequest()); } finally { From 0555dfb2ba116a8ce21ac87cb573bc716555a547 Mon Sep 17 00:00:00 2001 From: Stacey Levine Date: Mon, 9 Feb 2026 17:57:05 -0500 Subject: [PATCH 19/26] fix: extract inline JSX handlers into useCallback to fix lint warnings Co-Authored-By: Claude Opus 4.6 --- app/client/src/components/common/Card.tsx | 39 +++++++++++++---------- app/client/src/sagas/FavoritesSagas.ts | 1 + app/client/src/sagas/InitSagas.ts | 1 + 3 files changed, 25 insertions(+), 16 deletions(-) diff --git a/app/client/src/components/common/Card.tsx b/app/client/src/components/common/Card.tsx index 6a98f0eb0344..d96d45ab51a0 100644 --- a/app/client/src/components/common/Card.tsx +++ b/app/client/src/components/common/Card.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from "react"; +import React, { useCallback, useMemo } from "react"; import styled from "styled-components"; import { Card as BlueprintCard, Classes } from "@blueprintjs/core"; import { omit } from "lodash"; @@ -383,20 +383,32 @@ function Card({ return ; }, [isGitModEnabled]); + const handleMouseLeave = useCallback(() => { + // If the menu is not open, then setOverlay false + // Set overlay false on outside click. + !isContextMenuOpen && setShowOverlay(false); + }, [isContextMenuOpen, setShowOverlay]); + + const handleMouseOver = useCallback(() => { + !isFetching && setShowOverlay(true); + }, [isFetching, setShowOverlay]); + + const handleFavoriteClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + onToggleFavorite?.(e); + }, + [onToggleFavorite], + ); + return ( { - // If the menu is not open, then setOverlay false - // Set overlay false on outside click. - !isContextMenuOpen && setShowOverlay(false); - }} - onMouseOver={() => { - !isFetching && setShowOverlay(true); - }} + onMouseLeave={handleMouseLeave} + onMouseOver={handleMouseOver} showOverlay={showOverlay} testId={testId} > @@ -410,11 +422,8 @@ function Card({ { - e.stopPropagation(); - onToggleFavorite(e); - }} > @@ -433,9 +442,7 @@ function Card({
- - {children} - + {children}
)} diff --git a/app/client/src/sagas/FavoritesSagas.ts b/app/client/src/sagas/FavoritesSagas.ts index e53bc0cb00ca..f0eefe853483 100644 --- a/app/client/src/sagas/FavoritesSagas.ts +++ b/app/client/src/sagas/FavoritesSagas.ts @@ -53,6 +53,7 @@ function* toggleFavoriteApplicationSaga( if (!isValidResponse) { yield put(toggleFavoriteApplicationSuccess(applicationId, isFavorited)); yield put({ type: ReduxActionTypes.FETCH_FAVORITE_APPLICATIONS_INIT }); + return; } } catch (error: unknown) { diff --git a/app/client/src/sagas/InitSagas.ts b/app/client/src/sagas/InitSagas.ts index 2d5ae2531248..843f6da31dda 100644 --- a/app/client/src/sagas/InitSagas.ts +++ b/app/client/src/sagas/InitSagas.ts @@ -64,6 +64,7 @@ import AnalyticsUtil from "ee/utils/AnalyticsUtil"; import { getAppMode } from "ee/selectors/applicationSelectors"; import { getDebuggerErrors } from "selectors/debuggerSelectors"; import { deleteErrorLog } from "actions/debuggerActions"; +import { getCurrentUser } from "actions/authActions"; import { getCurrentUser as getCurrentUserSelector } from "selectors/usersSelectors"; import { ANONYMOUS_USERNAME } from "constants/userConstants"; import history from "utils/history"; From 90ab0af7cb106c47c1f4d3d3491ff5bddfc7887e Mon Sep 17 00:00:00 2001 From: Stacey Levine Date: Tue, 10 Feb 2026 09:32:35 -0500 Subject: [PATCH 20/26] fix(favorites): avoid duplicate favorite fetches and add toggle error action Co-authored-by: Cursor --- app/client/src/actions/applicationActions.ts | 8 ++++++++ app/client/src/ce/api/ApplicationApi.tsx | 4 ++-- app/client/src/ce/constants/ReduxActionConstants.tsx | 1 + app/client/src/ce/pages/Applications/index.tsx | 1 - app/client/src/ce/sagas/WorkspaceSagas.ts | 12 ++++++++---- app/client/src/sagas/FavoritesSagas.ts | 3 +++ 6 files changed, 22 insertions(+), 7 deletions(-) diff --git a/app/client/src/actions/applicationActions.ts b/app/client/src/actions/applicationActions.ts index fa004e2e574d..82f62ba0cf65 100644 --- a/app/client/src/actions/applicationActions.ts +++ b/app/client/src/actions/applicationActions.ts @@ -31,3 +31,11 @@ export const fetchFavoriteApplicationsSuccess = ( export const fetchFavoriteApplicationsError = () => ({ type: ReduxActionErrorTypes.FETCH_FAVORITE_APPLICATIONS_ERROR, }); + +export const toggleFavoriteApplicationError = ( + applicationId: string, + error: unknown, +) => ({ + type: ReduxActionErrorTypes.TOGGLE_FAVORITE_APPLICATION_ERROR, + payload: { applicationId, error, show: false }, +}); diff --git a/app/client/src/ce/api/ApplicationApi.tsx b/app/client/src/ce/api/ApplicationApi.tsx index ce9dc75c31f2..04c983e6f862 100644 --- a/app/client/src/ce/api/ApplicationApi.tsx +++ b/app/client/src/ce/api/ApplicationApi.tsx @@ -568,11 +568,11 @@ export class ApplicationApi extends Api { static async toggleFavoriteApplication( applicationId: string, ): Promise> { - return Api.put(`v1/users/applications/${applicationId}/favorite`); + return Api.put(`${ApplicationApi.baseURL}/${applicationId}/favorite`); } static async getFavoriteApplications(): Promise> { - return Api.get("v1/users/favoriteApplications"); + return Api.get(`${ApplicationApi.baseURL}/favoriteApplications`); } } diff --git a/app/client/src/ce/constants/ReduxActionConstants.tsx b/app/client/src/ce/constants/ReduxActionConstants.tsx index f1c6e192a891..731dfdc6fa73 100644 --- a/app/client/src/ce/constants/ReduxActionConstants.tsx +++ b/app/client/src/ce/constants/ReduxActionConstants.tsx @@ -696,6 +696,7 @@ const ApplicationActionErrorTypes = { FETCH_APP_SLUG_SUGGESTION_ERROR: "FETCH_APP_SLUG_SUGGESTION_ERROR", ENABLE_STATIC_URL_ERROR: "ENABLE_STATIC_URL_ERROR", DISABLE_STATIC_URL_ERROR: "DISABLE_STATIC_URL_ERROR", + TOGGLE_FAVORITE_APPLICATION_ERROR: "TOGGLE_FAVORITE_APPLICATION_ERROR", FETCH_FAVORITE_APPLICATIONS_ERROR: "FETCH_FAVORITE_APPLICATIONS_ERROR", }; diff --git a/app/client/src/ce/pages/Applications/index.tsx b/app/client/src/ce/pages/Applications/index.tsx index 47f03e733a96..6ad2cbf4e091 100644 --- a/app/client/src/ce/pages/Applications/index.tsx +++ b/app/client/src/ce/pages/Applications/index.tsx @@ -336,7 +336,6 @@ export function LeftPaneSection(props: { }, dispatch, ); - dispatch(fetchAllWorkspaces()); }; return ( diff --git a/app/client/src/ce/sagas/WorkspaceSagas.ts b/app/client/src/ce/sagas/WorkspaceSagas.ts index f673a9fc1d7d..d741c625aba0 100644 --- a/app/client/src/ce/sagas/WorkspaceSagas.ts +++ b/app/client/src/ce/sagas/WorkspaceSagas.ts @@ -66,12 +66,16 @@ export function* fetchAllWorkspacesSaga( payload: workspaces, }); - // Fetch user's favorite applications to populate favoriteApplicationIds - // This ensures favorites are shown correctly across all workspaces - yield put({ type: ReduxActionTypes.FETCH_FAVORITE_APPLICATIONS_INIT }); - if (action?.payload?.workspaceId || action?.payload?.fetchEntities) { + // When we're also fetching entities for a specific workspace (e.g. the + // Applications page), favorites will be refreshed from within + // fetchEntitiesOfWorkspaceSaga to avoid duplicate API calls. yield call(fetchEntitiesOfWorkspaceSaga, action); + } else { + // Callers that only need the workspace list (e.g. Templates, settings) + // still refresh favorites once here so the virtual Favorites workspace + // and favorite badges stay in sync. + yield put({ type: ReduxActionTypes.FETCH_FAVORITE_APPLICATIONS_INIT }); } } } catch (error) { diff --git a/app/client/src/sagas/FavoritesSagas.ts b/app/client/src/sagas/FavoritesSagas.ts index f0eefe853483..d0d925d00b86 100644 --- a/app/client/src/sagas/FavoritesSagas.ts +++ b/app/client/src/sagas/FavoritesSagas.ts @@ -3,6 +3,7 @@ import type { ReduxAction } from "actions/ReduxActionTypes"; import { ReduxActionTypes } from "ee/constants/ReduxActionConstants"; import ApplicationApi from "ee/api/ApplicationApi"; import { + toggleFavoriteApplicationError, toggleFavoriteApplicationSuccess, fetchFavoriteApplicationsError, fetchFavoriteApplicationsSuccess, @@ -59,6 +60,8 @@ function* toggleFavoriteApplicationSaga( } catch (error: unknown) { yield put(toggleFavoriteApplicationSuccess(applicationId, isFavorited)); + yield put(toggleFavoriteApplicationError(applicationId, error)); + const message = error instanceof Error ? error.message From 396208b49e3f9fa50158cd94f2c6bf254c1e4982 Mon Sep 17 00:00:00 2001 From: Stacey Levine Date: Thu, 12 Feb 2026 09:23:48 -0500 Subject: [PATCH 21/26] refactor(favorites): decouple ApplicationCard permissions and separate favorites loading state Move userPermissions enrichment from ApplicationCard into FavoritesSagas so the component no longer queries the global application list to resolve permissions for favorite apps. This removes a tight coupling between a presentational component and unrelated global state. Also introduce a dedicated isFetchingFavoriteApplications loading flag in selectedWorkspaceReducer so favorites fetches no longer reuse the workspace's isFetchingApplications flag, making Redux state unambiguous during debugging. Co-authored-by: Cursor --- .../uiReducers/selectedWorkspaceReducer.ts | 8 ++++--- .../selectors/selectedWorkspaceSelectors.ts | 6 ++++-- .../pages/Applications/ApplicationCard.tsx | 21 ++++++------------- app/client/src/sagas/FavoritesSagas.ts | 17 +++++++++++++++ 4 files changed, 32 insertions(+), 20 deletions(-) diff --git a/app/client/src/ce/reducers/uiReducers/selectedWorkspaceReducer.ts b/app/client/src/ce/reducers/uiReducers/selectedWorkspaceReducer.ts index 530b8ad691cc..237826ea61b1 100644 --- a/app/client/src/ce/reducers/uiReducers/selectedWorkspaceReducer.ts +++ b/app/client/src/ce/reducers/uiReducers/selectedWorkspaceReducer.ts @@ -21,6 +21,7 @@ export interface SelectedWorkspaceReduxState { packages: Package[]; loadingStates: { isFetchingApplications: boolean; + isFetchingFavoriteApplications: boolean; isFetchingAllUsers: boolean; isFetchingCurrentWorkspace: boolean; }; @@ -36,6 +37,7 @@ export const initialState: SelectedWorkspaceReduxState = { packages: [], loadingStates: { isFetchingApplications: false, + isFetchingFavoriteApplications: false, isFetchingAllUsers: false, isFetchingCurrentWorkspace: false, }, @@ -64,13 +66,13 @@ export const handlers = { [ReduxActionTypes.FETCH_FAVORITE_APPLICATIONS_INIT]: ( draftState: SelectedWorkspaceReduxState, ) => { - draftState.loadingStates.isFetchingApplications = true; + draftState.loadingStates.isFetchingFavoriteApplications = true; }, [ReduxActionTypes.FETCH_FAVORITE_APPLICATIONS_SUCCESS]: ( draftState: SelectedWorkspaceReduxState, action: ReduxAction, ) => { - draftState.loadingStates.isFetchingApplications = false; + draftState.loadingStates.isFetchingFavoriteApplications = false; // Only replace applications when we're in the virtual favorites workspace. // This prevents overwriting a real workspace's applications when favorites @@ -82,7 +84,7 @@ export const handlers = { [ReduxActionErrorTypes.FETCH_FAVORITE_APPLICATIONS_ERROR]: ( draftState: SelectedWorkspaceReduxState, ) => { - draftState.loadingStates.isFetchingApplications = false; + draftState.loadingStates.isFetchingFavoriteApplications = false; }, [ReduxActionTypes.DELETE_APPLICATION_SUCCESS]: ( draftState: SelectedWorkspaceReduxState, diff --git a/app/client/src/ce/selectors/selectedWorkspaceSelectors.ts b/app/client/src/ce/selectors/selectedWorkspaceSelectors.ts index 6ebb7fa27dd1..e9ead20187d2 100644 --- a/app/client/src/ce/selectors/selectedWorkspaceSelectors.ts +++ b/app/client/src/ce/selectors/selectedWorkspaceSelectors.ts @@ -2,7 +2,8 @@ import type { DefaultRootState } from "react-redux"; import { createSelector } from "reselect"; export const getIsFetchingApplications = (state: DefaultRootState): boolean => - state.ui.selectedWorkspace.loadingStates.isFetchingApplications; + state.ui.selectedWorkspace.loadingStates.isFetchingApplications || + state.ui.selectedWorkspace.loadingStates.isFetchingFavoriteApplications; const selectWorkspaceApplications = (state: DefaultRootState) => state.ui.selectedWorkspace.applications; @@ -31,7 +32,8 @@ export const isFetchingUsersOfWorkspace = (state: DefaultRootState): boolean => export const selectedWorkspaceLoadingStates = (state: DefaultRootState) => { return { isFetchingApplications: - state.ui.selectedWorkspace.loadingStates.isFetchingApplications, + state.ui.selectedWorkspace.loadingStates.isFetchingApplications || + state.ui.selectedWorkspace.loadingStates.isFetchingFavoriteApplications, isFetchingAllUsers: state.ui.selectedWorkspace.loadingStates.isFetchingAllUsers, isFetchingCurrentWorkspace: diff --git a/app/client/src/pages/Applications/ApplicationCard.tsx b/app/client/src/pages/Applications/ApplicationCard.tsx index b04583d10c8b..fcf59b3952b4 100644 --- a/app/client/src/pages/Applications/ApplicationCard.tsx +++ b/app/client/src/pages/Applications/ApplicationCard.tsx @@ -40,7 +40,6 @@ import type { UpdateApplicationPayload, } from "ee/api/ApplicationApi"; import { - getApplicationList, getIsSavingAppName, getIsErroredSavingAppName, } from "ee/selectors/applicationSelectors"; @@ -107,7 +106,6 @@ export function ApplicationCard(props: ApplicationCardProps) { const theme = useContext(ThemeContext); const isSavingName = useSelector(getIsSavingAppName); const isErroredSavingName = useSelector(getIsErroredSavingAppName); - const allApplications = useSelector(getApplicationList); const currentUser = useSelector(getCurrentUserSelector); const initialsAndColorCode = getInitialsAndColorCode( application.name, @@ -214,29 +212,22 @@ export function ApplicationCard(props: ApplicationCardProps) { const appIcon = (application.icon || getApplicationIcon(applicationId)) as AppIconName; - // Some views (like Favorites) may receive applications without populated userPermissions. - // Fall back to the main application list so permissions match the standard workspace cards. - const fallbackApp = allApplications?.find((app) => app.id === applicationId); - const effectiveUserPermissions = - (application.userPermissions && application.userPermissions.length > 0 - ? application.userPermissions - : fallbackApp?.userPermissions) ?? []; + // Permissions are enriched upstream (e.g. in FavoritesSagas); no local lookup needed. + const userPermissions = application.userPermissions ?? []; const hasEditPermission = isPermitted( - effectiveUserPermissions, + userPermissions, PERMISSION_TYPE.MANAGE_APPLICATION, ); const hasReadPermission = isPermitted( - effectiveUserPermissions, + userPermissions, PERMISSION_TYPE.READ_APPLICATION, ); const hasExportPermission = isPermitted( - effectiveUserPermissions, + userPermissions, PERMISSION_TYPE.EXPORT_APPLICATION, ); - const hasDeletePermission = hasDeleteApplicationPermission( - effectiveUserPermissions, - ); + const hasDeletePermission = hasDeleteApplicationPermission(userPermissions); const updateColor = (color: string) => { props.update && diff --git a/app/client/src/sagas/FavoritesSagas.ts b/app/client/src/sagas/FavoritesSagas.ts index d0d925d00b86..92115dcd7c16 100644 --- a/app/client/src/sagas/FavoritesSagas.ts +++ b/app/client/src/sagas/FavoritesSagas.ts @@ -13,6 +13,7 @@ import { toast } from "@appsmith/ads"; import { findDefaultPage } from "pages/utils"; import type { ApplicationPayload } from "entities/Application"; import type { ApiResponse } from "api/ApiResponses"; +import { getApplicationList } from "ee/selectors/applicationSelectors"; const MAX_FAVORITE_APPLICATIONS_LIMIT = 50; @@ -81,14 +82,30 @@ function* fetchFavoriteApplicationsSaga() { if (isValidResponse) { const rawApplications = response.data; + // Build a permissions lookup from the main application list so favorite + // apps returned by the API (which may omit permissions) are enriched. + const allApplications: ApplicationPayload[] = + (yield select(getApplicationList)) ?? []; + const permissionsById = new Map(); + + for (const app of allApplications) { + if (app.userPermissions?.length) { + permissionsById.set(app.id, app.userPermissions); + } + } + const applications = rawApplications.map( (application: ApplicationPayload) => { const defaultPage = findDefaultPage(application.pages); + const userPermissions = application.userPermissions?.length + ? application.userPermissions + : permissionsById.get(application.id) ?? []; return { ...application, defaultPageId: defaultPage?.id, defaultBasePageId: defaultPage?.baseId, + userPermissions, }; }, ); From a674c75291aa83055e8fe40990013ed7996a3376 Mon Sep 17 00:00:00 2001 From: Stacey Levine Date: Fri, 13 Feb 2026 11:08:51 -0500 Subject: [PATCH 22/26] fix(favorites): correct API URLs, normalize reducer IDs, and enforce limit atomically Client API methods were hitting the wrong controller (ApplicationController instead of UserController). The toggle reducer was mixing baseId/id into favoriteApplicationIds causing mismatches with the fetch reducer. The in-memory size check in toggleFavoriteApplication could race with concurrent addToSet calls, allowing the limit to be exceeded. - Point client favorites API calls to v1/users/... to match UserControllerCE - Resolve canonical app.id in TOGGLE_FAVORITE_APPLICATION_SUCCESS before mutating favoriteApplicationIds so it stays consistent with FETCH - Add addFavoriteApplicationForUserIfUnderLimit repo method using a conditional MongoDB update (array-index existence trick + $addToSet) - Replace in-memory size check with atomic DB-level enforcement Co-Authored-By: Claude Opus 4.6 --- app/client/src/ce/api/ApplicationApi.tsx | 4 +-- .../uiReducers/applicationsReducer.tsx | 8 +++-- .../ce/CustomUserDataRepositoryCE.java | 14 ++++++++ .../ce/CustomUserDataRepositoryCEImpl.java | 13 +++++++ .../services/ce/UserDataServiceCEImpl.java | 34 +++++++++++-------- 5 files changed, 55 insertions(+), 18 deletions(-) diff --git a/app/client/src/ce/api/ApplicationApi.tsx b/app/client/src/ce/api/ApplicationApi.tsx index 04c983e6f862..ce9dc75c31f2 100644 --- a/app/client/src/ce/api/ApplicationApi.tsx +++ b/app/client/src/ce/api/ApplicationApi.tsx @@ -568,11 +568,11 @@ export class ApplicationApi extends Api { static async toggleFavoriteApplication( applicationId: string, ): Promise> { - return Api.put(`${ApplicationApi.baseURL}/${applicationId}/favorite`); + return Api.put(`v1/users/applications/${applicationId}/favorite`); } static async getFavoriteApplications(): Promise> { - return Api.get(`${ApplicationApi.baseURL}/favoriteApplications`); + return Api.get("v1/users/favoriteApplications"); } } diff --git a/app/client/src/ce/reducers/uiReducers/applicationsReducer.tsx b/app/client/src/ce/reducers/uiReducers/applicationsReducer.tsx index 7773cf99b762..48acb8f110cc 100644 --- a/app/client/src/ce/reducers/uiReducers/applicationsReducer.tsx +++ b/app/client/src/ce/reducers/uiReducers/applicationsReducer.tsx @@ -888,12 +888,16 @@ export const handlers = { action: ReduxAction<{ applicationId: string; isFavorited: boolean }>, ) => { const { applicationId, isFavorited } = action.payload; + const matchedApp = state.applicationList.find( + (app) => (app.baseId || app.id) === applicationId, + ); + const canonicalId = matchedApp ? matchedApp.id : applicationId; return { ...state, favoriteApplicationIds: isFavorited - ? [...state.favoriteApplicationIds, applicationId] - : state.favoriteApplicationIds.filter((id) => id !== applicationId), + ? [...state.favoriteApplicationIds, canonicalId] + : state.favoriteApplicationIds.filter((id) => id !== canonicalId), applicationList: state.applicationList.map((app) => (app.baseId || app.id) === applicationId ? { ...app, isFavorited } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/ce/CustomUserDataRepositoryCE.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/ce/CustomUserDataRepositoryCE.java index 280565203790..974f3290a7e4 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/ce/CustomUserDataRepositoryCE.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/ce/CustomUserDataRepositoryCE.java @@ -23,6 +23,20 @@ public interface CustomUserDataRepositoryCE extends AppsmithRepository */ Mono addFavoriteApplicationForUser(String userId, String applicationId); + /** + * Atomically add an application to a user's favorites list only if the list + * has fewer than {@code maxLimit} entries. Uses a single conditional MongoDB + * update ({@code $addToSet} + array-index existence check) so the limit + * cannot be exceeded by concurrent requests. + * + * @param userId ID of the user whose favorites list should be updated + * @param applicationId ID of the application to add to favorites + * @param maxLimit Maximum allowed size of the favorites list + * @return Number of matched documents: 1 if the update was applied, + * 0 if the array already had {@code maxLimit} or more entries + */ + Mono addFavoriteApplicationForUserIfUnderLimit(String userId, String applicationId, int maxLimit); + /** * Remove an application from a single user's favorites list using an atomic update. * diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/ce/CustomUserDataRepositoryCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/ce/CustomUserDataRepositoryCEImpl.java index d3c83fe2abc3..affa94cef257 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/ce/CustomUserDataRepositoryCEImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/ce/CustomUserDataRepositoryCEImpl.java @@ -6,6 +6,7 @@ import com.appsmith.server.helpers.ce.bridge.BridgeUpdate; import com.appsmith.server.projections.UserRecentlyUsedEntitiesProjection; import com.appsmith.server.repositories.BaseAppsmithRepositoryImpl; +import org.springframework.data.mongodb.core.query.Criteria; import org.springframework.util.CollectionUtils; import reactor.core.publisher.Mono; @@ -64,6 +65,18 @@ public Mono addFavoriteApplicationForUser(String userId, String applicatio .then(); } + @Override + public Mono addFavoriteApplicationForUserIfUnderLimit(String userId, String applicationId, int maxLimit) { + BridgeUpdate update = new BridgeUpdate(); + update.addToSet(UserData.Fields.favoriteApplicationIds, applicationId); + // Array-index existence trick: "field.{N-1}" not existing means the array has fewer than N elements. + Criteria criteria = Criteria.where(UserData.Fields.userId) + .is(userId) + .and(UserData.Fields.favoriteApplicationIds + "." + (maxLimit - 1)) + .exists(false); + return queryBuilder().criteria(criteria).updateFirst(update); + } + @Override public Mono removeFavoriteApplicationForUser(String userId, String applicationId) { BridgeUpdate update = new BridgeUpdate(); diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserDataServiceCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserDataServiceCEImpl.java index 4682c1f6422a..7015a40d6035 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserDataServiceCEImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserDataServiceCEImpl.java @@ -464,28 +464,34 @@ public Mono toggleFavoriteApplication(String applicationId) { .switchIfEmpty(Mono.error(new AppsmithException( AppsmithError.NO_RESOURCE_FOUND, FieldName.APPLICATION, applicationId))) .flatMap(application -> { - if (finalFavorites.contains(applicationId)) { - return Mono.just(userData); - } - if (finalFavorites.size() >= MAX_FAVORITE_APPLICATIONS_LIMIT) { - return Mono.error(new AppsmithException( - AppsmithError.INVALID_PARAMETER, - String.format( - "Maximum favorite applications limit (%d) reached. Please remove some favorites before adding new ones.", - MAX_FAVORITE_APPLICATIONS_LIMIT))); - } - // For new users who don't yet have a persisted UserData document, fall back to save. if (userData.getId() == null) { + if (finalFavorites.size() >= MAX_FAVORITE_APPLICATIONS_LIMIT) { + return Mono.error(new AppsmithException( + AppsmithError.INVALID_PARAMETER, + String.format( + "Maximum favorite applications limit (%d) reached. Please remove some favorites before adding new ones.", + MAX_FAVORITE_APPLICATIONS_LIMIT))); + } finalFavorites.add(applicationId); userData.setFavoriteApplicationIds(finalFavorites); return repository.save(userData); } - // For existing users, use an atomic addToSet update to avoid lost updates + // For existing users, use a conditional atomic update that enforces the limit return repository - .addFavoriteApplicationForUser(user.getId(), applicationId) - .then(getForUser(user.getId())); + .addFavoriteApplicationForUserIfUnderLimit( + user.getId(), applicationId, MAX_FAVORITE_APPLICATIONS_LIMIT) + .flatMap(matchedCount -> { + if (matchedCount == 0) { + return Mono.error(new AppsmithException( + AppsmithError.INVALID_PARAMETER, + String.format( + "Maximum favorite applications limit (%d) reached. Please remove some favorites before adding new ones.", + MAX_FAVORITE_APPLICATIONS_LIMIT))); + } + return getForUser(user.getId()); + }); }); }); } From 26eca7337edb262f942ccb36a78db18b20ca0e58 Mon Sep 17 00:00:00 2001 From: Stacey Levine Date: Fri, 13 Feb 2026 12:29:06 -0500 Subject: [PATCH 23/26] fix(favorites): scope 404-to-favorites redirect to only favorited apps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The blanket redirect of all logged-in-user 404s to the favorites page broke the standard error page (`.t--error-page-title`) for users who simply lack access to an app. This caused the ShareAppTests_Spec "validate public access disable" test to fail. - InitSagas: check getFavoriteApplicationIds before redirecting; only redirect if the app is actually in the user's favorites list, otherwise fall through to safeCrashAppRequest (error page) - ApplicationSagas: restore original handleFetchApplicationError behavior — anonymous users get the crash page, logged-in users get FETCH_APPLICATION_ERROR + FETCH_PAGE_LIST_ERROR (error page) Co-Authored-By: Claude Opus 4.6 --- app/client/src/ce/sagas/ApplicationSagas.tsx | 42 ++++++-------------- app/client/src/sagas/InitSagas.ts | 23 ++++++++--- 2 files changed, 30 insertions(+), 35 deletions(-) diff --git a/app/client/src/ce/sagas/ApplicationSagas.tsx b/app/client/src/ce/sagas/ApplicationSagas.tsx index 112f25c267da..69d01b77cb6b 100644 --- a/app/client/src/ce/sagas/ApplicationSagas.tsx +++ b/app/client/src/ce/sagas/ApplicationSagas.tsx @@ -78,8 +78,6 @@ import { getEnableStartSignposting, } from "utils/storage"; import { getFetchedWorkspaces } from "ee/selectors/workspaceSelectors"; -import { FAVORITES_KEY } from "ee/constants/workspaceConstants"; -import { APPLICATIONS_URL } from "constants/routes"; import { fetchPluginFormConfigs, fetchPlugins } from "actions/pluginActions"; import { @@ -428,34 +426,20 @@ export function* handleFetchApplicationError(error: any) { error?.code === ERROR_CODES.PAGE_NOT_FOUND ) { yield put(safeCrashAppRequest(ERROR_CODES.PAGE_NOT_FOUND)); - - return; - } - - if ( - currentUser && - currentUser.email !== ANONYMOUS_USERNAME && - error?.code === ERROR_CODES.PAGE_NOT_FOUND - ) { - history.replace(`${APPLICATIONS_URL}?workspaceId=${FAVORITES_KEY}`); - yield put({ type: ReduxActionTypes.FETCH_FAVORITE_APPLICATIONS_INIT }); - toast.show("Application not found or deleted.", { kind: "error" }); - - return; + } else { + yield put({ + type: ReduxActionErrorTypes.FETCH_APPLICATION_ERROR, + payload: { + error, + }, + }); + yield put({ + type: ReduxActionErrorTypes.FETCH_PAGE_LIST_ERROR, + payload: { + error, + }, + }); } - - yield put({ - type: ReduxActionErrorTypes.FETCH_APPLICATION_ERROR, - payload: { - error, - }, - }); - yield put({ - type: ReduxActionErrorTypes.FETCH_PAGE_LIST_ERROR, - payload: { - error, - }, - }); } export function* setDefaultApplicationPageSaga( diff --git a/app/client/src/sagas/InitSagas.ts b/app/client/src/sagas/InitSagas.ts index 843f6da31dda..279f0e261223 100644 --- a/app/client/src/sagas/InitSagas.ts +++ b/app/client/src/sagas/InitSagas.ts @@ -61,7 +61,10 @@ import { import { APP_MODE } from "../entities/App"; import { GIT_BRANCH_QUERY_KEY } from "../constants/routes"; import AnalyticsUtil from "ee/utils/AnalyticsUtil"; -import { getAppMode } from "ee/selectors/applicationSelectors"; +import { + getAppMode, + getFavoriteApplicationIds, +} from "ee/selectors/applicationSelectors"; import { getDebuggerErrors } from "selectors/debuggerSelectors"; import { deleteErrorLog } from "actions/debuggerActions"; import { getCurrentUser } from "actions/authActions"; @@ -427,11 +430,19 @@ export function* startAppEngine(action: ReduxAction) { yield select(getCurrentUserSelector); if (currentUser && currentUser.email !== ANONYMOUS_USERNAME) { - history.replace(`${APPLICATIONS_URL}?workspaceId=${FAVORITES_KEY}`); - yield put({ type: ReduxActionTypes.FETCH_FAVORITE_APPLICATIONS_INIT }); - toast.show("Application not found or deleted.", { kind: "error" }); - - return; + // Only redirect to favorites page if the app was actually favorited; + // otherwise fall through to safeCrashAppRequest to show the error page. + const favoriteIds: string[] = yield select(getFavoriteApplicationIds); + + if (favoriteIds.includes(action.payload.applicationId ?? "")) { + history.replace(`${APPLICATIONS_URL}?workspaceId=${FAVORITES_KEY}`); + yield put({ + type: ReduxActionTypes.FETCH_FAVORITE_APPLICATIONS_INIT, + }); + toast.show("Application not found or deleted.", { kind: "error" }); + + return; + } } } From 397b831ba4afbf9266f8197b709e8638fba84e8a Mon Sep 17 00:00:00 2001 From: Stacey Levine Date: Fri, 13 Feb 2026 12:57:44 -0500 Subject: [PATCH 24/26] fix(favorites): make toggle fully atomic and handle new-user save races MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The in-memory favorites.contains() check was stale under concurrency, and the new-user repository.save() could race to create duplicate UserData documents. - removeFavoriteApplicationForUser now returns Mono and adds an array-contains criteria so matchedCount signals whether a removal actually occurred - toggleFavoriteApplication for existing users: tries atomic remove first, inspects the DB result, then falls back to atomic add — no in-memory snapshot decision - toggleFavoriteApplication for new users: both save() calls catch DuplicateKeyException and retry through the atomic existing-user path (removeFavoriteApplicationForUser / addFavoriteApplicationFor- UserIfUnderLimit) Co-Authored-By: Claude Opus 4.6 --- .../ce/CustomUserDataRepositoryCE.java | 10 +- .../ce/CustomUserDataRepositoryCEImpl.java | 13 +- .../services/ce/UserDataServiceCEImpl.java | 116 +++++++++++------- 3 files changed, 89 insertions(+), 50 deletions(-) diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/ce/CustomUserDataRepositoryCE.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/ce/CustomUserDataRepositoryCE.java index 974f3290a7e4..3cc8db080c10 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/ce/CustomUserDataRepositoryCE.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/ce/CustomUserDataRepositoryCE.java @@ -38,11 +38,15 @@ public interface CustomUserDataRepositoryCE extends AppsmithRepository Mono addFavoriteApplicationForUserIfUnderLimit(String userId, String applicationId, int maxLimit); /** - * Remove an application from a single user's favorites list using an atomic update. + * Atomically remove an application from a user's favorites list only if it + * is present. The query matches the user document only when the array + * contains {@code applicationId}, so the returned count doubles as a + * "was-it-actually-removed?" signal. * * @param userId ID of the user whose favorites list should be updated * @param applicationId ID of the application to remove from favorites - * @return Completion signal when the update operation finishes + * @return Number of matched documents: 1 if the application was removed, + * 0 if it was not in the favorites list */ - Mono removeFavoriteApplicationForUser(String userId, String applicationId); + Mono removeFavoriteApplicationForUser(String userId, String applicationId); } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/ce/CustomUserDataRepositoryCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/ce/CustomUserDataRepositoryCEImpl.java index affa94cef257..a439e8dc94c2 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/ce/CustomUserDataRepositoryCEImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/ce/CustomUserDataRepositoryCEImpl.java @@ -78,12 +78,15 @@ public Mono addFavoriteApplicationForUserIfUnderLimit(String userId, St } @Override - public Mono removeFavoriteApplicationForUser(String userId, String applicationId) { + public Mono removeFavoriteApplicationForUser(String userId, String applicationId) { BridgeUpdate update = new BridgeUpdate(); update.pull(UserData.Fields.favoriteApplicationIds, applicationId); - return queryBuilder() - .criteria(Bridge.equal(UserData.Fields.userId, userId)) - .updateFirst(update) - .then(); + // Only match if the array actually contains the applicationId so that + // matchedCount == 1 means "removed" and 0 means "was not present". + Criteria criteria = Criteria.where(UserData.Fields.userId) + .is(userId) + .and(UserData.Fields.favoriteApplicationIds) + .is(applicationId); + return queryBuilder().criteria(criteria).updateFirst(update); } } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserDataServiceCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserDataServiceCEImpl.java index 7015a40d6035..1fe5cc1b6d77 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserDataServiceCEImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserDataServiceCEImpl.java @@ -29,6 +29,7 @@ import jakarta.validation.Validator; import org.apache.commons.lang3.ObjectUtils; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DuplicateKeyException; import org.springframework.http.codec.multipart.Part; import org.springframework.util.StringUtils; import org.springframework.web.server.ServerWebExchange; @@ -439,33 +440,34 @@ public Mono toggleFavoriteApplication(String applicationId) { User user = tuple.getT1(); UserData userData = tuple.getT2(); - List favorites = userData.getFavoriteApplicationIds(); - if (favorites == null) { - favorites = new ArrayList<>(); - } - - boolean isRemoving = favorites.contains(applicationId); - - if (isRemoving) { - // Use an atomic pull update so concurrent toggles don't overwrite each other - return repository - .removeFavoriteApplicationForUser(user.getId(), applicationId) - .then(getForUser(user.getId())); - } - - // When adding a favorite, verify user has access to the application - AclPermission readPermission = applicationPermission.getReadPermission(); - List finalFavorites = favorites; - return applicationRepository - .queryBuilder() - .criteria(Bridge.equal(Application.Fields.id, applicationId)) - .permission(readPermission) - .first() - .switchIfEmpty(Mono.error(new AppsmithException( - AppsmithError.NO_RESOURCE_FOUND, FieldName.APPLICATION, applicationId))) - .flatMap(application -> { - // For new users who don't yet have a persisted UserData document, fall back to save. - if (userData.getId() == null) { + // For new users without a persisted UserData document the atomic + // repo operations will not match anything, so fall back to save. + // If a concurrent request creates the document first (DuplicateKeyException), + // retry through the atomic existing-user path. + if (userData.getId() == null) { + List favorites = userData.getFavoriteApplicationIds(); + if (favorites == null) { + favorites = new ArrayList<>(); + } + + if (favorites.remove(applicationId)) { + userData.setFavoriteApplicationIds(favorites); + return repository.save(userData).onErrorResume(DuplicateKeyException.class, ex -> repository + .removeFavoriteApplicationForUser(user.getId(), applicationId) + .flatMap(count -> getForUser(user.getId()))); + } + + // Adding — verify access first + AclPermission readPermission = applicationPermission.getReadPermission(); + List finalFavorites = favorites; + return applicationRepository + .queryBuilder() + .criteria(Bridge.equal(Application.Fields.id, applicationId)) + .permission(readPermission) + .first() + .switchIfEmpty(Mono.error(new AppsmithException( + AppsmithError.NO_RESOURCE_FOUND, FieldName.APPLICATION, applicationId))) + .flatMap(application -> { if (finalFavorites.size() >= MAX_FAVORITE_APPLICATIONS_LIMIT) { return Mono.error(new AppsmithException( AppsmithError.INVALID_PARAMETER, @@ -475,23 +477,53 @@ public Mono toggleFavoriteApplication(String applicationId) { } finalFavorites.add(applicationId); userData.setFavoriteApplicationIds(finalFavorites); - return repository.save(userData); + return repository.save(userData).onErrorResume(DuplicateKeyException.class, ex -> repository + .addFavoriteApplicationForUserIfUnderLimit( + user.getId(), applicationId, MAX_FAVORITE_APPLICATIONS_LIMIT) + .flatMap(matchedCount -> { + if (matchedCount == 0) { + return Mono.error(new AppsmithException( + AppsmithError.INVALID_PARAMETER, + String.format( + "Maximum favorite applications limit (%d) reached. Please remove some favorites before adding new ones.", + MAX_FAVORITE_APPLICATIONS_LIMIT))); + } + return getForUser(user.getId()); + })); + }); + } + + // For existing users, let the DB decide: try an atomic remove first. + return repository + .removeFavoriteApplicationForUser(user.getId(), applicationId) + .flatMap(removedCount -> { + if (removedCount > 0) { + // Was in favorites and has been removed. + return getForUser(user.getId()); } - // For existing users, use a conditional atomic update that enforces the limit - return repository - .addFavoriteApplicationForUserIfUnderLimit( - user.getId(), applicationId, MAX_FAVORITE_APPLICATIONS_LIMIT) - .flatMap(matchedCount -> { - if (matchedCount == 0) { - return Mono.error(new AppsmithException( - AppsmithError.INVALID_PARAMETER, - String.format( - "Maximum favorite applications limit (%d) reached. Please remove some favorites before adding new ones.", - MAX_FAVORITE_APPLICATIONS_LIMIT))); - } - return getForUser(user.getId()); - }); + // Not in favorites — add it after verifying access. + AclPermission readPermission = applicationPermission.getReadPermission(); + return applicationRepository + .queryBuilder() + .criteria(Bridge.equal(Application.Fields.id, applicationId)) + .permission(readPermission) + .first() + .switchIfEmpty(Mono.error(new AppsmithException( + AppsmithError.NO_RESOURCE_FOUND, FieldName.APPLICATION, applicationId))) + .flatMap(application -> repository + .addFavoriteApplicationForUserIfUnderLimit( + user.getId(), applicationId, MAX_FAVORITE_APPLICATIONS_LIMIT) + .flatMap(matchedCount -> { + if (matchedCount == 0) { + return Mono.error(new AppsmithException( + AppsmithError.INVALID_PARAMETER, + String.format( + "Maximum favorite applications limit (%d) reached. Please remove some favorites before adding new ones.", + MAX_FAVORITE_APPLICATIONS_LIMIT))); + } + return getForUser(user.getId()); + })); }); }); } From 72a8032d15a7f41098b269d6b0ee9044a9143fb8 Mon Sep 17 00:00:00 2001 From: Stacey Levine Date: Fri, 13 Feb 2026 13:11:41 -0500 Subject: [PATCH 25/26] fix(favorites): use unfiltered app list for permissions lookup getApplicationList is filtered by the active search keyword, so the permissions map built in fetchFavoriteApplicationsSaga could miss apps that don't match the current search query. Use getApplications instead to include all loaded apps. Co-Authored-By: Claude Opus 4.6 --- app/client/src/sagas/FavoritesSagas.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/client/src/sagas/FavoritesSagas.ts b/app/client/src/sagas/FavoritesSagas.ts index 92115dcd7c16..1df8d611e2ce 100644 --- a/app/client/src/sagas/FavoritesSagas.ts +++ b/app/client/src/sagas/FavoritesSagas.ts @@ -13,7 +13,7 @@ import { toast } from "@appsmith/ads"; import { findDefaultPage } from "pages/utils"; import type { ApplicationPayload } from "entities/Application"; import type { ApiResponse } from "api/ApiResponses"; -import { getApplicationList } from "ee/selectors/applicationSelectors"; +import { getApplications } from "ee/selectors/applicationSelectors"; const MAX_FAVORITE_APPLICATIONS_LIMIT = 50; @@ -85,7 +85,7 @@ function* fetchFavoriteApplicationsSaga() { // Build a permissions lookup from the main application list so favorite // apps returned by the API (which may omit permissions) are enriched. const allApplications: ApplicationPayload[] = - (yield select(getApplicationList)) ?? []; + (yield select(getApplications)) ?? []; const permissionsById = new Map(); for (const app of allApplications) { From 56d760c33061ec60f297e1336a324c603465ad54 Mon Sep 17 00:00:00 2001 From: Stacey Levine Date: Fri, 13 Feb 2026 15:31:25 -0500 Subject: [PATCH 26/26] fix(favorites): decouple favorites loading from main app-loading indicator Favorites fetch was OR'd into getIsFetchingApplications, causing app cards to flash/disappear when the background favorites refresh set isFetchingFavoriteApplications=true after entity loading completed. Now only the Favorites virtual workspace shows a skeleton during favorites fetch; regular workspaces load favorites silently. Co-Authored-By: Claude Opus 4.6 --- app/client/src/ce/pages/Applications/index.tsx | 9 +++++++-- .../src/ce/selectors/selectedWorkspaceSelectors.ts | 9 ++++++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/app/client/src/ce/pages/Applications/index.tsx b/app/client/src/ce/pages/Applications/index.tsx index 6ad2cbf4e091..9aeb86c4519a 100644 --- a/app/client/src/ce/pages/Applications/index.tsx +++ b/app/client/src/ce/pages/Applications/index.tsx @@ -32,10 +32,10 @@ import { getApplicationSearchKeyword, getCreateApplicationError, getCurrentApplicationIdForCreateNewApp, + getHasFavorites, getIsCreatingApplication, getIsDeletingApplication, } from "ee/selectors/applicationSelectors"; -import { getHasFavorites } from "ee/selectors/applicationSelectors"; import { DEFAULT_FAVORITES_WORKSPACE, FAVORITES_KEY, @@ -104,6 +104,7 @@ import { getApplicationsOfWorkspace, getCurrentWorkspaceId, getIsFetchingApplications, + getIsFetchingFavoriteApplications, } from "ee/selectors/selectedWorkspaceSelectors"; import { getIsFetchingMyOrganizations, @@ -701,6 +702,7 @@ export function ApplicationsSection(props: any) { const isSavingWorkspaceInfo = useSelector(getIsSavingWorkspaceInfo); const isFetchingWorkspaces = useSelector(getIsFetchingWorkspaces); const isFetchingApplications = useSelector(getIsFetchingApplications); + const isFetchingFavoriteApps = useSelector(getIsFetchingFavoriteApplications); const isDeletingWorkspace = useSelector(getIsDeletingWorkspace); const { isFetchingPackages } = usePackage(); const creatingApplicationMap = useSelector(getIsCreatingApplication); @@ -735,7 +737,10 @@ export function ApplicationsSection(props: any) { dispatch(updateApplication(id, data)); }; const isLoadingResources = - isFetchingWorkspaces || isFetchingApplications || isFetchingPackages; + isFetchingWorkspaces || + isFetchingApplications || + isFetchingPackages || + (activeWorkspaceId === FAVORITES_KEY && isFetchingFavoriteApps); const isGACEnabled = useFeatureFlag(FEATURE_FLAG.license_gac_enabled); const [ isCreateAppFromTemplateModalOpen, diff --git a/app/client/src/ce/selectors/selectedWorkspaceSelectors.ts b/app/client/src/ce/selectors/selectedWorkspaceSelectors.ts index e9ead20187d2..dd7946e43410 100644 --- a/app/client/src/ce/selectors/selectedWorkspaceSelectors.ts +++ b/app/client/src/ce/selectors/selectedWorkspaceSelectors.ts @@ -2,7 +2,11 @@ import type { DefaultRootState } from "react-redux"; import { createSelector } from "reselect"; export const getIsFetchingApplications = (state: DefaultRootState): boolean => - state.ui.selectedWorkspace.loadingStates.isFetchingApplications || + state.ui.selectedWorkspace.loadingStates.isFetchingApplications; + +export const getIsFetchingFavoriteApplications = ( + state: DefaultRootState, +): boolean => state.ui.selectedWorkspace.loadingStates.isFetchingFavoriteApplications; const selectWorkspaceApplications = (state: DefaultRootState) => @@ -32,8 +36,7 @@ export const isFetchingUsersOfWorkspace = (state: DefaultRootState): boolean => export const selectedWorkspaceLoadingStates = (state: DefaultRootState) => { return { isFetchingApplications: - state.ui.selectedWorkspace.loadingStates.isFetchingApplications || - state.ui.selectedWorkspace.loadingStates.isFetchingFavoriteApplications, + state.ui.selectedWorkspace.loadingStates.isFetchingApplications, isFetchingAllUsers: state.ui.selectedWorkspace.loadingStates.isFetchingAllUsers, isFetchingCurrentWorkspace: