diff --git a/app/client/src/PluginActionEditor/hooks/index.ts b/app/client/src/PluginActionEditor/hooks/index.ts index c61530a08d8..c6a483d54fc 100644 --- a/app/client/src/PluginActionEditor/hooks/index.ts +++ b/app/client/src/PluginActionEditor/hooks/index.ts @@ -1,5 +1,6 @@ export { useActionSettingsConfig } from "ee/PluginActionEditor/hooks/useActionSettingsConfig"; export { useHandleDeleteClick } from "ee/PluginActionEditor/hooks/useHandleDeleteClick"; +export { useHandleDuplicateClick } from "./useHandleDuplicateClick"; export { useHandleRunClick } from "ee/PluginActionEditor/hooks/useHandleRunClick"; export { useBlockExecution } from "ee/PluginActionEditor/hooks/useBlockExecution"; export { useAnalyticsOnRunClick } from "ee/PluginActionEditor/hooks/useAnalyticsOnRunClick"; diff --git a/app/client/src/PluginActionEditor/hooks/useHandleDuplicateClick.ts b/app/client/src/PluginActionEditor/hooks/useHandleDuplicateClick.ts new file mode 100644 index 00000000000..6551d7b9852 --- /dev/null +++ b/app/client/src/PluginActionEditor/hooks/useHandleDuplicateClick.ts @@ -0,0 +1,26 @@ +import { copyActionRequest } from "actions/pluginActionActions"; +import { usePluginActionContext } from "PluginActionEditor/PluginActionContext"; +import { useCallback } from "react"; +import { useDispatch } from "react-redux"; + +function useHandleDuplicateClick() { + const { action } = usePluginActionContext(); + const dispatch = useDispatch(); + + const handleDuplicateClick = useCallback( + (destinationEntityId: string) => { + dispatch( + copyActionRequest({ + id: action.id, + destinationEntityId, + name: action.name, + }), + ); + }, + [action.id, action.name, dispatch], + ); + + return { handleDuplicateClick }; +} + +export { useHandleDuplicateClick }; diff --git a/app/client/src/actions/pluginActionActions.ts b/app/client/src/actions/pluginActionActions.ts index ca6c9988d52..461a29d5172 100644 --- a/app/client/src/actions/pluginActionActions.ts +++ b/app/client/src/actions/pluginActionActions.ts @@ -21,6 +21,7 @@ import type { ApiResponse } from "api/ApiResponses"; import type { JSCollection } from "entities/JSCollection"; import type { ErrorActionPayload } from "sagas/ErrorSagas"; import type { EventLocation } from "ee/utils/analyticsUtilTypes"; +import type { GenerateDestinationIdInfoReturnType } from "ee/sagas/helpers"; export const createActionRequest = (payload: Partial) => { return { @@ -225,7 +226,7 @@ export const moveActionError = ( export const copyActionRequest = (payload: { id: string; - destinationPageId: string; + destinationEntityId: string; name: string; }) => { return { @@ -244,7 +245,7 @@ export const copyActionSuccess = (payload: Action) => { export const copyActionError = ( payload: { id: string; - destinationPageId: string; + destinationEntityIdInfo: GenerateDestinationIdInfoReturnType; } & ErrorActionPayload, ) => { return { diff --git a/app/client/src/ce/constants/messages.ts b/app/client/src/ce/constants/messages.ts index 46e2c4aa91b..7ba25c3cb17 100644 --- a/app/client/src/ce/constants/messages.ts +++ b/app/client/src/ce/constants/messages.ts @@ -337,7 +337,7 @@ export const ACTION_MOVE_SUCCESS = (actionName: string, pageName: string) => export const ERROR_ACTION_MOVE_FAIL = (actionName: string) => `Error while moving action ${actionName}`; export const ACTION_COPY_SUCCESS = (actionName: string, pageName: string) => - `${actionName} action copied to page ${pageName} successfully`; + `${actionName} action copied ${pageName.length > 0 ? "to page " + pageName : ""} successfully`; export const ERROR_ACTION_COPY_FAIL = (actionName: string) => `Error while copying action ${actionName}`; export const ERROR_ACTION_RENAME_FAIL = (actionName: string) => @@ -1731,6 +1731,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_DUPLICATE = () => "Duplicate"; export const CONTEXT_DELETE = () => "Delete"; export const CONFIRM_CONTEXT_DELETE = () => "Are you sure?"; export const CONFIRM_CONTEXT_DELETING = () => "Deleting"; diff --git a/app/client/src/ce/pages/Editor/AppPluginActionEditor/components/ToolbarMenu/Copy.tsx b/app/client/src/ce/pages/Editor/AppPluginActionEditor/components/ToolbarMenu/Copy.tsx index de4037213c4..7ade07c3853 100644 --- a/app/client/src/ce/pages/Editor/AppPluginActionEditor/components/ToolbarMenu/Copy.tsx +++ b/app/client/src/ce/pages/Editor/AppPluginActionEditor/components/ToolbarMenu/Copy.tsx @@ -17,7 +17,7 @@ export const Copy = () => { dispatch( copyActionRequest({ id: action.id, - destinationPageId: pageId, + destinationEntityId: pageId, name: action.name, }), ), diff --git a/app/client/src/ce/sagas/helpers.ts b/app/client/src/ce/sagas/helpers.ts index 6a8e70d2c47..6a57f1cf322 100644 --- a/app/client/src/ce/sagas/helpers.ts +++ b/app/client/src/ce/sagas/helpers.ts @@ -10,6 +10,23 @@ export interface ResolveParentEntityMetadataReturnType { parentEntityKey?: CreateNewActionKeyInterface; } +// This function is extended in EE. Please check the EE implementation before any modification. +export interface GenerateDestinationIdInfoReturnType { + pageId?: string; +} + +// This function is extended in EE. Please check the EE implementation before any modification. +export function generateDestinationIdInfoForQueryDuplication( + destinationEntityId: string, + parentEntityKey: CreateNewActionKeyInterface, +): GenerateDestinationIdInfoReturnType { + if (parentEntityKey === CreateNewActionKey.PAGE) { + return { pageId: destinationEntityId }; + } + + return {}; +} + // This function is extended in EE. Please check the EE implementation before any modification. export const resolveParentEntityMetadata = ( action: Partial, diff --git a/app/client/src/pages/Editor/Explorer/Actions/ActionEntityContextMenu.test.tsx b/app/client/src/pages/Editor/Explorer/Actions/ActionEntityContextMenu.test.tsx new file mode 100644 index 00000000000..4a5258016c8 --- /dev/null +++ b/app/client/src/pages/Editor/Explorer/Actions/ActionEntityContextMenu.test.tsx @@ -0,0 +1,170 @@ +import React from "react"; +import { fireEvent, render, waitFor } from "@testing-library/react"; +import "@testing-library/jest-dom/extend-expect"; +import { Provider } from "react-redux"; +import { lightTheme } from "selectors/themeSelectors"; +import { ThemeProvider } from "styled-components"; +import configureStore from "redux-mock-store"; +import { PluginType } from "entities/Action"; +import { + ActionEntityContextMenu, + type EntityContextMenuProps, +} from "./ActionEntityContextMenu"; +import { FilesContextProvider } from "../Files/FilesContextProvider"; +import { ActionParentEntityType } from "ee/entities/Engine/actionHelpers"; +import { act } from "react-dom/test-utils"; +import { + CONTEXT_COPY, + CONTEXT_DELETE, + CONTEXT_MOVE, + CONTEXT_RENAME, + CONTEXT_SHOW_BINDING, + createMessage, +} from "ee/constants/messages"; +import { + ReduxActionTypes, + type ReduxAction, +} from "ee/constants/ReduxActionConstants"; + +const mockStore = configureStore([]); + +const page1Id = "605c435a91dea93f0eaf91ba"; +const page2Id = "605c435a91dea93f0eaf91bc"; +const basePage2Id = "605c435a91dea93f0eaf91bc"; +const defaultState = { + ui: { + selectedWorkspace: { + workspace: {}, + }, + workspaces: { + packagesList: [], + }, + users: { + featureFlag: { + data: {}, + }, + }, + }, + entities: { + actions: [], + pageList: { + pages: [ + { + pageId: page1Id, + basePageId: page2Id, + pageName: "Page1", + isDefault: true, + slug: "page-1", + }, + { + pageId: page2Id, + basePageId: basePage2Id, + pageName: "Page2", + isDefault: false, + slug: "page-2", + }, + ], + }, + }, +}; + +const defaultProps: EntityContextMenuProps = { + id: "test-action-id", + name: "test-action", + canManageAction: true, + canDeleteAction: true, + pluginType: PluginType.DB, +}; + +const defaultContext = { + editorId: "test-editor-id", + canCreateActions: true, + parentEntityId: "test-parent-entity-id", + parentEntityType: ActionParentEntityType.PAGE, +}; + +describe("ActionEntityContextMenu", () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let store: any; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("renders context menu with correct options for application editor", async () => { + store = mockStore(defaultState); + const { findByText, getByRole } = render( + + + + + + + , + ); + const triggerToOpenMenu = getByRole("button"); + + act(() => { + fireEvent.click(triggerToOpenMenu); + }); + + await waitFor(() => { + expect(triggerToOpenMenu.parentNode).toHaveAttribute( + "aria-expanded", + "true", + ); + }); + + // In context of pages, the copy to page option will be shown + const copyQueryToPageOptions = await findByText( + createMessage(CONTEXT_COPY), + ); + + expect(await findByText(createMessage(CONTEXT_RENAME))).toBeInTheDocument(); + expect(await findByText(createMessage(CONTEXT_DELETE))).toBeInTheDocument(); + expect(await findByText(createMessage(CONTEXT_MOVE))).toBeInTheDocument(); + expect( + await findByText(createMessage(CONTEXT_SHOW_BINDING)), + ).toBeInTheDocument(); + expect(copyQueryToPageOptions).toBeInTheDocument(); + + // Now we click on the copy to page option + act(() => { + fireEvent.click(copyQueryToPageOptions); + }); + + // Now a menu with the list of pages will show up + const copyQueryToPageSubOptionPage1 = await findByText("Page1"); + + expect(copyQueryToPageSubOptionPage1).toBeInTheDocument(); + expect(await findByText("Page2")).toBeInTheDocument(); + + // Clicking on the page will trigger the correct action + act(() => { + fireEvent.click(copyQueryToPageSubOptionPage1); + }); + + let actions: Array< + ReduxAction<{ + payload: { + id: string; + destinationEntityId: string; + name: string; + }; + }> + > = []; + + await waitFor(() => { + actions = store.getActions(); + }); + + expect(actions.length).toBe(1); + expect(actions[0].type).toBe(ReduxActionTypes.COPY_ACTION_INIT); + expect(actions[0].payload).toEqual({ + destinationEntityId: page1Id, + id: "test-action-id", + name: "test-action", + }); + }); + // TODO: add tests for all options rendered in the context menu +}); diff --git a/app/client/src/pages/Editor/Explorer/Actions/ActionEntityContextMenu.tsx b/app/client/src/pages/Editor/Explorer/Actions/ActionEntityContextMenu.tsx index e17e37c3c3f..7e23af50883 100644 --- a/app/client/src/pages/Editor/Explorer/Actions/ActionEntityContextMenu.tsx +++ b/app/client/src/pages/Editor/Explorer/Actions/ActionEntityContextMenu.tsx @@ -20,6 +20,7 @@ import { CONTEXT_NO_PAGE, CONTEXT_SHOW_BINDING, createMessage, + CONTEXT_DUPLICATE, } from "ee/constants/messages"; import { builderURL } from "ee/RouteBuilder"; @@ -33,8 +34,9 @@ import { useConvertToModuleOptions } from "ee/pages/Editor/Explorer/hooks"; import { MODULE_TYPE } from "ee/constants/ModuleConstants"; import { PluginType } from "entities/Action"; import { convertToBaseParentEntityIdSelector } from "selectors/pageListSelectors"; +import { ActionParentEntityType } from "ee/entities/Engine/actionHelpers"; -interface EntityContextMenuProps { +export interface EntityContextMenuProps { id: string; name: string; className?: string; @@ -45,7 +47,7 @@ interface EntityContextMenuProps { export function ActionEntityContextMenu(props: EntityContextMenuProps) { // Import the context const context = useContext(FilesContext); - const { menuItems, parentEntityId } = context; + const { menuItems, parentEntityId, parentEntityType } = context; const baseParentEntityId = useSelector((state) => convertToBaseParentEntityIdSelector(state, parentEntityId), ); @@ -53,12 +55,12 @@ export function ActionEntityContextMenu(props: EntityContextMenuProps) { const { canDeleteAction, canManageAction } = props; const dispatch = useDispatch(); const [confirmDelete, setConfirmDelete] = useState(false); - const copyActionToPage = useCallback( - (actionId: string, actionName: string, pageId: string) => + const copyAction = useCallback( + (actionId: string, actionName: string, destinationEntityId: string) => dispatch( copyActionRequest({ id: actionId, - destinationPageId: pageId, + destinationEntityId, name: actionName, }), ), @@ -129,14 +131,26 @@ export function ActionEntityContextMenu(props: EntityContextMenuProps) { menuItems.includes(ActionEntityContextMenuItemsEnum.COPY) && canManageAction && { value: "copy", - onSelect: noop, - label: createMessage(CONTEXT_COPY), - children: menuPages.map((page) => { - return { - ...page, - onSelect: () => copyActionToPage(props.id, props.name, page.id), - }; - }), + onSelect: + parentEntityType === ActionParentEntityType.PAGE + ? noop + : () => { + copyAction(props.id, props.name, parentEntityId); + }, + label: createMessage( + parentEntityType === ActionParentEntityType.PAGE + ? CONTEXT_COPY + : CONTEXT_DUPLICATE, + ), + children: + parentEntityType === ActionParentEntityType.PAGE && + menuPages.length > 0 && + menuPages.map((page) => { + return { + ...page, + onSelect: () => copyAction(props.id, props.name, page.id), + }; + }), }, menuItems.includes(ActionEntityContextMenuItemsEnum.MOVE) && canManageAction && { diff --git a/app/client/src/pages/Editor/Explorer/Actions/MoreActionsMenu.tsx b/app/client/src/pages/Editor/Explorer/Actions/MoreActionsMenu.tsx index 4311feaf459..b87fbe468a1 100644 --- a/app/client/src/pages/Editor/Explorer/Actions/MoreActionsMenu.tsx +++ b/app/client/src/pages/Editor/Explorer/Actions/MoreActionsMenu.tsx @@ -63,7 +63,7 @@ export function MoreActionsMenu(props: EntityContextMenuProps) { dispatch( copyActionRequest({ id: actionId, - destinationPageId: pageId, + destinationEntityId: pageId, name: actionName, }), ), diff --git a/app/client/src/sagas/ActionSagas.ts b/app/client/src/sagas/ActionSagas.ts index 2dc50983aa6..60c375e7694 100644 --- a/app/client/src/sagas/ActionSagas.ts +++ b/app/client/src/sagas/ActionSagas.ts @@ -134,7 +134,10 @@ import { sendAnalyticsEventSaga } from "./AnalyticsSaga"; import { EditorModes } from "components/editorComponents/CodeEditor/EditorConfig"; import { updateActionAPICall } from "ee/sagas/ApiCallerSagas"; import FocusRetention from "./FocusRetentionSaga"; -import { resolveParentEntityMetadata } from "ee/sagas/helpers"; +import { + generateDestinationIdInfoForQueryDuplication, + resolveParentEntityMetadata, +} from "ee/sagas/helpers"; import { handleQueryEntityRedirect } from "./IDESaga"; import { EditorViewMode, IDE_TYPE } from "ee/entities/IDE/constants"; import { getIDETypeByUrl } from "ee/entities/IDE/utils"; @@ -144,7 +147,8 @@ import { } from "actions/ideActions"; import { getIsSideBySideEnabled } from "selectors/ideSelectors"; import { CreateNewActionKey } from "ee/entities/Engine/actionHelpers"; -import { convertToBasePageIdSelector } from "selectors/pageListSelectors"; +import { objectKeys } from "@appsmith/utils"; +import { convertToBaseParentEntityIdSelector } from "selectors/pageListSelectors"; export const DEFAULT_PREFIX = { QUERY: "Query", @@ -745,17 +749,35 @@ function* moveActionSaga( } function* copyActionSaga( - action: ReduxAction<{ id: string; destinationPageId: string; name: string }>, + action: ReduxAction<{ + id: string; + destinationEntityId: string; + name: string; + }>, ) { - let actionObject: Action = yield select(getAction, action.payload.id); + const { destinationEntityId, id, name } = action.payload; + let actionObject: Action = yield select(getAction, id); + + const { parentEntityId, parentEntityKey } = + resolveParentEntityMetadata(actionObject); + + if (!parentEntityId || !parentEntityKey) return; + const newName: string = yield select(getNewEntityName, { - prefix: action.payload.name, - parentEntityId: action.payload.destinationPageId, - parentEntityKey: CreateNewActionKey.PAGE, + prefix: name, + parentEntityId: destinationEntityId, + parentEntityKey, suffix: "Copy", startWithoutIndex: true, }); + const destinationEntityIdInfo = generateDestinationIdInfoForQueryDuplication( + destinationEntityId, + parentEntityKey, + ); + + if (objectKeys(destinationEntityIdInfo).length === 0) return; + try { if (!actionObject) throw new Error("Could not find action to copy"); @@ -768,7 +790,7 @@ function* copyActionSaga( const copyAction = Object.assign({}, actionObject, { name: newName, - pageId: action.payload.destinationPageId, + ...destinationEntityIdInfo, }) as Partial; // Indicates that source of action creation is copy action @@ -781,11 +803,15 @@ function* copyActionSaga( const datasources: Datasource[] = yield select(getDatasources); const isValidResponse: boolean = yield validateResponse(response); - const pageName: string = yield select( - getPageNameByPageId, - // @ts-expect-error: pageId not present on ActionCreateUpdateResponse - response.data.pageId, - ); + let pageName: string = ""; + + if (parentEntityKey === CreateNewActionKey.PAGE) { + pageName = yield select( + getPageNameByPageId, + // @ts-expect-error: pageId not present on ActionCreateUpdateResponse + response.data.pageId, + ); + } if (isValidResponse) { toast.show( @@ -801,12 +827,14 @@ function* copyActionSaga( const originalActionId = get( actionObject, `${RequestPayloadAnalyticsPath}.originalActionId`, - action.payload.id, + id, ); AnalyticsUtil.logEvent("DUPLICATE_ACTION", { // @ts-expect-error: name not present on ActionCreateUpdateResponse actionName: response.data.name, + parentEntityId, + parentEntityKey, pageName: pageName, actionId: response.data.id, originalActionId, @@ -836,7 +864,8 @@ function* copyActionSaga( yield put( copyActionError({ - ...action.payload, + id, + destinationEntityIdInfo, show: true, error: { message: errorMessage, @@ -1039,21 +1068,23 @@ function* toggleActionExecuteOnLoadSaga( } function* handleMoveOrCopySaga(actionPayload: ReduxAction) { - const { - baseId: baseActionId, - pageId, - pluginId, - pluginType, - } = actionPayload.payload; + const { baseId: baseActionId, pluginId, pluginType } = actionPayload.payload; const isApi = pluginType === PluginType.API; const isQuery = pluginType === PluginType.DB; const isSaas = pluginType === PluginType.SAAS; - const basePageId: string = yield select(convertToBasePageIdSelector, pageId); + const { parentEntityId } = resolveParentEntityMetadata(actionPayload.payload); + + if (!parentEntityId) return; + + const baseParentEntityId: string = yield select( + convertToBaseParentEntityIdSelector, + parentEntityId, + ); if (isApi) { history.push( apiEditorIdURL({ - basePageId, + baseParentEntityId, baseApiId: baseActionId, }), ); @@ -1062,7 +1093,7 @@ function* handleMoveOrCopySaga(actionPayload: ReduxAction) { if (isQuery) { history.push( queryEditorIdURL({ - basePageId, + baseParentEntityId, baseQueryId: baseActionId, }), ); @@ -1076,7 +1107,7 @@ function* handleMoveOrCopySaga(actionPayload: ReduxAction) { history.push( saasEditorApiIdURL({ - basePageId, + baseParentEntityId, pluginPackageName: plugin.packageName, baseApiId: baseActionId, }),