From fe561ef3d010a0ce5a65992a8538e9ce36e839dc Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:11:24 +0800 Subject: [PATCH] feat(workflow): add edge context menu with delete support (#33391) --- .../__tests__/workflow-edge-events.spec.tsx | 396 ++++++++++++++++++ .../workflow/edge-contextmenu.spec.tsx | 340 +++++++++++++++ .../components/workflow/edge-contextmenu.tsx | 62 +++ .../__tests__/use-edges-interactions.spec.ts | 83 +++- .../__tests__/use-panel-interactions.spec.ts | 21 +- .../use-selection-interactions.spec.ts | 11 +- .../workflow/hooks/use-edges-interactions.ts | 143 +++++-- .../workflow/hooks/use-nodes-interactions.ts | 7 + .../workflow/hooks/use-panel-interactions.ts | 10 + .../hooks/use-selection-interactions.ts | 3 + web/app/components/workflow/index.tsx | 5 + .../components/workflow/node-contextmenu.tsx | 7 +- .../components/workflow/panel-contextmenu.tsx | 8 +- .../store/__tests__/workflow-store.spec.ts | 1 + .../workflow/store/workflow/panel-slice.ts | 8 + 15 files changed, 1051 insertions(+), 54 deletions(-) create mode 100644 web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx create mode 100644 web/app/components/workflow/edge-contextmenu.spec.tsx create mode 100644 web/app/components/workflow/edge-contextmenu.tsx diff --git a/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx b/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx new file mode 100644 index 0000000000..1e40ea65da --- /dev/null +++ b/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx @@ -0,0 +1,396 @@ +import type { EdgeChange, ReactFlowProps } from 'reactflow' +import type { Edge, Node } from '../types' +import { act, fireEvent, screen } from '@testing-library/react' +import * as React from 'react' +import { FlowType } from '@/types/common' +import { WORKFLOW_DATA_UPDATE } from '../constants' +import { Workflow } from '../index' +import { renderWorkflowComponent } from './workflow-test-env' + +const reactFlowState = vi.hoisted(() => ({ + lastProps: null as ReactFlowProps | null, +})) + +type WorkflowUpdateEvent = { + type: string + payload: { + nodes: Node[] + edges: Edge[] + } +} + +const eventEmitterState = vi.hoisted(() => ({ + subscription: null as null | ((payload: WorkflowUpdateEvent) => void), +})) + +const workflowHookMocks = vi.hoisted(() => ({ + handleNodeDragStart: vi.fn(), + handleNodeDrag: vi.fn(), + handleNodeDragStop: vi.fn(), + handleNodeEnter: vi.fn(), + handleNodeLeave: vi.fn(), + handleNodeClick: vi.fn(), + handleNodeConnect: vi.fn(), + handleNodeConnectStart: vi.fn(), + handleNodeConnectEnd: vi.fn(), + handleNodeContextMenu: vi.fn(), + handleHistoryBack: vi.fn(), + handleHistoryForward: vi.fn(), + handleEdgeEnter: vi.fn(), + handleEdgeLeave: vi.fn(), + handleEdgesChange: vi.fn(), + handleEdgeContextMenu: vi.fn(), + handleSelectionStart: vi.fn(), + handleSelectionChange: vi.fn(), + handleSelectionDrag: vi.fn(), + handleSelectionContextMenu: vi.fn(), + handlePaneContextMenu: vi.fn(), + handleSyncWorkflowDraft: vi.fn(), + fetchInspectVars: vi.fn(), + isValidConnection: vi.fn(), + useShortcuts: vi.fn(), + useWorkflowSearch: vi.fn(), +})) + +const baseNodes = [ + { + id: 'node-1', + type: 'custom', + position: { x: 0, y: 0 }, + data: {}, + }, +] as unknown as Node[] + +const baseEdges = [ + { + id: 'edge-1', + source: 'node-1', + target: 'node-2', + data: { sourceType: 'start', targetType: 'end' }, + }, +] as unknown as Edge[] + +const edgeChanges: EdgeChange[] = [{ id: 'edge-1', type: 'remove' }] + +function createMouseEvent() { + return { + preventDefault: vi.fn(), + clientX: 24, + clientY: 48, + } as unknown as React.MouseEvent +} + +vi.mock('next/dynamic', () => ({ + default: () => () => null, +})) + +vi.mock('reactflow', async () => { + const mod = await import('./reactflow-mock-state') + const base = mod.createReactFlowModuleMock() + const ReactFlowMock = (props: ReactFlowProps) => { + reactFlowState.lastProps = props + return React.createElement( + 'div', + { 'data-testid': 'reactflow-mock' }, + React.createElement('button', { + 'type': 'button', + 'aria-label': 'Emit edge mouse enter', + 'onClick': () => props.onEdgeMouseEnter?.(createMouseEvent(), baseEdges[0]), + }), + React.createElement('button', { + 'type': 'button', + 'aria-label': 'Emit edge mouse leave', + 'onClick': () => props.onEdgeMouseLeave?.(createMouseEvent(), baseEdges[0]), + }), + React.createElement('button', { + 'type': 'button', + 'aria-label': 'Emit edges change', + 'onClick': () => props.onEdgesChange?.(edgeChanges), + }), + React.createElement('button', { + 'type': 'button', + 'aria-label': 'Emit edge context menu', + 'onClick': () => props.onEdgeContextMenu?.(createMouseEvent(), baseEdges[0]), + }), + React.createElement('button', { + 'type': 'button', + 'aria-label': 'Emit node context menu', + 'onClick': () => props.onNodeContextMenu?.(createMouseEvent(), baseNodes[0]), + }), + React.createElement('button', { + 'type': 'button', + 'aria-label': 'Emit pane context menu', + 'onClick': () => props.onPaneContextMenu?.(createMouseEvent()), + }), + props.children, + ) + } + + return { + ...base, + SelectionMode: { + Partial: 'partial', + }, + ReactFlow: ReactFlowMock, + default: ReactFlowMock, + } +}) + +vi.mock('@/context/event-emitter', () => ({ + useEventEmitterContextContext: () => ({ + eventEmitter: { + useSubscription: (handler: (payload: WorkflowUpdateEvent) => void) => { + eventEmitterState.subscription = handler + }, + }, + }), +})) + +vi.mock('@/service/use-tools', () => ({ + useAllBuiltInTools: () => ({ data: [] }), + useAllCustomTools: () => ({ data: [] }), + useAllMCPTools: () => ({ data: [] }), + useAllWorkflowTools: () => ({ data: [] }), +})) + +vi.mock('@/service/workflow', () => ({ + fetchAllInspectVars: vi.fn().mockResolvedValue([]), +})) + +vi.mock('../candidate-node', () => ({ + default: () => null, +})) + +vi.mock('../custom-connection-line', () => ({ + default: () => null, +})) + +vi.mock('../custom-edge', () => ({ + default: () => null, +})) + +vi.mock('../help-line', () => ({ + default: () => null, +})) + +vi.mock('../edge-contextmenu', () => ({ + default: () => null, +})) + +vi.mock('../node-contextmenu', () => ({ + default: () => null, +})) + +vi.mock('../nodes', () => ({ + default: () => null, +})) + +vi.mock('../nodes/data-source-empty', () => ({ + default: () => null, +})) + +vi.mock('../nodes/iteration-start', () => ({ + default: () => null, +})) + +vi.mock('../nodes/loop-start', () => ({ + default: () => null, +})) + +vi.mock('../note-node', () => ({ + default: () => null, +})) + +vi.mock('../operator', () => ({ + default: () => null, +})) + +vi.mock('../operator/control', () => ({ + default: () => null, +})) + +vi.mock('../panel-contextmenu', () => ({ + default: () => null, +})) + +vi.mock('../selection-contextmenu', () => ({ + default: () => null, +})) + +vi.mock('../simple-node', () => ({ + default: () => null, +})) + +vi.mock('../syncing-data-modal', () => ({ + default: () => null, +})) + +vi.mock('../hooks', () => ({ + useEdgesInteractions: () => ({ + handleEdgeEnter: workflowHookMocks.handleEdgeEnter, + handleEdgeLeave: workflowHookMocks.handleEdgeLeave, + handleEdgesChange: workflowHookMocks.handleEdgesChange, + handleEdgeContextMenu: workflowHookMocks.handleEdgeContextMenu, + }), + useNodesInteractions: () => ({ + handleNodeDragStart: workflowHookMocks.handleNodeDragStart, + handleNodeDrag: workflowHookMocks.handleNodeDrag, + handleNodeDragStop: workflowHookMocks.handleNodeDragStop, + handleNodeEnter: workflowHookMocks.handleNodeEnter, + handleNodeLeave: workflowHookMocks.handleNodeLeave, + handleNodeClick: workflowHookMocks.handleNodeClick, + handleNodeConnect: workflowHookMocks.handleNodeConnect, + handleNodeConnectStart: workflowHookMocks.handleNodeConnectStart, + handleNodeConnectEnd: workflowHookMocks.handleNodeConnectEnd, + handleNodeContextMenu: workflowHookMocks.handleNodeContextMenu, + handleHistoryBack: workflowHookMocks.handleHistoryBack, + handleHistoryForward: workflowHookMocks.handleHistoryForward, + }), + useNodesReadOnly: () => ({ + nodesReadOnly: false, + getNodesReadOnly: () => false, + }), + useNodesSyncDraft: () => ({ + handleSyncWorkflowDraft: workflowHookMocks.handleSyncWorkflowDraft, + syncWorkflowDraftWhenPageClose: vi.fn(), + }), + usePanelInteractions: () => ({ + handlePaneContextMenu: workflowHookMocks.handlePaneContextMenu, + handleEdgeContextmenuCancel: vi.fn(), + }), + useSelectionInteractions: () => ({ + handleSelectionStart: workflowHookMocks.handleSelectionStart, + handleSelectionChange: workflowHookMocks.handleSelectionChange, + handleSelectionDrag: workflowHookMocks.handleSelectionDrag, + handleSelectionContextMenu: workflowHookMocks.handleSelectionContextMenu, + }), + useSetWorkflowVarsWithValue: () => ({ + fetchInspectVars: workflowHookMocks.fetchInspectVars, + }), + useShortcuts: workflowHookMocks.useShortcuts, + useWorkflow: () => ({ + isValidConnection: workflowHookMocks.isValidConnection, + }), + useWorkflowReadOnly: () => ({ + workflowReadOnly: false, + }), + useWorkflowRefreshDraft: () => ({ + handleRefreshWorkflowDraft: vi.fn(), + }), +})) + +vi.mock('../hooks/use-workflow-search', () => ({ + useWorkflowSearch: workflowHookMocks.useWorkflowSearch, +})) + +vi.mock('../nodes/_base/components/variable/use-match-schema-type', () => ({ + default: () => ({ + schemaTypeDefinitions: undefined, + }), +})) + +vi.mock('../workflow-history-store', () => ({ + WorkflowHistoryProvider: ({ children }: { children?: React.ReactNode }) => React.createElement(React.Fragment, null, children), +})) + +function renderSubject() { + return renderWorkflowComponent( + , + { + hooksStoreProps: { + configsMap: { + flowId: 'flow-1', + flowType: FlowType.appFlow, + fileSettings: {}, + }, + }, + }, + ) +} + +describe('Workflow edge event wiring', () => { + beforeEach(() => { + vi.clearAllMocks() + reactFlowState.lastProps = null + eventEmitterState.subscription = null + }) + + it('should forward React Flow edge events to workflow handlers when emitted by the canvas', () => { + renderSubject() + + fireEvent.click(screen.getByRole('button', { name: 'Emit edge mouse enter' })) + fireEvent.click(screen.getByRole('button', { name: 'Emit edge mouse leave' })) + fireEvent.click(screen.getByRole('button', { name: 'Emit edges change' })) + fireEvent.click(screen.getByRole('button', { name: 'Emit edge context menu' })) + fireEvent.click(screen.getByRole('button', { name: 'Emit node context menu' })) + fireEvent.click(screen.getByRole('button', { name: 'Emit pane context menu' })) + + expect(workflowHookMocks.handleEdgeEnter).toHaveBeenCalledWith(expect.objectContaining({ + clientX: 24, + clientY: 48, + }), baseEdges[0]) + expect(workflowHookMocks.handleEdgeLeave).toHaveBeenCalledWith(expect.objectContaining({ + clientX: 24, + clientY: 48, + }), baseEdges[0]) + expect(workflowHookMocks.handleEdgesChange).toHaveBeenCalledWith(edgeChanges) + expect(workflowHookMocks.handleEdgeContextMenu).toHaveBeenCalledWith(expect.objectContaining({ + clientX: 24, + clientY: 48, + }), baseEdges[0]) + expect(workflowHookMocks.handleNodeContextMenu).toHaveBeenCalledWith(expect.objectContaining({ + clientX: 24, + clientY: 48, + }), baseNodes[0]) + expect(workflowHookMocks.handlePaneContextMenu).toHaveBeenCalledWith(expect.objectContaining({ + clientX: 24, + clientY: 48, + })) + }) + + it('should keep edge deletion delegated to workflow shortcuts instead of React Flow defaults', () => { + renderSubject() + + expect(reactFlowState.lastProps?.deleteKeyCode).toBeNull() + }) + + it('should clear edgeMenu when workflow data updates remove the current edge', () => { + const { store } = renderWorkflowComponent( + , + { + initialStoreState: { + edgeMenu: { + clientX: 320, + clientY: 180, + edgeId: 'edge-1', + }, + }, + hooksStoreProps: { + configsMap: { + flowId: 'flow-1', + flowType: FlowType.appFlow, + fileSettings: {}, + }, + }, + }, + ) + + act(() => { + eventEmitterState.subscription?.({ + type: WORKFLOW_DATA_UPDATE, + payload: { + nodes: baseNodes, + edges: [], + }, + }) + }) + + expect(store.getState().edgeMenu).toBeUndefined() + }) +}) diff --git a/web/app/components/workflow/edge-contextmenu.spec.tsx b/web/app/components/workflow/edge-contextmenu.spec.tsx new file mode 100644 index 0000000000..c1b021e624 --- /dev/null +++ b/web/app/components/workflow/edge-contextmenu.spec.tsx @@ -0,0 +1,340 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useEffect } from 'react' +import { resetReactFlowMockState, rfState } from './__tests__/reactflow-mock-state' +import { renderWorkflowComponent } from './__tests__/workflow-test-env' +import EdgeContextmenu from './edge-contextmenu' +import { useEdgesInteractions } from './hooks/use-edges-interactions' + +vi.mock('reactflow', async () => + (await import('./__tests__/reactflow-mock-state')).createReactFlowModuleMock()) + +const mockSaveStateToHistory = vi.fn() + +vi.mock('./hooks/use-workflow-history', () => ({ + useWorkflowHistory: () => ({ saveStateToHistory: mockSaveStateToHistory }), + WorkflowHistoryEvent: { + EdgeDelete: 'EdgeDelete', + EdgeDeleteByDeleteBranch: 'EdgeDeleteByDeleteBranch', + EdgeSourceHandleChange: 'EdgeSourceHandleChange', + }, +})) + +vi.mock('./hooks/use-workflow', () => ({ + useNodesReadOnly: () => ({ + getNodesReadOnly: () => false, + }), +})) + +vi.mock('./utils', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + getNodesConnectedSourceOrTargetHandleIdsMap: vi.fn(() => ({})), + } +}) + +vi.mock('./hooks', async () => { + const { useEdgesInteractions } = await import('./hooks/use-edges-interactions') + const { usePanelInteractions } = await import('./hooks/use-panel-interactions') + + return { + useEdgesInteractions, + usePanelInteractions, + } +}) + +describe('EdgeContextmenu', () => { + const hooksStoreProps = { + doSyncWorkflowDraft: vi.fn().mockResolvedValue(undefined), + } + type TestNode = typeof rfState.nodes[number] & { + selected?: boolean + data: { + selected?: boolean + _isBundled?: boolean + } + } + type TestEdge = typeof rfState.edges[number] & { + selected?: boolean + } + const createNode = (id: string, selected = false): TestNode => ({ + id, + position: { x: 0, y: 0 }, + data: { selected }, + selected, + }) + const createEdge = (id: string, selected = false): TestEdge => ({ + id, + source: 'n1', + target: 'n2', + data: {}, + selected, + }) + + const EdgeMenuHarness = () => { + const { handleEdgeContextMenu, handleEdgeDelete } = useEdgesInteractions() + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key !== 'Delete' && e.key !== 'Backspace') + return + + e.preventDefault() + handleEdgeDelete() + } + + document.addEventListener('keydown', handleKeyDown) + return () => { + document.removeEventListener('keydown', handleKeyDown) + } + }, [handleEdgeDelete]) + + return ( +
+ + + +
+ ) + } + + beforeEach(() => { + vi.clearAllMocks() + resetReactFlowMockState() + rfState.nodes = [ + createNode('n1'), + createNode('n2'), + ] + rfState.edges = [ + createEdge('e1', true) as typeof rfState.edges[number] & { selected: boolean }, + createEdge('e2'), + ] + rfState.setNodes.mockImplementation((nextNodes) => { + rfState.nodes = nextNodes as typeof rfState.nodes + }) + rfState.setEdges.mockImplementation((nextEdges) => { + rfState.edges = nextEdges as typeof rfState.edges + }) + }) + + it('should not render when edgeMenu is absent', () => { + renderWorkflowComponent(, { + hooksStoreProps, + }) + + expect(screen.queryByRole('menu')).not.toBeInTheDocument() + }) + + it('should delete the menu edge and close the menu when another edge is selected', async () => { + const user = userEvent.setup() + ;(rfState.edges[0] as Record).selected = true + ;(rfState.edges[1] as Record).selected = false + + const { store } = renderWorkflowComponent(, { + initialStoreState: { + edgeMenu: { + clientX: 320, + clientY: 180, + edgeId: 'e2', + }, + }, + hooksStoreProps, + }) + + const deleteAction = await screen.findByRole('menuitem', { name: /common:operation\.delete/i }) + expect(screen.getByText(/^del$/i)).toBeInTheDocument() + + await user.click(deleteAction) + + const updatedEdges = rfState.setEdges.mock.calls.at(-1)?.[0] + expect(updatedEdges).toHaveLength(1) + expect(updatedEdges[0].id).toBe('e1') + expect(updatedEdges[0].selected).toBe(true) + expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeDelete') + + await waitFor(() => { + expect(store.getState().edgeMenu).toBeUndefined() + expect(screen.queryByRole('menu')).not.toBeInTheDocument() + }) + }) + + it('should not render the menu when the referenced edge no longer exists', () => { + renderWorkflowComponent(, { + initialStoreState: { + edgeMenu: { + clientX: 320, + clientY: 180, + edgeId: 'missing-edge', + }, + }, + hooksStoreProps, + }) + + expect(screen.queryByRole('menu')).not.toBeInTheDocument() + }) + + it('should open the edge menu at the right-click position', async () => { + const fromRectSpy = vi.spyOn(DOMRect, 'fromRect') + + renderWorkflowComponent(, { + hooksStoreProps, + }) + + fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e2' }), { + clientX: 320, + clientY: 180, + }) + + expect(await screen.findByRole('menu')).toBeInTheDocument() + expect(screen.getByRole('menuitem', { name: /common:operation\.delete/i })).toBeInTheDocument() + expect(fromRectSpy).toHaveBeenLastCalledWith(expect.objectContaining({ + x: 320, + y: 180, + width: 0, + height: 0, + })) + }) + + it('should delete the right-clicked edge and close the menu when delete is clicked', async () => { + const user = userEvent.setup() + + renderWorkflowComponent(, { + hooksStoreProps, + }) + + fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e2' }), { + clientX: 320, + clientY: 180, + }) + + await user.click(await screen.findByRole('menuitem', { name: /common:operation\.delete/i })) + + await waitFor(() => { + expect(screen.queryByRole('menu')).not.toBeInTheDocument() + }) + expect(rfState.edges.map(edge => edge.id)).toEqual(['e1']) + expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeDelete') + }) + + it.each([ + ['Delete', 'Delete'], + ['Backspace', 'Backspace'], + ])('should delete the right-clicked edge with %s after switching from a selected node', async (_, key) => { + renderWorkflowComponent(, { + hooksStoreProps, + }) + rfState.nodes = [createNode('n1', true), createNode('n2')] + + fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e2' }), { + clientX: 240, + clientY: 120, + }) + + expect(await screen.findByRole('menu')).toBeInTheDocument() + + fireEvent.keyDown(document, { key }) + + await waitFor(() => { + expect(screen.queryByRole('menu')).not.toBeInTheDocument() + }) + expect(rfState.edges.map(edge => edge.id)).toEqual(['e1']) + expect(rfState.nodes.map(node => node.id)).toEqual(['n1', 'n2']) + expect((rfState.nodes as TestNode[]).every(node => !node.selected && !node.data.selected)).toBe(true) + }) + + it('should keep bundled multi-selection nodes intact when delete runs after right-clicking an edge', async () => { + renderWorkflowComponent(, { + hooksStoreProps, + }) + rfState.nodes = [ + { ...createNode('n1', true), data: { selected: true, _isBundled: true } }, + { ...createNode('n2', true), data: { selected: true, _isBundled: true } }, + ] + + fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e1' }), { + clientX: 200, + clientY: 100, + }) + + expect(await screen.findByRole('menu')).toBeInTheDocument() + + fireEvent.keyDown(document, { key: 'Delete' }) + + await waitFor(() => { + expect(screen.queryByRole('menu')).not.toBeInTheDocument() + }) + expect(rfState.edges.map(edge => edge.id)).toEqual(['e2']) + expect(rfState.nodes).toHaveLength(2) + expect((rfState.nodes as TestNode[]).every(node => !node.selected && !node.data.selected && !node.data._isBundled)).toBe(true) + }) + + it('should retarget the menu and selected edge when right-clicking a different edge', async () => { + const fromRectSpy = vi.spyOn(DOMRect, 'fromRect') + + renderWorkflowComponent(, { + hooksStoreProps, + }) + const edgeOneButton = screen.getByLabelText('Right-click edge e1') + const edgeTwoButton = screen.getByLabelText('Right-click edge e2') + + fireEvent.contextMenu(edgeOneButton, { + clientX: 80, + clientY: 60, + }) + expect(await screen.findByRole('menu')).toBeInTheDocument() + + fireEvent.contextMenu(edgeTwoButton, { + clientX: 360, + clientY: 240, + }) + + await waitFor(() => { + expect(screen.getAllByRole('menu')).toHaveLength(1) + expect(fromRectSpy).toHaveBeenLastCalledWith(expect.objectContaining({ + x: 360, + y: 240, + })) + expect((rfState.edges as TestEdge[]).find(edge => edge.id === 'e1')?.selected).toBe(false) + expect((rfState.edges as TestEdge[]).find(edge => edge.id === 'e2')?.selected).toBe(true) + }) + }) + + it('should hide the menu when the target edge disappears after opening it', async () => { + const { store } = renderWorkflowComponent(, { + hooksStoreProps, + }) + + fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e1' }), { + clientX: 160, + clientY: 100, + }) + expect(await screen.findByRole('menu')).toBeInTheDocument() + + rfState.edges = [createEdge('e2')] + store.setState({ + edgeMenu: { + clientX: 160, + clientY: 100, + edgeId: 'e1', + }, + }) + + await waitFor(() => { + expect(screen.queryByRole('menu')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/workflow/edge-contextmenu.tsx b/web/app/components/workflow/edge-contextmenu.tsx new file mode 100644 index 0000000000..254df5165b --- /dev/null +++ b/web/app/components/workflow/edge-contextmenu.tsx @@ -0,0 +1,62 @@ +import { + memo, + useMemo, +} from 'react' +import { useTranslation } from 'react-i18next' +import { useEdges } from 'reactflow' +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, +} from '@/app/components/base/ui/context-menu' +import { useEdgesInteractions, usePanelInteractions } from './hooks' +import ShortcutsName from './shortcuts-name' +import { useStore } from './store' + +const EdgeContextmenu = () => { + const { t } = useTranslation() + const edgeMenu = useStore(s => s.edgeMenu) + const { handleEdgeDeleteById } = useEdgesInteractions() + const { handleEdgeContextmenuCancel } = usePanelInteractions() + const edges = useEdges() + const currentEdgeExists = !edgeMenu || edges.some(edge => edge.id === edgeMenu.edgeId) + + const anchor = useMemo(() => { + if (!edgeMenu || !currentEdgeExists) + return null + + return { + getBoundingClientRect: () => DOMRect.fromRect({ + width: 0, + height: 0, + x: edgeMenu.clientX, + y: edgeMenu.clientY, + }), + } + }, [currentEdgeExists, edgeMenu]) + + if (!edgeMenu || !currentEdgeExists || !anchor) + return null + + return ( + !open && handleEdgeContextmenuCancel()} + > + + handleEdgeDeleteById(edgeMenu.edgeId)} + > + {t('common:operation.delete')} + + + + + ) +} + +export default memo(EdgeContextmenu) diff --git a/web/app/components/workflow/hooks/__tests__/use-edges-interactions.spec.ts b/web/app/components/workflow/hooks/__tests__/use-edges-interactions.spec.ts index 6d19862efd..c596be0a4b 100644 --- a/web/app/components/workflow/hooks/__tests__/use-edges-interactions.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-edges-interactions.spec.ts @@ -83,15 +83,56 @@ describe('useEdgesInteractions', () => { expect(updated.find((e: { id: string }) => e.id === 'e2').selected).toBe(false) }) + it('handleEdgeContextMenu should select the clicked edge and open edgeMenu', () => { + const preventDefault = vi.fn() + const { result, store } = renderEdgesInteractions() + rfState.nodes = [ + { id: 'n1', position: { x: 0, y: 0 }, data: { selected: true, _isBundled: true }, selected: true } as typeof rfState.nodes[number] & { selected: boolean }, + { id: 'n2', position: { x: 100, y: 0 }, data: { _isBundled: true } }, + ] + rfState.edges = [ + { id: 'e1', source: 'n1', target: 'n2', sourceHandle: 'branch-a', data: { _hovering: false, _isBundled: true } }, + { id: 'e2', source: 'n1', target: 'n2', sourceHandle: 'branch-b', data: { _hovering: false, _isBundled: true } }, + ] + + result.current.handleEdgeContextMenu({ + preventDefault, + clientX: 320, + clientY: 180, + } as never, rfState.edges[1] as never) + + expect(preventDefault).toHaveBeenCalled() + + const updated = rfState.setEdges.mock.calls[0][0] + expect(updated.find((e: { id: string }) => e.id === 'e1').selected).toBe(false) + expect(updated.find((e: { id: string }) => e.id === 'e2').selected).toBe(true) + expect(updated.every((e: { data: { _isBundled?: boolean } }) => !e.data._isBundled)).toBe(true) + const updatedNodes = rfState.setNodes.mock.calls[0][0] + expect(updatedNodes.every((node: { data: { selected?: boolean, _isBundled?: boolean }, selected?: boolean }) => !node.data.selected && !node.selected && !node.data._isBundled)).toBe(true) + + expect(store.getState().edgeMenu).toEqual({ + clientX: 320, + clientY: 180, + edgeId: 'e2', + }) + expect(store.getState().nodeMenu).toBeUndefined() + expect(store.getState().panelMenu).toBeUndefined() + expect(store.getState().selectionMenu).toBeUndefined() + }) + it('handleEdgeDelete should remove selected edge and trigger sync + history', () => { ;(rfState.edges[0] as Record).selected = true - const { result } = renderEdgesInteractions() + const { result, store } = renderEdgesInteractions() + store.setState({ + edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e1' }, + }) result.current.handleEdgeDelete() const updated = rfState.setEdges.mock.calls[0][0] expect(updated).toHaveLength(1) expect(updated[0].id).toBe('e2') + expect(store.getState().edgeMenu).toBeUndefined() expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeDelete') }) @@ -101,13 +142,34 @@ describe('useEdgesInteractions', () => { expect(rfState.setEdges).not.toHaveBeenCalled() }) + it('handleEdgeDeleteById should remove the requested edge even when another edge is selected', () => { + ;(rfState.edges[0] as Record).selected = true + const { result, store } = renderEdgesInteractions() + store.setState({ + edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e2' }, + }) + + result.current.handleEdgeDeleteById('e2') + + const updated = rfState.setEdges.mock.calls[0][0] + expect(updated).toHaveLength(1) + expect(updated[0].id).toBe('e1') + expect(updated[0].selected).toBe(true) + expect(store.getState().edgeMenu).toBeUndefined() + expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeDelete') + }) + it('handleEdgeDeleteByDeleteBranch should remove edges for the given branch', () => { - const { result } = renderEdgesInteractions() + const { result, store } = renderEdgesInteractions() + store.setState({ + edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e1' }, + }) result.current.handleEdgeDeleteByDeleteBranch('n1', 'branch-a') const updated = rfState.setEdges.mock.calls[0][0] expect(updated).toHaveLength(1) expect(updated[0].id).toBe('e2') + expect(store.getState().edgeMenu).toBeUndefined() expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeDeleteByDeleteBranch') }) @@ -142,6 +204,23 @@ describe('useEdgesInteractions', () => { expect(rfState.setEdges).not.toHaveBeenCalled() }) + it('handleEdgeDeleteById should do nothing', () => { + const { result } = renderEdgesInteractions() + result.current.handleEdgeDeleteById('e1') + expect(rfState.setEdges).not.toHaveBeenCalled() + }) + + it('handleEdgeContextMenu should do nothing', () => { + const { result, store } = renderEdgesInteractions() + result.current.handleEdgeContextMenu({ + preventDefault: vi.fn(), + clientX: 200, + clientY: 120, + } as never, rfState.edges[0] as never) + expect(rfState.setEdges).not.toHaveBeenCalled() + expect(store.getState().edgeMenu).toBeUndefined() + }) + it('handleEdgeDeleteByDeleteBranch should do nothing', () => { const { result } = renderEdgesInteractions() result.current.handleEdgeDeleteByDeleteBranch('n1', 'branch-a') diff --git a/web/app/components/workflow/hooks/__tests__/use-panel-interactions.spec.ts b/web/app/components/workflow/hooks/__tests__/use-panel-interactions.spec.ts index ec689f23f9..517af513b9 100644 --- a/web/app/components/workflow/hooks/__tests__/use-panel-interactions.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-panel-interactions.spec.ts @@ -26,7 +26,13 @@ describe('usePanelInteractions', () => { }) it('handlePaneContextMenu should set panelMenu with computed coordinates when container exists', () => { - const { result, store } = renderWorkflowHook(() => usePanelInteractions()) + const { result, store } = renderWorkflowHook(() => usePanelInteractions(), { + initialStoreState: { + nodeMenu: { top: 20, left: 40, nodeId: 'n1' }, + selectionMenu: { top: 30, left: 50 }, + edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e1' }, + }, + }) const preventDefault = vi.fn() result.current.handlePaneContextMenu({ @@ -40,6 +46,9 @@ describe('usePanelInteractions', () => { top: 200, left: 250, }) + expect(store.getState().nodeMenu).toBeUndefined() + expect(store.getState().selectionMenu).toBeUndefined() + expect(store.getState().edgeMenu).toBeUndefined() }) it('handlePaneContextMenu should throw when container does not exist', () => { @@ -75,4 +84,14 @@ describe('usePanelInteractions', () => { expect(store.getState().nodeMenu).toBeUndefined() }) + + it('handleEdgeContextmenuCancel should clear edgeMenu', () => { + const { result, store } = renderWorkflowHook(() => usePanelInteractions(), { + initialStoreState: { edgeMenu: { clientX: 300, clientY: 200, edgeId: 'e1' } }, + }) + + result.current.handleEdgeContextmenuCancel() + + expect(store.getState().edgeMenu).toBeUndefined() + }) }) diff --git a/web/app/components/workflow/hooks/__tests__/use-selection-interactions.spec.ts b/web/app/components/workflow/hooks/__tests__/use-selection-interactions.spec.ts index 7e65176e6f..4c4eb010e6 100644 --- a/web/app/components/workflow/hooks/__tests__/use-selection-interactions.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-selection-interactions.spec.ts @@ -150,7 +150,13 @@ describe('useSelectionInteractions', () => { }) it('handleSelectionContextMenu should set menu only when clicking on selection rect', () => { - const { result, store } = renderWorkflowHook(() => useSelectionInteractions()) + const { result, store } = renderWorkflowHook(() => useSelectionInteractions(), { + initialStoreState: { + nodeMenu: { top: 10, left: 20, nodeId: 'n1' }, + panelMenu: { top: 30, left: 40 }, + edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e1' }, + }, + }) const wrongTarget = document.createElement('div') wrongTarget.classList.add('some-other-class') @@ -176,6 +182,9 @@ describe('useSelectionInteractions', () => { top: 150, left: 200, }) + expect(store.getState().nodeMenu).toBeUndefined() + expect(store.getState().panelMenu).toBeUndefined() + expect(store.getState().edgeMenu).toBeUndefined() }) it('handleSelectionContextmenuCancel should clear selectionMenu', () => { diff --git a/web/app/components/workflow/hooks/use-edges-interactions.ts b/web/app/components/workflow/hooks/use-edges-interactions.ts index 0e911c3de8..484e552ba2 100644 --- a/web/app/components/workflow/hooks/use-edges-interactions.ts +++ b/web/app/components/workflow/hooks/use-edges-interactions.ts @@ -10,6 +10,7 @@ import { useCallback } from 'react' import { useStoreApi, } from 'reactflow' +import { useWorkflowStore } from '../store' import { getNodesConnectedSourceOrTargetHandleIdsMap } from '../utils' import { useNodesSyncDraft } from './use-nodes-sync-draft' import { useNodesReadOnly } from './use-workflow' @@ -17,10 +18,52 @@ import { useWorkflowHistory, WorkflowHistoryEvent } from './use-workflow-history export const useEdgesInteractions = () => { const store = useStoreApi() + const workflowStore = useWorkflowStore() const { handleSyncWorkflowDraft } = useNodesSyncDraft() const { getNodesReadOnly } = useNodesReadOnly() const { saveStateToHistory } = useWorkflowHistory() + const deleteEdgeById = useCallback((edgeId: string) => { + const { + getNodes, + setNodes, + edges, + setEdges, + } = store.getState() + const currentEdgeIndex = edges.findIndex(edge => edge.id === edgeId) + + if (currentEdgeIndex < 0) + return + const currentEdge = edges[currentEdgeIndex] + const nodes = getNodes() + const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap( + [ + { type: 'remove', edge: currentEdge }, + ], + nodes, + ) + const newNodes = produce(nodes, (draft: Node[]) => { + draft.forEach((node) => { + if (nodesConnectedSourceOrTargetHandleIdsMap[node.id]) { + node.data = { + ...node.data, + ...nodesConnectedSourceOrTargetHandleIdsMap[node.id], + } + } + }) + }) + setNodes(newNodes) + const newEdges = produce(edges, (draft) => { + draft.splice(currentEdgeIndex, 1) + }) + setEdges(newEdges) + const currentEdgeMenu = workflowStore.getState().edgeMenu + if (currentEdgeMenu?.edgeId === currentEdge.id) + workflowStore.setState({ edgeMenu: undefined }) + handleSyncWorkflowDraft() + saveStateToHistory(WorkflowHistoryEvent.EdgeDelete) + }, [store, workflowStore, handleSyncWorkflowDraft, saveStateToHistory]) + const handleEdgeEnter = useCallback((_, edge) => { if (getNodesReadOnly()) return @@ -88,50 +131,31 @@ export const useEdgesInteractions = () => { return draft.filter(edge => !edgeWillBeDeleted.find(e => e.id === edge.id)) }) setEdges(newEdges) + const currentEdgeMenu = workflowStore.getState().edgeMenu + if (currentEdgeMenu && edgeWillBeDeleted.some(edge => edge.id === currentEdgeMenu.edgeId)) + workflowStore.setState({ edgeMenu: undefined }) handleSyncWorkflowDraft() saveStateToHistory(WorkflowHistoryEvent.EdgeDeleteByDeleteBranch) - }, [getNodesReadOnly, store, handleSyncWorkflowDraft, saveStateToHistory]) + }, [getNodesReadOnly, store, workflowStore, handleSyncWorkflowDraft, saveStateToHistory]) const handleEdgeDelete = useCallback(() => { if (getNodesReadOnly()) return + const { edges } = store.getState() + const currentEdge = edges.find(edge => edge.selected) - const { - getNodes, - setNodes, - edges, - setEdges, - } = store.getState() - const currentEdgeIndex = edges.findIndex(edge => edge.selected) - - if (currentEdgeIndex < 0) + if (!currentEdge) return - const currentEdge = edges[currentEdgeIndex] - const nodes = getNodes() - const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap( - [ - { type: 'remove', edge: currentEdge }, - ], - nodes, - ) - const newNodes = produce(nodes, (draft: Node[]) => { - draft.forEach((node) => { - if (nodesConnectedSourceOrTargetHandleIdsMap[node.id]) { - node.data = { - ...node.data, - ...nodesConnectedSourceOrTargetHandleIdsMap[node.id], - } - } - }) - }) - setNodes(newNodes) - const newEdges = produce(edges, (draft) => { - draft.splice(currentEdgeIndex, 1) - }) - setEdges(newEdges) - handleSyncWorkflowDraft() - saveStateToHistory(WorkflowHistoryEvent.EdgeDelete) - }, [getNodesReadOnly, store, handleSyncWorkflowDraft, saveStateToHistory]) + + deleteEdgeById(currentEdge.id) + }, [deleteEdgeById, getNodesReadOnly, store]) + + const handleEdgeDeleteById = useCallback((edgeId: string) => { + if (getNodesReadOnly()) + return + + deleteEdgeById(edgeId) + }, [deleteEdgeById, getNodesReadOnly]) const handleEdgesChange = useCallback((changes) => { if (getNodesReadOnly()) @@ -200,16 +224,61 @@ export const useEdgesInteractions = () => { }) }) setEdges(newEdges) + const currentEdgeMenu = workflowStore.getState().edgeMenu + if (currentEdgeMenu && !newEdges.some(edge => edge.id === currentEdgeMenu.edgeId)) + workflowStore.setState({ edgeMenu: undefined }) handleSyncWorkflowDraft() saveStateToHistory(WorkflowHistoryEvent.EdgeSourceHandleChange) - }, [getNodesReadOnly, store, handleSyncWorkflowDraft, saveStateToHistory]) + }, [getNodesReadOnly, store, workflowStore, handleSyncWorkflowDraft, saveStateToHistory]) + + const handleEdgeContextMenu = useCallback((e, edge) => { + if (getNodesReadOnly()) + return + + e.preventDefault() + + const { getNodes, setNodes, edges, setEdges } = store.getState() + const newEdges = produce(edges, (draft) => { + draft.forEach((item) => { + item.selected = item.id === edge.id + if (item.data._isBundled) + item.data._isBundled = false + }) + }) + setEdges(newEdges) + const nodes = getNodes() + if (nodes.some(node => node.data.selected || node.selected || node.data._isBundled)) { + const newNodes = produce(nodes, (draft: Node[]) => { + draft.forEach((node) => { + node.data.selected = false + if (node.data._isBundled) + node.data._isBundled = false + node.selected = false + }) + }) + setNodes(newNodes) + } + + workflowStore.setState({ + nodeMenu: undefined, + panelMenu: undefined, + selectionMenu: undefined, + edgeMenu: { + clientX: e.clientX, + clientY: e.clientY, + edgeId: edge.id, + }, + }) + }, [store, workflowStore, getNodesReadOnly]) return { handleEdgeEnter, handleEdgeLeave, handleEdgeDeleteByDeleteBranch, handleEdgeDelete, + handleEdgeDeleteById, handleEdgesChange, handleEdgeSourceHandleChange, + handleEdgeContextMenu, } } diff --git a/web/app/components/workflow/hooks/use-nodes-interactions.ts b/web/app/components/workflow/hooks/use-nodes-interactions.ts index e18405b196..b0dd43eb1d 100644 --- a/web/app/components/workflow/hooks/use-nodes-interactions.ts +++ b/web/app/components/workflow/hooks/use-nodes-interactions.ts @@ -1642,6 +1642,9 @@ export const useNodesInteractions = () => { const container = document.querySelector('#workflow-container') const { x, y } = container!.getBoundingClientRect() workflowStore.setState({ + panelMenu: undefined, + selectionMenu: undefined, + edgeMenu: undefined, nodeMenu: { top: e.clientY - y, left: e.clientX - x, @@ -2098,7 +2101,9 @@ export const useNodesInteractions = () => { setEdges(edges) setNodes(nodes) + workflowStore.setState({ edgeMenu: undefined }) }, [ + workflowStore, store, undo, workflowHistoryStore, @@ -2119,9 +2124,11 @@ export const useNodesInteractions = () => { setEdges(edges) setNodes(nodes) + workflowStore.setState({ edgeMenu: undefined }) }, [ redo, store, + workflowStore, workflowHistoryStore, getNodesReadOnly, getWorkflowReadOnly, diff --git a/web/app/components/workflow/hooks/use-panel-interactions.ts b/web/app/components/workflow/hooks/use-panel-interactions.ts index 1f02ac7c74..469a7abdee 100644 --- a/web/app/components/workflow/hooks/use-panel-interactions.ts +++ b/web/app/components/workflow/hooks/use-panel-interactions.ts @@ -10,6 +10,9 @@ export const usePanelInteractions = () => { const container = document.querySelector('#workflow-container') const { x, y } = container!.getBoundingClientRect() workflowStore.setState({ + nodeMenu: undefined, + selectionMenu: undefined, + edgeMenu: undefined, panelMenu: { top: e.clientY - y, left: e.clientX - x, @@ -29,9 +32,16 @@ export const usePanelInteractions = () => { }) }, [workflowStore]) + const handleEdgeContextmenuCancel = useCallback(() => { + workflowStore.setState({ + edgeMenu: undefined, + }) + }, [workflowStore]) + return { handlePaneContextMenu, handlePaneContextmenuCancel, handleNodeContextmenuCancel, + handleEdgeContextmenuCancel, } } diff --git a/web/app/components/workflow/hooks/use-selection-interactions.ts b/web/app/components/workflow/hooks/use-selection-interactions.ts index dbdef306c5..3c05d64cc4 100644 --- a/web/app/components/workflow/hooks/use-selection-interactions.ts +++ b/web/app/components/workflow/hooks/use-selection-interactions.ts @@ -140,6 +140,9 @@ export const useSelectionInteractions = () => { const container = document.querySelector('#workflow-container') const { x, y } = container!.getBoundingClientRect() workflowStore.setState({ + nodeMenu: undefined, + panelMenu: undefined, + edgeMenu: undefined, selectionMenu: { top: e.clientY - y, left: e.clientX - x, diff --git a/web/app/components/workflow/index.tsx b/web/app/components/workflow/index.tsx index 62516a797d..087368029f 100644 --- a/web/app/components/workflow/index.tsx +++ b/web/app/components/workflow/index.tsx @@ -55,6 +55,7 @@ import { import CustomConnectionLine from './custom-connection-line' import CustomEdge from './custom-edge' import DatasetsDetailProvider from './datasets-detail-store/provider' +import EdgeContextmenu from './edge-contextmenu' import HelpLine from './help-line' import { useEdgesInteractions, @@ -203,6 +204,7 @@ export const Workflow: FC = memo(({ setNodes(v.payload.nodes) store.getState().setNodes(v.payload.nodes) setEdges(v.payload.edges) + workflowStore.setState({ edgeMenu: undefined }) if (v.payload.viewport) reactflow.setViewport(v.payload.viewport) @@ -306,6 +308,7 @@ export const Workflow: FC = memo(({ handleEdgeEnter, handleEdgeLeave, handleEdgesChange, + handleEdgeContextMenu, } = useEdgesInteractions() const { handleSelectionStart, @@ -401,6 +404,7 @@ export const Workflow: FC = memo(({ + { @@ -433,6 +437,7 @@ export const Workflow: FC = memo(({ onEdgeMouseEnter={handleEdgeEnter} onEdgeMouseLeave={handleEdgeLeave} onEdgesChange={handleEdgesChange} + onEdgeContextMenu={handleEdgeContextMenu} onSelectionStart={handleSelectionStart} onSelectionChange={handleSelectionChange} onSelectionDrag={handleSelectionDrag} diff --git a/web/app/components/workflow/node-contextmenu.tsx b/web/app/components/workflow/node-contextmenu.tsx index cd749fefc0..b02fbc5a6e 100644 --- a/web/app/components/workflow/node-contextmenu.tsx +++ b/web/app/components/workflow/node-contextmenu.tsx @@ -2,7 +2,6 @@ import type { Node } from './types' import { useClickAway } from 'ahooks' import { memo, - useEffect, useRef, } from 'react' import useNodes from '@/app/components/workflow/store/workflow/use-nodes' @@ -13,13 +12,9 @@ import { useStore } from './store' const NodeContextmenu = () => { const ref = useRef(null) const nodes = useNodes() - const { handleNodeContextmenuCancel, handlePaneContextmenuCancel } = usePanelInteractions() + const { handleNodeContextmenuCancel } = usePanelInteractions() const nodeMenu = useStore(s => s.nodeMenu) const currentNode = nodes.find(node => node.id === nodeMenu?.nodeId) as Node - useEffect(() => { - if (nodeMenu) - handlePaneContextmenuCancel() - }, [nodeMenu, handlePaneContextmenuCancel]) useClickAway(() => { handleNodeContextmenuCancel() diff --git a/web/app/components/workflow/panel-contextmenu.tsx b/web/app/components/workflow/panel-contextmenu.tsx index d21d5f1dbc..41b0fbfbad 100644 --- a/web/app/components/workflow/panel-contextmenu.tsx +++ b/web/app/components/workflow/panel-contextmenu.tsx @@ -1,7 +1,6 @@ import { useClickAway } from 'ahooks' import { memo, - useEffect, useRef, } from 'react' import { useTranslation } from 'react-i18next' @@ -25,16 +24,11 @@ const PanelContextmenu = () => { const clipboardElements = useStore(s => s.clipboardElements) const setShowImportDSLModal = useStore(s => s.setShowImportDSLModal) const { handleNodesPaste } = useNodesInteractions() - const { handlePaneContextmenuCancel, handleNodeContextmenuCancel } = usePanelInteractions() + const { handlePaneContextmenuCancel } = usePanelInteractions() const { handleStartWorkflowRun } = useWorkflowStartRun() const { handleAddNote } = useOperator() const { exportCheck } = useDSL() - useEffect(() => { - if (panelMenu) - handleNodeContextmenuCancel() - }, [panelMenu, handleNodeContextmenuCancel]) - useClickAway(() => { handlePaneContextmenuCancel() }, ref) diff --git a/web/app/components/workflow/store/__tests__/workflow-store.spec.ts b/web/app/components/workflow/store/__tests__/workflow-store.spec.ts index c917986953..df0288ac09 100644 --- a/web/app/components/workflow/store/__tests__/workflow-store.spec.ts +++ b/web/app/components/workflow/store/__tests__/workflow-store.spec.ts @@ -97,6 +97,7 @@ describe('createWorkflowStore', () => { ['showDebugAndPreviewPanel', 'setShowDebugAndPreviewPanel', true], ['panelMenu', 'setPanelMenu', { top: 10, left: 20 }], ['selectionMenu', 'setSelectionMenu', { top: 50, left: 60 }], + ['edgeMenu', 'setEdgeMenu', { clientX: 320, clientY: 180, edgeId: 'e1' }], ['showVariableInspectPanel', 'setShowVariableInspectPanel', true], ['initShowLastRunTab', 'setInitShowLastRunTab', true], ])('should update %s', (stateKey, setter, value) => { diff --git a/web/app/components/workflow/store/workflow/panel-slice.ts b/web/app/components/workflow/store/workflow/panel-slice.ts index 4848beeac5..bf8b248c3a 100644 --- a/web/app/components/workflow/store/workflow/panel-slice.ts +++ b/web/app/components/workflow/store/workflow/panel-slice.ts @@ -20,6 +20,12 @@ export type PanelSliceShape = { left: number } setSelectionMenu: (selectionMenu: PanelSliceShape['selectionMenu']) => void + edgeMenu?: { + clientX: number + clientY: number + edgeId: string + } + setEdgeMenu: (edgeMenu: PanelSliceShape['edgeMenu']) => void showVariableInspectPanel: boolean setShowVariableInspectPanel: (showVariableInspectPanel: boolean) => void initShowLastRunTab: boolean @@ -40,6 +46,8 @@ export const createPanelSlice: StateCreator = set => ({ setPanelMenu: panelMenu => set(() => ({ panelMenu })), selectionMenu: undefined, setSelectionMenu: selectionMenu => set(() => ({ selectionMenu })), + edgeMenu: undefined, + setEdgeMenu: edgeMenu => set(() => ({ edgeMenu })), showVariableInspectPanel: false, setShowVariableInspectPanel: showVariableInspectPanel => set(() => ({ showVariableInspectPanel })), initShowLastRunTab: false,