mirror of
https://github.com/langgenius/dify.git
synced 2026-05-06 18:27:19 +08:00
refactor: enhance node handle components with opacity transitions and add tests for visibility behavior (#35494)
Co-authored-by: CodingOnStar <hanxujiang@dify.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
4b6803ba06
commit
2a3deee385
@ -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
|
||||
|
||||
@ -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) => (
|
||||
<div
|
||||
data-testid={`handle-${id ?? 'unknown'}`}
|
||||
data-handleid={id}
|
||||
className={className}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Position: {
|
||||
Left: 'left',
|
||||
Right: 'right',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/block-selector', () => ({
|
||||
default: ({ open = false, onOpenChange, onSelect, triggerClassName }: BlockSelectorProps) => (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className={triggerClassName?.(open)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onOpenChange?.(!open)
|
||||
}}
|
||||
>
|
||||
add-node
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onSelect?.(BlockEnum.Answer, { pluginId: 'plugin-1' })
|
||||
}}
|
||||
>
|
||||
select-node
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
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: <T,>(selector: (state: MockStoreState) => T) => selector(mockStoreState),
|
||||
useWorkflowStore: () => ({
|
||||
setState: mockWorkflowStoreSetState,
|
||||
}),
|
||||
}))
|
||||
|
||||
const createNodeData = (overrides: Partial<CommonNodeType> = {}): 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<CommonNodeType> = {}) => {
|
||||
return render(
|
||||
<NodeTargetHandle
|
||||
id="target-node"
|
||||
data={createNodeData(dataOverrides)}
|
||||
handleId="target-handle"
|
||||
nodeSelectorClassName="custom-selector"
|
||||
handleClassName="custom-target-handle"
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
const renderSourceHandle = (
|
||||
dataOverrides: Partial<CommonNodeType> = {},
|
||||
propsOverrides: Partial<React.ComponentProps<typeof NodeSourceHandle>> = {},
|
||||
) => {
|
||||
return render(
|
||||
<NodeSourceHandle
|
||||
id="source-node"
|
||||
data={createNodeData(dataOverrides)}
|
||||
handleId="source-handle"
|
||||
nodeSelectorClassName="custom-selector"
|
||||
handleClassName="custom-source-handle"
|
||||
{...propsOverrides}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -36,6 +36,16 @@ type NodeHandleProps = {
|
||||
showExceptionStatus?: boolean
|
||||
} & Pick<Node, 'id' | 'data'>
|
||||
|
||||
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}
|
||||
/>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user