From 6cdb58a036ef68c06741a18dc3e67fec6d8fcf84 Mon Sep 17 00:00:00 2001 From: Stacey Levine Date: Mon, 22 Jun 2026 22:23:20 -0400 Subject: [PATCH 1/7] feat(client): copy an API/query/JS object to another application (#41919) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a "Copy to application" right-click option on actions (APIs/queries) and JS objects in the editor entity explorer. It opens a modal to pick a target Workspace -> Application -> Page (filtered to ones the user can edit) and copies the entity there, reusing the existing partial export/import endpoints — no server changes. Client orchestration: export the single entity via the partial-export endpoint, wrap the returned ApplicationJson as an in-memory File, and import it into the chosen target via the partial-import endpoint. Datasource reconciliation and name-collision refactoring are handled server-side by the import; the user stays in the current editor and sees a success toast. Notable choices: - Target-workspace apps are fetched into a dedicated ui.copyEntityToApp slice rather than ui.selectedWorkspace.applications, which reflects the current editor's workspace and must not be clobbered mid-session. - Saga lives in ce/sagas with an ee/ passthrough stub for an EE override seam (git-branched apps), matching the NavigationSagas convention. - Client permission filtering is UX-only; the server re-authorizes every target id (MANAGE_PAGES on the page, edit on the app/workspace). Reviewed via the council (architect, security, QA, data-migration, UX, DX, product) — all APPROVE WITH RISKS, no blockers. Addressed findings in-PR: error-path + invalid-import tests, aria-labels on the selects, destination page in the success toast, an informational callout that datasources/referenced queries aren't copied, and a COPY_ENTITY_TO_APP analytics event. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/client/src/actions/copyToAppActions.ts | 17 + .../src/ce/constants/ReduxActionConstants.tsx | 10 + app/client/src/ce/constants/messages.ts | 27 ++ app/client/src/ce/reducers/index.tsx | 2 + .../src/ce/reducers/uiReducers/index.tsx | 2 + .../src/ce/sagas/CopyToAppSagas.test.ts | 295 +++++++++++++++++ app/client/src/ce/sagas/CopyToAppSagas.ts | 203 ++++++++++++ app/client/src/ce/sagas/index.tsx | 2 + app/client/src/ce/utils/analyticsUtilTypes.ts | 1 + app/client/src/ee/sagas/CopyToAppSagas.ts | 2 + .../Actions/ActionEntityContextMenu.tsx | 33 +- .../CopyToApp/CopyEntityToAppModal.tsx | 310 ++++++++++++++++++ .../pages/Editor/Explorer/CopyToApp/types.ts | 16 + .../Explorer/Files/FilesContextProvider.tsx | 2 + .../JSActions/JSActionContextMenu.tsx | 36 +- .../uiReducers/copyEntityToAppReducer.test.ts | 81 +++++ .../uiReducers/copyEntityToAppReducer.ts | 82 +++++ .../src/selectors/copyToAppSelectors.ts | 13 + 18 files changed, 1123 insertions(+), 11 deletions(-) create mode 100644 app/client/src/actions/copyToAppActions.ts create mode 100644 app/client/src/ce/sagas/CopyToAppSagas.test.ts create mode 100644 app/client/src/ce/sagas/CopyToAppSagas.ts create mode 100644 app/client/src/ee/sagas/CopyToAppSagas.ts create mode 100644 app/client/src/pages/Editor/Explorer/CopyToApp/CopyEntityToAppModal.tsx create mode 100644 app/client/src/pages/Editor/Explorer/CopyToApp/types.ts create mode 100644 app/client/src/reducers/uiReducers/copyEntityToAppReducer.test.ts create mode 100644 app/client/src/reducers/uiReducers/copyEntityToAppReducer.ts create mode 100644 app/client/src/selectors/copyToAppSelectors.ts diff --git a/app/client/src/actions/copyToAppActions.ts b/app/client/src/actions/copyToAppActions.ts new file mode 100644 index 000000000000..167b675c8e99 --- /dev/null +++ b/app/client/src/actions/copyToAppActions.ts @@ -0,0 +1,17 @@ +import { ReduxActionTypes } from "ee/constants/ReduxActionConstants"; +import type { CopyEntityToAppPayload } from "pages/Editor/Explorer/CopyToApp/types"; + +export const copyActionToApp = (payload: CopyEntityToAppPayload) => ({ + type: ReduxActionTypes.COPY_ACTION_TO_APP_INIT, + payload, +}); + +export const copyJSActionToApp = (payload: CopyEntityToAppPayload) => ({ + type: ReduxActionTypes.COPY_JS_ACTION_TO_APP_INIT, + payload, +}); + +export const fetchAppsForCopyTarget = (workspaceId: string) => ({ + type: ReduxActionTypes.FETCH_COPY_TARGET_APPLICATIONS_INIT, + payload: { workspaceId }, +}); diff --git a/app/client/src/ce/constants/ReduxActionConstants.tsx b/app/client/src/ce/constants/ReduxActionConstants.tsx index 2d08cb91cd83..a04cb844c7ca 100644 --- a/app/client/src/ce/constants/ReduxActionConstants.tsx +++ b/app/client/src/ce/constants/ReduxActionConstants.tsx @@ -223,6 +223,13 @@ const ImportExportActionTypes = { PARTIAL_EXPORT_MODAL_OPEN: "PARTIAL_EXPORT_MODAL_OPEN", PARTIAL_EXPORT_INIT: "PARTIAL_EXPORT_INIT", PARTIAL_EXPORT_SUCCESS: "PARTIAL_EXPORT_SUCCESS", + COPY_ACTION_TO_APP_INIT: "COPY_ACTION_TO_APP_INIT", + COPY_ACTION_TO_APP_SUCCESS: "COPY_ACTION_TO_APP_SUCCESS", + COPY_JS_ACTION_TO_APP_INIT: "COPY_JS_ACTION_TO_APP_INIT", + COPY_JS_ACTION_TO_APP_SUCCESS: "COPY_JS_ACTION_TO_APP_SUCCESS", + FETCH_COPY_TARGET_APPLICATIONS_INIT: "FETCH_COPY_TARGET_APPLICATIONS_INIT", + FETCH_COPY_TARGET_APPLICATIONS_SUCCESS: + "FETCH_COPY_TARGET_APPLICATIONS_SUCCESS", IMPORT_APPLICATION_INIT: "IMPORT_APPLICATION_INIT", IMPORT_APPLICATION_FROM_GIT_INIT: "IMPORT_APPLICATION_FROM_GIT_INIT", IMPORT_APPLICATION_SUCCESS: "IMPORT_APPLICATION_SUCCESS", @@ -233,6 +240,9 @@ const ImportExportActionErrorTypes = { IMPORT_APPLICATION_ERROR: "IMPORT_APPLICATION_ERROR", PARTIAL_IMPORT_ERROR: "PARTIAL_IMPORT_ERROR", PARTIAL_EXPORT_ERROR: "PARTIAL_EXPORT_ERROR", + COPY_ACTION_TO_APP_ERROR: "COPY_ACTION_TO_APP_ERROR", + COPY_JS_ACTION_TO_APP_ERROR: "COPY_JS_ACTION_TO_APP_ERROR", + FETCH_COPY_TARGET_APPLICATIONS_ERROR: "FETCH_COPY_TARGET_APPLICATIONS_ERROR", }; const ImportGitActionTypes = { diff --git a/app/client/src/ce/constants/messages.ts b/app/client/src/ce/constants/messages.ts index d35ef66b79ce..78abaa22d908 100644 --- a/app/client/src/ce/constants/messages.ts +++ b/app/client/src/ce/constants/messages.ts @@ -1841,6 +1841,7 @@ export const CONTEXT_RENAME = () => "Rename"; export const CONTEXT_SHOW_BINDING = () => "Show bindings"; export const CONTEXT_MOVE = () => "Move to page"; export const CONTEXT_COPY = () => "Copy to page"; +export const CONTEXT_COPY_TO_APP = () => "Copy to application"; export const CONTEXT_DUPLICATE = () => "Duplicate"; export const CONTEXT_DELETE = () => "Delete"; export const CONFIRM_CONTEXT_DELETE = () => "Are you sure?"; @@ -1924,6 +1925,32 @@ export const FORK_APP_MODAL_SUCCESS_TITLE = () => "Choose where to fork the app"; export const FORK = () => `Fork`; +export const COPY_ENTITY_TO_APP_MODAL_TITLE = () => "Copy to application"; +export const COPY_ENTITY_TO_APP_WORKSPACE_LABEL = () => "Workspace"; +export const COPY_ENTITY_TO_APP_APPLICATION_LABEL = () => "Application"; +export const COPY_ENTITY_TO_APP_PAGE_LABEL = () => "Page"; +export const COPY_ENTITY_TO_APP_WORKSPACE_PLACEHOLDER = () => + "Select a workspace"; +export const COPY_ENTITY_TO_APP_APPLICATION_PLACEHOLDER = () => + "Select an application"; +export const COPY_ENTITY_TO_APP_PAGE_PLACEHOLDER = () => "Select a page"; +export const COPY_ENTITY_TO_APP_CONFIRM = () => "Copy"; +export const COPY_ENTITY_TO_APP_NO_WORKSPACES = () => + "No workspaces available to copy to"; +export const COPY_ENTITY_TO_APP_NO_APPS = () => + "No applications available in this workspace"; +export const COPY_ENTITY_TO_APP_NO_PAGES = () => + "No pages available in this application"; +export const COPY_ENTITY_TO_APP_SUCCESS = ( + entityName: string, + appName: string, + pageName: string, +) => `Copied ${entityName} to ${appName} / ${pageName}`; +export const COPY_ENTITY_TO_APP_ERROR = () => + "Failed to copy to the selected application"; +export const COPY_ENTITY_TO_APP_NOTE = () => + "Datasources and any referenced queries aren't copied — you may need to reconfigure them in the target application."; + export const CLEAN_URL_UPDATE = { name: () => "Update URLs", shortDesc: () => diff --git a/app/client/src/ce/reducers/index.tsx b/app/client/src/ce/reducers/index.tsx index 8bac41cfd429..b15845b59826 100644 --- a/app/client/src/ce/reducers/index.tsx +++ b/app/client/src/ce/reducers/index.tsx @@ -79,6 +79,7 @@ import type { LayoutElementPositionsReduxState } from "layoutSystems/anvil/integ import type { ActiveField } from "reducers/uiReducers/activeFieldEditorReducer"; import type { SelectedWorkspaceReduxState } from "ee/reducers/uiReducers/selectedWorkspaceReducer"; import type { ConsolidatedPageLoadState } from "reducers/uiReducers/consolidatedPageLoadReducer"; +import type { CopyEntityToAppReduxState } from "reducers/uiReducers/copyEntityToAppReducer"; import type { BuildingBlocksReduxState } from "reducers/uiReducers/buildingBlockReducer"; import type { GitArtifactRootReduxState, @@ -149,6 +150,7 @@ export interface AppState { ide: IDEState; pluginActionEditor: PluginActionEditorState; windowDimensions: WindowDimensionsState; + copyEntityToApp: CopyEntityToAppReduxState; }; entities: { canvasWidgetsStructure: CanvasWidgetStructure; diff --git a/app/client/src/ce/reducers/uiReducers/index.tsx b/app/client/src/ce/reducers/uiReducers/index.tsx index 26cf750c9e3c..989a50c6799e 100644 --- a/app/client/src/ce/reducers/uiReducers/index.tsx +++ b/app/client/src/ce/reducers/uiReducers/index.tsx @@ -47,6 +47,7 @@ import activeFieldReducer from "reducers/uiReducers/activeFieldEditorReducer"; import selectedWorkspaceReducer from "ee/reducers/uiReducers/selectedWorkspaceReducer"; import ideReducer from "reducers/uiReducers/ideReducer"; import consolidatedPageLoadReducer from "reducers/uiReducers/consolidatedPageLoadReducer"; +import copyEntityToAppReducer from "reducers/uiReducers/copyEntityToAppReducer"; import { pluginActionReducer } from "PluginActionEditor/store"; export const uiReducerObject = { @@ -100,4 +101,5 @@ export const uiReducerObject = { ide: ideReducer, consolidatedPageLoad: consolidatedPageLoadReducer, pluginActionEditor: pluginActionReducer, + copyEntityToApp: copyEntityToAppReducer, }; diff --git a/app/client/src/ce/sagas/CopyToAppSagas.test.ts b/app/client/src/ce/sagas/CopyToAppSagas.test.ts new file mode 100644 index 000000000000..24a6be97f349 --- /dev/null +++ b/app/client/src/ce/sagas/CopyToAppSagas.test.ts @@ -0,0 +1,295 @@ +import { call, put, select } from "redux-saga/effects"; +import ApplicationApi from "ee/api/ApplicationApi"; +import { + ReduxActionErrorTypes, + ReduxActionTypes, +} from "ee/constants/ReduxActionConstants"; +import { getCurrentApplicationId } from "selectors/editorSelectors"; +import { getCurrentWorkspaceId } from "ee/selectors/selectedWorkspaceSelectors"; +import { + copyActionToAppSaga, + copyJSActionToAppSaga, + fetchAppsForCopyTargetSaga, +} from "ee/sagas/CopyToAppSagas"; +import { validateResponse } from "sagas/ErrorSagas"; +import { CopyToAppEntityType } from "pages/Editor/Explorer/CopyToApp/types"; + +const toastShow = jest.fn(); +const logEvent = jest.fn(); + +jest.mock("@appsmith/ads", () => ({ + toast: { + show: (...args: unknown[]) => toastShow(...args), + }, +})); + +// Mock ErrorSagas so importing it does not pull in the full store/saga tree. +// The saga uses `call(validateResponse, ...)`, so the test asserts against this +// same mocked reference. +jest.mock("sagas/ErrorSagas", () => ({ + validateResponse: jest.fn(), +})); + +jest.mock("ee/utils/AnalyticsUtil", () => ({ + __esModule: true, + default: { + logEvent: (...args: unknown[]) => logEvent(...args), + }, +})); + +const basePayload = { + entityId: "entity-1", + entityName: "Query1", + sourcePageId: "source-page-1", + targetWorkspaceId: "target-ws-1", + targetApplicationId: "target-app-1", + targetApplicationName: "App B", + targetPageId: "target-page-1", + targetPageName: "Page1", + onSuccess: jest.fn(), +}; + +describe("copyActionToAppSaga", () => { + it("delegates with an action export body (actionList populated)", () => { + const gen = copyActionToAppSaga({ + type: ReduxActionTypes.COPY_ACTION_TO_APP_INIT, + payload: basePayload, + }); + + const delegated = gen.next().value as ReturnType; + + expect(typeof delegated.payload.fn).toBe("function"); + expect(delegated.payload.args).toEqual([ + basePayload, + CopyToAppEntityType.ACTION, + { + actionList: ["entity-1"], + actionCollectionList: [], + customJsLib: [], + datasourceList: [], + widget: "", + }, + ReduxActionTypes.COPY_ACTION_TO_APP_SUCCESS, + ReduxActionErrorTypes.COPY_ACTION_TO_APP_ERROR, + ]); + }); +}); + +describe("copyJSActionToAppSaga", () => { + it("delegates with a JS-collection export body (actionCollectionList populated)", () => { + const gen = copyJSActionToAppSaga({ + type: ReduxActionTypes.COPY_JS_ACTION_TO_APP_INIT, + payload: basePayload, + }); + + const delegated = gen.next().value as ReturnType; + + expect(typeof delegated.payload.fn).toBe("function"); + expect(delegated.payload.args).toEqual([ + basePayload, + CopyToAppEntityType.JS_OBJECT, + { + actionList: [], + actionCollectionList: ["entity-1"], + customJsLib: [], + datasourceList: [], + widget: "", + }, + ReduxActionTypes.COPY_JS_ACTION_TO_APP_SUCCESS, + ReduxActionErrorTypes.COPY_JS_ACTION_TO_APP_ERROR, + ]); + }); +}); + +describe("copyEntityToApp end-to-end orchestration", () => { + beforeEach(() => { + toastShow.mockClear(); + logEvent.mockClear(); + basePayload.onSuccess.mockClear(); + }); + + // Re-derive the shared orchestration generator from copyActionToAppSaga by + // invoking the delegated call target with its arguments. + function runOrchestration(): Generator { + const outer = copyActionToAppSaga({ + type: ReduxActionTypes.COPY_ACTION_TO_APP_INIT, + payload: basePayload, + }); + const callEffect = outer.next().value as ReturnType; + const { args, fn } = callEffect.payload as { + args: unknown[]; + fn: (...callArgs: unknown[]) => Generator; + }; + + return fn(...args); + } + + it("wraps the export response as a File and imports it to the target ids, then toasts success", () => { + const gen = runOrchestration(); + + // 0. determine cross-workspace via current workspace id + expect(gen.next().value).toEqual(select(getCurrentWorkspaceId)); + + // 1. select current application id (source) + expect(gen.next("source-ws-1").value).toEqual( + select(getCurrentApplicationId), + ); + + // 2. export the entity from the source app + page + const exportEffect = gen.next("source-app-1").value; + + expect(exportEffect).toEqual( + call( + ApplicationApi.exportPartialApplication, + "source-app-1", + "source-page-1", + { + actionList: ["entity-1"], + actionCollectionList: [], + customJsLib: [], + datasourceList: [], + widget: "", + }, + ), + ); + + // 3. validate the export response + const exportResponse = { data: { exported: true }, responseMeta: {} }; + + expect(gen.next(exportResponse).value).toEqual( + call(validateResponse, exportResponse), + ); + + // 4. import wraps the export data in a File and targets the chosen ids + const importEffect = gen.next(true).value as ReturnType; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const importArgs = (importEffect.payload as any).args[0]; + + expect(importEffect.payload.fn).toBe( + ApplicationApi.importPartialApplication, + ); + expect(importArgs.workspaceId).toBe("target-ws-1"); + expect(importArgs.applicationId).toBe("target-app-1"); + expect(importArgs.pageId).toBe("target-page-1"); + expect(importArgs.applicationFile).toBeInstanceOf(File); + expect(importArgs.applicationFile.type).toBe("application/json"); + + // 5. validate the import response + const importResponse = { data: {}, responseMeta: {} }; + + expect(gen.next(importResponse).value).toEqual( + call(validateResponse, importResponse), + ); + + // 6. success: analytics + toast (with page name) + success action, NO navigation + const successEffect = gen.next(true).value; + + expect(logEvent).toHaveBeenCalledWith("COPY_ENTITY_TO_APP", { + entityType: CopyToAppEntityType.ACTION, + isCrossWorkspace: true, + }); + expect(toastShow).toHaveBeenCalledWith("Copied Query1 to App B / Page1", { + kind: "success", + }); + expect(successEffect).toEqual( + put({ type: ReduxActionTypes.COPY_ACTION_TO_APP_SUCCESS }), + ); + + gen.next(); + expect(basePayload.onSuccess).toHaveBeenCalledTimes(1); + expect(gen.next().done).toBe(true); + }); + + it("stops without importing when the export response is invalid", () => { + const gen = runOrchestration(); + + gen.next(); // select workspace id + gen.next("source-ws-1"); // select application id + gen.next("source-app-1"); // export call + gen.next({ data: null, responseMeta: {} }); // validateResponse call + + // invalid export -> generator returns early, no further effects + expect(gen.next(false).done).toBe(true); + expect(toastShow).not.toHaveBeenCalled(); + expect(logEvent).not.toHaveBeenCalled(); + }); + + it("stops after import without toasting when the import response is invalid", () => { + const gen = runOrchestration(); + + gen.next(); // select workspace id + gen.next("source-ws-1"); // select application id + gen.next("source-app-1"); // export call + gen.next({ data: { exported: true }, responseMeta: {} }); // validateResponse(export) + gen.next(true); // import call + gen.next({ data: {}, responseMeta: {} }); // validateResponse(import) + + // invalid import -> generator returns early before toast/success + expect(gen.next(false).done).toBe(true); + expect(toastShow).not.toHaveBeenCalled(); + expect(basePayload.onSuccess).not.toHaveBeenCalled(); + }); + + it("dispatches the error action with an error toast when import throws", () => { + const gen = runOrchestration(); + + gen.next(); // select workspace id + gen.next("source-ws-1"); // select application id + gen.next("source-app-1"); // export call + gen.next({ data: { exported: true }, responseMeta: {} }); // validateResponse(export) + gen.next(true); // import call + + // import throws -> caught -> analytics(error) + error action with show+message + const errorEffect = gen.throw(new Error("network down")); + const errorAction = ( + errorEffect.value as { + payload: { + action: { + type: string; + payload: { show: boolean; error: { message: string } }; + }; + }; + } + ).payload.action; + + expect(logEvent).toHaveBeenCalledWith("COPY_ENTITY_TO_APP", { + entityType: CopyToAppEntityType.ACTION, + isCrossWorkspace: true, + error: true, + }); + expect(errorAction.type).toBe( + ReduxActionErrorTypes.COPY_ACTION_TO_APP_ERROR, + ); + expect(errorAction.payload.show).toBe(true); + expect(errorAction.payload.error.message).toBe( + "Failed to copy to the selected application", + ); + expect(basePayload.onSuccess).not.toHaveBeenCalled(); + }); +}); + +describe("fetchAppsForCopyTargetSaga", () => { + it("fetches applications for the workspace and stores them in the copyEntityToApp slice", () => { + const gen: Generator = fetchAppsForCopyTargetSaga({ + type: ReduxActionTypes.FETCH_COPY_TARGET_APPLICATIONS_INIT, + payload: { workspaceId: "ws-9" }, + }); + + expect(gen.next().value).toEqual( + call(ApplicationApi.fetchAllApplicationsOfWorkspace, "ws-9"), + ); + + const response = { data: [{ id: "app-1" }], responseMeta: {} }; + + expect(gen.next(response).value).toEqual(call(validateResponse, response)); + + expect(gen.next(true).value).toEqual( + put({ + type: ReduxActionTypes.FETCH_COPY_TARGET_APPLICATIONS_SUCCESS, + payload: { applications: [{ id: "app-1" }] }, + }), + ); + + expect(gen.next().done).toBe(true); + }); +}); diff --git a/app/client/src/ce/sagas/CopyToAppSagas.ts b/app/client/src/ce/sagas/CopyToAppSagas.ts new file mode 100644 index 000000000000..647fb2cc9bf8 --- /dev/null +++ b/app/client/src/ce/sagas/CopyToAppSagas.ts @@ -0,0 +1,203 @@ +import ApplicationApi, { + type exportApplicationRequest, +} from "ee/api/ApplicationApi"; +import type { ReduxAction } from "actions/ReduxActionTypes"; +import { + ReduxActionErrorTypes, + ReduxActionTypes, +} from "ee/constants/ReduxActionConstants"; +import type { ApplicationPayload } from "entities/Application"; +import type { ApiResponse } from "api/ApiResponses"; +import { toast } from "@appsmith/ads"; +import { all, call, put, select, takeLatest } from "redux-saga/effects"; +import { getCurrentApplicationId } from "selectors/editorSelectors"; +import { getCurrentWorkspaceId } from "ee/selectors/selectedWorkspaceSelectors"; +import { + createMessage, + COPY_ENTITY_TO_APP_SUCCESS, + COPY_ENTITY_TO_APP_ERROR, +} from "ee/constants/messages"; +import { + CopyToAppEntityType, + type CopyEntityToAppPayload, +} from "pages/Editor/Explorer/CopyToApp/types"; +import { validateResponse } from "sagas/ErrorSagas"; +import AnalyticsUtil from "ee/utils/AnalyticsUtil"; + +const COPY_FILE_NAME = "copy-entity.json"; + +/** + * Fetches the applications of a target workspace for the copy-to-app picker. + * + * This writes to the dedicated `copyEntityToApp` slice rather than reusing the + * `FETCH_ALL_APPLICATIONS_OF_WORKSPACE` flow, which mutates + * `state.ui.selectedWorkspace.applications` — that slice reflects the current + * editor's workspace and must not be clobbered while editing. + */ +export function* fetchAppsForCopyTargetSaga( + action: ReduxAction<{ workspaceId: string }>, +) { + try { + const response: ApiResponse = yield call( + ApplicationApi.fetchAllApplicationsOfWorkspace, + action.payload.workspaceId, + ); + const isValidResponse: boolean = yield call(validateResponse, response); + + if (isValidResponse) { + yield put({ + type: ReduxActionTypes.FETCH_COPY_TARGET_APPLICATIONS_SUCCESS, + payload: { applications: response.data || [] }, + }); + } + } catch (error) { + yield put({ + type: ReduxActionErrorTypes.FETCH_COPY_TARGET_APPLICATIONS_ERROR, + payload: { error }, + }); + } +} + +/** + * Orchestrates copying a single entity (action/query or JS object) into a page + * of a different application, possibly in a different workspace. + * + * It reuses the existing partial export + partial import endpoints: export the + * single entity to an in-memory ApplicationJson, wrap it as a File, and import + * it into the chosen target. Datasource reconciliation and name-collision + * refactoring are handled server-side by the import. The user stays in the + * current editor — no navigation is performed. + */ +function* copyEntityToAppSaga( + payload: CopyEntityToAppPayload, + entityType: CopyToAppEntityType, + exportBody: exportApplicationRequest, + successType: string, + errorType: string, +) { + const currentWorkspaceId: string = yield select(getCurrentWorkspaceId); + const isCrossWorkspace = currentWorkspaceId !== payload.targetWorkspaceId; + + try { + const sourceApplicationId: string = yield select(getCurrentApplicationId); + + const exportResponse: ApiResponse = yield call( + ApplicationApi.exportPartialApplication, + sourceApplicationId, + payload.sourcePageId, + exportBody, + ); + const isExportValid: boolean = yield call(validateResponse, exportResponse); + + if (!isExportValid) return; + + const applicationFile = new File( + [JSON.stringify(exportResponse.data)], + COPY_FILE_NAME, + { type: "application/json" }, + ); + + const importResponse: ApiResponse = yield call( + ApplicationApi.importPartialApplication, + { + applicationFile, + workspaceId: payload.targetWorkspaceId, + applicationId: payload.targetApplicationId, + pageId: payload.targetPageId, + }, + ); + const isImportValid: boolean = yield call(validateResponse, importResponse); + + if (!isImportValid) return; + + AnalyticsUtil.logEvent("COPY_ENTITY_TO_APP", { + entityType, + isCrossWorkspace, + }); + + toast.show( + createMessage( + COPY_ENTITY_TO_APP_SUCCESS, + payload.entityName, + payload.targetApplicationName, + payload.targetPageName, + ), + { kind: "success" }, + ); + + yield put({ type: successType }); + + if (payload.onSuccess) { + payload.onSuccess(); + } + } catch (error) { + AnalyticsUtil.logEvent("COPY_ENTITY_TO_APP", { + entityType, + isCrossWorkspace, + error: true, + }); + yield put({ + type: errorType, + payload: { + show: true, + error: { message: createMessage(COPY_ENTITY_TO_APP_ERROR) }, + }, + }); + } +} + +export function* copyActionToAppSaga( + action: ReduxAction, +) { + const exportBody: exportApplicationRequest = { + actionList: [action.payload.entityId], + actionCollectionList: [], + customJsLib: [], + datasourceList: [], + widget: "", + }; + + yield call( + copyEntityToAppSaga, + action.payload, + CopyToAppEntityType.ACTION, + exportBody, + ReduxActionTypes.COPY_ACTION_TO_APP_SUCCESS, + ReduxActionErrorTypes.COPY_ACTION_TO_APP_ERROR, + ); +} + +export function* copyJSActionToAppSaga( + action: ReduxAction, +) { + const exportBody: exportApplicationRequest = { + actionList: [], + actionCollectionList: [action.payload.entityId], + customJsLib: [], + datasourceList: [], + widget: "", + }; + + yield call( + copyEntityToAppSaga, + action.payload, + CopyToAppEntityType.JS_OBJECT, + exportBody, + ReduxActionTypes.COPY_JS_ACTION_TO_APP_SUCCESS, + ReduxActionErrorTypes.COPY_JS_ACTION_TO_APP_ERROR, + ); +} + +export default function* copyToAppSagas() { + yield all([ + takeLatest( + ReduxActionTypes.FETCH_COPY_TARGET_APPLICATIONS_INIT, + fetchAppsForCopyTargetSaga, + ), + takeLatest(ReduxActionTypes.COPY_ACTION_TO_APP_INIT, copyActionToAppSaga), + takeLatest( + ReduxActionTypes.COPY_JS_ACTION_TO_APP_INIT, + copyJSActionToAppSaga, + ), + ]); +} diff --git a/app/client/src/ce/sagas/index.tsx b/app/client/src/ce/sagas/index.tsx index 3844fa1e985e..80bf225e2519 100644 --- a/app/client/src/ce/sagas/index.tsx +++ b/app/client/src/ce/sagas/index.tsx @@ -54,6 +54,7 @@ import ideSagas from "sagas/IDESaga"; import sendSideBySideWidgetHoverAnalyticsEventSaga from "sagas/AnalyticsSaga"; import gitSagas from "git/sagas"; import PostEvaluationSagas from "sagas/PostEvaluationSagas"; +import copyToAppSagas from "ee/sagas/CopyToAppSagas"; /* Sagas that are registered by a module that is designed to be independent of the core platform */ import ternSagas from "sagas/TernSaga"; @@ -117,4 +118,5 @@ export const sagas = [ gitApplicationSagas, PostEvaluationSagas, favoritesSagasListener, + copyToAppSagas, ]; diff --git a/app/client/src/ce/utils/analyticsUtilTypes.ts b/app/client/src/ce/utils/analyticsUtilTypes.ts index fc8dbc8fa25f..64c6d68bc25b 100644 --- a/app/client/src/ce/utils/analyticsUtilTypes.ts +++ b/app/client/src/ce/utils/analyticsUtilTypes.ts @@ -59,6 +59,7 @@ export type EventName = | "ADD_API_PAGE" | "DUPLICATE_ACTION" | "DUPLICATE_ACTION_CLICK" + | "COPY_ENTITY_TO_APP" | "RUN_QUERY_CLICK" | "RUN_ACTION_CLICK" | "DELETE_QUERY" diff --git a/app/client/src/ee/sagas/CopyToAppSagas.ts b/app/client/src/ee/sagas/CopyToAppSagas.ts new file mode 100644 index 000000000000..0a54ecdfa651 --- /dev/null +++ b/app/client/src/ee/sagas/CopyToAppSagas.ts @@ -0,0 +1,2 @@ +export * from "ce/sagas/CopyToAppSagas"; +export { default } from "ce/sagas/CopyToAppSagas"; diff --git a/app/client/src/pages/Editor/Explorer/Actions/ActionEntityContextMenu.tsx b/app/client/src/pages/Editor/Explorer/Actions/ActionEntityContextMenu.tsx index c54d1a8a74c9..d24ee4524543 100644 --- a/app/client/src/pages/Editor/Explorer/Actions/ActionEntityContextMenu.tsx +++ b/app/client/src/pages/Editor/Explorer/Actions/ActionEntityContextMenu.tsx @@ -13,6 +13,7 @@ import { ReduxActionTypes } from "ee/constants/ReduxActionConstants"; import { ENTITY_TYPE } from "ee/entities/DataTree/types"; import { CONTEXT_COPY, + CONTEXT_COPY_TO_APP, CONTEXT_DELETE, CONFIRM_CONTEXT_DELETE, CONTEXT_RENAME, @@ -22,6 +23,8 @@ import { createMessage, CONTEXT_DUPLICATE, } from "ee/constants/messages"; +import CopyEntityToAppModal from "pages/Editor/Explorer/CopyToApp/CopyEntityToAppModal"; +import { CopyToAppEntityType } from "pages/Editor/Explorer/CopyToApp/types"; import { builderURL } from "ee/RouteBuilder"; import ContextMenu from "pages/Editor/Explorer/ContextMenu"; @@ -56,6 +59,7 @@ export function ActionEntityContextMenu(props: EntityContextMenuProps) { const { canDeleteAction, canManageAction } = props; const dispatch = useDispatch(); const [confirmDelete, setConfirmDelete] = useState(false); + const [showCopyToAppModal, setShowCopyToAppModal] = useState(false); const copyAction = useCallback( (actionId: string, actionName: string, destinationEntityId: string) => dispatch( @@ -153,6 +157,13 @@ export function ActionEntityContextMenu(props: EntityContextMenuProps) { }; }), }, + menuItems.includes(ActionEntityContextMenuItemsEnum.COPY_TO_APP) && + canManageAction && + parentEntityType === ActionParentEntityType.PAGE && { + value: "copyToApp", + onSelect: () => setShowCopyToAppModal(true), + label: createMessage(CONTEXT_COPY_TO_APP), + }, menuItems.includes(ActionEntityContextMenuItemsEnum.MOVE) && canManageAction && { value: "move", @@ -198,11 +209,23 @@ export function ActionEntityContextMenu(props: EntityContextMenuProps) { ].filter(Boolean); return optionsTree.length > 0 ? ( - + <> + + {showCopyToAppModal && ( + setShowCopyToAppModal(false)} + sourcePageId={parentEntityId} + /> + )} + ) : null; } diff --git a/app/client/src/pages/Editor/Explorer/CopyToApp/CopyEntityToAppModal.tsx b/app/client/src/pages/Editor/Explorer/CopyToApp/CopyEntityToAppModal.tsx new file mode 100644 index 000000000000..5e6382e721ca --- /dev/null +++ b/app/client/src/pages/Editor/Explorer/CopyToApp/CopyEntityToAppModal.tsx @@ -0,0 +1,310 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { + Button, + Callout, + Modal, + ModalContent, + ModalHeader, + Select, + Option, + Text, +} from "@appsmith/ads"; +import { + hasCreateNewAppPermission, + hasManagePagePermission, + isPermitted, + PERMISSION_TYPE, +} from "ee/utils/permissionHelpers"; +import { getFetchedWorkspaces } from "ee/selectors/workspaceSelectors"; +import { fetchAllWorkspaces } from "ee/actions/workspaceActions"; +import { + CANCEL, + COPY_ENTITY_TO_APP_APPLICATION_LABEL, + COPY_ENTITY_TO_APP_APPLICATION_PLACEHOLDER, + COPY_ENTITY_TO_APP_CONFIRM, + COPY_ENTITY_TO_APP_MODAL_TITLE, + COPY_ENTITY_TO_APP_NO_APPS, + COPY_ENTITY_TO_APP_NO_PAGES, + COPY_ENTITY_TO_APP_NO_WORKSPACES, + COPY_ENTITY_TO_APP_NOTE, + COPY_ENTITY_TO_APP_PAGE_LABEL, + COPY_ENTITY_TO_APP_PAGE_PLACEHOLDER, + COPY_ENTITY_TO_APP_WORKSPACE_LABEL, + COPY_ENTITY_TO_APP_WORKSPACE_PLACEHOLDER, + createMessage, +} from "ee/constants/messages"; +import { + copyActionToApp, + copyJSActionToApp, + fetchAppsForCopyTarget, +} from "actions/copyToAppActions"; +import { + getCopyTargetApplications, + getIsCopyingEntityToApp, + getIsFetchingCopyTargetApplications, +} from "selectors/copyToAppSelectors"; +import { CopyToAppEntityType } from "./types"; + +interface CopyEntityToAppModalProps { + isOpen: boolean; + onClose: () => void; + entityType: CopyToAppEntityType; + entityId: string; + entityName: string; + sourcePageId: string; +} + +const FieldWrapper = ({ + children, + label, +}: { + children: React.ReactNode; + label: string; +}) => ( +
+ + {label} + +
{children}
+
+); + +function CopyEntityToAppModal(props: CopyEntityToAppModalProps) { + const { entityId, entityName, entityType, isOpen, onClose, sourcePageId } = + props; + const dispatch = useDispatch(); + + const [workspaceId, setWorkspaceId] = useState(""); + const [applicationId, setApplicationId] = useState(""); + const [pageId, setPageId] = useState(""); + + const workspaces = useSelector(getFetchedWorkspaces); + const applications = useSelector(getCopyTargetApplications); + const isFetchingApplications = useSelector( + getIsFetchingCopyTargetApplications, + ); + const isCopying = useSelector(getIsCopyingEntityToApp); + + const workspaceOptions = useMemo( + () => + workspaces.filter((workspace) => + hasCreateNewAppPermission(workspace.userPermissions ?? []), + ), + [workspaces], + ); + + const applicationOptions = useMemo( + () => + applications.filter((application) => + isPermitted( + application.userPermissions ?? [], + PERMISSION_TYPE.MANAGE_APPLICATION, + ), + ), + [applications], + ); + + const selectedApplication = useMemo( + () => + applicationOptions.find( + (application) => application.id === applicationId, + ), + [applicationOptions, applicationId], + ); + + const pageOptions = useMemo( + () => + (selectedApplication?.pages ?? []).filter((page) => + hasManagePagePermission(page.userPermissions ?? []), + ), + [selectedApplication], + ); + + const selectedPage = useMemo( + () => pageOptions.find((page) => page.id === pageId), + [pageOptions, pageId], + ); + + // Fetch the list of workspaces when the modal is opened. + useEffect( + function fetchWorkspacesOnOpen() { + if (isOpen) { + dispatch(fetchAllWorkspaces({ fetchEntities: false })); + } + }, + [isOpen, dispatch], + ); + + // Fetch applications whenever the selected workspace changes. + useEffect( + function fetchApplicationsOnWorkspaceChange() { + if (workspaceId) { + dispatch(fetchAppsForCopyTarget(workspaceId)); + } + }, + [workspaceId, dispatch], + ); + + const handleWorkspaceSelect = (value: string) => { + setWorkspaceId(value); + setApplicationId(""); + setPageId(""); + }; + + const handleApplicationSelect = (value: string) => { + setApplicationId(value); + setPageId(""); + }; + + const handleClose = () => { + setWorkspaceId(""); + setApplicationId(""); + setPageId(""); + onClose(); + }; + + const handleCopy = () => { + if (!selectedApplication || !selectedPage) return; + + const payload = { + entityId, + entityName, + sourcePageId, + targetWorkspaceId: workspaceId, + targetApplicationId: applicationId, + targetApplicationName: selectedApplication.name, + targetPageId: pageId, + targetPageName: selectedPage.name, + onSuccess: handleClose, + }; + + dispatch( + entityType === CopyToAppEntityType.JS_OBJECT + ? copyJSActionToApp(payload) + : copyActionToApp(payload), + ); + }; + + const handleOpenChange = (open: boolean) => { + if (!open) { + handleClose(); + } + }; + + const isConfirmDisabled = !workspaceId || !applicationId || !pageId; + + return ( + + + + {createMessage(COPY_ENTITY_TO_APP_MODAL_TITLE)} + + + {workspaceOptions.length ? ( + + ) : ( + + {createMessage(COPY_ENTITY_TO_APP_NO_WORKSPACES)} + + )} + + + {!!workspaceId && ( + + + {!isFetchingApplications && !applicationOptions.length && ( + + {createMessage(COPY_ENTITY_TO_APP_NO_APPS)} + + )} + + )} + + {!!applicationId && ( + + {pageOptions.length ? ( + + ) : ( + + {createMessage(COPY_ENTITY_TO_APP_NO_PAGES)} + + )} + + )} + + {createMessage(COPY_ENTITY_TO_APP_NOTE)} + +
+ + +
+
+
+ ); +} + +export default CopyEntityToAppModal; diff --git a/app/client/src/pages/Editor/Explorer/CopyToApp/types.ts b/app/client/src/pages/Editor/Explorer/CopyToApp/types.ts new file mode 100644 index 000000000000..b9fd975e8906 --- /dev/null +++ b/app/client/src/pages/Editor/Explorer/CopyToApp/types.ts @@ -0,0 +1,16 @@ +export enum CopyToAppEntityType { + ACTION = "ACTION", + JS_OBJECT = "JS_OBJECT", +} + +export interface CopyEntityToAppPayload { + entityId: string; + entityName: string; + sourcePageId: string; + targetWorkspaceId: string; + targetApplicationId: string; + targetApplicationName: string; + targetPageId: string; + targetPageName: string; + onSuccess?: () => void; +} diff --git a/app/client/src/pages/Editor/Explorer/Files/FilesContextProvider.tsx b/app/client/src/pages/Editor/Explorer/Files/FilesContextProvider.tsx index 48985dd7cea0..c07134a0c4a7 100644 --- a/app/client/src/pages/Editor/Explorer/Files/FilesContextProvider.tsx +++ b/app/client/src/pages/Editor/Explorer/Files/FilesContextProvider.tsx @@ -6,6 +6,7 @@ export enum ActionEntityContextMenuItemsEnum { SHOW_BINDING = "Show Bindings", CONVERT_QUERY_MODULE_INSTANCE = "Create Module", COPY = "Copy", + COPY_TO_APP = "Copy to application", MOVE = "Move", DELETE = "Delete", } @@ -15,6 +16,7 @@ export const defaultMenuItems = [ ActionEntityContextMenuItemsEnum.DELETE, ActionEntityContextMenuItemsEnum.SHOW_BINDING, ActionEntityContextMenuItemsEnum.COPY, + ActionEntityContextMenuItemsEnum.COPY_TO_APP, ActionEntityContextMenuItemsEnum.MOVE, ActionEntityContextMenuItemsEnum.CONVERT_QUERY_MODULE_INSTANCE, ]; diff --git a/app/client/src/pages/Editor/Explorer/JSActions/JSActionContextMenu.tsx b/app/client/src/pages/Editor/Explorer/JSActions/JSActionContextMenu.tsx index 86bf1f6f3a1f..9c9de7a6318f 100644 --- a/app/client/src/pages/Editor/Explorer/JSActions/JSActionContextMenu.tsx +++ b/app/client/src/pages/Editor/Explorer/JSActions/JSActionContextMenu.tsx @@ -11,6 +11,7 @@ import { ReduxActionTypes } from "ee/constants/ReduxActionConstants"; import { ENTITY_TYPE } from "ee/entities/DataTree/types"; import { CONTEXT_COPY, + CONTEXT_COPY_TO_APP, CONTEXT_DELETE, CONFIRM_CONTEXT_DELETE, CONTEXT_RENAME, @@ -27,6 +28,9 @@ import { ActionEntityContextMenuItemsEnum, FilesContext, } from "../Files/FilesContextProvider"; +import CopyEntityToAppModal from "pages/Editor/Explorer/CopyToApp/CopyEntityToAppModal"; +import { CopyToAppEntityType } from "pages/Editor/Explorer/CopyToApp/types"; +import { ActionParentEntityType } from "ee/entities/Engine/actionHelpers"; interface EntityContextMenuProps { id: string; @@ -40,10 +44,11 @@ interface EntityContextMenuProps { export function JSCollectionEntityContextMenu(props: EntityContextMenuProps) { // Import the context const context = useContext(FilesContext); - const { menuItems, parentEntityId } = context; + const { menuItems, parentEntityId, parentEntityType } = context; const { canDelete, canManage } = props; const [confirmDelete, setConfirmDelete] = useState(false); + const [showCopyToAppModal, setShowCopyToAppModal] = useState(false); const dispatch = useDispatch(); const showBinding = useCallback( @@ -121,6 +126,13 @@ export function JSCollectionEntityContextMenu(props: EntityContextMenuProps) { }; }), }, + menuItems.includes(ActionEntityContextMenuItemsEnum.COPY_TO_APP) && + canManage && + parentEntityType === ActionParentEntityType.PAGE && { + value: "copyToApp", + onSelect: () => setShowCopyToAppModal(true), + label: createMessage(CONTEXT_COPY_TO_APP), + }, menuItems.includes(ActionEntityContextMenuItemsEnum.MOVE) && canManage && { value: "move", @@ -163,11 +175,23 @@ export function JSCollectionEntityContextMenu(props: EntityContextMenuProps) { ].filter(Boolean); return !props.hideMenuItems && optionsTree.length > 0 ? ( - + <> + + {showCopyToAppModal && ( + setShowCopyToAppModal(false)} + sourcePageId={parentEntityId} + /> + )} + ) : null; } diff --git a/app/client/src/reducers/uiReducers/copyEntityToAppReducer.test.ts b/app/client/src/reducers/uiReducers/copyEntityToAppReducer.test.ts new file mode 100644 index 000000000000..3655110361d6 --- /dev/null +++ b/app/client/src/reducers/uiReducers/copyEntityToAppReducer.test.ts @@ -0,0 +1,81 @@ +import { + ReduxActionErrorTypes, + ReduxActionTypes, +} from "ee/constants/ReduxActionConstants"; +import reducer from "reducers/uiReducers/copyEntityToAppReducer"; +import type { ApplicationPayload } from "entities/Application"; + +const initialState = { + targetApplications: [], + isFetchingApplications: false, + isCopying: false, +}; + +const sampleApps = [{ id: "app-1" }] as unknown as ApplicationPayload[]; + +describe("copyEntityToAppReducer", () => { + it("sets isFetchingApplications and clears the list on fetch init", () => { + const state = reducer( + { ...initialState, targetApplications: sampleApps }, + { + type: ReduxActionTypes.FETCH_COPY_TARGET_APPLICATIONS_INIT, + payload: {}, + }, + ); + + expect(state.isFetchingApplications).toBe(true); + expect(state.targetApplications).toEqual([]); + }); + + it("stores the fetched applications on fetch success", () => { + const state = reducer( + { ...initialState, isFetchingApplications: true }, + { + type: ReduxActionTypes.FETCH_COPY_TARGET_APPLICATIONS_SUCCESS, + payload: { applications: sampleApps }, + }, + ); + + expect(state.isFetchingApplications).toBe(false); + expect(state.targetApplications).toEqual(sampleApps); + }); + + it("resets fetching state on fetch error", () => { + const state = reducer( + { ...initialState, isFetchingApplications: true }, + { + type: ReduxActionErrorTypes.FETCH_COPY_TARGET_APPLICATIONS_ERROR, + payload: {}, + }, + ); + + expect(state.isFetchingApplications).toBe(false); + expect(state.targetApplications).toEqual([]); + }); + + it("toggles isCopying through the copy lifecycle", () => { + const copying = reducer(initialState, { + type: ReduxActionTypes.COPY_ACTION_TO_APP_INIT, + payload: {}, + }); + + expect(copying.isCopying).toBe(true); + + const done = reducer(copying, { + type: ReduxActionTypes.COPY_ACTION_TO_APP_SUCCESS, + payload: {}, + }); + + expect(done.isCopying).toBe(false); + + const failed = reducer( + { ...initialState, isCopying: true }, + { + type: ReduxActionErrorTypes.COPY_JS_ACTION_TO_APP_ERROR, + payload: {}, + }, + ); + + expect(failed.isCopying).toBe(false); + }); +}); diff --git a/app/client/src/reducers/uiReducers/copyEntityToAppReducer.ts b/app/client/src/reducers/uiReducers/copyEntityToAppReducer.ts new file mode 100644 index 000000000000..e0615b8b5543 --- /dev/null +++ b/app/client/src/reducers/uiReducers/copyEntityToAppReducer.ts @@ -0,0 +1,82 @@ +import { createReducer } from "utils/ReducerUtils"; +import type { ReduxAction } from "actions/ReduxActionTypes"; +import { + ReduxActionTypes, + ReduxActionErrorTypes, +} from "ee/constants/ReduxActionConstants"; +import type { ApplicationPayload } from "entities/Application"; + +export interface CopyEntityToAppReduxState { + targetApplications: ApplicationPayload[]; + isFetchingApplications: boolean; + isCopying: boolean; +} + +const initialState: CopyEntityToAppReduxState = { + targetApplications: [], + isFetchingApplications: false, + isCopying: false, +}; + +const copyEntityToAppReducer = createReducer(initialState, { + [ReduxActionTypes.FETCH_COPY_TARGET_APPLICATIONS_INIT]: ( + state: CopyEntityToAppReduxState, + ) => ({ + ...state, + isFetchingApplications: true, + targetApplications: [], + }), + [ReduxActionTypes.FETCH_COPY_TARGET_APPLICATIONS_SUCCESS]: ( + state: CopyEntityToAppReduxState, + action: ReduxAction<{ applications: ApplicationPayload[] }>, + ) => ({ + ...state, + isFetchingApplications: false, + targetApplications: action.payload.applications, + }), + [ReduxActionErrorTypes.FETCH_COPY_TARGET_APPLICATIONS_ERROR]: ( + state: CopyEntityToAppReduxState, + ) => ({ + ...state, + isFetchingApplications: false, + targetApplications: [], + }), + [ReduxActionTypes.COPY_ACTION_TO_APP_INIT]: ( + state: CopyEntityToAppReduxState, + ) => ({ + ...state, + isCopying: true, + }), + [ReduxActionTypes.COPY_JS_ACTION_TO_APP_INIT]: ( + state: CopyEntityToAppReduxState, + ) => ({ + ...state, + isCopying: true, + }), + [ReduxActionTypes.COPY_ACTION_TO_APP_SUCCESS]: ( + state: CopyEntityToAppReduxState, + ) => ({ + ...state, + isCopying: false, + }), + [ReduxActionTypes.COPY_JS_ACTION_TO_APP_SUCCESS]: ( + state: CopyEntityToAppReduxState, + ) => ({ + ...state, + isCopying: false, + }), + [ReduxActionErrorTypes.COPY_ACTION_TO_APP_ERROR]: ( + state: CopyEntityToAppReduxState, + ) => ({ + ...state, + isCopying: false, + }), + [ReduxActionErrorTypes.COPY_JS_ACTION_TO_APP_ERROR]: ( + state: CopyEntityToAppReduxState, + ) => ({ + ...state, + isCopying: false, + }), +}); + +export default copyEntityToAppReducer; diff --git a/app/client/src/selectors/copyToAppSelectors.ts b/app/client/src/selectors/copyToAppSelectors.ts new file mode 100644 index 000000000000..ad671cbe2e83 --- /dev/null +++ b/app/client/src/selectors/copyToAppSelectors.ts @@ -0,0 +1,13 @@ +import type { DefaultRootState } from "react-redux"; +import type { ApplicationPayload } from "entities/Application"; + +export const getCopyTargetApplications = ( + state: DefaultRootState, +): ApplicationPayload[] => state.ui.copyEntityToApp.targetApplications; + +export const getIsFetchingCopyTargetApplications = ( + state: DefaultRootState, +): boolean => state.ui.copyEntityToApp.isFetchingApplications; + +export const getIsCopyingEntityToApp = (state: DefaultRootState): boolean => + state.ui.copyEntityToApp.isCopying; From f450d1f589c3cfd97de395791c195169cee22f8d Mon Sep 17 00:00:00 2001 From: Stacey Levine Date: Tue, 23 Jun 2026 13:28:14 -0400 Subject: [PATCH 2/7] fix(client): wire "Copy to application" into the actual App IDE menus (#41919) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The feature was originally wired into the legacy `pages/Editor/Explorer` query/JS context menus, which the current App IDE does not render — so "Copy to application" never appeared in the editor's 3-dot menu. Re-target the feature to the menus the IDE actually uses and make the modal open via Redux instead of local component state (a menu item unmounts on select and can't reliably host a modal): - Add `CopyToApp` menu-item components following the existing ShowBindings/PartialExport pattern, in both AppPluginActionEditor/ContextMenuItems (queries) and Editor/JSEditor/ContextMenuItems (JS objects). Each dispatches `openCopyToAppModal({ entityType, entityId, entityName, sourcePageId })` and is rendered in AppQueryContextMenuItems / AppJSContextMenuItems. - Make CopyEntityToAppModal self-contained: it reads open state + the source entity from the `copyEntityToApp` slice (new `isModalOpen` + `entity`, OPEN/CLOSE actions) and is mounted once in AppIDEModals, alongside PartialExportModal/PartialImportModal. - Revert the legacy Explorer menu wiring to pristine `release` (those menus aren't used by the current IDE). `sourcePageId` comes from `action.pageId` / `jsAction.pageId`; the export saga pairs it with the server-derived current application id, and the server re-validates edit permission + that the page belongs to the app. Tests: add render-and-dispatch tests for both menu items (guarding the exact "wrong menu" regression) and OPEN/CLOSE reducer coverage. tsc, ESLint, Prettier, and 15 unit tests across 4 suites all pass. Reviewed via the council (architect, security, QA, DX) — APPROVE WITH RISKS, no blockers. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/client/src/actions/copyToAppActions.ts | 14 +++++- .../src/ce/constants/ReduxActionConstants.tsx | 2 + .../pages/AppIDE/components/AppIDEModals.tsx | 2 + .../ContextMenuItems/CopyToApp.test.tsx | 44 +++++++++++++++++++ .../components/ContextMenuItems/CopyToApp.tsx | 33 ++++++++++++++ .../components/ContextMenuItems/index.ts | 1 + .../JSEntityItem/AppJSContextMenuItems.tsx | 2 + .../AppQueryContextMenuItems.tsx | 2 + .../Actions/ActionEntityContextMenu.tsx | 33 +++----------- .../CopyToApp/CopyEntityToAppModal.tsx | 41 ++++++++--------- .../pages/Editor/Explorer/CopyToApp/types.ts | 8 ++++ .../Explorer/Files/FilesContextProvider.tsx | 2 - .../JSActions/JSActionContextMenu.tsx | 36 +++------------ .../ContextMenuItems/CopyToApp.test.tsx | 44 +++++++++++++++++++ .../JSEditor/ContextMenuItems/CopyToApp.tsx | 33 ++++++++++++++ .../JSEditor/ContextMenuItems/index.tsx | 1 + .../uiReducers/copyEntityToAppReducer.test.ts | 30 +++++++++++++ .../uiReducers/copyEntityToAppReducer.ts | 21 +++++++++ .../src/selectors/copyToAppSelectors.ts | 8 ++++ 19 files changed, 273 insertions(+), 84 deletions(-) create mode 100644 app/client/src/pages/AppIDE/components/AppPluginActionEditor/components/ContextMenuItems/CopyToApp.test.tsx create mode 100644 app/client/src/pages/AppIDE/components/AppPluginActionEditor/components/ContextMenuItems/CopyToApp.tsx create mode 100644 app/client/src/pages/Editor/JSEditor/ContextMenuItems/CopyToApp.test.tsx create mode 100644 app/client/src/pages/Editor/JSEditor/ContextMenuItems/CopyToApp.tsx diff --git a/app/client/src/actions/copyToAppActions.ts b/app/client/src/actions/copyToAppActions.ts index 167b675c8e99..0351f422b382 100644 --- a/app/client/src/actions/copyToAppActions.ts +++ b/app/client/src/actions/copyToAppActions.ts @@ -1,5 +1,17 @@ import { ReduxActionTypes } from "ee/constants/ReduxActionConstants"; -import type { CopyEntityToAppPayload } from "pages/Editor/Explorer/CopyToApp/types"; +import type { + CopyEntityToAppPayload, + CopyToAppModalEntity, +} from "pages/Editor/Explorer/CopyToApp/types"; + +export const openCopyToAppModal = (payload: CopyToAppModalEntity) => ({ + type: ReduxActionTypes.OPEN_COPY_ENTITY_TO_APP_MODAL, + payload, +}); + +export const closeCopyToAppModal = () => ({ + type: ReduxActionTypes.CLOSE_COPY_ENTITY_TO_APP_MODAL, +}); export const copyActionToApp = (payload: CopyEntityToAppPayload) => ({ type: ReduxActionTypes.COPY_ACTION_TO_APP_INIT, diff --git a/app/client/src/ce/constants/ReduxActionConstants.tsx b/app/client/src/ce/constants/ReduxActionConstants.tsx index a04cb844c7ca..53f819550da4 100644 --- a/app/client/src/ce/constants/ReduxActionConstants.tsx +++ b/app/client/src/ce/constants/ReduxActionConstants.tsx @@ -223,6 +223,8 @@ const ImportExportActionTypes = { PARTIAL_EXPORT_MODAL_OPEN: "PARTIAL_EXPORT_MODAL_OPEN", PARTIAL_EXPORT_INIT: "PARTIAL_EXPORT_INIT", PARTIAL_EXPORT_SUCCESS: "PARTIAL_EXPORT_SUCCESS", + OPEN_COPY_ENTITY_TO_APP_MODAL: "OPEN_COPY_ENTITY_TO_APP_MODAL", + CLOSE_COPY_ENTITY_TO_APP_MODAL: "CLOSE_COPY_ENTITY_TO_APP_MODAL", COPY_ACTION_TO_APP_INIT: "COPY_ACTION_TO_APP_INIT", COPY_ACTION_TO_APP_SUCCESS: "COPY_ACTION_TO_APP_SUCCESS", COPY_JS_ACTION_TO_APP_INIT: "COPY_JS_ACTION_TO_APP_INIT", diff --git a/app/client/src/ce/pages/AppIDE/components/AppIDEModals.tsx b/app/client/src/ce/pages/AppIDE/components/AppIDEModals.tsx index 5a0b7d342951..6c92afc15d93 100644 --- a/app/client/src/ce/pages/AppIDE/components/AppIDEModals.tsx +++ b/app/client/src/ce/pages/AppIDE/components/AppIDEModals.tsx @@ -8,6 +8,7 @@ import { PartialExportModal } from "components/editorComponents/PartialImportExp import { PartialImportModal } from "components/editorComponents/PartialImportExport/PartialImportModal"; import { AppCURLImportModal } from "ee/pages/Editor/CurlImport"; import GeneratePageModal from "pages/Editor/GeneratePage"; +import CopyEntityToAppModal from "pages/Editor/Explorer/CopyToApp/CopyEntityToAppModal"; export function AppIDEModals() { return ( @@ -21,6 +22,7 @@ export function AppIDEModals() { + ); } diff --git a/app/client/src/pages/AppIDE/components/AppPluginActionEditor/components/ContextMenuItems/CopyToApp.test.tsx b/app/client/src/pages/AppIDE/components/AppPluginActionEditor/components/ContextMenuItems/CopyToApp.test.tsx new file mode 100644 index 000000000000..39b9694a1b26 --- /dev/null +++ b/app/client/src/pages/AppIDE/components/AppPluginActionEditor/components/ContextMenuItems/CopyToApp.test.tsx @@ -0,0 +1,44 @@ +import React from "react"; +import { render, fireEvent, screen } from "@testing-library/react"; +import { Menu, MenuContent, MenuTrigger } from "@appsmith/ads"; +import type { Action } from "entities/Action"; +import { CopyToApp } from "./CopyToApp"; +import { openCopyToAppModal } from "actions/copyToAppActions"; + +const mockDispatch = jest.fn(); + +jest.mock("react-redux", () => ({ + useDispatch: () => mockDispatch, +})); + +const action = { + id: "action-1", + name: "Query1", + pageId: "page-1", +} as unknown as Action; + +describe("CopyToApp query menu item", () => { + beforeEach(() => mockDispatch.mockClear()); + + it("dispatches openCopyToAppModal with the action descriptor on select", () => { + render( + + trigger + + + + , + ); + + fireEvent.click(screen.getByText("Copy to application")); + + expect(mockDispatch).toHaveBeenCalledWith( + openCopyToAppModal({ + entityType: "ACTION", + entityId: "action-1", + entityName: "Query1", + sourcePageId: "page-1", + }), + ); + }); +}); diff --git a/app/client/src/pages/AppIDE/components/AppPluginActionEditor/components/ContextMenuItems/CopyToApp.tsx b/app/client/src/pages/AppIDE/components/AppPluginActionEditor/components/ContextMenuItems/CopyToApp.tsx new file mode 100644 index 000000000000..28716c45154a --- /dev/null +++ b/app/client/src/pages/AppIDE/components/AppPluginActionEditor/components/ContextMenuItems/CopyToApp.tsx @@ -0,0 +1,33 @@ +import React, { useCallback } from "react"; +import { CONTEXT_COPY_TO_APP, createMessage } from "ee/constants/messages"; +import { MenuItem } from "@appsmith/ads"; +import type { Action } from "entities/Action"; +import { useDispatch } from "react-redux"; +import { openCopyToAppModal } from "actions/copyToAppActions"; +import { CopyToAppEntityType } from "pages/Editor/Explorer/CopyToApp/types"; + +interface Props { + action: Action; + disabled?: boolean; +} + +export const CopyToApp = ({ action, disabled }: Props) => { + const dispatch = useDispatch(); + + const handleSelect = useCallback(() => { + dispatch( + openCopyToAppModal({ + entityType: CopyToAppEntityType.ACTION, + entityId: action.id, + entityName: action.name, + sourcePageId: action.pageId, + }), + ); + }, [dispatch, action.id, action.name, action.pageId]); + + return ( + + {createMessage(CONTEXT_COPY_TO_APP)} + + ); +}; diff --git a/app/client/src/pages/AppIDE/components/AppPluginActionEditor/components/ContextMenuItems/index.ts b/app/client/src/pages/AppIDE/components/AppPluginActionEditor/components/ContextMenuItems/index.ts index dba139b4290d..f5d8f228912f 100644 --- a/app/client/src/pages/AppIDE/components/AppPluginActionEditor/components/ContextMenuItems/index.ts +++ b/app/client/src/pages/AppIDE/components/AppPluginActionEditor/components/ContextMenuItems/index.ts @@ -1,4 +1,5 @@ export { Copy } from "./Copy"; +export { CopyToApp } from "./CopyToApp"; export { Move } from "./Move"; export { Delete } from "./Delete"; export { Rename } from "./Rename"; diff --git a/app/client/src/pages/AppIDE/components/JSEntityItem/AppJSContextMenuItems.tsx b/app/client/src/pages/AppIDE/components/JSEntityItem/AppJSContextMenuItems.tsx index bac234dc46f5..629d50c6ffbc 100644 --- a/app/client/src/pages/AppIDE/components/JSEntityItem/AppJSContextMenuItems.tsx +++ b/app/client/src/pages/AppIDE/components/JSEntityItem/AppJSContextMenuItems.tsx @@ -8,6 +8,7 @@ import { import type { JSCollection } from "entities/JSCollection"; import { Copy, + CopyToApp, Delete, Move, Rename, @@ -42,6 +43,7 @@ export function AppJSContextMenuItems(props: Props) { + diff --git a/app/client/src/pages/AppIDE/components/QueryEntityItem/AppQueryContextMenuItems.tsx b/app/client/src/pages/AppIDE/components/QueryEntityItem/AppQueryContextMenuItems.tsx index da0ea7d31cd9..5a7cbdf3b7c3 100644 --- a/app/client/src/pages/AppIDE/components/QueryEntityItem/AppQueryContextMenuItems.tsx +++ b/app/client/src/pages/AppIDE/components/QueryEntityItem/AppQueryContextMenuItems.tsx @@ -9,6 +9,7 @@ import { import { ConvertToModule, Copy, + CopyToApp, Delete, Move, Rename, @@ -44,6 +45,7 @@ export function AppQueryContextMenuItems(props: Props) { + diff --git a/app/client/src/pages/Editor/Explorer/Actions/ActionEntityContextMenu.tsx b/app/client/src/pages/Editor/Explorer/Actions/ActionEntityContextMenu.tsx index d24ee4524543..c54d1a8a74c9 100644 --- a/app/client/src/pages/Editor/Explorer/Actions/ActionEntityContextMenu.tsx +++ b/app/client/src/pages/Editor/Explorer/Actions/ActionEntityContextMenu.tsx @@ -13,7 +13,6 @@ import { ReduxActionTypes } from "ee/constants/ReduxActionConstants"; import { ENTITY_TYPE } from "ee/entities/DataTree/types"; import { CONTEXT_COPY, - CONTEXT_COPY_TO_APP, CONTEXT_DELETE, CONFIRM_CONTEXT_DELETE, CONTEXT_RENAME, @@ -23,8 +22,6 @@ import { createMessage, CONTEXT_DUPLICATE, } from "ee/constants/messages"; -import CopyEntityToAppModal from "pages/Editor/Explorer/CopyToApp/CopyEntityToAppModal"; -import { CopyToAppEntityType } from "pages/Editor/Explorer/CopyToApp/types"; import { builderURL } from "ee/RouteBuilder"; import ContextMenu from "pages/Editor/Explorer/ContextMenu"; @@ -59,7 +56,6 @@ export function ActionEntityContextMenu(props: EntityContextMenuProps) { const { canDeleteAction, canManageAction } = props; const dispatch = useDispatch(); const [confirmDelete, setConfirmDelete] = useState(false); - const [showCopyToAppModal, setShowCopyToAppModal] = useState(false); const copyAction = useCallback( (actionId: string, actionName: string, destinationEntityId: string) => dispatch( @@ -157,13 +153,6 @@ export function ActionEntityContextMenu(props: EntityContextMenuProps) { }; }), }, - menuItems.includes(ActionEntityContextMenuItemsEnum.COPY_TO_APP) && - canManageAction && - parentEntityType === ActionParentEntityType.PAGE && { - value: "copyToApp", - onSelect: () => setShowCopyToAppModal(true), - label: createMessage(CONTEXT_COPY_TO_APP), - }, menuItems.includes(ActionEntityContextMenuItemsEnum.MOVE) && canManageAction && { value: "move", @@ -209,23 +198,11 @@ export function ActionEntityContextMenu(props: EntityContextMenuProps) { ].filter(Boolean); return optionsTree.length > 0 ? ( - <> - - {showCopyToAppModal && ( - setShowCopyToAppModal(false)} - sourcePageId={parentEntityId} - /> - )} - + ) : null; } diff --git a/app/client/src/pages/Editor/Explorer/CopyToApp/CopyEntityToAppModal.tsx b/app/client/src/pages/Editor/Explorer/CopyToApp/CopyEntityToAppModal.tsx index 5e6382e721ca..3063332d60bc 100644 --- a/app/client/src/pages/Editor/Explorer/CopyToApp/CopyEntityToAppModal.tsx +++ b/app/client/src/pages/Editor/Explorer/CopyToApp/CopyEntityToAppModal.tsx @@ -35,26 +35,20 @@ import { createMessage, } from "ee/constants/messages"; import { + closeCopyToAppModal, copyActionToApp, copyJSActionToApp, fetchAppsForCopyTarget, } from "actions/copyToAppActions"; import { getCopyTargetApplications, + getCopyToAppModalEntity, getIsCopyingEntityToApp, + getIsCopyToAppModalOpen, getIsFetchingCopyTargetApplications, } from "selectors/copyToAppSelectors"; import { CopyToAppEntityType } from "./types"; -interface CopyEntityToAppModalProps { - isOpen: boolean; - onClose: () => void; - entityType: CopyToAppEntityType; - entityId: string; - entityName: string; - sourcePageId: string; -} - const FieldWrapper = ({ children, label, @@ -70,11 +64,12 @@ const FieldWrapper = ({ ); -function CopyEntityToAppModal(props: CopyEntityToAppModalProps) { - const { entityId, entityName, entityType, isOpen, onClose, sourcePageId } = - props; +function CopyEntityToAppModal() { const dispatch = useDispatch(); + const isOpen = useSelector(getIsCopyToAppModalOpen); + const entity = useSelector(getCopyToAppModalEntity); + const [workspaceId, setWorkspaceId] = useState(""); const [applicationId, setApplicationId] = useState(""); const [pageId, setPageId] = useState(""); @@ -126,10 +121,13 @@ function CopyEntityToAppModal(props: CopyEntityToAppModalProps) { [pageOptions, pageId], ); - // Fetch the list of workspaces when the modal is opened. + // Reset the cascading selections and fetch workspaces when the modal opens. useEffect( - function fetchWorkspacesOnOpen() { + function onModalOpen() { if (isOpen) { + setWorkspaceId(""); + setApplicationId(""); + setPageId(""); dispatch(fetchAllWorkspaces({ fetchEntities: false })); } }, @@ -158,19 +156,16 @@ function CopyEntityToAppModal(props: CopyEntityToAppModalProps) { }; const handleClose = () => { - setWorkspaceId(""); - setApplicationId(""); - setPageId(""); - onClose(); + dispatch(closeCopyToAppModal()); }; const handleCopy = () => { - if (!selectedApplication || !selectedPage) return; + if (!entity || !selectedApplication || !selectedPage) return; const payload = { - entityId, - entityName, - sourcePageId, + entityId: entity.entityId, + entityName: entity.entityName, + sourcePageId: entity.sourcePageId, targetWorkspaceId: workspaceId, targetApplicationId: applicationId, targetApplicationName: selectedApplication.name, @@ -180,7 +175,7 @@ function CopyEntityToAppModal(props: CopyEntityToAppModalProps) { }; dispatch( - entityType === CopyToAppEntityType.JS_OBJECT + entity.entityType === CopyToAppEntityType.JS_OBJECT ? copyJSActionToApp(payload) : copyActionToApp(payload), ); diff --git a/app/client/src/pages/Editor/Explorer/CopyToApp/types.ts b/app/client/src/pages/Editor/Explorer/CopyToApp/types.ts index b9fd975e8906..f0285582ceef 100644 --- a/app/client/src/pages/Editor/Explorer/CopyToApp/types.ts +++ b/app/client/src/pages/Editor/Explorer/CopyToApp/types.ts @@ -3,6 +3,14 @@ export enum CopyToAppEntityType { JS_OBJECT = "JS_OBJECT", } +// Identifies the source entity to be copied; used to open the modal. +export interface CopyToAppModalEntity { + entityType: CopyToAppEntityType; + entityId: string; + entityName: string; + sourcePageId: string; +} + export interface CopyEntityToAppPayload { entityId: string; entityName: string; diff --git a/app/client/src/pages/Editor/Explorer/Files/FilesContextProvider.tsx b/app/client/src/pages/Editor/Explorer/Files/FilesContextProvider.tsx index c07134a0c4a7..48985dd7cea0 100644 --- a/app/client/src/pages/Editor/Explorer/Files/FilesContextProvider.tsx +++ b/app/client/src/pages/Editor/Explorer/Files/FilesContextProvider.tsx @@ -6,7 +6,6 @@ export enum ActionEntityContextMenuItemsEnum { SHOW_BINDING = "Show Bindings", CONVERT_QUERY_MODULE_INSTANCE = "Create Module", COPY = "Copy", - COPY_TO_APP = "Copy to application", MOVE = "Move", DELETE = "Delete", } @@ -16,7 +15,6 @@ export const defaultMenuItems = [ ActionEntityContextMenuItemsEnum.DELETE, ActionEntityContextMenuItemsEnum.SHOW_BINDING, ActionEntityContextMenuItemsEnum.COPY, - ActionEntityContextMenuItemsEnum.COPY_TO_APP, ActionEntityContextMenuItemsEnum.MOVE, ActionEntityContextMenuItemsEnum.CONVERT_QUERY_MODULE_INSTANCE, ]; diff --git a/app/client/src/pages/Editor/Explorer/JSActions/JSActionContextMenu.tsx b/app/client/src/pages/Editor/Explorer/JSActions/JSActionContextMenu.tsx index 9c9de7a6318f..86bf1f6f3a1f 100644 --- a/app/client/src/pages/Editor/Explorer/JSActions/JSActionContextMenu.tsx +++ b/app/client/src/pages/Editor/Explorer/JSActions/JSActionContextMenu.tsx @@ -11,7 +11,6 @@ import { ReduxActionTypes } from "ee/constants/ReduxActionConstants"; import { ENTITY_TYPE } from "ee/entities/DataTree/types"; import { CONTEXT_COPY, - CONTEXT_COPY_TO_APP, CONTEXT_DELETE, CONFIRM_CONTEXT_DELETE, CONTEXT_RENAME, @@ -28,9 +27,6 @@ import { ActionEntityContextMenuItemsEnum, FilesContext, } from "../Files/FilesContextProvider"; -import CopyEntityToAppModal from "pages/Editor/Explorer/CopyToApp/CopyEntityToAppModal"; -import { CopyToAppEntityType } from "pages/Editor/Explorer/CopyToApp/types"; -import { ActionParentEntityType } from "ee/entities/Engine/actionHelpers"; interface EntityContextMenuProps { id: string; @@ -44,11 +40,10 @@ interface EntityContextMenuProps { export function JSCollectionEntityContextMenu(props: EntityContextMenuProps) { // Import the context const context = useContext(FilesContext); - const { menuItems, parentEntityId, parentEntityType } = context; + const { menuItems, parentEntityId } = context; const { canDelete, canManage } = props; const [confirmDelete, setConfirmDelete] = useState(false); - const [showCopyToAppModal, setShowCopyToAppModal] = useState(false); const dispatch = useDispatch(); const showBinding = useCallback( @@ -126,13 +121,6 @@ export function JSCollectionEntityContextMenu(props: EntityContextMenuProps) { }; }), }, - menuItems.includes(ActionEntityContextMenuItemsEnum.COPY_TO_APP) && - canManage && - parentEntityType === ActionParentEntityType.PAGE && { - value: "copyToApp", - onSelect: () => setShowCopyToAppModal(true), - label: createMessage(CONTEXT_COPY_TO_APP), - }, menuItems.includes(ActionEntityContextMenuItemsEnum.MOVE) && canManage && { value: "move", @@ -175,23 +163,11 @@ export function JSCollectionEntityContextMenu(props: EntityContextMenuProps) { ].filter(Boolean); return !props.hideMenuItems && optionsTree.length > 0 ? ( - <> - - {showCopyToAppModal && ( - setShowCopyToAppModal(false)} - sourcePageId={parentEntityId} - /> - )} - + ) : null; } diff --git a/app/client/src/pages/Editor/JSEditor/ContextMenuItems/CopyToApp.test.tsx b/app/client/src/pages/Editor/JSEditor/ContextMenuItems/CopyToApp.test.tsx new file mode 100644 index 000000000000..4f4964213194 --- /dev/null +++ b/app/client/src/pages/Editor/JSEditor/ContextMenuItems/CopyToApp.test.tsx @@ -0,0 +1,44 @@ +import React from "react"; +import { render, fireEvent, screen } from "@testing-library/react"; +import { Menu, MenuContent, MenuTrigger } from "@appsmith/ads"; +import type { JSCollection } from "entities/JSCollection"; +import { CopyToApp } from "./CopyToApp"; +import { openCopyToAppModal } from "actions/copyToAppActions"; + +const mockDispatch = jest.fn(); + +jest.mock("react-redux", () => ({ + useDispatch: () => mockDispatch, +})); + +const jsAction = { + id: "js-1", + name: "JSObject1", + pageId: "page-1", +} as unknown as JSCollection; + +describe("CopyToApp JS menu item", () => { + beforeEach(() => mockDispatch.mockClear()); + + it("dispatches openCopyToAppModal with the JS-object descriptor on select", () => { + render( + + trigger + + + + , + ); + + fireEvent.click(screen.getByText("Copy to application")); + + expect(mockDispatch).toHaveBeenCalledWith( + openCopyToAppModal({ + entityType: "JS_OBJECT", + entityId: "js-1", + entityName: "JSObject1", + sourcePageId: "page-1", + }), + ); + }); +}); diff --git a/app/client/src/pages/Editor/JSEditor/ContextMenuItems/CopyToApp.tsx b/app/client/src/pages/Editor/JSEditor/ContextMenuItems/CopyToApp.tsx new file mode 100644 index 000000000000..30fe74c9dfa3 --- /dev/null +++ b/app/client/src/pages/Editor/JSEditor/ContextMenuItems/CopyToApp.tsx @@ -0,0 +1,33 @@ +import React, { useCallback } from "react"; +import { CONTEXT_COPY_TO_APP, createMessage } from "ee/constants/messages"; +import { MenuItem } from "@appsmith/ads"; +import type { JSCollection } from "entities/JSCollection"; +import { useDispatch } from "react-redux"; +import { openCopyToAppModal } from "actions/copyToAppActions"; +import { CopyToAppEntityType } from "pages/Editor/Explorer/CopyToApp/types"; + +interface Props { + jsAction: JSCollection; + disabled?: boolean; +} + +export const CopyToApp = ({ disabled, jsAction }: Props) => { + const dispatch = useDispatch(); + + const handleSelect = useCallback(() => { + dispatch( + openCopyToAppModal({ + entityType: CopyToAppEntityType.JS_OBJECT, + entityId: jsAction.id, + entityName: jsAction.name, + sourcePageId: jsAction.pageId, + }), + ); + }, [dispatch, jsAction.id, jsAction.name, jsAction.pageId]); + + return ( + + {createMessage(CONTEXT_COPY_TO_APP)} + + ); +}; diff --git a/app/client/src/pages/Editor/JSEditor/ContextMenuItems/index.tsx b/app/client/src/pages/Editor/JSEditor/ContextMenuItems/index.tsx index 9d7cf3e78950..98d0d6899d2e 100644 --- a/app/client/src/pages/Editor/JSEditor/ContextMenuItems/index.tsx +++ b/app/client/src/pages/Editor/JSEditor/ContextMenuItems/index.tsx @@ -1,4 +1,5 @@ export { Copy } from "./Copy"; +export { CopyToApp } from "./CopyToApp"; export { Move } from "./Move"; export { Delete } from "./Delete"; export { Rename } from "./Rename"; diff --git a/app/client/src/reducers/uiReducers/copyEntityToAppReducer.test.ts b/app/client/src/reducers/uiReducers/copyEntityToAppReducer.test.ts index 3655110361d6..7dcaf4d4a9d7 100644 --- a/app/client/src/reducers/uiReducers/copyEntityToAppReducer.test.ts +++ b/app/client/src/reducers/uiReducers/copyEntityToAppReducer.test.ts @@ -6,6 +6,8 @@ import reducer from "reducers/uiReducers/copyEntityToAppReducer"; import type { ApplicationPayload } from "entities/Application"; const initialState = { + isModalOpen: false, + entity: null, targetApplications: [], isFetchingApplications: false, isCopying: false, @@ -13,7 +15,35 @@ const initialState = { const sampleApps = [{ id: "app-1" }] as unknown as ApplicationPayload[]; +const sampleEntity = { + entityType: "ACTION", + entityId: "entity-1", + entityName: "Query1", + sourcePageId: "page-1", +}; + describe("copyEntityToAppReducer", () => { + it("opens the modal and stores the entity on open", () => { + const state = reducer(initialState, { + type: ReduxActionTypes.OPEN_COPY_ENTITY_TO_APP_MODAL, + payload: sampleEntity, + }); + + expect(state.isModalOpen).toBe(true); + expect(state.entity).toEqual(sampleEntity); + expect(state.targetApplications).toEqual([]); + }); + + it("closes the modal and clears the entity on close", () => { + const state = reducer( + { ...initialState, isModalOpen: true, entity: sampleEntity }, + { type: ReduxActionTypes.CLOSE_COPY_ENTITY_TO_APP_MODAL, payload: {} }, + ); + + expect(state.isModalOpen).toBe(false); + expect(state.entity).toBeNull(); + }); + it("sets isFetchingApplications and clears the list on fetch init", () => { const state = reducer( { ...initialState, targetApplications: sampleApps }, diff --git a/app/client/src/reducers/uiReducers/copyEntityToAppReducer.ts b/app/client/src/reducers/uiReducers/copyEntityToAppReducer.ts index e0615b8b5543..f4731becd654 100644 --- a/app/client/src/reducers/uiReducers/copyEntityToAppReducer.ts +++ b/app/client/src/reducers/uiReducers/copyEntityToAppReducer.ts @@ -5,20 +5,41 @@ import { ReduxActionErrorTypes, } from "ee/constants/ReduxActionConstants"; import type { ApplicationPayload } from "entities/Application"; +import type { CopyToAppModalEntity } from "pages/Editor/Explorer/CopyToApp/types"; export interface CopyEntityToAppReduxState { + isModalOpen: boolean; + entity: CopyToAppModalEntity | null; targetApplications: ApplicationPayload[]; isFetchingApplications: boolean; isCopying: boolean; } const initialState: CopyEntityToAppReduxState = { + isModalOpen: false, + entity: null, targetApplications: [], isFetchingApplications: false, isCopying: false, }; const copyEntityToAppReducer = createReducer(initialState, { + [ReduxActionTypes.OPEN_COPY_ENTITY_TO_APP_MODAL]: ( + state: CopyEntityToAppReduxState, + action: ReduxAction, + ) => ({ + ...state, + isModalOpen: true, + entity: action.payload, + targetApplications: [], + }), + [ReduxActionTypes.CLOSE_COPY_ENTITY_TO_APP_MODAL]: ( + state: CopyEntityToAppReduxState, + ) => ({ + ...state, + isModalOpen: false, + entity: null, + }), [ReduxActionTypes.FETCH_COPY_TARGET_APPLICATIONS_INIT]: ( state: CopyEntityToAppReduxState, ) => ({ diff --git a/app/client/src/selectors/copyToAppSelectors.ts b/app/client/src/selectors/copyToAppSelectors.ts index ad671cbe2e83..0852831f0fb2 100644 --- a/app/client/src/selectors/copyToAppSelectors.ts +++ b/app/client/src/selectors/copyToAppSelectors.ts @@ -1,5 +1,13 @@ import type { DefaultRootState } from "react-redux"; import type { ApplicationPayload } from "entities/Application"; +import type { CopyToAppModalEntity } from "pages/Editor/Explorer/CopyToApp/types"; + +export const getIsCopyToAppModalOpen = (state: DefaultRootState): boolean => + state.ui.copyEntityToApp.isModalOpen; + +export const getCopyToAppModalEntity = ( + state: DefaultRootState, +): CopyToAppModalEntity | null => state.ui.copyEntityToApp.entity; export const getCopyTargetApplications = ( state: DefaultRootState, From 02fcb79eea7a63040ef6f56efac0384fc7ae992c Mon Sep 17 00:00:00 2001 From: Stacey Levine Date: Tue, 23 Jun 2026 13:43:26 -0400 Subject: [PATCH 3/7] fix(client): use CopyToAppEntityType enum in menu-item tests (#41919) The two CopyToApp menu-item tests passed entityType as string literals ("ACTION"/"JS_OBJECT"), which the strongly-typed openCopyToAppModal rejects. tsc had not re-run locally after these test files were added, so CI's type-check caught it. Use the CopyToAppEntityType enum members. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/ContextMenuItems/CopyToApp.test.tsx | 3 ++- .../pages/Editor/JSEditor/ContextMenuItems/CopyToApp.test.tsx | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/client/src/pages/AppIDE/components/AppPluginActionEditor/components/ContextMenuItems/CopyToApp.test.tsx b/app/client/src/pages/AppIDE/components/AppPluginActionEditor/components/ContextMenuItems/CopyToApp.test.tsx index 39b9694a1b26..7956e4cfbabe 100644 --- a/app/client/src/pages/AppIDE/components/AppPluginActionEditor/components/ContextMenuItems/CopyToApp.test.tsx +++ b/app/client/src/pages/AppIDE/components/AppPluginActionEditor/components/ContextMenuItems/CopyToApp.test.tsx @@ -4,6 +4,7 @@ import { Menu, MenuContent, MenuTrigger } from "@appsmith/ads"; import type { Action } from "entities/Action"; import { CopyToApp } from "./CopyToApp"; import { openCopyToAppModal } from "actions/copyToAppActions"; +import { CopyToAppEntityType } from "pages/Editor/Explorer/CopyToApp/types"; const mockDispatch = jest.fn(); @@ -34,7 +35,7 @@ describe("CopyToApp query menu item", () => { expect(mockDispatch).toHaveBeenCalledWith( openCopyToAppModal({ - entityType: "ACTION", + entityType: CopyToAppEntityType.ACTION, entityId: "action-1", entityName: "Query1", sourcePageId: "page-1", diff --git a/app/client/src/pages/Editor/JSEditor/ContextMenuItems/CopyToApp.test.tsx b/app/client/src/pages/Editor/JSEditor/ContextMenuItems/CopyToApp.test.tsx index 4f4964213194..7f1d644553a7 100644 --- a/app/client/src/pages/Editor/JSEditor/ContextMenuItems/CopyToApp.test.tsx +++ b/app/client/src/pages/Editor/JSEditor/ContextMenuItems/CopyToApp.test.tsx @@ -4,6 +4,7 @@ import { Menu, MenuContent, MenuTrigger } from "@appsmith/ads"; import type { JSCollection } from "entities/JSCollection"; import { CopyToApp } from "./CopyToApp"; import { openCopyToAppModal } from "actions/copyToAppActions"; +import { CopyToAppEntityType } from "pages/Editor/Explorer/CopyToApp/types"; const mockDispatch = jest.fn(); @@ -34,7 +35,7 @@ describe("CopyToApp JS menu item", () => { expect(mockDispatch).toHaveBeenCalledWith( openCopyToAppModal({ - entityType: "JS_OBJECT", + entityType: CopyToAppEntityType.JS_OBJECT, entityId: "js-1", entityName: "JSObject1", sourcePageId: "page-1", From fb545b225a7f997262c331539b5f3440b0aebe94 Mon Sep 17 00:00:00 2001 From: Stacey Levine Date: Tue, 23 Jun 2026 15:38:26 -0400 Subject: [PATCH 4/7] fix(client): show page names and hide the source app in copy-to-app picker (#41919) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes to the "Copy to application" picker: - The Page dropdown showed the page's ObjectId instead of its name. The /applications/home payload's pages are server ApplicationPage domain objects with no `name` field (only id/slug/baseId). Fetch the target app's named pages on application-select via PageApi.fetchAppAndPages (the same /v1/pages endpoint the editor uses), store them in the copyEntityToApp slice (targetPages/isFetchingPages), and render those. New action FETCH_COPY_TARGET_PAGES_*, creator fetchPagesForCopyTarget, saga fetchPagesForCopyTargetSaga (mirrors fetchAppsForCopyTargetSaga), selectors getCopyTargetPages/getIsFetchingCopyTargetPages. - Exclude the source application (getCurrentApplicationId) from the target application list — you can't copy an entity into the app it came from. Server enforcement is unchanged: GET /v1/pages requires edit permission on the application and read permission per page (verified), and the copy itself still goes through the partial import with MANAGE_PAGES re-checked. Tests: add fetchPagesForCopyTargetSaga + reducer page-fetch coverage, and a CopyEntityToAppModal render test asserting the source app is excluded and that selecting an app dispatches fetchPagesForCopyTarget. tsc, ESLint, Prettier, and 19 unit tests across 5 suites all pass. Reviewed via the council (architect, security, QA) — architect/security APPROVE, QA APPROVE WITH RISKS (gap closed by the new modal tests). Co-Authored-By: Claude Opus 4.8 (1M context) --- app/client/src/actions/copyToAppActions.ts | 5 ++ .../src/ce/constants/ReduxActionConstants.tsx | 3 + .../src/ce/sagas/CopyToAppSagas.test.ts | 35 ++++++++ app/client/src/ce/sagas/CopyToAppSagas.ts | 37 +++++++++ .../CopyToApp/CopyEntityToAppModal.test.tsx | 82 +++++++++++++++++++ .../CopyToApp/CopyEntityToAppModal.tsx | 57 +++++++------ .../uiReducers/copyEntityToAppReducer.test.ts | 25 ++++++ .../uiReducers/copyEntityToAppReducer.ts | 28 +++++++ .../src/selectors/copyToAppSelectors.ts | 9 ++ 9 files changed, 258 insertions(+), 23 deletions(-) create mode 100644 app/client/src/pages/Editor/Explorer/CopyToApp/CopyEntityToAppModal.test.tsx diff --git a/app/client/src/actions/copyToAppActions.ts b/app/client/src/actions/copyToAppActions.ts index 0351f422b382..02014fb6cf70 100644 --- a/app/client/src/actions/copyToAppActions.ts +++ b/app/client/src/actions/copyToAppActions.ts @@ -27,3 +27,8 @@ export const fetchAppsForCopyTarget = (workspaceId: string) => ({ type: ReduxActionTypes.FETCH_COPY_TARGET_APPLICATIONS_INIT, payload: { workspaceId }, }); + +export const fetchPagesForCopyTarget = (applicationId: string) => ({ + type: ReduxActionTypes.FETCH_COPY_TARGET_PAGES_INIT, + payload: { applicationId }, +}); diff --git a/app/client/src/ce/constants/ReduxActionConstants.tsx b/app/client/src/ce/constants/ReduxActionConstants.tsx index 53f819550da4..c1ce8e312f7e 100644 --- a/app/client/src/ce/constants/ReduxActionConstants.tsx +++ b/app/client/src/ce/constants/ReduxActionConstants.tsx @@ -232,6 +232,8 @@ const ImportExportActionTypes = { FETCH_COPY_TARGET_APPLICATIONS_INIT: "FETCH_COPY_TARGET_APPLICATIONS_INIT", FETCH_COPY_TARGET_APPLICATIONS_SUCCESS: "FETCH_COPY_TARGET_APPLICATIONS_SUCCESS", + FETCH_COPY_TARGET_PAGES_INIT: "FETCH_COPY_TARGET_PAGES_INIT", + FETCH_COPY_TARGET_PAGES_SUCCESS: "FETCH_COPY_TARGET_PAGES_SUCCESS", IMPORT_APPLICATION_INIT: "IMPORT_APPLICATION_INIT", IMPORT_APPLICATION_FROM_GIT_INIT: "IMPORT_APPLICATION_FROM_GIT_INIT", IMPORT_APPLICATION_SUCCESS: "IMPORT_APPLICATION_SUCCESS", @@ -245,6 +247,7 @@ const ImportExportActionErrorTypes = { COPY_ACTION_TO_APP_ERROR: "COPY_ACTION_TO_APP_ERROR", COPY_JS_ACTION_TO_APP_ERROR: "COPY_JS_ACTION_TO_APP_ERROR", FETCH_COPY_TARGET_APPLICATIONS_ERROR: "FETCH_COPY_TARGET_APPLICATIONS_ERROR", + FETCH_COPY_TARGET_PAGES_ERROR: "FETCH_COPY_TARGET_PAGES_ERROR", }; const ImportGitActionTypes = { diff --git a/app/client/src/ce/sagas/CopyToAppSagas.test.ts b/app/client/src/ce/sagas/CopyToAppSagas.test.ts index 24a6be97f349..b5de7c0ecde1 100644 --- a/app/client/src/ce/sagas/CopyToAppSagas.test.ts +++ b/app/client/src/ce/sagas/CopyToAppSagas.test.ts @@ -10,7 +10,10 @@ import { copyActionToAppSaga, copyJSActionToAppSaga, fetchAppsForCopyTargetSaga, + fetchPagesForCopyTargetSaga, } from "ee/sagas/CopyToAppSagas"; +import PageApi from "api/PageApi"; +import { APP_MODE } from "entities/App"; import { validateResponse } from "sagas/ErrorSagas"; import { CopyToAppEntityType } from "pages/Editor/Explorer/CopyToApp/types"; @@ -293,3 +296,35 @@ describe("fetchAppsForCopyTargetSaga", () => { expect(gen.next().done).toBe(true); }); }); + +describe("fetchPagesForCopyTargetSaga", () => { + it("fetches named pages for the target app and stores them in the slice", () => { + const gen: Generator = fetchPagesForCopyTargetSaga({ + type: ReduxActionTypes.FETCH_COPY_TARGET_PAGES_INIT, + payload: { applicationId: "app-9" }, + }); + + expect(gen.next().value).toEqual( + call(PageApi.fetchAppAndPages, { + applicationId: "app-9", + mode: APP_MODE.EDIT, + }), + ); + + const response = { + data: { pages: [{ id: "pg-1", name: "Page1" }] }, + responseMeta: {}, + }; + + expect(gen.next(response).value).toEqual(call(validateResponse, response)); + + expect(gen.next(true).value).toEqual( + put({ + type: ReduxActionTypes.FETCH_COPY_TARGET_PAGES_SUCCESS, + payload: { pages: [{ id: "pg-1", name: "Page1" }] }, + }), + ); + + expect(gen.next().done).toBe(true); + }); +}); diff --git a/app/client/src/ce/sagas/CopyToAppSagas.ts b/app/client/src/ce/sagas/CopyToAppSagas.ts index 647fb2cc9bf8..2d9425d47fcd 100644 --- a/app/client/src/ce/sagas/CopyToAppSagas.ts +++ b/app/client/src/ce/sagas/CopyToAppSagas.ts @@ -1,6 +1,9 @@ import ApplicationApi, { type exportApplicationRequest, + type FetchApplicationResponse, } from "ee/api/ApplicationApi"; +import PageApi from "api/PageApi"; +import { APP_MODE } from "entities/App"; import type { ReduxAction } from "actions/ReduxActionTypes"; import { ReduxActionErrorTypes, @@ -58,6 +61,36 @@ export function* fetchAppsForCopyTargetSaga( } } +/** + * Fetches the named pages of a target application for the copy-to-app picker. + * + * The `/applications/home` payload only carries page ids/slugs (no names), so + * page names are fetched separately via the same endpoint the editor uses. + */ +export function* fetchPagesForCopyTargetSaga( + action: ReduxAction<{ applicationId: string }>, +) { + try { + const response: FetchApplicationResponse = yield call( + PageApi.fetchAppAndPages, + { applicationId: action.payload.applicationId, mode: APP_MODE.EDIT }, + ); + const isValidResponse: boolean = yield call(validateResponse, response); + + if (isValidResponse) { + yield put({ + type: ReduxActionTypes.FETCH_COPY_TARGET_PAGES_SUCCESS, + payload: { pages: response.data?.pages || [] }, + }); + } + } catch (error) { + yield put({ + type: ReduxActionErrorTypes.FETCH_COPY_TARGET_PAGES_ERROR, + payload: { error }, + }); + } +} + /** * Orchestrates copying a single entity (action/query or JS object) into a page * of a different application, possibly in a different workspace. @@ -194,6 +227,10 @@ export default function* copyToAppSagas() { ReduxActionTypes.FETCH_COPY_TARGET_APPLICATIONS_INIT, fetchAppsForCopyTargetSaga, ), + takeLatest( + ReduxActionTypes.FETCH_COPY_TARGET_PAGES_INIT, + fetchPagesForCopyTargetSaga, + ), takeLatest(ReduxActionTypes.COPY_ACTION_TO_APP_INIT, copyActionToAppSaga), takeLatest( ReduxActionTypes.COPY_JS_ACTION_TO_APP_INIT, diff --git a/app/client/src/pages/Editor/Explorer/CopyToApp/CopyEntityToAppModal.test.tsx b/app/client/src/pages/Editor/Explorer/CopyToApp/CopyEntityToAppModal.test.tsx new file mode 100644 index 000000000000..34150f986139 --- /dev/null +++ b/app/client/src/pages/Editor/Explorer/CopyToApp/CopyEntityToAppModal.test.tsx @@ -0,0 +1,82 @@ +import React from "react"; +import { render, fireEvent, screen } from "@testing-library/react"; +import CopyEntityToAppModal from "./CopyEntityToAppModal"; +import { fetchPagesForCopyTarget } from "actions/copyToAppActions"; + +const mockDispatch = jest.fn(); + +jest.mock("react-redux", () => ({ + useDispatch: () => mockDispatch, + // Selectors below are mocked to ignore state, so call them with no args. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + useSelector: (selector: any) => selector(), +})); + +const applications = [ + { id: "source-app", name: "Source App", pages: [], userPermissions: [] }, + { id: "other-app", name: "Other App", pages: [], userPermissions: [] }, +]; + +jest.mock("selectors/copyToAppSelectors", () => ({ + getIsCopyToAppModalOpen: () => true, + getCopyToAppModalEntity: () => ({ + entityType: "ACTION", + entityId: "a1", + entityName: "Query1", + sourcePageId: "p1", + }), + getCopyTargetApplications: () => applications, + getCopyTargetPages: () => [], + getIsCopyingEntityToApp: () => false, + getIsFetchingCopyTargetApplications: () => false, + getIsFetchingCopyTargetPages: () => false, +})); + +jest.mock("ee/selectors/workspaceSelectors", () => ({ + getFetchedWorkspaces: () => [ + { id: "ws1", name: "Workspace 1", userPermissions: [] }, + ], +})); + +jest.mock("selectors/editorSelectors", () => ({ + getCurrentApplicationId: () => "source-app", +})); + +jest.mock("ee/utils/permissionHelpers", () => ({ + hasCreateNewAppPermission: () => true, + hasManagePagePermission: () => true, + isPermitted: () => true, + PERMISSION_TYPE: { MANAGE_APPLICATION: "manage:applications" }, +})); + +// Open the application dropdown after selecting the workspace, so rc-select +// renders the (lazily-rendered) application options. +function openApplicationOptions() { + fireEvent.mouseDown(screen.getByRole("combobox", { name: "Workspace" })); + fireEvent.click(screen.getByText("Workspace 1")); + fireEvent.mouseDown(screen.getByRole("combobox", { name: "Application" })); +} + +describe("CopyEntityToAppModal", () => { + beforeEach(() => mockDispatch.mockClear()); + + it("excludes the source application from the target application list", () => { + render(); + openApplicationOptions(); + + // The source app the entity is copied FROM must not be a target option. + expect(screen.queryByText("Source App")).toBeNull(); + expect(screen.getAllByText("Other App").length).toBeGreaterThan(0); + }); + + it("fetches the target application's pages when an application is selected", () => { + render(); + openApplicationOptions(); + + fireEvent.click(screen.getByText("Other App")); + + expect(mockDispatch).toHaveBeenCalledWith( + fetchPagesForCopyTarget("other-app"), + ); + }); +}); diff --git a/app/client/src/pages/Editor/Explorer/CopyToApp/CopyEntityToAppModal.tsx b/app/client/src/pages/Editor/Explorer/CopyToApp/CopyEntityToAppModal.tsx index 3063332d60bc..5ea37c547bf3 100644 --- a/app/client/src/pages/Editor/Explorer/CopyToApp/CopyEntityToAppModal.tsx +++ b/app/client/src/pages/Editor/Explorer/CopyToApp/CopyEntityToAppModal.tsx @@ -18,6 +18,7 @@ import { } from "ee/utils/permissionHelpers"; import { getFetchedWorkspaces } from "ee/selectors/workspaceSelectors"; import { fetchAllWorkspaces } from "ee/actions/workspaceActions"; +import { getCurrentApplicationId } from "selectors/editorSelectors"; import { CANCEL, COPY_ENTITY_TO_APP_APPLICATION_LABEL, @@ -39,13 +40,16 @@ import { copyActionToApp, copyJSActionToApp, fetchAppsForCopyTarget, + fetchPagesForCopyTarget, } from "actions/copyToAppActions"; import { getCopyTargetApplications, + getCopyTargetPages, getCopyToAppModalEntity, getIsCopyingEntityToApp, getIsCopyToAppModalOpen, getIsFetchingCopyTargetApplications, + getIsFetchingCopyTargetPages, } from "selectors/copyToAppSelectors"; import { CopyToAppEntityType } from "./types"; @@ -74,11 +78,14 @@ function CopyEntityToAppModal() { const [applicationId, setApplicationId] = useState(""); const [pageId, setPageId] = useState(""); + const currentApplicationId = useSelector(getCurrentApplicationId); const workspaces = useSelector(getFetchedWorkspaces); const applications = useSelector(getCopyTargetApplications); + const pages = useSelector(getCopyTargetPages); const isFetchingApplications = useSelector( getIsFetchingCopyTargetApplications, ); + const isFetchingPages = useSelector(getIsFetchingCopyTargetPages); const isCopying = useSelector(getIsCopyingEntityToApp); const workspaceOptions = useMemo( @@ -91,13 +98,16 @@ function CopyEntityToAppModal() { const applicationOptions = useMemo( () => - applications.filter((application) => - isPermitted( - application.userPermissions ?? [], - PERMISSION_TYPE.MANAGE_APPLICATION, - ), + applications.filter( + (application) => + // The source application cannot be a copy target. + application.id !== currentApplicationId && + isPermitted( + application.userPermissions ?? [], + PERMISSION_TYPE.MANAGE_APPLICATION, + ), ), - [applications], + [applications, currentApplicationId], ); const selectedApplication = useMemo( @@ -110,10 +120,10 @@ function CopyEntityToAppModal() { const pageOptions = useMemo( () => - (selectedApplication?.pages ?? []).filter((page) => + pages.filter((page) => hasManagePagePermission(page.userPermissions ?? []), ), - [selectedApplication], + [pages], ); const selectedPage = useMemo( @@ -153,6 +163,7 @@ function CopyEntityToAppModal() { const handleApplicationSelect = (value: string) => { setApplicationId(value); setPageId(""); + dispatch(fetchPagesForCopyTarget(value)); }; const handleClose = () => { @@ -252,21 +263,21 @@ function CopyEntityToAppModal() { {!!applicationId && ( - {pageOptions.length ? ( - - ) : ( + + {!isFetchingPages && !pageOptions.length && ( {createMessage(COPY_ENTITY_TO_APP_NO_PAGES)} diff --git a/app/client/src/reducers/uiReducers/copyEntityToAppReducer.test.ts b/app/client/src/reducers/uiReducers/copyEntityToAppReducer.test.ts index 7dcaf4d4a9d7..966c5089cc35 100644 --- a/app/client/src/reducers/uiReducers/copyEntityToAppReducer.test.ts +++ b/app/client/src/reducers/uiReducers/copyEntityToAppReducer.test.ts @@ -4,12 +4,15 @@ import { } from "ee/constants/ReduxActionConstants"; import reducer from "reducers/uiReducers/copyEntityToAppReducer"; import type { ApplicationPayload } from "entities/Application"; +import type { ApplicationPagePayload } from "ee/api/ApplicationApi"; const initialState = { isModalOpen: false, entity: null, targetApplications: [], isFetchingApplications: false, + targetPages: [], + isFetchingPages: false, isCopying: false, }; @@ -70,6 +73,28 @@ describe("copyEntityToAppReducer", () => { expect(state.targetApplications).toEqual(sampleApps); }); + it("stores the fetched pages on page fetch success", () => { + const samplePages = [ + { id: "pg-1", name: "Page1" }, + ] as unknown as ApplicationPagePayload[]; + + const fetching = reducer(initialState, { + type: ReduxActionTypes.FETCH_COPY_TARGET_PAGES_INIT, + payload: { applicationId: "app-1" }, + }); + + expect(fetching.isFetchingPages).toBe(true); + expect(fetching.targetPages).toEqual([]); + + const done = reducer(fetching, { + type: ReduxActionTypes.FETCH_COPY_TARGET_PAGES_SUCCESS, + payload: { pages: samplePages }, + }); + + expect(done.isFetchingPages).toBe(false); + expect(done.targetPages).toEqual(samplePages); + }); + it("resets fetching state on fetch error", () => { const state = reducer( { ...initialState, isFetchingApplications: true }, diff --git a/app/client/src/reducers/uiReducers/copyEntityToAppReducer.ts b/app/client/src/reducers/uiReducers/copyEntityToAppReducer.ts index f4731becd654..f38e8c9dd682 100644 --- a/app/client/src/reducers/uiReducers/copyEntityToAppReducer.ts +++ b/app/client/src/reducers/uiReducers/copyEntityToAppReducer.ts @@ -5,6 +5,7 @@ import { ReduxActionErrorTypes, } from "ee/constants/ReduxActionConstants"; import type { ApplicationPayload } from "entities/Application"; +import type { ApplicationPagePayload } from "ee/api/ApplicationApi"; import type { CopyToAppModalEntity } from "pages/Editor/Explorer/CopyToApp/types"; export interface CopyEntityToAppReduxState { @@ -12,6 +13,8 @@ export interface CopyEntityToAppReduxState { entity: CopyToAppModalEntity | null; targetApplications: ApplicationPayload[]; isFetchingApplications: boolean; + targetPages: ApplicationPagePayload[]; + isFetchingPages: boolean; isCopying: boolean; } @@ -20,6 +23,8 @@ const initialState: CopyEntityToAppReduxState = { entity: null, targetApplications: [], isFetchingApplications: false, + targetPages: [], + isFetchingPages: false, isCopying: false, }; @@ -32,6 +37,7 @@ const copyEntityToAppReducer = createReducer(initialState, { isModalOpen: true, entity: action.payload, targetApplications: [], + targetPages: [], }), [ReduxActionTypes.CLOSE_COPY_ENTITY_TO_APP_MODAL]: ( state: CopyEntityToAppReduxState, @@ -40,6 +46,28 @@ const copyEntityToAppReducer = createReducer(initialState, { isModalOpen: false, entity: null, }), + [ReduxActionTypes.FETCH_COPY_TARGET_PAGES_INIT]: ( + state: CopyEntityToAppReduxState, + ) => ({ + ...state, + isFetchingPages: true, + targetPages: [], + }), + [ReduxActionTypes.FETCH_COPY_TARGET_PAGES_SUCCESS]: ( + state: CopyEntityToAppReduxState, + action: ReduxAction<{ pages: ApplicationPagePayload[] }>, + ) => ({ + ...state, + isFetchingPages: false, + targetPages: action.payload.pages, + }), + [ReduxActionErrorTypes.FETCH_COPY_TARGET_PAGES_ERROR]: ( + state: CopyEntityToAppReduxState, + ) => ({ + ...state, + isFetchingPages: false, + targetPages: [], + }), [ReduxActionTypes.FETCH_COPY_TARGET_APPLICATIONS_INIT]: ( state: CopyEntityToAppReduxState, ) => ({ diff --git a/app/client/src/selectors/copyToAppSelectors.ts b/app/client/src/selectors/copyToAppSelectors.ts index 0852831f0fb2..8d46eec5f4e2 100644 --- a/app/client/src/selectors/copyToAppSelectors.ts +++ b/app/client/src/selectors/copyToAppSelectors.ts @@ -1,5 +1,6 @@ import type { DefaultRootState } from "react-redux"; import type { ApplicationPayload } from "entities/Application"; +import type { ApplicationPagePayload } from "ee/api/ApplicationApi"; import type { CopyToAppModalEntity } from "pages/Editor/Explorer/CopyToApp/types"; export const getIsCopyToAppModalOpen = (state: DefaultRootState): boolean => @@ -17,5 +18,13 @@ export const getIsFetchingCopyTargetApplications = ( state: DefaultRootState, ): boolean => state.ui.copyEntityToApp.isFetchingApplications; +export const getCopyTargetPages = ( + state: DefaultRootState, +): ApplicationPagePayload[] => state.ui.copyEntityToApp.targetPages; + +export const getIsFetchingCopyTargetPages = ( + state: DefaultRootState, +): boolean => state.ui.copyEntityToApp.isFetchingPages; + export const getIsCopyingEntityToApp = (state: DefaultRootState): boolean => state.ui.copyEntityToApp.isCopying; From 17654dea54fb883ab707d48ce4d30faf50091014 Mon Sep 17 00:00:00 2001 From: Stacey Levine Date: Tue, 23 Jun 2026 16:01:33 -0400 Subject: [PATCH 5/7] fix(server): friendlier entity rename on partial-import name collision (#41919) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When copying/importing an entity whose name already exists in the target page, the importer renamed the incoming copy with `oldName + i` (i from 1), so `JSObject1` became `JSObject11` and `Query1` became `Query11`. It never overwrote the existing entity (the copy is a net-new create), but the name was confusing. Follow Appsmith's convention instead: `JSObject1` -> `JSObject2`. Added a shared pure helper `ImportExportUtils.generateUniqueNameForImport(name, existingNames)` and use it from both `updateActionNameBeforeMerge` (NewActionImportableServiceCEImpl) and `updateActionCollectionNameBeforeMerge` (ActionCollectionImportableServiceCEImpl), replacing the duplicated inline loops. Behavior is identical for names without a trailing digit (`Query` -> `Query1`, as before), so existing PartialImportServiceTest collision assertions are unaffected; only digit-suffixed names change. This applies to all partial-import flows (building blocks, app/file import, copy-to-app), since the old naming was poor everywhere. Tests: 12 ImportExportUtilsTest cases incl. JSObject1->JSObject2->JSObject3, gap-in-sequence, internal-vs-trailing digit, all-digit, and oversized numeric-suffix overflow fallback. All pass locally. Reviewed via the council (data-migration, architect, security, QA) — no blockers; security APPROVE, others APPROVE WITH RISKS (run the Spring PartialImportServiceTest in CI as the end-to-end guard). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...tionCollectionImportableServiceCEImpl.java | 13 ++--- .../server/helpers/ImportExportUtils.java | 47 ++++++++++++++++ .../NewActionImportableServiceCEImpl.java | 9 +-- .../server/helpers/ImportExportUtilsTest.java | 56 +++++++++++++++++++ 4 files changed, 110 insertions(+), 15 deletions(-) diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/actioncollections/importable/ActionCollectionImportableServiceCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/actioncollections/importable/ActionCollectionImportableServiceCEImpl.java index 3c551841c6c0..a64cd8984e7f 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/actioncollections/importable/ActionCollectionImportableServiceCEImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/actioncollections/importable/ActionCollectionImportableServiceCEImpl.java @@ -31,6 +31,7 @@ import java.util.Set; import static com.appsmith.external.helpers.AppsmithBeanUtils.copyNestedNonNullProperties; +import static com.appsmith.server.helpers.ImportExportUtils.generateUniqueNameForImport; @Slf4j @Service @@ -362,15 +363,9 @@ private void updateActionCollectionNameBeforeMerge( mappedImportableResourcesDTO.getRefactoringNameReference().keySet(); for (ActionCollection actionCollection : importedNewActionCollectionList) { - String - oldNameActionCollection = - actionCollection.getUnpublishedCollection().getName(), - newNameActionCollection = - actionCollection.getUnpublishedCollection().getName(); - int i = 1; - while (refactoringNameSet.contains(newNameActionCollection)) { - newNameActionCollection = oldNameActionCollection + i++; - } + String oldNameActionCollection = + actionCollection.getUnpublishedCollection().getName(); + String newNameActionCollection = generateUniqueNameForImport(oldNameActionCollection, refactoringNameSet); String oldId = actionCollection.getId().split("_")[1]; actionCollection.setId(newNameActionCollection + "_" + oldId); actionCollection.getUnpublishedCollection().setName(newNameActionCollection); diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/ImportExportUtils.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/ImportExportUtils.java index 82ba3f57d294..93cd91ea9787 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/ImportExportUtils.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/ImportExportUtils.java @@ -14,12 +14,59 @@ import java.time.Instant; import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import static com.appsmith.external.helpers.AppsmithBeanUtils.copyNestedNonNullProperties; @Slf4j public class ImportExportUtils { + private static final Pattern TRAILING_NUMBER_PATTERN = Pattern.compile("^(.*?)(\\d+)$"); + + /** + * Generates a unique name for an entity being imported into a context that already contains + * {@code existingNames}, following Appsmith's entity-naming convention. + *

+ * When the incoming name already ends in a number it increments from there + * (e.g. {@code JSObject1 -> JSObject2}); otherwise it appends an incrementing suffix + * (e.g. {@code Query -> Query1}). If the name does not clash it is returned unchanged. + * + * @param name the incoming entity name + * @param existingNames the names already present in the destination context + * @return a name not present in {@code existingNames} + */ + public static String generateUniqueNameForImport(String name, Set existingNames) { + if (name == null || existingNames == null || !existingNames.contains(name)) { + return name; + } + + Matcher matcher = TRAILING_NUMBER_PATTERN.matcher(name); + String base = name; + long suffix = 0; + + if (matcher.matches()) { + base = matcher.group(1); + try { + suffix = Long.parseLong(matcher.group(2)); + } catch (NumberFormatException e) { + // Suffix too large to parse — fall back to treating the whole name as the base. + base = name; + suffix = 0; + } + } + + String candidate; + + do { + suffix++; + candidate = base + suffix; + } while (existingNames.contains(candidate)); + + return candidate; + } + /** * Method to provide non-cryptic and user-friendly error message with actionable input for Import-Export flows * diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/newactions/importable/NewActionImportableServiceCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/newactions/importable/NewActionImportableServiceCEImpl.java index cfa2aea15de7..7284c0feaf73 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/newactions/importable/NewActionImportableServiceCEImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/newactions/importable/NewActionImportableServiceCEImpl.java @@ -42,6 +42,7 @@ import java.util.Set; import static com.appsmith.external.helpers.AppsmithBeanUtils.copyNestedNonNullProperties; +import static com.appsmith.server.helpers.ImportExportUtils.generateUniqueNameForImport; import static com.appsmith.server.helpers.ImportExportUtils.sanitizeDatasourceInActionDTO; import static java.lang.Boolean.TRUE; @@ -527,12 +528,8 @@ private void updateActionNameBeforeMerge( mappedImportableResourcesDTO.getRefactoringNameReference().keySet(); for (NewAction newAction : importedNewActionList) { - String oldNameAction = newAction.getUnpublishedAction().getName(), - newNameAction = newAction.getUnpublishedAction().getName(); - int i = 1; - while (refactoringNames.contains(newNameAction)) { - newNameAction = oldNameAction + i++; - } + String oldNameAction = newAction.getUnpublishedAction().getName(); + String newNameAction = generateUniqueNameForImport(oldNameAction, refactoringNames); String oldId = newAction.getId().split("_")[1]; newAction.setId(newNameAction + "_" + oldId); diff --git a/app/server/appsmith-server/src/test/java/com/appsmith/server/helpers/ImportExportUtilsTest.java b/app/server/appsmith-server/src/test/java/com/appsmith/server/helpers/ImportExportUtilsTest.java index fac258f850fe..069d5bc9d546 100644 --- a/app/server/appsmith-server/src/test/java/com/appsmith/server/helpers/ImportExportUtilsTest.java +++ b/app/server/appsmith-server/src/test/java/com/appsmith/server/helpers/ImportExportUtilsTest.java @@ -84,4 +84,60 @@ public void isDatasourceUpdatedSinceLastCommit() { // should return false if last commit date is null Assertions.assertFalse(ImportExportUtils.isDatasourceUpdatedSinceLastCommit(map, actionDTO, null)); } + + @Test + void generateUniqueNameForImport_noClash_returnsNameUnchanged() { + Assertions.assertEquals( + "JSObject1", ImportExportUtils.generateUniqueNameForImport("JSObject1", Set.of("Query1"))); + } + + @Test + void generateUniqueNameForImport_numberedNameClashes_incrementsFromExistingNumber() { + // JSObject1 already exists -> JSObject2 (not JSObject11) + Assertions.assertEquals( + "JSObject2", ImportExportUtils.generateUniqueNameForImport("JSObject1", Set.of("JSObject1"))); + // JSObject1 and JSObject2 exist -> JSObject3 + Assertions.assertEquals( + "JSObject3", + ImportExportUtils.generateUniqueNameForImport("JSObject1", Set.of("JSObject1", "JSObject2"))); + } + + @Test + void generateUniqueNameForImport_unnumberedNameClashes_appendsNumber() { + Assertions.assertEquals("Query1", ImportExportUtils.generateUniqueNameForImport("Query", Set.of("Query"))); + Assertions.assertEquals( + "Query2", ImportExportUtils.generateUniqueNameForImport("Query", Set.of("Query", "Query1"))); + } + + @Test + void generateUniqueNameForImport_nullName_returnsNull() { + Assertions.assertNull(ImportExportUtils.generateUniqueNameForImport(null, Set.of("Query"))); + } + + @Test + void generateUniqueNameForImport_gapInSequence_picksFirstFreeAboveOwnSuffix() { + // JSObject1 clashes; JSObject2 free even though JSObject3 exists -> JSObject2 + Assertions.assertEquals( + "JSObject2", + ImportExportUtils.generateUniqueNameForImport("JSObject1", Set.of("JSObject1", "JSObject3"))); + } + + @Test + void generateUniqueNameForImport_internalDigitOnly_appendsNumber() { + // "get4Items" has no TRAILING digit -> treated as base, append 1 + Assertions.assertEquals( + "get4Items1", ImportExportUtils.generateUniqueNameForImport("get4Items", Set.of("get4Items"))); + } + + @Test + void generateUniqueNameForImport_allDigitName_incrementsNumber() { + Assertions.assertEquals("124", ImportExportUtils.generateUniqueNameForImport("123", Set.of("123"))); + } + + @Test + void generateUniqueNameForImport_oversizedNumericSuffix_fallsBackToAppendingOne() { + // 20-digit suffix overflows long -> NumberFormatException guard -> base=whole name, append 1 + String huge = "Q" + "9".repeat(20); + Assertions.assertEquals(huge + "1", ImportExportUtils.generateUniqueNameForImport(huge, Set.of(huge))); + } } From d084d65c986b2f05bef14b39e485352be0397980 Mon Sep 17 00:00:00 2001 From: Stacey Levine Date: Tue, 23 Jun 2026 16:44:40 -0400 Subject: [PATCH 6/7] fix(server): rename JS objects (not just queries) on partial-import name clash (#41919) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copying a JS object into a page that already had one of the same name produced a duplicate (two "JSObject1") instead of renaming; queries renamed fine. Root cause: PartialImportServiceCEImpl built its collision-name set via refactoringService.getAllExistingEntitiesMono(..., isFQN=true). In RefactoringServiceCEImpl.getExistingEntityNamesFlux, isFQN=true skips action-collection (JS object) and widget names — an optimization meant for refactoring dotted fully-qualified names. Partial import compares plain entity names, so passing true hid JS-object/widget collisions and they were never renamed. Fix: pass isFQN=false at that one call site so the collision set includes JS object and widget names (page entity names share one namespace). The existing rename helper then turns JSObject1 -> JSObject2. Also strengthen PartialImportServiceTest.testPartialImport_nameClashInAction: it asserted each name is "contained in" {"utils","utils1"}, which passes even with two "utils" — exactly why this shipped. Now asserts the distinct sets via containsExactlyInAnyOrder, so a duplicate JS object fails the test. Reviewed via the council (data-migration, security, architect, QA) — no blockers. The Spring/Mongo PartialImportServiceTest runs in CI. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../partial/PartialImportServiceCEImpl.java | 6 ++- .../solutions/PartialImportServiceTest.java | 42 ++++++++++--------- 2 files changed, 27 insertions(+), 21 deletions(-) diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/imports/internal/partial/PartialImportServiceCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/imports/internal/partial/PartialImportServiceCEImpl.java index 1ebdd5095364..ff6b4d8ac07f 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/imports/internal/partial/PartialImportServiceCEImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/imports/internal/partial/PartialImportServiceCEImpl.java @@ -211,8 +211,12 @@ private Mono importResourceInPage( importingMetaDTO.setRefName(page.getRefName()); Layout layout = page.getUnpublishedPage().getLayouts().get(0); + // isFQN=false so the collision set includes action-collection (JS object) + // and widget names, not just action names. These are plain entity names + // (not dotted FQNs), so an imported JS object named "JSObject1" is correctly + // detected against an existing "JSObject1" and renamed rather than duplicated. return refactoringService.getAllExistingEntitiesMono( - page.getId(), CreatorContextType.PAGE, layout.getId(), true); + page.getId(), CreatorContextType.PAGE, layout.getId(), false); }) .flatMap(nameSet -> { // Fetch name of the existing resources in the page to avoid name clashing diff --git a/app/server/appsmith-server/src/test/java/com/appsmith/server/solutions/PartialImportServiceTest.java b/app/server/appsmith-server/src/test/java/com/appsmith/server/solutions/PartialImportServiceTest.java index 6aa2cbfd78e2..1e55a55a1350 100644 --- a/app/server/appsmith-server/src/test/java/com/appsmith/server/solutions/PartialImportServiceTest.java +++ b/app/server/appsmith-server/src/test/java/com/appsmith/server/solutions/PartialImportServiceTest.java @@ -61,6 +61,7 @@ import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.stream.Collectors; import static org.assertj.core.api.Assertions.assertThat; @@ -414,28 +415,29 @@ public void testPartialImport_nameClashInAction_successWithNoNameDuplicates() { // Verify that the application has the imported resource assertThat(application.getPages()).hasSize(1); + // Assert the exact, DISTINCT names so a duplicate (e.g. two "utils") is caught: + // re-importing must rename the JS object rather than duplicating it. assertThat(actionCollectionList).hasSize(2); - Set nameList = Set.of("utils", "utils1"); - actionCollectionList.forEach(collection -> { - assertThat(nameList.contains( - collection.getUnpublishedCollection().getName())) - .isTrue(); - }); + Set collectionNames = actionCollectionList.stream() + .map(collection -> + collection.getUnpublishedCollection().getName()) + .collect(Collectors.toSet()); + assertThat(collectionNames).containsExactlyInAnyOrder("utils", "utils1"); + assertThat(actionList).hasSize(8); - Set actionNames = Set.of( - "DeleteQuery", - "UpdateQuery", - "SelectQuery", - "InsertQuery", - "DeleteQuery1", - "UpdateQuery1", - "SelectQuery1", - "InsertQuery1"); - actionList.forEach(action -> { - assertThat(actionNames.contains( - action.getUnpublishedAction().getName())) - .isTrue(); - }); + Set actionNames = actionList.stream() + .map(action -> action.getUnpublishedAction().getName()) + .collect(Collectors.toSet()); + assertThat(actionNames) + .containsExactlyInAnyOrder( + "DeleteQuery", + "UpdateQuery", + "SelectQuery", + "InsertQuery", + "DeleteQuery1", + "UpdateQuery1", + "SelectQuery1", + "InsertQuery1"); }) .verifyComplete(); } From 6880fd39b8d22e795402470880068453427735d2 Mon Sep 17 00:00:00 2001 From: Stacey Levine Date: Tue, 23 Jun 2026 19:07:06 -0400 Subject: [PATCH 7/7] fix(server): guard Long.MAX_VALUE suffix in import rename helper (#41919) generateUniqueNameForImport only caught a NumberFormatException (>19-digit suffix). A name ending in exactly Long.MAX_VALUE parsed fine, then suffix++ in the loop wrapped to a negative value, producing a name like "Q-9223372036854775808". Only adopt the parsed suffix when it is strictly less than Long.MAX_VALUE; otherwise keep the whole-name fallback (append 1). Added a unit test pinning the Long.MAX_VALUE-suffix case. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../appsmith/server/helpers/ImportExportUtils.java | 14 +++++++++----- .../server/helpers/ImportExportUtilsTest.java | 8 ++++++++ 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/ImportExportUtils.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/ImportExportUtils.java index 93cd91ea9787..8ed86110d45f 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/ImportExportUtils.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/ImportExportUtils.java @@ -43,17 +43,21 @@ public static String generateUniqueNameForImport(String name, Set existi } Matcher matcher = TRAILING_NUMBER_PATTERN.matcher(name); + // Default to appending a fresh suffix to the whole name. This is also the fallback + // when the trailing number cannot be incremented safely (too large to parse, or at + // Long.MAX_VALUE where suffix++ would overflow into a negative value). String base = name; long suffix = 0; if (matcher.matches()) { - base = matcher.group(1); try { - suffix = Long.parseLong(matcher.group(2)); + long parsedSuffix = Long.parseLong(matcher.group(2)); + if (parsedSuffix < Long.MAX_VALUE) { + base = matcher.group(1); + suffix = parsedSuffix; + } } catch (NumberFormatException e) { - // Suffix too large to parse — fall back to treating the whole name as the base. - base = name; - suffix = 0; + // Suffix too large to parse — keep the whole-name fallback above. } } diff --git a/app/server/appsmith-server/src/test/java/com/appsmith/server/helpers/ImportExportUtilsTest.java b/app/server/appsmith-server/src/test/java/com/appsmith/server/helpers/ImportExportUtilsTest.java index 069d5bc9d546..d08826c1674b 100644 --- a/app/server/appsmith-server/src/test/java/com/appsmith/server/helpers/ImportExportUtilsTest.java +++ b/app/server/appsmith-server/src/test/java/com/appsmith/server/helpers/ImportExportUtilsTest.java @@ -140,4 +140,12 @@ void generateUniqueNameForImport_oversizedNumericSuffix_fallsBackToAppendingOne( String huge = "Q" + "9".repeat(20); Assertions.assertEquals(huge + "1", ImportExportUtils.generateUniqueNameForImport(huge, Set.of(huge))); } + + @Test + void generateUniqueNameForImport_maxLongSuffix_fallsBackToAppendingOne() { + // Suffix == Long.MAX_VALUE parses fine but suffix++ would overflow to a negative value; + // it must fall back to appending 1 to the whole name rather than producing "Q-9223372036854775808". + String maxLong = "Q" + Long.MAX_VALUE; + Assertions.assertEquals(maxLong + "1", ImportExportUtils.generateUniqueNameForImport(maxLong, Set.of(maxLong))); + } }