diff --git a/app/client/src/actions/copyToAppActions.ts b/app/client/src/actions/copyToAppActions.ts new file mode 100644 index 000000000000..02014fb6cf70 --- /dev/null +++ b/app/client/src/actions/copyToAppActions.ts @@ -0,0 +1,34 @@ +import { ReduxActionTypes } from "ee/constants/ReduxActionConstants"; +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, + 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 }, +}); + +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 2d08cb91cd83..c1ce8e312f7e 100644 --- a/app/client/src/ce/constants/ReduxActionConstants.tsx +++ b/app/client/src/ce/constants/ReduxActionConstants.tsx @@ -223,6 +223,17 @@ 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", + 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", + 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", @@ -233,6 +244,10 @@ 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", + FETCH_COPY_TARGET_PAGES_ERROR: "FETCH_COPY_TARGET_PAGES_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/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/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..b5de7c0ecde1 --- /dev/null +++ b/app/client/src/ce/sagas/CopyToAppSagas.test.ts @@ -0,0 +1,330 @@ +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, + 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"; + +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); + }); +}); + +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 new file mode 100644 index 000000000000..2d9425d47fcd --- /dev/null +++ b/app/client/src/ce/sagas/CopyToAppSagas.ts @@ -0,0 +1,240 @@ +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, + 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 }, + }); + } +} + +/** + * 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. + * + * 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.FETCH_COPY_TARGET_PAGES_INIT, + fetchPagesForCopyTargetSaga, + ), + 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/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..7956e4cfbabe --- /dev/null +++ b/app/client/src/pages/AppIDE/components/AppPluginActionEditor/components/ContextMenuItems/CopyToApp.test.tsx @@ -0,0 +1,45 @@ +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"; +import { CopyToAppEntityType } from "pages/Editor/Explorer/CopyToApp/types"; + +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: CopyToAppEntityType.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/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 new file mode 100644 index 000000000000..5ea37c547bf3 --- /dev/null +++ b/app/client/src/pages/Editor/Explorer/CopyToApp/CopyEntityToAppModal.tsx @@ -0,0 +1,316 @@ +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 { getCurrentApplicationId } from "selectors/editorSelectors"; +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 { + closeCopyToAppModal, + copyActionToApp, + copyJSActionToApp, + fetchAppsForCopyTarget, + fetchPagesForCopyTarget, +} from "actions/copyToAppActions"; +import { + getCopyTargetApplications, + getCopyTargetPages, + getCopyToAppModalEntity, + getIsCopyingEntityToApp, + getIsCopyToAppModalOpen, + getIsFetchingCopyTargetApplications, + getIsFetchingCopyTargetPages, +} from "selectors/copyToAppSelectors"; +import { CopyToAppEntityType } from "./types"; + +const FieldWrapper = ({ + children, + label, +}: { + children: React.ReactNode; + label: string; +}) => ( +
+ + {label} + +
{children}
+
+); + +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(""); + + 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( + () => + workspaces.filter((workspace) => + hasCreateNewAppPermission(workspace.userPermissions ?? []), + ), + [workspaces], + ); + + const applicationOptions = useMemo( + () => + applications.filter( + (application) => + // The source application cannot be a copy target. + application.id !== currentApplicationId && + isPermitted( + application.userPermissions ?? [], + PERMISSION_TYPE.MANAGE_APPLICATION, + ), + ), + [applications, currentApplicationId], + ); + + const selectedApplication = useMemo( + () => + applicationOptions.find( + (application) => application.id === applicationId, + ), + [applicationOptions, applicationId], + ); + + const pageOptions = useMemo( + () => + pages.filter((page) => + hasManagePagePermission(page.userPermissions ?? []), + ), + [pages], + ); + + const selectedPage = useMemo( + () => pageOptions.find((page) => page.id === pageId), + [pageOptions, pageId], + ); + + // Reset the cascading selections and fetch workspaces when the modal opens. + useEffect( + function onModalOpen() { + if (isOpen) { + setWorkspaceId(""); + setApplicationId(""); + setPageId(""); + 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(""); + dispatch(fetchPagesForCopyTarget(value)); + }; + + const handleClose = () => { + dispatch(closeCopyToAppModal()); + }; + + const handleCopy = () => { + if (!entity || !selectedApplication || !selectedPage) return; + + const payload = { + entityId: entity.entityId, + entityName: entity.entityName, + sourcePageId: entity.sourcePageId, + targetWorkspaceId: workspaceId, + targetApplicationId: applicationId, + targetApplicationName: selectedApplication.name, + targetPageId: pageId, + targetPageName: selectedPage.name, + onSuccess: handleClose, + }; + + dispatch( + entity.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 && ( + + + {!isFetchingPages && !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..f0285582ceef --- /dev/null +++ b/app/client/src/pages/Editor/Explorer/CopyToApp/types.ts @@ -0,0 +1,24 @@ +export enum CopyToAppEntityType { + ACTION = "ACTION", + 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; + sourcePageId: string; + targetWorkspaceId: string; + targetApplicationId: string; + targetApplicationName: string; + targetPageId: string; + targetPageName: string; + onSuccess?: () => void; +} 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..7f1d644553a7 --- /dev/null +++ b/app/client/src/pages/Editor/JSEditor/ContextMenuItems/CopyToApp.test.tsx @@ -0,0 +1,45 @@ +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"; +import { CopyToAppEntityType } from "pages/Editor/Explorer/CopyToApp/types"; + +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: CopyToAppEntityType.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 new file mode 100644 index 000000000000..966c5089cc35 --- /dev/null +++ b/app/client/src/reducers/uiReducers/copyEntityToAppReducer.test.ts @@ -0,0 +1,136 @@ +import { + ReduxActionErrorTypes, + ReduxActionTypes, +} 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, +}; + +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 }, + { + 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("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 }, + { + 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..f38e8c9dd682 --- /dev/null +++ b/app/client/src/reducers/uiReducers/copyEntityToAppReducer.ts @@ -0,0 +1,131 @@ +import { createReducer } from "utils/ReducerUtils"; +import type { ReduxAction } from "actions/ReduxActionTypes"; +import { + ReduxActionTypes, + 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 { + isModalOpen: boolean; + entity: CopyToAppModalEntity | null; + targetApplications: ApplicationPayload[]; + isFetchingApplications: boolean; + targetPages: ApplicationPagePayload[]; + isFetchingPages: boolean; + isCopying: boolean; +} + +const initialState: CopyEntityToAppReduxState = { + isModalOpen: false, + entity: null, + targetApplications: [], + isFetchingApplications: false, + targetPages: [], + isFetchingPages: 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: [], + targetPages: [], + }), + [ReduxActionTypes.CLOSE_COPY_ENTITY_TO_APP_MODAL]: ( + state: CopyEntityToAppReduxState, + ) => ({ + ...state, + 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, + ) => ({ + ...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..8d46eec5f4e2 --- /dev/null +++ b/app/client/src/selectors/copyToAppSelectors.ts @@ -0,0 +1,30 @@ +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 => + state.ui.copyEntityToApp.isModalOpen; + +export const getCopyToAppModalEntity = ( + state: DefaultRootState, +): CopyToAppModalEntity | null => state.ui.copyEntityToApp.entity; + +export const getCopyTargetApplications = ( + state: DefaultRootState, +): ApplicationPayload[] => state.ui.copyEntityToApp.targetApplications; + +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; 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..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 @@ -14,12 +14,63 @@ 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); + // 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()) { + try { + 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 — keep the whole-name fallback above. + } + } + + 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/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/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..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 @@ -84,4 +84,68 @@ 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))); + } + + @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))); + } } 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(); }