Skip to content

Commit 90fe54c

Browse files
authored
refactor(web): migrate workflow panel context menu primitive (#35787)
1 parent b43ebf5 commit 90fe54c

18 files changed

Lines changed: 278 additions & 275 deletions
Lines changed: 86 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -1,107 +1,63 @@
1-
import type { ReactNode } from 'react'
2-
import { fireEvent, render, screen } from '@testing-library/react'
1+
import { fireEvent, screen, waitFor } from '@testing-library/react'
32
import PanelContextmenu from '../panel-contextmenu'
3+
import { BlockEnum } from '../types'
4+
import { createNode } from './fixtures'
5+
import { renderWorkflowFlowComponent } from './workflow-test-env'
46

5-
const mockUseClickAway = vi.hoisted(() => vi.fn())
67
const mockUseTranslation = vi.hoisted(() => vi.fn())
7-
const mockUseStore = vi.hoisted(() => vi.fn())
88
const mockUseNodesInteractions = vi.hoisted(() => vi.fn())
99
const mockUsePanelInteractions = vi.hoisted(() => vi.fn())
1010
const mockUseWorkflowStartRun = vi.hoisted(() => vi.fn())
1111
const mockUseWorkflowMoveMode = vi.hoisted(() => vi.fn())
1212
const mockUseOperator = vi.hoisted(() => vi.fn())
1313
const mockUseDSL = vi.hoisted(() => vi.fn())
14-
15-
vi.mock('ahooks', () => ({
16-
useClickAway: (...args: unknown[]) => mockUseClickAway(...args),
17-
}))
14+
const mockUseNodesReadOnly = vi.hoisted(() => vi.fn())
15+
const mockUseAvailableBlocks = vi.hoisted(() => vi.fn())
16+
const mockUseNodesMetaData = vi.hoisted(() => vi.fn())
17+
const mockUseIsChatMode = vi.hoisted(() => vi.fn())
1818

1919
vi.mock('react-i18next', () => ({
2020
useTranslation: () => mockUseTranslation(),
2121
}))
2222

23-
vi.mock('@/app/components/workflow/store', () => ({
24-
useStore: (selector: (state: {
25-
panelMenu?: { left: number, top: number }
26-
clipboardElements: unknown[]
27-
pendingComment: null | { pageX: number, pageY: number, elementX: number, elementY: number }
28-
setCommentPlacing: (placing: boolean) => void
29-
setCommentQuickAdd: (quickAdd: boolean) => void
30-
setShowImportDSLModal: (visible: boolean) => void
31-
}) => unknown) => mockUseStore(selector),
32-
}))
33-
3423
vi.mock('@/app/components/workflow/hooks', () => ({
24+
useAvailableBlocks: () => mockUseAvailableBlocks(),
25+
useDSL: () => mockUseDSL(),
26+
useIsChatMode: () => mockUseIsChatMode(),
3527
useNodesInteractions: () => mockUseNodesInteractions(),
28+
useNodesMetaData: () => mockUseNodesMetaData(),
29+
useNodesReadOnly: () => mockUseNodesReadOnly(),
3630
usePanelInteractions: () => mockUsePanelInteractions(),
37-
useWorkflowStartRun: () => mockUseWorkflowStartRun(),
3831
useWorkflowMoveMode: () => mockUseWorkflowMoveMode(),
39-
useDSL: () => mockUseDSL(),
32+
useWorkflowStartRun: () => mockUseWorkflowStartRun(),
4033
}))
4134

4235
vi.mock('@/app/components/workflow/operator/hooks', () => ({
4336
useOperator: () => mockUseOperator(),
4437
}))
4538

46-
vi.mock('@/app/components/workflow/operator/add-block', () => ({
47-
__esModule: true,
48-
default: ({ renderTrigger }: { renderTrigger: () => ReactNode }) => (
49-
<div data-testid="add-block">{renderTrigger()}</div>
50-
),
51-
}))
52-
53-
vi.mock('@/app/components/base/divider', () => ({
54-
__esModule: true,
55-
default: ({ className }: { className?: string }) => <div data-testid="divider" className={className} />,
56-
}))
57-
58-
vi.mock('@/app/components/workflow/shortcuts-name', () => ({
59-
__esModule: true,
60-
default: ({ keys }: { keys: string[] }) => <span data-testid={`shortcut-${keys.join('-')}`}>{keys.join('+')}</span>,
61-
}))
62-
6339
describe('PanelContextmenu', () => {
6440
const mockHandleNodesPaste = vi.fn()
6541
const mockHandlePaneContextmenuCancel = vi.fn()
6642
const mockHandleStartWorkflowRun = vi.fn()
43+
const mockHandleWorkflowStartRunInChatflow = vi.fn()
6744
const mockHandleAddNote = vi.fn()
6845
const mockExportCheck = vi.fn()
69-
const mockSetShowImportDSLModal = vi.fn()
70-
const mockSetCommentPlacing = vi.fn()
71-
const mockSetCommentQuickAdd = vi.fn()
72-
let panelMenu: { left: number, top: number } | undefined
73-
let clipboardElements: unknown[]
74-
let pendingComment: null | { pageX: number, pageY: number, elementX: number, elementY: number }
75-
let clickAwayHandler: (() => void) | undefined
46+
const defaultNodesMetaDataMap = {
47+
[BlockEnum.Answer]: {
48+
defaultValue: {
49+
title: 'Answer',
50+
desc: '',
51+
type: BlockEnum.Answer,
52+
},
53+
},
54+
}
7655

7756
beforeEach(() => {
7857
vi.clearAllMocks()
79-
panelMenu = undefined
80-
clipboardElements = []
81-
pendingComment = null
82-
clickAwayHandler = undefined
83-
84-
mockUseClickAway.mockImplementation((handler: () => void) => {
85-
clickAwayHandler = handler
86-
})
8758
mockUseTranslation.mockReturnValue({
8859
t: (key: string) => key,
8960
})
90-
mockUseStore.mockImplementation((selector: (state: {
91-
panelMenu?: { left: number, top: number }
92-
clipboardElements: unknown[]
93-
pendingComment: null | { pageX: number, pageY: number, elementX: number, elementY: number }
94-
setCommentPlacing: (placing: boolean) => void
95-
setCommentQuickAdd: (quickAdd: boolean) => void
96-
setShowImportDSLModal: (visible: boolean) => void
97-
}) => unknown) => selector({
98-
panelMenu,
99-
clipboardElements,
100-
pendingComment,
101-
setCommentPlacing: mockSetCommentPlacing,
102-
setCommentQuickAdd: mockSetCommentQuickAdd,
103-
setShowImportDSLModal: mockSetShowImportDSLModal,
104-
}))
10561
mockUseNodesInteractions.mockReturnValue({
10662
handleNodesPaste: mockHandleNodesPaste,
10763
})
@@ -110,6 +66,7 @@ describe('PanelContextmenu', () => {
11066
})
11167
mockUseWorkflowStartRun.mockReturnValue({
11268
handleStartWorkflowRun: mockHandleStartWorkflowRun,
69+
handleWorkflowStartRunInChatflow: mockHandleWorkflowStartRunInChatflow,
11370
})
11471
mockUseWorkflowMoveMode.mockReturnValue({
11572
isCommentModeAvailable: false,
@@ -120,50 +77,86 @@ describe('PanelContextmenu', () => {
12077
mockUseDSL.mockReturnValue({
12178
exportCheck: mockExportCheck,
12279
})
80+
mockUseNodesReadOnly.mockReturnValue({
81+
nodesReadOnly: false,
82+
})
83+
mockUseAvailableBlocks.mockReturnValue({
84+
availableNextBlocks: [BlockEnum.Answer],
85+
})
86+
mockUseNodesMetaData.mockReturnValue({
87+
nodesMap: defaultNodesMetaDataMap,
88+
})
89+
mockUseIsChatMode.mockReturnValue(false)
12390
})
12491

12592
it('should stay hidden when the panel menu is absent', () => {
126-
render(<PanelContextmenu />)
93+
renderWorkflowFlowComponent(<PanelContextmenu />)
12794

128-
expect(screen.queryByTestId('add-block')).not.toBeInTheDocument()
95+
expect(screen.queryByText('common.addBlock')).not.toBeInTheDocument()
12996
})
13097

131-
it('should keep paste disabled when the clipboard is empty', () => {
132-
panelMenu = { left: 24, top: 48 }
133-
134-
render(<PanelContextmenu />)
98+
it('should keep paste disabled when the clipboard is empty', async () => {
99+
renderWorkflowFlowComponent(<PanelContextmenu />, {
100+
initialStoreState: {
101+
panelMenu: { clientX: 24, clientY: 48 },
102+
},
103+
hooksStoreProps: {},
104+
})
135105

106+
await screen.findByText('common.pasteHere')
136107
fireEvent.click(screen.getByText('common.pasteHere'))
137108

138109
expect(mockHandleNodesPaste).not.toHaveBeenCalled()
139110
expect(mockHandlePaneContextmenuCancel).not.toHaveBeenCalled()
140111
})
141112

142-
it('should render actions, position the menu, and execute each action', () => {
143-
panelMenu = { left: 24, top: 48 }
144-
clipboardElements = [{ id: 'copied-node' }]
145-
const { container } = render(<PanelContextmenu />)
146-
147-
expect(screen.getByTestId('add-block')).toHaveTextContent('common.addBlock')
148-
expect(screen.getByRole('button', { name: /common\.run/i })).toHaveTextContent(/Alt\s*R/)
149-
expect(screen.getByRole('button', { name: /common\.pasteHere/i })).toHaveTextContent(/Ctrl\s*V/)
150-
expect(container.firstChild).toHaveStyle({
151-
left: '24px',
152-
top: '48px',
113+
it('should render actions and execute enabled actions', async () => {
114+
const { store } = renderWorkflowFlowComponent(<PanelContextmenu />, {
115+
initialStoreState: {
116+
panelMenu: { clientX: 24, clientY: 48 },
117+
clipboardElements: [createNode({ id: 'copied-node' })],
118+
},
119+
hooksStoreProps: {},
153120
})
154121

122+
expect(await screen.findByText('common.addBlock')).toBeInTheDocument()
123+
expect(screen.getByText('common.run')).toBeInTheDocument()
124+
expect(screen.getByText('common.pasteHere')).toBeInTheDocument()
125+
155126
fireEvent.click(screen.getByText('nodes.note.addNote'))
156127
fireEvent.click(screen.getByText('common.run'))
157128
fireEvent.click(screen.getByText('common.pasteHere'))
158129
fireEvent.click(screen.getByText('export'))
159130
fireEvent.click(screen.getByText('importApp'))
160-
clickAwayHandler?.()
161-
162-
expect(mockHandleAddNote).toHaveBeenCalledTimes(1)
163-
expect(mockHandleStartWorkflowRun).toHaveBeenCalledTimes(1)
164-
expect(mockHandleNodesPaste).toHaveBeenCalledTimes(1)
165-
expect(mockExportCheck).toHaveBeenCalledTimes(1)
166-
expect(mockSetShowImportDSLModal).toHaveBeenCalledWith(true)
167-
expect(mockHandlePaneContextmenuCancel).toHaveBeenCalledTimes(4)
131+
132+
await waitFor(() => {
133+
expect(mockHandleAddNote).toHaveBeenCalledTimes(1)
134+
expect(mockHandleStartWorkflowRun).toHaveBeenCalledTimes(1)
135+
expect(mockHandleNodesPaste).toHaveBeenCalledTimes(1)
136+
expect(mockExportCheck).toHaveBeenCalledTimes(1)
137+
expect(store.getState().showImportDSLModal).toBe(true)
138+
})
139+
})
140+
141+
it('should render preview action in chat mode', async () => {
142+
mockUseIsChatMode.mockReturnValue(true)
143+
144+
renderWorkflowFlowComponent(<PanelContextmenu />, {
145+
initialStoreState: {
146+
panelMenu: { clientX: 24, clientY: 48 },
147+
},
148+
hooksStoreProps: {},
149+
})
150+
151+
expect(await screen.findByText('common.debugAndPreview')).toBeInTheDocument()
152+
expect(screen.queryByText('common.run')).not.toBeInTheDocument()
153+
154+
fireEvent.click(screen.getByText('common.debugAndPreview'))
155+
156+
await waitFor(() => {
157+
expect(mockHandleWorkflowStartRunInChatflow).toHaveBeenCalledTimes(1)
158+
expect(mockHandleStartWorkflowRun).not.toHaveBeenCalled()
159+
expect(mockHandlePaneContextmenuCancel).toHaveBeenCalled()
160+
})
168161
})
169162
})

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

Lines changed: 4 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ describe('usePanelInteractions', () => {
3636
container.remove()
3737
})
3838

39-
it('handlePaneContextMenu should set panelMenu with computed coordinates when container exists', () => {
39+
it('handlePaneContextMenu should set panelMenu with viewport coordinates', () => {
4040
const { result, store } = renderWorkflowHook(() => usePanelInteractions(), {
4141
initialStoreState: {
4242
nodeMenu: { clientX: 40, clientY: 20, nodeId: 'n1' },
@@ -54,28 +54,14 @@ describe('usePanelInteractions', () => {
5454

5555
expect(preventDefault).toHaveBeenCalled()
5656
expect(store.getState().panelMenu).toEqual({
57-
top: 200,
58-
left: 250,
57+
clientX: 350,
58+
clientY: 250,
5959
})
6060
expect(store.getState().nodeMenu).toBeUndefined()
6161
expect(store.getState().selectionMenu).toBeUndefined()
6262
expect(store.getState().edgeMenu).toBeUndefined()
6363
})
6464

65-
it('handlePaneContextMenu should throw when container does not exist', () => {
66-
container.remove()
67-
68-
const { result } = renderWorkflowHook(() => usePanelInteractions())
69-
70-
expect(() => {
71-
result.current.handlePaneContextMenu({
72-
preventDefault: vi.fn(),
73-
clientX: 350,
74-
clientY: 250,
75-
} as unknown as React.MouseEvent)
76-
}).toThrow()
77-
})
78-
7965
it('handlePaneContextMenu should sync clipboard from navigator clipboard', async () => {
8066
const clipboardNode = createNode({ id: 'clipboard-node' })
8167
const clipboardEdge = createEdge({
@@ -106,7 +92,7 @@ describe('usePanelInteractions', () => {
10692

10793
it('handlePaneContextmenuCancel should clear panelMenu', () => {
10894
const { result, store } = renderWorkflowHook(() => usePanelInteractions(), {
109-
initialStoreState: { panelMenu: { top: 10, left: 20 } },
95+
initialStoreState: { panelMenu: { clientX: 20, clientY: 10 } },
11096
})
11197

11298
result.current.handlePaneContextmenuCancel()

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
@@ -174,7 +174,7 @@ describe('useSelectionInteractions', () => {
174174
it('handleSelectionContextMenu should set menu only when clicking on selection rect', () => {
175175
const { result, store } = renderSelectionInteractions({
176176
nodeMenu: { clientX: 20, clientY: 10, nodeId: 'n1' },
177-
panelMenu: { top: 30, left: 40 },
177+
panelMenu: { clientX: 40, clientY: 30 },
178178
edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e1' },
179179
})
180180

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

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,13 @@ export const usePanelInteractions = () => {
2222
workflowStore.getState().setClipboardData({ nodes, edges })
2323
})
2424

25-
const container = document.querySelector('#workflow-container')
26-
const { x, y } = container!.getBoundingClientRect()
2725
workflowStore.setState({
2826
nodeMenu: undefined,
2927
selectionMenu: undefined,
3028
edgeMenu: undefined,
3129
panelMenu: {
32-
top: e.clientY - y,
33-
left: e.clientX - x,
30+
clientX: e.clientX,
31+
clientY: e.clientY,
3432
},
3533
})
3634
}, [workflowStore, appDslVersion])

0 commit comments

Comments
 (0)