mirror of
https://github.com/langgenius/dify.git
synced 2026-04-28 20:17:29 +08:00
feat(workflow): add edge context menu with delete support (#33391)
This commit is contained in:
parent
1104d35bbb
commit
fe561ef3d0
@ -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<Element, 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(
|
||||||
|
<Workflow
|
||||||
|
nodes={baseNodes}
|
||||||
|
edges={baseEdges}
|
||||||
|
/>,
|
||||||
|
{
|
||||||
|
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(
|
||||||
|
<Workflow
|
||||||
|
nodes={baseNodes}
|
||||||
|
edges={baseEdges}
|
||||||
|
/>,
|
||||||
|
{
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
340
web/app/components/workflow/edge-contextmenu.spec.tsx
Normal file
340
web/app/components/workflow/edge-contextmenu.spec.tsx
Normal file
@ -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<typeof import('./utils')>()
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Right-click edge e1"
|
||||||
|
onContextMenu={e => handleEdgeContextMenu(e as never, rfState.edges.find(edge => edge.id === 'e1') as never)}
|
||||||
|
>
|
||||||
|
edge-e1
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Right-click edge e2"
|
||||||
|
onContextMenu={e => handleEdgeContextMenu(e as never, rfState.edges.find(edge => edge.id === 'e2') as never)}
|
||||||
|
>
|
||||||
|
edge-e2
|
||||||
|
</button>
|
||||||
|
<EdgeContextmenu />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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(<EdgeContextmenu />, {
|
||||||
|
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<string, unknown>).selected = true
|
||||||
|
;(rfState.edges[1] as Record<string, unknown>).selected = false
|
||||||
|
|
||||||
|
const { store } = renderWorkflowComponent(<EdgeContextmenu />, {
|
||||||
|
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(<EdgeContextmenu />, {
|
||||||
|
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(<EdgeMenuHarness />, {
|
||||||
|
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(<EdgeMenuHarness />, {
|
||||||
|
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(<EdgeMenuHarness />, {
|
||||||
|
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(<EdgeMenuHarness />, {
|
||||||
|
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(<EdgeMenuHarness />, {
|
||||||
|
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(<EdgeMenuHarness />, {
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
62
web/app/components/workflow/edge-contextmenu.tsx
Normal file
62
web/app/components/workflow/edge-contextmenu.tsx
Normal file
@ -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 (
|
||||||
|
<ContextMenu
|
||||||
|
open={!!edgeMenu}
|
||||||
|
onOpenChange={open => !open && handleEdgeContextmenuCancel()}
|
||||||
|
>
|
||||||
|
<ContextMenuContent
|
||||||
|
positionerProps={{ anchor }}
|
||||||
|
popupClassName="rounded-lg"
|
||||||
|
>
|
||||||
|
<ContextMenuItem
|
||||||
|
className="justify-between gap-4 px-3 text-text-secondary data-[highlighted]:bg-state-destructive-hover data-[highlighted]:text-text-destructive"
|
||||||
|
onClick={() => handleEdgeDeleteById(edgeMenu.edgeId)}
|
||||||
|
>
|
||||||
|
<span>{t('common:operation.delete')}</span>
|
||||||
|
<ShortcutsName keys={['del']} />
|
||||||
|
</ContextMenuItem>
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(EdgeContextmenu)
|
||||||
@ -83,15 +83,56 @@ describe('useEdgesInteractions', () => {
|
|||||||
expect(updated.find((e: { id: string }) => e.id === 'e2').selected).toBe(false)
|
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', () => {
|
it('handleEdgeDelete should remove selected edge and trigger sync + history', () => {
|
||||||
;(rfState.edges[0] as Record<string, unknown>).selected = true
|
;(rfState.edges[0] as Record<string, unknown>).selected = true
|
||||||
const { result } = renderEdgesInteractions()
|
const { result, store } = renderEdgesInteractions()
|
||||||
|
store.setState({
|
||||||
|
edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e1' },
|
||||||
|
})
|
||||||
|
|
||||||
result.current.handleEdgeDelete()
|
result.current.handleEdgeDelete()
|
||||||
|
|
||||||
const updated = rfState.setEdges.mock.calls[0][0]
|
const updated = rfState.setEdges.mock.calls[0][0]
|
||||||
expect(updated).toHaveLength(1)
|
expect(updated).toHaveLength(1)
|
||||||
expect(updated[0].id).toBe('e2')
|
expect(updated[0].id).toBe('e2')
|
||||||
|
expect(store.getState().edgeMenu).toBeUndefined()
|
||||||
expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeDelete')
|
expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeDelete')
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -101,13 +142,34 @@ describe('useEdgesInteractions', () => {
|
|||||||
expect(rfState.setEdges).not.toHaveBeenCalled()
|
expect(rfState.setEdges).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('handleEdgeDeleteById should remove the requested edge even when another edge is selected', () => {
|
||||||
|
;(rfState.edges[0] as Record<string, unknown>).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', () => {
|
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')
|
result.current.handleEdgeDeleteByDeleteBranch('n1', 'branch-a')
|
||||||
|
|
||||||
const updated = rfState.setEdges.mock.calls[0][0]
|
const updated = rfState.setEdges.mock.calls[0][0]
|
||||||
expect(updated).toHaveLength(1)
|
expect(updated).toHaveLength(1)
|
||||||
expect(updated[0].id).toBe('e2')
|
expect(updated[0].id).toBe('e2')
|
||||||
|
expect(store.getState().edgeMenu).toBeUndefined()
|
||||||
expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeDeleteByDeleteBranch')
|
expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeDeleteByDeleteBranch')
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -142,6 +204,23 @@ describe('useEdgesInteractions', () => {
|
|||||||
expect(rfState.setEdges).not.toHaveBeenCalled()
|
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', () => {
|
it('handleEdgeDeleteByDeleteBranch should do nothing', () => {
|
||||||
const { result } = renderEdgesInteractions()
|
const { result } = renderEdgesInteractions()
|
||||||
result.current.handleEdgeDeleteByDeleteBranch('n1', 'branch-a')
|
result.current.handleEdgeDeleteByDeleteBranch('n1', 'branch-a')
|
||||||
|
|||||||
@ -26,7 +26,13 @@ describe('usePanelInteractions', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('handlePaneContextMenu should set panelMenu with computed coordinates when container exists', () => {
|
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()
|
const preventDefault = vi.fn()
|
||||||
|
|
||||||
result.current.handlePaneContextMenu({
|
result.current.handlePaneContextMenu({
|
||||||
@ -40,6 +46,9 @@ describe('usePanelInteractions', () => {
|
|||||||
top: 200,
|
top: 200,
|
||||||
left: 250,
|
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', () => {
|
it('handlePaneContextMenu should throw when container does not exist', () => {
|
||||||
@ -75,4 +84,14 @@ describe('usePanelInteractions', () => {
|
|||||||
|
|
||||||
expect(store.getState().nodeMenu).toBeUndefined()
|
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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -150,7 +150,13 @@ describe('useSelectionInteractions', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('handleSelectionContextMenu should set menu only when clicking on selection rect', () => {
|
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')
|
const wrongTarget = document.createElement('div')
|
||||||
wrongTarget.classList.add('some-other-class')
|
wrongTarget.classList.add('some-other-class')
|
||||||
@ -176,6 +182,9 @@ describe('useSelectionInteractions', () => {
|
|||||||
top: 150,
|
top: 150,
|
||||||
left: 200,
|
left: 200,
|
||||||
})
|
})
|
||||||
|
expect(store.getState().nodeMenu).toBeUndefined()
|
||||||
|
expect(store.getState().panelMenu).toBeUndefined()
|
||||||
|
expect(store.getState().edgeMenu).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('handleSelectionContextmenuCancel should clear selectionMenu', () => {
|
it('handleSelectionContextmenuCancel should clear selectionMenu', () => {
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { useCallback } from 'react'
|
|||||||
import {
|
import {
|
||||||
useStoreApi,
|
useStoreApi,
|
||||||
} from 'reactflow'
|
} from 'reactflow'
|
||||||
|
import { useWorkflowStore } from '../store'
|
||||||
import { getNodesConnectedSourceOrTargetHandleIdsMap } from '../utils'
|
import { getNodesConnectedSourceOrTargetHandleIdsMap } from '../utils'
|
||||||
import { useNodesSyncDraft } from './use-nodes-sync-draft'
|
import { useNodesSyncDraft } from './use-nodes-sync-draft'
|
||||||
import { useNodesReadOnly } from './use-workflow'
|
import { useNodesReadOnly } from './use-workflow'
|
||||||
@ -17,10 +18,52 @@ import { useWorkflowHistory, WorkflowHistoryEvent } from './use-workflow-history
|
|||||||
|
|
||||||
export const useEdgesInteractions = () => {
|
export const useEdgesInteractions = () => {
|
||||||
const store = useStoreApi()
|
const store = useStoreApi()
|
||||||
|
const workflowStore = useWorkflowStore()
|
||||||
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
|
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
|
||||||
const { getNodesReadOnly } = useNodesReadOnly()
|
const { getNodesReadOnly } = useNodesReadOnly()
|
||||||
const { saveStateToHistory } = useWorkflowHistory()
|
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<EdgeMouseHandler>((_, edge) => {
|
const handleEdgeEnter = useCallback<EdgeMouseHandler>((_, edge) => {
|
||||||
if (getNodesReadOnly())
|
if (getNodesReadOnly())
|
||||||
return
|
return
|
||||||
@ -88,50 +131,31 @@ export const useEdgesInteractions = () => {
|
|||||||
return draft.filter(edge => !edgeWillBeDeleted.find(e => e.id === edge.id))
|
return draft.filter(edge => !edgeWillBeDeleted.find(e => e.id === edge.id))
|
||||||
})
|
})
|
||||||
setEdges(newEdges)
|
setEdges(newEdges)
|
||||||
|
const currentEdgeMenu = workflowStore.getState().edgeMenu
|
||||||
|
if (currentEdgeMenu && edgeWillBeDeleted.some(edge => edge.id === currentEdgeMenu.edgeId))
|
||||||
|
workflowStore.setState({ edgeMenu: undefined })
|
||||||
handleSyncWorkflowDraft()
|
handleSyncWorkflowDraft()
|
||||||
saveStateToHistory(WorkflowHistoryEvent.EdgeDeleteByDeleteBranch)
|
saveStateToHistory(WorkflowHistoryEvent.EdgeDeleteByDeleteBranch)
|
||||||
}, [getNodesReadOnly, store, handleSyncWorkflowDraft, saveStateToHistory])
|
}, [getNodesReadOnly, store, workflowStore, handleSyncWorkflowDraft, saveStateToHistory])
|
||||||
|
|
||||||
const handleEdgeDelete = useCallback(() => {
|
const handleEdgeDelete = useCallback(() => {
|
||||||
if (getNodesReadOnly())
|
if (getNodesReadOnly())
|
||||||
return
|
return
|
||||||
|
const { edges } = store.getState()
|
||||||
|
const currentEdge = edges.find(edge => edge.selected)
|
||||||
|
|
||||||
const {
|
if (!currentEdge)
|
||||||
getNodes,
|
|
||||||
setNodes,
|
|
||||||
edges,
|
|
||||||
setEdges,
|
|
||||||
} = store.getState()
|
|
||||||
const currentEdgeIndex = edges.findIndex(edge => edge.selected)
|
|
||||||
|
|
||||||
if (currentEdgeIndex < 0)
|
|
||||||
return
|
return
|
||||||
const currentEdge = edges[currentEdgeIndex]
|
|
||||||
const nodes = getNodes()
|
deleteEdgeById(currentEdge.id)
|
||||||
const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap(
|
}, [deleteEdgeById, getNodesReadOnly, store])
|
||||||
[
|
|
||||||
{ type: 'remove', edge: currentEdge },
|
const handleEdgeDeleteById = useCallback((edgeId: string) => {
|
||||||
],
|
if (getNodesReadOnly())
|
||||||
nodes,
|
return
|
||||||
)
|
|
||||||
const newNodes = produce(nodes, (draft: Node[]) => {
|
deleteEdgeById(edgeId)
|
||||||
draft.forEach((node) => {
|
}, [deleteEdgeById, getNodesReadOnly])
|
||||||
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])
|
|
||||||
|
|
||||||
const handleEdgesChange = useCallback<OnEdgesChange>((changes) => {
|
const handleEdgesChange = useCallback<OnEdgesChange>((changes) => {
|
||||||
if (getNodesReadOnly())
|
if (getNodesReadOnly())
|
||||||
@ -200,16 +224,61 @@ export const useEdgesInteractions = () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
setEdges(newEdges)
|
setEdges(newEdges)
|
||||||
|
const currentEdgeMenu = workflowStore.getState().edgeMenu
|
||||||
|
if (currentEdgeMenu && !newEdges.some(edge => edge.id === currentEdgeMenu.edgeId))
|
||||||
|
workflowStore.setState({ edgeMenu: undefined })
|
||||||
handleSyncWorkflowDraft()
|
handleSyncWorkflowDraft()
|
||||||
saveStateToHistory(WorkflowHistoryEvent.EdgeSourceHandleChange)
|
saveStateToHistory(WorkflowHistoryEvent.EdgeSourceHandleChange)
|
||||||
}, [getNodesReadOnly, store, handleSyncWorkflowDraft, saveStateToHistory])
|
}, [getNodesReadOnly, store, workflowStore, handleSyncWorkflowDraft, saveStateToHistory])
|
||||||
|
|
||||||
|
const handleEdgeContextMenu = useCallback<EdgeMouseHandler>((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 {
|
return {
|
||||||
handleEdgeEnter,
|
handleEdgeEnter,
|
||||||
handleEdgeLeave,
|
handleEdgeLeave,
|
||||||
handleEdgeDeleteByDeleteBranch,
|
handleEdgeDeleteByDeleteBranch,
|
||||||
handleEdgeDelete,
|
handleEdgeDelete,
|
||||||
|
handleEdgeDeleteById,
|
||||||
handleEdgesChange,
|
handleEdgesChange,
|
||||||
handleEdgeSourceHandleChange,
|
handleEdgeSourceHandleChange,
|
||||||
|
handleEdgeContextMenu,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1642,6 +1642,9 @@ export const useNodesInteractions = () => {
|
|||||||
const container = document.querySelector('#workflow-container')
|
const container = document.querySelector('#workflow-container')
|
||||||
const { x, y } = container!.getBoundingClientRect()
|
const { x, y } = container!.getBoundingClientRect()
|
||||||
workflowStore.setState({
|
workflowStore.setState({
|
||||||
|
panelMenu: undefined,
|
||||||
|
selectionMenu: undefined,
|
||||||
|
edgeMenu: undefined,
|
||||||
nodeMenu: {
|
nodeMenu: {
|
||||||
top: e.clientY - y,
|
top: e.clientY - y,
|
||||||
left: e.clientX - x,
|
left: e.clientX - x,
|
||||||
@ -2098,7 +2101,9 @@ export const useNodesInteractions = () => {
|
|||||||
|
|
||||||
setEdges(edges)
|
setEdges(edges)
|
||||||
setNodes(nodes)
|
setNodes(nodes)
|
||||||
|
workflowStore.setState({ edgeMenu: undefined })
|
||||||
}, [
|
}, [
|
||||||
|
workflowStore,
|
||||||
store,
|
store,
|
||||||
undo,
|
undo,
|
||||||
workflowHistoryStore,
|
workflowHistoryStore,
|
||||||
@ -2119,9 +2124,11 @@ export const useNodesInteractions = () => {
|
|||||||
|
|
||||||
setEdges(edges)
|
setEdges(edges)
|
||||||
setNodes(nodes)
|
setNodes(nodes)
|
||||||
|
workflowStore.setState({ edgeMenu: undefined })
|
||||||
}, [
|
}, [
|
||||||
redo,
|
redo,
|
||||||
store,
|
store,
|
||||||
|
workflowStore,
|
||||||
workflowHistoryStore,
|
workflowHistoryStore,
|
||||||
getNodesReadOnly,
|
getNodesReadOnly,
|
||||||
getWorkflowReadOnly,
|
getWorkflowReadOnly,
|
||||||
|
|||||||
@ -10,6 +10,9 @@ export const usePanelInteractions = () => {
|
|||||||
const container = document.querySelector('#workflow-container')
|
const container = document.querySelector('#workflow-container')
|
||||||
const { x, y } = container!.getBoundingClientRect()
|
const { x, y } = container!.getBoundingClientRect()
|
||||||
workflowStore.setState({
|
workflowStore.setState({
|
||||||
|
nodeMenu: undefined,
|
||||||
|
selectionMenu: undefined,
|
||||||
|
edgeMenu: undefined,
|
||||||
panelMenu: {
|
panelMenu: {
|
||||||
top: e.clientY - y,
|
top: e.clientY - y,
|
||||||
left: e.clientX - x,
|
left: e.clientX - x,
|
||||||
@ -29,9 +32,16 @@ export const usePanelInteractions = () => {
|
|||||||
})
|
})
|
||||||
}, [workflowStore])
|
}, [workflowStore])
|
||||||
|
|
||||||
|
const handleEdgeContextmenuCancel = useCallback(() => {
|
||||||
|
workflowStore.setState({
|
||||||
|
edgeMenu: undefined,
|
||||||
|
})
|
||||||
|
}, [workflowStore])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
handlePaneContextMenu,
|
handlePaneContextMenu,
|
||||||
handlePaneContextmenuCancel,
|
handlePaneContextmenuCancel,
|
||||||
handleNodeContextmenuCancel,
|
handleNodeContextmenuCancel,
|
||||||
|
handleEdgeContextmenuCancel,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -140,6 +140,9 @@ export const useSelectionInteractions = () => {
|
|||||||
const container = document.querySelector('#workflow-container')
|
const container = document.querySelector('#workflow-container')
|
||||||
const { x, y } = container!.getBoundingClientRect()
|
const { x, y } = container!.getBoundingClientRect()
|
||||||
workflowStore.setState({
|
workflowStore.setState({
|
||||||
|
nodeMenu: undefined,
|
||||||
|
panelMenu: undefined,
|
||||||
|
edgeMenu: undefined,
|
||||||
selectionMenu: {
|
selectionMenu: {
|
||||||
top: e.clientY - y,
|
top: e.clientY - y,
|
||||||
left: e.clientX - x,
|
left: e.clientX - x,
|
||||||
|
|||||||
@ -55,6 +55,7 @@ import {
|
|||||||
import CustomConnectionLine from './custom-connection-line'
|
import CustomConnectionLine from './custom-connection-line'
|
||||||
import CustomEdge from './custom-edge'
|
import CustomEdge from './custom-edge'
|
||||||
import DatasetsDetailProvider from './datasets-detail-store/provider'
|
import DatasetsDetailProvider from './datasets-detail-store/provider'
|
||||||
|
import EdgeContextmenu from './edge-contextmenu'
|
||||||
import HelpLine from './help-line'
|
import HelpLine from './help-line'
|
||||||
import {
|
import {
|
||||||
useEdgesInteractions,
|
useEdgesInteractions,
|
||||||
@ -203,6 +204,7 @@ export const Workflow: FC<WorkflowProps> = memo(({
|
|||||||
setNodes(v.payload.nodes)
|
setNodes(v.payload.nodes)
|
||||||
store.getState().setNodes(v.payload.nodes)
|
store.getState().setNodes(v.payload.nodes)
|
||||||
setEdges(v.payload.edges)
|
setEdges(v.payload.edges)
|
||||||
|
workflowStore.setState({ edgeMenu: undefined })
|
||||||
|
|
||||||
if (v.payload.viewport)
|
if (v.payload.viewport)
|
||||||
reactflow.setViewport(v.payload.viewport)
|
reactflow.setViewport(v.payload.viewport)
|
||||||
@ -306,6 +308,7 @@ export const Workflow: FC<WorkflowProps> = memo(({
|
|||||||
handleEdgeEnter,
|
handleEdgeEnter,
|
||||||
handleEdgeLeave,
|
handleEdgeLeave,
|
||||||
handleEdgesChange,
|
handleEdgesChange,
|
||||||
|
handleEdgeContextMenu,
|
||||||
} = useEdgesInteractions()
|
} = useEdgesInteractions()
|
||||||
const {
|
const {
|
||||||
handleSelectionStart,
|
handleSelectionStart,
|
||||||
@ -401,6 +404,7 @@ export const Workflow: FC<WorkflowProps> = memo(({
|
|||||||
<Operator handleRedo={handleHistoryForward} handleUndo={handleHistoryBack} />
|
<Operator handleRedo={handleHistoryForward} handleUndo={handleHistoryBack} />
|
||||||
<PanelContextmenu />
|
<PanelContextmenu />
|
||||||
<NodeContextmenu />
|
<NodeContextmenu />
|
||||||
|
<EdgeContextmenu />
|
||||||
<SelectionContextmenu />
|
<SelectionContextmenu />
|
||||||
<HelpLine />
|
<HelpLine />
|
||||||
{
|
{
|
||||||
@ -433,6 +437,7 @@ export const Workflow: FC<WorkflowProps> = memo(({
|
|||||||
onEdgeMouseEnter={handleEdgeEnter}
|
onEdgeMouseEnter={handleEdgeEnter}
|
||||||
onEdgeMouseLeave={handleEdgeLeave}
|
onEdgeMouseLeave={handleEdgeLeave}
|
||||||
onEdgesChange={handleEdgesChange}
|
onEdgesChange={handleEdgesChange}
|
||||||
|
onEdgeContextMenu={handleEdgeContextMenu}
|
||||||
onSelectionStart={handleSelectionStart}
|
onSelectionStart={handleSelectionStart}
|
||||||
onSelectionChange={handleSelectionChange}
|
onSelectionChange={handleSelectionChange}
|
||||||
onSelectionDrag={handleSelectionDrag}
|
onSelectionDrag={handleSelectionDrag}
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import type { Node } from './types'
|
|||||||
import { useClickAway } from 'ahooks'
|
import { useClickAway } from 'ahooks'
|
||||||
import {
|
import {
|
||||||
memo,
|
memo,
|
||||||
useEffect,
|
|
||||||
useRef,
|
useRef,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
|
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
|
||||||
@ -13,13 +12,9 @@ import { useStore } from './store'
|
|||||||
const NodeContextmenu = () => {
|
const NodeContextmenu = () => {
|
||||||
const ref = useRef(null)
|
const ref = useRef(null)
|
||||||
const nodes = useNodes()
|
const nodes = useNodes()
|
||||||
const { handleNodeContextmenuCancel, handlePaneContextmenuCancel } = usePanelInteractions()
|
const { handleNodeContextmenuCancel } = usePanelInteractions()
|
||||||
const nodeMenu = useStore(s => s.nodeMenu)
|
const nodeMenu = useStore(s => s.nodeMenu)
|
||||||
const currentNode = nodes.find(node => node.id === nodeMenu?.nodeId) as Node
|
const currentNode = nodes.find(node => node.id === nodeMenu?.nodeId) as Node
|
||||||
useEffect(() => {
|
|
||||||
if (nodeMenu)
|
|
||||||
handlePaneContextmenuCancel()
|
|
||||||
}, [nodeMenu, handlePaneContextmenuCancel])
|
|
||||||
|
|
||||||
useClickAway(() => {
|
useClickAway(() => {
|
||||||
handleNodeContextmenuCancel()
|
handleNodeContextmenuCancel()
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { useClickAway } from 'ahooks'
|
import { useClickAway } from 'ahooks'
|
||||||
import {
|
import {
|
||||||
memo,
|
memo,
|
||||||
useEffect,
|
|
||||||
useRef,
|
useRef,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
@ -25,16 +24,11 @@ const PanelContextmenu = () => {
|
|||||||
const clipboardElements = useStore(s => s.clipboardElements)
|
const clipboardElements = useStore(s => s.clipboardElements)
|
||||||
const setShowImportDSLModal = useStore(s => s.setShowImportDSLModal)
|
const setShowImportDSLModal = useStore(s => s.setShowImportDSLModal)
|
||||||
const { handleNodesPaste } = useNodesInteractions()
|
const { handleNodesPaste } = useNodesInteractions()
|
||||||
const { handlePaneContextmenuCancel, handleNodeContextmenuCancel } = usePanelInteractions()
|
const { handlePaneContextmenuCancel } = usePanelInteractions()
|
||||||
const { handleStartWorkflowRun } = useWorkflowStartRun()
|
const { handleStartWorkflowRun } = useWorkflowStartRun()
|
||||||
const { handleAddNote } = useOperator()
|
const { handleAddNote } = useOperator()
|
||||||
const { exportCheck } = useDSL()
|
const { exportCheck } = useDSL()
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (panelMenu)
|
|
||||||
handleNodeContextmenuCancel()
|
|
||||||
}, [panelMenu, handleNodeContextmenuCancel])
|
|
||||||
|
|
||||||
useClickAway(() => {
|
useClickAway(() => {
|
||||||
handlePaneContextmenuCancel()
|
handlePaneContextmenuCancel()
|
||||||
}, ref)
|
}, ref)
|
||||||
|
|||||||
@ -97,6 +97,7 @@ describe('createWorkflowStore', () => {
|
|||||||
['showDebugAndPreviewPanel', 'setShowDebugAndPreviewPanel', true],
|
['showDebugAndPreviewPanel', 'setShowDebugAndPreviewPanel', true],
|
||||||
['panelMenu', 'setPanelMenu', { top: 10, left: 20 }],
|
['panelMenu', 'setPanelMenu', { top: 10, left: 20 }],
|
||||||
['selectionMenu', 'setSelectionMenu', { top: 50, left: 60 }],
|
['selectionMenu', 'setSelectionMenu', { top: 50, left: 60 }],
|
||||||
|
['edgeMenu', 'setEdgeMenu', { clientX: 320, clientY: 180, edgeId: 'e1' }],
|
||||||
['showVariableInspectPanel', 'setShowVariableInspectPanel', true],
|
['showVariableInspectPanel', 'setShowVariableInspectPanel', true],
|
||||||
['initShowLastRunTab', 'setInitShowLastRunTab', true],
|
['initShowLastRunTab', 'setInitShowLastRunTab', true],
|
||||||
])('should update %s', (stateKey, setter, value) => {
|
])('should update %s', (stateKey, setter, value) => {
|
||||||
|
|||||||
@ -20,6 +20,12 @@ export type PanelSliceShape = {
|
|||||||
left: number
|
left: number
|
||||||
}
|
}
|
||||||
setSelectionMenu: (selectionMenu: PanelSliceShape['selectionMenu']) => void
|
setSelectionMenu: (selectionMenu: PanelSliceShape['selectionMenu']) => void
|
||||||
|
edgeMenu?: {
|
||||||
|
clientX: number
|
||||||
|
clientY: number
|
||||||
|
edgeId: string
|
||||||
|
}
|
||||||
|
setEdgeMenu: (edgeMenu: PanelSliceShape['edgeMenu']) => void
|
||||||
showVariableInspectPanel: boolean
|
showVariableInspectPanel: boolean
|
||||||
setShowVariableInspectPanel: (showVariableInspectPanel: boolean) => void
|
setShowVariableInspectPanel: (showVariableInspectPanel: boolean) => void
|
||||||
initShowLastRunTab: boolean
|
initShowLastRunTab: boolean
|
||||||
@ -40,6 +46,8 @@ export const createPanelSlice: StateCreator<PanelSliceShape> = set => ({
|
|||||||
setPanelMenu: panelMenu => set(() => ({ panelMenu })),
|
setPanelMenu: panelMenu => set(() => ({ panelMenu })),
|
||||||
selectionMenu: undefined,
|
selectionMenu: undefined,
|
||||||
setSelectionMenu: selectionMenu => set(() => ({ selectionMenu })),
|
setSelectionMenu: selectionMenu => set(() => ({ selectionMenu })),
|
||||||
|
edgeMenu: undefined,
|
||||||
|
setEdgeMenu: edgeMenu => set(() => ({ edgeMenu })),
|
||||||
showVariableInspectPanel: false,
|
showVariableInspectPanel: false,
|
||||||
setShowVariableInspectPanel: showVariableInspectPanel => set(() => ({ showVariableInspectPanel })),
|
setShowVariableInspectPanel: showVariableInspectPanel => set(() => ({ showVariableInspectPanel })),
|
||||||
initShowLastRunTab: false,
|
initShowLastRunTab: false,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user