feat: add copy/delete to multi nodes context menu (#34138)

This commit is contained in:
非法操作
2026-03-27 09:20:39 +08:00
committed by GitHub
parent 408f650b0c
commit 368896d84d
2 changed files with 93 additions and 1 deletions

View File

@@ -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<typeof import('../hooks')>('../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 }),

View File

@@ -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}
>
<ContextMenuGroup>
<ContextMenuItem
className="justify-between px-3 text-text-secondary"
data-testid="selection-contextmenu-item-copy"
onClick={handleCopyNodes}
>
<span>{t('common.copy', { defaultValue: 'common.copy', ns: 'workflow' })}</span>
<ShortcutsName keys={['ctrl', 'c']} />
</ContextMenuItem>
<ContextMenuItem
className="justify-between px-3 text-text-secondary"
data-testid="selection-contextmenu-item-duplicate"
onClick={handleDuplicateNodes}
>
<span>{t('common.duplicate', { defaultValue: 'common.duplicate', ns: 'workflow' })}</span>
<ShortcutsName keys={['ctrl', 'd']} />
</ContextMenuItem>
</ContextMenuGroup>
<ContextMenuSeparator />
<ContextMenuGroup>
<ContextMenuItem
className="justify-between px-3 text-text-secondary data-[highlighted]:bg-state-destructive-hover data-[highlighted]:text-text-destructive"
data-testid="selection-contextmenu-item-delete"
onClick={handleDeleteNodes}
>
<span>{t('operation.delete', { defaultValue: 'operation.delete', ns: 'common' })}</span>
<ShortcutsName keys={['del']} />
</ContextMenuItem>
</ContextMenuGroup>
<ContextMenuSeparator />
{menuSections.map((section, sectionIndex) => (
<ContextMenuGroup key={section.titleKey}>
{sectionIndex > 0 && <ContextMenuSeparator />}