From 1943785c1c62701f38b26f2a171af2b539fe66af Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Tue, 24 Mar 2026 17:53:48 +0800 Subject: [PATCH] feat(workflow): add selection context menu helpers and integrate with context menu component - Introduced helper functions for managing alignment and distribution of nodes within the workflow. - Created a new file for selection context menu helpers, encapsulating logic for menu positioning and node alignment. - Updated the SelectionContextmenu component to utilize the new helpers, improving code organization and readability. - Added unit tests for the new helper functions to ensure functionality and correctness. --- .../selection-contextmenu.helpers.spec.ts | 136 +++++ .../__tests__/selection-contextmenu.spec.tsx | 200 ++++++++ .../form-input-item.branches.spec.tsx | 410 +++++++++++++++ .../__tests__/form-input-item.helpers.spec.ts | 166 ++++++ .../form-input-item.sections.spec.tsx | 60 +++ .../__tests__/form-input-item.spec.tsx | 148 ++++++ .../components/form-input-item.helpers.ts | 259 ++++++++++ .../components/form-input-item.sections.tsx | 129 +++++ .../_base/components/form-input-item.tsx | 383 ++++---------- .../var-reference-picker.branches.spec.tsx | 226 +++++++++ .../var-reference-picker.helpers.spec.ts | 236 +++++++++ .../__tests__/var-reference-picker.spec.tsx | 140 +++++ .../var-reference-picker.trigger.spec.tsx | 176 +++++++ .../variable/var-reference-picker.helpers.ts | 221 ++++++++ .../variable/var-reference-picker.trigger.tsx | 315 ++++++++++++ .../variable/var-reference-picker.tsx | 477 +++++------------- .../workflow/selection-contextmenu.helpers.ts | 242 +++++++++ .../workflow/selection-contextmenu.tsx | 382 ++------------ 18 files changed, 3341 insertions(+), 965 deletions(-) create mode 100644 web/app/components/workflow/__tests__/selection-contextmenu.helpers.spec.ts create mode 100644 web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx create mode 100644 web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.branches.spec.tsx create mode 100644 web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.helpers.spec.ts create mode 100644 web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.sections.spec.tsx create mode 100644 web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.spec.tsx create mode 100644 web/app/components/workflow/nodes/_base/components/form-input-item.helpers.ts create mode 100644 web/app/components/workflow/nodes/_base/components/form-input-item.sections.tsx create mode 100644 web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.branches.spec.tsx create mode 100644 web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.helpers.spec.ts create mode 100644 web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.spec.tsx create mode 100644 web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.trigger.spec.tsx create mode 100644 web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.helpers.ts create mode 100644 web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.trigger.tsx create mode 100644 web/app/components/workflow/selection-contextmenu.helpers.ts diff --git a/web/app/components/workflow/__tests__/selection-contextmenu.helpers.spec.ts b/web/app/components/workflow/__tests__/selection-contextmenu.helpers.spec.ts new file mode 100644 index 0000000000..dd2cbf124a --- /dev/null +++ b/web/app/components/workflow/__tests__/selection-contextmenu.helpers.spec.ts @@ -0,0 +1,136 @@ +import { + alignNodePosition, + AlignType, + distributeNodes, + getAlignableNodes, + getAlignBounds, + getMenuPosition, +} from '../selection-contextmenu.helpers' +import { createNode } from './fixtures' + +describe('selection-contextmenu helpers', () => { + it('should keep the menu inside the workflow container bounds', () => { + expect(getMenuPosition(undefined, { width: 800, height: 600 })).toEqual({ + left: 0, + top: 0, + }) + expect(getMenuPosition({ left: 780, top: 590 }, { width: 800, height: 600 })).toEqual({ + left: 540, + top: 210, + }) + expect(getMenuPosition({ left: -10, top: -20 }, { width: 800, height: 600 })).toEqual({ + left: 0, + top: 0, + }) + }) + + it('should exclude child nodes when their container node is selected', () => { + const container = createNode({ + id: 'container', + selected: true, + data: { + _children: [{ nodeId: 'child', nodeType: 'code' as never }], + }, + }) + const child = createNode({ id: 'child', selected: true }) + const other = createNode({ id: 'other', selected: true }) + + expect(getAlignableNodes([container, child, other], [container, child, other]).map(node => node.id)).toEqual([ + 'container', + 'other', + ]) + }) + + it('should calculate bounds and align nodes by type', () => { + const leftNode = createNode({ + id: 'left', + position: { x: 10, y: 30 }, + positionAbsolute: { x: 10, y: 30 }, + width: 40, + height: 20, + }) + const rightNode = createNode({ + id: 'right', + position: { x: 100, y: 70 }, + positionAbsolute: { x: 100, y: 70 }, + width: 60, + height: 40, + }) + const bounds = getAlignBounds([leftNode, rightNode]) + + expect(bounds).toEqual({ + minX: 10, + maxX: 160, + minY: 30, + maxY: 110, + }) + + alignNodePosition(rightNode, rightNode, AlignType.Left, bounds!) + expect(rightNode.position.x).toBe(10) + + alignNodePosition(rightNode, rightNode, AlignType.Center, bounds!) + expect(rightNode.position.x).toBe(55) + + alignNodePosition(rightNode, rightNode, AlignType.Right, bounds!) + expect(rightNode.position.x).toBe(100) + + alignNodePosition(leftNode, leftNode, AlignType.Top, bounds!) + expect(leftNode.position.y).toBe(30) + + alignNodePosition(leftNode, leftNode, AlignType.Middle, bounds!) + expect(leftNode.position.y).toBe(60) + expect(leftNode.positionAbsolute?.y).toBe(60) + + alignNodePosition(leftNode, leftNode, AlignType.Bottom, bounds!) + expect(leftNode.position.y).toBe(90) + expect(leftNode.positionAbsolute?.y).toBe(90) + }) + + it('should distribute nodes horizontally and vertically', () => { + const first = createNode({ id: 'first', position: { x: 0, y: 0 }, width: 20, height: 20 }) + const middle = createNode({ id: 'middle', position: { x: 100, y: 80 }, width: 20, height: 20 }) + const last = createNode({ id: 'last', position: { x: 300, y: 200 }, width: 20, height: 20 }) + + const horizontal = distributeNodes([first, middle, last], [first, middle, last], AlignType.DistributeHorizontal) + expect(horizontal?.find(node => node.id === 'middle')?.position.x).toBe(150) + + const vertical = distributeNodes([first, middle, last], [first, middle, last], AlignType.DistributeVertical) + expect(vertical?.find(node => node.id === 'middle')?.position.y).toBe(100) + }) + + it('should return null when nodes cannot be evenly distributed', () => { + const first = createNode({ id: 'first', position: { x: 0, y: 0 }, width: 100, height: 100 }) + const middle = createNode({ id: 'middle', position: { x: 50, y: 50 }, width: 100, height: 100 }) + const last = createNode({ id: 'last', position: { x: 120, y: 120 }, width: 100, height: 100 }) + + expect(distributeNodes([first, middle, last], [first, middle, last], AlignType.DistributeHorizontal)).toBeNull() + expect(distributeNodes([first, middle], [first, middle], AlignType.DistributeVertical)).toBeNull() + expect(getAlignBounds([first])).toBeNull() + }) + + it('should skip missing draft nodes and keep absolute positions in vertical distribution', () => { + const first = createNode({ id: 'first', position: { x: 0, y: 0 }, width: 20, height: 20 }) + const middle = createNode({ + id: 'middle', + position: { x: 0, y: 60 }, + positionAbsolute: { x: 0, y: 60 }, + width: 20, + height: 20, + }) + const last = createNode({ id: 'last', position: { x: 0, y: 180 }, width: 20, height: 20 }) + + const distributed = distributeNodes( + [first, middle, last], + [first, last], + AlignType.DistributeVertical, + ) + expect(distributed).toEqual([first, last]) + + const distributedWithMiddle = distributeNodes( + [first, middle, last], + [first, middle, last], + AlignType.DistributeVertical, + ) + expect(distributedWithMiddle?.find(node => node.id === 'middle')?.positionAbsolute?.y).toBe(90) + }) +}) diff --git a/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx b/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx new file mode 100644 index 0000000000..a36cfa3255 --- /dev/null +++ b/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx @@ -0,0 +1,200 @@ +import type { Edge, Node } from '../types' +import { act, fireEvent, screen, waitFor } from '@testing-library/react' +import { useEffect } from 'react' +import { useNodes } from 'reactflow' +import SelectionContextmenu from '../selection-contextmenu' +import { useWorkflowHistoryStore } from '../workflow-history-store' +import { createEdge, createNode } from './fixtures' +import { renderWorkflowFlowComponent } from './workflow-test-env' + +let latestNodes: Node[] = [] +let latestHistoryEvent: string | undefined + +const RuntimeProbe = () => { + latestNodes = useNodes() as Node[] + const { store } = useWorkflowHistoryStore() + + useEffect(() => { + latestHistoryEvent = store.getState().workflowHistoryEvent + return store.subscribe((state) => { + latestHistoryEvent = state.workflowHistoryEvent + }) + }, [store]) + + return null +} + +const hooksStoreProps = { + doSyncWorkflowDraft: vi.fn().mockResolvedValue(undefined), +} + +const renderSelectionMenu = (options?: { + nodes?: Node[] + edges?: Edge[] + initialStoreState?: Record +}) => { + latestNodes = [] + latestHistoryEvent = undefined + + const nodes = options?.nodes ?? [] + const edges = options?.edges ?? [] + + return renderWorkflowFlowComponent( +
+ + +
, + { + nodes, + edges, + hooksStoreProps, + historyStore: { nodes, edges }, + initialStoreState: options?.initialStoreState, + reactFlowProps: { fitView: false }, + }, + ) +} + +describe('SelectionContextmenu', () => { + beforeEach(() => { + vi.clearAllMocks() + latestNodes = [] + latestHistoryEvent = undefined + }) + + it('should not render when selectionMenu is absent', () => { + renderSelectionMenu() + + expect(screen.queryByText('operator.vertical')).not.toBeInTheDocument() + }) + + it('should keep the menu inside the workflow container bounds', () => { + const nodes = [ + createNode({ id: 'n1', selected: true, width: 80, height: 40 }), + createNode({ id: 'n2', selected: true, position: { x: 140, y: 0 }, width: 80, height: 40 }), + ] + const { store } = renderSelectionMenu({ nodes }) + + act(() => { + store.setState({ selectionMenu: { left: 780, top: 590 } }) + }) + + const menu = screen.getByTestId('selection-contextmenu') + expect(menu).toHaveStyle({ left: '540px', top: '210px' }) + }) + + it('should close itself when only one node is selected', async () => { + const nodes = [ + createNode({ id: 'n1', selected: true, width: 80, height: 40 }), + ] + + const { store } = renderSelectionMenu({ nodes }) + + act(() => { + store.setState({ selectionMenu: { left: 120, top: 120 } }) + }) + + await waitFor(() => { + expect(store.getState().selectionMenu).toBeUndefined() + }) + }) + + it('should align selected nodes to the left and save history', async () => { + vi.useFakeTimers() + const nodes = [ + createNode({ id: 'n1', selected: true, position: { x: 20, y: 40 }, width: 40, height: 20 }), + createNode({ id: 'n2', selected: true, position: { x: 140, y: 90 }, width: 60, height: 30 }), + ] + + const { store } = renderSelectionMenu({ + nodes, + edges: [createEdge({ source: 'n1', target: 'n2' })], + initialStoreState: { + helpLineHorizontal: { y: 10 } as never, + helpLineVertical: { x: 10 } as never, + }, + }) + + act(() => { + store.setState({ selectionMenu: { left: 100, top: 100 } }) + }) + + fireEvent.click(screen.getByTestId('selection-contextmenu-item-left')) + + expect(latestNodes.find(node => node.id === 'n1')?.position.x).toBe(20) + expect(latestNodes.find(node => node.id === 'n2')?.position.x).toBe(20) + expect(store.getState().selectionMenu).toBeUndefined() + expect(store.getState().helpLineHorizontal).toBeUndefined() + expect(store.getState().helpLineVertical).toBeUndefined() + + act(() => { + store.getState().flushPendingSync() + vi.advanceTimersByTime(600) + }) + + expect(hooksStoreProps.doSyncWorkflowDraft).toHaveBeenCalled() + expect(latestHistoryEvent).toBe('NodeDragStop') + vi.useRealTimers() + }) + + it('should distribute selected nodes horizontally', async () => { + const nodes = [ + createNode({ id: 'n1', selected: true, position: { x: 0, y: 10 }, width: 20, height: 20 }), + createNode({ id: 'n2', selected: true, position: { x: 100, y: 20 }, width: 20, height: 20 }), + createNode({ id: 'n3', selected: true, position: { x: 300, y: 30 }, width: 20, height: 20 }), + ] + + const { store } = renderSelectionMenu({ + nodes, + }) + + act(() => { + store.setState({ selectionMenu: { left: 160, top: 120 } }) + }) + + fireEvent.click(screen.getByTestId('selection-contextmenu-item-distributeHorizontal')) + + expect(latestNodes.find(node => node.id === 'n2')?.position.x).toBe(150) + }) + + it('should ignore child nodes when the selected container is aligned', async () => { + const nodes = [ + createNode({ + id: 'container', + selected: true, + position: { x: 200, y: 0 }, + width: 100, + height: 80, + data: { _children: [{ nodeId: 'child', nodeType: 'code' as never }] }, + }), + createNode({ + id: 'child', + selected: true, + position: { x: 210, y: 10 }, + width: 30, + height: 20, + }), + createNode({ + id: 'other', + selected: true, + position: { x: 40, y: 60 }, + width: 40, + height: 20, + }), + ] + + const { store } = renderSelectionMenu({ + nodes, + }) + + act(() => { + store.setState({ selectionMenu: { left: 180, top: 120 } }) + }) + + fireEvent.click(screen.getByTestId('selection-contextmenu-item-left')) + + expect(latestNodes.find(node => node.id === 'container')?.position.x).toBe(40) + expect(latestNodes.find(node => node.id === 'other')?.position.x).toBe(40) + expect(latestNodes.find(node => node.id === 'child')?.position.x).toBe(210) + }) +}) diff --git a/web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.branches.spec.tsx b/web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.branches.spec.tsx new file mode 100644 index 0000000000..49de788314 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.branches.spec.tsx @@ -0,0 +1,410 @@ +import type { ComponentProps } from 'react' +import type { CredentialFormSchema, FormOption } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { PluginCategoryEnum } from '@/app/components/plugins/types' +import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env' +import { VarKindType } from '../../types' +import FormInputItem from '../form-input-item' + +const { + mockFetchDynamicOptions, + mockTriggerDynamicOptionsState, +} = vi.hoisted(() => ({ + mockFetchDynamicOptions: vi.fn(), + mockTriggerDynamicOptionsState: { + data: undefined as { options: FormOption[] } | undefined, + isLoading: false, + }, +})) + +vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ + useLanguage: () => 'en_US', +})) + +vi.mock('@/service/use-plugins', () => ({ + useFetchDynamicOptions: () => ({ + mutateAsync: mockFetchDynamicOptions, + }), +})) + +vi.mock('@/service/use-triggers', () => ({ + useTriggerPluginDynamicOptions: () => mockTriggerDynamicOptionsState, +})) + +vi.mock('@/app/components/plugins/plugin-detail-panel/app-selector', () => ({ + default: ({ onSelect }: { onSelect: (value: string) => void }) => ( + + ), +})) + +vi.mock('@/app/components/plugins/plugin-detail-panel/model-selector', () => ({ + default: ({ setModel }: { setModel: (value: string) => void }) => ( + + ), +})) + +vi.mock('@/app/components/workflow/nodes/tool/components/mixed-variable-text-input', () => ({ + default: ({ onChange, value }: { onChange: (value: string) => void, value: string }) => ( + onChange(e.target.value)} /> + ), +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({ + default: ({ onChange, value }: { onChange: (value: string) => void, value: string }) => ( +