mirror of
https://github.com/langgenius/dify.git
synced 2026-04-05 05:24:20 +08:00
feat: add copy/delete to multi nodes context menu (#34138)
This commit is contained in:
@@ -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 }),
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
Reference in New Issue
Block a user