From 2a3deee38515fbf8a96886f49b6f4dba6e935225 Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Wed, 22 Apr 2026 18:50:44 +0800 Subject: [PATCH] refactor: enhance node handle components with opacity transitions and add tests for visibility behavior (#35494) Co-authored-by: CodingOnStar Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- eslint-suppressions.json | 5 - .../components/__tests__/node-handle.spec.tsx | 373 ++++++++++++++++++ .../nodes/_base/components/node-handle.tsx | 32 +- 3 files changed, 394 insertions(+), 16 deletions(-) create mode 100644 web/app/components/workflow/nodes/_base/components/__tests__/node-handle.spec.tsx diff --git a/eslint-suppressions.json b/eslint-suppressions.json index f1a5e6ce87..dcb58d4b57 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -4354,11 +4354,6 @@ "count": 1 } }, - "web/app/components/workflow/nodes/_base/components/node-handle.tsx": { - "react/set-state-in-effect": { - "count": 1 - } - }, "web/app/components/workflow/nodes/_base/components/option-card.tsx": { "no-restricted-imports": { "count": 1 diff --git a/web/app/components/workflow/nodes/_base/components/__tests__/node-handle.spec.tsx b/web/app/components/workflow/nodes/_base/components/__tests__/node-handle.spec.tsx new file mode 100644 index 0000000000..772814f0f3 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/__tests__/node-handle.spec.tsx @@ -0,0 +1,373 @@ +import type { ReactNode } from 'react' +import type { CommonNodeType } from '@/app/components/workflow/types' +import { fireEvent, render, screen } from '@testing-library/react' +import { BlockEnum, NodeRunningStatus } from '@/app/components/workflow/types' +import { NodeSourceHandle, NodeTargetHandle } from '../node-handle' + +type MockHooksState = { + availablePrevBlocks: BlockEnum[] + availableNextBlocks: BlockEnum[] + isChatMode: boolean + isReadOnly: boolean +} + +type MockStoreState = { + shouldAutoOpenStartNodeSelector: boolean + setShouldAutoOpenStartNodeSelector?: (open: boolean) => void + setHasSelectedStartNode?: (selected: boolean) => void +} + +const { + mockHandleNodeAdd, + mockSetShouldAutoOpenStartNodeSelector, + mockSetHasSelectedStartNode, + mockWorkflowStoreSetState, + mockHooksState, + mockStoreState, +} = vi.hoisted(() => { + const mockHooksState: MockHooksState = { + availablePrevBlocks: [], + availableNextBlocks: [], + isChatMode: false, + isReadOnly: false, + } + const mockStoreState: MockStoreState = { + shouldAutoOpenStartNodeSelector: false, + setShouldAutoOpenStartNodeSelector: undefined, + setHasSelectedStartNode: undefined, + } + + return { + mockHandleNodeAdd: vi.fn(), + mockSetShouldAutoOpenStartNodeSelector: vi.fn(), + mockSetHasSelectedStartNode: vi.fn(), + mockWorkflowStoreSetState: vi.fn(), + mockHooksState, + mockStoreState, + } +}) + +type HandleProps = { + id?: string + className?: string + children?: ReactNode + onClick?: () => void +} + +type BlockSelectorProps = { + open?: boolean + onOpenChange?: (open: boolean) => void + onSelect?: (type: BlockEnum, pluginDefaultValue?: { pluginId: string }) => void + triggerClassName?: (open: boolean) => string +} + +vi.mock('reactflow', () => ({ + Handle: ({ id, className, children, onClick }: HandleProps) => ( +
+ {children} +
+ ), + Position: { + Left: 'left', + Right: 'right', + }, +})) + +vi.mock('@/app/components/workflow/block-selector', () => ({ + default: ({ open = false, onOpenChange, onSelect, triggerClassName }: BlockSelectorProps) => ( +
+ + +
+ ), +})) + +vi.mock('@/app/components/workflow/hooks', () => ({ + useAvailableBlocks: () => ({ + availablePrevBlocks: mockHooksState.availablePrevBlocks, + availableNextBlocks: mockHooksState.availableNextBlocks, + }), + useIsChatMode: () => mockHooksState.isChatMode, + useNodesInteractions: () => ({ + handleNodeAdd: mockHandleNodeAdd, + }), + useNodesReadOnly: () => ({ + getNodesReadOnly: () => mockHooksState.isReadOnly, + }), +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useStore: (selector: (state: MockStoreState) => T) => selector(mockStoreState), + useWorkflowStore: () => ({ + setState: mockWorkflowStoreSetState, + }), +})) + +const createNodeData = (overrides: Partial = {}): CommonNodeType => ({ + type: BlockEnum.Code, + title: 'Node', + desc: '', + selected: false, + ...overrides, +}) + +const getAddNodeButton = () => screen.getByRole('button', { name: 'add-node' }) +const queryAddNodeButton = () => screen.queryByRole('button', { name: 'add-node' }) +const getSelectNodeButton = () => screen.getByRole('button', { name: 'select-node' }) + +const renderTargetHandle = (dataOverrides: Partial = {}) => { + return render( + , + ) +} + +const renderSourceHandle = ( + dataOverrides: Partial = {}, + propsOverrides: Partial> = {}, +) => { + return render( + , + ) +} + +describe('node-handle', () => { + beforeEach(() => { + vi.clearAllMocks() + + mockHooksState.availablePrevBlocks = [BlockEnum.Code] + mockHooksState.availableNextBlocks = [BlockEnum.Code] + mockHooksState.isChatMode = false + mockHooksState.isReadOnly = false + + mockStoreState.shouldAutoOpenStartNodeSelector = false + mockStoreState.setShouldAutoOpenStartNodeSelector = mockSetShouldAutoOpenStartNodeSelector + mockStoreState.setHasSelectedStartNode = mockSetHasSelectedStartNode + }) + + // Target-side tests cover selector visibility, connection locking, and status rendering. + describe('NodeTargetHandle', () => { + it('should toggle the target add trigger and select the next node', () => { + renderTargetHandle() + + const handle = screen.getByTestId('handle-target-handle') + const addNodeButton = getAddNodeButton() + + expect(addNodeButton).toHaveClass('custom-selector') + expect(addNodeButton).toHaveClass('opacity-0') + expect(addNodeButton).toHaveClass('pointer-events-none') + + fireEvent.click(addNodeButton) + + expect(addNodeButton).toHaveClass('opacity-100') + expect(addNodeButton).toHaveClass('pointer-events-auto') + + fireEvent.click(handle) + + expect(addNodeButton).toHaveClass('opacity-0') + + fireEvent.click(getSelectNodeButton()) + + expect(mockHandleNodeAdd).toHaveBeenCalledWith( + { + nodeType: BlockEnum.Answer, + pluginDefaultValue: { pluginId: 'plugin-1' }, + }, + { + nextNodeId: 'target-node', + nextNodeTargetHandle: 'target-handle', + }, + ) + }) + + it('should not render the target add trigger when the handle is already connected', () => { + renderTargetHandle({ + _connectedTargetHandleIds: ['target-handle'], + }) + + fireEvent.click(screen.getByTestId('handle-target-handle')) + + expect(queryAddNodeButton()).not.toBeInTheDocument() + }) + + it('should hide the target handle for workflow entry nodes', () => { + renderTargetHandle({ type: BlockEnum.TriggerPlugin }) + + expect(screen.getByTestId('handle-target-handle')).toHaveClass('opacity-0') + }) + + it('should keep the target add trigger visible when the node is selected', () => { + renderTargetHandle({ + selected: true, + }) + + expect(getAddNodeButton()).toHaveClass('opacity-100') + expect(getAddNodeButton()).toHaveClass('pointer-events-auto') + }) + + it.each([ + ['succeeded', NodeRunningStatus.Succeeded, 'after:bg-workflow-link-line-success-handle'], + ['failed', NodeRunningStatus.Failed, 'after:bg-workflow-link-line-error-handle'], + ['exception', NodeRunningStatus.Exception, 'after:bg-workflow-link-line-failure-handle'], + ])('should render the target %s status class', (_label, runningStatus, expectedClass) => { + renderTargetHandle({ + _runningStatus: runningStatus, + }) + + expect(screen.getByTestId('handle-target-handle')).toHaveClass(expectedClass) + expect(screen.getByTestId('handle-target-handle')).toHaveClass('custom-target-handle') + }) + }) + + // Source-side tests cover selector opening paths, previous-node selection, and status styling. + describe('NodeSourceHandle', () => { + it('should toggle the source add trigger and select the previous node', () => { + renderSourceHandle() + + const handle = screen.getByTestId('handle-source-handle') + const addNodeButton = getAddNodeButton() + + expect(addNodeButton).toHaveClass('opacity-0') + + fireEvent.click(addNodeButton) + + expect(addNodeButton).toHaveClass('opacity-100') + expect(addNodeButton).toHaveClass('pointer-events-auto') + + fireEvent.click(getSelectNodeButton()) + + expect(mockHandleNodeAdd).toHaveBeenCalledWith( + { + nodeType: BlockEnum.Answer, + pluginDefaultValue: { pluginId: 'plugin-1' }, + }, + { + prevNodeId: 'source-node', + prevNodeSourceHandle: 'source-handle', + }, + ) + + fireEvent.click(handle) + + expect(addNodeButton).toHaveClass('opacity-0') + }) + + it('should keep the source add trigger visible when the node is selected', () => { + renderSourceHandle({ + selected: true, + }) + + const addNodeButton = getAddNodeButton() + + expect(addNodeButton).toHaveClass('custom-selector') + expect(addNodeButton).toHaveClass('opacity-100') + expect(addNodeButton).toHaveClass('pointer-events-auto') + }) + + it.each([ + ['succeeded', NodeRunningStatus.Succeeded, undefined, 'after:bg-workflow-link-line-success-handle'], + ['failed', NodeRunningStatus.Failed, undefined, 'after:bg-workflow-link-line-error-handle'], + ['exception', NodeRunningStatus.Exception, true, 'after:bg-workflow-link-line-failure-handle'], + ])('should render the source %s status class', (_label, runningStatus, showExceptionStatus, expectedClass) => { + renderSourceHandle( + { + _runningStatus: runningStatus, + }, + { + showExceptionStatus, + }, + ) + + expect(screen.getByTestId('handle-source-handle')).toHaveClass(expectedClass) + expect(screen.getByTestId('handle-source-handle')).toHaveClass('custom-source-handle') + }) + }) + + // Auto-open tests cover workflow start-trigger variants, chat-mode bypass, and store fallback paths. + describe('NodeSourceHandle auto-open', () => { + it.each([ + BlockEnum.Start, + BlockEnum.TriggerSchedule, + BlockEnum.TriggerWebhook, + BlockEnum.TriggerPlugin, + ])('should auto-open immediately for %s nodes', (type) => { + mockStoreState.shouldAutoOpenStartNodeSelector = true + + renderSourceHandle({ type }) + + const addNodeButton = getAddNodeButton() + + expect(addNodeButton).toHaveClass('opacity-100') + expect(addNodeButton).toHaveClass('pointer-events-auto') + expect(mockSetShouldAutoOpenStartNodeSelector).toHaveBeenCalledWith(false) + expect(mockSetHasSelectedStartNode).toHaveBeenCalledWith(false) + }) + + it('should skip source auto-open in chat mode and only reset the start selector flag', () => { + mockHooksState.isChatMode = true + mockStoreState.shouldAutoOpenStartNodeSelector = true + + renderSourceHandle({ type: BlockEnum.Start }) + + expect(getAddNodeButton()).toHaveClass('opacity-0') + expect(mockSetShouldAutoOpenStartNodeSelector).toHaveBeenCalledWith(false) + expect(mockSetHasSelectedStartNode).not.toHaveBeenCalled() + }) + + it('should use the workflow store fallback when the selector setters are unavailable', () => { + mockStoreState.shouldAutoOpenStartNodeSelector = true + mockStoreState.setShouldAutoOpenStartNodeSelector = undefined + mockStoreState.setHasSelectedStartNode = undefined + + renderSourceHandle({ type: BlockEnum.Start }) + + expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ shouldAutoOpenStartNodeSelector: false }) + expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ hasSelectedStartNode: false }) + }) + + it('should not auto-open when the node type is not a workflow entry node', () => { + mockStoreState.shouldAutoOpenStartNodeSelector = true + + renderSourceHandle({ type: BlockEnum.Code }) + + expect(getAddNodeButton()).toHaveClass('opacity-0') + expect(mockSetShouldAutoOpenStartNodeSelector).not.toHaveBeenCalled() + expect(mockSetHasSelectedStartNode).not.toHaveBeenCalled() + expect(mockWorkflowStoreSetState).not.toHaveBeenCalled() + }) + }) +}) diff --git a/web/app/components/workflow/nodes/_base/components/node-handle.tsx b/web/app/components/workflow/nodes/_base/components/node-handle.tsx index 0bcdfc0f46..e84b09ac95 100644 --- a/web/app/components/workflow/nodes/_base/components/node-handle.tsx +++ b/web/app/components/workflow/nodes/_base/components/node-handle.tsx @@ -36,6 +36,16 @@ type NodeHandleProps = { showExceptionStatus?: boolean } & Pick +const canAutoOpenStartNodeSelector = (nodeType: BlockEnum, isChatMode: boolean) => { + if (isChatMode) + return false + + return nodeType === BlockEnum.Start + || nodeType === BlockEnum.TriggerSchedule + || nodeType === BlockEnum.TriggerWebhook + || nodeType === BlockEnum.TriggerPlugin +} + export const NodeTargetHandle = memo(({ id, data, @@ -103,11 +113,11 @@ export const NodeTargetHandle = memo(({ asChild placement="left" triggerClassName={open => ` - hidden absolute left-0 top-0 pointer-events-none + absolute left-0 top-0 opacity-0 pointer-events-none transition-opacity duration-150 ${nodeSelectorClassName} - group-hover:flex! - ${data.selected && 'flex!'} - ${open && 'flex!'} + group-hover:opacity-100 group-hover:pointer-events-auto + ${data.selected && 'opacity-100 pointer-events-auto'} + ${open && 'opacity-100 pointer-events-auto'} `} availableBlocksTypes={availablePrevBlocks} /> @@ -132,12 +142,13 @@ export const NodeSourceHandle = memo(({ const setShouldAutoOpenStartNodeSelector = useStore(s => s.setShouldAutoOpenStartNodeSelector) const setHasSelectedStartNode = useStore(s => s.setHasSelectedStartNode) const workflowStoreApi = useWorkflowStore() - const [open, setOpen] = useState(false) const { handleNodeAdd } = useNodesInteractions() const { getNodesReadOnly } = useNodesReadOnly() const { availableNextBlocks } = useAvailableBlocks(data.type, data.isInIteration || data.isInLoop) const isConnectable = !!availableNextBlocks.length const isChatMode = useIsChatMode() + const shouldAutoOpen = shouldAutoOpenStartNodeSelector && canAutoOpenStartNodeSelector(data.type, isChatMode) + const [open, setOpen] = useState(() => shouldAutoOpen) const connected = data._connectedSourceHandleIds?.includes(handleId) const handleOpenChange = useCallback((v: boolean) => { @@ -169,8 +180,7 @@ export const NodeSourceHandle = memo(({ return } - if (data.type === BlockEnum.Start || data.type === BlockEnum.TriggerSchedule || data.type === BlockEnum.TriggerWebhook || data.type === BlockEnum.TriggerPlugin) { - setOpen(true) + if (canAutoOpenStartNodeSelector(data.type, false)) { if (setShouldAutoOpenStartNodeSelector) setShouldAutoOpenStartNodeSelector(false) else @@ -221,11 +231,11 @@ export const NodeSourceHandle = memo(({ onSelect={handleSelect} asChild triggerClassName={open => ` - hidden absolute top-0 left-0 pointer-events-none + absolute top-0 left-0 opacity-0 pointer-events-none transition-opacity duration-150 ${nodeSelectorClassName} - group-hover:flex! - ${data.selected && 'flex!'} - ${open && 'flex!'} + group-hover:opacity-100 group-hover:pointer-events-auto + ${data.selected && 'opacity-100 pointer-events-auto'} + ${open && 'opacity-100 pointer-events-auto'} `} availableBlocksTypes={availableNextBlocks} />