Skip to content

Commit 8f3e42e

Browse files
authored
refactor(web): migrate workflow node actions menu (#35785)
1 parent 1359c03 commit 8f3e42e

25 files changed

Lines changed: 877 additions & 735 deletions

web/app/components/workflow/__tests__/node-contextmenu.spec.tsx

Lines changed: 40 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,24 @@
11
import type { Node } from '../types'
22
import { fireEvent, render, screen } from '@testing-library/react'
3-
import NodeContextmenu from '../node-contextmenu'
3+
import { NodeContextmenu } from '../node-contextmenu'
44

5-
const mockUseClickAway = vi.hoisted(() => vi.fn())
65
const mockUseNodes = vi.hoisted(() => vi.fn())
76
const mockUsePanelInteractions = vi.hoisted(() => vi.fn())
87
const mockUseStore = vi.hoisted(() => vi.fn())
9-
const mockPanelOperatorPopup = vi.hoisted(() => vi.fn())
10-
11-
vi.mock('ahooks', () => ({
12-
useClickAway: (...args: unknown[]) => mockUseClickAway(...args),
8+
const mockNodeActionsContextMenuContent = vi.hoisted(() => vi.fn())
9+
const mockContextMenuContent = vi.hoisted(() => vi.fn())
10+
11+
vi.mock('@langgenius/dify-ui/context-menu', () => ({
12+
ContextMenu: ({ children, onOpenChange }: { children: React.ReactNode, onOpenChange: (open: boolean) => void }) => (
13+
<div>
14+
{children}
15+
<button type="button" onClick={() => onOpenChange(false)}>close-context-menu</button>
16+
</div>
17+
),
18+
ContextMenuContent: ({ children, positionerProps, popupClassName }: { children: React.ReactNode, positionerProps?: { anchor?: unknown }, popupClassName?: string }) => {
19+
mockContextMenuContent({ positionerProps, popupClassName })
20+
return <div>{children}</div>
21+
},
1322
}))
1423

1524
vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({
@@ -22,20 +31,19 @@ vi.mock('@/app/components/workflow/hooks', () => ({
2231
}))
2332

2433
vi.mock('@/app/components/workflow/store', () => ({
25-
useStore: (selector: (state: { nodeMenu?: { nodeId: string, left: number, top: number } }) => unknown) => mockUseStore(selector),
34+
useStore: (selector: (state: { nodeMenu?: { nodeId: string, clientX: number, clientY: number } }) => unknown) => mockUseStore(selector),
2635
}))
2736

28-
vi.mock('@/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup', () => ({
29-
__esModule: true,
30-
default: (props: {
37+
vi.mock('@/app/components/workflow/node-actions-menu/context-menu-content', () => ({
38+
NodeActionsContextMenuContent: (props: {
3139
id: string
3240
data: Node['data']
3341
showHelpLink: boolean
34-
onClosePopup: () => void
42+
onClose: () => void
3543
}) => {
36-
mockPanelOperatorPopup(props)
44+
mockNodeActionsContextMenuContent(props)
3745
return (
38-
<button type="button" onClick={props.onClosePopup}>
46+
<button type="button" onClick={props.onClose}>
3947
{props.id}
4048
:
4149
{props.data.title}
@@ -46,9 +54,8 @@ vi.mock('@/app/components/workflow/nodes/_base/components/panel-operator/panel-o
4654

4755
describe('NodeContextmenu', () => {
4856
const mockHandleNodeContextmenuCancel = vi.fn()
49-
let nodeMenu: { nodeId: string, left: number, top: number } | undefined
57+
let nodeMenu: { nodeId: string, clientX: number, clientY: number } | undefined
5058
let nodes: Node[]
51-
let clickAwayHandler: (() => void) | undefined
5259

5360
beforeEach(() => {
5461
vi.clearAllMocks()
@@ -63,51 +70,50 @@ describe('NodeContextmenu', () => {
6370
type: 'code' as never,
6471
},
6572
} as Node]
66-
clickAwayHandler = undefined
6773

68-
mockUseClickAway.mockImplementation((handler: () => void) => {
69-
clickAwayHandler = handler
70-
})
7174
mockUseNodes.mockImplementation(() => nodes)
7275
mockUsePanelInteractions.mockReturnValue({
7376
handleNodeContextmenuCancel: mockHandleNodeContextmenuCancel,
7477
})
75-
mockUseStore.mockImplementation((selector: (state: { nodeMenu?: { nodeId: string, left: number, top: number } }) => unknown) => selector({ nodeMenu }))
78+
mockUseStore.mockImplementation((selector: (state: { nodeMenu?: { nodeId: string, clientX: number, clientY: number } }) => unknown) => selector({ nodeMenu }))
7679
})
7780

7881
it('should stay hidden when the node menu is absent', () => {
7982
render(<NodeContextmenu />)
8083

8184
expect(screen.queryByRole('button')).not.toBeInTheDocument()
82-
expect(mockPanelOperatorPopup).not.toHaveBeenCalled()
85+
expect(mockNodeActionsContextMenuContent).not.toHaveBeenCalled()
8386
})
8487

8588
it('should stay hidden when the referenced node cannot be found', () => {
86-
nodeMenu = { nodeId: 'missing-node', left: 80, top: 120 }
89+
nodeMenu = { nodeId: 'missing-node', clientX: 80, clientY: 120 }
8790

8891
render(<NodeContextmenu />)
8992

9093
expect(screen.queryByRole('button')).not.toBeInTheDocument()
91-
expect(mockPanelOperatorPopup).not.toHaveBeenCalled()
94+
expect(mockNodeActionsContextMenuContent).not.toHaveBeenCalled()
9295
})
9396

94-
it('should render the popup at the stored position and close on popup/click-away actions', () => {
95-
nodeMenu = { nodeId: 'node-1', left: 80, top: 120 }
96-
const { container } = render(<NodeContextmenu />)
97+
it('should render the context menu at the stored pointer position and close on content/root actions', () => {
98+
nodeMenu = { nodeId: 'node-1', clientX: 80, clientY: 120 }
99+
render(<NodeContextmenu />)
97100

98-
expect(screen.getByRole('button')).toHaveTextContent('node-1:Node 1')
99-
expect(mockPanelOperatorPopup).toHaveBeenCalledWith(expect.objectContaining({
101+
expect(screen.getByText('node-1:Node 1')).toBeInTheDocument()
102+
expect(mockNodeActionsContextMenuContent).toHaveBeenCalledWith(expect.objectContaining({
100103
id: 'node-1',
101104
data: expect.objectContaining({ title: 'Node 1' }),
102105
showHelpLink: true,
103106
}))
104-
expect(container.firstChild).toHaveStyle({
105-
left: '80px',
106-
top: '120px',
107-
})
107+
expect(mockContextMenuContent).toHaveBeenCalledWith(expect.objectContaining({
108+
popupClassName: 'w-[240px] rounded-lg',
109+
}))
110+
const anchor = mockContextMenuContent.mock.calls[0]![0].positionerProps.anchor as { getBoundingClientRect: () => DOMRect }
111+
const rect = anchor.getBoundingClientRect()
112+
expect(rect.x).toBe(80)
113+
expect(rect.y).toBe(120)
108114

109-
fireEvent.click(screen.getByRole('button'))
110-
clickAwayHandler?.()
115+
fireEvent.click(screen.getByText('node-1:Node 1'))
116+
fireEvent.click(screen.getByText('close-context-menu'))
111117

112118
expect(mockHandleNodeContextmenuCancel).toHaveBeenCalledTimes(2)
113119
})

web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,7 @@ vi.mock('../edge-contextmenu', () => ({
297297
}))
298298

299299
vi.mock('../node-contextmenu', () => ({
300-
default: () => null,
300+
NodeContextmenu: () => null,
301301
}))
302302

303303
vi.mock('../nodes', () => ({

web/app/components/workflow/hooks/__tests__/use-panel-interactions.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ describe('usePanelInteractions', () => {
3939
it('handlePaneContextMenu should set panelMenu with computed coordinates when container exists', () => {
4040
const { result, store } = renderWorkflowHook(() => usePanelInteractions(), {
4141
initialStoreState: {
42-
nodeMenu: { top: 20, left: 40, nodeId: 'n1' },
42+
nodeMenu: { clientX: 40, clientY: 20, nodeId: 'n1' },
4343
selectionMenu: { clientX: 30, clientY: 50 },
4444
edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e1' },
4545
},
@@ -116,7 +116,7 @@ describe('usePanelInteractions', () => {
116116

117117
it('handleNodeContextmenuCancel should clear nodeMenu', () => {
118118
const { result, store } = renderWorkflowHook(() => usePanelInteractions(), {
119-
initialStoreState: { nodeMenu: { top: 10, left: 20, nodeId: 'n1' } },
119+
initialStoreState: { nodeMenu: { clientX: 20, clientY: 10, nodeId: 'n1' } },
120120
})
121121

122122
result.current.handleNodeContextmenuCancel()

web/app/components/workflow/hooks/__tests__/use-selection-interactions.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ describe('useSelectionInteractions', () => {
173173

174174
it('handleSelectionContextMenu should set menu only when clicking on selection rect', () => {
175175
const { result, store } = renderSelectionInteractions({
176-
nodeMenu: { top: 10, left: 20, nodeId: 'n1' },
176+
nodeMenu: { clientX: 20, clientY: 10, nodeId: 'n1' },
177177
panelMenu: { top: 30, left: 40 },
178178
edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e1' },
179179
})

web/app/components/workflow/hooks/use-nodes-interactions.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1700,15 +1700,13 @@ export const useNodesInteractions = () => {
17001700
}
17011701

17021702
e.preventDefault()
1703-
const container = document.querySelector('#workflow-container')
1704-
const { x, y } = container!.getBoundingClientRect()
17051703
workflowStore.setState({
17061704
panelMenu: undefined,
17071705
selectionMenu: undefined,
17081706
edgeMenu: undefined,
17091707
nodeMenu: {
1710-
top: e.clientY - y,
1711-
left: e.clientX - x,
1708+
clientX: e.clientX,
1709+
clientY: e.clientY,
17121710
nodeId: node.id,
17131711
},
17141712
})

web/app/components/workflow/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ import {
9595
import { HooksStoreContextProvider, useHooksStore } from './hooks-store'
9696
import { useWorkflowComment } from './hooks/use-workflow-comment'
9797
import { useWorkflowSearch } from './hooks/use-workflow-search'
98-
import NodeContextmenu from './node-contextmenu'
98+
import { NodeContextmenu } from './node-contextmenu'
9999
import CustomNode from './nodes'
100100
import useMatchSchemaType from './nodes/_base/components/variable/use-match-schema-type'
101101
import CustomDataSourceEmptyNode from './nodes/data-source-empty'

0 commit comments

Comments
 (0)