diff --git a/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx b/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx index b153eb8b8a4..1106cfcb754 100644 --- a/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx +++ b/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx @@ -10,6 +10,9 @@ import { renderWorkflowFlowComponent } from './workflow-test-env' let latestNodes: Node[] = [] let latestHistoryEvent: string | undefined const mockGetNodesReadOnly = vi.fn() +const mockHandleNodesCopy = vi.fn() +const mockHandleNodesDuplicate = vi.fn() +const mockHandleNodesDelete = vi.fn() vi.mock('../hooks', async () => { const actual = await vi.importActual('../hooks') @@ -18,6 +21,11 @@ vi.mock('../hooks', async () => { useNodesReadOnly: () => ({ getNodesReadOnly: mockGetNodesReadOnly, }), + useNodesInteractions: () => ({ + handleNodesCopy: mockHandleNodesCopy, + handleNodesDuplicate: mockHandleNodesDuplicate, + handleNodesDelete: mockHandleNodesDelete, + }), } }) @@ -73,6 +81,9 @@ describe('SelectionContextmenu', () => { latestHistoryEvent = undefined mockGetNodesReadOnly.mockReset() mockGetNodesReadOnly.mockReturnValue(false) + mockHandleNodesCopy.mockReset() + mockHandleNodesDuplicate.mockReset() + mockHandleNodesDelete.mockReset() }) it('should not render when selectionMenu is absent', () => { @@ -97,6 +108,40 @@ describe('SelectionContextmenu', () => { }) }) + it('should render and execute copy/duplicate/delete operations', async () => { + const nodes = [ + createNode({ id: 'n1', selected: true, width: 80, height: 40 }), + createNode({ id: 'n2', selected: true, position: { x: 140, y: 0 }, width: 80, height: 40 }), + ] + const { store } = renderSelectionMenu({ nodes }) + + act(() => { + store.setState({ selectionMenu: { clientX: 120, clientY: 120 } }) + }) + + await waitFor(() => { + expect(screen.getByTestId('selection-contextmenu-item-copy')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('selection-contextmenu-item-copy')) + expect(mockHandleNodesCopy).toHaveBeenCalledTimes(1) + expect(store.getState().selectionMenu).toBeUndefined() + + act(() => { + store.setState({ selectionMenu: { clientX: 120, clientY: 120 } }) + }) + fireEvent.click(screen.getByTestId('selection-contextmenu-item-duplicate')) + expect(mockHandleNodesDuplicate).toHaveBeenCalledTimes(1) + expect(store.getState().selectionMenu).toBeUndefined() + + act(() => { + store.setState({ selectionMenu: { clientX: 120, clientY: 120 } }) + }) + fireEvent.click(screen.getByTestId('selection-contextmenu-item-delete')) + expect(mockHandleNodesDelete).toHaveBeenCalledTimes(1) + expect(store.getState().selectionMenu).toBeUndefined() + }) + it('should close itself when only one node is selected', async () => { const nodes = [ createNode({ id: 'n1', selected: true, width: 80, height: 40 }), diff --git a/web/app/components/workflow/selection-contextmenu.tsx b/web/app/components/workflow/selection-contextmenu.tsx index c13d881cc2e..2b22df50124 100644 --- a/web/app/components/workflow/selection-contextmenu.tsx +++ b/web/app/components/workflow/selection-contextmenu.tsx @@ -16,9 +16,10 @@ import { ContextMenuItem, ContextMenuSeparator, } from '@/app/components/base/ui/context-menu' -import { useNodesReadOnly, useNodesSyncDraft } from './hooks' +import { useNodesInteractions, useNodesReadOnly, useNodesSyncDraft } from './hooks' import { useSelectionInteractions } from './hooks/use-selection-interactions' import { useWorkflowHistory, WorkflowHistoryEvent } from './hooks/use-workflow-history' +import ShortcutsName from './shortcuts-name' import { useStore, useWorkflowStore } from './store' const AlignType = { @@ -223,6 +224,7 @@ const SelectionContextmenu = () => { const { t } = useTranslation() const { getNodesReadOnly } = useNodesReadOnly() const { handleSelectionContextmenuCancel } = useSelectionInteractions() + const { handleNodesCopy, handleNodesDelete, handleNodesDuplicate } = useNodesInteractions() const selectionMenu = useStore(s => s.selectionMenu) const store = useStoreApi() const workflowStore = useWorkflowStore() @@ -251,6 +253,21 @@ const SelectionContextmenu = () => { handleSelectionContextmenuCancel() }, [selectionMenu, selectedNodes.length, handleSelectionContextmenuCancel]) + const handleCopyNodes = useCallback(() => { + handleNodesCopy() + handleSelectionContextmenuCancel() + }, [handleNodesCopy, handleSelectionContextmenuCancel]) + + const handleDuplicateNodes = useCallback(() => { + handleNodesDuplicate() + handleSelectionContextmenuCancel() + }, [handleNodesDuplicate, handleSelectionContextmenuCancel]) + + const handleDeleteNodes = useCallback(() => { + handleNodesDelete() + handleSelectionContextmenuCancel() + }, [handleNodesDelete, handleSelectionContextmenuCancel]) + const handleAlignNodes = useCallback((alignType: AlignTypeValue) => { if (getNodesReadOnly() || selectedNodes.length <= 1) { handleSelectionContextmenuCancel() @@ -329,6 +346,36 @@ const SelectionContextmenu = () => { popupClassName="w-[240px]" positionerProps={anchor ? { anchor } : undefined} > + + + {t('common.copy', { defaultValue: 'common.copy', ns: 'workflow' })} + + + + {t('common.duplicate', { defaultValue: 'common.duplicate', ns: 'workflow' })} + + + + + + + {t('operation.delete', { defaultValue: 'operation.delete', ns: 'common' })} + + + + {menuSections.map((section, sectionIndex) => ( {sectionIndex > 0 && }