Merge remote-tracking branch 'origin/main' into feat/support-agent-sandbox

This commit is contained in:
yyh
2026-03-25 17:49:36 +08:00
93 changed files with 13582 additions and 2337 deletions

View File

@ -774,7 +774,7 @@ export default translation`
const endTime = Date.now()
expect(keys.length).toBe(1000)
expect(endTime - startTime).toBeLessThan(1000) // Should complete in under 1 second
expect(endTime - startTime).toBeLessThan(10000)
})
it('should handle multiple translation files concurrently', async () => {
@ -796,7 +796,7 @@ export default translation`
const endTime = Date.now()
expect(keys.length).toBe(20) // 10 files * 2 keys each
expect(endTime - startTime).toBeLessThan(500)
expect(endTime - startTime).toBeLessThan(10000)
})
})

View File

@ -0,0 +1,45 @@
import { render } from '@testing-library/react'
import PartnerStackCookieRecorder from '../cookie-recorder'
let isCloudEdition = true
const saveOrUpdate = vi.fn()
vi.mock('@/config', () => ({
get IS_CLOUD_EDITION() {
return isCloudEdition
},
}))
vi.mock('../use-ps-info', () => ({
default: () => ({
saveOrUpdate,
}),
}))
describe('PartnerStackCookieRecorder', () => {
beforeEach(() => {
vi.clearAllMocks()
isCloudEdition = true
})
it('should call saveOrUpdate once on mount when running in cloud edition', () => {
render(<PartnerStackCookieRecorder />)
expect(saveOrUpdate).toHaveBeenCalledTimes(1)
})
it('should not call saveOrUpdate when not running in cloud edition', () => {
isCloudEdition = false
render(<PartnerStackCookieRecorder />)
expect(saveOrUpdate).not.toHaveBeenCalled()
})
it('should render null', () => {
const { container } = render(<PartnerStackCookieRecorder />)
expect(container.innerHTML).toBe('')
})
})

View File

@ -0,0 +1,19 @@
'use client'
import { useEffect } from 'react'
import { IS_CLOUD_EDITION } from '@/config'
import usePSInfo from './use-ps-info'
const PartnerStackCookieRecorder = () => {
const { saveOrUpdate } = usePSInfo()
useEffect(() => {
if (!IS_CLOUD_EDITION)
return
saveOrUpdate()
}, [])
return null
}
export default PartnerStackCookieRecorder

View File

@ -24,7 +24,7 @@ const usePSInfo = () => {
}] = useBoolean(false)
const { mutateAsync } = useBindPartnerStackInfo()
// Save to top domain. cloud.dify.ai => .dify.ai
const domain = globalThis.location.hostname.replace('cloud', '')
const domain = globalThis.location?.hostname.replace('cloud', '')
const saveOrUpdate = useCallback(() => {
if (!psPartnerKey || !psClickId)
@ -39,7 +39,7 @@ const usePSInfo = () => {
path: '/',
domain,
})
}, [psPartnerKey, psClickId, isPSChanged])
}, [psPartnerKey, psClickId, isPSChanged, domain])
const bind = useCallback(async () => {
if (psPartnerKey && psClickId && !hasBind) {
@ -59,7 +59,7 @@ const usePSInfo = () => {
Cookies.remove(PARTNER_STACK_CONFIG.cookieName, { path: '/', domain })
setBind()
}
}, [psPartnerKey, psClickId, mutateAsync, hasBind, setBind])
}, [psPartnerKey, psClickId, hasBind, domain, setBind, mutateAsync])
return {
psPartnerKey,
psClickId,

View File

@ -0,0 +1,260 @@
import { render, screen } from '@testing-library/react'
import CandidateNodeMain from '../candidate-node-main'
import { CUSTOM_NODE } from '../constants'
import { CUSTOM_NOTE_NODE } from '../note-node/constants'
import { BlockEnum } from '../types'
import { createNode } from './fixtures'
const mockUseEventListener = vi.hoisted(() => vi.fn())
const mockUseStoreApi = vi.hoisted(() => vi.fn())
const mockUseReactFlow = vi.hoisted(() => vi.fn())
const mockUseViewport = vi.hoisted(() => vi.fn())
const mockUseStore = vi.hoisted(() => vi.fn())
const mockUseWorkflowStore = vi.hoisted(() => vi.fn())
const mockUseHooks = vi.hoisted(() => vi.fn())
const mockCustomNode = vi.hoisted(() => vi.fn())
const mockCustomNoteNode = vi.hoisted(() => vi.fn())
const mockGetIterationStartNode = vi.hoisted(() => vi.fn())
const mockGetLoopStartNode = vi.hoisted(() => vi.fn())
vi.mock('ahooks', () => ({
useEventListener: (...args: unknown[]) => mockUseEventListener(...args),
}))
vi.mock('reactflow', () => ({
useStoreApi: () => mockUseStoreApi(),
useReactFlow: () => mockUseReactFlow(),
useViewport: () => mockUseViewport(),
Position: {
Left: 'left',
Right: 'right',
},
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: { mousePosition: {
pageX: number
pageY: number
elementX: number
elementY: number
} }) => unknown) => mockUseStore(selector),
useWorkflowStore: () => mockUseWorkflowStore(),
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useNodesInteractions: () => mockUseHooks().useNodesInteractions(),
useNodesSyncDraft: () => mockUseHooks().useNodesSyncDraft(),
useWorkflowHistory: () => mockUseHooks().useWorkflowHistory(),
useAutoGenerateWebhookUrl: () => mockUseHooks().useAutoGenerateWebhookUrl(),
WorkflowHistoryEvent: {
NodeAdd: 'NodeAdd',
NoteAdd: 'NoteAdd',
},
}))
vi.mock('@/app/components/workflow/nodes', () => ({
__esModule: true,
default: (props: { id: string }) => {
mockCustomNode(props)
return <div data-testid="candidate-custom-node">{props.id}</div>
},
}))
vi.mock('@/app/components/workflow/note-node', () => ({
__esModule: true,
default: (props: { id: string }) => {
mockCustomNoteNode(props)
return <div data-testid="candidate-note-node">{props.id}</div>
},
}))
vi.mock('@/app/components/workflow/utils', () => ({
getIterationStartNode: (...args: unknown[]) => mockGetIterationStartNode(...args),
getLoopStartNode: (...args: unknown[]) => mockGetLoopStartNode(...args),
}))
describe('CandidateNodeMain', () => {
const mockSetNodes = vi.fn()
const mockHandleNodeSelect = vi.fn()
const mockSaveStateToHistory = vi.fn()
const mockHandleSyncWorkflowDraft = vi.fn()
const mockAutoGenerateWebhookUrl = vi.fn()
const mockWorkflowStoreSetState = vi.fn()
const createNodesInteractions = () => ({
handleNodeSelect: mockHandleNodeSelect,
})
const createWorkflowHistory = () => ({
saveStateToHistory: mockSaveStateToHistory,
})
const createNodesSyncDraft = () => ({
handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft,
})
const createAutoGenerateWebhookUrl = () => mockAutoGenerateWebhookUrl
const eventHandlers: Partial<Record<'click' | 'contextmenu', (event: { preventDefault: () => void }) => void>> = {}
let nodes = [createNode({ id: 'existing-node' })]
beforeEach(() => {
vi.clearAllMocks()
nodes = [createNode({ id: 'existing-node' })]
eventHandlers.click = undefined
eventHandlers.contextmenu = undefined
mockUseEventListener.mockImplementation((event: 'click' | 'contextmenu', handler: (event: { preventDefault: () => void }) => void) => {
eventHandlers[event] = handler
})
mockUseStoreApi.mockReturnValue({
getState: () => ({
getNodes: () => nodes,
setNodes: mockSetNodes,
}),
})
mockUseReactFlow.mockReturnValue({
screenToFlowPosition: ({ x, y }: { x: number, y: number }) => ({ x: x + 10, y: y + 20 }),
})
mockUseViewport.mockReturnValue({ zoom: 1.5 })
mockUseStore.mockImplementation((selector: (state: { mousePosition: {
pageX: number
pageY: number
elementX: number
elementY: number
} }) => unknown) => selector({
mousePosition: {
pageX: 100,
pageY: 200,
elementX: 30,
elementY: 40,
},
}))
mockUseWorkflowStore.mockReturnValue({
setState: mockWorkflowStoreSetState,
})
mockUseHooks.mockReturnValue({
useNodesInteractions: createNodesInteractions,
useWorkflowHistory: createWorkflowHistory,
useNodesSyncDraft: createNodesSyncDraft,
useAutoGenerateWebhookUrl: createAutoGenerateWebhookUrl,
})
mockHandleSyncWorkflowDraft.mockImplementation((_isSync: boolean, _force: boolean, options?: { onSuccess?: () => void }) => {
options?.onSuccess?.()
})
mockGetIterationStartNode.mockReturnValue(createNode({ id: 'iteration-start' }))
mockGetLoopStartNode.mockReturnValue(createNode({ id: 'loop-start' }))
})
it('should render the candidate node and commit a webhook node on click', () => {
const candidateNode = createNode({
id: 'candidate-webhook',
type: CUSTOM_NODE,
data: {
type: BlockEnum.TriggerWebhook,
title: 'Webhook Candidate',
_isCandidate: true,
},
})
const { container } = render(<CandidateNodeMain candidateNode={candidateNode} />)
expect(screen.getByTestId('candidate-custom-node')).toHaveTextContent('candidate-webhook')
expect(container.firstChild).toHaveStyle({
left: '30px',
top: '40px',
transform: 'scale(1.5)',
})
eventHandlers.click?.({ preventDefault: vi.fn() })
expect(mockSetNodes).toHaveBeenCalledWith(expect.arrayContaining([
expect.objectContaining({ id: 'existing-node' }),
expect.objectContaining({
id: 'candidate-webhook',
position: { x: 110, y: 220 },
data: expect.objectContaining({ _isCandidate: false }),
}),
]))
expect(mockSaveStateToHistory).toHaveBeenCalledWith('NodeAdd', { nodeId: 'candidate-webhook' })
expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ candidateNode: undefined })
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledWith(true, true, expect.objectContaining({
onSuccess: expect.any(Function),
}))
expect(mockAutoGenerateWebhookUrl).toHaveBeenCalledWith('candidate-webhook')
expect(mockHandleNodeSelect).not.toHaveBeenCalled()
})
it('should save note candidates as notes and select the inserted note', () => {
const candidateNode = createNode({
id: 'candidate-note',
type: CUSTOM_NOTE_NODE,
data: {
type: BlockEnum.Code,
title: 'Note Candidate',
_isCandidate: true,
},
})
render(<CandidateNodeMain candidateNode={candidateNode} />)
expect(screen.getByTestId('candidate-note-node')).toHaveTextContent('candidate-note')
eventHandlers.click?.({ preventDefault: vi.fn() })
expect(mockSaveStateToHistory).toHaveBeenCalledWith('NoteAdd', { nodeId: 'candidate-note' })
expect(mockHandleNodeSelect).toHaveBeenCalledWith('candidate-note')
})
it('should append iteration and loop start helper nodes for control-flow candidates', () => {
const iterationNode = createNode({
id: 'candidate-iteration',
type: CUSTOM_NODE,
data: {
type: BlockEnum.Iteration,
title: 'Iteration Candidate',
_isCandidate: true,
},
})
const loopNode = createNode({
id: 'candidate-loop',
type: CUSTOM_NODE,
data: {
type: BlockEnum.Loop,
title: 'Loop Candidate',
_isCandidate: true,
},
})
const { rerender } = render(<CandidateNodeMain candidateNode={iterationNode} />)
eventHandlers.click?.({ preventDefault: vi.fn() })
expect(mockGetIterationStartNode).toHaveBeenCalledWith('candidate-iteration')
expect(mockSetNodes.mock.calls[0][0]).toEqual(expect.arrayContaining([
expect.objectContaining({ id: 'candidate-iteration' }),
expect.objectContaining({ id: 'iteration-start' }),
]))
rerender(<CandidateNodeMain candidateNode={loopNode} />)
eventHandlers.click?.({ preventDefault: vi.fn() })
expect(mockGetLoopStartNode).toHaveBeenCalledWith('candidate-loop')
expect(mockSetNodes.mock.calls[1][0]).toEqual(expect.arrayContaining([
expect.objectContaining({ id: 'candidate-loop' }),
expect.objectContaining({ id: 'loop-start' }),
]))
})
it('should clear the candidate node on contextmenu', () => {
const candidateNode = createNode({
id: 'candidate-context',
type: CUSTOM_NODE,
data: {
type: BlockEnum.Code,
title: 'Context Candidate',
_isCandidate: true,
},
})
render(<CandidateNodeMain candidateNode={candidateNode} />)
eventHandlers.contextmenu?.({ preventDefault: vi.fn() })
expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ candidateNode: undefined })
})
})

View File

@ -0,0 +1,235 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { Position } from 'reactflow'
import { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types'
import CustomEdge from '../custom-edge'
import { BlockEnum, NodeRunningStatus } from '../types'
const mockUseAvailableBlocks = vi.hoisted(() => vi.fn())
const mockUseNodesInteractions = vi.hoisted(() => vi.fn())
const mockBlockSelector = vi.hoisted(() => vi.fn())
const mockGradientRender = vi.hoisted(() => vi.fn())
vi.mock('reactflow', () => ({
BaseEdge: (props: {
id: string
path: string
style: {
stroke: string
strokeWidth: number
opacity: number
strokeDasharray?: string
}
}) => (
<div
data-testid="base-edge"
data-id={props.id}
data-path={props.path}
data-stroke={props.style.stroke}
data-stroke-width={props.style.strokeWidth}
data-opacity={props.style.opacity}
data-dasharray={props.style.strokeDasharray}
/>
),
EdgeLabelRenderer: ({ children }: { children?: ReactNode }) => <div data-testid="edge-label">{children}</div>,
getBezierPath: () => ['M 0 0', 24, 48],
Position: {
Right: 'right',
Left: 'left',
},
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useAvailableBlocks: (...args: unknown[]) => mockUseAvailableBlocks(...args),
useNodesInteractions: () => mockUseNodesInteractions(),
}))
vi.mock('@/app/components/workflow/block-selector', () => ({
__esModule: true,
default: (props: {
open: boolean
onOpenChange: (open: boolean) => void
onSelect: (nodeType: string, pluginDefaultValue?: Record<string, unknown>) => void
availableBlocksTypes: string[]
triggerClassName?: () => string
}) => {
mockBlockSelector(props)
return (
<button
type="button"
data-testid="block-selector"
data-trigger-class={props.triggerClassName?.()}
onClick={() => {
props.onOpenChange(true)
props.onSelect('llm', { provider: 'openai' })
}}
>
{props.availableBlocksTypes.join(',')}
</button>
)
},
}))
vi.mock('@/app/components/workflow/custom-edge-linear-gradient-render', () => ({
__esModule: true,
default: (props: {
id: string
startColor: string
stopColor: string
}) => {
mockGradientRender(props)
return <div data-testid="edge-gradient">{props.id}</div>
},
}))
describe('CustomEdge', () => {
const mockHandleNodeAdd = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockUseNodesInteractions.mockReturnValue({
handleNodeAdd: mockHandleNodeAdd,
})
mockUseAvailableBlocks.mockImplementation((nodeType: BlockEnum) => {
if (nodeType === BlockEnum.Code)
return { availablePrevBlocks: ['code', 'llm'] }
return { availableNextBlocks: ['llm', 'tool'] }
})
})
it('should render a gradient edge and insert a node between the source and target', () => {
render(
<CustomEdge
id="edge-1"
source="source-node"
sourceHandleId="source"
target="target-node"
targetHandleId="target"
sourceX={100}
sourceY={120}
sourcePosition={Position.Right}
targetX={300}
targetY={220}
targetPosition={Position.Left}
selected={false}
data={{
sourceType: BlockEnum.Start,
targetType: BlockEnum.Code,
_sourceRunningStatus: NodeRunningStatus.Succeeded,
_targetRunningStatus: NodeRunningStatus.Failed,
_hovering: true,
_waitingRun: true,
_dimmed: true,
_isTemp: true,
isInIteration: true,
isInLoop: true,
} as never}
/>,
)
expect(screen.getByTestId('edge-gradient')).toHaveTextContent('edge-1')
expect(mockGradientRender).toHaveBeenCalledWith(expect.objectContaining({
id: 'edge-1',
startColor: 'var(--color-workflow-link-line-success-handle)',
stopColor: 'var(--color-workflow-link-line-error-handle)',
}))
expect(screen.getByTestId('base-edge')).toHaveAttribute('data-stroke', 'url(#edge-1)')
expect(screen.getByTestId('base-edge')).toHaveAttribute('data-opacity', '0.3')
expect(screen.getByTestId('base-edge')).toHaveAttribute('data-dasharray', '8 8')
expect(screen.getByTestId('block-selector')).toHaveTextContent('llm')
expect(screen.getByTestId('block-selector').parentElement).toHaveStyle({
transform: 'translate(-50%, -50%) translate(24px, 48px)',
opacity: '0.7',
})
fireEvent.click(screen.getByTestId('block-selector'))
expect(mockHandleNodeAdd).toHaveBeenCalledWith(
{
nodeType: 'llm',
pluginDefaultValue: { provider: 'openai' },
},
{
prevNodeId: 'source-node',
prevNodeSourceHandle: 'source',
nextNodeId: 'target-node',
nextNodeTargetHandle: 'target',
},
)
})
it('should prefer the running stroke color when the edge is selected', () => {
render(
<CustomEdge
id="edge-selected"
source="source-node"
target="target-node"
sourceX={0}
sourceY={0}
sourcePosition={Position.Right}
targetX={100}
targetY={100}
targetPosition={Position.Left}
selected
data={{
sourceType: BlockEnum.Start,
targetType: BlockEnum.Code,
_sourceRunningStatus: NodeRunningStatus.Succeeded,
_targetRunningStatus: NodeRunningStatus.Running,
} as never}
/>,
)
expect(screen.getByTestId('base-edge')).toHaveAttribute('data-stroke', 'var(--color-workflow-link-line-handle)')
})
it('should use the fail-branch running color while the connected node is hovering', () => {
render(
<CustomEdge
id="edge-hover"
source="source-node"
sourceHandleId={ErrorHandleTypeEnum.failBranch}
target="target-node"
sourceX={0}
sourceY={0}
sourcePosition={Position.Right}
targetX={100}
targetY={100}
targetPosition={Position.Left}
selected={false}
data={{
sourceType: BlockEnum.Start,
targetType: BlockEnum.Code,
_connectedNodeIsHovering: true,
} as never}
/>,
)
expect(screen.getByTestId('base-edge')).toHaveAttribute('data-stroke', 'var(--color-workflow-link-line-failure-handle)')
})
it('should fall back to the default edge color when no highlight state is active', () => {
render(
<CustomEdge
id="edge-default"
source="source-node"
target="target-node"
sourceX={0}
sourceY={0}
sourcePosition={Position.Right}
targetX={100}
targetY={100}
targetPosition={Position.Left}
selected={false}
data={{
sourceType: BlockEnum.Start,
targetType: BlockEnum.Code,
} as never}
/>,
)
expect(screen.getByTestId('base-edge')).toHaveAttribute('data-stroke', 'var(--color-workflow-link-line-normal)')
expect(screen.getByTestId('block-selector')).toHaveAttribute('data-trigger-class', 'hover:scale-150 transition-all')
})
})

View File

@ -0,0 +1,114 @@
import type { Node } from '../types'
import { fireEvent, render, screen } from '@testing-library/react'
import NodeContextmenu from '../node-contextmenu'
const mockUseClickAway = vi.hoisted(() => vi.fn())
const mockUseNodes = vi.hoisted(() => vi.fn())
const mockUsePanelInteractions = vi.hoisted(() => vi.fn())
const mockUseStore = vi.hoisted(() => vi.fn())
const mockPanelOperatorPopup = vi.hoisted(() => vi.fn())
vi.mock('ahooks', () => ({
useClickAway: (...args: unknown[]) => mockUseClickAway(...args),
}))
vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({
__esModule: true,
default: () => mockUseNodes(),
}))
vi.mock('@/app/components/workflow/hooks', () => ({
usePanelInteractions: () => mockUsePanelInteractions(),
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: { nodeMenu?: { nodeId: string, left: number, top: number } }) => unknown) => mockUseStore(selector),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup', () => ({
__esModule: true,
default: (props: {
id: string
data: Node['data']
showHelpLink: boolean
onClosePopup: () => void
}) => {
mockPanelOperatorPopup(props)
return (
<button type="button" onClick={props.onClosePopup}>
{props.id}
:
{props.data.title}
</button>
)
},
}))
describe('NodeContextmenu', () => {
const mockHandleNodeContextmenuCancel = vi.fn()
let nodeMenu: { nodeId: string, left: number, top: number } | undefined
let nodes: Node[]
let clickAwayHandler: (() => void) | undefined
beforeEach(() => {
vi.clearAllMocks()
nodeMenu = undefined
nodes = [{
id: 'node-1',
type: 'custom',
position: { x: 0, y: 0 },
data: {
title: 'Node 1',
desc: '',
type: 'code' as never,
},
} as Node]
clickAwayHandler = undefined
mockUseClickAway.mockImplementation((handler: () => void) => {
clickAwayHandler = handler
})
mockUseNodes.mockImplementation(() => nodes)
mockUsePanelInteractions.mockReturnValue({
handleNodeContextmenuCancel: mockHandleNodeContextmenuCancel,
})
mockUseStore.mockImplementation((selector: (state: { nodeMenu?: { nodeId: string, left: number, top: number } }) => unknown) => selector({ nodeMenu }))
})
it('should stay hidden when the node menu is absent', () => {
render(<NodeContextmenu />)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
expect(mockPanelOperatorPopup).not.toHaveBeenCalled()
})
it('should stay hidden when the referenced node cannot be found', () => {
nodeMenu = { nodeId: 'missing-node', left: 80, top: 120 }
render(<NodeContextmenu />)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
expect(mockPanelOperatorPopup).not.toHaveBeenCalled()
})
it('should render the popup at the stored position and close on popup/click-away actions', () => {
nodeMenu = { nodeId: 'node-1', left: 80, top: 120 }
const { container } = render(<NodeContextmenu />)
expect(screen.getByRole('button')).toHaveTextContent('node-1:Node 1')
expect(mockPanelOperatorPopup).toHaveBeenCalledWith(expect.objectContaining({
id: 'node-1',
data: expect.objectContaining({ title: 'Node 1' }),
showHelpLink: true,
}))
expect(container.firstChild).toHaveStyle({
left: '80px',
top: '120px',
})
fireEvent.click(screen.getByRole('button'))
clickAwayHandler?.()
expect(mockHandleNodeContextmenuCancel).toHaveBeenCalledTimes(2)
})
})

View File

@ -0,0 +1,151 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import PanelContextmenu from '../panel-contextmenu'
const mockUseClickAway = vi.hoisted(() => vi.fn())
const mockUseTranslation = vi.hoisted(() => vi.fn())
const mockUseStore = vi.hoisted(() => vi.fn())
const mockUseNodesInteractions = vi.hoisted(() => vi.fn())
const mockUsePanelInteractions = vi.hoisted(() => vi.fn())
const mockUseWorkflowStartRun = vi.hoisted(() => vi.fn())
const mockUseOperator = vi.hoisted(() => vi.fn())
const mockUseDSL = vi.hoisted(() => vi.fn())
vi.mock('ahooks', () => ({
useClickAway: (...args: unknown[]) => mockUseClickAway(...args),
}))
vi.mock('react-i18next', () => ({
useTranslation: () => mockUseTranslation(),
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: {
panelMenu?: { left: number, top: number }
clipboardElements: unknown[]
setShowImportDSLModal: (visible: boolean) => void
}) => unknown) => mockUseStore(selector),
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useNodesInteractions: () => mockUseNodesInteractions(),
usePanelInteractions: () => mockUsePanelInteractions(),
useWorkflowStartRun: () => mockUseWorkflowStartRun(),
useDSL: () => mockUseDSL(),
}))
vi.mock('@/app/components/workflow/operator/hooks', () => ({
useOperator: () => mockUseOperator(),
}))
vi.mock('@/app/components/workflow/operator/add-block', () => ({
__esModule: true,
default: ({ renderTrigger }: { renderTrigger: () => ReactNode }) => (
<div data-testid="add-block">{renderTrigger()}</div>
),
}))
vi.mock('@/app/components/base/divider', () => ({
__esModule: true,
default: ({ className }: { className?: string }) => <div data-testid="divider" className={className} />,
}))
vi.mock('@/app/components/workflow/shortcuts-name', () => ({
__esModule: true,
default: ({ keys }: { keys: string[] }) => <span data-testid={`shortcut-${keys.join('-')}`}>{keys.join('+')}</span>,
}))
describe('PanelContextmenu', () => {
const mockHandleNodesPaste = vi.fn()
const mockHandlePaneContextmenuCancel = vi.fn()
const mockHandleStartWorkflowRun = vi.fn()
const mockHandleAddNote = vi.fn()
const mockExportCheck = vi.fn()
const mockSetShowImportDSLModal = vi.fn()
let panelMenu: { left: number, top: number } | undefined
let clipboardElements: unknown[]
let clickAwayHandler: (() => void) | undefined
beforeEach(() => {
vi.clearAllMocks()
panelMenu = undefined
clipboardElements = []
clickAwayHandler = undefined
mockUseClickAway.mockImplementation((handler: () => void) => {
clickAwayHandler = handler
})
mockUseTranslation.mockReturnValue({
t: (key: string) => key,
})
mockUseStore.mockImplementation((selector: (state: {
panelMenu?: { left: number, top: number }
clipboardElements: unknown[]
setShowImportDSLModal: (visible: boolean) => void
}) => unknown) => selector({
panelMenu,
clipboardElements,
setShowImportDSLModal: mockSetShowImportDSLModal,
}))
mockUseNodesInteractions.mockReturnValue({
handleNodesPaste: mockHandleNodesPaste,
})
mockUsePanelInteractions.mockReturnValue({
handlePaneContextmenuCancel: mockHandlePaneContextmenuCancel,
})
mockUseWorkflowStartRun.mockReturnValue({
handleStartWorkflowRun: mockHandleStartWorkflowRun,
})
mockUseOperator.mockReturnValue({
handleAddNote: mockHandleAddNote,
})
mockUseDSL.mockReturnValue({
exportCheck: mockExportCheck,
})
})
it('should stay hidden when the panel menu is absent', () => {
render(<PanelContextmenu />)
expect(screen.queryByTestId('add-block')).not.toBeInTheDocument()
})
it('should keep paste disabled when the clipboard is empty', () => {
panelMenu = { left: 24, top: 48 }
render(<PanelContextmenu />)
fireEvent.click(screen.getByText('common.pasteHere'))
expect(mockHandleNodesPaste).not.toHaveBeenCalled()
expect(mockHandlePaneContextmenuCancel).not.toHaveBeenCalled()
})
it('should render actions, position the menu, and execute each action', () => {
panelMenu = { left: 24, top: 48 }
clipboardElements = [{ id: 'copied-node' }]
const { container } = render(<PanelContextmenu />)
expect(screen.getByTestId('add-block')).toHaveTextContent('common.addBlock')
expect(screen.getByTestId('shortcut-alt-r')).toHaveTextContent('alt+r')
expect(screen.getByTestId('shortcut-ctrl-v')).toHaveTextContent('ctrl+v')
expect(container.firstChild).toHaveStyle({
left: '24px',
top: '48px',
})
fireEvent.click(screen.getByText('nodes.note.addNote'))
fireEvent.click(screen.getByText('common.run'))
fireEvent.click(screen.getByText('common.pasteHere'))
fireEvent.click(screen.getByText('export'))
fireEvent.click(screen.getByText('common.importDSL'))
clickAwayHandler?.()
expect(mockHandleAddNote).toHaveBeenCalledTimes(1)
expect(mockHandleStartWorkflowRun).toHaveBeenCalledTimes(1)
expect(mockHandleNodesPaste).toHaveBeenCalledTimes(1)
expect(mockExportCheck).toHaveBeenCalledTimes(1)
expect(mockSetShowImportDSLModal).toHaveBeenCalledWith(true)
expect(mockHandlePaneContextmenuCancel).toHaveBeenCalledTimes(4)
})
})

View File

@ -0,0 +1,275 @@
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 mockGetNodesReadOnly = vi.fn()
vi.mock('../hooks', async () => {
const actual = await vi.importActual<typeof import('../hooks')>('../hooks')
return {
...actual,
useNodesReadOnly: () => ({
getNodesReadOnly: mockGetNodesReadOnly,
}),
}
})
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<string, unknown>
}) => {
latestNodes = []
latestHistoryEvent = undefined
const nodes = options?.nodes ?? []
const edges = options?.edges ?? []
return renderWorkflowFlowComponent(
<div id="workflow-container" style={{ width: 800, height: 600 }}>
<RuntimeProbe />
<SelectionContextmenu />
</div>,
{
nodes,
edges,
hooksStoreProps,
historyStore: { nodes, edges },
initialStoreState: options?.initialStoreState,
reactFlowProps: { fitView: false },
},
)
}
describe('SelectionContextmenu', () => {
beforeEach(() => {
vi.clearAllMocks()
latestNodes = []
latestHistoryEvent = undefined
mockGetNodesReadOnly.mockReset()
mockGetNodesReadOnly.mockReturnValue(false)
})
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)
})
it('should cancel when align bounds cannot be resolved', () => {
const nodes = [
createNode({ id: 'n1', selected: true }),
createNode({ id: 'n2', selected: true, position: { x: 80, y: 20 } }),
]
const { store } = renderSelectionMenu({ nodes })
act(() => {
store.setState({ selectionMenu: { left: 100, top: 100 } })
})
fireEvent.click(screen.getByTestId('selection-contextmenu-item-left'))
expect(store.getState().selectionMenu).toBeUndefined()
})
it('should cancel without aligning when nodes are read only', () => {
mockGetNodesReadOnly.mockReturnValue(true)
const nodes = [
createNode({ id: 'n1', selected: true, width: 40, height: 20 }),
createNode({ id: 'n2', selected: true, position: { x: 80, y: 20 }, width: 40, height: 20 }),
]
const { store } = renderSelectionMenu({ nodes })
act(() => {
store.setState({ selectionMenu: { left: 100, top: 100 } })
})
fireEvent.click(screen.getByTestId('selection-contextmenu-item-left'))
expect(store.getState().selectionMenu).toBeUndefined()
expect(latestNodes.find(node => node.id === 'n1')?.position.x).toBe(0)
expect(latestNodes.find(node => node.id === 'n2')?.position.x).toBe(80)
})
it('should cancel when alignable nodes shrink to one item', () => {
const nodes = [
createNode({
id: 'container',
selected: true,
width: 40,
height: 20,
data: { _children: [{ nodeId: 'child', nodeType: 'code' as never }] },
}),
createNode({ id: 'child', selected: true, position: { x: 80, y: 20 }, width: 40, height: 20 }),
]
const { store } = renderSelectionMenu({ nodes })
act(() => {
store.setState({ selectionMenu: { left: 100, top: 100 } })
})
fireEvent.click(screen.getByTestId('selection-contextmenu-item-left'))
expect(store.getState().selectionMenu).toBeUndefined()
expect(latestNodes.find(node => node.id === 'container')?.position.x).toBe(0)
expect(latestNodes.find(node => node.id === 'child')?.position.x).toBe(80)
})
})

View File

@ -0,0 +1,79 @@
import { DSLImportStatus } from '@/models/app'
import { AppModeEnum } from '@/types/app'
import { BlockEnum } from '../types'
import {
getInvalidNodeTypes,
isImportCompleted,
normalizeWorkflowFeatures,
validateDSLContent,
} from '../update-dsl-modal.helpers'
describe('update-dsl-modal helpers', () => {
describe('dsl validation', () => {
it('should reject advanced chat dsl content with disallowed trigger nodes', () => {
const content = `
workflow:
graph:
nodes:
- data:
type: trigger-webhook
`
expect(validateDSLContent(content, AppModeEnum.ADVANCED_CHAT)).toBe(false)
})
it('should reject malformed yaml and answer nodes in non-advanced mode', () => {
expect(validateDSLContent('[', AppModeEnum.CHAT)).toBe(false)
expect(validateDSLContent(`
workflow:
graph:
nodes:
- data:
type: answer
`, AppModeEnum.CHAT)).toBe(false)
})
it('should accept valid node types for advanced chat mode', () => {
expect(validateDSLContent(`
workflow:
graph:
nodes:
- data:
type: tool
`, AppModeEnum.ADVANCED_CHAT)).toBe(true)
})
it('should expose the invalid node sets per mode', () => {
expect(getInvalidNodeTypes(AppModeEnum.ADVANCED_CHAT)).toEqual(
expect.arrayContaining([BlockEnum.End, BlockEnum.TriggerWebhook]),
)
expect(getInvalidNodeTypes(AppModeEnum.CHAT)).toEqual([BlockEnum.Answer])
})
})
describe('status and feature normalization', () => {
it('should treat completed statuses as successful imports', () => {
expect(isImportCompleted(DSLImportStatus.COMPLETED)).toBe(true)
expect(isImportCompleted(DSLImportStatus.COMPLETED_WITH_WARNINGS)).toBe(true)
expect(isImportCompleted(DSLImportStatus.PENDING)).toBe(false)
})
it('should normalize workflow features with defaults', () => {
const features = normalizeWorkflowFeatures({
file_upload: {
image: {
enabled: true,
},
},
opening_statement: 'hello',
suggested_questions: ['what can you do?'],
})
expect(features.file.enabled).toBe(true)
expect(features.file.number_limits).toBe(3)
expect(features.opening.enabled).toBe(true)
expect(features.suggested).toEqual({ enabled: false })
expect(features.text2speech).toEqual({ enabled: false })
})
})
})

View File

@ -0,0 +1,365 @@
import type { EventEmitter } from 'ahooks/lib/useEventEmitter'
import type { EventEmitterValue } from '@/context/event-emitter'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { toast } from '@/app/components/base/ui/toast'
import { EventEmitterContext } from '@/context/event-emitter'
import { DSLImportStatus } from '@/models/app'
import UpdateDSLModal from '../update-dsl-modal'
class MockFileReader {
onload: ((this: FileReader, event: ProgressEvent<FileReader>) => void) | null = null
readAsText(_file: Blob) {
const event = { target: { result: 'workflow:\n graph:\n nodes:\n - data:\n type: tool\n' } } as unknown as ProgressEvent<FileReader>
this.onload?.call(this as unknown as FileReader, event)
}
}
vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader)
const mockEmit = vi.fn()
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
error: vi.fn(),
info: vi.fn(),
success: vi.fn(),
warning: vi.fn(),
},
}))
const mockImportDSL = vi.fn()
const mockImportDSLConfirm = vi.fn()
vi.mock('@/service/apps', () => ({
importDSL: (payload: unknown) => mockImportDSL(payload),
importDSLConfirm: (payload: unknown) => mockImportDSLConfirm(payload),
}))
const mockFetchWorkflowDraft = vi.fn()
vi.mock('@/service/workflow', () => ({
fetchWorkflowDraft: (path: string) => mockFetchWorkflowDraft(path),
}))
const mockHandleCheckPluginDependencies = vi.fn()
vi.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({
usePluginDependencies: () => ({
handleCheckPluginDependencies: mockHandleCheckPluginDependencies,
}),
}))
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: { appDetail: { id: string, mode: string } }) => unknown) => selector({
appDetail: {
id: 'app-1',
mode: 'chat',
},
}),
}))
vi.mock('@/app/components/app/create-from-dsl-modal/uploader', () => ({
default: ({ updateFile }: { updateFile: (file?: File) => void }) => (
<input
data-testid="dsl-file-input"
type="file"
onChange={event => updateFile(event.target.files?.[0])}
/>
),
}))
describe('UpdateDSLModal', () => {
const mockToastError = vi.mocked(toast.error)
const defaultProps = {
onCancel: vi.fn(),
onBackup: vi.fn(),
onImport: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
vi.useRealTimers()
mockFetchWorkflowDraft.mockResolvedValue({
graph: { nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } },
features: {},
hash: 'hash-1',
conversation_variables: [],
environment_variables: [],
})
mockImportDSL.mockResolvedValue({
id: 'import-1',
status: DSLImportStatus.COMPLETED,
app_id: 'app-1',
})
mockImportDSLConfirm.mockResolvedValue({
status: DSLImportStatus.COMPLETED,
app_id: 'app-1',
})
mockHandleCheckPluginDependencies.mockResolvedValue(undefined)
})
const renderModal = (props = defaultProps) => {
const eventEmitter = { emit: mockEmit } as unknown as EventEmitter<EventEmitterValue>
return render(
<EventEmitterContext.Provider value={{ eventEmitter }}>
<UpdateDSLModal {...props} />
</EventEmitterContext.Provider>,
)
}
it('should keep import disabled until a file is selected', () => {
renderModal()
expect(screen.getByRole('button', { name: 'workflow.common.overwriteAndImport' })).toBeDisabled()
})
it('should call backup handler from the warning area', () => {
renderModal()
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.backupCurrentDraft' }))
expect(defaultProps.onBackup).toHaveBeenCalledTimes(1)
})
it('should import a valid file and emit workflow update payload', async () => {
renderModal()
fireEvent.change(screen.getByTestId('dsl-file-input'), {
target: { files: [new File(['workflow'], 'workflow.yml', { type: 'text/yaml' })] },
})
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.overwriteAndImport' }))
await waitFor(() => {
expect(mockImportDSL).toHaveBeenCalledWith(expect.objectContaining({
app_id: 'app-1',
yaml_content: expect.stringContaining('workflow:'),
}))
})
expect(mockEmit).toHaveBeenCalledWith(expect.objectContaining({
type: 'WORKFLOW_DATA_UPDATE',
}))
expect(defaultProps.onImport).toHaveBeenCalledTimes(1)
expect(defaultProps.onCancel).toHaveBeenCalledTimes(1)
})
it('should show an error notification when import fails', async () => {
mockImportDSL.mockResolvedValue({
id: 'import-1',
status: DSLImportStatus.FAILED,
app_id: 'app-1',
})
renderModal()
fireEvent.change(screen.getByTestId('dsl-file-input'), {
target: { files: [new File(['invalid'], 'workflow.yml', { type: 'text/yaml' })] },
})
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.overwriteAndImport' }))
await waitFor(() => {
expect(mockToastError).toHaveBeenCalled()
})
})
it('should open the version warning modal for pending imports and confirm them', async () => {
mockImportDSL.mockResolvedValue({
id: 'import-2',
status: DSLImportStatus.PENDING,
imported_dsl_version: '1.0.0',
current_dsl_version: '2.0.0',
})
renderModal()
fireEvent.change(screen.getByTestId('dsl-file-input'), {
target: { files: [new File(['workflow'], 'workflow.yml', { type: 'text/yaml' })] },
})
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.overwriteAndImport' }))
await waitFor(() => {
expect(screen.getByRole('button', { name: 'app.newApp.Confirm' })).toBeInTheDocument()
})
fireEvent.click(screen.getByRole('button', { name: 'app.newApp.Confirm' }))
await waitFor(() => {
expect(mockImportDSLConfirm).toHaveBeenCalledWith({ import_id: 'import-2' })
})
})
it('should open the pending modal after the timeout and allow dismissing it', async () => {
mockImportDSL.mockResolvedValue({
id: 'import-5',
status: DSLImportStatus.PENDING,
imported_dsl_version: '1.0.0',
current_dsl_version: '2.0.0',
})
renderModal()
fireEvent.change(screen.getByTestId('dsl-file-input'), {
target: { files: [new File(['workflow'], 'workflow.yml', { type: 'text/yaml' })] },
})
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.overwriteAndImport' }))
await waitFor(() => {
expect(mockImportDSL).toHaveBeenCalled()
})
await waitFor(() => {
expect(screen.getByRole('button', { name: 'app.newApp.Cancel' })).toBeInTheDocument()
}, { timeout: 1000 })
fireEvent.click(screen.getByRole('button', { name: 'app.newApp.Cancel' }))
await waitFor(() => {
expect(screen.queryByRole('button', { name: 'app.newApp.Confirm' })).not.toBeInTheDocument()
})
})
it('should show an error when the selected file content is invalid for the current app mode', async () => {
class InvalidDSLFileReader extends MockFileReader {
readAsText(_file: Blob) {
const event = { target: { result: 'workflow:\n graph:\n nodes:\n - data:\n type: answer\n' } } as unknown as ProgressEvent<FileReader>
this.onload?.call(this as unknown as FileReader, event)
}
}
vi.stubGlobal('FileReader', InvalidDSLFileReader as unknown as typeof FileReader)
renderModal()
fireEvent.change(screen.getByTestId('dsl-file-input'), {
target: { files: [new File(['workflow'], 'workflow.yml', { type: 'text/yaml' })] },
})
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.overwriteAndImport' }))
await waitFor(() => {
expect(mockToastError).toHaveBeenCalled()
})
expect(mockImportDSL).not.toHaveBeenCalled()
vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader)
})
it('should show an error notification when import throws', async () => {
mockImportDSL.mockRejectedValue(new Error('boom'))
renderModal()
fireEvent.change(screen.getByTestId('dsl-file-input'), {
target: { files: [new File(['workflow'], 'workflow.yml', { type: 'text/yaml' })] },
})
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.overwriteAndImport' }))
await waitFor(() => {
expect(mockToastError).toHaveBeenCalled()
})
})
it('should show an error when completed import does not return an app id', async () => {
mockImportDSL.mockResolvedValue({
id: 'import-3',
status: DSLImportStatus.COMPLETED,
})
renderModal()
fireEvent.change(screen.getByTestId('dsl-file-input'), {
target: { files: [new File(['workflow'], 'workflow.yml', { type: 'text/yaml' })] },
})
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.overwriteAndImport' }))
await waitFor(() => {
expect(mockToastError).toHaveBeenCalled()
})
})
it('should show an error when confirming a pending import fails', async () => {
mockImportDSL.mockResolvedValue({
id: 'import-4',
status: DSLImportStatus.PENDING,
imported_dsl_version: '1.0.0',
current_dsl_version: '2.0.0',
})
mockImportDSLConfirm.mockResolvedValue({
status: DSLImportStatus.FAILED,
})
renderModal()
fireEvent.change(screen.getByTestId('dsl-file-input'), {
target: { files: [new File(['workflow'], 'workflow.yml', { type: 'text/yaml' })] },
})
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.overwriteAndImport' }))
await waitFor(() => {
expect(screen.getByRole('button', { name: 'app.newApp.Confirm' })).toBeInTheDocument()
})
fireEvent.click(screen.getByRole('button', { name: 'app.newApp.Confirm' }))
await waitFor(() => {
expect(mockToastError).toHaveBeenCalled()
})
})
it('should show an error when confirming a pending import throws', async () => {
mockImportDSL.mockResolvedValue({
id: 'import-6',
status: DSLImportStatus.PENDING,
imported_dsl_version: '1.0.0',
current_dsl_version: '2.0.0',
})
mockImportDSLConfirm.mockRejectedValue(new Error('boom'))
renderModal()
fireEvent.change(screen.getByTestId('dsl-file-input'), {
target: { files: [new File(['workflow'], 'workflow.yml', { type: 'text/yaml' })] },
})
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.overwriteAndImport' }))
await waitFor(() => {
expect(screen.getByRole('button', { name: 'app.newApp.Confirm' })).toBeInTheDocument()
})
fireEvent.click(screen.getByRole('button', { name: 'app.newApp.Confirm' }))
await waitFor(() => {
expect(mockToastError).toHaveBeenCalled()
})
})
it('should show an error when a confirmed pending import completes without an app id', async () => {
mockImportDSL.mockResolvedValue({
id: 'import-7',
status: DSLImportStatus.PENDING,
imported_dsl_version: '1.0.0',
current_dsl_version: '2.0.0',
})
mockImportDSLConfirm.mockResolvedValue({
status: DSLImportStatus.COMPLETED,
})
renderModal()
fireEvent.change(screen.getByTestId('dsl-file-input'), {
target: { files: [new File(['workflow'], 'workflow.yml', { type: 'text/yaml' })] },
})
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.overwriteAndImport' }))
await waitFor(() => {
expect(screen.getByRole('button', { name: 'app.newApp.Confirm' })).toBeInTheDocument()
})
fireEvent.click(screen.getByRole('button', { name: 'app.newApp.Confirm' }))
await waitFor(() => {
expect(mockToastError).toHaveBeenCalled()
})
})
})

View File

@ -0,0 +1,61 @@
import { render } from '@testing-library/react'
import HelpLine from '../index'
const mockUseViewport = vi.hoisted(() => vi.fn())
const mockUseStore = vi.hoisted(() => vi.fn())
vi.mock('reactflow', () => ({
useViewport: () => mockUseViewport(),
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: {
helpLineHorizontal?: { top: number, left: number, width: number }
helpLineVertical?: { top: number, left: number, height: number }
}) => unknown) => mockUseStore(selector),
}))
describe('HelpLine', () => {
let helpLineHorizontal: { top: number, left: number, width: number } | undefined
let helpLineVertical: { top: number, left: number, height: number } | undefined
beforeEach(() => {
vi.clearAllMocks()
helpLineHorizontal = undefined
helpLineVertical = undefined
mockUseViewport.mockReturnValue({ x: 10, y: 20, zoom: 2 })
mockUseStore.mockImplementation((selector: (state: {
helpLineHorizontal?: { top: number, left: number, width: number }
helpLineVertical?: { top: number, left: number, height: number }
}) => unknown) => selector({
helpLineHorizontal,
helpLineVertical,
}))
})
it('should render nothing when both help lines are absent', () => {
const { container } = render(<HelpLine />)
expect(container).toBeEmptyDOMElement()
})
it('should render the horizontal and vertical guide lines using viewport offsets and zoom', () => {
helpLineHorizontal = { top: 30, left: 40, width: 50 }
helpLineVertical = { top: 60, left: 70, height: 80 }
const { container } = render(<HelpLine />)
const [horizontal, vertical] = Array.from(container.querySelectorAll('div'))
expect(horizontal).toHaveStyle({
top: '80px',
left: '90px',
width: '100px',
})
expect(vertical).toHaveStyle({
top: '140px',
left: '150px',
height: '160px',
})
})
})

View File

@ -0,0 +1,171 @@
import type { ModelConfig, VisionSetting } from '@/app/components/workflow/types'
import { act, renderHook } from '@testing-library/react'
import { ModelFeatureEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { Resolution } from '@/types/app'
import useConfigVision from '../use-config-vision'
const mockUseTextGenerationCurrentProviderAndModelAndModelList = vi.hoisted(() => vi.fn())
const mockUseIsChatMode = vi.hoisted(() => vi.fn())
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useTextGenerationCurrentProviderAndModelAndModelList: (...args: unknown[]) =>
mockUseTextGenerationCurrentProviderAndModelAndModelList(...args),
}))
vi.mock('../use-workflow', () => ({
useIsChatMode: () => mockUseIsChatMode(),
}))
const createModel = (overrides: Partial<ModelConfig> = {}): ModelConfig => ({
provider: 'openai',
name: 'gpt-4o',
mode: 'chat',
completion_params: [],
...overrides,
})
const createVisionPayload = (overrides: Partial<{ enabled: boolean, configs?: VisionSetting }> = {}) => ({
enabled: false,
...overrides,
})
describe('useConfigVision', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseIsChatMode.mockReturnValue(false)
mockUseTextGenerationCurrentProviderAndModelAndModelList.mockReturnValue({
currentModel: {
features: [],
},
})
})
it('should expose vision capability and enable default chat configs for vision models', () => {
const onChange = vi.fn()
mockUseIsChatMode.mockReturnValue(true)
mockUseTextGenerationCurrentProviderAndModelAndModelList.mockReturnValue({
currentModel: {
features: [ModelFeatureEnum.vision],
},
})
const { result } = renderHook(() => useConfigVision(createModel(), {
payload: createVisionPayload(),
onChange,
}))
expect(result.current.isVisionModel).toBe(true)
act(() => {
result.current.handleVisionResolutionEnabledChange(true)
})
expect(onChange).toHaveBeenCalledWith({
enabled: true,
configs: {
detail: Resolution.high,
variable_selector: ['sys', 'files'],
},
})
})
it('should clear configs when disabling vision resolution', () => {
const onChange = vi.fn()
const { result } = renderHook(() => useConfigVision(createModel(), {
payload: createVisionPayload({
enabled: true,
configs: {
detail: Resolution.low,
variable_selector: ['node', 'files'],
},
}),
onChange,
}))
act(() => {
result.current.handleVisionResolutionEnabledChange(false)
})
expect(onChange).toHaveBeenCalledWith({
enabled: false,
})
})
it('should update the resolution config payload directly', () => {
const onChange = vi.fn()
const config: VisionSetting = {
detail: Resolution.low,
variable_selector: ['upstream', 'images'],
}
const { result } = renderHook(() => useConfigVision(createModel(), {
payload: createVisionPayload({ enabled: true }),
onChange,
}))
act(() => {
result.current.handleVisionResolutionChange(config)
})
expect(onChange).toHaveBeenCalledWith({
enabled: true,
configs: config,
})
})
it('should disable vision settings when the selected model is no longer a vision model', () => {
const onChange = vi.fn()
const { result } = renderHook(() => useConfigVision(createModel(), {
payload: createVisionPayload({
enabled: true,
configs: {
detail: Resolution.high,
variable_selector: ['sys', 'files'],
},
}),
onChange,
}))
act(() => {
result.current.handleModelChanged()
})
expect(onChange).toHaveBeenCalledWith({
enabled: false,
})
})
it('should reset enabled vision configs when the model changes but still supports vision', () => {
const onChange = vi.fn()
mockUseTextGenerationCurrentProviderAndModelAndModelList.mockReturnValue({
currentModel: {
features: [ModelFeatureEnum.vision],
},
})
const { result } = renderHook(() => useConfigVision(createModel(), {
payload: createVisionPayload({
enabled: true,
configs: {
detail: Resolution.low,
variable_selector: ['old', 'files'],
},
}),
onChange,
}))
act(() => {
result.current.handleModelChanged()
})
expect(onChange).toHaveBeenCalledWith({
enabled: true,
configs: {
detail: Resolution.high,
variable_selector: [],
},
})
})
})

View File

@ -0,0 +1,146 @@
import { renderHook } from '@testing-library/react'
import { BlockEnum } from '../../types'
import { useDynamicTestRunOptions } from '../use-dynamic-test-run-options'
const mockUseTranslation = vi.hoisted(() => vi.fn())
const mockUseNodes = vi.hoisted(() => vi.fn())
const mockUseStore = vi.hoisted(() => vi.fn())
const mockUseAllTriggerPlugins = vi.hoisted(() => vi.fn())
const mockGetWorkflowEntryNode = vi.hoisted(() => vi.fn())
vi.mock('react-i18next', () => ({
useTranslation: () => mockUseTranslation(),
}))
vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({
__esModule: true,
default: () => mockUseNodes(),
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: {
buildInTools: unknown[]
customTools: unknown[]
workflowTools: unknown[]
mcpTools: unknown[]
}) => unknown) => mockUseStore(selector),
}))
vi.mock('@/service/use-triggers', () => ({
useAllTriggerPlugins: () => mockUseAllTriggerPlugins(),
}))
vi.mock('@/app/components/workflow/utils/workflow-entry', () => ({
getWorkflowEntryNode: (...args: unknown[]) => mockGetWorkflowEntryNode(...args),
}))
describe('useDynamicTestRunOptions', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseTranslation.mockReturnValue({
t: (key: string) => key,
})
mockUseStore.mockImplementation((selector: (state: {
buildInTools: unknown[]
customTools: unknown[]
workflowTools: unknown[]
mcpTools: unknown[]
}) => unknown) => selector({
buildInTools: [],
customTools: [],
workflowTools: [],
mcpTools: [],
}))
mockUseAllTriggerPlugins.mockReturnValue({
data: [{
name: 'plugin-provider',
icon: '/plugin-icon.png',
}],
})
})
it('should build user input, trigger options, and a run-all option from workflow nodes', () => {
mockUseNodes.mockReturnValue([
{
id: 'start-1',
data: { type: BlockEnum.Start, title: 'User Input' },
},
{
id: 'schedule-1',
data: { type: BlockEnum.TriggerSchedule, title: 'Daily Schedule' },
},
{
id: 'webhook-1',
data: { type: BlockEnum.TriggerWebhook, title: 'Webhook Trigger' },
},
{
id: 'plugin-1',
data: {
type: BlockEnum.TriggerPlugin,
title: '',
plugin_name: 'Plugin Trigger',
provider_id: 'plugin-provider',
},
},
])
const { result } = renderHook(() => useDynamicTestRunOptions())
expect(result.current.userInput).toEqual(expect.objectContaining({
id: 'start-1',
type: 'user_input',
name: 'User Input',
nodeId: 'start-1',
enabled: true,
}))
expect(result.current.triggers).toEqual([
expect.objectContaining({
id: 'schedule-1',
type: 'schedule',
name: 'Daily Schedule',
nodeId: 'schedule-1',
}),
expect.objectContaining({
id: 'webhook-1',
type: 'webhook',
name: 'Webhook Trigger',
nodeId: 'webhook-1',
}),
expect.objectContaining({
id: 'plugin-1',
type: 'plugin',
name: 'Plugin Trigger',
nodeId: 'plugin-1',
}),
])
expect(result.current.runAll).toEqual(expect.objectContaining({
id: 'run-all',
type: 'all',
relatedNodeIds: ['schedule-1', 'webhook-1', 'plugin-1'],
}))
})
it('should fall back to the workflow entry node and omit run-all when only one trigger exists', () => {
mockUseNodes.mockReturnValue([
{
id: 'webhook-1',
data: { type: BlockEnum.TriggerWebhook, title: 'Webhook Trigger' },
},
])
mockGetWorkflowEntryNode.mockReturnValue({
id: 'fallback-start',
data: { type: BlockEnum.Start, title: '' },
})
const { result } = renderHook(() => useDynamicTestRunOptions())
expect(result.current.userInput).toEqual(expect.objectContaining({
id: 'fallback-start',
type: 'user_input',
name: 'blocks.start',
nodeId: 'fallback-start',
}))
expect(result.current.triggers).toHaveLength(1)
expect(result.current.runAll).toBeUndefined()
})
})

View File

@ -0,0 +1,135 @@
import type { TFunction } from 'i18next'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { BlockEnum, NodeRunningStatus } from '@/app/components/workflow/types'
import { NodeBody, NodeDescription, NodeHeaderMeta } from '../node-sections'
describe('node sections', () => {
it('should render loop and loading metadata in the header section', () => {
const t = ((key: string) => key) as unknown as TFunction
render(
<NodeHeaderMeta
data={{
type: BlockEnum.Loop,
_loopIndex: 2,
_runningStatus: NodeRunningStatus.Running,
} as never}
hasVarValue={false}
isLoading
loopIndex={<div>loop-index</div>}
t={t}
/>,
)
expect(screen.getByText('loop-index')).toBeInTheDocument()
expect(document.querySelector('.i-ri-loader-2-line')).toBeInTheDocument()
})
it('should render the container node body and description branches', () => {
const { rerender } = render(
<NodeBody
data={{ type: BlockEnum.Loop } as never}
child={<div>body-content</div>}
/>,
)
expect(screen.getByText('body-content').parentElement).toHaveClass('grow')
rerender(<NodeDescription data={{ type: BlockEnum.Tool, desc: 'node description' } as never} />)
expect(screen.getByText('node description')).toBeInTheDocument()
})
it('should render iteration parallel metadata and running progress', async () => {
const t = ((key: string) => key) as unknown as TFunction
const user = userEvent.setup()
render(
<NodeHeaderMeta
data={{
type: BlockEnum.Iteration,
is_parallel: true,
_iterationLength: 3,
_iterationIndex: 5,
_runningStatus: NodeRunningStatus.Running,
} as never}
hasVarValue={false}
isLoading={false}
loopIndex={null}
t={t}
/>,
)
expect(screen.getByText('nodes.iteration.parallelModeUpper')).toBeInTheDocument()
await user.hover(screen.getByText('nodes.iteration.parallelModeUpper'))
expect(await screen.findByText('nodes.iteration.parallelModeEnableTitle')).toBeInTheDocument()
expect(screen.getByText('nodes.iteration.parallelModeEnableDesc')).toBeInTheDocument()
expect(screen.getByText('3/3')).toBeInTheDocument()
})
it('should render failed, exception, success and paused status icons', () => {
const t = ((key: string) => key) as unknown as TFunction
const { rerender } = render(
<NodeHeaderMeta
data={{ type: BlockEnum.Tool, _runningStatus: NodeRunningStatus.Failed } as never}
hasVarValue={false}
isLoading={false}
loopIndex={null}
t={t}
/>,
)
expect(document.querySelector('.i-ri-error-warning-fill')).toBeInTheDocument()
rerender(
<NodeHeaderMeta
data={{ type: BlockEnum.Tool, _runningStatus: NodeRunningStatus.Exception } as never}
hasVarValue={false}
isLoading={false}
loopIndex={null}
t={t}
/>,
)
expect(document.querySelector('.i-ri-alert-fill')).toBeInTheDocument()
rerender(
<NodeHeaderMeta
data={{ type: BlockEnum.Tool, _runningStatus: NodeRunningStatus.Succeeded } as never}
hasVarValue={false}
isLoading={false}
loopIndex={null}
t={t}
/>,
)
expect(document.querySelector('.i-ri-checkbox-circle-fill')).toBeInTheDocument()
rerender(
<NodeHeaderMeta
data={{ type: BlockEnum.Tool, _runningStatus: NodeRunningStatus.Paused } as never}
hasVarValue={false}
isLoading={false}
loopIndex={null}
t={t}
/>,
)
expect(document.querySelector('.i-ri-pause-circle-fill')).toBeInTheDocument()
})
it('should render success icon when inspect vars exist without running status and hide description for loop nodes', () => {
const t = ((key: string) => key) as unknown as TFunction
const { rerender } = render(
<NodeHeaderMeta
data={{ type: BlockEnum.Tool } as never}
hasVarValue
isLoading={false}
loopIndex={null}
t={t}
/>,
)
expect(document.querySelector('.i-ri-checkbox-circle-fill')).toBeInTheDocument()
rerender(<NodeDescription data={{ type: BlockEnum.Loop, desc: 'hidden' } as never} />)
expect(screen.queryByText('hidden')).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,34 @@
import { BlockEnum, NodeRunningStatus } from '@/app/components/workflow/types'
import {
getLoopIndexTextKey,
getNodeStatusBorders,
isContainerNode,
isEntryWorkflowNode,
} from '../node.helpers'
describe('node helpers', () => {
it('should derive node border states from running status and selection state', () => {
expect(getNodeStatusBorders(NodeRunningStatus.Running, false, false).showRunningBorder).toBe(true)
expect(getNodeStatusBorders(NodeRunningStatus.Succeeded, false, false).showSuccessBorder).toBe(true)
expect(getNodeStatusBorders(NodeRunningStatus.Failed, false, false).showFailedBorder).toBe(true)
expect(getNodeStatusBorders(NodeRunningStatus.Exception, false, false).showExceptionBorder).toBe(true)
expect(getNodeStatusBorders(NodeRunningStatus.Succeeded, false, true).showSuccessBorder).toBe(false)
})
it('should expose the correct loop translation key per running status', () => {
expect(getLoopIndexTextKey(NodeRunningStatus.Running)).toBe('nodes.loop.currentLoopCount')
expect(getLoopIndexTextKey(NodeRunningStatus.Succeeded)).toBe('nodes.loop.totalLoopCount')
expect(getLoopIndexTextKey(NodeRunningStatus.Failed)).toBe('nodes.loop.totalLoopCount')
expect(getLoopIndexTextKey(NodeRunningStatus.Paused)).toBeUndefined()
})
it('should identify entry and container nodes', () => {
expect(isEntryWorkflowNode(BlockEnum.Start)).toBe(true)
expect(isEntryWorkflowNode(BlockEnum.TriggerWebhook)).toBe(true)
expect(isEntryWorkflowNode(BlockEnum.Tool)).toBe(false)
expect(isContainerNode(BlockEnum.Iteration)).toBe(true)
expect(isContainerNode(BlockEnum.Loop)).toBe(true)
expect(isContainerNode(BlockEnum.Tool)).toBe(false)
})
})

View File

@ -0,0 +1,229 @@
import type { PropsWithChildren } from 'react'
import type { CommonNodeType } from '@/app/components/workflow/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { renderWorkflowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import { BlockEnum, NodeRunningStatus } from '@/app/components/workflow/types'
import BaseNode from '../node'
const mockHasNodeInspectVars = vi.fn()
const mockUseNodePluginInstallation = vi.fn()
const mockHandleNodeIterationChildSizeChange = vi.fn()
const mockHandleNodeLoopChildSizeChange = vi.fn()
const mockUseNodeResizeObserver = vi.fn()
vi.mock('@/app/components/workflow/hooks', () => ({
useNodesReadOnly: () => ({ nodesReadOnly: false }),
useToolIcon: () => undefined,
}))
vi.mock('@/app/components/workflow/hooks/use-inspect-vars-crud', () => ({
default: () => ({
hasNodeInspectVars: mockHasNodeInspectVars,
}),
}))
vi.mock('@/app/components/workflow/hooks/use-node-plugin-installation', () => ({
useNodePluginInstallation: (...args: unknown[]) => mockUseNodePluginInstallation(...args),
}))
vi.mock('@/app/components/workflow/nodes/iteration/use-interactions', () => ({
useNodeIterationInteractions: () => ({
handleNodeIterationChildSizeChange: mockHandleNodeIterationChildSizeChange,
}),
}))
vi.mock('@/app/components/workflow/nodes/loop/use-interactions', () => ({
useNodeLoopInteractions: () => ({
handleNodeLoopChildSizeChange: mockHandleNodeLoopChildSizeChange,
}),
}))
vi.mock('../use-node-resize-observer', () => ({
default: (options: { enabled: boolean, onResize: () => void }) => {
mockUseNodeResizeObserver(options)
if (options.enabled)
options.onResize()
},
}))
vi.mock('../components/add-variable-popup-with-position', () => ({
default: () => <div data-testid="add-var-popup" />,
}))
vi.mock('../components/entry-node-container', () => ({
__esModule: true,
StartNodeTypeEnum: { Start: 'start', Trigger: 'trigger' },
default: ({ children }: PropsWithChildren) => <div data-testid="entry-node-container">{children}</div>,
}))
vi.mock('../components/error-handle/error-handle-on-node', () => ({
default: () => <div data-testid="error-handle-node" />,
}))
vi.mock('../components/node-control', () => ({
default: () => <div data-testid="node-control" />,
}))
vi.mock('../components/node-handle', () => ({
NodeSourceHandle: () => <div data-testid="node-source-handle" />,
NodeTargetHandle: () => <div data-testid="node-target-handle" />,
}))
vi.mock('../components/node-resizer', () => ({
default: () => <div data-testid="node-resizer" />,
}))
vi.mock('../components/retry/retry-on-node', () => ({
default: () => <div data-testid="retry-node" />,
}))
vi.mock('@/app/components/workflow/block-icon', () => ({
default: () => <div data-testid="block-icon" />,
}))
vi.mock('@/app/components/workflow/nodes/tool/components/copy-id', () => ({
default: ({ content }: { content: string }) => <div>{content}</div>,
}))
const createData = (overrides: Record<string, unknown> = {}) => ({
type: BlockEnum.Tool,
title: 'Node title',
desc: 'Node description',
selected: false,
width: 280,
height: 180,
provider_type: 'builtin',
provider_id: 'tool-1',
_runningStatus: undefined,
_singleRunningStatus: undefined,
...overrides,
})
const toNodeData = (data: ReturnType<typeof createData>) => data as CommonNodeType
describe('BaseNode', () => {
beforeEach(() => {
vi.clearAllMocks()
mockHasNodeInspectVars.mockReturnValue(false)
mockUseNodeResizeObserver.mockReset()
mockUseNodePluginInstallation.mockReturnValue({
shouldDim: false,
isChecking: false,
isMissing: false,
canInstall: false,
uniqueIdentifier: undefined,
})
})
it('should render content, handles and description for a regular node', () => {
renderWorkflowComponent(
<BaseNode id="node-1" data={toNodeData(createData())}>
<div>Body</div>
</BaseNode>,
)
expect(screen.getByText('Node title')).toBeInTheDocument()
expect(screen.getByText('Node description')).toBeInTheDocument()
expect(screen.getByTestId('node-control')).toBeInTheDocument()
expect(screen.getByTestId('node-source-handle')).toBeInTheDocument()
expect(screen.getByTestId('node-target-handle')).toBeInTheDocument()
})
it('should render safely without a workflow provider', () => {
render(
<BaseNode id="node-safe" data={toNodeData(createData())}>
<div>Body</div>
</BaseNode>,
)
expect(screen.getByText('Node title')).toBeInTheDocument()
expect(screen.getByText('Node description')).toBeInTheDocument()
})
it('should render entry nodes inside the entry container', () => {
renderWorkflowComponent(
<BaseNode id="node-1" data={toNodeData(createData({ type: BlockEnum.Start }))}>
<div>Body</div>
</BaseNode>,
)
expect(screen.getByTestId('entry-node-container')).toBeInTheDocument()
})
it('should block interaction when plugin installation is required', () => {
mockUseNodePluginInstallation.mockReturnValue({
shouldDim: false,
isChecking: false,
isMissing: true,
canInstall: true,
uniqueIdentifier: 'plugin-1',
})
renderWorkflowComponent(
<BaseNode id="node-1" data={toNodeData(createData())}>
<div>Body</div>
</BaseNode>,
)
const overlay = screen.getByTestId('workflow-node-install-overlay')
expect(overlay).toBeInTheDocument()
fireEvent.click(overlay)
})
it('should render running status indicators for loop nodes', () => {
renderWorkflowComponent(
<BaseNode
id="node-1"
data={toNodeData(createData({
type: BlockEnum.Loop,
_loopIndex: 3,
_runningStatus: NodeRunningStatus.Running,
width: 320,
height: 220,
}))}
>
<div>Loop body</div>
</BaseNode>,
)
expect(screen.getByText(/workflow\.nodes\.loop\.currentLoopCount/)).toBeInTheDocument()
expect(screen.getByTestId('node-resizer')).toBeInTheDocument()
})
it('should render an iteration node resizer and dimmed overlay', () => {
mockUseNodePluginInstallation.mockReturnValue({
shouldDim: true,
isChecking: false,
isMissing: false,
canInstall: false,
uniqueIdentifier: undefined,
})
renderWorkflowComponent(
<BaseNode
id="node-1"
data={toNodeData(createData({
type: BlockEnum.Iteration,
selected: true,
isInIteration: true,
}))}
>
<div>Iteration body</div>
</BaseNode>,
)
expect(screen.getByTestId('node-resizer')).toBeInTheDocument()
expect(screen.getByTestId('workflow-node-install-overlay')).toBeInTheDocument()
expect(mockHandleNodeIterationChildSizeChange).toHaveBeenCalledWith('node-1')
})
it('should trigger loop resize updates when the selected node is inside a loop', () => {
renderWorkflowComponent(
<BaseNode
id="node-2"
data={toNodeData(createData({
type: BlockEnum.Loop,
selected: true,
isInLoop: true,
}))}
>
<div>Loop body</div>
</BaseNode>,
)
expect(mockHandleNodeLoopChildSizeChange).toHaveBeenCalledWith('node-2')
expect(mockUseNodeResizeObserver).toHaveBeenCalledTimes(2)
})
})

View File

@ -0,0 +1,55 @@
import { renderHook } from '@testing-library/react'
import useNodeResizeObserver from '../use-node-resize-observer'
describe('useNodeResizeObserver', () => {
it('should observe and disconnect when enabled with a mounted node ref', () => {
const observe = vi.fn()
const disconnect = vi.fn()
const onResize = vi.fn()
let resizeCallback: (() => void) | undefined
vi.stubGlobal('ResizeObserver', class {
constructor(callback: () => void) {
resizeCallback = callback
}
observe = observe
disconnect = disconnect
unobserve = vi.fn()
})
const node = document.createElement('div')
const nodeRef = { current: node }
const { unmount } = renderHook(() => useNodeResizeObserver({
enabled: true,
nodeRef,
onResize,
}))
expect(observe).toHaveBeenCalledWith(node)
resizeCallback?.()
expect(onResize).toHaveBeenCalledTimes(1)
unmount()
expect(disconnect).toHaveBeenCalledTimes(1)
})
it('should do nothing when disabled', () => {
const observe = vi.fn()
vi.stubGlobal('ResizeObserver', class {
observe = observe
disconnect = vi.fn()
unobserve = vi.fn()
})
renderHook(() => useNodeResizeObserver({
enabled: false,
nodeRef: { current: document.createElement('div') },
onResize: vi.fn(),
}))
expect(observe).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,413 @@
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 { FeaturesProvider } from '@/app/components/base/features/context'
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 }) => (
<button onClick={() => onSelect('app-1')}>app-selector</button>
),
}))
vi.mock('@/app/components/plugins/plugin-detail-panel/model-selector', () => ({
default: ({ setModel }: { setModel: (value: string) => void }) => (
<button onClick={() => setModel('model-1')}>model-selector</button>
),
}))
vi.mock('@/app/components/workflow/nodes/tool/components/mixed-variable-text-input', () => ({
default: ({ onChange, value }: { onChange: (value: string) => void, value: string }) => (
<input aria-label="mixed-variable-input" value={value} onChange={e => onChange(e.target.value)} />
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
default: ({ onChange, value }: { onChange: (value: string) => void, value: string }) => (
<textarea aria-label="json-editor" value={value} onChange={e => onChange(e.target.value)} />
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({
default: ({ onChange }: { onChange: (value: string[]) => void }) => (
<button onClick={() => onChange(['node-2', 'asset'])}>variable-picker</button>
),
}))
const createSchema = (
overrides: Partial<CredentialFormSchema & {
_type?: FormTypeEnum
multiple?: boolean
options?: FormOption[]
}> = {},
) => ({
label: { en_US: 'Field', zh_Hans: '字段' },
name: 'field',
required: false,
show_on: [],
type: FormTypeEnum.textInput,
variable: 'field',
...overrides,
}) as CredentialFormSchema & {
_type?: FormTypeEnum
multiple?: boolean
options?: FormOption[]
}
const createOption = (
value: string,
overrides: Partial<FormOption> = {},
): FormOption => ({
label: { en_US: value, zh_Hans: value },
show_on: [],
value,
...overrides,
})
const renderFormInputItem = (props: Partial<ComponentProps<typeof FormInputItem>> = {}) => {
const onChange = vi.fn()
const result = renderWorkflowFlowComponent(
<FeaturesProvider>
<FormInputItem
readOnly={false}
nodeId="node-1"
schema={createSchema()}
value={{
field: {
type: VarKindType.constant,
value: '',
},
}}
onChange={onChange}
{...props}
/>
</FeaturesProvider>,
{
edges: [],
hooksStoreProps: {},
nodes: [],
},
)
return { ...result, onChange }
}
describe('FormInputItem branches', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFetchDynamicOptions.mockResolvedValue({ options: [] })
mockTriggerDynamicOptionsState.data = undefined
mockTriggerDynamicOptionsState.isLoading = false
})
it('should update mixed string inputs via the shared text input', () => {
const { onChange } = renderFormInputItem()
fireEvent.change(screen.getByLabelText('mixed-variable-input'), { target: { value: 'hello world' } })
expect(onChange).toHaveBeenCalledWith({
field: {
type: VarKindType.mixed,
value: 'hello world',
},
})
})
it('should switch from variable mode back to constant mode with the schema default value', () => {
const { container, onChange } = renderFormInputItem({
schema: createSchema({
default: 7 as never,
type: FormTypeEnum.textNumber,
}),
value: {
field: {
type: VarKindType.variable,
value: ['node-1', 'count'],
},
},
})
const switchRoot = container.querySelector('.inline-flex.h-8.shrink-0.gap-px')
const clickableItems = switchRoot?.querySelectorAll('.cursor-pointer') ?? []
fireEvent.click(clickableItems[1] as HTMLElement)
expect(onChange).toHaveBeenCalledWith({
field: {
type: VarKindType.constant,
value: 7,
},
})
})
it('should render static select options with icons and update the selected item', () => {
const { onChange } = renderFormInputItem({
schema: createSchema({
type: FormTypeEnum.select,
options: [
createOption('basic', { icon: '/basic.svg' }),
createOption('pro'),
],
}),
value: {
field: {
type: VarKindType.constant,
value: '',
},
},
})
fireEvent.click(screen.getByRole('button'))
expect(document.querySelector('img[src="/basic.svg"]')).toBeInTheDocument()
fireEvent.click(screen.getByText('basic'))
expect(onChange).toHaveBeenCalledWith({
field: {
type: VarKindType.constant,
value: 'basic',
},
})
})
it('should render static multi-select values and update selected labels', () => {
const { onChange } = renderFormInputItem({
schema: createSchema({
multiple: true,
type: FormTypeEnum.select,
options: [
createOption('alpha'),
createOption('beta'),
],
}),
value: {
field: {
type: VarKindType.constant,
value: ['alpha'],
},
},
})
expect(screen.getByText('alpha')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button'))
fireEvent.click(screen.getByText('beta'))
expect(onChange).toHaveBeenCalledWith({
field: {
type: VarKindType.constant,
value: ['alpha', 'beta'],
},
})
})
it('should fetch tool dynamic options, render them, and update the value', async () => {
mockFetchDynamicOptions.mockResolvedValueOnce({
options: [
createOption('remote', { icon: '/remote.svg' }),
],
})
const { onChange } = renderFormInputItem({
schema: createSchema({
type: FormTypeEnum.dynamicSelect,
}),
currentProvider: { plugin_id: 'provider-1', name: 'provider-1' } as never,
currentTool: { name: 'tool-1' } as never,
providerType: PluginCategoryEnum.tool,
value: {
field: {
type: VarKindType.constant,
value: '',
},
},
})
await waitFor(() => {
expect(mockFetchDynamicOptions).toHaveBeenCalledTimes(1)
})
fireEvent.click(screen.getByRole('button'))
expect(document.querySelector('img[src="/remote.svg"]')).toBeInTheDocument()
fireEvent.click(screen.getByText('remote'))
expect(onChange).toHaveBeenCalledWith({
field: {
type: VarKindType.constant,
value: 'remote',
},
})
})
it('should recover when fetching dynamic tool options fails', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
mockFetchDynamicOptions.mockRejectedValueOnce(new Error('network'))
renderFormInputItem({
schema: createSchema({
type: FormTypeEnum.dynamicSelect,
}),
currentProvider: { plugin_id: 'provider-1', name: 'provider-1' } as never,
currentTool: { name: 'tool-1' } as never,
providerType: PluginCategoryEnum.tool,
})
await waitFor(() => {
expect(consoleSpy).toHaveBeenCalled()
})
consoleSpy.mockRestore()
})
it('should use trigger dynamic options for multi-select values', async () => {
mockTriggerDynamicOptionsState.data = {
options: [
createOption('trigger-option'),
],
}
const { onChange } = renderFormInputItem({
schema: createSchema({
multiple: true,
type: FormTypeEnum.dynamicSelect,
}),
currentProvider: { plugin_id: 'provider-2', name: 'provider-2', credential_id: 'credential-1' } as never,
currentTool: { name: 'trigger-tool' } as never,
providerType: PluginCategoryEnum.trigger,
value: {
field: {
type: VarKindType.constant,
value: [],
},
},
})
await waitFor(() => {
expect(screen.getByRole('button')).not.toBeDisabled()
})
fireEvent.click(screen.getByRole('button'))
fireEvent.click(screen.getByText('trigger-option'))
expect(onChange).toHaveBeenCalledWith({
field: {
type: VarKindType.constant,
value: ['trigger-option'],
},
})
})
it('should delegate app and model selection to their dedicated controls', () => {
const app = renderFormInputItem({
schema: createSchema({ type: FormTypeEnum.appSelector }),
})
fireEvent.click(screen.getByText('app-selector'))
expect(app.onChange).toHaveBeenCalledWith({
field: {
type: VarKindType.constant,
value: 'app-1',
},
})
app.unmount()
const model = renderFormInputItem({
schema: createSchema({ type: FormTypeEnum.modelSelector }),
})
fireEvent.click(screen.getByText('model-selector'))
expect(model.onChange).toHaveBeenCalledWith({
field: {
type: VarKindType.constant,
value: 'model-1',
},
})
})
it('should render the JSON editor and variable picker specialized branches', () => {
const json = renderFormInputItem({
schema: createSchema({ type: FormTypeEnum.object }),
value: {
field: {
type: VarKindType.constant,
value: '{"enabled":false}',
},
},
})
fireEvent.change(screen.getByLabelText('json-editor'), { target: { value: '{"enabled":true}' } })
expect(json.onChange).toHaveBeenCalledWith({
field: {
type: VarKindType.constant,
value: '{"enabled":true}',
},
})
json.unmount()
const picker = renderFormInputItem({
schema: createSchema({ type: FormTypeEnum.file }),
value: {
field: {
type: VarKindType.constant,
value: '',
},
},
})
fireEvent.click(screen.getByText('variable-picker'))
expect(picker.onChange).toHaveBeenCalledWith({
field: {
type: VarKindType.variable,
value: ['node-2', 'asset'],
},
})
})
it('should render variable selectors for boolean variable inputs', () => {
const { onChange } = renderFormInputItem({
schema: createSchema({
_type: FormTypeEnum.boolean,
type: FormTypeEnum.textInput,
}),
value: {
field: {
type: VarKindType.variable,
value: ['node-3', 'flag'],
},
},
})
fireEvent.click(screen.getByText('variable-picker'))
expect(onChange).toHaveBeenCalledWith({
field: {
type: VarKindType.variable,
value: ['node-2', 'asset'],
},
})
})
})

View File

@ -0,0 +1,166 @@
import type { CredentialFormSchema, FormOption } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { Var } from '@/app/components/workflow/types'
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { VarType } from '@/app/components/workflow/types'
import { VarKindType } from '../../types'
import {
filterVisibleOptions,
getCheckboxListOptions,
getCheckboxListValue,
getFilterVar,
getFormInputState,
getNumberInputValue,
getSelectedLabels,
getTargetVarType,
getVarKindType,
hasOptionIcon,
mapSelectItems,
normalizeVariableSelectorValue,
} from '../form-input-item.helpers'
const createSchema = (
overrides: Partial<CredentialFormSchema & {
_type?: FormTypeEnum
multiple?: boolean
options?: FormOption[]
}> = {},
) => ({
label: { en_US: 'Field', zh_Hans: '字段' },
name: 'field',
required: false,
show_on: [],
type: FormTypeEnum.textInput,
variable: 'field',
...overrides,
}) as CredentialFormSchema & {
_type?: FormTypeEnum
multiple?: boolean
options?: FormOption[]
}
const createOption = (
value: string,
overrides: Partial<FormOption> = {},
): FormOption => ({
label: { en_US: value, zh_Hans: value },
show_on: [],
value,
...overrides,
})
describe('form-input-item helpers', () => {
it('should derive field state and target var type', () => {
const numberState = getFormInputState(
createSchema({ type: FormTypeEnum.textNumber }),
{ type: VarKindType.constant, value: 1 },
)
const filesState = getFormInputState(
createSchema({ type: FormTypeEnum.files }),
{ type: VarKindType.variable, value: ['node', 'files'] },
)
expect(numberState.isNumber).toBe(true)
expect(numberState.showTypeSwitch).toBe(true)
expect(getTargetVarType(numberState)).toBe(VarType.number)
expect(filesState.isFile).toBe(true)
expect(filesState.showVariableSelector).toBe(true)
expect(getTargetVarType(filesState)).toBe(VarType.arrayFile)
})
it('should return filter functions and var kind types by schema mode', () => {
const stringFilter = getFilterVar(getFormInputState(createSchema(), { type: VarKindType.mixed, value: '' }))
const booleanState = getFormInputState(
createSchema({ _type: FormTypeEnum.boolean, type: FormTypeEnum.textInput }),
{ type: VarKindType.constant, value: true },
)
expect(stringFilter?.({ type: VarType.secret } as Var)).toBe(true)
expect(stringFilter?.({ type: VarType.file } as Var)).toBe(false)
expect(getVarKindType(booleanState)).toBe(VarKindType.constant)
expect(getFilterVar(booleanState)?.({ type: VarType.boolean } as Var)).toBe(false)
const fileState = getFormInputState(
createSchema({ type: FormTypeEnum.file }),
{ type: VarKindType.variable, value: ['node', 'file'] },
)
const objectState = getFormInputState(
createSchema({ type: FormTypeEnum.object }),
{ type: VarKindType.constant, value: '{}' },
)
const arrayState = getFormInputState(
createSchema({ type: FormTypeEnum.array }),
{ type: VarKindType.constant, value: '[]' },
)
const dynamicState = getFormInputState(
createSchema({ type: FormTypeEnum.dynamicSelect }),
{ type: VarKindType.constant, value: 'selected' },
)
expect(getFilterVar(fileState)?.({ type: VarType.file } as Var)).toBe(true)
expect(getFilterVar(objectState)?.({ type: VarType.object } as Var)).toBe(true)
expect(getFilterVar(arrayState)?.({ type: VarType.arrayString } as Var)).toBe(true)
expect(getVarKindType(fileState)).toBe(VarKindType.variable)
expect(getVarKindType(dynamicState)).toBe(VarKindType.constant)
expect(getVarKindType(getFormInputState(createSchema({ type: FormTypeEnum.appSelector }), undefined))).toBeUndefined()
})
it('should filter and map visible options using show_on rules', () => {
const options = [
createOption('always'),
createOption('premium', {
show_on: [{ variable: 'mode', value: 'pro' }],
}),
]
const values = {
mode: {
type: VarKindType.constant,
value: 'pro',
},
}
const visibleOptions = filterVisibleOptions(options, values)
expect(visibleOptions).toHaveLength(2)
expect(mapSelectItems(visibleOptions, 'en_US')).toEqual([
{ name: 'always', value: 'always' },
{ name: 'premium', value: 'premium' },
])
expect(hasOptionIcon(visibleOptions)).toBe(false)
})
it('should compute selected labels and checkbox state from visible options', () => {
const options = [
createOption('alpha'),
createOption('beta'),
createOption('gamma'),
]
expect(getSelectedLabels(['alpha', 'beta'], options, 'en_US')).toBe('alpha, beta')
expect(getSelectedLabels(['alpha', 'beta', 'gamma'], options, 'en_US')).toBe('3 selected')
expect(getCheckboxListOptions(options, 'en_US')).toEqual([
{ label: 'alpha', value: 'alpha' },
{ label: 'beta', value: 'beta' },
{ label: 'gamma', value: 'gamma' },
])
expect(getCheckboxListValue(['alpha', 'missing'], ['beta'], options)).toEqual(['alpha'])
})
it('should normalize number and variable selector values', () => {
expect(getNumberInputValue(Number.NaN)).toBe('')
expect(getNumberInputValue(2)).toBe(2)
expect(getNumberInputValue('3')).toBe('3')
expect(getNumberInputValue(undefined)).toBe('')
expect(normalizeVariableSelectorValue([])).toEqual([])
expect(normalizeVariableSelectorValue(['node', 'answer'])).toEqual(['node', 'answer'])
expect(normalizeVariableSelectorValue('')).toBe('')
})
it('should derive remaining target variable types and label states', () => {
const objectState = getFormInputState(createSchema({ type: FormTypeEnum.object }), undefined)
const arrayState = getFormInputState(createSchema({ type: FormTypeEnum.array }), undefined)
expect(getTargetVarType(objectState)).toBe(VarType.object)
expect(getTargetVarType(arrayState)).toBe(VarType.arrayObject)
expect(getSelectedLabels(undefined, [], 'en_US')).toBe('')
expect(getCheckboxListValue('alpha', [], [createOption('alpha')])).toEqual(['alpha'])
})
})

View File

@ -0,0 +1,60 @@
import { fireEvent, screen } from '@testing-library/react'
import { renderWorkflowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import {
JsonEditorField,
MultiSelectField,
} from '../form-input-item.sections'
describe('form-input-item sections', () => {
it('should render a loading multi-select label', () => {
renderWorkflowComponent(
<MultiSelectField
disabled={false}
isLoading
items={[{ name: 'Alpha', value: 'alpha' }]}
onChange={vi.fn()}
selectedLabel=""
value={[]}
/>,
)
expect(screen.getByText('Loading...')).toBeInTheDocument()
})
it('should render the shared json editor section', () => {
renderWorkflowComponent(
<JsonEditorField
value={'{"enabled":true}'}
onChange={vi.fn()}
placeholder={<div>JSON placeholder</div>}
/>,
)
expect(screen.getByText('JSON')).toBeInTheDocument()
})
it('should render placeholder, icons, and select multi-select options', () => {
const onChange = vi.fn()
renderWorkflowComponent(
<MultiSelectField
disabled={false}
items={[
{ name: 'Alpha', value: 'alpha', icon: '/alpha.svg' },
{ name: 'Beta', value: 'beta' },
]}
onChange={onChange}
placeholder="Choose options"
selectedLabel=""
value={[]}
/>,
)
expect(screen.getByText('Choose options')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button'))
fireEvent.click(screen.getByText('Alpha'))
expect(document.querySelector('img[src="/alpha.svg"]')).toBeInTheDocument()
expect(onChange).toHaveBeenCalled()
})
})

View File

@ -0,0 +1,148 @@
import type { ComponentProps } from 'react'
import type { CredentialFormSchema, FormOption } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { fireEvent, screen } from '@testing-library/react'
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import { VarKindType } from '../../types'
import FormInputItem from '../form-input-item'
const createSchema = (
overrides: Partial<CredentialFormSchema & {
_type?: FormTypeEnum
multiple?: boolean
options?: FormOption[]
}> = {},
) => ({
label: { en_US: 'Field', zh_Hans: '字段' },
name: 'field',
required: false,
show_on: [],
type: FormTypeEnum.textInput,
variable: 'field',
...overrides,
}) as CredentialFormSchema & {
_type?: FormTypeEnum
multiple?: boolean
options?: FormOption[]
}
const createOption = (
value: string,
overrides: Partial<FormOption> = {},
): FormOption => ({
label: { en_US: value, zh_Hans: value },
show_on: [],
value,
...overrides,
})
const renderFormInputItem = (props: Partial<ComponentProps<typeof FormInputItem>> = {}) => {
const onChange = vi.fn()
renderWorkflowFlowComponent(
<FormInputItem
readOnly={false}
nodeId="node-1"
schema={createSchema()}
value={{
field: {
type: VarKindType.constant,
value: '',
},
}}
onChange={onChange}
{...props}
/>,
{
edges: [],
hooksStoreProps: {},
nodes: [],
},
)
return { onChange }
}
describe('FormInputItem', () => {
it('should parse number inputs as numbers', () => {
const { onChange } = renderFormInputItem({
schema: createSchema({ type: FormTypeEnum.textNumber }),
value: {
field: {
type: VarKindType.constant,
value: 1,
},
},
})
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '3.5' } })
expect(onChange).toHaveBeenCalledWith({
field: {
type: VarKindType.constant,
value: 3.5,
},
})
})
it('should toggle boolean fields using the shared boolean input', () => {
const { onChange } = renderFormInputItem({
schema: createSchema({
_type: FormTypeEnum.boolean,
type: FormTypeEnum.textInput,
}),
value: {
field: {
type: VarKindType.constant,
value: true,
},
},
})
fireEvent.click(screen.getByText('False'))
expect(onChange).toHaveBeenCalledWith({
field: {
type: VarKindType.constant,
value: false,
},
})
})
it('should filter checkbox options by show_on and update selected values', () => {
const { onChange } = renderFormInputItem({
schema: createSchema({
_type: FormTypeEnum.checkbox,
options: [
createOption('basic'),
createOption('pro', {
show_on: [{ variable: 'mode', value: 'pro' }],
}),
],
type: FormTypeEnum.textInput,
}),
value: {
field: {
type: VarKindType.constant,
value: ['basic'],
},
mode: {
type: VarKindType.constant,
value: 'pro',
},
},
})
fireEvent.click(screen.getByText('pro'))
expect(onChange).toHaveBeenCalledWith({
field: {
type: VarKindType.constant,
value: ['basic', 'pro'],
},
mode: {
type: VarKindType.constant,
value: 'pro',
},
})
})
})

View File

@ -0,0 +1,115 @@
import type { InputVar } from '@/app/components/workflow/types'
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
import {
buildSubmitData,
formatValue,
getFormErrorMessage,
isFilesLoaded,
shouldAutoRunBeforeRunForm,
shouldAutoShowGeneratedForm,
} from '../helpers'
type FormArg = Parameters<typeof buildSubmitData>[0][number]
describe('before-run-form helpers', () => {
const createValues = (values: Record<string, unknown>) => values as unknown as Record<string, string>
const createInput = (input: Partial<InputVar>): InputVar => ({
variable: 'field',
label: 'Field',
type: InputVarType.textInput,
required: false,
...input,
})
const createForm = (form: Partial<FormArg>): FormArg => ({
inputs: [],
values: createValues({}),
onChange: vi.fn(),
...form,
} as FormArg)
it('should format values by input type', () => {
expect(formatValue('12.5', InputVarType.number)).toBe(12.5)
expect(formatValue('{"foo":1}', InputVarType.json)).toEqual({ foo: 1 })
expect(formatValue('', InputVarType.checkbox)).toBe(false)
expect(formatValue(['{"foo":1}'], InputVarType.contexts)).toEqual([{ foo: 1 }])
expect(formatValue(null, InputVarType.singleFile)).toBeNull()
expect(formatValue([{ transfer_method: TransferMethod.remote_url, related_id: '3' }], InputVarType.singleFile)).toEqual(expect.any(Array))
expect(formatValue('', InputVarType.singleFile)).toBeUndefined()
})
it('should detect when file uploads are still in progress', () => {
expect(isFilesLoaded([])).toBe(true)
expect(isFilesLoaded([createForm({ inputs: [], values: {} })])).toBe(true)
expect(isFilesLoaded([createForm({
inputs: [],
values: createValues({
'#files#': [{ transfer_method: TransferMethod.local_file }],
}),
})])).toBe(false)
})
it('should report required and uploading file errors', () => {
const t = (key: string, options?: Record<string, unknown>) => `${key}:${options?.field ?? ''}`
expect(getFormErrorMessage([createForm({
inputs: [createInput({ variable: 'query', label: 'Query', required: true })],
values: createValues({ query: '' }),
})], [{}], t)).toContain('errorMsg.fieldRequired')
expect(getFormErrorMessage([createForm({
inputs: [createInput({ variable: 'file', label: 'File', type: InputVarType.singleFile })],
values: createValues({ file: { transferMethod: TransferMethod.local_file } }),
})], [{}], t)).toContain('errorMessage.waitForFileUpload')
expect(getFormErrorMessage([createForm({
inputs: [createInput({ variable: 'files', label: 'Files', type: InputVarType.multiFiles })],
values: createValues({ files: [{ transferMethod: TransferMethod.local_file }] }),
})], [{}], t)).toContain('errorMessage.waitForFileUpload')
expect(getFormErrorMessage([createForm({
inputs: [createInput({
variable: 'config',
label: { nodeType: BlockEnum.Tool, nodeName: 'Tool', variable: 'Config' },
required: true,
})],
values: createValues({ config: '' }),
})], [{}], t)).toContain('Config')
})
it('should build submit data and keep parse errors', () => {
expect(buildSubmitData([createForm({
inputs: [createInput({ variable: 'query' })],
values: createValues({ query: 'hello' }),
})])).toEqual({
submitData: { query: 'hello' },
parseErrorJsonField: '',
})
expect(buildSubmitData([createForm({
inputs: [createInput({ variable: 'payload', type: InputVarType.json })],
values: createValues({ payload: '{' }),
})]).parseErrorJsonField).toBe('payload')
expect(buildSubmitData([createForm({
inputs: [
createInput({ variable: 'files', type: InputVarType.multiFiles }),
createInput({ variable: 'file', type: InputVarType.singleFile }),
],
values: createValues({
files: [{ transfer_method: TransferMethod.remote_url, related_id: '1' }],
file: { transfer_method: TransferMethod.remote_url, related_id: '2' },
}),
})]).submitData).toEqual(expect.objectContaining({
files: expect.any(Array),
file: expect.any(Object),
}))
})
it('should derive the zero-form auto behaviors', () => {
expect(shouldAutoRunBeforeRunForm([], false)).toBe(true)
expect(shouldAutoRunBeforeRunForm([], true)).toBe(false)
expect(shouldAutoShowGeneratedForm([], true)).toBe(true)
expect(shouldAutoShowGeneratedForm([createForm({})], true)).toBe(false)
})
})

View File

@ -0,0 +1,226 @@
import type { Props as FormProps } from '../form'
import type { BeforeRunFormProps } from '../index'
import { fireEvent, render, screen } from '@testing-library/react'
import { toast } from '@/app/components/base/ui/toast'
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
import BeforeRunForm from '../index'
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
error: vi.fn(),
},
}))
vi.mock('../form', () => ({
default: ({ values }: { values: Record<string, unknown> }) => <div>{Object.keys(values).join(',')}</div>,
}))
vi.mock('../panel-wrap', () => ({
default: ({ children, nodeName }: { children: React.ReactNode, nodeName: string }) => (
<div>
<div>{nodeName}</div>
{children}
</div>
),
}))
vi.mock('@/app/components/workflow/nodes/human-input/components/single-run-form', () => ({
default: ({ onSubmit, handleBack }: { onSubmit: (data: Record<string, unknown>) => void, handleBack?: () => void }) => (
<div>
<div>single-run-form</div>
<button onClick={() => onSubmit({ approved: true })}>submit-generated-form</button>
<button onClick={handleBack}>back-generated-form</button>
</div>
),
}))
describe('BeforeRunForm', () => {
const mockToastError = vi.mocked(toast.error)
const createForm = (form: Partial<FormProps>): FormProps => ({
inputs: [],
values: {},
onChange: vi.fn(),
...form,
})
const createProps = (props: Partial<BeforeRunFormProps>): BeforeRunFormProps => ({
nodeName: 'Tool',
onHide: vi.fn(),
onRun: vi.fn(),
onStop: vi.fn(),
runningStatus: 'idle' as BeforeRunFormProps['runningStatus'],
forms: [],
filteredExistVarForms: [],
existVarValuesInForms: [],
...props,
})
beforeEach(() => {
vi.clearAllMocks()
})
it('should auto run and render nothing when there are no filtered forms', () => {
const onRun = vi.fn()
const { container } = render(
<BeforeRunForm
{...createProps({
onRun,
})}
/>,
)
expect(onRun).toHaveBeenCalledWith({})
expect(container).toBeEmptyDOMElement()
})
it('should show an error toast when required fields are missing', () => {
render(
<BeforeRunForm
{...createProps({
forms: [createForm({
inputs: [{ variable: 'query', label: 'Query', type: InputVarType.textInput, required: true }],
values: { query: '' },
})],
filteredExistVarForms: [createForm({
inputs: [{ variable: 'query', label: 'Query', type: InputVarType.textInput, required: true }],
values: { query: '' },
})],
existVarValuesInForms: [{}],
})}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'workflow.singleRun.startRun' }))
expect(mockToastError).toHaveBeenCalled()
})
it('should generate the human input form instead of running immediately', () => {
const handleShowGeneratedForm = vi.fn()
render(
<BeforeRunForm
{...createProps({
nodeName: 'Human input',
nodeType: BlockEnum.HumanInput,
forms: [createForm({
inputs: [{ variable: 'query', label: 'Query', type: InputVarType.textInput, required: true }],
values: { query: 'hello' },
})],
filteredExistVarForms: [createForm({
inputs: [{ variable: 'query', label: 'Query', type: InputVarType.textInput, required: true }],
values: { query: 'hello' },
})],
existVarValuesInForms: [{}],
handleShowGeneratedForm,
})}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'workflow.nodes.humanInput.singleRun.button' }))
expect(handleShowGeneratedForm).toHaveBeenCalledWith({ query: 'hello' })
})
it('should render the generated human input form and submit it', async () => {
const handleSubmitHumanInputForm = vi.fn().mockResolvedValue(undefined)
const handleAfterHumanInputStepRun = vi.fn()
const handleHideGeneratedForm = vi.fn()
render(
<BeforeRunForm
{...createProps({
nodeName: 'Human input',
nodeType: BlockEnum.HumanInput,
forms: [createForm({
inputs: [{ variable: 'query', label: 'Query', type: InputVarType.textInput, required: true }],
values: { query: 'hello' },
})],
filteredExistVarForms: [createForm({
inputs: [{ variable: 'query', label: 'Query', type: InputVarType.textInput, required: true }],
values: { query: 'hello' },
})],
existVarValuesInForms: [{}],
showGeneratedForm: true,
formData: {} as BeforeRunFormProps['formData'],
handleSubmitHumanInputForm,
handleAfterHumanInputStepRun,
handleHideGeneratedForm,
})}
/>,
)
expect(screen.getByText('single-run-form')).toBeInTheDocument()
fireEvent.click(screen.getByText('submit-generated-form'))
await Promise.resolve()
expect(handleSubmitHumanInputForm).toHaveBeenCalledWith({ approved: true })
expect(handleAfterHumanInputStepRun).toHaveBeenCalledTimes(1)
fireEvent.click(screen.getByText('back-generated-form'))
expect(handleHideGeneratedForm).toHaveBeenCalledTimes(1)
})
it('should run immediately when the form is valid', () => {
const onRun = vi.fn()
render(
<BeforeRunForm
{...createProps({
onRun,
forms: [createForm({
inputs: [{ variable: 'query', label: 'Query', type: InputVarType.textInput, required: true }],
values: { query: 'hello' },
})],
filteredExistVarForms: [createForm({
inputs: [{ variable: 'query', label: 'Query', type: InputVarType.textInput, required: true }],
values: { query: 'hello' },
})],
existVarValuesInForms: [{}],
})}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'workflow.singleRun.startRun' }))
expect(onRun).toHaveBeenCalledWith({ query: 'hello' })
})
it('should auto show the generated form when human input has no filtered vars', () => {
const handleShowGeneratedForm = vi.fn()
render(
<BeforeRunForm
{...createProps({
nodeName: 'Human input',
nodeType: BlockEnum.HumanInput,
handleShowGeneratedForm,
})}
/>,
)
expect(handleShowGeneratedForm).toHaveBeenCalledWith({})
expect(screen.getByRole('button', { name: 'workflow.nodes.humanInput.singleRun.button' })).toBeInTheDocument()
})
it('should show an error toast when json input is invalid', () => {
render(
<BeforeRunForm
{...createProps({
forms: [createForm({
inputs: [{ variable: 'payload', label: 'Payload', type: InputVarType.json, required: true }],
values: { payload: '{' },
})],
filteredExistVarForms: [createForm({
inputs: [{ variable: 'payload', label: 'Payload', type: InputVarType.json, required: true }],
values: { payload: '{' },
})],
existVarValuesInForms: [{}],
})}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'workflow.singleRun.startRun' }))
expect(mockToastError).toHaveBeenCalled()
})
})

View File

@ -0,0 +1,105 @@
import type { Props as FormProps } from './form'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import { getProcessedFiles } from '@/app/components/base/file-uploader/utils'
import { InputVarType } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
export function formatValue(value: unknown, type: InputVarType) {
if (type === InputVarType.checkbox)
return !!value
if (value === undefined || value === null)
return value
if (type === InputVarType.number)
return Number.parseFloat(String(value))
if (type === InputVarType.json)
return JSON.parse(String(value))
if (type === InputVarType.contexts)
return (value as string[]).map(item => JSON.parse(item))
if (type === InputVarType.multiFiles)
return getProcessedFiles(value as FileEntity[])
if (type === InputVarType.singleFile) {
if (Array.isArray(value))
return getProcessedFiles(value as FileEntity[])
if (!value)
return undefined
return getProcessedFiles([value as FileEntity])[0]
}
return value
}
export const isFilesLoaded = (forms: FormProps[]) => {
if (!forms.length)
return true
const filesForm = forms.find(item => !!item.values['#files#'])
if (!filesForm)
return true
const files = filesForm.values['#files#'] as unknown as Array<{ transfer_method?: TransferMethod, upload_file_id?: string }> | undefined
return !files?.some(item => item.transfer_method === TransferMethod.local_file && !item.upload_file_id)
}
export const getFormErrorMessage = (
forms: FormProps[],
existVarValuesInForms: Record<string, unknown>[],
t: (key: string, options?: Record<string, unknown>) => string,
) => {
let errMsg = ''
forms.forEach((form, index) => {
const existVarValuesInForm = existVarValuesInForms[index]
form.inputs.forEach((input) => {
const value = form.values[input.variable] as unknown
const missingRequired = input.required
&& input.type !== InputVarType.checkbox
&& !(input.variable in existVarValuesInForm)
&& (value === '' || value === undefined || value === null || (input.type === InputVarType.files && Array.isArray(value) && value.length === 0))
if (!errMsg && missingRequired) {
errMsg = t('errorMsg.fieldRequired', { ns: 'workflow', field: typeof input.label === 'object' ? input.label.variable : input.label })
return
}
if (!errMsg && (input.type === InputVarType.singleFile || input.type === InputVarType.multiFiles) && value) {
const fileIsUploading = Array.isArray(value)
? value.find((item: { transferMethod?: TransferMethod, uploadedId?: string }) => item.transferMethod === TransferMethod.local_file && !item.uploadedId)
: (value as { transferMethod?: TransferMethod, uploadedId?: string }).transferMethod === TransferMethod.local_file
&& !(value as { transferMethod?: TransferMethod, uploadedId?: string }).uploadedId
if (fileIsUploading)
errMsg = t('errorMessage.waitForFileUpload', { ns: 'appDebug' })
}
})
})
return errMsg
}
export const buildSubmitData = (forms: FormProps[]) => {
const submitData: Record<string, unknown> = {}
let parseErrorJsonField = ''
forms.forEach((form) => {
form.inputs.forEach((input) => {
try {
submitData[input.variable] = formatValue(form.values[input.variable], input.type)
}
catch {
parseErrorJsonField = input.variable
}
})
})
return { submitData, parseErrorJsonField }
}
export const shouldAutoRunBeforeRunForm = (filteredExistVarForms: FormProps[], isHumanInput: boolean) => {
return filteredExistVarForms.length === 0 && !isHumanInput
}
export const shouldAutoShowGeneratedForm = (filteredExistVarForms: FormProps[], isHumanInput: boolean) => {
return filteredExistVarForms.length === 0 && isHumanInput
}

View File

@ -9,14 +9,19 @@ import * as React from 'react'
import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import { getProcessedFiles } from '@/app/components/base/file-uploader/utils'
import { toast } from '@/app/components/base/ui/toast'
import Split from '@/app/components/workflow/nodes/_base/components/split'
import SingleRunForm from '@/app/components/workflow/nodes/human-input/components/single-run-form'
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
import { BlockEnum } from '@/app/components/workflow/types'
import { cn } from '@/utils/classnames'
import Form from './form'
import {
buildSubmitData,
getFormErrorMessage,
isFilesLoaded,
shouldAutoRunBeforeRunForm,
shouldAutoShowGeneratedForm,
} from './helpers'
import PanelWrap from './panel-wrap'
const i18nPrefix = 'singleRun'
@ -41,33 +46,6 @@ export type BeforeRunFormProps = {
handleAfterHumanInputStepRun?: () => void
} & Partial<SpecialResultPanelProps>
function formatValue(value: string | any, type: InputVarType) {
if (type === InputVarType.checkbox)
return !!value
if (value === undefined || value === null)
return value
if (type === InputVarType.number)
return Number.parseFloat(value)
if (type === InputVarType.json)
return JSON.parse(value)
if (type === InputVarType.contexts) {
return value.map((item: any) => {
return JSON.parse(item)
})
}
if (type === InputVarType.multiFiles)
return getProcessedFiles(value)
if (type === InputVarType.singleFile) {
if (Array.isArray(value))
return getProcessedFiles(value)
if (!value)
return undefined
return getProcessedFiles([value])[0]
}
return value
}
const BeforeRunForm: FC<BeforeRunFormProps> = ({
nodeName,
nodeType,
@ -88,61 +66,16 @@ const BeforeRunForm: FC<BeforeRunFormProps> = ({
const isHumanInput = nodeType === BlockEnum.HumanInput
const showBackButton = filteredExistVarForms.length > 0
const isFileLoaded = (() => {
if (!forms || forms.length === 0)
return true
// system files
const filesForm = forms.find(item => !!item.values['#files#'])
if (!filesForm)
return true
const files = filesForm.values['#files#'] as any
if (files?.some((item: any) => item.transfer_method === TransferMethod.local_file && !item.upload_file_id))
return false
return true
})()
const isFileLoaded = isFilesLoaded(forms)
const handleRunOrGenerateForm = () => {
let errMsg = ''
forms.forEach((form, i) => {
const existVarValuesInForm = existVarValuesInForms[i]
form.inputs.forEach((input) => {
const value = form.values[input.variable] as any
if (!errMsg && input.required && (input.type !== InputVarType.checkbox) && !(input.variable in existVarValuesInForm) && (value === '' || value === undefined || value === null || (input.type === InputVarType.files && value.length === 0)))
errMsg = t('errorMsg.fieldRequired', { ns: 'workflow', field: typeof input.label === 'object' ? input.label.variable : input.label })
if (!errMsg && (input.type === InputVarType.singleFile || input.type === InputVarType.multiFiles) && value) {
let fileIsUploading = false
if (Array.isArray(value))
fileIsUploading = value.find(item => item.transferMethod === TransferMethod.local_file && !item.uploadedId)
else
fileIsUploading = value.transferMethod === TransferMethod.local_file && !value.uploadedId
if (fileIsUploading)
errMsg = t('errorMessage.waitForFileUpload', { ns: 'appDebug' })
}
})
})
const errMsg = getFormErrorMessage(forms, existVarValuesInForms, t)
if (errMsg) {
toast.error(errMsg)
return
}
const submitData: Record<string, any> = {}
let parseErrorJsonField = ''
forms.forEach((form) => {
form.inputs.forEach((input) => {
try {
const value = formatValue(form.values[input.variable], input.type)
submitData[input.variable] = value
}
catch {
parseErrorJsonField = input.variable
}
})
})
const { submitData, parseErrorJsonField } = buildSubmitData(forms)
if (parseErrorJsonField) {
toast.error(t('errorMsg.invalidJson', { ns: 'workflow', field: parseErrorJsonField }))
return
@ -165,13 +98,13 @@ const BeforeRunForm: FC<BeforeRunFormProps> = ({
if (hasRun.current)
return
hasRun.current = true
if (filteredExistVarForms.length === 0 && !isHumanInput)
if (shouldAutoRunBeforeRunForm(filteredExistVarForms, isHumanInput))
onRun({})
if (filteredExistVarForms.length === 0 && isHumanInput)
if (shouldAutoShowGeneratedForm(filteredExistVarForms, isHumanInput))
handleShowGeneratedForm?.({})
}, [filteredExistVarForms, handleShowGeneratedForm, isHumanInput, onRun])
if (filteredExistVarForms.length === 0 && !isHumanInput)
if (shouldAutoRunBeforeRunForm(filteredExistVarForms, isHumanInput))
return null
return (

View File

@ -0,0 +1,259 @@
'use client'
import type { ResourceVarInputs } from '../types'
import type {
CredentialFormSchema,
FormOption,
TypeWithI18N,
} from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { ValueSelector, Var } from '@/app/components/workflow/types'
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { VarType } from '@/app/components/workflow/types'
import { VarKindType } from '../types'
type FormInputSchema = CredentialFormSchema & Partial<{
_type: FormTypeEnum
multiple: boolean
options: FormOption[]
placeholder: TypeWithI18N
scope: string
}>
type FormInputValue = ResourceVarInputs[string] | undefined
type ShowOnCondition = {
value: unknown
variable: string
}
type OptionLabel = string | TypeWithI18N
type SelectableOption = {
icon?: string
label: OptionLabel
show_on?: ShowOnCondition[]
value: string
}
export type SelectItem = {
icon?: string
name: string
value: string
}
export type FormInputState = {
defaultValue: unknown
isAppSelector: boolean
isArray: boolean
isBoolean: boolean
isCheckbox: boolean
isConstant: boolean
isDynamicSelect: boolean
isFile: boolean
isFiles: boolean
isModelSelector: boolean
isMultipleSelect: boolean
isNumber: boolean
isObject: boolean
isSelect: boolean
isShowJSONEditor: boolean
isString: boolean
options: FormOption[]
placeholder?: TypeWithI18N
scope?: string
showVariableSelector: boolean
showTypeSwitch: boolean
variable: string
}
const optionMatchesValue = (
values: ResourceVarInputs,
showOnItem: ShowOnCondition,
) => values[showOnItem.variable]?.value === showOnItem.value || values[showOnItem.variable] === showOnItem.value
const getOptionLabel = (option: SelectableOption, language: string) => {
if (typeof option.label === 'string')
return option.label
return option.label[language] || option.label.en_US || option.value
}
export const getFormInputState = (
schema: FormInputSchema,
varInput: FormInputValue,
): FormInputState => {
const {
default: defaultValue,
multiple = false,
options = [],
placeholder,
scope,
type,
variable,
_type,
} = schema
const isString = type === FormTypeEnum.textInput || type === FormTypeEnum.secretInput
const isNumber = type === FormTypeEnum.textNumber
const isObject = type === FormTypeEnum.object
const isArray = type === FormTypeEnum.array
const isShowJSONEditor = isObject || isArray
const isFile = type === FormTypeEnum.file || type === FormTypeEnum.files
const isFiles = type === FormTypeEnum.files
const isBoolean = _type === FormTypeEnum.boolean
const isCheckbox = _type === FormTypeEnum.checkbox
const isSelect = type === FormTypeEnum.select
const isDynamicSelect = type === FormTypeEnum.dynamicSelect
const isAppSelector = type === FormTypeEnum.appSelector
const isModelSelector = type === FormTypeEnum.modelSelector
const showTypeSwitch = isNumber || isBoolean || isObject || isArray || isSelect
const isConstant = varInput?.type === VarKindType.constant || !varInput?.type
const showVariableSelector = isFile || varInput?.type === VarKindType.variable
const isMultipleSelect = multiple && (isSelect || isDynamicSelect)
return {
defaultValue,
isAppSelector,
isArray,
isBoolean,
isCheckbox,
isConstant,
isDynamicSelect,
isFile,
isFiles,
isModelSelector,
isMultipleSelect,
isNumber,
isObject,
isSelect,
isShowJSONEditor,
isString,
options,
placeholder,
scope,
showTypeSwitch,
showVariableSelector,
variable,
}
}
export const getTargetVarType = (state: FormInputState) => {
if (state.isString)
return VarType.string
if (state.isNumber)
return VarType.number
if (state.isFile)
return state.isFiles ? VarType.arrayFile : VarType.file
if (state.isSelect)
return VarType.string
if (state.isBoolean)
return VarType.boolean
if (state.isObject)
return VarType.object
if (state.isArray)
return VarType.arrayObject
return VarType.string
}
export const getFilterVar = (state: FormInputState) => {
if (state.isNumber)
return (varPayload: Var) => varPayload.type === VarType.number
if (state.isString)
return (varPayload: Var) => [VarType.string, VarType.number, VarType.secret].includes(varPayload.type)
if (state.isFile)
return (varPayload: Var) => [VarType.file, VarType.arrayFile].includes(varPayload.type)
if (state.isBoolean)
return (varPayload: Var) => varPayload.type === VarType.boolean
if (state.isObject)
return (varPayload: Var) => varPayload.type === VarType.object
if (state.isArray)
return (varPayload: Var) => [VarType.array, VarType.arrayString, VarType.arrayNumber, VarType.arrayObject, VarType.arrayMessage].includes(varPayload.type)
return undefined
}
export const getVarKindType = (state: FormInputState) => {
if (state.isFile)
return VarKindType.variable
if (state.isSelect || state.isDynamicSelect || state.isBoolean || state.isNumber || state.isArray || state.isObject)
return VarKindType.constant
if (state.isString)
return VarKindType.mixed
return undefined
}
export const filterVisibleOptions = (
options: SelectableOption[],
values: ResourceVarInputs,
) => options.filter((option) => {
if (option.show_on?.length)
return option.show_on.every(showOnItem => optionMatchesValue(values, showOnItem))
return true
})
export const mapSelectItems = (
options: SelectableOption[],
language: string,
): SelectItem[] => options.map(option => ({
icon: option.icon,
name: getOptionLabel(option, language),
value: option.value,
}))
export const hasOptionIcon = (options: SelectableOption[]) => options.some(option => !!option.icon)
export const getSelectedLabels = (
selectedValues: string[] | undefined,
options: SelectableOption[],
language: string,
) => {
if (!selectedValues?.length)
return ''
const selectedOptions = options.filter(option => selectedValues.includes(option.value))
if (selectedOptions.length <= 2) {
return selectedOptions
.map(option => getOptionLabel(option, language))
.join(', ')
}
return `${selectedOptions.length} selected`
}
export const getCheckboxListOptions = (
options: SelectableOption[],
language: string,
) => options.map(option => ({
label: getOptionLabel(option, language),
value: option.value,
}))
export const getCheckboxListValue = (
currentValue: unknown,
defaultValue: unknown,
availableOptions: SelectableOption[],
) => {
let current: string[] = []
if (Array.isArray(currentValue))
current = currentValue as string[]
else if (typeof currentValue === 'string')
current = [currentValue]
else if (Array.isArray(defaultValue))
current = defaultValue as string[]
const allowedValues = new Set(availableOptions.map(option => option.value))
return current.filter(item => allowedValues.has(item))
}
export const getNumberInputValue = (currentValue: unknown): number | string => {
if (typeof currentValue === 'number')
return Number.isNaN(currentValue) ? '' : currentValue
if (typeof currentValue === 'string')
return currentValue
return ''
}
export const normalizeVariableSelectorValue = (value: ValueSelector | string) =>
value || ''

View File

@ -0,0 +1,129 @@
'use client'
import type { FC, ReactElement } from 'react'
import type { SelectItem } from './form-input-item.helpers'
import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react'
import { ChevronDownIcon } from '@heroicons/react/20/solid'
import { RiCheckLine, RiLoader4Line } from '@remixicon/react'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import { cn } from '@/utils/classnames'
type MultiSelectFieldProps = {
disabled: boolean
isLoading?: boolean
items: SelectItem[]
onChange: (value: string[]) => void
placeholder?: string
selectedLabel: string
value: string[]
}
const LoadingIndicator = () => (
<RiLoader4Line className="h-3.5 w-3.5 animate-spin text-text-secondary" />
)
const ToggleIndicator = () => (
<ChevronDownIcon
className="h-4 w-4 text-text-quaternary group-hover/simple-select:text-text-secondary"
aria-hidden="true"
/>
)
const SelectedMark = () => (
<span className="absolute inset-y-0 right-0 flex items-center pr-2 text-text-accent">
<RiCheckLine className="h-4 w-4" aria-hidden="true" />
</span>
)
export const MultiSelectField: FC<MultiSelectFieldProps> = ({
disabled,
isLoading = false,
items,
onChange,
placeholder,
selectedLabel,
value,
}) => {
const textClassName = cn(
'block truncate text-left system-sm-regular',
isLoading
? 'text-components-input-text-placeholder'
: value.length > 0
? 'text-components-input-text-filled'
: 'text-components-input-text-placeholder',
)
const renderLabel = () => {
if (isLoading)
return 'Loading...'
return selectedLabel || placeholder || 'Select options'
}
return (
<Listbox multiple value={value} onChange={onChange} disabled={disabled}>
<div className="group/simple-select relative h-8 grow">
<ListboxButton className="flex h-full w-full cursor-pointer items-center rounded-lg border-0 bg-components-input-bg-normal pl-3 pr-10 focus-visible:bg-state-base-hover-alt focus-visible:outline-none group-hover/simple-select:bg-state-base-hover-alt sm:text-sm sm:leading-6">
<span className={textClassName}>
{renderLabel()}
</span>
<span className="absolute inset-y-0 right-0 flex items-center pr-2">
{isLoading ? <LoadingIndicator /> : <ToggleIndicator />}
</span>
</ListboxButton>
<ListboxOptions className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur px-1 py-1 text-base shadow-lg backdrop-blur-sm focus:outline-none sm:text-sm">
{items.map(item => (
<ListboxOption
key={item.value}
value={item.value}
className={({ focus }) =>
cn('relative cursor-pointer select-none rounded-lg py-2 pl-3 pr-9 text-text-secondary hover:bg-state-base-hover', focus && 'bg-state-base-hover')}
>
{({ selected }) => (
<>
<div className="flex items-center">
{item.icon && (
<img src={item.icon} alt="" className="mr-2 h-4 w-4" />
)}
<span className={cn('block truncate', selected && 'font-normal')}>
{item.name}
</span>
</div>
{selected && <SelectedMark />}
</>
)}
</ListboxOption>
))}
</ListboxOptions>
</div>
</Listbox>
)
}
type JsonEditorFieldProps = {
onChange: (value: string) => void
placeholder?: ReactElement | string
value: string
}
export const JsonEditorField: FC<JsonEditorFieldProps> = ({
onChange,
placeholder,
value,
}) => {
return (
<div className="mt-1 w-full">
<CodeEditor
title="JSON"
value={value}
isExpand
isInNode
language={CodeLanguage.json}
onChange={onChange}
className="w-full"
placeholder={placeholder}
/>
</div>
)
}

View File

@ -1,64 +1,138 @@
'use client'
import type { FC } from 'react'
import type { NestedNodeConfig, ResourceVarInputs } from '../types'
import type { FormOption as BaseSelectFormOption } from '@/app/components/base/form/types'
import type { CredentialFormSchema, FormOption, FormShowOnObject, TypeWithI18N } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { CredentialFormSchema, FormOption, FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { Event, Tool } from '@/app/components/tools/types'
import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types'
import type { ToolWithProvider, ValueSelector, Var } from '@/app/components/workflow/types'
import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react'
import { ChevronDownIcon } from '@heroicons/react/20/solid'
import { RiCheckLine, RiLoader4Line } from '@remixicon/react'
import { useContext, useEffect, useMemo, useState } from 'react'
import CheckboxList from '@/app/components/base/checkbox-list'
import { useFeaturesStore } from '@/app/components/base/features/hooks'
import Input from '@/app/components/base/input'
import { SimpleSelect } from '@/app/components/base/select'
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
import AppSelector from '@/app/components/plugins/plugin-detail-panel/app-selector'
import ModelParameterModal from '@/app/components/plugins/plugin-detail-panel/model-selector'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { WorkflowContext } from '@/app/components/workflow/context'
import { HooksStoreContext } from '@/app/components/workflow/hooks-store/provider'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker'
import { NULL_STRATEGY } from '@/app/components/workflow/nodes/_base/constants'
import useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use-available-var-list'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import MixedVariableTextInput from '@/app/components/workflow/nodes/tool/components/mixed-variable-text-input'
import { VarType } from '@/app/components/workflow/types'
import { useFetchDynamicOptions } from '@/service/use-plugins'
import { useTriggerPluginDynamicOptions } from '@/service/use-triggers'
import { cn } from '@/utils/classnames'
import { VarKindType } from '../types'
import FormInputBoolean from './form-input-boolean'
import {
filterVisibleOptions,
getCheckboxListOptions,
getCheckboxListValue,
getFilterVar,
getFormInputState,
getNumberInputValue,
getSelectedLabels,
getTargetVarType,
getVarKindType,
hasOptionIcon,
mapSelectItems,
normalizeVariableSelectorValue,
} from './form-input-item.helpers'
import {
JsonEditorField,
MultiSelectField,
} from './form-input-item.sections'
import FormInputTypeSwitch from './form-input-type-switch'
type SelectOptionRow = FormOption | BaseSelectFormOption
function credentialInputMatchesShowOn(value: ResourceVarInputs, showOnItem: FormShowOnObject): boolean {
const entry = value[showOnItem.variable]
const comparable = entry !== undefined && entry !== null && typeof entry === 'object' && 'value' in entry
? (entry as { value: unknown }).value
: entry
return comparable === showOnItem.value
type VariableReferenceFieldsProps = {
canRenderVariableReference: boolean
currentProvider?: ToolWithProvider | TriggerWithProvider
currentTool?: Tool | Event
disableVariableInsertion?: boolean
filterVar?: (payload: Var, selector: ValueSelector) => boolean
inPanel?: boolean
isBoolean: boolean
isString: boolean
nodeId: string
onManageInputField?: () => void
onValueChange: (newValue: unknown, newType?: VarKindType, nestedNodeConfig?: NestedNodeConfig | null) => void
onVariableSelectorChange: (newValue: ValueSelector | string, variable: string) => void
readOnly: boolean
schema: CredentialFormSchema
showManageInputField?: boolean
showVariableSelector: boolean
targetVarType: string
value: ResourceVarInputs[string]
variable: string
}
function selectOptionDisplayLabel(opt: SelectOptionRow, language: string): string {
const { label } = opt
if (typeof label === 'string')
return label
if (label && typeof label === 'object')
return (label as Record<string, string | undefined>)[language] || (label as { en_US?: string }).en_US || opt.value
return opt.value
}
const VariableReferenceFields: FC<VariableReferenceFieldsProps> = ({
canRenderVariableReference,
currentProvider,
currentTool,
disableVariableInsertion,
filterVar,
inPanel,
isBoolean,
isString,
nodeId,
onManageInputField,
onValueChange,
onVariableSelectorChange,
readOnly,
schema,
showManageInputField,
showVariableSelector,
targetVarType,
value,
variable,
}) => {
const { availableVars, availableNodesWithParent } = useAvailableVarList(nodeId, {
onlyLeafNodeVar: false,
filterVar: filterVar || (() => true),
})
type CredentialFormSchemaRuntime = CredentialFormSchema & {
_type?: FormTypeEnum
multiple?: boolean
options?: FormOption[]
placeholder?: TypeWithI18N
if (!canRenderVariableReference)
return null
return (
<>
{isString && (
<MixedVariableTextInput
readOnly={readOnly}
value={value?.value as string || ''}
onChange={onValueChange}
nodesOutputVars={availableVars}
availableNodes={availableNodesWithParent}
showManageInputField={showManageInputField}
onManageInputField={onManageInputField}
disableVariableInsertion={disableVariableInsertion}
toolNodeId={nodeId}
paramKey={variable}
/>
)}
{showVariableSelector && (
<VarReferencePicker
zIndex={inPanel ? 1000 : undefined}
className="h-8 grow"
readonly={readOnly}
isShowNodeName
nodeId={nodeId}
value={value?.value || []}
onChange={newValue => onVariableSelectorChange(newValue, variable)}
filterVar={filterVar}
schema={schema}
valueTypePlaceHolder={targetVarType}
currentTool={currentTool}
currentProvider={currentProvider}
isFilterFileVar={isBoolean}
availableVars={availableVars}
availableNodes={availableNodesWithParent}
/>
)}
</>
)
}
type Props = {
@ -77,93 +151,6 @@ type Props = {
disableVariableInsertion?: boolean
}
type VariableReferenceFieldsProps = {
nodeId: string
isString: boolean
showVariableSelector: boolean
readOnly: boolean
schema: CredentialFormSchema
varInput: ResourceVarInputs[string]
targetVarType: string
filterVar?: (payload: Var, selector: ValueSelector) => boolean
onValueChange: (newValue: unknown) => void
onVariableSelectorChange: (newValue: ValueSelector | string) => void
showManageInputField?: boolean
onManageInputField?: () => void
disableVariableInsertion?: boolean
inPanel?: boolean
currentTool?: Tool | Event
currentProvider?: ToolWithProvider | TriggerWithProvider
isFilterFileVar?: boolean
toolNodeId?: string
paramKey?: string
}
const VariableReferenceFields: FC<VariableReferenceFieldsProps> = ({
nodeId,
isString,
showVariableSelector,
readOnly,
schema,
varInput,
targetVarType,
filterVar,
onValueChange,
onVariableSelectorChange,
showManageInputField,
onManageInputField,
disableVariableInsertion,
inPanel,
currentTool,
currentProvider,
isFilterFileVar,
toolNodeId,
paramKey,
}) => {
const { availableVars, availableNodesWithParent } = useAvailableVarList(nodeId, {
onlyLeafNodeVar: false,
filterVar: filterVar || (() => true),
})
return (
<>
{isString && (
<MixedVariableTextInput
readOnly={readOnly}
value={varInput?.value as string || ''}
onChange={onValueChange}
nodesOutputVars={availableVars}
availableNodes={availableNodesWithParent}
showManageInputField={showManageInputField}
onManageInputField={onManageInputField}
disableVariableInsertion={disableVariableInsertion}
toolNodeId={toolNodeId}
paramKey={paramKey}
/>
)}
{showVariableSelector && (
<VarReferencePicker
zIndex={inPanel ? 1000 : undefined}
className="h-8 grow"
readonly={readOnly}
isShowNodeName
nodeId={nodeId}
value={varInput?.value || []}
onChange={onVariableSelectorChange}
filterVar={filterVar}
schema={schema}
valueTypePlaceHolder={targetVarType}
currentTool={currentTool}
currentProvider={currentProvider}
isFilterFileVar={isFilterFileVar}
availableVars={availableVars}
availableNodes={availableNodesWithParent}
/>
)}
</>
)
}
const FormInputItem: FC<Props> = ({
readOnly,
nodeId,
@ -180,90 +167,53 @@ const FormInputItem: FC<Props> = ({
disableVariableInsertion = false,
}) => {
const language = useLanguage()
const featuresStore = useFeaturesStore()
const hooksStore = useContext(HooksStoreContext)
const workflowStore = useContext(WorkflowContext)
const canUseWorkflowHooks = !!hooksStore && !!workflowStore
const [toolsOptions, setToolsOptions] = useState<SelectOptionRow[] | null>(null)
const [toolsOptions, setToolsOptions] = useState<FormOption[] | null>(null)
const [isLoadingToolsOptions, setIsLoadingToolsOptions] = useState(false)
const formState = getFormInputState(schema as CredentialFormSchema & {
_type?: FormTypeEnum
multiple?: boolean
options?: FormOption[]
scope?: string
}, value[schema.variable])
const {
defaultValue,
isAppSelector,
isBoolean,
isCheckbox,
isConstant,
isDynamicSelect,
isModelSelector,
isMultipleSelect,
isNumber,
isSelect,
isShowJSONEditor,
isString,
options,
placeholder,
variable,
type,
_type,
default: defaultValue,
options = [],
multiple,
scope,
} = schema as CredentialFormSchemaRuntime
variable,
} = formState
const varInput = value[variable]
const isString = type === FormTypeEnum.textInput || type === FormTypeEnum.secretInput
const isNumber = type === FormTypeEnum.textNumber
const isObject = type === FormTypeEnum.object
const isArray = type === FormTypeEnum.array
const isShowJSONEditor = isObject || isArray
const isFile = type === FormTypeEnum.file || type === FormTypeEnum.files
const isBoolean = _type === FormTypeEnum.boolean
const isCheckbox = _type === FormTypeEnum.checkbox
const isSelect = type === FormTypeEnum.select
const isDynamicSelect = type === FormTypeEnum.dynamicSelect
const isAppSelector = type === FormTypeEnum.appSelector
const isModelSelector = type === FormTypeEnum.modelSelector
const showTypeSwitch = canUseWorkflowHooks && (isNumber || isBoolean || isObject || isArray || isSelect)
const isConstant = varInput?.type === VarKindType.constant || !varInput?.type
const showVariableSelector = canUseWorkflowHooks && (isFile || varInput?.type === VarKindType.variable)
const isMultipleSelect = multiple && (isSelect || isDynamicSelect)
const showTypeSwitch = canUseWorkflowHooks && formState.showTypeSwitch
const showVariableSelector = canUseWorkflowHooks && formState.showVariableSelector
const canRenderVariableReference = canUseWorkflowHooks && !!nodeId
const targetVarType = () => {
if (isString)
return VarType.string
else if (isNumber)
return VarType.number
else if (type === FormTypeEnum.files)
return VarType.arrayFile
else if (type === FormTypeEnum.file)
return VarType.file
else if (isSelect)
return VarType.string
// else if (isAppSelector)
// return VarType.appSelector
// else if (isModelSelector)
// return VarType.modelSelector
else if (isBoolean)
return VarType.boolean
else if (isObject)
return VarType.object
else if (isArray)
return VarType.arrayObject
else
return VarType.string
}
const getFilterVar = () => {
if (isNumber)
return (varPayload: Var) => varPayload.type === VarType.number
else if (isString)
return (varPayload: Var) => ([VarType.string, VarType.number, VarType.secret] as VarType[]).includes(varPayload.type)
else if (isFile)
return (varPayload: Var) => ([VarType.file, VarType.arrayFile] as VarType[]).includes(varPayload.type)
else if (isBoolean)
return (varPayload: Var) => varPayload.type === VarType.boolean
else if (isObject)
return (varPayload: Var) => varPayload.type === VarType.object
else if (isArray)
return (varPayload: Var) => ([VarType.array, VarType.arrayString, VarType.arrayNumber, VarType.arrayObject, VarType.arrayMessage] as VarType[]).includes(varPayload.type)
return undefined
}
const getVarKindType = () => {
if (isFile)
return VarKindType.variable
if (isSelect || isDynamicSelect || isBoolean || isNumber || isArray || isObject)
return VarKindType.constant
if (isString)
return VarKindType.mixed
}
const canMountVariableReferenceFields = canRenderVariableReference && !!featuresStore
const jsonEditorValue = useMemo(() => {
const currentValue = varInput?.value
if (currentValue === undefined || currentValue === null)
return ''
if (typeof currentValue === 'string')
return currentValue
if (typeof currentValue === 'object')
return JSON.stringify(currentValue, null, 2)
return ''
}, [varInput?.value])
// Fetch dynamic options hook for tools
const { mutateAsync: fetchDynamicOptions } = useFetchDynamicOptions(
@ -316,8 +266,8 @@ const FormInputItem: FC<Props> = ({
fetchPanelDynamicOptions()
}, [
isDynamicSelect,
currentTool?.name,
currentProvider?.name,
currentTool,
currentProvider,
variable,
extraParams,
providerType,
@ -353,11 +303,11 @@ const FormInputItem: FC<Props> = ({
? `{{#${nodeId}_ext_${variable}.result#}}`
: ''
const isAssembleValue = typeof normalizedValue === 'string'
&& assemblePlaceholder
&& !!assemblePlaceholder
&& normalizedValue.includes(assemblePlaceholder)
const resolvedType: VarKindType = isAssembleValue
const resolvedType = isAssembleValue
? VarKindType.nested_node
: newType ?? (varInput?.type === VarKindType.nested_node ? VarKindType.nested_node : getVarKindType() ?? VarKindType.constant)
: newType ?? (varInput?.type === VarKindType.nested_node ? VarKindType.nested_node : getVarKindType(formState))
const resolvedNestedNodeConfig = resolvedType === VarKindType.nested_node
? (nestedNodeConfig ?? varInput?.nested_node_config ?? {
extractor_node_id: nodeId && variable ? `${nodeId}_ext_${variable}` : '',
@ -378,25 +328,6 @@ const FormInputItem: FC<Props> = ({
})
}
const getSelectedLabels = (selectedValues: unknown) => {
if (!Array.isArray(selectedValues) || selectedValues.length === 0)
return ''
const values = selectedValues as string[]
const optionsList: SelectOptionRow[] = isDynamicSelect ? (dynamicOptions || options || []) : (options || [])
const selectedOptions = optionsList.filter((opt: SelectOptionRow) =>
values.includes(opt.value),
)
if (selectedOptions.length <= 2) {
return selectedOptions
.map((opt: SelectOptionRow) => selectOptionDisplayLabel(opt, language))
.join(', ')
}
return `${selectedOptions.length} selected`
}
const handleAppOrModelSelect = (newValue: unknown) => {
onChange({
...value,
@ -413,38 +344,44 @@ const FormInputItem: FC<Props> = ({
[variable]: {
...varInput,
type: VarKindType.variable,
value: newValue || '',
value: normalizeVariableSelectorValue(newValue),
},
})
}
const availableCheckboxOptions = useMemo(() => (
(options || []).filter((option: { show_on?: FormShowOnObject[] }) => {
if (option.show_on?.length)
return option.show_on.every(showOnItem => credentialInputMatchesShowOn(value, showOnItem))
return true
})
), [options, value])
const availableCheckboxOptions = useMemo(
() => filterVisibleOptions(options, value),
[options, value],
)
const checkboxListOptions = useMemo(
() => getCheckboxListOptions(availableCheckboxOptions, language),
[availableCheckboxOptions, language],
)
const checkboxListValue = useMemo(
() => getCheckboxListValue(varInput?.value, defaultValue, availableCheckboxOptions),
[availableCheckboxOptions, defaultValue, varInput?.value],
)
const checkboxListOptions = useMemo(() => (
availableCheckboxOptions.map((option: { value: string, label: Record<string, string> }) => ({
value: option.value,
label: option.label?.[language] || option.label?.en_US || option.value,
}))
), [availableCheckboxOptions, language])
const checkboxListValue = useMemo(() => {
let current: string[] = []
if (Array.isArray(varInput?.value))
current = varInput.value as string[]
else if (typeof varInput?.value === 'string')
current = [varInput.value as string]
else if (Array.isArray(defaultValue))
current = defaultValue as string[]
const allowedValues = new Set(availableCheckboxOptions.map((option: { value: string }) => option.value))
return current.filter(item => allowedValues.has(item))
}, [varInput?.value, defaultValue, availableCheckboxOptions])
const visibleSelectOptions = useMemo(
() => filterVisibleOptions(options, value),
[options, value],
)
const visibleDynamicOptions = useMemo(
() => filterVisibleOptions(dynamicOptions || options || [], value),
[dynamicOptions, options, value],
)
const staticSelectItems = useMemo(
() => mapSelectItems(visibleSelectOptions, language),
[language, visibleSelectOptions],
)
const dynamicSelectItems = useMemo(
() => mapSelectItems(visibleDynamicOptions, language),
[language, visibleDynamicOptions],
)
const selectedLabels = useMemo(
() => getSelectedLabels(varInput?.value as string[] | undefined, isDynamicSelect ? visibleDynamicOptions : visibleSelectOptions, language),
[isDynamicSelect, language, varInput?.value, visibleDynamicOptions, visibleSelectOptions],
)
const handleCheckboxListChange = (selected: string[]) => {
onChange({
@ -471,34 +408,34 @@ const FormInputItem: FC<Props> = ({
disabled={readOnly}
/>
)}
{canRenderVariableReference && (
{canMountVariableReferenceFields && (isString || showVariableSelector) && (
<VariableReferenceFields
nodeId={nodeId}
canRenderVariableReference={canRenderVariableReference}
currentProvider={currentProvider}
currentTool={currentTool}
disableVariableInsertion={disableVariableInsertion}
filterVar={getFilterVar(formState)}
inPanel={inPanel}
isBoolean={isBoolean}
isString={isString}
showVariableSelector={showVariableSelector}
nodeId={nodeId}
onManageInputField={onManageInputField}
onValueChange={handleValueChange}
onVariableSelectorChange={handleVariableSelectorChange}
readOnly={readOnly}
schema={schema}
varInput={varInput}
targetVarType={targetVarType()}
filterVar={getFilterVar()}
onValueChange={handleValueChange}
onVariableSelectorChange={newValue => handleVariableSelectorChange(newValue, variable)}
showManageInputField={showManageInputField}
onManageInputField={onManageInputField}
disableVariableInsertion={disableVariableInsertion}
inPanel={inPanel}
currentTool={currentTool}
currentProvider={currentProvider}
isFilterFileVar={isBoolean}
toolNodeId={nodeId}
paramKey={variable}
showVariableSelector={showVariableSelector}
targetVarType={getTargetVarType(formState)}
value={varInput}
variable={variable}
/>
)}
{isNumber && isConstant && (
<Input
className="h-8 grow"
type="number"
value={Number.isNaN(varInput?.value) ? '' : varInput?.value}
value={getNumberInputValue(varInput?.value)}
onChange={e => handleValueChange(e.target.value)}
placeholder={placeholder?.[language] || placeholder?.en_US}
/>
@ -523,20 +460,11 @@ const FormInputItem: FC<Props> = ({
<SimpleSelect
wrapperClassName="h-8 grow"
disabled={readOnly}
defaultValue={varInput?.value}
items={options.filter((option: FormOption) => {
if (option.show_on.length)
return option.show_on.every(showOnItem => credentialInputMatchesShowOn(value, showOnItem))
return true
}).map((option: FormOption) => ({
value: option.value,
name: selectOptionDisplayLabel(option, language),
icon: option.icon,
}))}
defaultValue={varInput?.value as string | undefined}
items={staticSelectItems}
onSelect={item => handleValueChange(item.value as string)}
placeholder={placeholder?.[language] || placeholder?.en_US}
renderOption={options.some((opt: FormOption) => opt.icon)
renderOption={hasOptionIcon(visibleSelectOptions)
? ({ item }) => (
<div className="flex items-center">
{item.icon && (
@ -549,74 +477,21 @@ const FormInputItem: FC<Props> = ({
/>
)}
{isSelect && isConstant && isMultipleSelect && (
<Listbox
multiple
value={varInput?.value || []}
onChange={handleValueChange}
<MultiSelectField
disabled={readOnly}
>
<div className="group/simple-select relative h-8 grow">
<ListboxButton className="flex h-full w-full cursor-pointer items-center rounded-lg border-0 bg-components-input-bg-normal pl-3 pr-10 focus-visible:bg-state-base-hover-alt focus-visible:outline-none group-hover/simple-select:bg-state-base-hover-alt sm:text-sm sm:leading-6">
<span className={cn('block truncate text-left system-sm-regular', varInput?.value?.length > 0 ? 'text-components-input-text-filled' : 'text-components-input-text-placeholder')}>
{getSelectedLabels(varInput?.value) || placeholder?.[language] || placeholder?.en_US || 'Select options'}
</span>
<span className="absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronDownIcon
className="h-4 w-4 text-text-quaternary group-hover/simple-select:text-text-secondary"
aria-hidden="true"
/>
</span>
</ListboxButton>
<ListboxOptions className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur px-1 py-1 text-base shadow-lg backdrop-blur-sm focus:outline-none sm:text-sm">
{options.filter((option: FormOption) => {
if (option.show_on?.length)
return option.show_on.every(showOnItem => credentialInputMatchesShowOn(value, showOnItem))
return true
}).map((option: FormOption) => (
<ListboxOption
key={option.value}
value={option.value}
className={({ focus }) =>
cn('relative cursor-pointer select-none rounded-lg py-2 pl-3 pr-9 text-text-secondary hover:bg-state-base-hover', focus && 'bg-state-base-hover')}
>
{({ selected }) => (
<>
<div className="flex items-center">
{option.icon && (
<img src={option.icon} alt="" className="mr-2 h-4 w-4" />
)}
<span className={cn('block truncate', selected && 'font-normal')}>
{selectOptionDisplayLabel(option, language)}
</span>
</div>
{selected && (
<span className="absolute inset-y-0 right-0 flex items-center pr-2 text-text-accent">
<RiCheckLine className="h-4 w-4" aria-hidden="true" />
</span>
)}
</>
)}
</ListboxOption>
))}
</ListboxOptions>
</div>
</Listbox>
value={(varInput?.value as string[] | undefined) || []}
items={staticSelectItems}
onChange={handleValueChange}
placeholder={placeholder?.[language] || placeholder?.en_US}
selectedLabel={selectedLabels}
/>
)}
{isDynamicSelect && !isMultipleSelect && (
<SimpleSelect
wrapperClassName="h-8 grow"
disabled={readOnly || isLoadingOptions}
defaultValue={varInput?.value}
items={(dynamicOptions || options || []).filter((option: SelectOptionRow) => {
if (option.show_on?.length)
return option.show_on.every(showOnItem => credentialInputMatchesShowOn(value, showOnItem))
return true
}).map((option: SelectOptionRow) => ({
value: option.value,
name: selectOptionDisplayLabel(option, language),
icon: option.icon,
}))}
defaultValue={varInput?.value as string | undefined}
items={dynamicSelectItems}
onSelect={item => handleValueChange(item.value as string)}
placeholder={isLoadingOptions ? 'Loading...' : (placeholder?.[language] || placeholder?.en_US)}
renderOption={({ item }) => (
@ -630,90 +505,22 @@ const FormInputItem: FC<Props> = ({
/>
)}
{isDynamicSelect && isMultipleSelect && (
<Listbox
multiple
value={varInput?.value || []}
onChange={handleValueChange}
<MultiSelectField
disabled={readOnly || isLoadingOptions}
>
<div className="group/simple-select relative h-8 grow">
<ListboxButton className="flex h-full w-full cursor-pointer items-center rounded-lg border-0 bg-components-input-bg-normal pl-3 pr-10 focus-visible:bg-state-base-hover-alt focus-visible:outline-none group-hover/simple-select:bg-state-base-hover-alt sm:text-sm sm:leading-6">
<span className={cn('block truncate text-left system-sm-regular', isLoadingOptions
? 'text-components-input-text-placeholder'
: varInput?.value?.length > 0 ? 'text-components-input-text-filled' : 'text-components-input-text-placeholder')}
>
{isLoadingOptions
? 'Loading...'
: getSelectedLabels(varInput?.value) || placeholder?.[language] || placeholder?.en_US || 'Select options'}
</span>
<span className="absolute inset-y-0 right-0 flex items-center pr-2">
{isLoadingOptions
? (
<RiLoader4Line className="h-3.5 w-3.5 animate-spin text-text-secondary" />
)
: (
<ChevronDownIcon
className="h-4 w-4 text-text-quaternary group-hover/simple-select:text-text-secondary"
aria-hidden="true"
/>
)}
</span>
</ListboxButton>
<ListboxOptions className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur px-1 py-1 text-base shadow-lg backdrop-blur-sm focus:outline-none sm:text-sm">
{(dynamicOptions || options || []).filter((option: SelectOptionRow) => {
if (option.show_on?.length)
return option.show_on.every(showOnItem => credentialInputMatchesShowOn(value, showOnItem))
return true
}).map((option: SelectOptionRow) => (
<ListboxOption
key={option.value}
value={option.value}
className={({ focus }) =>
cn('relative cursor-pointer select-none rounded-lg py-2 pl-3 pr-9 text-text-secondary hover:bg-state-base-hover', focus && 'bg-state-base-hover')}
>
{({ selected }) => (
<>
<div className="flex items-center">
{option.icon && (
<img src={option.icon} alt="" className="mr-2 h-4 w-4" />
)}
<span className={cn('block truncate', selected && 'font-normal')}>
{selectOptionDisplayLabel(option, language)}
</span>
</div>
{selected && (
<span className="absolute inset-y-0 right-0 flex items-center pr-2 text-text-accent">
<RiCheckLine className="h-4 w-4" aria-hidden="true" />
</span>
)}
</>
)}
</ListboxOption>
))}
</ListboxOptions>
</div>
</Listbox>
isLoading={isLoadingOptions}
value={(varInput?.value as string[] | undefined) || []}
items={dynamicSelectItems}
onChange={handleValueChange}
placeholder={placeholder?.[language] || placeholder?.en_US}
selectedLabel={selectedLabels}
/>
)}
{isShowJSONEditor && isConstant && (
<div className="mt-1 w-full">
<CodeEditor
title="JSON"
value={(() => {
const v = varInput?.value
if (v === undefined || v === null)
return undefined
if (typeof v === 'string' || typeof v === 'object')
return v as string | object
return undefined
})()}
isExpand
isInNode
language={CodeLanguage.json}
onChange={handleValueChange}
className="w-full"
placeholder={<div className="whitespace-pre">{placeholder?.[language] || placeholder?.en_US}</div>}
/>
</div>
<JsonEditorField
value={jsonEditorValue}
onChange={handleValueChange}
placeholder={<div className="whitespace-pre">{placeholder?.[language] || placeholder?.en_US}</div>}
/>
)}
{isAppSelector && (
<AppSelector

View File

@ -0,0 +1,226 @@
import type { ComponentProps } from 'react'
import type { FormOption } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { NodeOutPutVar } from '@/app/components/workflow/types'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { createNode, createStartNode, resetFixtureCounters } from '@/app/components/workflow/__tests__/fixtures'
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import { BlockEnum, InputVarType, VarType } from '@/app/components/workflow/types'
import { VarType as VarKindType } from '../../../../tool/types'
import VarReferencePicker from '../var-reference-picker'
const {
mockFetchDynamicOptions,
} = vi.hoisted(() => ({
mockFetchDynamicOptions: vi.fn(),
}))
vi.mock('@/service/use-plugins', () => ({
useFetchDynamicOptions: () => ({
mutateAsync: mockFetchDynamicOptions,
}),
}))
vi.mock('../var-reference-popup', () => ({
default: ({
onChange,
}: {
onChange: (value: string[], item: { variable: string, type: VarType }) => void
}) => (
<div>
<button onClick={() => onChange(['node-a', 'answer'], { variable: 'answer', type: VarType.string })}>select-normal</button>
<button onClick={() => onChange(['node-a', 'sys.query'], { variable: 'sys.query', type: VarType.string })}>select-system</button>
</div>
),
}))
describe('VarReferencePicker branches', () => {
const startNode = createStartNode({
id: 'start-node',
data: {
title: 'Start',
variables: [{
variable: 'query',
label: 'Query',
type: InputVarType.textInput,
required: false,
}],
},
})
const sourceNode = createNode({
id: 'node-a',
width: 120,
height: 60,
position: { x: 120, y: 80 },
data: {
type: BlockEnum.Code,
title: 'Source Node',
outputs: {
answer: { type: VarType.string },
},
},
})
const currentNode = createNode({
id: 'node-current',
data: { type: BlockEnum.Code, title: 'Current Node' },
})
const availableVars: NodeOutPutVar[] = [{
nodeId: 'node-a',
title: 'Source Node',
vars: [
{ variable: 'answer', type: VarType.string },
],
}]
const renderPicker = (props: Partial<ComponentProps<typeof VarReferencePicker>> = {}) => {
const onChange = vi.fn()
const onOpen = vi.fn()
const result = renderWorkflowFlowComponent(
<div id="workflow-container" style={{ width: 800, height: 600 }}>
<VarReferencePicker
nodeId="node-current"
readonly={false}
value={[]}
onChange={onChange}
onOpen={onOpen}
availableNodes={[startNode, sourceNode, currentNode]}
availableVars={availableVars}
{...props}
/>
</div>,
{
nodes: [startNode, sourceNode, currentNode],
edges: [],
hooksStoreProps: {},
},
)
return { ...result, onChange, onOpen }
}
beforeEach(() => {
resetFixtureCounters()
vi.clearAllMocks()
mockFetchDynamicOptions.mockResolvedValue({ options: [] as FormOption[] })
})
it('should toggle a custom trigger and call onOpen when opening the popup', async () => {
const { onOpen } = renderPicker({
trigger: <button>custom-trigger</button>,
})
fireEvent.click(screen.getByText('custom-trigger'))
expect(await screen.findByText('select-normal')).toBeInTheDocument()
await waitFor(() => {
expect(onOpen).toHaveBeenCalled()
})
})
it('should rewrite system selectors before forwarding the selection', async () => {
const { onChange } = renderPicker()
fireEvent.click(screen.getByTestId('var-reference-picker-trigger'))
fireEvent.click(await screen.findByText('select-system'))
expect(onChange).toHaveBeenCalledWith(
['sys', 'query'],
VarKindType.constant,
expect.objectContaining({
variable: 'sys.query',
type: VarType.string,
}),
)
})
it('should clear variable-mode values to an empty selector array', () => {
const { onChange } = renderPicker({
defaultVarKindType: VarKindType.variable,
isSupportConstantValue: true,
value: ['node-a', 'answer'],
})
fireEvent.click(screen.getByTestId('var-reference-picker-clear'))
expect(onChange).toHaveBeenCalledWith([], VarKindType.variable)
})
it('should jump to the selected node when ctrl-clicking the node name', () => {
const { onChange } = renderPicker({
value: ['node-a', 'answer'],
})
fireEvent.click(screen.getByText('Source Node'), { ctrlKey: true })
expect(onChange).not.toHaveBeenCalled()
})
it('should fetch dynamic options for supported constant fields', async () => {
mockFetchDynamicOptions.mockResolvedValueOnce({
options: [{
value: 'dyn-1',
label: { en_US: 'Dynamic 1', zh_Hans: '动态 1' },
show_on: [],
}],
})
renderPicker({
currentProvider: { plugin_id: 'provider-1', name: 'provider-1' } as never,
currentTool: { name: 'tool-1' } as never,
isSupportConstantValue: true,
schema: {
variable: 'field',
type: 'dynamic-select',
} as never,
value: 'dyn-1',
})
await waitFor(() => {
expect(mockFetchDynamicOptions).toHaveBeenCalledTimes(1)
})
})
it('should focus the hidden control input for supported constant values', async () => {
const { container } = renderPicker({
isSupportConstantValue: true,
schema: {
type: 'text-input',
} as never,
value: 'constant-value',
})
fireEvent.click(screen.getByTestId('var-reference-picker-trigger'))
const hiddenInput = container.querySelector('input.sr-only') as HTMLInputElement
await waitFor(() => {
expect(document.activeElement).toBe(hiddenInput)
})
})
it('should render tooltip branches for partial paths and invalid variables without changing behavior', () => {
const objectVars: NodeOutPutVar[] = [{
nodeId: 'node-a',
title: 'Source Node',
vars: [{
variable: 'payload',
type: VarType.object,
children: [{ variable: 'child', type: VarType.string }],
}],
}]
const { unmount } = renderPicker({
availableVars: objectVars,
value: ['node-a', 'payload', 'child'],
})
expect(screen.getByText('child')).toBeInTheDocument()
unmount()
renderPicker({
value: ['missing-node', 'answer'],
})
expect(screen.getByText('answer')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,235 @@
import type { CredentialFormSchema } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { CommonNodeType, Node, ValueSelector } from '@/app/components/workflow/types'
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { createLoopNode, createNode, createStartNode } from '@/app/components/workflow/__tests__/fixtures'
import { BlockEnum } from '@/app/components/workflow/types'
import {
getDynamicSelectSchema,
getHasValue,
getIsIterationVar,
getIsLoopVar,
getOutputVarNode,
getOutputVarNodeId,
getTooltipContent,
getVarDisplayName,
getVariableCategory,
getVariableMeta,
getWidthAllocations,
isShowAPartSelector,
} from '../var-reference-picker.helpers'
describe('var-reference-picker.helpers', () => {
it('should detect whether the picker has a variable value', () => {
expect(getHasValue(false, ['node-1', 'answer'])).toBe(true)
expect(getHasValue(true, 'constant')).toBe(false)
expect(getHasValue(false, [])).toBe(false)
})
it('should detect iteration and loop variables by parent node id', () => {
expect(getIsIterationVar(true, ['iter-parent', 'item'], 'iter-parent')).toBe(true)
expect(getIsIterationVar(true, ['iter-parent', 'value'], 'iter-parent')).toBe(false)
expect(getIsLoopVar(true, ['loop-parent', 'index'], 'loop-parent')).toBe(true)
expect(getIsLoopVar(false, ['loop-parent', 'item'], 'loop-parent')).toBe(false)
})
it('should resolve output variable nodes for normal, system, iteration, and loop variables', () => {
const startNode = createStartNode({ id: 'start-1', data: { title: 'Start Node' } })
const normalNode = createNode({ id: 'node-a', data: { type: BlockEnum.Code, title: 'Answer Node' } })
const iterationNode = createNode({ id: 'iter-parent', data: { type: BlockEnum.Iteration, title: 'Iteration Parent' } }) as Node<CommonNodeType>
const loopNode = createLoopNode({ id: 'loop-parent', data: { title: 'Loop Parent' } }) as Node<CommonNodeType>
expect(getOutputVarNode({
availableNodes: [normalNode],
hasValue: true,
isConstant: false,
isIterationVar: false,
isLoopVar: false,
iterationNode: null,
loopNode: null,
outputVarNodeId: 'node-a',
startNode,
value: ['node-a', 'answer'],
})).toMatchObject({ id: 'node-a', title: 'Answer Node' })
expect(getOutputVarNode({
availableNodes: [normalNode],
hasValue: true,
isConstant: false,
isIterationVar: false,
isLoopVar: false,
iterationNode: null,
loopNode: null,
outputVarNodeId: 'sys',
startNode,
value: ['sys', 'files'],
})).toEqual(startNode.data)
expect(getOutputVarNode({
availableNodes: [normalNode],
hasValue: true,
isConstant: false,
isIterationVar: true,
isLoopVar: false,
iterationNode,
loopNode: null,
outputVarNodeId: 'iter-parent',
startNode,
value: ['iter-parent', 'item'],
})).toEqual(iterationNode.data)
expect(getOutputVarNode({
availableNodes: [normalNode],
hasValue: true,
isConstant: false,
isIterationVar: false,
isLoopVar: true,
iterationNode: null,
loopNode,
outputVarNodeId: 'loop-parent',
startNode,
value: ['loop-parent', 'item'],
})).toEqual(loopNode.data)
expect(getOutputVarNode({
availableNodes: [normalNode],
hasValue: true,
isConstant: false,
isIterationVar: false,
isLoopVar: false,
iterationNode: null,
loopNode: null,
outputVarNodeId: 'missing-node',
startNode,
value: ['missing-node', 'answer'],
})).toBeNull()
})
it('should format display names and output node ids correctly', () => {
expect(getOutputVarNodeId(true, ['node-a', 'answer'])).toBe('node-a')
expect(getOutputVarNodeId(false, [])).toBe('')
expect(getVarDisplayName(true, ['sys', 'query'])).toBe('query')
expect(getVarDisplayName(true, ['node-a', 'answer'])).toBe('answer')
expect(getVarDisplayName(false, [])).toBe('')
})
it('should derive variable meta and category from selectors', () => {
const meta = getVariableMeta(false, [], ['env', 'API_KEY'])
expect(meta).toMatchObject({
isEnv: true,
isValidVar: true,
})
expect(getVariableCategory({
isChatVar: true,
isEnv: false,
isGlobal: false,
isLoopVar: false,
isRagVar: false,
})).toBe('conversation')
expect(getVariableCategory({
isChatVar: false,
isEnv: false,
isGlobal: true,
isLoopVar: false,
isRagVar: false,
})).toBe('global')
expect(getVariableCategory({
isChatVar: false,
isEnv: false,
isGlobal: false,
isLoopVar: true,
isRagVar: false,
})).toBe('loop')
expect(getVariableCategory({
isChatVar: false,
isEnv: true,
isGlobal: false,
isLoopVar: false,
isRagVar: false,
})).toBe('environment')
expect(getVariableCategory({
isChatVar: false,
isEnv: false,
isGlobal: false,
isLoopVar: false,
isRagVar: true,
})).toBe('rag')
})
it('should calculate width allocations and tooltip behavior', () => {
expect(getWidthAllocations(240, 'Node', 'answer', 'string')).toEqual({
maxNodeNameWidth: expect.any(Number),
maxTypeWidth: expect.any(Number),
maxVarNameWidth: expect.any(Number),
})
expect(getTooltipContent(true, true, true)).toBe('full-path')
expect(getTooltipContent(true, false, false)).toBe('invalid-variable')
expect(getTooltipContent(false, false, true)).toBeNull()
})
it('should produce dynamic select schemas and detect partial selectors', () => {
const value = 'selected'
const schema: Partial<CredentialFormSchema> = {
type: 'dynamic-select',
} as Partial<CredentialFormSchema>
expect(getDynamicSelectSchema({
dynamicOptions: [{
value: 'a',
label: { en_US: 'A', zh_Hans: 'A' },
show_on: [],
}],
isLoading: false,
schema,
value,
})).toMatchObject({
options: [{ value: 'a' }],
})
expect(getDynamicSelectSchema({
dynamicOptions: null,
isLoading: true,
schema,
value,
})).toMatchObject({
options: [{ value: 'selected' }],
})
expect(getDynamicSelectSchema({
dynamicOptions: null,
isLoading: false,
schema,
value,
})).toMatchObject({ options: [] })
expect(isShowAPartSelector(['node-a', 'payload', 'child'] as ValueSelector)).toBe(true)
expect(isShowAPartSelector(['rag', 'node-a', 'payload'] as ValueSelector)).toBe(false)
})
it('should keep mapped variable names for known workflow aliases', () => {
expect(getVarDisplayName(true, ['sys', 'files'])).toBe('files')
expect(getVariableMeta(false, [], ['conversation', 'name'])).toMatchObject({
isChatVar: true,
isValidVar: true,
})
})
it('should preserve non-dynamic schemas', () => {
const schema: Partial<CredentialFormSchema> = {
type: FormTypeEnum.textInput,
}
expect(getDynamicSelectSchema({
dynamicOptions: null,
isLoading: false,
schema,
value: '',
})).toEqual(schema)
})
})

View File

@ -0,0 +1,140 @@
import type { ComponentProps } from 'react'
import type { NodeOutPutVar } from '@/app/components/workflow/types'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { createNode, createStartNode, resetFixtureCounters } from '@/app/components/workflow/__tests__/fixtures'
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import { BlockEnum, InputVarType, VarType } from '@/app/components/workflow/types'
import VarReferencePicker from '../var-reference-picker'
describe('VarReferencePicker', () => {
const startNode = createStartNode({
id: 'start-node',
data: {
title: 'Start',
variables: [{
variable: 'query',
label: 'Query',
type: InputVarType.textInput,
required: false,
}],
},
})
const sourceNode = createNode({
id: 'node-a',
data: {
type: BlockEnum.Code,
title: 'Source Node',
outputs: {
answer: { type: VarType.string },
payload: { type: VarType.object },
},
},
})
const currentNode = createNode({
id: 'node-current',
data: { type: BlockEnum.Code, title: 'Current Node' },
})
const availableVars: NodeOutPutVar[] = [{
nodeId: 'node-a',
title: 'Source Node',
vars: [
{ variable: 'answer', type: VarType.string },
{
variable: 'payload',
type: VarType.object,
children: [{ variable: 'child', type: VarType.string }],
},
],
}]
const renderPicker = (props: Partial<ComponentProps<typeof VarReferencePicker>> = {}) => {
const onChange = vi.fn()
const result = renderWorkflowFlowComponent(
<div id="workflow-container">
<VarReferencePicker
nodeId="node-current"
readonly={false}
value={[]}
onChange={onChange}
availableNodes={[startNode, sourceNode, currentNode]}
availableVars={availableVars}
{...props}
/>
</div>,
{
nodes: [startNode, sourceNode, currentNode],
edges: [],
hooksStoreProps: {},
},
)
return { ...result, onChange }
}
beforeEach(() => {
resetFixtureCounters()
})
it('should open the popup and select a variable from the available list', async () => {
const { onChange } = renderPicker()
fireEvent.click(screen.getByTestId('var-reference-picker-trigger'))
fireEvent.click(await screen.findByText('answer'))
expect(onChange).toHaveBeenCalledWith(
['node-a', 'answer'],
'constant',
expect.objectContaining({
variable: 'answer',
type: VarType.string,
}),
)
})
it('should render the selected node and variable name, then clear it', async () => {
const { onChange } = renderPicker({
value: ['node-a', 'answer'],
})
expect(screen.getByText('Source Node')).toBeInTheDocument()
expect(screen.getByText('answer')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('var-reference-picker-clear'))
expect(onChange).toHaveBeenCalledWith('', 'constant')
})
it('should show object variables in the popup and select the root object path', async () => {
const { onChange } = renderPicker()
fireEvent.click(screen.getByTestId('var-reference-picker-trigger'))
fireEvent.click(await screen.findByText('payload'))
expect(onChange).toHaveBeenCalledWith(
['node-a', 'payload'],
'constant',
expect.objectContaining({
variable: 'payload',
type: VarType.object,
}),
)
})
it('should render a placeholder and respect readonly mode', async () => {
const { onChange } = renderPicker({
readonly: true,
placeholder: 'Pick a variable',
})
expect(screen.getByText('Pick a variable')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('var-reference-picker-trigger'))
await waitFor(() => {
expect(screen.queryByText('answer')).not.toBeInTheDocument()
})
expect(onChange).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,176 @@
import type { ComponentProps } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
import { VarType as VarKindType } from '../../../../tool/types'
import VarReferencePickerTrigger from '../var-reference-picker.trigger'
const createProps = (
overrides: Partial<ComponentProps<typeof VarReferencePickerTrigger>> = {},
): ComponentProps<typeof VarReferencePickerTrigger> => ({
controlFocus: 0,
handleClearVar: vi.fn(),
handleVarKindTypeChange: vi.fn(),
handleVariableJump: vi.fn(),
hasValue: false,
inputRef: { current: null },
isConstant: false,
isException: false,
isFocus: false,
isLoading: false,
isShowAPart: false,
isShowNodeName: true,
maxNodeNameWidth: 80,
maxTypeWidth: 60,
maxVarNameWidth: 80,
onChange: vi.fn(),
open: false,
outputVarNode: null,
readonly: false,
setControlFocus: vi.fn(),
setOpen: vi.fn(),
tooltipPopup: null,
triggerRef: { current: null },
value: [],
varKindType: VarKindType.constant,
varKindTypes: [
{ label: 'Variable', value: VarKindType.variable },
{ label: 'Constant', value: VarKindType.constant },
],
varName: '',
variableCategory: 'system',
WrapElem: 'div',
VarPickerWrap: 'div',
...overrides,
})
describe('VarReferencePickerTrigger', () => {
it('should show the placeholder state and open the picker for variable mode', () => {
const setOpen = vi.fn()
render(
<VarReferencePickerTrigger
{...createProps({
placeholder: 'Pick variable',
setOpen,
})}
/>,
)
expect(screen.getByText('Pick variable')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('var-reference-picker-trigger'))
expect(setOpen).toHaveBeenCalledWith(true)
})
it('should render the selected variable state and clear it', () => {
const handleClearVar = vi.fn()
const handleVariableJump = vi.fn()
render(
<VarReferencePickerTrigger
{...createProps({
handleClearVar,
handleVariableJump,
hasValue: true,
outputVarNode: { title: 'Source Node', desc: '', type: BlockEnum.Code },
outputVarNodeId: 'node-a',
type: VarType.string,
value: ['node-a', 'answer'],
varName: 'answer',
})}
/>,
)
expect(screen.getByText('Source Node')).toBeInTheDocument()
expect(screen.getByText('answer')).toBeInTheDocument()
fireEvent.click(screen.getByText('Source Node'), { ctrlKey: true })
expect(handleVariableJump).toHaveBeenCalledWith('node-a')
fireEvent.click(screen.getByTestId('var-reference-picker-clear'))
expect(handleClearVar).toHaveBeenCalledTimes(1)
})
it('should render the support-constant trigger and focus constant input when clicked', () => {
const setControlFocus = vi.fn()
const setOpen = vi.fn()
render(
<VarReferencePickerTrigger
{...createProps({
isConstant: true,
isSupportConstantValue: true,
schemaWithDynamicSelect: {
type: 'text-input',
} as never,
setOpen,
setControlFocus,
value: 'constant-value',
})}
/>,
)
fireEvent.click(screen.getByTestId('var-reference-picker-trigger'))
expect(setControlFocus).toHaveBeenCalledTimes(1)
fireEvent.click(screen.getByText('Constant'))
expect(setOpen).toHaveBeenCalledWith(false)
})
it('should render add button trigger in table mode', () => {
render(
<VarReferencePickerTrigger
{...createProps({
hasValue: true,
isAddBtnTrigger: true,
isInTable: true,
value: ['node-a', 'answer'],
varName: 'answer',
})}
/>,
)
expect(document.querySelector('button')).toBeInTheDocument()
})
it('should stay inert in readonly mode and show value type placeholder badge', () => {
const setOpen = vi.fn()
render(
<VarReferencePickerTrigger
{...createProps({
placeholder: 'Readonly placeholder',
readonly: true,
setOpen,
typePlaceHolder: 'string',
valueTypePlaceHolder: 'text',
})}
/>,
)
fireEvent.click(screen.getByTestId('var-reference-picker-trigger'))
expect(setOpen).not.toHaveBeenCalled()
expect(screen.getByText('string')).toBeInTheDocument()
expect(screen.getByText('text')).toBeInTheDocument()
})
it('should show loading placeholder and remove rows in table mode', () => {
const onRemove = vi.fn()
render(
<VarReferencePickerTrigger
{...createProps({
hasValue: false,
isInTable: true,
isLoading: true,
onRemove,
placeholder: 'Loading variable',
})}
/>,
)
expect(screen.getByText('Loading variable')).toBeInTheDocument()
const buttons = screen.getAllByRole('button')
fireEvent.click(buttons[buttons.length - 1])
expect(onRemove).toHaveBeenCalledTimes(1)
})
})

View File

@ -0,0 +1,84 @@
import type { NodeOutPutVar, Var } from '@/app/components/workflow/types'
import { VarType } from '@/app/components/workflow/types'
import {
filterReferenceVars,
getValueSelector,
getVariableCategory,
getVariableDisplayName,
} from '../var-reference-vars.helpers'
describe('var-reference-vars helpers', () => {
it('should derive display names for flat and mapped variables', () => {
expect(getVariableDisplayName('sys.files', false)).toBe('files')
expect(getVariableDisplayName('current', true, true)).toBe('current_code')
expect(getVariableDisplayName('foo', true, false)).toBe('foo')
})
it('should resolve variable categories', () => {
expect(getVariableCategory({ isEnv: true, isChatVar: false })).toBe('environment')
expect(getVariableCategory({ isEnv: false, isChatVar: true })).toBe('conversation')
expect(getVariableCategory({ isEnv: false, isChatVar: false, isLoopVar: true })).toBe('loop')
expect(getVariableCategory({ isEnv: false, isChatVar: false, isRagVariable: true })).toBe('rag')
})
it('should build selectors by variable scope and file support', () => {
const itemData: Var = { variable: 'output', type: VarType.string }
expect(getValueSelector({
itemData,
isFlat: true,
isSupportFileVar: true,
isFile: false,
isSys: false,
isEnv: false,
isChatVar: false,
nodeId: 'node-1',
objPath: [],
})).toEqual(['output'])
expect(getValueSelector({
itemData: { variable: 'env.apiKey', type: VarType.string },
isFlat: false,
isSupportFileVar: true,
isFile: false,
isSys: false,
isEnv: true,
isChatVar: false,
nodeId: 'node-1',
objPath: ['parent'],
})).toEqual(['parent', 'env', 'apiKey'])
expect(getValueSelector({
itemData: { variable: 'file', type: VarType.file },
isFlat: false,
isSupportFileVar: false,
isFile: true,
isSys: false,
isEnv: false,
isChatVar: false,
nodeId: 'node-1',
objPath: [],
})).toBeUndefined()
})
it('should filter out invalid vars and apply search text', () => {
const vars = filterReferenceVars([
{
title: 'Node A',
nodeId: 'node-a',
vars: [
{ variable: 'valid_name', type: VarType.string },
{ variable: 'invalid-key', type: VarType.string },
],
},
{
title: 'Node B',
nodeId: 'node-b',
vars: [{ variable: 'another_value', type: VarType.string }],
},
] as NodeOutPutVar[], 'another')
expect(vars).toHaveLength(1)
expect(vars[0].title).toBe('Node B')
expect(vars[0].vars).toEqual([expect.objectContaining({ variable: 'another_value' })])
})
})

View File

@ -0,0 +1,226 @@
import type { NodeOutPutVar } from '@/app/components/workflow/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { VarType } from '@/app/components/workflow/types'
import VarReferenceVars from '../var-reference-vars'
vi.mock('../object-child-tree-panel/picker', () => ({
default: ({
onHovering,
onSelect,
}: {
onHovering?: (value: boolean) => void
onSelect?: (value: string[]) => void
}) => (
<div>
<button onMouseEnter={() => onHovering?.(true)} onMouseLeave={() => onHovering?.(false)}>
picker-panel
</button>
<button onClick={() => onSelect?.(['node-obj', 'payload', 'child'])}>pick-child</button>
</div>
),
}))
vi.mock('../manage-input-field', () => ({
default: ({ onManage }: { onManage: () => void }) => <button onClick={onManage}>manage-input</button>,
}))
describe('VarReferenceVars', () => {
const createVars = (vars: NodeOutPutVar[]) => vars
const baseVars = createVars([{
title: 'Node A',
nodeId: 'node-a',
vars: [{ variable: 'valid_name', type: VarType.string }],
}])
it('should filter vars through the search box and call onClose on escape', () => {
const onClose = vi.fn()
render(
<VarReferenceVars
vars={baseVars}
onChange={vi.fn()}
onClose={onClose}
/>,
)
fireEvent.change(screen.getByPlaceholderText('workflow.common.searchVar'), {
target: { value: 'valid' },
})
expect(screen.getByText('valid_name')).toBeInTheDocument()
fireEvent.keyDown(screen.getByPlaceholderText('workflow.common.searchVar'), { key: 'Escape' })
expect(onClose).toHaveBeenCalledTimes(1)
})
it('should call onChange when a variable item is chosen', () => {
const onChange = vi.fn()
render(
<VarReferenceVars
vars={baseVars}
onChange={onChange}
/>,
)
fireEvent.click(screen.getByText('valid_name'))
expect(onChange).toHaveBeenCalledWith(['node-a', 'valid_name'], expect.objectContaining({
variable: 'valid_name',
}))
})
it('should render empty state and manage input action', () => {
const onManageInputField = vi.fn()
render(
<VarReferenceVars
vars={[]}
onChange={vi.fn()}
showManageInputField
onManageInputField={onManageInputField}
/>,
)
expect(screen.getByText('workflow.common.noVar')).toBeInTheDocument()
fireEvent.click(screen.getByText('manage-input'))
expect(onManageInputField).toHaveBeenCalledTimes(1)
})
it('should render special variable labels and schema types', () => {
render(
<VarReferenceVars
hideSearch
preferSchemaType
vars={createVars([
{
title: 'Specials',
nodeId: 'node-special',
vars: [
{ variable: 'env.API_KEY', type: VarType.string, schemaType: 'secret' },
{ variable: 'conversation.user_name', type: VarType.string, des: 'User name' },
{ variable: 'retrieval.source.title', type: VarType.string, isRagVariable: true },
],
},
])}
onChange={vi.fn()}
/>,
)
expect(screen.queryByPlaceholderText('workflow.common.searchVar')).not.toBeInTheDocument()
expect(screen.getByText('API_KEY')).toBeInTheDocument()
expect(screen.getByText('user_name')).toBeInTheDocument()
expect(screen.getByText('secret')).toBeInTheDocument()
})
it('should render flat vars and the last output separator', () => {
render(
<VarReferenceVars
hideSearch
vars={createVars([
{
title: 'Flat',
nodeId: 'node-flat',
isFlat: true,
vars: [{ variable: 'current', type: VarType.string }],
},
{
title: 'Node B',
nodeId: 'node-b',
vars: [{ variable: 'payload', type: VarType.string }],
},
])}
onChange={vi.fn()}
/>,
)
expect(screen.getByText('workflow.debug.lastOutput')).toBeInTheDocument()
expect(screen.getByText('current_prompt')).toBeInTheDocument()
})
it('should resolve selectors for special variables and file support', () => {
const onChange = vi.fn()
render(
<VarReferenceVars
hideSearch
isSupportFileVar
vars={createVars([
{
title: 'Specials',
nodeId: 'node-special',
vars: [
{ variable: 'env.API_KEY', type: VarType.string },
{ variable: 'conversation.user_name', type: VarType.string, des: 'User name' },
{ variable: 'current', type: VarType.string },
{ variable: 'asset', type: VarType.file },
],
},
])}
onChange={onChange}
/>,
)
fireEvent.click(screen.getByText('API_KEY'))
fireEvent.click(screen.getByText('user_name'))
fireEvent.click(screen.getByText('current'))
fireEvent.click(screen.getByText('asset'))
expect(onChange).toHaveBeenNthCalledWith(1, ['env', 'API_KEY'], expect.objectContaining({ variable: 'env.API_KEY' }))
expect(onChange).toHaveBeenNthCalledWith(2, ['conversation', 'user_name'], expect.objectContaining({ variable: 'conversation.user_name' }))
expect(onChange).toHaveBeenNthCalledWith(3, ['node-special', 'current'], expect.objectContaining({ variable: 'current' }))
expect(onChange).toHaveBeenNthCalledWith(4, ['node-special', 'asset'], expect.objectContaining({ variable: 'asset' }))
})
it('should render object vars and select them by node path', () => {
const onChange = vi.fn()
render(
<VarReferenceVars
hideSearch
vars={createVars([
{
title: 'Object vars',
nodeId: 'node-obj',
vars: [{
variable: 'payload',
type: VarType.object,
children: [{ variable: 'child', type: VarType.string }],
}],
},
])}
onChange={onChange}
/>,
)
fireEvent.click(screen.getByText('payload'))
expect(onChange).toHaveBeenCalledWith(['node-obj', 'payload'], expect.objectContaining({
variable: 'payload',
}))
})
it('should ignore file vars when file support is disabled and forward blur events', () => {
const onChange = vi.fn()
const onBlur = vi.fn()
render(
<VarReferenceVars
vars={createVars([
{
title: 'Files',
nodeId: 'node-files',
vars: [{ variable: 'asset', type: VarType.file }],
},
])}
onChange={onChange}
onBlur={onBlur}
/>,
)
fireEvent.blur(screen.getByPlaceholderText('workflow.common.searchVar'))
expect(onBlur).toHaveBeenCalledTimes(1)
fireEvent.click(screen.getByText('asset'))
expect(onChange).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,222 @@
'use client'
import type { VarType as VarKindType } from '../../../tool/types'
import type { CredentialFormSchema, FormOption } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { CommonNodeType, Node, NodeOutPutVar, ValueSelector } from '@/app/components/workflow/types'
import { VAR_SHOW_NAME_MAP } from '@/app/components/workflow/constants'
import { getNodeInfoById, isConversationVar, isENV, isGlobalVar, isRagVariableVar, isSystemVar, isValueSelectorInNodeOutputVars } from './utils'
type DynamicSchemaParams = {
dynamicOptions: FormOption[] | null
isLoading: boolean
schema?: Partial<CredentialFormSchema>
value: ValueSelector | string
}
type VariableCategoryParams = {
isChatVar: boolean
isEnv: boolean
isGlobal: boolean
isLoopVar: boolean
isRagVar: boolean
}
type OutputVarNodeParams = {
availableNodes: Node[]
hasValue: boolean
isConstant: boolean
isIterationVar: boolean
isLoopVar: boolean
iterationNode: Node<CommonNodeType> | null
loopNode: Node<CommonNodeType> | null
outputVarNodeId: string
startNode?: Node | null
value: ValueSelector | string
}
export const getVarKindOptions = (variableLabel = 'Variable', constantLabel = 'Constant') => ([
{ label: variableLabel, value: 'variable' as VarKindType },
{ label: constantLabel, value: 'constant' as VarKindType },
])
export const getHasValue = (isConstant: boolean, value: ValueSelector | string) =>
!isConstant && value.length > 0
export const getIsIterationVar = (
isInIteration: boolean,
value: ValueSelector | string,
parentId?: string,
) => {
if (!isInIteration || !Array.isArray(value))
return false
return value[0] === parentId && ['item', 'index'].includes(value[1])
}
export const getIsLoopVar = (
isInLoop: boolean,
value: ValueSelector | string,
parentId?: string,
) => {
if (!isInLoop || !Array.isArray(value))
return false
return value[0] === parentId && ['item', 'index'].includes(value[1])
}
export const getOutputVarNode = ({
availableNodes,
hasValue,
isConstant,
isIterationVar,
isLoopVar,
iterationNode,
loopNode,
outputVarNodeId,
startNode,
value,
}: OutputVarNodeParams) => {
if (!hasValue || isConstant)
return null
if (isIterationVar)
return iterationNode?.data ?? null
if (isLoopVar)
return loopNode?.data ?? null
if (isSystemVar(value as ValueSelector))
return startNode?.data ?? null
const node = getNodeInfoById(availableNodes, outputVarNodeId)?.data
if (!node)
return null
return {
...node,
id: outputVarNodeId,
}
}
export const getVarDisplayName = (
hasValue: boolean,
value: ValueSelector | string,
) => {
if (!hasValue || !Array.isArray(value))
return ''
const showName = VAR_SHOW_NAME_MAP[value.join('.')]
if (showName)
return showName
const isSystem = isSystemVar(value)
const varName = value[value.length - 1] ?? ''
return `${isSystem ? 'sys.' : ''}${varName}`
}
export const getVariableMeta = (
hasValue: boolean,
outputVars: NodeOutPutVar[],
value: ValueSelector | string,
) => {
const selector = value as ValueSelector
const isEnv = isENV(selector)
const isChatVar = isConversationVar(selector)
const isGlobal = isGlobalVar(selector)
const isRagVar = isRagVariableVar(selector)
const isValidVar = !hasValue || !Array.isArray(value)
? true
: isValueSelectorInNodeOutputVars(value, outputVars)
return {
isChatVar,
isEnv,
isGlobal,
isRagVar,
isValidVar,
}
}
export const getVariableCategory = ({
isChatVar,
isEnv,
isGlobal,
isLoopVar,
isRagVar,
}: VariableCategoryParams) => {
if (isEnv)
return 'environment'
if (isChatVar)
return 'conversation'
if (isGlobal)
return 'global'
if (isLoopVar)
return 'loop'
if (isRagVar)
return 'rag'
return 'system'
}
export const getWidthAllocations = (
triggerWidth: number,
nodeTitle: string,
varName: string,
type: string,
) => {
const availableWidth = triggerWidth - 56
const totalTextLength = (nodeTitle + varName + type).length || 1
const priorityWidth = 15
return {
maxNodeNameWidth: priorityWidth + Math.floor(nodeTitle.length / totalTextLength * availableWidth),
maxTypeWidth: Math.floor(type.length / totalTextLength * availableWidth),
maxVarNameWidth: -priorityWidth + Math.floor(varName.length / totalTextLength * availableWidth),
}
}
export const getDynamicSelectSchema = ({
dynamicOptions,
isLoading,
schema,
value,
}: DynamicSchemaParams) => {
if (schema?.type !== 'dynamic-select')
return schema
if (dynamicOptions) {
return {
...schema,
options: dynamicOptions,
}
}
if (isLoading && value && typeof value === 'string') {
return {
...schema,
options: [{
value,
label: { en_US: value, zh_Hans: value },
show_on: [],
}],
}
}
return {
...schema,
options: [],
}
}
export const getTooltipContent = (
hasValue: boolean,
isShowAPart: boolean,
isValidVar: boolean,
) => {
if (isValidVar && isShowAPart)
return 'full-path'
if (!isValidVar && hasValue)
return 'invalid-variable'
return null
}
export const getOutputVarNodeId = (hasValue: boolean, value: ValueSelector | string) =>
hasValue && Array.isArray(value) ? value[0] : ''
export const isShowAPartSelector = (value: ValueSelector | string) =>
Array.isArray(value) && value.length > 2 && !isRagVariableVar(value)

View File

@ -0,0 +1,332 @@
'use client'
import type { FC, ReactNode } from 'react'
import type { VarType as VarKindType } from '../../../tool/types'
import type { CredentialFormSchema, CredentialFormSchemaSelect } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { Tool } from '@/app/components/tools/types'
import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types'
import type { Node, ToolWithProvider, ValueSelector, Var } from '@/app/components/workflow/types'
import { RiArrowDownSLine, RiCloseLine, RiErrorWarningFill, RiLoader4Line, RiMoreLine } from '@remixicon/react'
import Badge from '@/app/components/base/badge'
import AddButton from '@/app/components/base/button/add-button'
import { Line3 } from '@/app/components/base/icons/src/public/common'
import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
import { VarBlockIcon } from '@/app/components/workflow/block-icon'
import TypeSelector from '@/app/components/workflow/nodes/_base/components/selector'
import { VariableIconWithColor } from '@/app/components/workflow/nodes/_base/components/variable/variable-label'
import { cn } from '@/utils/classnames'
import RemoveButton from '../remove-button'
import ConstantField from './constant-field'
type Props = {
className?: string
controlFocus: number
currentProvider?: ToolWithProvider | TriggerWithProvider
currentTool?: Tool
handleClearVar: () => void
handleVarKindTypeChange: (value: VarKindType) => void
handleVariableJump: (nodeId: string) => void
hasValue: boolean
inputRef: React.RefObject<HTMLInputElement | null>
isAddBtnTrigger?: boolean
isConstant: boolean
isEnv?: boolean
isException: boolean
isFocus: boolean
isGlobal?: boolean
isInTable?: boolean
isJustShowValue?: boolean
isChatVar?: boolean
isValidVar: boolean
isLoading: boolean
isShowAPart: boolean
isShowNodeName: boolean
isSupportConstantValue?: boolean
maxNodeNameWidth: number
maxTypeWidth: number
maxVarNameWidth: number
onChange: (value: ValueSelector | string, varKindType: VarKindType, varInfo?: Var) => void
onRemove?: () => void
open: boolean
outputVarNode?: Node['data'] | null
outputVarNodeId?: string
placeholder?: string
readonly: boolean
schemaWithDynamicSelect?: Partial<CredentialFormSchema>
setControlFocus: (value: number) => void
setOpen: (value: boolean) => void
tooltipPopup: ReactNode
triggerRef: React.RefObject<HTMLDivElement | null>
type?: string
typePlaceHolder?: string
value: ValueSelector | string
valueTypePlaceHolder?: string
varKindType: VarKindType
varKindTypes: Array<{ label: string, value: VarKindType }>
varName: string
variableCategory: string
WrapElem: React.ElementType
VarPickerWrap: React.ElementType
}
const VarReferencePickerTrigger: FC<Props> = ({
className,
controlFocus,
handleClearVar,
handleVarKindTypeChange,
handleVariableJump,
hasValue,
inputRef,
isAddBtnTrigger,
isConstant,
isEnv,
isException,
isFocus,
isGlobal,
isInTable,
isJustShowValue,
isChatVar,
isValidVar,
isLoading,
isShowAPart,
isShowNodeName,
isSupportConstantValue,
maxNodeNameWidth,
maxTypeWidth,
maxVarNameWidth,
onChange,
onRemove,
open,
outputVarNode,
outputVarNodeId,
placeholder,
readonly,
schemaWithDynamicSelect,
setControlFocus,
setOpen,
tooltipPopup,
triggerRef,
type,
typePlaceHolder,
value,
valueTypePlaceHolder,
varKindType,
varKindTypes,
varName,
variableCategory,
VarPickerWrap,
WrapElem,
}) => {
return (
<WrapElem
onClick={() => {
if (readonly)
return
if (!isConstant)
setOpen(!open)
else
setControlFocus(Date.now())
}}
className={cn(className, 'group/picker-trigger-wrap relative !flex', !readonly && 'cursor-pointer')}
data-testid="var-reference-picker-trigger"
>
<>
{isAddBtnTrigger
? (
<div>
<AddButton onClick={() => {}}></AddButton>
</div>
)
: (
<div ref={!isSupportConstantValue ? triggerRef : null} className={cn((open || isFocus) ? 'border-gray-300' : 'border-gray-100', 'group/wrap relative flex h-8 w-full items-center', !isSupportConstantValue && 'rounded-lg bg-components-input-bg-normal p-1', isInTable && 'border-none bg-transparent', readonly && 'bg-components-input-bg-disabled', isJustShowValue && 'h-6 bg-transparent p-0')}>
{isSupportConstantValue
? (
<div
onClick={(e) => {
e.stopPropagation()
setOpen(false)
setControlFocus(Date.now())
}}
className="mr-1 flex h-full items-center space-x-1"
>
<TypeSelector
noLeft
trigger={(
<div className="flex h-8 items-center bg-components-input-bg-normal px-2 radius-md">
<div className="mr-1 text-components-input-text-filled system-sm-regular">{varKindTypes.find(item => item.value === varKindType)?.label}</div>
<RiArrowDownSLine className="h-4 w-4 text-text-quaternary" />
</div>
)}
popupClassName="top-8"
readonly={readonly}
value={varKindType}
options={varKindTypes}
onChange={handleVarKindTypeChange}
showChecked
/>
</div>
)
: (!hasValue && (
<div className="ml-1.5 mr-1">
<Variable02 className={`h-4 w-4 ${readonly ? 'text-components-input-text-disabled' : 'text-components-input-text-placeholder'}`} />
</div>
))}
{isConstant
? (
<ConstantField
value={value as string}
onChange={onChange as ((value: string | number, varKindType: VarKindType, varInfo?: Var) => void)}
schema={schemaWithDynamicSelect as CredentialFormSchemaSelect}
readonly={readonly}
isLoading={isLoading}
/>
)
: (
<VarPickerWrap
onClick={() => {
if (readonly)
return
if (!isConstant)
setOpen(!open)
else
setControlFocus(Date.now())
}}
className="h-full grow"
>
<div ref={isSupportConstantValue ? triggerRef : null} className={cn('h-full', isSupportConstantValue && 'flex items-center rounded-lg bg-components-panel-bg py-1 pl-1')}>
<Tooltip>
<TooltipTrigger
disabled={!tooltipPopup}
render={(
<div className={cn('h-full items-center rounded-[5px] px-1.5', hasValue ? 'inline-flex bg-components-badge-white-to-dark' : 'flex')}>
{hasValue
? (
<>
{isShowNodeName && (
<div
className="flex items-center"
onClick={(e) => {
if (e.metaKey || e.ctrlKey)
handleVariableJump(outputVarNodeId || '')
}}
>
<div className="h-3 px-[1px]">
{outputVarNode?.type && (
<VarBlockIcon
className="!text-text-primary"
type={outputVarNode.type}
/>
)}
</div>
<div
className="mx-0.5 truncate text-xs font-medium text-text-secondary"
title={outputVarNode?.title as string | undefined}
style={{ maxWidth: maxNodeNameWidth }}
>
{outputVarNode?.title as string | undefined}
</div>
<Line3 className="mr-0.5"></Line3>
</div>
)}
{isShowAPart && (
<div className="flex items-center">
<RiMoreLine className="h-3 w-3 text-text-secondary" />
<Line3 className="mr-0.5 text-divider-deep"></Line3>
</div>
)}
<div className="flex items-center text-text-accent">
{isLoading && <RiLoader4Line className="h-3.5 w-3.5 animate-spin text-text-secondary" />}
<VariableIconWithColor
variables={value as ValueSelector}
variableCategory={variableCategory}
isExceptionVariable={isException}
/>
<div
className={cn(
'ml-0.5 truncate text-xs font-medium',
isEnv && '!text-text-secondary',
isChatVar && 'text-util-colors-teal-teal-700',
isException && 'text-text-warning',
isGlobal && 'text-util-colors-orange-orange-600',
)}
title={varName}
style={{ maxWidth: maxVarNameWidth }}
>
{varName}
</div>
</div>
<div
className="ml-0.5 truncate text-center capitalize text-text-tertiary system-xs-regular"
title={type}
style={{ maxWidth: maxTypeWidth }}
>
{type}
</div>
{!isValidVar && <RiErrorWarningFill className="ml-0.5 h-3 w-3 text-text-destructive" />}
</>
)
: (
<div className={`overflow-hidden ${readonly ? 'text-components-input-text-disabled' : 'text-components-input-text-placeholder'} text-ellipsis system-sm-regular`}>
{isLoading
? (
<div className="flex items-center">
<RiLoader4Line className="mr-1 h-3.5 w-3.5 animate-spin text-text-secondary" />
<span>{placeholder}</span>
</div>
)
: placeholder}
</div>
)}
</div>
)}
/>
{tooltipPopup !== null && tooltipPopup !== undefined && (
<TooltipContent variant="plain">
{tooltipPopup}
</TooltipContent>
)}
</Tooltip>
</div>
</VarPickerWrap>
)}
{(hasValue && !readonly && !isInTable && !isJustShowValue) && (
<div
className="group invisible absolute right-1 top-[50%] h-5 translate-y-[-50%] cursor-pointer rounded-md p-1 hover:bg-state-base-hover group-hover/wrap:visible"
onClick={handleClearVar}
data-testid="var-reference-picker-clear"
>
<RiCloseLine className="h-3.5 w-3.5 text-text-tertiary group-hover:text-text-secondary" />
</div>
)}
{!hasValue && valueTypePlaceHolder && (
<Badge
className="absolute right-1 top-[50%] translate-y-[-50%] capitalize"
text={valueTypePlaceHolder}
uppercase={false}
/>
)}
</div>
)}
{!readonly && isInTable && (
<RemoveButton
className="absolute right-1 top-0.5 hidden group-hover/picker-trigger-wrap:block"
onClick={() => onRemove?.()}
/>
)}
{!hasValue && typePlaceHolder && (
<Badge
className="absolute right-2 top-1.5"
text={typePlaceHolder}
uppercase={false}
/>
)}
</>
<input ref={inputRef} className="sr-only" value={controlFocus} readOnly />
</WrapElem>
)
}
export default VarReferencePickerTrigger

View File

@ -4,13 +4,6 @@ import type { CredentialFormSchema, CredentialFormSchemaSelect, FormOption } fro
import type { Tool } from '@/app/components/tools/types'
import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types'
import type { CommonNodeType, Node, NodeOutPutVar, ToolWithProvider, ValueSelector, Var } from '@/app/components/workflow/types'
import {
RiArrowDownSLine,
RiCloseLine,
RiErrorWarningFill,
RiLoader4Line,
RiMoreLine,
} from '@remixicon/react'
import { noop } from 'es-toolkit/function'
import { produce } from 'immer'
import * as React from 'react'
@ -21,36 +14,41 @@ import {
useReactFlow,
useStoreApi,
} from 'reactflow'
import Badge from '@/app/components/base/badge'
import AddButton from '@/app/components/base/button/add-button'
import { Line3 } from '@/app/components/base/icons/src/public/common'
import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import Tooltip from '@/app/components/base/tooltip'
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { VarBlockIcon } from '@/app/components/workflow/block-icon'
import { VAR_SHOW_NAME_MAP } from '@/app/components/workflow/constants'
import {
useIsChatMode,
useWorkflowVariables,
} from '@/app/components/workflow/hooks'
// import type { BaseResource, BaseResourceProvider } from '@/app/components/workflow/nodes/_base/types'
import TypeSelector from '@/app/components/workflow/nodes/_base/components/selector'
import { VariableIconWithColor } from '@/app/components/workflow/nodes/_base/components/variable/variable-label'
import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types'
import { BlockEnum } from '@/app/components/workflow/types'
import { isExceptionVariable } from '@/app/components/workflow/utils'
import { useFetchDynamicOptions } from '@/service/use-plugins'
import { cn } from '@/utils/classnames'
import useAvailableVarList from '../../hooks/use-available-var-list'
import RemoveButton from '../remove-button'
import ConstantField from './constant-field'
import { getNodeInfoById, isConversationVar, isENV, isGlobalVar, isRagVariableVar, isSystemVar, isValueSelectorInNodeOutputVars, removeFileVars, varTypeToStructType } from './utils'
import { removeFileVars, varTypeToStructType } from './utils'
import VarFullPathPanel from './var-full-path-panel'
import {
getDynamicSelectSchema,
getHasValue,
getIsIterationVar,
getIsLoopVar,
getOutputVarNode,
getOutputVarNodeId,
getTooltipContent,
getVarDisplayName,
getVariableCategory,
getVariableMeta,
getVarKindOptions,
getWidthAllocations,
isShowAPartSelector,
} from './var-reference-picker.helpers'
import VarReferencePickerTrigger from './var-reference-picker.trigger'
import VarReferencePopup from './var-reference-popup'
const TRIGGER_DEFAULT_WIDTH = 227
@ -142,18 +140,19 @@ const VarReferencePicker: FC<Props> = ({
})
const node = nodes.find(n => n.id === nodeId)
const isInIteration = !!(node?.data as any)?.isInIteration
const iterationNode = isInIteration ? nodes.find(n => n.id === node?.parentId) : null
const nodeData = node?.data as Node['data'] & { isInIteration?: boolean, isInLoop?: boolean }
const isInIteration = !!nodeData?.isInIteration
const iterationNode = isInIteration ? (nodes.find(n => n.id === node?.parentId) ?? null) : null
const isInLoop = !!(node?.data as any)?.isInLoop
const loopNode = isInLoop ? nodes.find(n => n.id === node?.parentId) : null
const isInLoop = !!nodeData?.isInLoop
const loopNode = isInLoop ? (nodes.find(n => n.id === node?.parentId) ?? null) : null
const triggerRef = useRef<HTMLDivElement>(null)
const [triggerWidth, setTriggerWidth] = useState(TRIGGER_DEFAULT_WIDTH)
useEffect(() => {
if (triggerRef.current)
setTriggerWidth(triggerRef.current.clientWidth)
}, [triggerRef.current])
}, [])
const [varKindType, setVarKindType] = useState<VarKindType>(defaultVarKindType)
const isConstant = isSupportConstantValue && varKindType === VarKindType.constant
@ -167,71 +166,40 @@ const VarReferencePicker: FC<Props> = ({
useEffect(() => {
onOpen()
}, [open, onOpen])
const hasValue = !isConstant && value.length > 0
const hasValue = getHasValue(!!isConstant, value)
const isIterationVar = useMemo(() => {
if (!isInIteration)
return false
if (value[0] === node?.parentId && ['item', 'index'].includes(value[1]))
return true
return false
}, [isInIteration, value, node])
const isIterationVar = useMemo(
() => getIsIterationVar(isInIteration, value, node?.parentId),
[isInIteration, node?.parentId, value],
)
const isLoopVar = useMemo(() => {
if (!isInLoop)
return false
if (value[0] === node?.parentId && ['item', 'index'].includes(value[1]))
return true
return false
}, [isInLoop, value, node])
const isLoopVar = useMemo(
() => getIsLoopVar(isInLoop, value, node?.parentId),
[isInLoop, node?.parentId, value],
)
const outputVarNodeId = hasValue ? value[0] : ''
const outputVarNode = useMemo(() => {
if (!hasValue || isConstant)
return null
const outputVarNodeId = getOutputVarNodeId(hasValue, value)
const outputVarNode = useMemo(() => getOutputVarNode({
availableNodes,
hasValue,
isConstant: !!isConstant,
isIterationVar,
isLoopVar,
iterationNode,
loopNode,
outputVarNodeId,
startNode,
value,
}), [availableNodes, hasValue, isConstant, isIterationVar, isLoopVar, iterationNode, loopNode, outputVarNodeId, startNode, value])
if (isIterationVar)
return iterationNode?.data
const isShowAPart = isShowAPartSelector(value)
if (isLoopVar)
return loopNode?.data
const varName = useMemo(
() => getVarDisplayName(hasValue, value),
[hasValue, value],
)
if (isSystemVar(value as ValueSelector))
return startNode?.data
const node = getNodeInfoById(availableNodes, outputVarNodeId)?.data
if (node) {
return {
...node,
id: outputVarNodeId,
}
}
}, [value, hasValue, isConstant, isIterationVar, iterationNode, availableNodes, outputVarNodeId, startNode, isLoopVar, loopNode])
const isShowAPart = (value as ValueSelector).length > 2 && !isRagVariableVar((value as ValueSelector))
const varName = useMemo(() => {
if (!hasValue)
return ''
const showName = VAR_SHOW_NAME_MAP[(value as ValueSelector).join('.')]
if (showName)
return showName
const isSystem = isSystemVar(value as ValueSelector)
const varName = Array.isArray(value) ? value[(value as ValueSelector).length - 1] : ''
return `${isSystem ? 'sys.' : ''}${varName}`
}, [hasValue, value])
const varKindTypes = [
{
label: 'Variable',
value: VarKindType.variable,
},
{
label: 'Constant',
value: VarKindType.constant,
},
]
const varKindTypes = getVarKindOptions()
const handleVarKindTypeChange = useCallback((value: VarKindType) => {
setVarKindType(value)
@ -242,13 +210,11 @@ const VarReferencePicker: FC<Props> = ({
}, [onChange])
const inputRef = useRef<HTMLInputElement>(null)
const [isFocus, setIsFocus] = useState(false)
const [controlFocus, setControlFocus] = useState(0)
const isFocus = controlFocus > 0
useEffect(() => {
if (controlFocus && inputRef.current) {
if (controlFocus && inputRef.current)
inputRef.current.focus()
setIsFocus(true)
}
}, [controlFocus])
const handleVarReferenceChange = useCallback((value: ValueSelector, varInfo: Var) => {
@ -274,7 +240,7 @@ const VarReferencePicker: FC<Props> = ({
}, [onChange, varKindType])
const handleVariableJump = useCallback((nodeId: string) => {
const currentNode = nodes.find(node => node.id === nodeId)
const currentNode = availableNodes.find(node => node.id === nodeId)
if (!currentNode)
return
@ -296,10 +262,10 @@ const VarReferencePicker: FC<Props> = ({
y: (clientHeight - currentNode.height! * zoom) / 2 - position.y * zoom,
zoom: transform[2],
})
}, [nodes, reactflow, store])
}, [availableNodes, reactflow, store])
const type = getCurrentVariableType({
parentNode: (isInIteration ? iterationNode : loopNode) as any,
parentNode: isInIteration ? iterationNode : loopNode,
valueSelector: value as ValueSelector,
availableNodes,
isChatMode,
@ -307,41 +273,28 @@ const VarReferencePicker: FC<Props> = ({
preferSchemaType,
})
const { isEnv, isChatVar, isGlobal, isRagVar, isValidVar, isException } = useMemo(() => {
const isEnv = isENV(value as ValueSelector)
const isChatVar = isConversationVar(value as ValueSelector)
const isGlobal = isGlobalVar(value as ValueSelector)
const isRagVar = isRagVariableVar(value as ValueSelector)
const isValidVar = !hasValue || !Array.isArray(value)
? true
: isValueSelectorInNodeOutputVars(value, outputVars)
const isException = isExceptionVariable(varName, outputVarNode?.type)
return {
isEnv,
isChatVar,
isGlobal,
isRagVar,
isValidVar,
isException,
}
}, [value, hasValue, outputVarNode, outputVars, varName])
const { isEnv, isChatVar, isGlobal, isRagVar, isValidVar } = useMemo(
() => getVariableMeta(hasValue, outputVars, value),
[hasValue, outputVars, value],
)
const isException = useMemo(
() => isExceptionVariable(varName, outputVarNode?.type),
[outputVarNode?.type, varName],
)
// 8(left/right-padding) + 14(icon) + 4 + 14 + 2 = 42 + 17 buff
const availableWidth = triggerWidth - 56
const [maxNodeNameWidth, maxVarNameWidth, maxTypeWidth] = (() => {
const totalTextLength = ((outputVarNode?.title || '') + (varName || '') + (type || '')).length
const PRIORITY_WIDTH = 15
const maxNodeNameWidth = PRIORITY_WIDTH + Math.floor((outputVarNode?.title?.length || 0) / totalTextLength * availableWidth)
const maxVarNameWidth = -PRIORITY_WIDTH + Math.floor((varName?.length || 0) / totalTextLength * availableWidth)
const maxTypeWidth = Math.floor((type?.length || 0) / totalTextLength * availableWidth)
return [maxNodeNameWidth, maxVarNameWidth, maxTypeWidth]
})()
const {
maxNodeNameWidth,
maxTypeWidth,
maxVarNameWidth,
} = getWidthAllocations(triggerWidth, outputVarNode?.title || '', varName || '', type || '')
const WrapElem = isSupportConstantValue ? 'div' : PortalToFollowElemTrigger
const VarPickerWrap = !isSupportConstantValue ? 'div' : PortalToFollowElemTrigger
const tooltipPopup = useMemo(() => {
if (isValidVar && isShowAPart) {
const tooltipType = getTooltipContent(hasValue, isShowAPart, isValidVar)
if (tooltipType === 'full-path') {
return (
<VarFullPathPanel
nodeName={outputVarNode?.title}
@ -351,7 +304,7 @@ const VarReferencePicker: FC<Props> = ({
/>
)
}
if (!isValidVar && hasValue)
if (tooltipType === 'invalid-variable')
return t('errorMsg.invalidVariable', { ns: 'workflow' })
return null
@ -366,7 +319,7 @@ const VarReferencePicker: FC<Props> = ({
(schema as CredentialFormSchemaSelect)?.variable || '',
'tool',
)
const handleFetchDynamicOptions = async () => {
const handleFetchDynamicOptions = useCallback(async () => {
if (schema?.type !== FormTypeEnum.dynamicSelect || !currentTool || !currentProvider)
return
setIsLoading(true)
@ -377,58 +330,25 @@ const VarReferencePicker: FC<Props> = ({
finally {
setIsLoading(false)
}
}
}, [currentProvider, currentTool, fetchDynamicOptions, schema?.type])
useEffect(() => {
handleFetchDynamicOptions()
}, [currentTool, currentProvider, schema])
}, [handleFetchDynamicOptions])
const schemaWithDynamicSelect = useMemo(() => {
if (schema?.type !== FormTypeEnum.dynamicSelect)
return schema
// rewrite schema.options with dynamicOptions
if (dynamicOptions) {
return {
...schema,
options: dynamicOptions,
}
}
const schemaWithDynamicSelect = useMemo(
() => getDynamicSelectSchema({ dynamicOptions, isLoading, schema, value }),
[dynamicOptions, isLoading, schema, value],
)
// If we don't have dynamic options but we have a selected value, create a temporary option to preserve the selection during loading
if (isLoading && value && typeof value === 'string') {
const preservedOptions = [{
value,
label: { en_US: value, zh_Hans: value },
show_on: [],
}]
return {
...schema,
options: preservedOptions,
}
}
const variableCategory = useMemo(
() => getVariableCategory({ isChatVar, isEnv, isGlobal, isLoopVar, isRagVar }),
[isChatVar, isEnv, isGlobal, isLoopVar, isRagVar],
)
// Default case: return schema with empty options
return {
...schema,
options: [],
}
}, [schema, dynamicOptions, isLoading, value])
const variableCategory = useMemo(() => {
if (isEnv)
return 'environment'
if (isChatVar)
return 'conversation'
if (isGlobal)
return 'global'
if (isLoopVar)
return 'loop'
if (isRagVar)
return 'rag'
return 'system'
}, [isEnv, isChatVar, isGlobal, isLoopVar, isRagVar])
const triggerPlaceholder = placeholder ?? t('common.setVarValuePlaceholder', { ns: 'workflow' })
return (
<div className={cn(className, !readonly && 'cursor-pointer')}>
<div className={cn(className)}>
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
@ -436,204 +356,56 @@ const VarReferencePicker: FC<Props> = ({
>
{!!trigger && <PortalToFollowElemTrigger onClick={() => setOpen(!open)}>{trigger}</PortalToFollowElemTrigger>}
{!trigger && (
<WrapElem
onClick={() => {
if (readonly)
return
if (!isConstant)
setOpen(!open)
else
setControlFocus(Date.now())
}}
className="group/picker-trigger-wrap relative !flex"
>
<>
{isAddBtnTrigger
? (
<div>
<AddButton onClick={noop}></AddButton>
</div>
)
: (
<div ref={!isSupportConstantValue ? triggerRef : null} className={cn((open || isFocus) ? 'border-gray-300' : 'border-gray-100', 'group/wrap relative flex h-8 w-full items-center', !isSupportConstantValue && 'rounded-lg bg-components-input-bg-normal p-1', isInTable && 'border-none bg-transparent', readonly && 'bg-components-input-bg-disabled', isJustShowValue && 'h-6 bg-transparent p-0')}>
{isSupportConstantValue
? (
<div
onClick={(e) => {
e.stopPropagation()
setOpen(false)
setControlFocus(Date.now())
}}
className="mr-1 flex h-full items-center space-x-1"
>
<TypeSelector
noLeft
trigger={(
<div className="flex h-8 items-center bg-components-input-bg-normal px-2 radius-md">
<div className="mr-1 text-components-input-text-filled system-sm-regular">{varKindTypes.find(item => item.value === varKindType)?.label}</div>
<RiArrowDownSLine className="h-4 w-4 text-text-quaternary" />
</div>
)}
popupClassName="top-8"
readonly={readonly}
value={varKindType}
options={varKindTypes}
onChange={handleVarKindTypeChange}
showChecked
/>
</div>
)
: (!hasValue && (
<div className="ml-1.5 mr-1">
<Variable02 className={`h-4 w-4 ${readonly ? 'text-components-input-text-disabled' : 'text-components-input-text-placeholder'}`} />
</div>
))}
{isConstant
? (
<ConstantField
value={value as string}
onChange={onChange as ((value: string | number, varKindType: VarKindType, varInfo?: Var) => void)}
schema={schemaWithDynamicSelect as CredentialFormSchema}
readonly={readonly}
isLoading={isLoading}
/>
)
: (
<VarPickerWrap
onClick={() => {
if (readonly)
return
if (!isConstant)
setOpen(!open)
else
setControlFocus(Date.now())
}}
className="h-full grow"
>
<div ref={isSupportConstantValue ? triggerRef : null} className={cn('h-full', isSupportConstantValue && 'flex items-center rounded-lg bg-components-panel-bg py-1 pl-1')}>
<Tooltip noDecoration={isShowAPart} popupContent={tooltipPopup}>
<div className={cn('h-full items-center rounded-[5px] px-1.5', hasValue ? 'inline-flex bg-components-badge-white-to-dark' : 'flex')}>
{hasValue
? (
<>
{isShowNodeName && !isEnv && !isChatVar && !isGlobal && !isRagVar && (
<div
className="flex items-center"
onClick={(e) => {
if (e.metaKey || e.ctrlKey) {
e.stopPropagation()
handleVariableJump(outputVarNode?.id)
}
}}
>
<div className="h-3 px-[1px]">
{outputVarNode?.type && (
<VarBlockIcon
className="!text-text-primary"
type={outputVarNode.type}
/>
)}
</div>
<div
className="mx-0.5 truncate text-xs font-medium text-text-secondary"
title={outputVarNode?.title}
style={{
maxWidth: maxNodeNameWidth,
}}
>
{outputVarNode?.title}
</div>
<Line3 className="mr-0.5"></Line3>
</div>
)}
{isShowAPart && (
<div className="flex items-center">
<RiMoreLine className="h-3 w-3 text-text-secondary" />
<Line3 className="mr-0.5 text-divider-deep"></Line3>
</div>
)}
<div className="flex items-center text-text-accent">
{isLoading && <RiLoader4Line className="h-3.5 w-3.5 animate-spin text-text-secondary" />}
<VariableIconWithColor
variables={value as ValueSelector}
variableCategory={variableCategory}
isExceptionVariable={isException}
/>
<div
className={cn('ml-0.5 truncate text-xs font-medium', isEnv && '!text-text-secondary', isChatVar && 'text-util-colors-teal-teal-700', isException && 'text-text-warning', isGlobal && 'text-util-colors-orange-orange-600')}
title={varName}
style={{
maxWidth: maxVarNameWidth,
}}
>
{varName}
</div>
</div>
<div
className="ml-0.5 truncate text-center capitalize text-text-tertiary system-xs-regular"
title={type}
style={{
maxWidth: maxTypeWidth,
}}
>
{type}
</div>
{!isValidVar && <RiErrorWarningFill className="ml-0.5 h-3 w-3 text-text-destructive" />}
</>
)
: (
<div className={`overflow-hidden ${readonly ? 'text-components-input-text-disabled' : 'text-components-input-text-placeholder'} text-ellipsis system-sm-regular`}>
{isLoading
? (
<div className="flex items-center">
<RiLoader4Line className="mr-1 h-3.5 w-3.5 animate-spin text-text-secondary" />
<span>{placeholder ?? t('common.setVarValuePlaceholder', { ns: 'workflow' })}</span>
</div>
)
: (
placeholder ?? t('common.setVarValuePlaceholder', { ns: 'workflow' })
)}
</div>
)}
</div>
</Tooltip>
</div>
</VarPickerWrap>
)}
{(hasValue && !readonly && !isInTable && !isJustShowValue) && (
<div
className="group invisible absolute right-1 top-[50%] h-5 translate-y-[-50%] cursor-pointer rounded-md p-1 hover:bg-state-base-hover group-hover/wrap:visible"
onClick={handleClearVar}
>
<RiCloseLine className="h-3.5 w-3.5 text-text-tertiary group-hover:text-text-secondary" />
</div>
)}
{!hasValue && valueTypePlaceHolder && (
<Badge
className="absolute right-1 top-[50%] translate-y-[-50%] capitalize"
text={valueTypePlaceHolder}
uppercase={false}
/>
)}
</div>
)}
{!readonly && isInTable && (
<RemoveButton
className="absolute right-1 top-0.5 hidden group-hover/picker-trigger-wrap:block"
onClick={() => onRemove?.()}
/>
)}
{!hasValue && typePlaceHolder && (
<Badge
className="absolute right-2 top-1.5"
text={typePlaceHolder}
uppercase={false}
/>
)}
</>
</WrapElem>
<VarReferencePickerTrigger
className={className}
controlFocus={controlFocus}
currentProvider={currentProvider}
currentTool={currentTool}
handleClearVar={handleClearVar}
handleVarKindTypeChange={handleVarKindTypeChange}
handleVariableJump={handleVariableJump}
hasValue={hasValue}
inputRef={inputRef}
isAddBtnTrigger={isAddBtnTrigger}
isConstant={!!isConstant}
isEnv={isEnv}
isException={isException}
isFocus={isFocus}
isGlobal={isGlobal}
isInTable={isInTable}
isJustShowValue={isJustShowValue}
isChatVar={isChatVar}
isValidVar={isValidVar}
isLoading={isLoading}
isShowAPart={isShowAPart}
isShowNodeName={isShowNodeName && !isEnv && !isChatVar && !isGlobal && !isRagVar}
isSupportConstantValue={isSupportConstantValue}
maxNodeNameWidth={maxNodeNameWidth}
maxTypeWidth={maxTypeWidth}
maxVarNameWidth={maxVarNameWidth}
onChange={onChange}
onRemove={onRemove}
open={open}
outputVarNode={outputVarNode as Node['data'] | null}
outputVarNodeId={outputVarNodeId}
placeholder={triggerPlaceholder}
readonly={readonly}
schemaWithDynamicSelect={schemaWithDynamicSelect}
setControlFocus={setControlFocus}
setOpen={setOpen}
tooltipPopup={tooltipPopup}
triggerRef={triggerRef}
type={type}
typePlaceHolder={typePlaceHolder}
value={value}
valueTypePlaceHolder={valueTypePlaceHolder}
varKindType={varKindType}
varKindTypes={varKindTypes}
varName={varName}
variableCategory={variableCategory}
VarPickerWrap={VarPickerWrap}
WrapElem={WrapElem}
/>
)}
<PortalToFollowElemContent
style={{

View File

@ -0,0 +1,100 @@
import type { NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types'
import { VAR_SHOW_NAME_MAP } from '@/app/components/workflow/constants'
import { checkKeys } from '@/utils/var'
import { isSpecialVar } from './utils'
export const getVariableDisplayName = (
variable: string,
isFlat: boolean,
isInCodeGeneratorInstructionEditor?: boolean,
) => {
if (VAR_SHOW_NAME_MAP[variable])
return VAR_SHOW_NAME_MAP[variable]
if (!isFlat)
return variable
if (variable === 'current')
return isInCodeGeneratorInstructionEditor ? 'current_code' : 'current_prompt'
return variable
}
export const getVariableCategory = ({
isEnv,
isChatVar,
isLoopVar,
isRagVariable,
}: {
isEnv: boolean
isChatVar: boolean
isLoopVar?: boolean
isRagVariable?: boolean
}) => {
if (isEnv)
return 'environment'
if (isChatVar)
return 'conversation'
if (isLoopVar)
return 'loop'
if (isRagVariable)
return 'rag'
return 'system'
}
export const getValueSelector = ({
itemData,
isFlat,
isSupportFileVar,
isFile,
isSys,
isEnv,
isChatVar,
isRagVariable,
nodeId,
objPath,
}: {
itemData: Var
isFlat?: boolean
isSupportFileVar?: boolean
isFile: boolean
isSys: boolean
isEnv: boolean
isChatVar: boolean
isRagVariable?: boolean
nodeId: string
objPath: string[]
}): ValueSelector | undefined => {
if (!isSupportFileVar && isFile)
return undefined
if (isFlat)
return [itemData.variable]
if (isSys || isEnv || isChatVar || isRagVariable)
return [...objPath, ...itemData.variable.split('.')]
return [nodeId, ...objPath, itemData.variable]
}
const getVisibleChildren = (vars: Var[]) => {
return vars.filter(variable => checkKeys([variable.variable], false).isValid || isSpecialVar(variable.variable.split('.')[0]))
}
export const filterReferenceVars = (vars: NodeOutPutVar[], searchText: string) => {
const searchTextLower = searchText.toLowerCase()
return vars
.map(node => ({ ...node, vars: getVisibleChildren(node.vars) }))
.filter(node => node.vars.length > 0)
.filter((node) => {
if (!searchText)
return true
return node.vars.some(variable => variable.variable.toLowerCase().includes(searchTextLower))
|| node.title.toLowerCase().includes(searchTextLower)
})
.map((node) => {
if (!searchText || node.title.toLowerCase().includes(searchTextLower))
return node
return {
...node,
vars: node.vars.filter(variable => variable.variable.toLowerCase().includes(searchTextLower)),
}
})
}

View File

@ -17,7 +17,6 @@ import {
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { VAR_SHOW_NAME_MAP } from '@/app/components/workflow/constants'
import PickerStructurePanel from '@/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/picker'
import { VariableIconWithColor } from '@/app/components/workflow/nodes/_base/components/variable/variable-label'
import { VarType } from '@/app/components/workflow/types'
@ -26,6 +25,11 @@ import { checkKeys } from '@/utils/var'
import { Type } from '../../../llm/types'
import ManageInputField from './manage-input-field'
import { isSpecialVar, varTypeToStructType } from './utils'
import {
getValueSelector,
getVariableCategory,
getVariableDisplayName,
} from './var-reference-vars.helpers'
const isStructuredOutputChildren = (children?: Var['children']): children is StructuredOutput => {
return !!(children as StructuredOutput | undefined)?.schema?.properties
@ -119,28 +123,6 @@ type ItemProps = {
registerRef?: (element: HTMLDivElement | null) => void
}
const buildValueSelector = ({
nodeId,
objPath,
itemData,
isFlat,
}: {
nodeId: string
objPath: string[]
itemData: Var
isFlat?: boolean
}): ValueSelector => {
if (isFlat)
return [itemData.variable]
const isSys = itemData.variable.startsWith('sys.')
const isEnv = itemData.variable.startsWith('env.')
const isChatVar = itemData.variable.startsWith('conversation.')
const isRagVariable = itemData.isRagVariable
if (isSys || isEnv || isChatVar || isRagVariable)
return [...objPath, ...itemData.variable.split('.')]
return [nodeId, ...objPath, itemData.variable]
}
const Item: FC<ItemProps> = ({
nodeId,
title,
@ -162,7 +144,7 @@ const Item: FC<ItemProps> = ({
}) => {
const isStructureOutput = itemData.type === VarType.object && (itemData.children as StructuredOutput)?.schema?.properties
const isFile = itemData.type === VarType.file && !isStructureOutput
const isObj = (([VarType.object, VarType.file] as VarType[]).includes(itemData.type) && itemData.children && (itemData.children as Var[]).length > 0)
const isObj = ([VarType.object, VarType.file].includes(itemData.type) && itemData.children && (itemData.children as Var[]).length > 0)
const isSys = itemData.variable.startsWith('sys.')
const isEnv = itemData.variable.startsWith('env.')
const isChatVar = itemData.variable.startsWith('conversation.')
@ -183,17 +165,10 @@ const Item: FC<ItemProps> = ({
}
}, [isFlat, isInCodeGeneratorInstructionEditor, itemData.variable])
const varName = useMemo(() => {
if (VAR_SHOW_NAME_MAP[itemData.variable])
return VAR_SHOW_NAME_MAP[itemData.variable]
if (!isFlat)
return itemData.variable
if (itemData.variable === 'current')
return isInCodeGeneratorInstructionEditor ? 'current_code' : 'current_prompt'
return itemData.variable
}, [isFlat, isInCodeGeneratorInstructionEditor, itemData.variable])
const varName = useMemo(
() => getVariableDisplayName(itemData.variable, !!isFlat, isInCodeGeneratorInstructionEditor),
[isFlat, isInCodeGeneratorInstructionEditor, itemData.variable],
)
const objStructuredOutput: StructuredOutput | null = useMemo(() => {
if (!isObj)
@ -249,31 +224,30 @@ const Item: FC<ItemProps> = ({
const open = (isObj || isStructureOutput) && isHovering
useEffect(() => {
onHovering?.(isHovering)
}, [isHovering])
}, [isHovering, onHovering])
const handleChosen = (e: React.MouseEvent) => {
e.stopPropagation()
e.nativeEvent.stopImmediatePropagation()
if (!isSupportFileVar && isFile)
return
onChange(buildValueSelector({
nodeId,
objPath,
const valueSelector = getValueSelector({
itemData,
isFlat,
}), itemData)
isSupportFileVar,
isFile,
isSys,
isEnv,
isChatVar,
isRagVariable,
nodeId,
objPath,
})
if (valueSelector)
onChange(valueSelector, itemData)
}
const variableCategory = useMemo(() => {
if (isEnv)
return 'environment'
if (isChatVar)
return 'conversation'
if (isLoopVar)
return 'loop'
if (isRagVariable)
return 'rag'
return 'system'
}, [isEnv, isChatVar, isSys, isLoopVar, isRagVariable])
const variableCategory = useMemo(
() => getVariableCategory({ isEnv, isChatVar, isLoopVar, isRagVariable }),
[isEnv, isChatVar, isLoopVar, isRagVariable],
)
return (
<PortalToFollowElem
open={open}
@ -411,26 +385,26 @@ const VarReferenceVars: FC<Props> = ({
}
const validatedVars = useMemo(() => {
const res: NodeOutPutVar[] = []
const result: NodeOutPutVar[] = []
vars.forEach((node) => {
const nodeVars = node.vars.filter(v => checkKeys([v.variable], false).isValid || isSpecialVar(v.variable.split('.')[0]))
if (nodeVars.length === 0)
if (!nodeVars.length)
return
res.push({
result.push({
...node,
vars: nodeVars,
})
})
return res
return result
}, [vars])
const filteredVars = useMemo(() => {
if (!normalizedSearchTextTrimmed)
return validatedVars
const res: NodeOutPutVar[] = []
const result: NodeOutPutVar[] = []
validatedVars.forEach((node) => {
const titleLower = node.title.toLowerCase()
const matchedByTitle = titleLower.includes(normalizedSearchTextLower)
const matchedByTitle = node.title.toLowerCase().includes(normalizedSearchTextLower)
const nodeVars = matchedByTitle
? node.vars
: node.vars.filter((v) => {
@ -438,14 +412,14 @@ const VarReferenceVars: FC<Props> = ({
return true
return matchesNestedVar(v, normalizedSearchTextLower)
})
if (nodeVars.length === 0)
if (!nodeVars.length)
return
res.push({
result.push({
...node,
vars: nodeVars,
})
})
return res
return result
}, [normalizedSearchTextLower, normalizedSearchTextTrimmed, validatedVars])
const flatItems = useMemo(() => {
@ -504,33 +478,44 @@ const VarReferenceVars: FC<Props> = ({
const isStructureOutput = item.itemData.type === VarType.object
&& (item.itemData.children as StructuredOutput | undefined)?.schema?.properties
const isFile = item.itemData.type === VarType.file && !isStructureOutput
if (!isSupportFileVar && isFile)
return
const valueSelector = buildValueSelector({
nodeId: item.node.nodeId,
objPath: [],
const valueSelector = getValueSelector({
itemData: item.itemData,
isFlat: item.node.isFlat,
isSupportFileVar,
isFile,
isSys: item.itemData.variable.startsWith('sys.'),
isEnv: item.itemData.variable.startsWith('env.'),
isChatVar: item.itemData.variable.startsWith('conversation.'),
isRagVariable: item.itemData.isRagVariable,
nodeId: item.node.nodeId,
objPath: [],
})
if (!valueSelector)
return
onChange(valueSelector, item.itemData)
onClose?.()
}, [onChange, onClose, isSupportFileVar])
}, [isSupportFileVar, onChange, onClose])
useEffect(() => {
if (!enableKeyboardNavigation)
return
const handleKeyDown = (event: KeyboardEvent) => {
const handleDocumentKeyDown = (event: KeyboardEvent) => {
const items = flatItemsRef.current
if (items.length === 0)
if (!items.length)
return
if (!['ArrowDown', 'ArrowUp', 'Enter', 'Escape'].includes(event.key))
return
event.preventDefault()
event.stopPropagation()
if (event.key === 'Escape') {
onCloseRef.current?.()
return
}
if (event.key === 'Enter') {
const index = activeIndexRef.current
if (index < 0 || index >= items.length)
@ -538,16 +523,18 @@ const VarReferenceVars: FC<Props> = ({
handleSelectItem(items[index])
return
}
const delta = event.key === 'ArrowDown' ? 1 : -1
const baseIndex = activeIndexRef.current < 0 ? 0 : activeIndexRef.current
const nextIndex = Math.min(Math.max(baseIndex + delta, 0), items.length - 1)
handleHighlightIndex(nextIndex, 'keyboard')
}
document.addEventListener('keydown', handleKeyDown, true)
document.addEventListener('keydown', handleDocumentKeyDown, true)
return () => {
document.removeEventListener('keydown', handleKeyDown, true)
document.removeEventListener('keydown', handleDocumentKeyDown, true)
}
}, [enableKeyboardNavigation, handleHighlightIndex, handleSelectItem])
}, [activeIndexRef, enableKeyboardNavigation, flatItemsRef, handleHighlightIndex, handleSelectItem, onCloseRef])
let runningIndex = -1
@ -580,7 +567,6 @@ const VarReferenceVars: FC<Props> = ({
</>
)
}
{
showAssembleVariables && onAssembleVariables && (
<div className="flex items-center border-t border-divider-subtle pt-1">
@ -600,10 +586,10 @@ const VarReferenceVars: FC<Props> = ({
</div>
)
}
{filteredVars.length > 0
? (
<div className={cn('max-h-[85vh] overflow-y-auto', maxHeightClass)}>
{
filteredVars.map((item, i) => (
<div key={i} className={cn(!item.isFlat && 'mt-3', i === 0 && item.isFlat && 'mt-2')}>

View File

@ -0,0 +1,90 @@
import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types'
import type { CustomRunFormProps } from '@/app/components/workflow/nodes/data-source/types'
import type { Node, ToolWithProvider } from '@/app/components/workflow/types'
import { BlockEnum } from '@/app/components/workflow/types'
import {
clampNodePanelWidth,
getCompressedNodePanelWidth,
getCurrentDataSource,
getCurrentToolCollection,
getCurrentTriggerPlugin,
getCustomRunForm,
getMaxNodePanelWidth,
} from '../helpers'
describe('workflow-panel helpers', () => {
const asToolList = (tools: Array<Partial<ToolWithProvider>>) => tools as ToolWithProvider[]
const asTriggerList = (triggers: Array<Partial<TriggerWithProvider>>) => triggers as TriggerWithProvider[]
const asNodeData = (data: Partial<Node['data']>) => data as Node['data']
const createCustomRunFormProps = (payload: Partial<CustomRunFormProps['payload']>): CustomRunFormProps => ({
nodeId: 'node-1',
flowId: 'flow-1',
flowType: 'app' as CustomRunFormProps['flowType'],
payload: payload as CustomRunFormProps['payload'],
setRunResult: vi.fn(),
setIsRunAfterSingleRun: vi.fn(),
isPaused: false,
isRunAfterSingleRun: false,
onSuccess: vi.fn(),
onCancel: vi.fn(),
appendNodeInspectVars: vi.fn(),
})
describe('panel width helpers', () => {
it('should use the default max width when canvas width is unavailable', () => {
expect(getMaxNodePanelWidth(undefined, 120)).toBe(720)
})
it('should clamp width into the supported panel range', () => {
expect(clampNodePanelWidth(320, 800)).toBe(400)
expect(clampNodePanelWidth(960, 800)).toBe(800)
expect(clampNodePanelWidth(640, 800)).toBe(640)
})
it('should return a compressed width only when the canvas overflows', () => {
expect(getCompressedNodePanelWidth(500, 1500, 300)).toBeUndefined()
expect(getCompressedNodePanelWidth(900, 1200, 200)).toBe(600)
})
})
describe('tool and provider lookup', () => {
it('should prefer fresh built-in tool data when it is available', () => {
const storeTools = [{ id: 'legacy/tool', allow_delete: false }]
const queryTools = [{ id: 'provider/tool', allow_delete: true }]
expect(getCurrentToolCollection(asToolList(queryTools), asToolList(storeTools), 'provider/tool')).toEqual(queryTools[0])
})
it('should fall back to store data when query data is unavailable', () => {
const storeTools = [{ id: 'provider/tool', allow_delete: false }]
expect(getCurrentToolCollection(undefined, asToolList(storeTools), 'provider/tool')).toEqual(storeTools[0])
})
it('should resolve the current trigger plugin and datasource only for matching node types', () => {
const triggerData = asNodeData({ type: BlockEnum.TriggerPlugin, plugin_id: 'trigger-1' })
const dataSourceData = asNodeData({ type: BlockEnum.DataSource, plugin_id: 'source-1', provider_type: 'remote' })
const triggerPlugins = [{ plugin_id: 'trigger-1', id: '1' }]
const dataSources = [{ plugin_id: 'source-1' }]
expect(getCurrentTriggerPlugin(triggerData, asTriggerList(triggerPlugins))).toEqual(triggerPlugins[0])
expect(getCurrentDataSource(dataSourceData, dataSources)).toEqual(dataSources[0])
expect(getCurrentTriggerPlugin(asNodeData({ type: BlockEnum.Tool }), asTriggerList(triggerPlugins))).toBeUndefined()
expect(getCurrentDataSource(asNodeData({ type: BlockEnum.Tool }), dataSources)).toBeUndefined()
})
})
describe('custom run form fallback', () => {
it('should return a fallback message for unsupported custom run form nodes', () => {
const form = getCustomRunForm({
...createCustomRunFormProps({ type: BlockEnum.Tool }),
})
expect(form).toMatchObject({
props: {
children: expect.arrayContaining(['Custom Run Form:', ' ', 'not found']),
},
})
})
})
})

View File

@ -1,146 +1,615 @@
/**
* Workflow Panel Width Persistence Tests
* Tests for GitHub issue #22745: Panel width persistence bug fix
*/
import type { PropsWithChildren } from 'react'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { renderWorkflowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import { BlockEnum, NodeRunningStatus } from '@/app/components/workflow/types'
import BasePanel from '../index'
export {}
const mockHandleNodeSelect = vi.fn()
const mockHandleNodeDataUpdate = vi.fn()
const mockHandleNodeDataUpdateWithSyncDraft = vi.fn()
const mockSaveStateToHistory = vi.fn()
const mockSetDetail = vi.fn()
const mockSetShowAccountSettingModal = vi.fn()
const mockHandleSingleRun = vi.fn()
const mockHandleStop = vi.fn()
const mockHandleRunWithParams = vi.fn()
let mockShowMessageLogModal = false
let mockBuiltInTools = [{
id: 'provider/tool',
name: 'Tool',
type: 'builtin',
allow_delete: true,
}]
let mockTriggerPlugins: Array<Record<string, unknown>> = []
type PanelWidthSource = 'user' | 'system'
// Core panel width logic extracted from the component
const createPanelWidthManager = (storageKey: string) => {
return {
updateWidth: (width: number, source: PanelWidthSource = 'user') => {
const newValue = Math.max(400, Math.min(width, 800))
if (source === 'user')
localStorage.setItem(storageKey, `${newValue}`)
return newValue
},
getStoredWidth: () => {
const stored = localStorage.getItem(storageKey)
return stored ? Number.parseFloat(stored) : 400
},
}
const mockLogsState = {
showSpecialResultPanel: false,
}
describe('Workflow Panel Width Persistence', () => {
describe('Node Panel Width Management', () => {
const storageKey = 'workflow-node-panel-width'
const mockLastRunState = {
isShowSingleRun: false,
hideSingleRun: vi.fn(),
runningStatus: NodeRunningStatus.Succeeded,
runInputData: {},
runInputDataRef: { current: {} },
runResult: {},
setRunResult: vi.fn(),
getInputVars: vi.fn(),
toVarInputs: vi.fn(),
tabType: 'settings',
isRunAfterSingleRun: false,
setIsRunAfterSingleRun: vi.fn(),
setTabType: vi.fn(),
handleAfterCustomSingleRun: vi.fn(),
singleRunParams: {
forms: [],
onStop: vi.fn(),
runningStatus: NodeRunningStatus.Succeeded,
existVarValuesInForms: [],
filteredExistVarForms: [],
},
nodeInfo: { id: 'node-1' },
setRunInputData: vi.fn(),
handleStop: () => mockHandleStop(),
handleSingleRun: () => mockHandleSingleRun(),
handleRunWithParams: (...args: unknown[]) => mockHandleRunWithParams(...args),
getExistVarValuesInForms: vi.fn(() => []),
getFilteredExistVarForms: vi.fn(() => []),
}
it('should save user resize to localStorage', () => {
const manager = createPanelWidthManager(storageKey)
const createDataSourceCollection = (overrides: Partial<ToolWithProvider> = {}): ToolWithProvider => ({
id: 'source-1',
name: 'Source',
author: 'Author',
description: { en_US: 'Source description', zh_Hans: 'Source description' },
icon: 'source-icon',
label: { en_US: 'Source', zh_Hans: 'Source' },
type: 'datasource',
team_credentials: {},
is_team_authorization: false,
allow_delete: false,
labels: [],
plugin_id: 'source-1',
tools: [],
meta: {} as ToolWithProvider['meta'],
...overrides,
}) as ToolWithProvider
const result = manager.updateWidth(500, 'user')
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: { showMessageLogModal: boolean, appDetail: { id: string } }) => unknown) => selector({
showMessageLogModal: mockShowMessageLogModal,
appDetail: { id: 'app-1' },
}),
}))
expect(result).toBe(500)
expect(localStorage.setItem).toHaveBeenCalledWith(storageKey, '500')
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useLanguage: () => 'en_US',
}))
vi.mock('@/app/components/plugins/plugin-detail-panel/store', () => ({
usePluginStore: () => ({
setDetail: mockSetDetail,
}),
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useAvailableBlocks: () => ({ availableNextBlocks: [] }),
useEdgesInteractions: () => ({
handleEdgeDeleteByDeleteBranch: vi.fn(),
}),
useNodeDataUpdate: () => ({
handleNodeDataUpdate: mockHandleNodeDataUpdate,
handleNodeDataUpdateWithSyncDraft: mockHandleNodeDataUpdateWithSyncDraft,
}),
useNodesInteractions: () => ({
handleNodeSelect: mockHandleNodeSelect,
}),
useNodesMetaData: () => ({
nodesMap: {
[BlockEnum.Tool]: { defaultRunInputData: {}, metaData: { helpLinkUri: '' } },
[BlockEnum.DataSource]: { defaultRunInputData: {}, metaData: { helpLinkUri: '' } },
},
}),
useNodesReadOnly: () => ({
nodesReadOnly: false,
}),
useToolIcon: () => undefined,
useWorkflowHistory: () => ({
saveStateToHistory: mockSaveStateToHistory,
}),
WorkflowHistoryEvent: {
NodeTitleChange: 'NodeTitleChange',
NodeDescriptionChange: 'NodeDescriptionChange',
},
}))
vi.mock('@/app/components/workflow/hooks-store', () => ({
useHooksStore: (selector: (state: { configsMap: { flowId: string, flowType: string } }) => unknown) => selector({
configsMap: {
flowId: 'flow-1',
flowType: 'app',
},
}),
}))
vi.mock('@/app/components/workflow/hooks/use-inspect-vars-crud', () => ({
default: () => ({
appendNodeInspectVars: vi.fn(),
}),
}))
vi.mock('@/app/components/workflow/run/hooks', () => ({
useLogs: () => mockLogsState,
}))
vi.mock('@/service/use-tools', () => ({
useAllBuiltInTools: () => ({
data: mockBuiltInTools,
}),
}))
vi.mock('@/service/use-triggers', () => ({
useAllTriggerPlugins: () => ({
data: mockTriggerPlugins,
}),
}))
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
}),
}))
vi.mock('@/app/components/workflow/utils', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/workflow/utils')>()
return {
...actual,
canRunBySingle: () => true,
hasErrorHandleNode: () => false,
hasRetryNode: () => false,
isSupportCustomRunForm: (type: string) => type === BlockEnum.DataSource,
}
})
vi.mock('../hooks/use-resize-panel', () => ({
useResizePanel: () => ({
triggerRef: { current: null },
containerRef: { current: null },
}),
}))
vi.mock('../last-run/use-last-run', () => ({
default: () => mockLastRunState,
}))
vi.mock('@/app/components/plugins/plugin-auth', () => ({
PluginAuth: ({ children }: PropsWithChildren) => <div>{children}</div>,
AuthorizedInNode: ({ onAuthorizationItemClick }: { onAuthorizationItemClick?: (credentialId: string) => void }) => (
<button onClick={() => onAuthorizationItemClick?.('credential-1')}>authorized-in-node</button>
),
PluginAuthInDataSourceNode: ({ children, onJumpToDataSourcePage }: PropsWithChildren<{ onJumpToDataSourcePage?: () => void }>) => (
<div>
<button onClick={onJumpToDataSourcePage}>jump-to-datasource</button>
{children}
</div>
),
AuthorizedInDataSourceNode: ({ onJumpToDataSourcePage }: { onJumpToDataSourcePage?: () => void }) => (
<button onClick={onJumpToDataSourcePage}>authorized-in-datasource-node</button>
),
AuthCategory: { tool: 'tool' },
}))
vi.mock('@/app/components/plugins/readme-panel/entrance', () => ({
ReadmeEntrance: () => <div>readme-entrance</div>,
}))
vi.mock('@/app/components/workflow/block-icon', () => ({
default: () => <div>block-icon</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/split', () => ({
default: () => <div>split</div>,
}))
vi.mock('@/app/components/workflow/nodes/data-source/before-run-form', () => ({
default: () => <div>data-source-before-run-form</div>,
}))
vi.mock('@/app/components/workflow/run/special-result-panel', () => ({
default: () => <div>special-result-panel</div>,
}))
vi.mock('../before-run-form', () => ({
default: () => <div>before-run-form</div>,
}))
vi.mock('../before-run-form/panel-wrap', () => ({
default: ({ children }: PropsWithChildren<{ nodeName: string, onHide: () => void }>) => <div>{children}</div>,
}))
vi.mock('../error-handle/error-handle-on-panel', () => ({
default: () => <div>error-handle-panel</div>,
}))
vi.mock('../help-link', () => ({
default: () => <div>help-link</div>,
}))
vi.mock('../next-step', () => ({
default: () => <div>next-step</div>,
}))
vi.mock('../panel-operator', () => ({
default: () => <div>panel-operator</div>,
}))
vi.mock('../retry/retry-on-panel', () => ({
default: () => <div>retry-panel</div>,
}))
vi.mock('../title-description-input', () => ({
TitleInput: ({ value, onBlur }: { value: string, onBlur: (value: string) => void }) => (
<input aria-label="title-input" defaultValue={value} onBlur={event => onBlur(event.target.value)} />
),
DescriptionInput: ({ value, onChange }: { value: string, onChange: (value: string) => void }) => (
<textarea aria-label="description-input" defaultValue={value} onChange={event => onChange(event.target.value)} />
),
}))
vi.mock('../last-run', () => ({
default: ({
isPaused,
updateNodeRunningStatus,
}: {
isPaused?: boolean
updateNodeRunningStatus?: (status: NodeRunningStatus) => void
}) => (
<div>
<div>{isPaused ? 'paused' : 'active'}</div>
<button onClick={() => updateNodeRunningStatus?.(NodeRunningStatus.Running)}>last-run-update-status</button>
<div>last-run-panel</div>
</div>
),
}))
vi.mock('../tab', () => ({
__esModule: true,
TabType: { settings: 'settings', lastRun: 'lastRun' },
default: ({ value, onChange }: { value: string, onChange: (value: string) => void }) => (
<div>
<button onClick={() => onChange('settings')}>settings-tab</button>
<button onClick={() => onChange('lastRun')}>last-run-tab</button>
<span>{value}</span>
</div>
),
}))
vi.mock('../trigger-subscription', () => ({
TriggerSubscription: ({ children, onSubscriptionChange }: PropsWithChildren<{ onSubscriptionChange?: (value: { id: string }, callback?: () => void) => void }>) => (
<div>
<button onClick={() => onSubscriptionChange?.({ id: 'subscription-1' }, vi.fn())}>change-subscription</button>
{children}
</div>
),
}))
const createData = (overrides: Record<string, unknown> = {}) => ({
title: 'Tool Node',
desc: 'Node description',
type: BlockEnum.Tool,
provider_id: 'provider/tool',
_singleRunningStatus: undefined,
...overrides,
})
describe('workflow-panel index', () => {
beforeEach(() => {
vi.clearAllMocks()
mockShowMessageLogModal = false
mockBuiltInTools = [{
id: 'provider/tool',
name: 'Tool',
type: 'builtin',
allow_delete: true,
}]
mockTriggerPlugins = []
mockLogsState.showSpecialResultPanel = false
mockLastRunState.isShowSingleRun = false
mockLastRunState.tabType = 'settings'
})
it('should render the settings panel and wire title, description, run, and close actions', async () => {
const { container } = renderWorkflowComponent(
<BasePanel id="node-1" data={createData() as never}>
<div>panel-child</div>
</BasePanel>,
{
initialStoreState: {
showSingleRunPanel: false,
workflowCanvasWidth: 1200,
nodePanelWidth: 480,
otherPanelWidth: 200,
buildInTools: [],
dataSourceList: [],
},
},
)
expect(screen.getByText('panel-child')).toBeInTheDocument()
expect(screen.getByText('authorized-in-node')).toBeInTheDocument()
fireEvent.blur(screen.getByDisplayValue('Tool Node'), { target: { value: 'Updated title' } })
fireEvent.change(screen.getByDisplayValue('Node description'), { target: { value: 'Updated description' } })
await waitFor(() => {
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenCalled()
})
expect(mockSaveStateToHistory).toHaveBeenCalled()
fireEvent.click(screen.getByText('authorized-in-node'))
it('should not save system compression to localStorage', () => {
const manager = createPanelWidthManager(storageKey)
const clickableItems = container.querySelectorAll('.cursor-pointer')
fireEvent.click(clickableItems[0] as HTMLElement)
fireEvent.click(clickableItems[clickableItems.length - 1] as HTMLElement)
const result = manager.updateWidth(200, 'system')
expect(mockHandleSingleRun).toHaveBeenCalledTimes(1)
expect(mockHandleNodeSelect).toHaveBeenCalledWith('node-1', true)
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ credential_id: 'credential-1' }),
}))
})
expect(result).toBe(400) // Respects minimum width
expect(localStorage.setItem).not.toHaveBeenCalled()
})
it('should render the special result panel when logs request it', () => {
mockLogsState.showSpecialResultPanel = true
it('should enforce minimum width of 400px', () => {
const manager = createPanelWidthManager(storageKey)
renderWorkflowComponent(
<BasePanel id="node-1" data={createData() as never}>
<div>panel-child</div>
</BasePanel>,
{
initialStoreState: {
nodePanelWidth: 480,
otherPanelWidth: 200,
},
},
)
// User tries to set below minimum
const userResult = manager.updateWidth(300, 'user')
expect(userResult).toBe(400)
expect(localStorage.setItem).toHaveBeenCalledWith(storageKey, '400')
expect(screen.getByText('special-result-panel')).toBeInTheDocument()
})
// System compression below minimum
const systemResult = manager.updateWidth(150, 'system')
expect(systemResult).toBe(400)
expect(localStorage.setItem).toHaveBeenCalledTimes(1) // Only user call
})
it('should render last-run content when the tab switches', () => {
mockLastRunState.tabType = 'lastRun'
it('should preserve user preferences during system compression', () => {
localStorage.setItem(storageKey, '600')
const manager = createPanelWidthManager(storageKey)
renderWorkflowComponent(
<BasePanel id="node-1" data={createData() as never}>
<div>panel-child</div>
</BasePanel>,
{
initialStoreState: {
nodePanelWidth: 480,
otherPanelWidth: 200,
},
},
)
// System compresses panel
manager.updateWidth(200, 'system')
expect(screen.getByText('last-run-panel')).toBeInTheDocument()
})
// User preference should remain unchanged
expect(localStorage.getItem(storageKey)).toBe('600')
it('should render the plain tab layout and allow last-run status updates', async () => {
mockLastRunState.tabType = 'lastRun'
renderWorkflowComponent(
<BasePanel id="node-plain" data={createData({ type: 'custom' }) as never}>
<div>panel-child</div>
</BasePanel>,
{
initialStoreState: {
nodePanelWidth: 480,
otherPanelWidth: 200,
},
},
)
expect(screen.queryByText('authorized-in-node')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('last-run-update-status'))
await waitFor(() => {
expect(mockHandleNodeDataUpdate).toHaveBeenCalledWith(expect.objectContaining({
id: 'node-plain',
data: expect.objectContaining({
_singleRunningStatus: NodeRunningStatus.Running,
}),
}))
})
})
describe('Bug Scenario Reproduction', () => {
it('should reproduce original bug behavior (for comparison)', () => {
const storageKey = 'workflow-node-panel-width'
it('should mark the last run as paused after a running single-run completes', async () => {
mockLastRunState.tabType = 'lastRun'
// Original buggy behavior - always saves regardless of source
const buggyUpdate = (width: number) => {
localStorage.setItem(storageKey, `${width}`)
return Math.max(400, width)
}
const { rerender } = renderWorkflowComponent(
<BasePanel id="node-pause" data={createData({ _singleRunningStatus: NodeRunningStatus.Running }) as never}>
<div>panel-child</div>
</BasePanel>,
{
initialStoreState: {
nodePanelWidth: 480,
otherPanelWidth: 200,
},
},
)
localStorage.setItem(storageKey, '500') // User preference
buggyUpdate(200) // System compression pollutes localStorage
expect(screen.getByText('active')).toBeInTheDocument()
expect(localStorage.getItem(storageKey)).toBe('200') // Bug: corrupted state
})
rerender(
<BasePanel id="node-pause" data={createData({ _isSingleRun: true, _singleRunningStatus: undefined }) as never}>
<div>panel-child</div>
</BasePanel>,
)
it('should verify fix prevents localStorage pollution', () => {
const storageKey = 'workflow-node-panel-width'
const manager = createPanelWidthManager(storageKey)
localStorage.setItem(storageKey, '500') // User preference
manager.updateWidth(200, 'system') // System compression
expect(localStorage.getItem(storageKey)).toBe('500') // Fix: preserved state
await waitFor(() => {
expect(screen.getByText('paused')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle multiple rapid operations correctly', () => {
const manager = createPanelWidthManager('workflow-node-panel-width')
it('should render custom data source single run form for supported nodes', () => {
mockLastRunState.isShowSingleRun = true
// Rapid system adjustments
manager.updateWidth(300, 'system')
manager.updateWidth(250, 'system')
manager.updateWidth(180, 'system')
renderWorkflowComponent(
<BasePanel id="node-1" data={createData({ type: BlockEnum.DataSource }) as never}>
<div>panel-child</div>
</BasePanel>,
{
initialStoreState: {
nodePanelWidth: 480,
otherPanelWidth: 200,
},
},
)
// Single user adjustment
manager.updateWidth(550, 'user')
expect(localStorage.setItem).toHaveBeenCalledTimes(1)
expect(localStorage.setItem).toHaveBeenCalledWith('workflow-node-panel-width', '550')
})
it('should handle corrupted localStorage gracefully', () => {
localStorage.setItem('workflow-node-panel-width', '150') // Below minimum
const manager = createPanelWidthManager('workflow-node-panel-width')
const storedWidth = manager.getStoredWidth()
expect(storedWidth).toBe(150) // Returns raw value
// User can correct the preference
const correctedWidth = manager.updateWidth(500, 'user')
expect(correctedWidth).toBe(500)
expect(localStorage.getItem('workflow-node-panel-width')).toBe('500')
})
expect(screen.getByText('data-source-before-run-form')).toBeInTheDocument()
})
describe('TypeScript Type Safety', () => {
it('should enforce source parameter type', () => {
const manager = createPanelWidthManager('workflow-node-panel-width')
it('should render data source authorization controls and jump to the settings modal', () => {
renderWorkflowComponent(
<BasePanel id="node-1" data={createData({ type: BlockEnum.DataSource, plugin_id: 'source-1', provider_type: 'remote' }) as never}>
<div>panel-child</div>
</BasePanel>,
{
initialStoreState: {
nodePanelWidth: 480,
otherPanelWidth: 200,
dataSourceList: [createDataSourceCollection({ is_authorized: false })],
},
},
)
// Valid source values
manager.updateWidth(500, 'user')
manager.updateWidth(500, 'system')
fireEvent.click(screen.getByText('authorized-in-datasource-node'))
// Default to 'user'
manager.updateWidth(500)
expect(mockSetShowAccountSettingModal).toHaveBeenCalled()
})
expect(localStorage.setItem).toHaveBeenCalledTimes(2) // user + default
it('should react to pending single run actions', () => {
renderWorkflowComponent(
<BasePanel id="node-1" data={createData() as never}>
<div>panel-child</div>
</BasePanel>,
{
initialStoreState: {
nodePanelWidth: 480,
otherPanelWidth: 200,
pendingSingleRun: {
nodeId: 'node-1',
action: 'run',
},
},
},
)
expect(mockHandleSingleRun).toHaveBeenCalledTimes(1)
renderWorkflowComponent(
<BasePanel id="node-1" data={createData() as never}>
<div>panel-child</div>
</BasePanel>,
{
initialStoreState: {
nodePanelWidth: 480,
otherPanelWidth: 200,
pendingSingleRun: {
nodeId: 'node-1',
action: 'stop',
},
},
},
)
expect(mockHandleStop).toHaveBeenCalledTimes(1)
})
it('should load trigger plugin details when the selected node is a trigger plugin', async () => {
mockTriggerPlugins = [{
id: 'trigger-1',
name: 'trigger-name',
plugin_id: 'plugin-id',
plugin_unique_identifier: 'plugin-uid',
label: {
en_US: 'Trigger Name',
},
declaration: {},
subscription_schema: [],
subscription_constructor: {},
}]
renderWorkflowComponent(
<BasePanel id="node-1" data={createData({ type: BlockEnum.TriggerPlugin, plugin_id: 'plugin-id' }) as never}>
<div>panel-child</div>
</BasePanel>,
{
initialStoreState: {
nodePanelWidth: 480,
otherPanelWidth: 200,
},
},
)
await waitFor(() => {
expect(mockSetDetail).toHaveBeenCalledWith(expect.objectContaining({
id: 'trigger-1',
name: 'Trigger Name',
}))
})
fireEvent.click(screen.getByText('change-subscription'))
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenCalledWith(
{ id: 'node-1', data: { subscription_id: 'subscription-1' } },
expect.objectContaining({ sync: true }),
)
})
it('should stop a running node and offset when the log modal is visible', () => {
mockShowMessageLogModal = true
const { container } = renderWorkflowComponent(
<BasePanel id="node-1" data={createData({ _singleRunningStatus: NodeRunningStatus.Running }) as never}>
<div>panel-child</div>
</BasePanel>,
{
initialStoreState: {
nodePanelWidth: 480,
otherPanelWidth: 240,
},
},
)
const root = container.firstElementChild as HTMLElement
expect(root.style.right).toBe('240px')
expect(root.className).toContain('absolute')
const clickableItems = container.querySelectorAll('.cursor-pointer')
fireEvent.click(clickableItems[0] as HTMLElement)
expect(mockHandleStop).toHaveBeenCalledTimes(1)
})
it('should persist user resize changes and compress oversized panel widths', async () => {
const { container } = renderWorkflowComponent(
<BasePanel id="node-resize" data={createData() as never}>
<div>panel-child</div>
</BasePanel>,
{
initialStoreState: {
workflowCanvasWidth: 800,
nodePanelWidth: 600,
otherPanelWidth: 200,
},
},
)
await waitFor(() => {
const panel = container.querySelector('[style*="width"]') as HTMLElement
expect(panel.style.width).toBe('400px')
})
})
})

View File

@ -0,0 +1,80 @@
import type { ReactNode } from 'react'
import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types'
import type { CustomRunFormProps } from '@/app/components/workflow/nodes/data-source/types'
import type { Node, ToolWithProvider } from '@/app/components/workflow/types'
import DataSourceBeforeRunForm from '@/app/components/workflow/nodes/data-source/before-run-form'
import { DataSourceClassification } from '@/app/components/workflow/nodes/data-source/types'
import { BlockEnum } from '@/app/components/workflow/types'
import { canFindTool } from '@/utils'
const MIN_NODE_PANEL_WIDTH = 400
const DEFAULT_MAX_NODE_PANEL_WIDTH = 720
export const getMaxNodePanelWidth = (workflowCanvasWidth?: number, otherPanelWidth?: number, reservedCanvasWidth = MIN_NODE_PANEL_WIDTH) => {
if (!workflowCanvasWidth)
return DEFAULT_MAX_NODE_PANEL_WIDTH
const available = workflowCanvasWidth - (otherPanelWidth || 0) - reservedCanvasWidth
return Math.max(available, MIN_NODE_PANEL_WIDTH)
}
export const clampNodePanelWidth = (width: number, maxNodePanelWidth: number) => {
return Math.max(MIN_NODE_PANEL_WIDTH, Math.min(width, maxNodePanelWidth))
}
export const getCompressedNodePanelWidth = (nodePanelWidth: number, workflowCanvasWidth?: number, otherPanelWidth?: number, reservedCanvasWidth = MIN_NODE_PANEL_WIDTH) => {
if (!workflowCanvasWidth)
return undefined
const total = nodePanelWidth + (otherPanelWidth || 0) + reservedCanvasWidth
if (total <= workflowCanvasWidth)
return undefined
return clampNodePanelWidth(workflowCanvasWidth - (otherPanelWidth || 0) - reservedCanvasWidth, getMaxNodePanelWidth(workflowCanvasWidth, otherPanelWidth, reservedCanvasWidth))
}
export const getCustomRunForm = (params: CustomRunFormProps): ReactNode => {
const nodeType = params.payload.type
switch (nodeType) {
case BlockEnum.DataSource:
return <DataSourceBeforeRunForm {...params} />
default:
return (
<div>
Custom Run Form:
{nodeType}
{' '}
not found
</div>
)
}
}
export const getCurrentToolCollection = (
buildInTools: ToolWithProvider[] | undefined,
storeBuildInTools: ToolWithProvider[] | undefined,
providerId?: string,
) => {
const candidates = buildInTools ?? storeBuildInTools
return candidates?.find(item => canFindTool(item.id, providerId))
}
export const getCurrentDataSource = (
data: Node['data'],
dataSourceList: Array<{ plugin_id?: string, is_authorized?: boolean }> | undefined,
) => {
if (data.type !== BlockEnum.DataSource || data.provider_type === DataSourceClassification.localFile)
return undefined
return dataSourceList?.find(item => item.plugin_id === data.plugin_id)
}
export const getCurrentTriggerPlugin = (
data: Node['data'],
triggerPlugins: TriggerWithProvider[] | undefined,
) => {
if (data.type !== BlockEnum.TriggerPlugin || !data.plugin_id || !triggerPlugins?.length)
return undefined
return triggerPlugins.find(plugin => plugin.plugin_id === data.plugin_id)
}

View File

@ -1,6 +1,5 @@
import type { FC, ReactNode } from 'react'
import type { SimpleSubscription } from '@/app/components/plugins/plugin-detail-panel/subscription-list'
import type { CustomRunFormProps } from '@/app/components/workflow/nodes/data-source/types'
import type { Node } from '@/app/components/workflow/types'
import {
RiCloseLine,
@ -50,8 +49,6 @@ import {
import { useHooksStore } from '@/app/components/workflow/hooks-store'
import useInspectVarsCrud from '@/app/components/workflow/hooks/use-inspect-vars-crud'
import Split from '@/app/components/workflow/nodes/_base/components/split'
import DataSourceBeforeRunForm from '@/app/components/workflow/nodes/data-source/before-run-form'
import { DataSourceClassification } from '@/app/components/workflow/nodes/data-source/types'
import { useLogs } from '@/app/components/workflow/run/hooks'
import SpecialResultPanel from '@/app/components/workflow/run/special-result-panel'
import { useStore } from '@/app/components/workflow/store'
@ -67,7 +64,6 @@ import { useModalContext } from '@/context/modal-context'
import { useAllBuiltInTools } from '@/service/use-tools'
import { useAllTriggerPlugins } from '@/service/use-triggers'
import { FlowType } from '@/types/common'
import { canFindTool } from '@/utils'
import { cn } from '@/utils/classnames'
import { useResizePanel } from '../../hooks/use-resize-panel'
import BeforeRunForm from '../before-run-form'
@ -78,28 +74,20 @@ import NextStep from '../next-step'
import PanelOperator from '../panel-operator'
import RetryOnPanel from '../retry/retry-on-panel'
import { DescriptionInput, TitleInput } from '../title-description-input'
import {
clampNodePanelWidth,
getCompressedNodePanelWidth,
getCurrentDataSource,
getCurrentToolCollection,
getCurrentTriggerPlugin,
getCustomRunForm,
getMaxNodePanelWidth,
} from './helpers'
import LastRun from './last-run'
import useLastRun from './last-run/use-last-run'
import Tab, { TabType } from './tab'
import { TriggerSubscription } from './trigger-subscription'
const getCustomRunForm = (params: CustomRunFormProps): React.JSX.Element => {
const nodeType = params.payload.type
switch (nodeType) {
case BlockEnum.DataSource:
return <DataSourceBeforeRunForm {...params} />
default:
return (
<div>
Custom Run Form:
{nodeType}
{' '}
not found
</div>
)
}
}
type BasePanelProps = {
children: ReactNode
id: Node['id']
@ -168,17 +156,13 @@ const BasePanel: FC<BasePanelProps> = ({
const reservedCanvasWidth = 400 // Reserve the minimum visible width for the canvas
const maxNodePanelWidth = useMemo(() => {
if (!workflowCanvasWidth)
return 720
const available = workflowCanvasWidth - (otherPanelWidth || 0) - reservedCanvasWidth
return Math.max(available, 400)
}, [workflowCanvasWidth, otherPanelWidth])
const maxNodePanelWidth = useMemo(
() => getMaxNodePanelWidth(workflowCanvasWidth, otherPanelWidth, reservedCanvasWidth),
[workflowCanvasWidth, otherPanelWidth],
)
const updateNodePanelWidth = useCallback((width: number, source: 'user' | 'system' = 'user') => {
// Ensure the width is within the min and max range
const newValue = Math.max(400, Math.min(width, maxNodePanelWidth))
const newValue = clampNodePanelWidth(width, maxNodePanelWidth)
if (source === 'user')
localStorage.setItem('workflow-node-panel-width', `${newValue}`)
@ -206,15 +190,9 @@ const BasePanel: FC<BasePanelProps> = ({
})
useEffect(() => {
if (!workflowCanvasWidth)
return
// If the total width of the three exceeds the canvas, shrink the node panel to the available range (at least 400px)
const total = nodePanelWidth + otherPanelWidth + reservedCanvasWidth
if (total > workflowCanvasWidth) {
const target = Math.max(workflowCanvasWidth - otherPanelWidth - reservedCanvasWidth, 400)
debounceUpdate(target)
}
const compressedWidth = getCompressedNodePanelWidth(nodePanelWidth, workflowCanvasWidth, otherPanelWidth, reservedCanvasWidth)
if (compressedWidth !== undefined)
debounceUpdate(compressedWidth)
}, [nodePanelWidth, otherPanelWidth, workflowCanvasWidth, debounceUpdate])
const { handleNodeSelect } = useNodesInteractions()
@ -330,21 +308,17 @@ const BasePanel: FC<BasePanelProps> = ({
const storeBuildInTools = useStore(s => s.buildInTools)
const { data: buildInTools } = useAllBuiltInTools()
const currToolCollection = useMemo(() => {
const candidates = buildInTools ?? storeBuildInTools
return candidates?.find(item => canFindTool(item.id, data.provider_id))
}, [buildInTools, storeBuildInTools, data.provider_id])
const currToolCollection = useMemo(
() => getCurrentToolCollection(buildInTools, storeBuildInTools, data.provider_id),
[buildInTools, storeBuildInTools, data.provider_id],
)
const needsToolAuth = useMemo(() => {
return data.type === BlockEnum.Tool && currToolCollection?.allow_delete
}, [data.type, currToolCollection?.allow_delete])
// only fetch trigger plugins when the node is a trigger plugin
const { data: triggerPlugins = [] } = useAllTriggerPlugins(data.type === BlockEnum.TriggerPlugin)
const currentTriggerPlugin = useMemo(() => {
if (data.type !== BlockEnum.TriggerPlugin || !data.plugin_id || !triggerPlugins?.length)
return undefined
return triggerPlugins?.find(p => p.plugin_id === data.plugin_id)
}, [data.type, data.plugin_id, triggerPlugins])
const currentTriggerPlugin = useMemo(() => getCurrentTriggerPlugin(data, triggerPlugins), [data, triggerPlugins])
const { setDetail } = usePluginStore()
useEffect(() => {
@ -367,10 +341,7 @@ const BasePanel: FC<BasePanelProps> = ({
const dataSourceList = useStore(s => s.dataSourceList)
const currentDataSource = useMemo(() => {
if (data.type === BlockEnum.DataSource && data.provider_type !== DataSourceClassification.localFile)
return dataSourceList?.find(item => item.plugin_id === data.plugin_id)
}, [data.type, data.provider_type, data.plugin_id, dataSourceList])
const currentDataSource = useMemo(() => getCurrentDataSource(data, dataSourceList), [data, dataSourceList])
const handleAuthorizationItemClick = useCallback((credential_id: string) => {
handleNodeDataUpdateWithSyncDraft({

View File

@ -0,0 +1,235 @@
import { act, render, screen } from '@testing-library/react'
import { NodeRunningStatus } from '@/app/components/workflow/types'
import LastRun from '../index'
const mockUseHooksStore = vi.hoisted(() => vi.fn())
const mockUseLastRun = vi.hoisted(() => vi.fn())
const mockResultPanel = vi.hoisted(() => vi.fn())
vi.mock('@remixicon/react', () => ({
RiLoader2Line: () => <div data-testid="loading-icon" />,
}))
vi.mock('@/app/components/workflow/hooks-store', () => ({
useHooksStore: (selector: (state: {
configsMap?: { flowType?: string, flowId?: string }
}) => unknown) => mockUseHooksStore(selector),
}))
vi.mock('@/service/use-workflow', () => ({
useLastRun: (...args: unknown[]) => mockUseLastRun(...args),
}))
vi.mock('@/app/components/workflow/run/result-panel', () => ({
__esModule: true,
default: (props: Record<string, unknown>) => {
mockResultPanel(props)
return <div data-testid="result-panel">{String(props.status)}</div>
},
}))
vi.mock('../no-data', () => ({
__esModule: true,
default: ({ onSingleRun }: { onSingleRun: () => void }) => (
<button type="button" onClick={onSingleRun}>
no-data
</button>
),
}))
describe('LastRun', () => {
const updateNodeRunningStatus = vi.fn()
const onSingleRunClicked = vi.fn()
let visibilityState = 'visible'
beforeEach(() => {
vi.clearAllMocks()
mockUseHooksStore.mockImplementation((selector: (state: {
configsMap?: { flowType?: string, flowId?: string }
}) => unknown) => selector({
configsMap: {
flowType: 'appFlow',
flowId: 'flow-1',
},
}))
mockUseLastRun.mockReturnValue({
data: undefined,
isFetching: false,
error: undefined,
})
visibilityState = 'visible'
Object.defineProperty(document, 'visibilityState', {
configurable: true,
get: () => visibilityState,
})
})
it('should show a loader while fetching the last run before any single run starts', () => {
mockUseLastRun.mockReturnValue({
data: undefined,
isFetching: true,
error: undefined,
})
render(
<LastRun
appId="app-1"
nodeId="node-1"
canSingleRun
isRunAfterSingleRun={false}
updateNodeRunningStatus={updateNodeRunningStatus}
onSingleRunClicked={onSingleRunClicked}
/>,
)
expect(screen.getByTestId('loading-icon')).toBeInTheDocument()
expect(screen.queryByTestId('result-panel')).not.toBeInTheDocument()
})
it('should show a running result panel while a single run is still executing', () => {
render(
<LastRun
appId="app-1"
nodeId="node-1"
canSingleRun
isRunAfterSingleRun
updateNodeRunningStatus={updateNodeRunningStatus}
onSingleRunClicked={onSingleRunClicked}
runningStatus={NodeRunningStatus.Running}
/>,
)
expect(screen.getByTestId('result-panel')).toHaveTextContent('running')
expect(mockResultPanel).toHaveBeenCalledWith(expect.objectContaining({
status: 'running',
showSteps: false,
}))
})
it('should render the no-data state for 404 last-run responses and forward single-run clicks', () => {
mockUseLastRun.mockReturnValue({
data: undefined,
isFetching: false,
error: { status: 404 },
})
render(
<LastRun
appId="app-1"
nodeId="node-1"
canSingleRun
isRunAfterSingleRun={false}
updateNodeRunningStatus={updateNodeRunningStatus}
onSingleRunClicked={onSingleRunClicked}
/>,
)
act(() => {
screen.getByText('no-data').click()
})
expect(onSingleRunClicked).toHaveBeenCalledTimes(1)
})
it('should render resolved result data and let paused state override the final status', () => {
mockUseLastRun.mockReturnValue({
data: {
status: NodeRunningStatus.Succeeded,
execution_metadata: { total_tokens: 9 },
created_by_account: { created_by: 'Alice' },
},
isFetching: false,
error: undefined,
})
render(
<LastRun
appId="app-1"
nodeId="node-1"
canSingleRun
isRunAfterSingleRun
updateNodeRunningStatus={updateNodeRunningStatus}
onSingleRunClicked={onSingleRunClicked}
runningStatus={NodeRunningStatus.Succeeded}
isPaused
/>,
)
expect(screen.getByTestId('result-panel')).toHaveTextContent(NodeRunningStatus.Stopped)
expect(mockResultPanel).toHaveBeenCalledWith(expect.objectContaining({
status: NodeRunningStatus.Stopped,
total_tokens: 9,
created_by: 'Alice',
showSteps: false,
}))
})
it('should respect stopped and listening one-step statuses', () => {
mockUseLastRun.mockReturnValue({
data: {
status: NodeRunningStatus.Succeeded,
},
isFetching: false,
error: undefined,
})
const { rerender } = render(
<LastRun
appId="app-1"
nodeId="node-1"
canSingleRun
isRunAfterSingleRun
updateNodeRunningStatus={updateNodeRunningStatus}
onSingleRunClicked={onSingleRunClicked}
runningStatus={NodeRunningStatus.Stopped}
/>,
)
expect(screen.getByTestId('result-panel')).toHaveTextContent(NodeRunningStatus.Stopped)
rerender(
<LastRun
appId="app-1"
nodeId="node-1"
canSingleRun
isRunAfterSingleRun
updateNodeRunningStatus={updateNodeRunningStatus}
onSingleRunClicked={onSingleRunClicked}
runningStatus={NodeRunningStatus.Listening}
/>,
)
expect(screen.getByTestId('result-panel')).toHaveTextContent(NodeRunningStatus.Listening)
})
it('should react to page visibility changes while keeping the current result rendered', () => {
mockUseLastRun.mockReturnValue({
data: {
status: NodeRunningStatus.Succeeded,
},
isFetching: false,
error: undefined,
})
render(
<LastRun
appId="app-1"
nodeId="node-1"
canSingleRun
isRunAfterSingleRun
updateNodeRunningStatus={updateNodeRunningStatus}
onSingleRunClicked={onSingleRunClicked}
runningStatus={NodeRunningStatus.Succeeded}
/>,
)
act(() => {
visibilityState = 'hidden'
document.dispatchEvent(new Event('visibilitychange'))
visibilityState = 'visible'
document.dispatchEvent(new Event('visibilitychange'))
})
expect(screen.getByTestId('result-panel')).toHaveTextContent(NodeRunningStatus.Succeeded)
})
})

View File

@ -0,0 +1,94 @@
import type { TFunction } from 'i18next'
import type { ReactElement } from 'react'
import type { IterationNodeType } from '@/app/components/workflow/nodes/iteration/types'
import type { NodeProps } from '@/app/components/workflow/types'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
import { BlockEnum, NodeRunningStatus } from '@/app/components/workflow/types'
type HeaderMetaProps = {
data: NodeProps['data']
hasVarValue: boolean
isLoading: boolean
loopIndex: ReactElement | null
t: TFunction
}
export const NodeHeaderMeta = ({
data,
hasVarValue,
isLoading,
loopIndex,
t,
}: HeaderMetaProps) => {
return (
<>
{data.type === BlockEnum.Iteration && (data as IterationNodeType).is_parallel && (
<Tooltip>
<TooltipTrigger>
<div className="ml-1 flex items-center justify-center rounded-[5px] border-[1px] border-text-warning px-[5px] py-[3px] text-text-warning system-2xs-medium-uppercase">
{t('nodes.iteration.parallelModeUpper', { ns: 'workflow' })}
</div>
</TooltipTrigger>
<TooltipContent popupClassName="w-[180px]">
<div className="font-extrabold">
{t('nodes.iteration.parallelModeEnableTitle', { ns: 'workflow' })}
</div>
{t('nodes.iteration.parallelModeEnableDesc', { ns: 'workflow' })}
</TooltipContent>
</Tooltip>
)}
{!!(data._iterationLength && data._iterationIndex && data._runningStatus === NodeRunningStatus.Running) && (
<div className="mr-1.5 text-xs font-medium text-text-accent">
{data._iterationIndex > data._iterationLength ? data._iterationLength : data._iterationIndex}
/
{data._iterationLength}
</div>
)}
{!!(data.type === BlockEnum.Loop && data._loopIndex) && loopIndex}
{isLoading && <span className="i-ri-loader-2-line h-3.5 w-3.5 animate-spin text-text-accent" />}
{!isLoading && data._runningStatus === NodeRunningStatus.Failed && (
<span className="i-ri-error-warning-fill h-3.5 w-3.5 text-text-destructive" />
)}
{!isLoading && data._runningStatus === NodeRunningStatus.Exception && (
<span className="i-ri-alert-fill h-3.5 w-3.5 text-text-warning-secondary" />
)}
{!isLoading && (data._runningStatus === NodeRunningStatus.Succeeded || (!data._runningStatus && hasVarValue)) && (
<span className="i-ri-checkbox-circle-fill h-3.5 w-3.5 text-text-success" />
)}
{!isLoading && data._runningStatus === NodeRunningStatus.Paused && (
<span className="i-ri-pause-circle-fill h-3.5 w-3.5 text-text-warning-secondary" />
)}
</>
)
}
type NodeBodyProps = {
data: NodeProps['data']
child: ReactElement
}
export const NodeBody = ({
data,
child,
}: NodeBodyProps) => {
if (data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) {
return (
<div className="grow pb-1 pl-1 pr-1">
{child}
</div>
)
}
return child
}
export const NodeDescription = ({ data }: { data: NodeProps['data'] }) => {
if (!data.desc || data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop)
return null
return (
<div className="whitespace-pre-line break-words px-3 pb-2 pt-1 text-text-tertiary system-xs-regular">
{data.desc}
</div>
)
}

View File

@ -0,0 +1,32 @@
import type { NodeProps } from '@/app/components/workflow/types'
import { BlockEnum, isTriggerNode, NodeRunningStatus } from '@/app/components/workflow/types'
export const getNodeStatusBorders = (
runningStatus: NodeRunningStatus | undefined,
hasVarValue: boolean,
showSelectedBorder: boolean,
) => {
return {
showRunningBorder: (runningStatus === NodeRunningStatus.Running || runningStatus === NodeRunningStatus.Paused) && !showSelectedBorder,
showSuccessBorder: (runningStatus === NodeRunningStatus.Succeeded || (hasVarValue && !runningStatus)) && !showSelectedBorder,
showFailedBorder: runningStatus === NodeRunningStatus.Failed && !showSelectedBorder,
showExceptionBorder: runningStatus === NodeRunningStatus.Exception && !showSelectedBorder,
}
}
export const getLoopIndexTextKey = (runningStatus: NodeRunningStatus | undefined) => {
if (runningStatus === NodeRunningStatus.Running)
return 'nodes.loop.currentLoopCount'
if (runningStatus === NodeRunningStatus.Succeeded || runningStatus === NodeRunningStatus.Failed)
return 'nodes.loop.totalLoopCount'
return undefined
}
export const isEntryWorkflowNode = (type: NodeProps['data']['type']) => {
return isTriggerNode(type) || type === BlockEnum.Start
}
export const isContainerNode = (type: NodeProps['data']['type']) => {
return type === BlockEnum.Iteration || type === BlockEnum.Loop
}

View File

@ -2,32 +2,31 @@ import type {
FC,
ReactElement,
} from 'react'
import type { IterationNodeType } from '@/app/components/workflow/nodes/iteration/types'
import type { NodeProps } from '@/app/components/workflow/types'
import {
cloneElement,
memo,
useEffect,
useContext,
useMemo,
useRef,
} from 'react'
import { useTranslation } from 'react-i18next'
import Tooltip from '@/app/components/base/tooltip'
import { useStore as useZustandStore } from 'zustand'
import { UserAvatarList } from '@/app/components/base/user-avatar-list'
import BlockIcon from '@/app/components/workflow/block-icon'
import { ToolTypeEnum } from '@/app/components/workflow/block-selector/types'
import { useCollaboration } from '@/app/components/workflow/collaboration/hooks/use-collaboration'
import { WorkflowContext } from '@/app/components/workflow/context'
import { useNodesReadOnly, useToolIcon } from '@/app/components/workflow/hooks'
import useInspectVarsCrud from '@/app/components/workflow/hooks/use-inspect-vars-crud'
import { useNodePluginInstallation } from '@/app/components/workflow/hooks/use-node-plugin-installation'
import { useNodeIterationInteractions } from '@/app/components/workflow/nodes/iteration/use-interactions'
import { useNodeLoopInteractions } from '@/app/components/workflow/nodes/loop/use-interactions'
import CopyID from '@/app/components/workflow/nodes/tool/components/copy-id'
import { useStore } from '@/app/components/workflow/store'
import { createWorkflowStore } from '@/app/components/workflow/store'
import {
BlockEnum,
ControlMode,
isTriggerNode,
NodeRunningStatus,
} from '@/app/components/workflow/types'
import { hasErrorHandleNode, hasRetryNode } from '@/app/components/workflow/utils'
@ -43,6 +42,18 @@ import {
} from './components/node-handle'
import NodeResizer from './components/node-resizer'
import RetryOnNode from './components/retry/retry-on-node'
import {
NodeBody,
NodeDescription,
NodeHeaderMeta,
} from './node-sections'
import {
getLoopIndexTextKey,
getNodeStatusBorders,
isContainerNode,
isEntryWorkflowNode,
} from './node.helpers'
import useNodeResizeObserver from './use-node-resize-observer'
type NodeChildProps = {
id: string
@ -70,9 +81,14 @@ const BaseNode: FC<BaseNodeProps> = ({
const { handleNodeLoopChildSizeChange } = useNodeLoopInteractions()
const toolIcon = useToolIcon(data)
const { userProfile } = useAppContext()
const appId = useStore(s => s.appId)
const workflowStore = useContext(WorkflowContext)
const fallbackWorkflowStoreRef = useRef<ReturnType<typeof createWorkflowStore> | null>(null)
if (!fallbackWorkflowStoreRef.current)
fallbackWorkflowStoreRef.current = createWorkflowStore({})
const resolvedWorkflowStore = workflowStore ?? fallbackWorkflowStoreRef.current
const appId = useZustandStore(resolvedWorkflowStore, s => s.appId)
const controlMode = useZustandStore(resolvedWorkflowStore, s => s.controlMode)
const { nodePanelPresence } = useCollaboration(appId as string)
const controlMode = useStore(s => s.controlMode)
const { shouldDim: pluginDimmed, isChecking: pluginIsChecking, isMissing: pluginIsMissing, canInstall: pluginCanInstall, uniqueIdentifier: pluginUniqueIdentifier } = useNodePluginInstallation(data)
const pluginInstallLocked = !pluginIsChecking && pluginIsMissing && pluginCanInstall && Boolean(pluginUniqueIdentifier)
@ -82,9 +98,9 @@ const BaseNode: FC<BaseNodeProps> = ({
const avatar = userProfile?.avatar_url || userProfile?.avatar || null
return {
avatar,
userId,
username,
avatar,
}
}, [userProfile?.avatar, userProfile?.avatar_url, userProfile?.email, userProfile?.id, userProfile?.name])
@ -96,65 +112,40 @@ const BaseNode: FC<BaseNodeProps> = ({
return Object.values(presence)
.filter(viewer => viewer.userId && viewer.userId !== currentUserPresence.userId)
.map(viewer => ({
avatar_url: viewer.avatar || null,
id: viewer.userId,
name: viewer.username,
avatar_url: viewer.avatar || null,
}))
}, [currentUserPresence.userId, id, nodePanelPresence])
useEffect(() => {
if (nodeRef.current && data.selected && data.isInIteration) {
const resizeObserver = new ResizeObserver(() => {
handleNodeIterationChildSizeChange(id)
})
useNodeResizeObserver({
enabled: Boolean(data.selected && data.isInIteration),
nodeRef,
onResize: () => handleNodeIterationChildSizeChange(id),
})
resizeObserver.observe(nodeRef.current)
return () => {
resizeObserver.disconnect()
}
}
}, [data.isInIteration, data.selected, id, handleNodeIterationChildSizeChange])
useEffect(() => {
if (nodeRef.current && data.selected && data.isInLoop) {
const resizeObserver = new ResizeObserver(() => {
handleNodeLoopChildSizeChange(id)
})
resizeObserver.observe(nodeRef.current)
return () => {
resizeObserver.disconnect()
}
}
}, [data.isInLoop, data.selected, id, handleNodeLoopChildSizeChange])
useNodeResizeObserver({
enabled: Boolean(data.selected && data.isInLoop),
nodeRef,
onResize: () => handleNodeLoopChildSizeChange(id),
})
const { hasNodeInspectVars } = useInspectVarsCrud()
const isLoading = data._runningStatus === NodeRunningStatus.Running || data._singleRunningStatus === NodeRunningStatus.Running
const hasVarValue = hasNodeInspectVars(id)
const showSelectedBorder = data.selected || data._isBundled || data._isEntering
const showSelectedBorder = Boolean(data.selected || data._isBundled || data._isEntering)
const {
showRunningBorder,
showSuccessBorder,
showFailedBorder,
showExceptionBorder,
} = useMemo(() => {
return {
showRunningBorder: (data._runningStatus === NodeRunningStatus.Running || data._runningStatus === NodeRunningStatus.Paused) && !showSelectedBorder,
showSuccessBorder: (data._runningStatus === NodeRunningStatus.Succeeded || (hasVarValue && !data._runningStatus)) && !showSelectedBorder,
showFailedBorder: data._runningStatus === NodeRunningStatus.Failed && !showSelectedBorder,
showExceptionBorder: data._runningStatus === NodeRunningStatus.Exception && !showSelectedBorder,
}
}, [data._runningStatus, hasVarValue, showSelectedBorder])
} = useMemo(() => getNodeStatusBorders(data._runningStatus, hasVarValue, showSelectedBorder), [data._runningStatus, hasVarValue, showSelectedBorder])
const LoopIndex = useMemo(() => {
let text = ''
if (data._runningStatus === NodeRunningStatus.Running)
text = t('nodes.loop.currentLoopCount', { ns: 'workflow', count: data._loopIndex })
if (data._runningStatus === NodeRunningStatus.Succeeded || data._runningStatus === NodeRunningStatus.Failed)
text = t('nodes.loop.totalLoopCount', { ns: 'workflow', count: data._loopIndex })
const translationKey = getLoopIndexTextKey(data._runningStatus)
const text = translationKey
? t(translationKey, { ns: 'workflow', count: data._loopIndex })
: ''
if (text) {
return (
@ -224,8 +215,8 @@ const BaseNode: FC<BaseNodeProps> = ({
)}
ref={nodeRef}
style={{
width: (data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) ? data.width : 'auto',
height: (data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) ? data.height : 'auto',
width: isContainerNode(data.type) ? data.width : 'auto',
height: isContainerNode(data.type) ? data.height : 'auto',
}}
>
{(data._dimmed || pluginDimmed || pluginInstallLocked) && (
@ -253,9 +244,9 @@ const BaseNode: FC<BaseNodeProps> = ({
className={cn(
'group relative pb-1 shadow-xs',
'rounded-[15px] border border-transparent',
(controlMode === ControlMode.Comment) && 'hover:cursor-none',
(data.type !== BlockEnum.Iteration && data.type !== BlockEnum.Loop) && 'w-[240px] bg-workflow-block-bg',
(data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) && 'flex h-full w-full flex-col border-workflow-block-border bg-workflow-block-bg-transparent',
controlMode === ControlMode.Comment && 'hover:cursor-none',
!isContainerNode(data.type) && 'w-[240px] bg-workflow-block-bg',
isContainerNode(data.type) && 'flex h-full w-full flex-col border-workflow-block-border bg-workflow-block-bg-transparent',
!data._runningStatus && 'hover:shadow-lg',
showRunningBorder && '!border-state-accent-solid',
showSuccessBorder && '!border-state-success-solid',
@ -319,7 +310,7 @@ const BaseNode: FC<BaseNodeProps> = ({
}
<div className={cn(
'flex items-center rounded-t-2xl px-3 pb-2 pt-3',
(data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) && 'bg-transparent',
isContainerNode(data.type) && 'bg-transparent',
)}
>
<BlockIcon
@ -335,23 +326,6 @@ const BaseNode: FC<BaseNodeProps> = ({
<div>
{data.title}
</div>
{
data.type === BlockEnum.Iteration && (data as IterationNodeType).is_parallel && (
<Tooltip popupContent={(
<div className="w-[180px]">
<div className="font-extrabold">
{t('nodes.iteration.parallelModeEnableTitle', { ns: 'workflow' })}
</div>
{t('nodes.iteration.parallelModeEnableDesc', { ns: 'workflow' })}
</div>
)}
>
<div className="ml-1 flex items-center justify-center rounded-[5px] border-[1px] border-text-warning px-[5px] py-[3px] text-text-warning system-2xs-medium-uppercase">
{t('nodes.iteration.parallelModeUpper', { ns: 'workflow' })}
</div>
</Tooltip>
)
}
{viewingUsers.length > 0 && (
<div className="ml-3 shrink-0">
<UserAvatarList
@ -362,54 +336,18 @@ const BaseNode: FC<BaseNodeProps> = ({
</div>
)}
</div>
{
!!(data._iterationLength && data._iterationIndex && data._runningStatus === NodeRunningStatus.Running) && (
<div className="mr-1.5 text-xs font-medium text-text-accent">
{data._iterationIndex > data._iterationLength ? data._iterationLength : data._iterationIndex}
/
{data._iterationLength}
</div>
)
}
{
!!(data.type === BlockEnum.Loop && data._loopIndex) && LoopIndex
}
{
isLoading && <span className="i-ri-loader-2-line h-3.5 w-3.5 animate-spin text-text-accent" />
}
{
!isLoading && data._runningStatus === NodeRunningStatus.Failed && (
<span className="i-ri-error-warning-fill h-3.5 w-3.5 text-text-destructive" />
)
}
{
!isLoading && data._runningStatus === NodeRunningStatus.Exception && (
<span className="i-ri-alert-fill h-3.5 w-3.5 text-text-warning-secondary" />
)
}
{
!isLoading && (data._runningStatus === NodeRunningStatus.Succeeded || (hasVarValue && !data._runningStatus)) && (
<span className="i-ri-checkbox-circle-fill h-3.5 w-3.5 text-text-success" />
)
}
{
!isLoading && data._runningStatus === NodeRunningStatus.Paused && (
<span className="i-ri-pause-circle-fill h-3.5 w-3.5 text-text-warning-secondary" />
)
}
<NodeHeaderMeta
data={data}
hasVarValue={hasVarValue}
isLoading={isLoading}
loopIndex={LoopIndex}
t={t}
/>
</div>
{
data.type !== BlockEnum.Iteration && data.type !== BlockEnum.Loop && (
cloneElement(children, { id, data } as any)
)
}
{
(data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) && (
<div className="grow pb-1 pl-1 pr-1">
{cloneElement(children, { id, data } as any)}
</div>
)
}
<NodeBody
data={data}
child={cloneElement(children, { id, data } as Partial<NodeChildProps>)}
/>
{
hasRetryNode(data.type) && (
<RetryOnNode
@ -426,13 +364,7 @@ const BaseNode: FC<BaseNodeProps> = ({
/>
)
}
{
!!(data.desc && data.type !== BlockEnum.Iteration && data.type !== BlockEnum.Loop) && (
<div className="whitespace-pre-line break-words px-3 pb-2 pt-1 text-text-tertiary system-xs-regular">
{data.desc}
</div>
)
}
<NodeDescription data={data} />
{data.type === BlockEnum.Tool && data.provider_type === ToolTypeEnum.MCP && (
<div className="px-3 pb-2">
<CopyID content={data.provider_id || ''} />
@ -443,7 +375,7 @@ const BaseNode: FC<BaseNodeProps> = ({
)
const isStartNode = data.type === BlockEnum.Start
const isEntryNode = isTriggerNode(data.type as any) || isStartNode
const isEntryNode = isEntryWorkflowNode(data.type)
const shouldWrapEntryNode = isEntryNode && !(isStartNode && _subGraphEntry)
return shouldWrapEntryNode

View File

@ -0,0 +1,30 @@
import { useEffect } from 'react'
type ResizeObserverParams = {
enabled: boolean
nodeRef: React.RefObject<HTMLDivElement | null>
onResize: () => void
}
const useNodeResizeObserver = ({
enabled,
nodeRef,
onResize,
}: ResizeObserverParams) => {
useEffect(() => {
if (!enabled || !nodeRef.current)
return
const resizeObserver = new ResizeObserver(() => {
onResize()
})
resizeObserver.observe(nodeRef.current)
return () => {
resizeObserver.disconnect()
}
}, [enabled, nodeRef, onResize])
}
export default useNodeResizeObserver

View File

@ -0,0 +1,139 @@
import type { DataSourceNodeType } from '../../types'
import { renderHook } from '@testing-library/react'
import { VarType as VarKindType } from '../../types'
import { useConfig } from '../use-config'
const mockUseStoreApi = vi.hoisted(() => vi.fn())
const mockUseNodeDataUpdate = vi.hoisted(() => vi.fn())
vi.mock('reactflow', () => ({
useStoreApi: () => mockUseStoreApi(),
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useNodeDataUpdate: () => mockUseNodeDataUpdate(),
}))
const createNode = (overrides: Partial<DataSourceNodeType> = {}): { id: string, data: DataSourceNodeType } => ({
id: 'data-source-node',
data: {
title: 'Datasource',
desc: '',
type: 'data-source',
plugin_id: 'plugin-1',
provider_type: 'local_file',
provider_name: 'provider',
datasource_name: 'source-a',
datasource_label: 'Source A',
datasource_parameters: {},
datasource_configurations: {},
_dataSourceStartToAdd: true,
...overrides,
} as DataSourceNodeType,
})
describe('data-source/hooks/use-config', () => {
const mockHandleNodeDataUpdateWithSyncDraft = vi.fn()
let currentNode = createNode()
beforeEach(() => {
vi.clearAllMocks()
currentNode = createNode()
mockUseStoreApi.mockReturnValue({
getState: () => ({
getNodes: () => [currentNode],
}),
})
mockUseNodeDataUpdate.mockReturnValue({
handleNodeDataUpdateWithSyncDraft: mockHandleNodeDataUpdateWithSyncDraft,
})
})
it('should clear the local-file auto-add flag on mount and update datasource payloads', () => {
const { result } = renderHook(() => useConfig('data-source-node'))
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenCalledWith({
id: 'data-source-node',
data: expect.objectContaining({
_dataSourceStartToAdd: false,
}),
})
mockHandleNodeDataUpdateWithSyncDraft.mockClear()
result.current.handleFileExtensionsChange(['pdf', 'csv'])
result.current.handleParametersChange({
dataset: {
type: VarKindType.constant,
value: 'docs',
},
})
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenNthCalledWith(1, {
id: 'data-source-node',
data: expect.objectContaining({
fileExtensions: ['pdf', 'csv'],
}),
})
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenNthCalledWith(2, {
id: 'data-source-node',
data: expect.objectContaining({
datasource_parameters: {
dataset: {
type: VarKindType.constant,
value: 'docs',
},
},
}),
})
})
it('should derive output schema metadata and detect object outputs', () => {
const dataSourceList = [{
plugin_id: 'plugin-1',
tools: [{
name: 'source-a',
output_schema: {
properties: {
items: {
type: 'array',
items: { type: 'string' },
description: 'List of items',
},
metadata: {
type: 'object',
description: 'Object field',
},
count: {
type: 'number',
description: 'Total count',
},
},
},
}],
}]
const { result } = renderHook(() => useConfig('data-source-node', dataSourceList))
expect(result.current.outputSchema).toEqual([
{
name: 'items',
type: 'Array[String]',
description: 'List of items',
},
{
name: 'metadata',
value: {
type: 'object',
description: 'Object field',
},
},
{
name: 'count',
type: 'Number',
description: 'Total count',
},
])
expect(result.current.hasObjectOutput).toBe(true)
})
})

View File

@ -0,0 +1,149 @@
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { UserActionButtonType } from '../../types'
import ButtonStyleDropdown from '../button-style-dropdown'
const mockUseTranslation = vi.hoisted(() => vi.fn())
const mockButton = vi.hoisted(() => vi.fn())
vi.mock('react-i18next', () => ({
useTranslation: () => mockUseTranslation(),
}))
vi.mock('@/app/components/base/button', () => ({
__esModule: true,
default: (props: {
variant?: string
children?: React.ReactNode
className?: string
}) => {
mockButton(props)
return <div data-testid={`button-${props.variant ?? 'default'}`}>{props.children}</div>
},
}))
vi.mock('@/app/components/base/portal-to-follow-elem', () => {
const OpenContext = React.createContext(false)
return {
PortalToFollowElem: ({
open,
children,
}: {
open: boolean
children?: React.ReactNode
}) => (
<OpenContext value={open}>
<div data-testid="portal" data-open={String(open)}>{children}</div>
</OpenContext>
),
PortalToFollowElemTrigger: ({
children,
onClick,
}: {
children?: React.ReactNode
onClick?: () => void
}) => (
<button type="button" data-testid="portal-trigger" onClick={onClick}>
{children}
</button>
),
PortalToFollowElemContent: ({
children,
}: {
children?: React.ReactNode
}) => {
const open = React.use(OpenContext)
return open ? <div data-testid="portal-content">{children}</div> : null
},
}
})
describe('ButtonStyleDropdown', () => {
const onChange = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockUseTranslation.mockReturnValue({
t: (key: string) => key,
})
})
it('should map the current style to the trigger button and update the selected style', () => {
render(
<ButtonStyleDropdown
text="Approve"
data={UserActionButtonType.Ghost}
onChange={onChange}
/>,
)
expect(mockButton).toHaveBeenCalledWith(expect.objectContaining({
variant: 'ghost',
}))
expect(screen.getByTestId('portal')).toHaveAttribute('data-open', 'false')
fireEvent.click(screen.getByTestId('portal-trigger'))
expect(screen.getByTestId('portal')).toHaveAttribute('data-open', 'true')
expect(screen.getByText('nodes.humanInput.userActions.chooseStyle')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('button-primary').parentElement as HTMLElement)
fireEvent.click(screen.getByTestId('button-secondary').parentElement as HTMLElement)
fireEvent.click(screen.getByTestId('button-secondary-accent').parentElement as HTMLElement)
fireEvent.click(screen.getAllByTestId('button-ghost')[1].parentElement as HTMLElement)
expect(onChange).toHaveBeenNthCalledWith(1, UserActionButtonType.Primary)
expect(onChange).toHaveBeenNthCalledWith(2, UserActionButtonType.Default)
expect(onChange).toHaveBeenNthCalledWith(3, UserActionButtonType.Accent)
expect(onChange).toHaveBeenNthCalledWith(4, UserActionButtonType.Ghost)
})
it('should keep the dropdown closed in readonly mode', () => {
render(
<ButtonStyleDropdown
text="Approve"
data={UserActionButtonType.Default}
onChange={onChange}
readonly
/>,
)
expect(mockButton).toHaveBeenCalledWith(expect.objectContaining({
variant: 'secondary',
}))
fireEvent.click(screen.getByTestId('portal-trigger'))
expect(screen.getByTestId('portal')).toHaveAttribute('data-open', 'false')
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
expect(onChange).not.toHaveBeenCalled()
})
it('should map the accent style to the secondary-accent trigger button', () => {
render(
<ButtonStyleDropdown
text="Approve"
data={UserActionButtonType.Accent}
onChange={onChange}
/>,
)
expect(mockButton).toHaveBeenCalledWith(expect.objectContaining({
variant: 'secondary-accent',
}))
})
it('should map the primary style to the primary trigger button', () => {
render(
<ButtonStyleDropdown
text="Approve"
data={UserActionButtonType.Primary}
onChange={onChange}
/>,
)
expect(mockButton).toHaveBeenCalledWith(expect.objectContaining({
variant: 'primary',
}))
})
})

View File

@ -0,0 +1,135 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { UserActionButtonType } from '../../types'
import FormContentPreview from '../form-content-preview'
const mockUseTranslation = vi.hoisted(() => vi.fn())
const mockUseStore = vi.hoisted(() => vi.fn())
const mockUseNodes = vi.hoisted(() => vi.fn())
const mockGetButtonStyle = vi.hoisted(() => vi.fn())
vi.mock('react-i18next', () => ({
useTranslation: () => mockUseTranslation(),
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: { panelWidth: number }) => unknown) => mockUseStore(selector),
}))
vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({
__esModule: true,
default: () => mockUseNodes(),
}))
vi.mock('@/app/components/base/action-button', () => ({
__esModule: true,
default: ({ children, onClick }: { children?: ReactNode, onClick?: () => void }) => (
<button type="button" aria-label="close-preview" onClick={onClick}>
{children}
</button>
),
}))
vi.mock('@/app/components/base/badge', () => ({
__esModule: true,
default: ({ children }: { children?: ReactNode }) => <div data-testid="badge">{children}</div>,
}))
vi.mock('@/app/components/base/button', () => ({
__esModule: true,
default: ({ children, variant }: { children?: ReactNode, variant?: string }) => (
<button type="button" data-testid={`action-${variant}`}>{children}</button>
),
}))
vi.mock('@/app/components/base/chat/chat/answer/human-input-content/utils', () => ({
getButtonStyle: (...args: unknown[]) => mockGetButtonStyle(...args),
}))
vi.mock('@/app/components/base/markdown', () => ({
Markdown: ({ customComponents }: {
customComponents: {
variable: (props: { node: { properties: { dataPath: string } } }) => ReactNode
section: (props: { node: { properties: { dataName: string } } }) => ReactNode
}
}) => (
<div>
{customComponents.variable({ node: { properties: { dataPath: '#node-1.answer#' } } })}
{customComponents.section({ node: { properties: { dataName: 'field_1' } } })}
{customComponents.section({ node: { properties: { dataName: 'missing_field' } } })}
</div>
),
}))
vi.mock('../variable-in-markdown', () => ({
rehypeNotes: vi.fn(),
rehypeVariable: vi.fn(),
Variable: ({ path }: { path: string }) => <div data-testid="variable-path">{path}</div>,
Note: ({ defaultInput, nodeName }: {
defaultInput: { selector: string[] }
nodeName: (nodeId: string) => string
}) => <div data-testid="note">{nodeName(defaultInput.selector[0])}</div>,
}))
describe('FormContentPreview', () => {
const onClose = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockUseTranslation.mockReturnValue({
t: (key: string) => key,
})
mockUseStore.mockImplementation((selector: (state: { panelWidth: number }) => unknown) => selector({ panelWidth: 320 }))
mockUseNodes.mockReturnValue([{
id: 'node-1',
data: { title: 'Classifier' },
}])
mockGetButtonStyle.mockImplementation((style: UserActionButtonType) => style.toLowerCase())
})
it('should render preview content with resolved node names, note fallbacks, and action buttons', () => {
const { container } = render(
<FormContentPreview
content="content"
formInputs={[{
type: 'text-input' as never,
output_variable_name: 'field_1',
default: {
type: 'variable',
selector: ['node-1', 'answer'],
value: '',
},
}]}
userActions={[{
id: 'approve',
title: 'Approve',
button_style: UserActionButtonType.Primary,
}]}
onClose={onClose}
/>,
)
expect(container.firstChild).toHaveStyle({ right: '328px' })
expect(screen.getByTestId('badge')).toHaveTextContent('nodes.humanInput.formContent.preview')
expect(screen.getByTestId('variable-path')).toHaveTextContent('#Classifier.answer#')
expect(screen.getByTestId('note')).toHaveTextContent('Classifier')
expect(screen.getByText(/Can't find note:/)).toHaveTextContent('missing_field')
expect(screen.getByTestId('action-primary')).toHaveTextContent('Approve')
expect(screen.getByText('nodes.humanInput.editor.previewTip')).toBeInTheDocument()
})
it('should close the preview when the close action is clicked', () => {
render(
<FormContentPreview
content="content"
formInputs={[]}
userActions={[]}
onClose={onClose}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'close-preview' }))
expect(onClose).toHaveBeenCalledTimes(1)
})
})

View File

@ -0,0 +1,258 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import FormContent from '../form-content'
const mockUseTranslation = vi.hoisted(() => vi.fn())
const mockUseWorkflowVariableType = vi.hoisted(() => vi.fn())
const mockIsMac = vi.hoisted(() => vi.fn())
const mockPromptEditor = vi.hoisted(() => vi.fn())
const mockAddInputField = vi.hoisted(() => vi.fn())
const mockOnInsert = vi.hoisted(() => vi.fn())
vi.mock('react-i18next', () => ({
useTranslation: () => mockUseTranslation(),
Trans: ({
i18nKey,
components,
}: {
i18nKey: string
components?: Record<string, ReactNode>
}) => (
<div>
<div>{i18nKey}</div>
{components?.CtrlKey}
{components?.Key}
</div>
),
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useWorkflowVariableType: () => mockUseWorkflowVariableType(),
}))
vi.mock('@/app/components/workflow/utils', () => ({
isMac: () => mockIsMac(),
}))
vi.mock('@/app/components/base/prompt-editor', () => ({
__esModule: true,
default: (props: {
onChange: (value: string) => void
onFocus: () => void
onBlur: () => void
shortcutPopups?: Array<{
Popup: (props: { onClose: () => void, onInsert: typeof mockOnInsert }) => ReactNode
}>
editable?: boolean
hitlInputBlock: {
workflowNodesMap: Record<string, unknown>
}
}) => {
mockPromptEditor(props)
const popup = props.shortcutPopups?.[0]
return (
<div>
<button type="button" onClick={props.onFocus}>focus-editor</button>
<button type="button" onClick={props.onBlur}>blur-editor</button>
<button type="button" onClick={() => props.onChange('updated value')}>change-editor</button>
{popup && popup.Popup({ onClose: vi.fn(), onInsert: mockOnInsert })}
</div>
)
},
}))
vi.mock('../add-input-field', () => ({
__esModule: true,
default: (props: {
onSave: (payload: {
type: string
output_variable_name: string
default: {
type: string
selector: string[]
value: string
}
}) => void
onCancel: () => void
}) => {
mockAddInputField(props)
return (
<div>
<button
type="button"
onClick={() => props.onSave({
type: 'text-input',
output_variable_name: 'approval',
default: {
type: 'variable',
selector: ['node-1', 'answer'],
value: '',
},
})}
>
save-input
</button>
<button type="button" onClick={props.onCancel}>cancel-input</button>
</div>
)
},
}))
vi.mock('@/app/components/base/prompt-editor/plugins/hitl-input-block', () => ({
INSERT_HITL_INPUT_BLOCK_COMMAND: 'INSERT_HITL_INPUT_BLOCK_COMMAND',
}))
describe('FormContent', () => {
const onChange = vi.fn()
const onFormInputsChange = vi.fn()
const onFormInputItemRename = vi.fn()
const onFormInputItemRemove = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockUseTranslation.mockReturnValue({
t: (key: string) => key,
})
mockUseWorkflowVariableType.mockReturnValue(() => 'string')
mockIsMac.mockReturnValue(false)
})
it('should build workflow node maps, show the hotkey tip on focus, and defer form-input sync until value changes', async () => {
const { rerender } = render(
<FormContent
nodeId="node-2"
value="Initial content"
onChange={onChange}
formInputs={[]}
onFormInputsChange={onFormInputsChange}
onFormInputItemRename={onFormInputItemRename}
onFormInputItemRemove={onFormInputItemRemove}
editorKey={1}
isExpand={false}
availableVars={[]}
availableNodes={[
{
id: 'node-1',
data: { title: 'Start', type: 'start' },
position: { x: 0, y: 0 },
width: 100,
height: 40,
} as never,
{
id: 'node-2',
data: { title: 'Classifier', type: 'code' },
position: { x: 120, y: 0 },
width: 100,
height: 40,
} as never,
]}
/>,
)
expect(mockPromptEditor).toHaveBeenCalledWith(expect.objectContaining({
editable: true,
hitlInputBlock: expect.objectContaining({
workflowNodesMap: expect.objectContaining({
'node-1': expect.objectContaining({ title: 'Start' }),
'node-2': expect.objectContaining({ title: 'Classifier' }),
'sys': expect.objectContaining({ title: 'blocks.start' }),
}),
}),
}))
fireEvent.click(screen.getByText('focus-editor'))
expect(screen.getByText('nodes.humanInput.formContent.hotkeyTip')).toBeInTheDocument()
fireEvent.click(screen.getByText('save-input'))
expect(mockOnInsert).toHaveBeenCalledWith('INSERT_HITL_INPUT_BLOCK_COMMAND', expect.objectContaining({
variableName: 'approval',
nodeId: 'node-2',
formInputs: [expect.objectContaining({ output_variable_name: 'approval' })],
onFormInputsChange,
onFormInputItemRename,
onFormInputItemRemove,
}))
expect(onFormInputsChange).not.toHaveBeenCalled()
rerender(
<FormContent
nodeId="node-2"
value="Initial content {{approval}}"
onChange={onChange}
formInputs={[]}
onFormInputsChange={onFormInputsChange}
onFormInputItemRename={onFormInputItemRename}
onFormInputItemRemove={onFormInputItemRemove}
editorKey={1}
isExpand={false}
availableVars={[]}
availableNodes={[
{
id: 'node-1',
data: { title: 'Start', type: 'start' },
position: { x: 0, y: 0 },
width: 100,
height: 40,
} as never,
]}
/>,
)
await waitFor(() => {
expect(onFormInputsChange).toHaveBeenCalledWith([
expect.objectContaining({ output_variable_name: 'approval' }),
])
})
})
it('should disable editing helpers in readonly mode', () => {
const { container } = render(
<FormContent
nodeId="node-2"
value="Initial content"
onChange={onChange}
formInputs={[]}
onFormInputsChange={onFormInputsChange}
onFormInputItemRename={onFormInputItemRename}
onFormInputItemRemove={onFormInputItemRemove}
editorKey={1}
isExpand={false}
availableVars={[]}
availableNodes={[]}
readonly
/>,
)
expect(mockPromptEditor).toHaveBeenCalledWith(expect.objectContaining({
editable: false,
shortcutPopups: [],
}))
expect(screen.queryByText('save-input')).not.toBeInTheDocument()
expect(container.firstChild).toHaveClass('pointer-events-none')
})
it('should render the mac hotkey hint when focused on macOS', () => {
mockIsMac.mockReturnValue(true)
render(
<FormContent
nodeId="node-2"
value="Initial content"
onChange={onChange}
formInputs={[]}
onFormInputsChange={onFormInputsChange}
onFormInputItemRename={onFormInputItemRename}
onFormInputItemRemove={onFormInputItemRemove}
editorKey={1}
isExpand={false}
availableVars={[]}
availableNodes={[]}
/>,
)
fireEvent.click(screen.getByText('focus-editor'))
expect(screen.getByText('⌘')).toBeInTheDocument()
expect(screen.getByText('/')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,77 @@
import { fireEvent, render, screen } from '@testing-library/react'
import TimeoutInput from '../timeout'
const mockUseTranslation = vi.hoisted(() => vi.fn())
vi.mock('react-i18next', () => ({
useTranslation: () => mockUseTranslation(),
}))
vi.mock('@/app/components/base/input', () => ({
__esModule: true,
default: (props: {
value: number
disabled?: boolean
onChange: (event: { target: { value: string } }) => void
}) => (
<input
data-testid="timeout-input"
value={props.value}
disabled={props.disabled}
onChange={e => props.onChange({ target: { value: e.target.value } })}
/>
),
}))
describe('TimeoutInput', () => {
const onChange = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockUseTranslation.mockReturnValue({
t: (key: string) => key,
})
})
it('should update the numeric timeout value and switch units', () => {
render(
<TimeoutInput
timeout={3}
unit="day"
onChange={onChange}
/>,
)
fireEvent.change(screen.getByTestId('timeout-input'), { target: { value: '12' } })
fireEvent.click(screen.getByText('nodes.humanInput.timeout.hours'))
expect(onChange).toHaveBeenNthCalledWith(1, { timeout: 12, unit: 'day' })
expect(onChange).toHaveBeenNthCalledWith(2, { timeout: 3, unit: 'hour' })
})
it('should fall back to 1 on invalid input and stay read-only when disabled', () => {
const { rerender } = render(
<TimeoutInput
timeout={5}
unit="hour"
onChange={onChange}
/>,
)
fireEvent.change(screen.getByTestId('timeout-input'), { target: { value: 'abc' } })
expect(onChange).toHaveBeenCalledWith({ timeout: 1, unit: 'hour' })
rerender(
<TimeoutInput
timeout={5}
unit="hour"
onChange={onChange}
readonly
/>,
)
fireEvent.click(screen.getByText('nodes.humanInput.timeout.days'))
expect(onChange).toHaveBeenCalledTimes(1)
expect(screen.getByTestId('timeout-input')).toBeDisabled()
})
})

View File

@ -0,0 +1,146 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { UserActionButtonType } from '../../types'
import UserActionItem from '../user-action'
const mockUseTranslation = vi.hoisted(() => vi.fn())
const mockNotify = vi.hoisted(() => vi.fn())
vi.mock('react-i18next', () => ({
useTranslation: () => mockUseTranslation(),
}))
vi.mock('@/app/components/base/input', () => ({
__esModule: true,
default: (props: {
value: string
placeholder?: string
disabled?: boolean
onChange: (event: { target: { value: string } }) => void
}) => (
<input
data-testid={props.placeholder}
value={props.value}
disabled={props.disabled}
onChange={e => props.onChange({ target: { value: e.target.value } })}
/>
),
}))
vi.mock('@/app/components/base/button', () => ({
__esModule: true,
default: (props: {
children?: ReactNode
onClick?: () => void
}) => (
<button type="button" onClick={props.onClick}>
{props.children}
</button>
),
}))
vi.mock('@/app/components/base/ui/toast', () => ({
__esModule: true,
toast: {
success: (message: string) => mockNotify({ type: 'success', message }),
error: (message: string) => mockNotify({ type: 'error', message }),
warning: (message: string) => mockNotify({ type: 'warning', message }),
info: (message: string) => mockNotify({ type: 'info', message }),
},
}))
vi.mock('../button-style-dropdown', () => ({
__esModule: true,
default: (props: {
onChange: (type: UserActionButtonType) => void
}) => (
<button type="button" onClick={() => props.onChange(UserActionButtonType.Ghost)}>
change-style
</button>
),
}))
describe('UserActionItem', () => {
const onChange = vi.fn()
const onDelete = vi.fn()
const action = {
id: 'approve',
title: 'Approve',
button_style: UserActionButtonType.Primary,
}
beforeEach(() => {
vi.clearAllMocks()
mockUseTranslation.mockReturnValue({
t: (key: string) => key,
})
})
it('should sanitize ids, enforce length limits, and update the button text', () => {
render(
<UserActionItem
data={action}
onChange={onChange}
onDelete={onDelete}
/>,
)
fireEvent.change(screen.getByTestId('nodes.humanInput.userActions.actionNamePlaceholder'), { target: { value: 'Approve action' } })
fireEvent.change(screen.getByTestId('nodes.humanInput.userActions.actionNamePlaceholder'), { target: { value: '1invalid' } })
fireEvent.change(screen.getByTestId('nodes.humanInput.userActions.actionNamePlaceholder'), { target: { value: 'averyveryveryverylongidentifier' } })
fireEvent.change(screen.getByTestId('nodes.humanInput.userActions.buttonTextPlaceholder'), { target: { value: 'A very very very long button title' } })
expect(onChange).toHaveBeenNthCalledWith(1, expect.objectContaining({
id: 'Approve_action',
}))
expect(onChange).toHaveBeenNthCalledWith(2, expect.objectContaining({
id: 'averyveryveryverylon',
}))
expect(onChange).toHaveBeenNthCalledWith(3, expect.objectContaining({
title: 'A very very very lon',
}))
expect(mockNotify).toHaveBeenNthCalledWith(1, expect.objectContaining({
type: 'error',
message: 'nodes.humanInput.userActions.actionIdFormatTip',
}))
expect(mockNotify).toHaveBeenNthCalledWith(2, expect.objectContaining({
type: 'error',
message: 'nodes.humanInput.userActions.actionIdTooLong',
}))
expect(mockNotify).toHaveBeenNthCalledWith(3, expect.objectContaining({
type: 'error',
message: 'nodes.humanInput.userActions.buttonTextTooLong',
}))
})
it('should support clearing ids, updating button style, deleting, and readonly mode', () => {
const { rerender } = render(
<UserActionItem
data={action}
onChange={onChange}
onDelete={onDelete}
/>,
)
fireEvent.change(screen.getByTestId('nodes.humanInput.userActions.actionNamePlaceholder'), { target: { value: ' ' } })
fireEvent.click(screen.getByText('change-style'))
fireEvent.click(screen.getAllByRole('button')[1])
expect(onChange).toHaveBeenNthCalledWith(1, expect.objectContaining({ id: '' }))
expect(onChange).toHaveBeenNthCalledWith(2, expect.objectContaining({ button_style: UserActionButtonType.Ghost }))
expect(onDelete).toHaveBeenCalledWith('approve')
rerender(
<UserActionItem
data={action}
onChange={onChange}
onDelete={onDelete}
readonly
/>,
)
expect(screen.getByTestId('nodes.humanInput.userActions.actionNamePlaceholder')).toBeDisabled()
expect(screen.getByTestId('nodes.humanInput.userActions.buttonTextPlaceholder')).toBeDisabled()
expect(screen.getAllByRole('button')).toHaveLength(1)
})
})

View File

@ -0,0 +1,150 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { DeliveryMethodType } from '../../../types'
import DeliveryMethodForm from '../index'
const mockUseTranslation = vi.hoisted(() => vi.fn())
const mockUseNodesSyncDraft = vi.hoisted(() => vi.fn())
vi.mock('react-i18next', () => ({
useTranslation: () => mockUseTranslation(),
}))
vi.mock('@/app/components/base/tooltip', () => ({
__esModule: true,
default: ({ popupContent }: { popupContent: string }) => <div data-testid="tooltip">{popupContent}</div>,
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useNodesSyncDraft: () => mockUseNodesSyncDraft(),
}))
vi.mock('../method-selector', () => ({
__esModule: true,
default: (props: {
onAdd: (method: { id: string, type: DeliveryMethodType, enabled: boolean }) => void
onShowUpgradeTip: () => void
}) => (
<div>
<button
type="button"
onClick={() => props.onAdd({ id: 'email-1', type: DeliveryMethodType.Email, enabled: false })}
>
add-method
</button>
<button type="button" onClick={props.onShowUpgradeTip}>
show-upgrade
</button>
</div>
),
}))
vi.mock('../method-item', () => ({
__esModule: true,
default: (props: {
method: { type: DeliveryMethodType, enabled: boolean }
onChange: (method: { type: DeliveryMethodType, enabled: boolean }) => void
onDelete: (type: DeliveryMethodType) => void
}) => (
<div data-testid={`method-${props.method.type}`}>
<button
type="button"
onClick={() => props.onChange({ ...props.method, enabled: !props.method.enabled })}
>
change-method
</button>
<button
type="button"
onClick={() => props.onDelete(props.method.type)}
>
delete-method
</button>
</div>
),
}))
vi.mock('../upgrade-modal', () => ({
__esModule: true,
default: ({ onClose }: { onClose: () => void }) => (
<button type="button" onClick={onClose}>
upgrade-modal
</button>
),
}))
describe('DeliveryMethodForm', () => {
const onChange = vi.fn()
const mockHandleSyncWorkflowDraft = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockUseTranslation.mockReturnValue({
t: (key: string) => key,
})
mockUseNodesSyncDraft.mockReturnValue({
handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft,
})
})
it('should render the empty state and add methods through the selector', () => {
render(
<DeliveryMethodForm
nodeId="node-1"
value={[]}
onChange={onChange}
/>,
)
expect(screen.getByText('nodes.humanInput.deliveryMethod.emptyTip')).toBeInTheDocument()
fireEvent.click(screen.getByText('add-method'))
expect(onChange).toHaveBeenCalledWith([
{
id: 'email-1',
type: DeliveryMethodType.Email,
enabled: false,
},
])
expect(mockHandleSyncWorkflowDraft).not.toHaveBeenCalled()
})
it('should change and delete methods, syncing the draft after updates', () => {
render(
<DeliveryMethodForm
nodeId="node-1"
value={[{
id: 'email-1',
type: DeliveryMethodType.Email,
enabled: false,
}]}
onChange={onChange}
/>,
)
fireEvent.click(screen.getByText('change-method'))
fireEvent.click(screen.getByText('delete-method'))
expect(onChange).toHaveBeenNthCalledWith(1, [{
id: 'email-1',
type: DeliveryMethodType.Email,
enabled: true,
}])
expect(onChange).toHaveBeenNthCalledWith(2, [])
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledWith(true, true)
})
it('should open and close the upgrade modal', () => {
render(
<DeliveryMethodForm
nodeId="node-1"
value={[]}
onChange={onChange}
/>,
)
fireEvent.click(screen.getByText('show-upgrade'))
expect(screen.getByText('upgrade-modal')).toBeInTheDocument()
fireEvent.click(screen.getByText('upgrade-modal'))
expect(screen.queryByText('upgrade-modal')).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,156 @@
import { fireEvent, render, screen } from '@testing-library/react'
import Recipient from '../index'
const mockUseTranslation = vi.hoisted(() => vi.fn())
const mockUseAppContext = vi.hoisted(() => vi.fn())
const mockUseMembers = vi.hoisted(() => vi.fn())
vi.mock('react-i18next', () => ({
useTranslation: () => mockUseTranslation(),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => mockUseAppContext(),
}))
vi.mock('@/service/use-common', () => ({
useMembers: () => mockUseMembers(),
}))
vi.mock('@/app/components/base/switch', () => ({
__esModule: true,
default: (props: {
value: boolean
onChange: (value: boolean) => void
}) => (
<button type="button" onClick={() => props.onChange(!props.value)}>
toggle-workspace
</button>
),
}))
vi.mock('../member-selector', () => ({
__esModule: true,
default: ({ onSelect }: { onSelect: (id: string) => void }) => (
<button type="button" onClick={() => onSelect('member-2')}>
add-member
</button>
),
}))
vi.mock('../email-input', () => ({
__esModule: true,
default: (props: {
onAdd: (email: string) => void
onSelect: (id: string) => void
onDelete: (recipient: { type: 'member' | 'external', user_id?: string, email?: string }) => void
}) => (
<div>
<button type="button" onClick={() => props.onAdd('new@example.com')}>
add-email
</button>
<button type="button" onClick={() => props.onSelect('member-3')}>
add-email-member
</button>
<button type="button" onClick={() => props.onDelete({ type: 'member', user_id: 'member-1' })}>
delete-member
</button>
<button type="button" onClick={() => props.onDelete({ type: 'external', email: 'external@example.com' })}>
delete-external
</button>
</div>
),
}))
describe('Recipient', () => {
const onChange = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockUseTranslation.mockReturnValue({
t: (key: string, options?: { workspaceName?: string }) => options?.workspaceName ?? key,
})
mockUseAppContext.mockReturnValue({
userProfile: { email: 'owner@example.com' },
currentWorkspace: { name: 'Dify\'s Lab' },
})
mockUseMembers.mockReturnValue({
data: {
accounts: [
{ id: 'member-1', email: 'member-1@example.com', name: 'Member One' },
{ id: 'member-2', email: 'member-2@example.com', name: 'Member Two' },
{ id: 'member-3', email: 'member-3@example.com', name: 'Member Three' },
],
},
})
})
it('should render workspace details and update recipients through member/email actions', () => {
render(
<Recipient
data={{
whole_workspace: false,
items: [
{ type: 'member', user_id: 'member-1' },
{ type: 'external', email: 'external@example.com' },
],
}}
onChange={onChange}
/>,
)
expect(screen.getByText('D')).toBeInTheDocument()
expect(screen.getByText('Difys Lab')).toBeInTheDocument()
fireEvent.click(screen.getByText('add-member'))
fireEvent.click(screen.getByText('add-email'))
fireEvent.click(screen.getByText('add-email-member'))
fireEvent.click(screen.getByText('delete-member'))
fireEvent.click(screen.getByText('delete-external'))
fireEvent.click(screen.getByText('toggle-workspace'))
expect(onChange).toHaveBeenNthCalledWith(1, {
whole_workspace: false,
items: [
{ type: 'member', user_id: 'member-1' },
{ type: 'external', email: 'external@example.com' },
{ type: 'member', user_id: 'member-2' },
],
})
expect(onChange).toHaveBeenNthCalledWith(2, {
whole_workspace: false,
items: [
{ type: 'member', user_id: 'member-1' },
{ type: 'external', email: 'external@example.com' },
{ type: 'external', email: 'new@example.com' },
],
})
expect(onChange).toHaveBeenNthCalledWith(3, {
whole_workspace: false,
items: [
{ type: 'member', user_id: 'member-1' },
{ type: 'external', email: 'external@example.com' },
{ type: 'member', user_id: 'member-3' },
],
})
expect(onChange).toHaveBeenNthCalledWith(4, {
whole_workspace: false,
items: [
{ type: 'external', email: 'external@example.com' },
],
})
expect(onChange).toHaveBeenNthCalledWith(5, {
whole_workspace: false,
items: [
{ type: 'member', user_id: 'member-1' },
],
})
expect(onChange).toHaveBeenNthCalledWith(6, {
whole_workspace: true,
items: [
{ type: 'member', user_id: 'member-1' },
{ type: 'external', email: 'external@example.com' },
],
})
})
})

View File

@ -0,0 +1,156 @@
import type { DeliveryMethod, HumanInputNodeType, UserAction } from '../../types'
import { act, renderHook } from '@testing-library/react'
import { BlockEnum } from '@/app/components/workflow/types'
import useConfig from '../use-config'
const mockUseUpdateNodeInternals = vi.hoisted(() => vi.fn())
const mockUseNodesReadOnly = vi.hoisted(() => vi.fn())
const mockUseEdgesInteractions = vi.hoisted(() => vi.fn())
const mockUseNodeCrud = vi.hoisted(() => vi.fn())
const mockUseFormContent = vi.hoisted(() => vi.fn())
vi.mock('reactflow', () => ({
useUpdateNodeInternals: () => mockUseUpdateNodeInternals(),
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useNodesReadOnly: () => mockUseNodesReadOnly(),
}))
vi.mock('@/app/components/workflow/hooks/use-edges-interactions', () => ({
useEdgesInteractions: () => mockUseEdgesInteractions(),
}))
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({
__esModule: true,
default: (...args: unknown[]) => mockUseNodeCrud(...args),
}))
vi.mock('../use-form-content', () => ({
__esModule: true,
default: (...args: unknown[]) => mockUseFormContent(...args),
}))
const createPayload = (overrides: Partial<HumanInputNodeType> = {}): HumanInputNodeType => ({
title: 'Human Input',
desc: '',
type: BlockEnum.HumanInput,
delivery_methods: [{
id: 'webapp',
type: 'webapp',
enabled: true,
} as DeliveryMethod],
form_content: 'Body',
inputs: [],
user_actions: [{
id: 'approve',
title: 'Approve',
button_style: 'primary',
} as UserAction],
timeout: 3,
timeout_unit: 'day',
...overrides,
})
describe('human-input/hooks/use-config', () => {
const mockSetInputs = vi.fn()
const mockHandleEdgeDeleteByDeleteBranch = vi.fn()
const mockHandleEdgeSourceHandleChange = vi.fn()
const mockUpdateNodeInternals = vi.fn()
const formContentHook = {
editorKey: 3,
handleFormContentChange: vi.fn(),
handleFormInputsChange: vi.fn(),
handleFormInputItemRename: vi.fn(),
handleFormInputItemRemove: vi.fn(),
}
let currentInputs = createPayload()
beforeEach(() => {
vi.clearAllMocks()
currentInputs = createPayload()
mockUseUpdateNodeInternals.mockReturnValue(mockUpdateNodeInternals)
mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false })
mockUseEdgesInteractions.mockReturnValue({
handleEdgeDeleteByDeleteBranch: mockHandleEdgeDeleteByDeleteBranch,
handleEdgeSourceHandleChange: mockHandleEdgeSourceHandleChange,
})
mockUseNodeCrud.mockImplementation(() => ({
inputs: currentInputs,
setInputs: mockSetInputs,
}))
mockUseFormContent.mockReturnValue(formContentHook)
})
it('should expose form-content helpers and update delivery methods, timeout, and collapsed state', () => {
const { result } = renderHook(() => useConfig('human-input-node', currentInputs))
const methods = [{
id: 'email',
type: 'email',
enabled: true,
} as DeliveryMethod]
expect(result.current.editorKey).toBe(3)
expect(result.current.readOnly).toBe(false)
expect(result.current.structuredOutputCollapsed).toBe(true)
act(() => {
result.current.handleDeliveryMethodChange(methods)
result.current.handleTimeoutChange({ timeout: 12, unit: 'hour' })
result.current.setStructuredOutputCollapsed(false)
})
expect(mockSetInputs).toHaveBeenNthCalledWith(1, expect.objectContaining({
delivery_methods: methods,
}))
expect(mockSetInputs).toHaveBeenNthCalledWith(2, expect.objectContaining({
timeout: 12,
timeout_unit: 'hour',
}))
expect(result.current.structuredOutputCollapsed).toBe(false)
})
it('should append and delete user actions while syncing branch-edge cleanup', () => {
const { result } = renderHook(() => useConfig('human-input-node', currentInputs))
const newAction = {
id: 'reject',
title: 'Reject',
button_style: 'default',
} as UserAction
act(() => {
result.current.handleUserActionAdd(newAction)
result.current.handleUserActionDelete('approve')
})
expect(mockSetInputs).toHaveBeenNthCalledWith(1, expect.objectContaining({
user_actions: [
expect.objectContaining({ id: 'approve' }),
newAction,
],
}))
expect(mockSetInputs).toHaveBeenNthCalledWith(2, expect.objectContaining({
user_actions: [],
}))
expect(mockHandleEdgeDeleteByDeleteBranch).toHaveBeenCalledWith('human-input-node', 'approve')
})
it('should update user action ids and refresh source handles when the branch key changes', () => {
const { result } = renderHook(() => useConfig('human-input-node', currentInputs))
const renamedAction = {
id: 'approved',
title: 'Approve',
button_style: 'primary',
} as UserAction
act(() => {
result.current.handleUserActionChange(0, renamedAction)
})
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
user_actions: [renamedAction],
}))
expect(mockHandleEdgeSourceHandleChange).toHaveBeenCalledWith('human-input-node', 'approve', 'approved')
expect(mockUpdateNodeInternals).toHaveBeenCalledWith('human-input-node')
})
})

View File

@ -0,0 +1,112 @@
import type { FormInputItem, HumanInputNodeType } from '../../types'
import { act, renderHook } from '@testing-library/react'
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
import useFormContent from '../use-form-content'
const mockUseWorkflow = vi.hoisted(() => vi.fn())
const mockUseNodeCrud = vi.hoisted(() => vi.fn())
vi.mock('@/app/components/workflow/hooks', () => ({
useWorkflow: () => mockUseWorkflow(),
}))
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({
__esModule: true,
default: (...args: unknown[]) => mockUseNodeCrud(...args),
}))
const createFormInput = (overrides: Partial<FormInputItem> = {}): FormInputItem => ({
type: InputVarType.textInput,
output_variable_name: 'old_name',
default: {
selector: [],
type: 'constant',
value: '',
},
...overrides,
})
const createPayload = (overrides: Partial<HumanInputNodeType> = {}): HumanInputNodeType => ({
title: 'Human Input',
desc: '',
type: BlockEnum.HumanInput,
delivery_methods: [],
form_content: 'Hello {{#$output.old_name#}}',
inputs: [createFormInput()],
user_actions: [],
timeout: 1,
timeout_unit: 'day',
...overrides,
})
describe('human-input/use-form-content', () => {
const mockSetInputs = vi.fn()
const mockHandleOutVarRenameChange = vi.fn()
let currentInputs = createPayload()
beforeEach(() => {
vi.clearAllMocks()
currentInputs = createPayload()
mockUseWorkflow.mockReturnValue({
handleOutVarRenameChange: mockHandleOutVarRenameChange,
})
mockUseNodeCrud.mockImplementation(() => ({
inputs: currentInputs,
setInputs: mockSetInputs,
}))
})
it('should update raw form content and replace the form input list', () => {
const { result } = renderHook(() => useFormContent('human-input-node', currentInputs))
const nextInputs = [
createFormInput({
output_variable_name: 'approval',
}),
]
act(() => {
result.current.handleFormContentChange('Updated body')
result.current.handleFormInputsChange(nextInputs)
})
expect(mockSetInputs).toHaveBeenNthCalledWith(1, expect.objectContaining({
form_content: 'Updated body',
}))
expect(mockSetInputs).toHaveBeenNthCalledWith(2, expect.objectContaining({
inputs: nextInputs,
}))
expect(result.current.editorKey).toBe(1)
})
it('should rename input placeholders inside markdown and notify downstream references', () => {
const { result } = renderHook(() => useFormContent('human-input-node', currentInputs))
const renamedInput = createFormInput({
output_variable_name: 'new_name',
})
act(() => {
result.current.handleFormInputItemRename(renamedInput, 'old_name')
})
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
form_content: 'Hello {{#$output.new_name#}}',
inputs: [renamedInput],
}))
expect(mockHandleOutVarRenameChange).toHaveBeenCalledWith('human-input-node', ['human-input-node', 'old_name'], ['human-input-node', 'new_name'])
expect(result.current.editorKey).toBe(1)
})
it('should remove an input placeholder and its form input metadata', () => {
const { result } = renderHook(() => useFormContent('human-input-node', currentInputs))
act(() => {
result.current.handleFormInputItemRemove('old_name')
})
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
form_content: 'Hello ',
inputs: [],
}))
expect(result.current.editorKey).toBe(1)
})
})

View File

@ -0,0 +1,234 @@
import type { HumanInputNodeType } from '../../types'
import type { InputVar } from '@/app/components/workflow/types'
import type { HumanInputFormData } from '@/types/workflow'
import { act, renderHook } from '@testing-library/react'
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
import { AppModeEnum } from '@/types/app'
import useSingleRunFormParams from '../use-single-run-form-params'
const mockUseTranslation = vi.hoisted(() => vi.fn())
const mockUseAppStore = vi.hoisted(() => vi.fn())
const mockFetchHumanInputNodeStepRunForm = vi.hoisted(() => vi.fn())
const mockSubmitHumanInputNodeStepRunForm = vi.hoisted(() => vi.fn())
const mockUseNodeCrud = vi.hoisted(() => vi.fn())
vi.mock('react-i18next', () => ({
useTranslation: () => mockUseTranslation(),
}))
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: { appDetail?: { id?: string, mode?: AppModeEnum } }) => unknown) => mockUseAppStore(selector),
}))
vi.mock('@/service/workflow', () => ({
fetchHumanInputNodeStepRunForm: (...args: unknown[]) => mockFetchHumanInputNodeStepRunForm(...args),
submitHumanInputNodeStepRunForm: (...args: unknown[]) => mockSubmitHumanInputNodeStepRunForm(...args),
}))
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({
__esModule: true,
default: (...args: unknown[]) => mockUseNodeCrud(...args),
}))
const createPayload = (overrides: Partial<HumanInputNodeType> = {}): HumanInputNodeType => ({
title: 'Human Input',
desc: '',
type: BlockEnum.HumanInput,
delivery_methods: [],
form_content: 'Summary: {{#start.topic#}}',
inputs: [{
type: InputVarType.textInput,
output_variable_name: 'summary',
default: {
type: 'variable',
selector: ['start', 'topic'],
value: '',
},
}],
user_actions: [],
timeout: 1,
timeout_unit: 'day',
...overrides,
})
const createInputVar = (overrides: Partial<InputVar> = {}): InputVar => ({
type: InputVarType.textInput,
label: 'Topic',
variable: '#start.topic#',
required: false,
value_selector: ['start', 'topic'],
...overrides,
})
const mockFormData: HumanInputFormData = {
form_id: 'form-1',
node_id: 'node-1',
node_title: 'Human Input',
form_content: 'Rendered content',
inputs: [],
actions: [],
form_token: 'token-1',
resolved_default_values: {
topic: 'AI',
},
display_in_ui: true,
expiration_time: 1000,
}
describe('human-input/hooks/use-single-run-form-params', () => {
const mockSetRunInputData = vi.fn()
const getInputVars = vi.fn()
let currentInputs = createPayload()
let appDetail: { id?: string, mode?: AppModeEnum } | undefined
beforeEach(() => {
vi.clearAllMocks()
currentInputs = createPayload()
appDetail = {
id: 'app-1',
mode: AppModeEnum.WORKFLOW,
}
mockUseTranslation.mockReturnValue({
t: (key: string) => key,
})
mockUseAppStore.mockImplementation((selector: (state: { appDetail?: { id?: string, mode?: AppModeEnum } }) => unknown) => selector({ appDetail }))
mockUseNodeCrud.mockImplementation(() => ({
inputs: currentInputs,
}))
getInputVars.mockReturnValue([
createInputVar(),
createInputVar({
label: 'Output',
variable: '#$output.answer#',
value_selector: ['$output', 'answer'],
}),
{
...createInputVar({
label: 'Broken',
}),
variable: undefined,
} as unknown as InputVar,
])
mockFetchHumanInputNodeStepRunForm.mockResolvedValue(mockFormData)
mockSubmitHumanInputNodeStepRunForm.mockResolvedValue({})
})
it('should build a single before-run form, filter output vars, and expose dependent vars', () => {
const { result } = renderHook(() => useSingleRunFormParams({
id: 'node-1',
payload: currentInputs,
runInputData: { topic: 'AI' },
getInputVars,
setRunInputData: mockSetRunInputData,
}))
expect(getInputVars).toHaveBeenCalledWith([
'{{#start.topic#}}',
'Summary: {{#start.topic#}}',
])
expect(result.current.forms).toHaveLength(1)
expect(result.current.forms[0]).toEqual(expect.objectContaining({
label: 'nodes.humanInput.singleRun.label',
values: { topic: 'AI' },
inputs: [
expect.objectContaining({ variable: '#start.topic#' }),
expect.objectContaining({ label: 'Broken' }),
],
}))
act(() => {
result.current.forms[0].onChange?.({ topic: 'Updated' })
})
expect(mockSetRunInputData).toHaveBeenCalledWith({ topic: 'Updated' })
expect(result.current.getDependentVars()).toEqual([
['start', 'topic'],
])
})
it('should fetch and submit generated forms in workflow mode while keeping required inputs', async () => {
const { result } = renderHook(() => useSingleRunFormParams({
id: 'node-1',
payload: currentInputs,
runInputData: {},
getInputVars,
setRunInputData: mockSetRunInputData,
}))
await act(async () => {
await result.current.handleShowGeneratedForm({
topic: 'AI',
ignored: undefined as unknown as string,
})
})
expect(result.current.showGeneratedForm).toBe(true)
expect(mockFetchHumanInputNodeStepRunForm).toHaveBeenCalledWith(
'/apps/app-1/workflows/draft/human-input/nodes/node-1/form',
{
inputs: { topic: 'AI' },
},
)
expect(result.current.formData).toEqual(mockFormData)
await act(async () => {
await result.current.handleSubmitHumanInputForm({
inputs: { answer: 'approved' },
form_inputs: { ignored: 'value' },
action: 'approve',
})
})
expect(mockSubmitHumanInputNodeStepRunForm).toHaveBeenCalledWith(
'/apps/app-1/workflows/draft/human-input/nodes/node-1/form',
{
inputs: { topic: 'AI' },
form_inputs: { answer: 'approved' },
action: 'approve',
},
)
act(() => {
result.current.handleHideGeneratedForm()
})
expect(result.current.showGeneratedForm).toBe(false)
})
it('should use the advanced-chat endpoint and skip remote fetches when app detail is missing', async () => {
appDetail = {
id: 'app-2',
mode: AppModeEnum.ADVANCED_CHAT,
}
const { result, rerender } = renderHook(() => useSingleRunFormParams({
id: 'node-9',
payload: currentInputs,
runInputData: {},
getInputVars,
setRunInputData: mockSetRunInputData,
}))
await act(async () => {
await result.current.handleFetchFormContent({ topic: 'hello' })
})
expect(mockFetchHumanInputNodeStepRunForm).toHaveBeenCalledWith(
'/apps/app-2/advanced-chat/workflows/draft/human-input/nodes/node-9/form',
{
inputs: { topic: 'hello' },
},
)
appDetail = undefined
rerender()
await act(async () => {
const data = await result.current.handleFetchFormContent({ topic: 'skip' })
expect(data).toBeNull()
})
expect(mockFetchHumanInputNodeStepRunForm).toHaveBeenCalledTimes(1)
})
})

View File

@ -0,0 +1,173 @@
import type { IterationNodeType } from '../types'
import type { Item } from '@/app/components/base/select'
import type { Var } from '@/app/components/workflow/types'
import { act, renderHook } from '@testing-library/react'
import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types'
import { BlockEnum, ErrorHandleMode, VarType } from '@/app/components/workflow/types'
import useConfig from '../use-config'
const mockUseInspectVarsCrud = vi.hoisted(() => vi.fn())
const mockUseNodesReadOnly = vi.hoisted(() => vi.fn())
const mockUseIsChatMode = vi.hoisted(() => vi.fn())
const mockUseWorkflow = vi.hoisted(() => vi.fn())
const mockUseStore = vi.hoisted(() => vi.fn())
const mockUseNodeCrud = vi.hoisted(() => vi.fn())
const mockUseAllBuiltInTools = vi.hoisted(() => vi.fn())
const mockUseAllCustomTools = vi.hoisted(() => vi.fn())
const mockUseAllWorkflowTools = vi.hoisted(() => vi.fn())
const mockUseAllMCPTools = vi.hoisted(() => vi.fn())
const mockToNodeOutputVars = vi.hoisted(() => vi.fn())
vi.mock('@/app/components/workflow/hooks/use-inspect-vars-crud', () => ({
__esModule: true,
default: (...args: unknown[]) => mockUseInspectVarsCrud(...args),
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useNodesReadOnly: () => mockUseNodesReadOnly(),
useIsChatMode: () => mockUseIsChatMode(),
useWorkflow: () => mockUseWorkflow(),
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: { dataSourceList: unknown[] }) => unknown) =>
selector({ dataSourceList: mockUseStore() }),
}))
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({
__esModule: true,
default: (...args: unknown[]) => mockUseNodeCrud(...args),
}))
vi.mock('@/service/use-tools', () => ({
useAllBuiltInTools: () => mockUseAllBuiltInTools(),
useAllCustomTools: () => mockUseAllCustomTools(),
useAllWorkflowTools: () => mockUseAllWorkflowTools(),
useAllMCPTools: () => mockUseAllMCPTools(),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/utils', () => ({
toNodeOutputVars: (...args: unknown[]) => mockToNodeOutputVars(...args),
}))
const createPayload = (overrides: Partial<IterationNodeType> = {}): IterationNodeType => ({
title: 'Iteration',
desc: '',
type: BlockEnum.Iteration,
iterator_selector: ['start', 'items'],
iterator_input_type: VarType.arrayString,
output_selector: ['child', 'result'],
output_type: VarType.arrayString,
is_parallel: false,
parallel_nums: 3,
error_handle_mode: ErrorHandleMode.Terminated,
flatten_output: false,
start_node_id: 'start-node',
_children: [],
_isShowTips: false,
...overrides,
})
const createVar = (type: VarType, variable = 'test.variable'): Var => ({
variable,
type,
})
describe('iteration/use-config', () => {
const mockSetInputs = vi.fn()
const mockDeleteNodeInspectorVars = vi.fn()
let currentInputs = createPayload()
beforeEach(() => {
vi.clearAllMocks()
currentInputs = createPayload()
mockUseInspectVarsCrud.mockReturnValue({
deleteNodeInspectorVars: mockDeleteNodeInspectorVars,
})
mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false })
mockUseIsChatMode.mockReturnValue(false)
mockUseWorkflow.mockReturnValue({
getIterationNodeChildren: vi.fn(() => [{ id: 'child-node' }]),
})
mockUseStore.mockReturnValue([])
mockUseNodeCrud.mockImplementation(() => ({
inputs: currentInputs,
setInputs: mockSetInputs,
}))
mockUseAllBuiltInTools.mockReturnValue({ data: [] })
mockUseAllCustomTools.mockReturnValue({ data: [] })
mockUseAllWorkflowTools.mockReturnValue({ data: [] })
mockUseAllMCPTools.mockReturnValue({ data: [] })
mockToNodeOutputVars.mockReturnValue([{ variable: 'child.result' }])
})
it('should expose iteration children vars and filter only array-like iterator inputs', () => {
const { result } = renderHook(() => useConfig('iteration-node', currentInputs))
expect(result.current.readOnly).toBe(false)
expect(result.current.childrenNodeVars).toEqual([{ variable: 'child.result' }])
expect(result.current.iterationChildrenNodes).toEqual([{ id: 'child-node' }])
expect(result.current.filterInputVar(createVar(VarType.arrayFile, 'files'))).toBe(true)
expect(result.current.filterInputVar(createVar(VarType.string, 'text'))).toBe(false)
expect(mockToNodeOutputVars).toHaveBeenCalled()
})
it('should update iterator input and output selectors and reset inspector vars on output changes', () => {
const { result } = renderHook(() => useConfig('iteration-node', currentInputs))
act(() => {
result.current.handleInputChange(['start', 'documents'], VarKindType.variable, createVar(VarType.arrayObject, 'start.documents'))
})
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
iterator_selector: ['start', 'documents'],
iterator_input_type: VarType.arrayObject,
}))
mockSetInputs.mockClear()
act(() => {
result.current.handleOutputVarChange(['child', 'score'], VarKindType.variable, createVar(VarType.number, 'child.score'))
})
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
output_selector: ['child', 'score'],
output_type: VarType.arrayNumber,
}))
expect(mockDeleteNodeInspectorVars).toHaveBeenCalledWith('iteration-node')
mockSetInputs.mockClear()
act(() => {
result.current.handleOutputVarChange(['child', 'result'], VarKindType.variable, createVar(VarType.string, 'child.result'))
})
expect(mockSetInputs).not.toHaveBeenCalled()
})
it('should update parallel, error-mode, and flatten options', () => {
const { result } = renderHook(() => useConfig('iteration-node', currentInputs))
const item: Item = { name: 'Continue', value: ErrorHandleMode.ContinueOnError }
act(() => {
result.current.changeParallel(true)
result.current.changeErrorResponseMode(item)
result.current.changeParallelNums(6)
result.current.changeFlattenOutput(true)
})
expect(mockSetInputs).toHaveBeenNthCalledWith(1, expect.objectContaining({
is_parallel: true,
}))
expect(mockSetInputs).toHaveBeenNthCalledWith(2, expect.objectContaining({
error_handle_mode: ErrorHandleMode.ContinueOnError,
}))
expect(mockSetInputs).toHaveBeenNthCalledWith(3, expect.objectContaining({
parallel_nums: 6,
}))
expect(mockSetInputs).toHaveBeenNthCalledWith(4, expect.objectContaining({
flatten_output: true,
}))
})
})

View File

@ -0,0 +1,168 @@
import type { InputVar, Node } from '../../../types'
import type { IterationNodeType } from '../types'
import type { NodeTracing } from '@/types/workflow'
import { act, renderHook } from '@testing-library/react'
import { BlockEnum, ErrorHandleMode, InputVarType, VarType } from '@/app/components/workflow/types'
import useSingleRunFormParams from '../use-single-run-form-params'
const mockUseIsNodeInIteration = vi.hoisted(() => vi.fn())
const mockUseWorkflow = vi.hoisted(() => vi.fn())
const mockFormatTracing = vi.hoisted(() => vi.fn())
const mockGetNodeUsedVars = vi.hoisted(() => vi.fn())
const mockGetNodeUsedVarPassToServerKey = vi.hoisted(() => vi.fn())
const mockGetNodeInfoById = vi.hoisted(() => vi.fn())
const mockIsSystemVar = vi.hoisted(() => vi.fn())
vi.mock('@/app/components/workflow/hooks', () => ({
useIsNodeInIteration: (...args: unknown[]) => mockUseIsNodeInIteration(...args),
useWorkflow: () => mockUseWorkflow(),
}))
vi.mock('@/app/components/workflow/run/utils/format-log', () => ({
__esModule: true,
default: (...args: unknown[]) => mockFormatTracing(...args),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/utils', () => ({
getNodeUsedVars: (...args: unknown[]) => mockGetNodeUsedVars(...args),
getNodeUsedVarPassToServerKey: (...args: unknown[]) => mockGetNodeUsedVarPassToServerKey(...args),
getNodeInfoById: (...args: unknown[]) => mockGetNodeInfoById(...args),
isSystemVar: (...args: unknown[]) => mockIsSystemVar(...args),
}))
const createInputVar = (variable: string): InputVar => ({
type: InputVarType.textInput,
label: variable,
variable,
required: false,
})
const createNode = (id: string, title: string, type = BlockEnum.Tool): Node => ({
id,
position: { x: 0, y: 0 },
data: {
title,
type,
desc: '',
},
} as Node)
const createPayload = (overrides: Partial<IterationNodeType> = {}): IterationNodeType => ({
title: 'Iteration',
desc: '',
type: BlockEnum.Iteration,
start_node_id: 'start-node',
iterator_selector: ['start-node', 'items'],
iterator_input_type: VarType.arrayString,
output_selector: ['child-node', 'text'],
output_type: VarType.arrayString,
is_parallel: false,
parallel_nums: 2,
error_handle_mode: ErrorHandleMode.Terminated,
flatten_output: false,
_children: [],
_isShowTips: false,
...overrides,
})
describe('iteration/use-single-run-form-params', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseIsNodeInIteration.mockReturnValue({
isNodeInIteration: (nodeId: string) => nodeId === 'inner-node',
})
mockUseWorkflow.mockReturnValue({
getIterationNodeChildren: () => [
createNode('tool-a', 'Tool A'),
createNode('inner-node', 'Inner Node'),
],
getBeforeNodesInSameBranch: () => [
createNode('start-node', 'Start Node', BlockEnum.Start),
],
})
mockGetNodeUsedVars.mockImplementation((node: Node) => {
if (node.id === 'tool-a')
return [['start-node', 'answer'], ['inner-node', 'secret'], ['iteration-node', 'item']]
return []
})
mockGetNodeUsedVarPassToServerKey.mockReturnValue('passed_key')
mockGetNodeInfoById.mockImplementation((nodes: Node[], id: string) => nodes.find(node => node.id === id))
mockIsSystemVar.mockReturnValue(false)
mockFormatTracing.mockReturnValue([{ id: 'formatted-node' }])
})
it('should build single-run forms from external vars and keep iterator state in a dedicated form', () => {
const toVarInputs = vi.fn(() => [createInputVar('#start-node.answer#')])
const { result } = renderHook(() => useSingleRunFormParams({
id: 'iteration-node',
payload: createPayload(),
runInputData: {
'query': 'hello',
'iteration-node.input_selector': ['start-node', 'items'],
},
runInputDataRef: { current: {} },
getInputVars: vi.fn(),
setRunInputData: vi.fn(),
toVarInputs,
iterationRunResult: [],
}))
expect(toVarInputs).toHaveBeenCalledWith([
expect.objectContaining({
variable: 'start-node.answer',
value_selector: ['start-node', 'answer'],
}),
])
expect(result.current.forms).toHaveLength(2)
expect(result.current.forms[0].inputs).toEqual([createInputVar('#start-node.answer#')])
expect(result.current.forms[0].values).toEqual({
'query': 'hello',
'iteration-node.input_selector': ['start-node', 'items'],
})
expect(result.current.forms[1].values).toEqual({
'iteration-node.input_selector': ['start-node', 'items'],
})
expect(result.current.allVarObject).toEqual({
'start-node.answer@@@tool-a@@@0': {
inSingleRunPassedKey: 'passed_key',
},
})
expect(result.current.nodeInfo).toEqual({ id: 'formatted-node' })
})
it('should forward form updates and expose iterator dependencies', () => {
const setRunInputData = vi.fn()
const { result } = renderHook(() => useSingleRunFormParams({
id: 'iteration-node',
payload: createPayload({
iterator_selector: ['source-node', 'records'],
}),
runInputData: {
'query': 'old',
'iteration-node.input_selector': ['source-node', 'records'],
},
runInputDataRef: { current: {} },
getInputVars: vi.fn(),
setRunInputData,
toVarInputs: vi.fn(() => []),
iterationRunResult: [] as NodeTracing[],
}))
act(() => {
result.current.forms[0].onChange({ query: 'new' })
result.current.forms[1].onChange({
'iteration-node.input_selector': ['source-node', 'next'],
})
})
expect(setRunInputData).toHaveBeenNthCalledWith(1, { query: 'new' })
expect(setRunInputData).toHaveBeenNthCalledWith(2, {
'query': 'old',
'iteration-node.input_selector': ['source-node', 'next'],
})
expect(result.current.getDependentVars()).toEqual([['source-node', 'records']])
expect(result.current.getDependentVar('iteration-node.input_selector')).toEqual(['source-node', 'records'])
})
})

View File

@ -0,0 +1,245 @@
import type { StartNodeType } from '../types'
import type { InputVar, ValueSelector } from '@/app/components/workflow/types'
import { act, renderHook } from '@testing-library/react'
import { BlockEnum, ChangeType, InputVarType } from '@/app/components/workflow/types'
import useConfig from '../use-config'
const mockUseTranslation = vi.hoisted(() => vi.fn())
const mockUseNodesReadOnly = vi.hoisted(() => vi.fn())
const mockUseWorkflow = vi.hoisted(() => vi.fn())
const mockUseIsChatMode = vi.hoisted(() => vi.fn())
const mockUseNodeCrud = vi.hoisted(() => vi.fn())
const mockUseInspectVarsCrud = vi.hoisted(() => vi.fn())
const mockNotify = vi.hoisted(() => vi.fn())
vi.mock('react-i18next', () => ({
useTranslation: () => mockUseTranslation(),
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useNodesReadOnly: () => mockUseNodesReadOnly(),
useWorkflow: () => mockUseWorkflow(),
useIsChatMode: () => mockUseIsChatMode(),
}))
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({
__esModule: true,
default: (...args: unknown[]) => mockUseNodeCrud(...args),
}))
vi.mock('@/app/components/workflow/hooks/use-inspect-vars-crud', () => ({
__esModule: true,
default: (...args: unknown[]) => mockUseInspectVarsCrud(...args),
}))
vi.mock('@/app/components/base/ui/toast', () => ({
__esModule: true,
toast: {
error: (message: string) => mockNotify({ type: 'error', message }),
},
}))
const createInputVar = (overrides: Partial<InputVar> = {}): InputVar => ({
label: 'Question',
variable: 'query',
type: InputVarType.textInput,
required: true,
...overrides,
})
const createPayload = (overrides: Partial<StartNodeType> = {}): StartNodeType => ({
title: 'Start',
desc: '',
type: BlockEnum.Start,
variables: [
createInputVar(),
createInputVar({
label: 'Age',
variable: 'age',
type: InputVarType.number,
required: false,
}),
],
...overrides,
})
describe('start/use-config', () => {
const mockSetInputs = vi.fn()
const mockHandleOutVarRenameChange = vi.fn()
const mockIsVarUsedInNodes = vi.fn()
const mockRemoveUsedVarInNodes = vi.fn()
const mockDeleteNodeInspectorVars = vi.fn()
const mockRenameInspectVarName = vi.fn()
const mockDeleteInspectVar = vi.fn()
const toastSpy = mockNotify
let currentInputs: StartNodeType
beforeEach(() => {
vi.clearAllMocks()
currentInputs = createPayload()
mockUseTranslation.mockReturnValue({
t: (key: string) => key,
})
mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false })
mockUseWorkflow.mockReturnValue({
handleOutVarRenameChange: mockHandleOutVarRenameChange,
isVarUsedInNodes: mockIsVarUsedInNodes,
removeUsedVarInNodes: mockRemoveUsedVarInNodes,
})
mockUseIsChatMode.mockReturnValue(false)
mockUseNodeCrud.mockImplementation(() => ({
inputs: currentInputs,
setInputs: mockSetInputs,
}))
mockUseInspectVarsCrud.mockReturnValue({
deleteNodeInspectorVars: mockDeleteNodeInspectorVars,
renameInspectVarName: mockRenameInspectVarName,
nodesWithInspectVars: [{
nodeId: 'start-node',
vars: [{ id: 'inspect-query', name: 'query' }],
}],
deleteInspectVar: mockDeleteInspectVar,
})
mockIsVarUsedInNodes.mockReturnValue(false)
})
it('should rename variables and sync downstream variable references', () => {
const { result } = renderHook(() => useConfig('start-node', currentInputs))
const renamedList = [
createInputVar({
label: 'Question',
variable: 'prompt',
}),
createInputVar({
label: 'Age',
variable: 'age',
type: InputVarType.number,
required: false,
}),
]
act(() => {
result.current.handleVarListChange(renamedList, {
index: 0,
payload: {
type: ChangeType.changeVarName,
payload: {
beforeKey: 'query',
},
},
})
})
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
variables: renamedList,
}))
expect(mockHandleOutVarRenameChange).toHaveBeenCalledWith('start-node', ['start-node', 'query'], ['start-node', 'prompt'])
expect(mockRenameInspectVarName).toHaveBeenCalledWith('start-node', 'query', 'prompt')
expect(result.current.readOnly).toBe(false)
expect(result.current.isChatMode).toBe(false)
})
it('should block removal when the variable is still in use and confirm the deletion later', () => {
mockIsVarUsedInNodes.mockReturnValue(true)
const { result } = renderHook(() => useConfig('start-node', currentInputs))
const nextList = [currentInputs.variables[1]]
act(() => {
result.current.handleVarListChange(nextList, {
index: 0,
payload: {
type: ChangeType.remove,
payload: {
beforeKey: 'query',
},
},
})
})
expect(mockDeleteInspectVar).toHaveBeenCalledWith('start-node', 'inspect-query')
expect(mockSetInputs).not.toHaveBeenCalled()
expect(result.current.isShowRemoveVarConfirm).toBe(true)
act(() => {
result.current.onRemoveVarConfirm()
})
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
variables: [expect.objectContaining({ variable: 'age' })],
}))
expect(mockRemoveUsedVarInNodes).toHaveBeenCalledWith(['start-node', 'query'] as ValueSelector)
expect(result.current.isShowRemoveVarConfirm).toBe(false)
})
it('should validate duplicate variables and labels before adding a new variable', () => {
const { result } = renderHook(() => useConfig('start-node', currentInputs))
let added = true
act(() => {
added = result.current.handleAddVariable(createInputVar({
label: 'Different Label',
variable: 'query',
}))
})
expect(added).toBe(false)
expect(toastSpy).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
message: 'varKeyError.keyAlreadyExists',
}))
mockSetInputs.mockClear()
let addedUnique = false
act(() => {
addedUnique = result.current.handleAddVariable(createInputVar({
label: 'Locale',
variable: 'locale',
required: false,
}))
})
expect(addedUnique).toBe(true)
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
variables: expect.arrayContaining([
expect.objectContaining({ variable: 'locale' }),
]),
}))
})
it('should clear inspector vars for non-remove list updates and reject duplicate labels', () => {
const { result } = renderHook(() => useConfig('start-node', currentInputs))
const typeEditedList = [
createInputVar({
label: 'Question',
variable: 'query',
type: InputVarType.paragraph,
}),
currentInputs.variables[1],
]
act(() => {
result.current.handleVarListChange(typeEditedList)
})
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
variables: typeEditedList,
}))
expect(mockDeleteNodeInspectorVars).toHaveBeenCalledWith('start-node')
toastSpy.mockClear()
let added = true
act(() => {
added = result.current.handleAddVariable(createInputVar({
label: 'Age',
variable: 'new_age',
}))
})
expect(added).toBe(false)
expect(toastSpy).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
message: 'varKeyError.keyAlreadyExists',
}))
})
})

View File

@ -144,7 +144,7 @@ describe('GenericTable', () => {
)
await user.click(screen.getByRole('button', { name: 'Choose method' }))
await user.click(await screen.findByText('POST'))
await user.click(await screen.findByRole('option', { name: 'POST' }))
await waitFor(() => {
expect(onChange).toHaveBeenCalledWith([{ method: 'post', preview: '' }])

View File

@ -0,0 +1,244 @@
import { act, renderHook } from '@testing-library/react'
import { VarType } from '../../../types'
import { useGetAvailableVars, useVariableAssigner } from '../hooks'
const mockUseStoreApi = vi.hoisted(() => vi.fn())
const mockUseNodes = vi.hoisted(() => vi.fn())
const mockUseNodeDataUpdate = vi.hoisted(() => vi.fn())
const mockUseWorkflow = vi.hoisted(() => vi.fn())
const mockUseWorkflowVariables = vi.hoisted(() => vi.fn())
const mockUseIsChatMode = vi.hoisted(() => vi.fn())
const mockUseWorkflowStore = vi.hoisted(() => vi.fn())
vi.mock('reactflow', () => ({
useStoreApi: () => mockUseStoreApi(),
useNodes: () => mockUseNodes(),
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useNodeDataUpdate: () => mockUseNodeDataUpdate(),
useWorkflow: () => mockUseWorkflow(),
useWorkflowVariables: () => mockUseWorkflowVariables(),
useIsChatMode: () => mockUseIsChatMode(),
}))
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => mockUseWorkflowStore(),
}))
describe('variable-assigner/hooks', () => {
const mockHandleNodeDataUpdate = vi.fn()
const mockSetNodes = vi.fn()
const mockSetShowAssignVariablePopup = vi.fn()
const mockSetHoveringAssignVariableGroupId = vi.fn()
const getNodes = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
getNodes.mockReturnValue([{
id: 'assigner-1',
data: {
variables: [['start', 'foo']],
output_type: VarType.string,
advanced_settings: {
groups: [{
groupId: 'group-1',
variables: [],
output_type: VarType.string,
}],
},
},
}])
mockUseStoreApi.mockReturnValue({
getState: () => ({
getNodes,
setNodes: mockSetNodes,
}),
})
mockUseNodeDataUpdate.mockReturnValue({
handleNodeDataUpdate: mockHandleNodeDataUpdate,
})
mockUseWorkflowStore.mockReturnValue({
getState: () => ({
setShowAssignVariablePopup: mockSetShowAssignVariablePopup,
setHoveringAssignVariableGroupId: mockSetHoveringAssignVariableGroupId,
connectingNodePayload: { id: 'connecting-node' },
}),
})
mockUseNodes.mockReturnValue([])
mockUseWorkflow.mockReturnValue({
getBeforeNodesInSameBranchIncludeParent: vi.fn(),
})
mockUseWorkflowVariables.mockReturnValue({
getNodeAvailableVars: vi.fn(),
})
mockUseIsChatMode.mockReturnValue(false)
})
it('should append target variables, ignore duplicates, and update grouped variables', () => {
const { result } = renderHook(() => useVariableAssigner())
act(() => {
result.current.handleAssignVariableValueChange('assigner-1', ['start', 'bar'], { type: VarType.number } as never)
result.current.handleAssignVariableValueChange('assigner-1', ['start', 'foo'], { type: VarType.number } as never)
result.current.handleAssignVariableValueChange('assigner-1', ['start', 'grouped'], { type: VarType.arrayString } as never, 'group-1')
})
expect(mockHandleNodeDataUpdate).toHaveBeenNthCalledWith(1, {
id: 'assigner-1',
data: {
variables: [
['start', 'foo'],
['start', 'bar'],
],
output_type: VarType.number,
},
})
expect(mockHandleNodeDataUpdate).toHaveBeenNthCalledWith(2, {
id: 'assigner-1',
data: {
advanced_settings: {
groups: [{
groupId: 'group-1',
variables: [['start', 'grouped']],
output_type: VarType.arrayString,
}],
},
},
})
expect(mockHandleNodeDataUpdate).toHaveBeenCalledTimes(2)
})
it('should close the popup and add variables through the positioned add-variable flow', () => {
getNodes.mockReturnValue([
{
id: 'source-node',
data: {
_showAddVariablePopup: true,
_holdAddVariablePopup: true,
},
},
{
id: 'assigner-1',
data: {
variables: [],
advanced_settings: {
groups: [{
groupId: 'group-1',
variables: [],
}],
},
_showAddVariablePopup: true,
_holdAddVariablePopup: true,
},
},
])
const { result } = renderHook(() => useVariableAssigner())
act(() => {
result.current.handleAddVariableInAddVariablePopupWithPosition(
'source-node',
'assigner-1',
'group-1',
['start', 'output'],
{ type: VarType.object } as never,
)
})
expect(mockSetNodes).toHaveBeenCalledWith([
expect.objectContaining({
id: 'source-node',
data: expect.objectContaining({
_showAddVariablePopup: false,
_holdAddVariablePopup: false,
}),
}),
expect.objectContaining({
id: 'assigner-1',
data: expect.objectContaining({
_showAddVariablePopup: false,
_holdAddVariablePopup: false,
}),
}),
])
expect(mockSetShowAssignVariablePopup).toHaveBeenCalledWith(undefined)
expect(mockHandleNodeDataUpdate).toHaveBeenCalledWith({
id: 'assigner-1',
data: {
advanced_settings: {
groups: [{
groupId: 'group-1',
variables: [['start', 'output']],
output_type: VarType.object,
}],
},
},
})
})
it('should update the hovered group state on enter and leave', () => {
const { result } = renderHook(() => useVariableAssigner())
act(() => {
result.current.handleGroupItemMouseEnter('group-1')
result.current.handleGroupItemMouseLeave()
})
expect(mockSetHoveringAssignVariableGroupId).toHaveBeenNthCalledWith(1, 'group-1')
expect(mockSetHoveringAssignVariableGroupId).toHaveBeenNthCalledWith(2, undefined)
})
it('should collect available vars and filter start-node env vars when hideEnv is enabled', () => {
mockUseNodes.mockReturnValue([
{
id: 'current-node',
parentId: 'parent-node',
},
{
id: 'before-1',
},
{
id: 'parent-node',
},
])
const getBeforeNodesInSameBranchIncludeParent = vi.fn(() => [
{ id: 'before-1' },
{ id: 'before-1' },
])
const getNodeAvailableVars = vi.fn()
.mockReturnValueOnce([{
isStartNode: true,
vars: [
{ variable: 'sys.user_id' },
{ variable: 'foo' },
],
}, {
isStartNode: false,
vars: [],
}])
.mockReturnValueOnce([{
isStartNode: false,
vars: [{ variable: 'bar' }],
}])
mockUseWorkflow.mockReturnValue({
getBeforeNodesInSameBranchIncludeParent,
})
mockUseWorkflowVariables.mockReturnValue({
getNodeAvailableVars,
})
const { result } = renderHook(() => useGetAvailableVars())
expect(result.current('current-node', 'target', () => true, true)).toEqual([{
isStartNode: true,
vars: [{ variable: 'foo' }],
}])
expect(result.current('current-node', 'target', () => true, false)).toEqual([{
isStartNode: false,
vars: [{ variable: 'bar' }],
}])
expect(result.current('missing-node', 'target', () => true)).toEqual([])
})
})

View File

@ -93,7 +93,7 @@ describe('ChatRecord integration', () => {
expect(mockFetchConversationMessages).toHaveBeenCalledWith('app-1', 'conversation-1')
})
expect(screen.getByText('Question 1:files-1')).toBeInTheDocument()
expect(await screen.findByText('Question 1:files-1')).toBeInTheDocument()
expect(screen.getByText('Answer 1:files-1')).toBeInTheDocument()
expect(screen.getByText('Question 3:files-0')).toBeInTheDocument()
expect(screen.getByText('Answer 3:files-0')).toBeInTheDocument()
@ -101,9 +101,11 @@ describe('ChatRecord integration', () => {
await user.click(screen.getByRole('button', { name: 'switch sibling' }))
expect(screen.getByText('Question 2:files-0')).toBeInTheDocument()
expect(await screen.findByText('Question 2:files-0')).toBeInTheDocument()
expect(screen.getByText('Answer 2:files-0')).toBeInTheDocument()
expect(screen.queryByText('Question 3:files-0')).not.toBeInTheDocument()
await waitFor(() => {
expect(screen.queryByText('Question 3:files-0')).not.toBeInTheDocument()
})
})
it('should close the record panel and restore the backup draft', async () => {

View File

@ -0,0 +1,195 @@
import type { ChangeEvent } from 'react'
import { act, renderHook } from '@testing-library/react'
import { ChatVarType } from '../../type'
import { useVariableModalState } from '../use-variable-modal-state'
vi.mock('uuid', () => ({
v4: () => 'generated-id',
}))
const createOptions = (overrides: Partial<Parameters<typeof useVariableModalState>[0]> = {}) => ({
chatVar: undefined,
conversationVariables: [],
notify: vi.fn(),
onClose: vi.fn(),
onSave: vi.fn(),
t: (key: string) => key,
...overrides,
})
describe('useVariableModalState', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should build initial state from an existing array object variable', () => {
const { result } = renderHook(() => useVariableModalState(createOptions({
chatVar: {
id: 'var-1',
name: 'payload',
description: 'desc',
value_type: ChatVarType.ArrayObject,
value: [{ enabled: true }],
},
})))
expect(result.current.name).toBe('payload')
expect(result.current.description).toBe('desc')
expect(result.current.type).toBe(ChatVarType.ArrayObject)
expect(result.current.editInJSON).toBe(true)
expect(result.current.editorContent).toBe(JSON.stringify([{ enabled: true }]))
})
it('should update state when changing types and editing scalar values', () => {
const { result } = renderHook(() => useVariableModalState(createOptions()))
act(() => {
result.current.handleTypeChange(ChatVarType.Object)
})
expect(result.current.type).toBe(ChatVarType.Object)
expect(result.current.objectValue).toHaveLength(1)
act(() => {
result.current.handleTypeChange(ChatVarType.Number)
result.current.handleStringOrNumberChange([12])
})
expect(result.current.value).toBe(12)
act(() => {
result.current.setDescription('note')
result.current.setValue(true)
})
expect(result.current.description).toBe('note')
expect(result.current.value).toBe(true)
})
it('should toggle object values between form and json modes', () => {
const { result } = renderHook(() => useVariableModalState(createOptions({
chatVar: {
id: 'var-2',
name: 'config',
description: '',
value_type: ChatVarType.Object,
value: { timeout: 30 },
},
})))
act(() => {
result.current.handleEditorChange(true)
})
expect(result.current.editInJSON).toBe(true)
expect(result.current.editorContent).toBe(JSON.stringify({ timeout: 30 }))
act(() => {
result.current.handleEditorValueChange('{"timeout":45}')
result.current.handleEditorChange(false)
})
expect(result.current.editInJSON).toBe(false)
expect(result.current.objectValue).toEqual([
{ key: 'timeout', type: ChatVarType.Number, value: 45 },
])
})
it('should reset object form values when leaving empty json mode', () => {
const { result } = renderHook(() => useVariableModalState(createOptions({
chatVar: {
id: 'var-3',
name: 'config',
description: '',
value_type: ChatVarType.Object,
value: {},
},
})))
act(() => {
result.current.handleEditorChange(true)
result.current.handleEditorValueChange('')
result.current.handleEditorChange(false)
})
expect(result.current.objectValue).toHaveLength(1)
expect(result.current.value).toBeUndefined()
})
it('should handle array editor toggles and invalid json safely', () => {
const { result } = renderHook(() => useVariableModalState(createOptions()))
act(() => {
result.current.handleTypeChange(ChatVarType.ArrayString)
result.current.setValue(['a', '', 'b'])
result.current.handleEditorChange(true)
})
expect(result.current.editInJSON).toBe(true)
expect(result.current.value).toEqual(['a', 'b'])
act(() => {
result.current.handleEditorValueChange('[invalid')
})
expect(result.current.editorContent).toBe('[invalid')
expect(result.current.value).toEqual(['a', 'b'])
act(() => {
result.current.handleEditorChange(false)
})
expect(result.current.value).toEqual(['a', 'b'])
act(() => {
result.current.handleTypeChange(ChatVarType.ArrayBoolean)
result.current.setValue([true, false])
result.current.handleEditorChange(true)
})
expect(result.current.editorContent).toBe(JSON.stringify(['True', 'False']))
})
it('should notify and stop saving when object keys are invalid', () => {
const notify = vi.fn()
const onSave = vi.fn()
const onClose = vi.fn()
const { result } = renderHook(() => useVariableModalState(createOptions({
notify,
onClose,
onSave,
})))
act(() => {
result.current.handleVarNameChange({ target: { value: 'config' } } as ChangeEvent<HTMLInputElement>)
result.current.handleTypeChange(ChatVarType.Object)
result.current.setObjectValue([{ key: '', type: ChatVarType.String, value: 'secret' }])
})
act(() => {
result.current.handleSave()
})
expect(notify).toHaveBeenCalledWith({ type: 'error', message: 'chatVariable.modal.objectKeyRequired' })
expect(onSave).not.toHaveBeenCalled()
expect(onClose).not.toHaveBeenCalled()
})
it('should save a new variable and close when state is valid', () => {
const onSave = vi.fn()
const onClose = vi.fn()
const { result } = renderHook(() => useVariableModalState(createOptions({
onClose,
onSave,
})))
act(() => {
result.current.handleVarNameChange({ target: { value: 'greeting' } } as ChangeEvent<HTMLInputElement>)
result.current.handleStringOrNumberChange(['hello'])
})
act(() => {
result.current.handleSave()
})
expect(onSave).toHaveBeenCalledWith({
description: '',
id: 'generated-id',
name: 'greeting',
value: 'hello',
value_type: ChatVarType.String,
})
expect(onClose).toHaveBeenCalled()
})
})

View File

@ -0,0 +1,123 @@
import { ChatVarType } from '../../type'
import {
buildObjectValueItems,
formatChatVariableValue,
formatObjectValueFromList,
getEditorMinHeight,
getEditorToggleLabelKey,
getPlaceholderByType,
getTypeChangeState,
parseEditorContent,
validateVariableName,
} from '../variable-modal.helpers'
describe('variable-modal helpers', () => {
it('should build object items from a conversation variable value', () => {
expect(buildObjectValueItems()).toHaveLength(1)
expect(buildObjectValueItems({
id: 'var-1',
name: 'config',
description: '',
value_type: ChatVarType.Object,
value: { apiKey: 'secret', timeout: 30 },
})).toEqual([
{ key: 'apiKey', type: ChatVarType.String, value: 'secret' },
{ key: 'timeout', type: ChatVarType.Number, value: 30 },
])
})
it('should format object and array values for saving', () => {
expect(formatObjectValueFromList([
{ key: 'apiKey', type: ChatVarType.String, value: 'secret' },
{ key: '', type: ChatVarType.Number, value: 1 },
])).toEqual({ apiKey: 'secret' })
expect(formatChatVariableValue({
editInJSON: false,
objectValue: [{ key: 'enabled', type: ChatVarType.String, value: 'true' }],
type: ChatVarType.Object,
value: undefined,
})).toEqual({ enabled: 'true' })
expect(formatChatVariableValue({
editInJSON: true,
objectValue: [],
type: ChatVarType.Object,
value: { count: 1 },
})).toEqual({ count: 1 })
expect(formatChatVariableValue({
editInJSON: false,
objectValue: [],
type: ChatVarType.ArrayString,
value: ['a', '', 'b'],
})).toEqual(['a', 'b'])
expect(formatChatVariableValue({
editInJSON: false,
objectValue: [],
type: ChatVarType.Number,
value: undefined,
})).toBe(0)
expect(formatChatVariableValue({
editInJSON: false,
objectValue: [],
type: ChatVarType.Boolean,
value: undefined,
})).toBe(true)
expect(formatChatVariableValue({
editInJSON: false,
objectValue: [],
type: ChatVarType.ArrayBoolean,
value: undefined,
})).toEqual([])
})
it('should derive placeholders, editor defaults, and editor toggle labels', () => {
expect(getEditorMinHeight(ChatVarType.ArrayObject)).toBe('240px')
expect(getEditorMinHeight(ChatVarType.Object)).toBe('120px')
expect(getPlaceholderByType(ChatVarType.ArrayBoolean)).toBeTruthy()
expect(getTypeChangeState(ChatVarType.Boolean).value).toBe(false)
expect(getTypeChangeState(ChatVarType.ArrayBoolean).value).toEqual([false])
expect(getTypeChangeState(ChatVarType.Object).objectValue).toHaveLength(1)
expect(getTypeChangeState(ChatVarType.ArrayObject).editInJSON).toBe(true)
expect(getEditorToggleLabelKey(ChatVarType.Object, true)).toBe('chatVariable.modal.editInForm')
expect(getEditorToggleLabelKey(ChatVarType.ArrayString, false)).toBe('chatVariable.modal.editInJSON')
})
it('should parse boolean arrays from JSON editor content', () => {
expect(parseEditorContent({
content: '["True","false",true,false,"invalid"]',
type: ChatVarType.ArrayBoolean,
})).toEqual([true, false, true, false])
expect(parseEditorContent({
content: '{"enabled":true}',
type: ChatVarType.Object,
})).toEqual({ enabled: true })
})
it('should validate variable names and notify when invalid', () => {
const notify = vi.fn()
const t = (key: string) => key
expect(validateVariableName({
name: 'valid_name',
notify,
t,
})).toBe(true)
expect(validateVariableName({
name: '1invalid',
notify,
t,
})).toBe(false)
expect(notify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
}))
})
})

View File

@ -0,0 +1,198 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { toast } from '@/app/components/base/ui/toast'
import { renderWorkflowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import { ChatVarType } from '../../type'
import VariableModal from '../variable-modal'
vi.mock('uuid', () => ({
v4: () => 'generated-id',
}))
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
error: vi.fn(),
info: vi.fn(),
success: vi.fn(),
warning: vi.fn(),
},
}))
const renderVariableModal = (props?: Partial<React.ComponentProps<typeof VariableModal>>) => {
const onClose = vi.fn()
const onSave = vi.fn()
const result = renderWorkflowComponent(
React.createElement(
VariableModal,
{
onClose,
onSave,
...props,
},
),
)
return { ...result, onClose, onSave }
}
describe('variable-modal', () => {
const mockToastError = vi.mocked(toast.error)
beforeEach(() => {
vi.clearAllMocks()
})
it('should create a new string variable and close after saving', async () => {
const user = userEvent.setup()
const { onClose, onSave } = renderVariableModal()
await user.type(screen.getByPlaceholderText('workflow.chatVariable.modal.namePlaceholder'), 'greeting')
await user.type(screen.getByPlaceholderText('workflow.chatVariable.modal.valuePlaceholder'), 'hello')
await user.type(screen.getByPlaceholderText('workflow.chatVariable.modal.descriptionPlaceholder'), 'demo variable')
await user.click(screen.getByText('common.operation.save'))
expect(onSave).toHaveBeenCalledWith({
id: 'generated-id',
name: 'greeting',
value_type: ChatVarType.String,
value: 'hello',
description: 'demo variable',
})
expect(onClose).toHaveBeenCalled()
})
it('should reject duplicate variable names from the workflow store', async () => {
const user = userEvent.setup()
const { onSave, store } = renderVariableModal()
store.setState({
conversationVariables: [{
id: 'var-1',
name: 'existing_name',
description: '',
value_type: ChatVarType.String,
value: '',
}],
})
await user.type(screen.getByPlaceholderText('workflow.chatVariable.modal.namePlaceholder'), 'existing_name')
await user.click(screen.getByText('common.operation.save'))
expect(mockToastError.mock.calls.at(-1)?.[0]).toBe('name is existed')
expect(onSave).not.toHaveBeenCalled()
})
it('should load an existing object variable and save object values edited in form mode', async () => {
const user = userEvent.setup()
const { onSave } = renderVariableModal({
chatVar: {
id: 'var-2',
name: 'config',
description: 'settings',
value_type: ChatVarType.Object,
value: { apiKey: 'secret', timeout: 30 },
},
})
expect(screen.getByDisplayValue('config')).toBeInTheDocument()
expect(screen.getByDisplayValue('secret')).toBeInTheDocument()
expect(screen.getByDisplayValue('30')).toBeInTheDocument()
await user.clear(screen.getByDisplayValue('secret'))
await user.type(screen.getByDisplayValue('30'), '5')
await user.click(screen.getByText('common.operation.save'))
expect(onSave).toHaveBeenCalledWith({
id: 'var-2',
name: 'config',
value_type: ChatVarType.Object,
value: {
apiKey: null,
timeout: 305,
},
description: 'settings',
})
})
it('should switch types and use default values for boolean arrays', async () => {
const user = userEvent.setup()
const { onSave } = renderVariableModal()
await user.type(screen.getByPlaceholderText('workflow.chatVariable.modal.namePlaceholder'), 'flags')
await user.click(screen.getByText('string'))
await user.click(screen.getByText('array[boolean]'))
await user.click(screen.getByText('common.operation.save'))
expect(onSave).toHaveBeenCalledWith({
id: 'generated-id',
name: 'flags',
value_type: ChatVarType.ArrayBoolean,
value: [false],
description: '',
})
})
it('should toggle object editing modes without changing behavior', async () => {
const user = userEvent.setup()
renderVariableModal({
chatVar: {
id: 'var-3',
name: 'payload',
description: '',
value_type: ChatVarType.Object,
value: { enabled: 1 },
},
})
await user.click(screen.getByText('workflow.chatVariable.modal.editInJSON'))
await waitFor(() => {
expect(screen.getByText('Loading...')).toBeInTheDocument()
})
await user.click(screen.getByText('workflow.chatVariable.modal.editInForm'))
expect(screen.getByDisplayValue('enabled')).toBeInTheDocument()
})
it('should validate variable names on blur and preserve underscore replacement', () => {
renderVariableModal()
const input = screen.getByPlaceholderText('workflow.chatVariable.modal.namePlaceholder')
fireEvent.change(input, { target: { value: 'bad name' } })
fireEvent.blur(input)
expect((input as HTMLInputElement).value).toBe('bad_name')
expect(mockToastError).not.toHaveBeenCalled()
})
it('should stop invalid variable names before they are stored in local state', async () => {
const { onSave } = renderVariableModal()
const input = screen.getByPlaceholderText('workflow.chatVariable.modal.namePlaceholder') as HTMLInputElement
fireEvent.change(input, { target: { value: '1bad' } })
await userEvent.click(screen.getByText('common.operation.save'))
expect(input.value).toBe('')
expect(mockToastError).toHaveBeenCalled()
expect(onSave).not.toHaveBeenCalled()
})
it('should edit number variables through the value input', async () => {
const user = userEvent.setup()
const { onSave } = renderVariableModal()
await user.type(screen.getByPlaceholderText('workflow.chatVariable.modal.namePlaceholder'), 'timeout')
await user.click(screen.getByText('string'))
await user.click(screen.getByText('number'))
await user.type(screen.getByPlaceholderText('workflow.chatVariable.modal.valuePlaceholder'), '3')
await user.click(screen.getByText('common.operation.save'))
expect(onSave).toHaveBeenCalledWith({
id: 'generated-id',
name: 'timeout',
value_type: ChatVarType.Number,
value: 3,
description: '',
})
})
})

View File

@ -0,0 +1,234 @@
import type { ObjectValueItem, ToastPayload } from './variable-modal.helpers'
import type { ConversationVariable } from '@/app/components/workflow/types'
import { useMemo, useState } from 'react'
import { v4 as uuid4 } from 'uuid'
import { DEFAULT_OBJECT_VALUE } from '@/app/components/workflow/panel/chat-variable-panel/components/object-value-item'
import { ChatVarType } from '@/app/components/workflow/panel/chat-variable-panel/type'
import {
buildObjectValueItems,
formatChatVariableValue,
formatObjectValueFromList,
getEditorMinHeight,
getPlaceholderByType,
getTypeChangeState,
parseEditorContent,
validateVariableName,
} from './variable-modal.helpers'
type UseVariableModalStateOptions = {
chatVar?: ConversationVariable
conversationVariables: ConversationVariable[]
notify: (props: ToastPayload) => void
onClose: () => void
onSave: (chatVar: ConversationVariable) => void
t: (key: string, options?: Record<string, unknown>) => string
}
type VariableModalState = {
description: string
editInJSON: boolean
editorContent?: string
name: string
objectValue: ObjectValueItem[]
type: ChatVarType
value: unknown
}
const buildObjectValueListFromRecord = (record: Record<string, string | number>) => {
return Object.keys(record).map(key => ({
key,
type: typeof record[key] === 'string' ? ChatVarType.String : ChatVarType.Number,
value: record[key],
}))
}
const buildInitialState = (chatVar?: ConversationVariable): VariableModalState => {
if (!chatVar) {
return {
description: '',
editInJSON: false,
editorContent: undefined,
name: '',
objectValue: [DEFAULT_OBJECT_VALUE],
type: ChatVarType.String,
value: undefined,
}
}
return {
description: chatVar.description,
editInJSON: chatVar.value_type === ChatVarType.ArrayObject,
editorContent: chatVar.value_type === ChatVarType.ArrayObject ? JSON.stringify(chatVar.value) : undefined,
name: chatVar.name,
objectValue: buildObjectValueItems(chatVar),
type: chatVar.value_type,
value: chatVar.value,
}
}
export const useVariableModalState = ({
chatVar,
conversationVariables,
notify,
onClose,
onSave,
t,
}: UseVariableModalStateOptions) => {
const [state, setState] = useState<VariableModalState>(() => buildInitialState(chatVar))
const editorMinHeight = useMemo(() => getEditorMinHeight(state.type), [state.type])
const placeholder = useMemo(() => getPlaceholderByType(state.type), [state.type])
const handleVarNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setState(prev => ({ ...prev, name: e.target.value || '' }))
}
const handleTypeChange = (nextType: ChatVarType) => {
const nextState = getTypeChangeState(nextType)
setState(prev => ({
...prev,
editInJSON: nextState.editInJSON,
editorContent: nextState.editorContent,
objectValue: nextState.objectValue ?? prev.objectValue,
type: nextType,
value: nextState.value,
}))
}
const handleStringOrNumberChange = (nextValue: Array<string | number | undefined>) => {
setState(prev => ({ ...prev, value: nextValue[0] }))
}
const handleEditorChange = (nextEditInJSON: boolean) => {
setState((prev) => {
const nextState: VariableModalState = {
...prev,
editInJSON: nextEditInJSON,
}
if (prev.type === ChatVarType.Object) {
if (nextEditInJSON) {
const nextValue = !prev.objectValue[0].key ? undefined : formatObjectValueFromList(prev.objectValue)
nextState.value = nextValue
nextState.editorContent = JSON.stringify(nextValue)
return nextState
}
if (!prev.editorContent) {
nextState.value = undefined
nextState.objectValue = [DEFAULT_OBJECT_VALUE]
return nextState
}
try {
const nextValue = JSON.parse(prev.editorContent) as Record<string, string | number>
nextState.value = nextValue
nextState.objectValue = buildObjectValueListFromRecord(nextValue)
}
catch {
// ignore JSON.parse errors
}
return nextState
}
if (prev.type === ChatVarType.ArrayString || prev.type === ChatVarType.ArrayNumber) {
if (nextEditInJSON) {
const nextValue = (Array.isArray(prev.value) && prev.value.length && prev.value.filter(Boolean).length)
? prev.value.filter(Boolean)
: undefined
nextState.value = nextValue
if (!prev.editorContent)
nextState.editorContent = JSON.stringify(nextValue)
return nextState
}
nextState.value = Array.isArray(prev.value) && prev.value.length ? prev.value : [undefined]
return nextState
}
if (prev.type === ChatVarType.ArrayBoolean && Array.isArray(prev.value) && nextEditInJSON)
nextState.editorContent = JSON.stringify(prev.value.map(item => item ? 'True' : 'False'))
return nextState
})
}
const handleEditorValueChange = (content: string) => {
setState((prev) => {
const nextState: VariableModalState = {
...prev,
editorContent: content,
}
if (!content) {
nextState.value = undefined
return nextState
}
try {
nextState.value = parseEditorContent({ content, type: prev.type })
}
catch {
// ignore JSON.parse errors
}
return nextState
})
}
const handleSave = () => {
if (!validateVariableName({ name: state.name, notify, t }))
return
if (!chatVar && conversationVariables.some(item => item.name === state.name)) {
notify({
type: 'error',
message: t('varKeyError.keyAlreadyExists', { ns: 'appDebug', key: t('chatVariable.modal.name', { ns: 'workflow' }) }),
})
return
}
if (state.type === ChatVarType.Object && state.objectValue.some(item => !item.key && !!item.value)) {
notify({
type: 'error',
message: t('chatVariable.modal.objectKeyRequired', { ns: 'workflow' }),
})
return
}
onSave({
description: state.description,
id: chatVar ? chatVar.id : uuid4(),
name: state.name,
value: formatChatVariableValue({
editInJSON: state.editInJSON,
objectValue: state.objectValue,
type: state.type,
value: state.value,
}),
value_type: state.type,
})
onClose()
}
return {
description: state.description,
editInJSON: state.editInJSON,
editorContent: state.editorContent,
editorMinHeight,
handleEditorChange,
handleEditorValueChange,
handleSave,
handleStringOrNumberChange,
handleTypeChange,
handleVarNameChange,
name: state.name,
objectValue: state.objectValue,
placeholder,
setDescription: (description: string) => setState(prev => ({ ...prev, description })),
setObjectValue: (objectValue: ObjectValueItem[]) => setState(prev => ({ ...prev, objectValue })),
setValue: (value: unknown) => setState(prev => ({ ...prev, value })),
type: state.type,
value: state.value,
}
}

View File

@ -57,6 +57,7 @@ const VariableModalTrigger = ({
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[11]">
<VariableModal
key={chatVar?.id ?? 'new'}
chatVar={chatVar}
onSave={onSave}
onClose={() => {

View File

@ -0,0 +1,170 @@
import type { ReactNode } from 'react'
import type { ChatVarType } from '../type'
import type { ConversationVariable } from '@/app/components/workflow/types'
import { checkKeys } from '@/utils/var'
import { ChatVarType as ChatVarTypeEnum } from '../type'
import {
arrayBoolPlaceholder,
arrayNumberPlaceholder,
arrayObjectPlaceholder,
arrayStringPlaceholder,
objectPlaceholder,
} from '../utils'
import { DEFAULT_OBJECT_VALUE } from './object-value-item'
export type ObjectValueItem = {
key: string
type: ChatVarType
value: string | number | undefined
}
export type ToastPayload = {
type?: 'success' | 'error' | 'warning' | 'info'
size?: 'md' | 'sm'
duration?: number
message: string
children?: ReactNode
onClose?: () => void
className?: string
customComponent?: ReactNode
}
export const typeList = [
ChatVarTypeEnum.String,
ChatVarTypeEnum.Number,
ChatVarTypeEnum.Boolean,
ChatVarTypeEnum.Object,
ChatVarTypeEnum.ArrayString,
ChatVarTypeEnum.ArrayNumber,
ChatVarTypeEnum.ArrayBoolean,
ChatVarTypeEnum.ArrayObject,
]
export const getEditorMinHeight = (type: ChatVarType) =>
type === ChatVarTypeEnum.ArrayObject ? '240px' : '120px'
export const getPlaceholderByType = (type: ChatVarType) => {
if (type === ChatVarTypeEnum.ArrayString)
return arrayStringPlaceholder
if (type === ChatVarTypeEnum.ArrayNumber)
return arrayNumberPlaceholder
if (type === ChatVarTypeEnum.ArrayObject)
return arrayObjectPlaceholder
if (type === ChatVarTypeEnum.ArrayBoolean)
return arrayBoolPlaceholder
return objectPlaceholder
}
export const buildObjectValueItems = (chatVar?: ConversationVariable): ObjectValueItem[] => {
if (!chatVar || !chatVar.value || Object.keys(chatVar.value).length === 0)
return [DEFAULT_OBJECT_VALUE]
return Object.keys(chatVar.value).map((key) => {
const itemValue = chatVar.value[key]
return {
key,
type: typeof itemValue === 'string' ? ChatVarTypeEnum.String : ChatVarTypeEnum.Number,
value: itemValue,
}
})
}
export const formatObjectValueFromList = (list: ObjectValueItem[]) => {
return list.reduce<Record<string, string | number | null>>((acc, curr) => {
if (curr.key)
acc[curr.key] = curr.value || null
return acc
}, {})
}
export const formatChatVariableValue = ({
editInJSON,
objectValue,
type,
value,
}: {
editInJSON: boolean
objectValue: ObjectValueItem[]
type: ChatVarType
value: unknown
}) => {
switch (type) {
case ChatVarTypeEnum.String:
return value || ''
case ChatVarTypeEnum.Number:
return value || 0
case ChatVarTypeEnum.Boolean:
return value === undefined ? true : value
case ChatVarTypeEnum.Object:
return editInJSON ? value : formatObjectValueFromList(objectValue)
case ChatVarTypeEnum.ArrayString:
case ChatVarTypeEnum.ArrayNumber:
case ChatVarTypeEnum.ArrayObject:
return Array.isArray(value) ? value.filter(Boolean) : []
case ChatVarTypeEnum.ArrayBoolean:
return value || []
}
}
export const validateVariableName = ({
name,
notify,
t,
}: {
name: string
notify: (props: ToastPayload) => void
t: (key: string, options?: Record<string, unknown>) => string
}) => {
const { isValid, errorMessageKey } = checkKeys([name], false)
if (!isValid) {
notify({
type: 'error',
message: t(`varKeyError.${errorMessageKey}`, { ns: 'appDebug', key: t('env.modal.name', { ns: 'workflow' }) }),
})
return false
}
return true
}
export const getTypeChangeState = (nextType: ChatVarType) => {
return {
editInJSON: nextType === ChatVarTypeEnum.ArrayObject,
editorContent: undefined as string | undefined,
objectValue: nextType === ChatVarTypeEnum.Object ? [DEFAULT_OBJECT_VALUE] : undefined,
value:
nextType === ChatVarTypeEnum.Boolean
? false
: nextType === ChatVarTypeEnum.ArrayBoolean
? [false]
: undefined,
}
}
export const parseEditorContent = ({
content,
type,
}: {
content: string
type: ChatVarType
}) => {
const parsed = JSON.parse(content)
if (type !== ChatVarTypeEnum.ArrayBoolean)
return parsed
return parsed
.map((item: string | boolean) => {
if (item === 'True' || item === 'true' || item === true)
return true
if (item === 'False' || item === 'false' || item === false)
return false
return undefined
})
.filter((item?: boolean) => item !== undefined)
}
export const getEditorToggleLabelKey = (type: ChatVarType, editInJSON: boolean) => {
if (type === ChatVarTypeEnum.Object)
return editInJSON ? 'chatVariable.modal.editInForm' : 'chatVariable.modal.editInJSON'
return editInJSON ? 'chatVariable.modal.oneByOne' : 'chatVariable.modal.editInJSON'
}

View File

@ -0,0 +1,217 @@
import type { ReactNode } from 'react'
import type { ObjectValueItem } from './variable-modal.helpers'
import { RiDraftLine, RiInputField } from '@remixicon/react'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import { ChatVarType } from '../type'
import ArrayBoolList from './array-bool-list'
import ArrayValueList from './array-value-list'
import BoolValue from './bool-value'
import ObjectValueList from './object-value-list'
import VariableTypeSelector from './variable-type-select'
type SectionTitleProps = {
children: ReactNode
}
export const SectionTitle = ({ children }: SectionTitleProps) => (
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-semibold">{children}</div>
)
type NameSectionProps = {
name: string
onBlur: (value: string) => void
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
placeholder: string
title: string
}
export const NameSection = ({
name,
onBlur,
onChange,
placeholder,
title,
}: NameSectionProps) => (
<div className="mb-4">
<SectionTitle>{title}</SectionTitle>
<div className="flex">
<Input
placeholder={placeholder}
value={name}
onChange={onChange}
onBlur={e => onBlur(e.target.value)}
type="text"
/>
</div>
</div>
)
type TypeSectionProps = {
list: ChatVarType[]
onSelect: (value: ChatVarType) => void
title: string
type: ChatVarType
}
export const TypeSection = ({
list,
onSelect,
title,
type,
}: TypeSectionProps) => (
<div className="mb-4">
<SectionTitle>{title}</SectionTitle>
<div className="flex">
<VariableTypeSelector
value={type}
list={list}
onSelect={onSelect}
popupClassName="w-[327px]"
/>
</div>
</div>
)
type ValueSectionProps = {
editorContent?: string
editorMinHeight: string
editInJSON: boolean
objectValue: ObjectValueItem[]
onArrayBoolChange: (value: boolean[]) => void
onArrayChange: (value: Array<string | number | undefined>) => void
onEditorChange: (nextEditInJson: boolean) => void
onEditorValueChange: (content: string) => void
onObjectChange: (value: ObjectValueItem[]) => void
onValueChange: (value: boolean) => void
placeholder: ReactNode
t: (key: string, options?: Record<string, unknown>) => string
toggleLabelKey?: string
type: ChatVarType
value: unknown
}
export const ValueSection = ({
editorContent,
editorMinHeight,
editInJSON,
objectValue,
onArrayBoolChange,
onArrayChange,
onEditorChange,
onEditorValueChange,
onObjectChange,
onValueChange,
placeholder,
t,
toggleLabelKey,
type,
value,
}: ValueSectionProps) => (
<div className="mb-4">
<div className="mb-1 flex h-6 items-center justify-between text-text-secondary system-sm-semibold">
<div>{t('chatVariable.modal.value', { ns: 'workflow' })}</div>
{toggleLabelKey && (
<Button
variant="ghost"
size="small"
className="text-text-tertiary"
onClick={() => onEditorChange(!editInJSON)}
>
{editInJSON ? <RiInputField className="mr-1 h-3.5 w-3.5" /> : <RiDraftLine className="mr-1 h-3.5 w-3.5" />}
{t(toggleLabelKey, { ns: 'workflow' })}
</Button>
)}
</div>
<div className="flex">
{type === ChatVarType.String && (
<textarea
className="block h-20 w-full resize-none appearance-none rounded-lg border border-transparent bg-components-input-bg-normal p-2 text-components-input-text-filled caret-primary-600 outline-none system-sm-regular placeholder:text-components-input-text-placeholder placeholder:system-sm-regular hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs"
value={(value as string) || ''}
placeholder={t('chatVariable.modal.valuePlaceholder', { ns: 'workflow' }) || ''}
onChange={e => onArrayChange([e.target.value])}
/>
)}
{type === ChatVarType.Number && (
<Input
placeholder={t('chatVariable.modal.valuePlaceholder', { ns: 'workflow' }) || ''}
value={value as number | undefined}
onChange={e => onArrayChange([Number(e.target.value)])}
type="number"
/>
)}
{type === ChatVarType.Boolean && (
<BoolValue
value={value as boolean}
onChange={onValueChange}
/>
)}
{type === ChatVarType.Object && !editInJSON && (
<ObjectValueList
list={objectValue}
onChange={onObjectChange}
/>
)}
{type === ChatVarType.ArrayString && !editInJSON && (
<ArrayValueList
isString
list={(value as Array<string | undefined>) || [undefined]}
onChange={onArrayChange}
/>
)}
{type === ChatVarType.ArrayNumber && !editInJSON && (
<ArrayValueList
isString={false}
list={(value as Array<number | undefined>) || [undefined]}
onChange={onArrayChange}
/>
)}
{type === ChatVarType.ArrayBoolean && !editInJSON && (
<ArrayBoolList
list={(value as boolean[]) || [true]}
onChange={onArrayBoolChange}
/>
)}
{editInJSON && (
<div className="w-full rounded-[10px] bg-components-input-bg-normal py-2 pl-3 pr-1" style={{ height: editorMinHeight }}>
<CodeEditor
isExpand
noWrapper
language={CodeLanguage.json}
value={editorContent}
placeholder={<div className="whitespace-pre">{placeholder}</div>}
onChange={onEditorValueChange}
/>
</div>
)}
</div>
</div>
)
type DescriptionSectionProps = {
description: string
onChange: (value: string) => void
placeholder: string
title: string
}
export const DescriptionSection = ({
description,
onChange,
placeholder,
title,
}: DescriptionSectionProps) => (
<div>
<SectionTitle>{title}</SectionTitle>
<div className="flex">
<textarea
className="block h-20 w-full resize-none appearance-none rounded-lg border border-transparent bg-components-input-bg-normal p-2 text-components-input-text-filled caret-primary-600 outline-none system-sm-regular placeholder:text-components-input-text-placeholder placeholder:system-sm-regular hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs"
value={description}
placeholder={placeholder}
onChange={e => onChange(e.target.value)}
/>
</div>
</div>
)

View File

@ -1,31 +1,26 @@
import type { ToastPayload } from './variable-modal.helpers'
import type { ConversationVariable } from '@/app/components/workflow/types'
import { RiCloseLine, RiDraftLine, RiInputField } from '@remixicon/react'
import { RiCloseLine } from '@remixicon/react'
import * as React from 'react'
import { useCallback, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { v4 as uuid4 } from 'uuid'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import { toast } from '@/app/components/base/ui/toast'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import ArrayValueList from '@/app/components/workflow/panel/chat-variable-panel/components/array-value-list'
import { DEFAULT_OBJECT_VALUE } from '@/app/components/workflow/panel/chat-variable-panel/components/object-value-item'
import ObjectValueList from '@/app/components/workflow/panel/chat-variable-panel/components/object-value-list'
import VariableTypeSelector from '@/app/components/workflow/panel/chat-variable-panel/components/variable-type-select'
import { ChatVarType } from '@/app/components/workflow/panel/chat-variable-panel/type'
import {
arrayBoolPlaceholder,
arrayNumberPlaceholder,
arrayObjectPlaceholder,
arrayStringPlaceholder,
objectPlaceholder,
} from '@/app/components/workflow/panel/chat-variable-panel/utils'
import { useWorkflowStore } from '@/app/components/workflow/store'
import { cn } from '@/utils/classnames'
import { checkKeys, replaceSpaceWithUnderscoreInVarNameInput } from '@/utils/var'
import ArrayBoolList from './array-bool-list'
import BoolValue from './bool-value'
import { replaceSpaceWithUnderscoreInVarNameInput } from '@/utils/var'
import { useVariableModalState } from './use-variable-modal-state'
import {
getEditorToggleLabelKey,
typeList,
validateVariableName,
} from './variable-modal.helpers'
import {
DescriptionSection,
NameSection,
TypeSection,
ValueSection,
} from './variable-modal.sections'
export type ModalPropsType = {
chatVar?: ConversationVariable
@ -33,23 +28,6 @@ export type ModalPropsType = {
onSave: (chatVar: ConversationVariable) => void
}
type ObjectValueItem = {
key: string
type: ChatVarType
value: string | number | undefined
}
const typeList = [
ChatVarType.String,
ChatVarType.Number,
ChatVarType.Boolean,
ChatVarType.Object,
ChatVarType.ArrayString,
ChatVarType.ArrayNumber,
ChatVarType.ArrayBoolean,
ChatVarType.ArrayObject,
]
const ChatVariableModal = ({
chatVar,
onClose,
@ -57,215 +35,43 @@ const ChatVariableModal = ({
}: ModalPropsType) => {
const { t } = useTranslation()
const workflowStore = useWorkflowStore()
const [name, setName] = React.useState('')
const [type, setType] = React.useState<ChatVarType>(ChatVarType.String)
const [value, setValue] = React.useState<any>()
const [objectValue, setObjectValue] = React.useState<ObjectValueItem[]>([DEFAULT_OBJECT_VALUE])
const [editorContent, setEditorContent] = React.useState<string>()
const [editInJSON, setEditInJSON] = React.useState(false)
const [description, setDescription] = React.useState<string>('')
const editorMinHeight = useMemo(() => {
if (type === ChatVarType.ArrayObject)
return '240px'
return '120px'
}, [type])
const placeholder = useMemo(() => {
if (type === ChatVarType.ArrayString)
return arrayStringPlaceholder
if (type === ChatVarType.ArrayNumber)
return arrayNumberPlaceholder
if (type === ChatVarType.ArrayObject)
return arrayObjectPlaceholder
if (type === ChatVarType.ArrayBoolean)
return arrayBoolPlaceholder
return objectPlaceholder
}, [type])
const getObjectValue = useCallback(() => {
const raw = chatVar?.value
if (!chatVar || raw === null || typeof raw !== 'object' || Array.isArray(raw) || Object.keys(raw).length === 0)
return [DEFAULT_OBJECT_VALUE]
return Object.keys(raw).map((key) => {
const v = raw[key]
const isStr = typeof v === 'string'
const isNum = typeof v === 'number'
return {
key,
type: isStr ? ChatVarType.String : ChatVarType.Number,
value: isStr || isNum ? v : undefined,
}
})
}, [chatVar])
const formatValueFromObject = useCallback((list: ObjectValueItem[]) => {
return list.reduce((acc: any, curr) => {
if (curr.key)
acc[curr.key] = curr.value || null
return acc
}, {})
const notify = React.useCallback(({ children, message, type = 'info' }: ToastPayload) => {
toast[type](message, children ? { description: children } : undefined)
}, [])
const {
description,
editInJSON,
editorContent,
editorMinHeight,
handleEditorChange,
handleEditorValueChange,
handleSave,
handleStringOrNumberChange,
handleTypeChange,
handleVarNameChange,
name,
objectValue,
placeholder,
setDescription,
setObjectValue,
setValue,
type,
value,
} = useVariableModalState({
chatVar,
conversationVariables: workflowStore.getState().conversationVariables,
notify,
onClose,
onSave,
t,
})
const formatValue = (value: any) => {
switch (type) {
case ChatVarType.String:
return value || ''
case ChatVarType.Number:
return value || 0
case ChatVarType.Boolean:
return value === undefined ? true : value
case ChatVarType.Object:
return editInJSON ? value : formatValueFromObject(objectValue)
case ChatVarType.ArrayString:
case ChatVarType.ArrayNumber:
case ChatVarType.ArrayObject:
return value?.filter(Boolean) || []
case ChatVarType.ArrayBoolean:
return value || []
}
}
const checkVariableName = (value: string) => {
const { isValid, errorMessageKey } = checkKeys([value], false)
if (!isValid) {
toast.error(t(`varKeyError.${errorMessageKey}`, { ns: 'appDebug', key: t('env.modal.name', { ns: 'workflow' }) }))
return false
}
return true
}
const handleVarNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
replaceSpaceWithUnderscoreInVarNameInput(e.target)
if (!!e.target.value && !checkVariableName(e.target.value))
if (e.target.value && !validateVariableName({ name: e.target.value, notify, t }))
return
setName(e.target.value || '')
handleVarNameChange(e)
}
const handleTypeChange = (v: ChatVarType) => {
setValue(undefined)
setEditorContent(undefined)
if (v === ChatVarType.ArrayObject)
setEditInJSON(true)
if (v === ChatVarType.String || v === ChatVarType.Number || v === ChatVarType.Object)
setEditInJSON(false)
if (v === ChatVarType.Boolean)
setValue(false)
if (v === ChatVarType.ArrayBoolean)
setValue([false])
setType(v)
}
const handleEditorChange = (editInJSON: boolean) => {
if (type === ChatVarType.Object) {
if (editInJSON) {
const newValue = !objectValue[0].key ? undefined : formatValueFromObject(objectValue)
setValue(newValue)
setEditorContent(JSON.stringify(newValue))
}
else {
if (!editorContent) {
setValue(undefined)
setObjectValue([DEFAULT_OBJECT_VALUE])
}
else {
try {
const newValue = JSON.parse(editorContent)
setValue(newValue)
const newObjectValue = Object.keys(newValue).map((key) => {
return {
key,
type: typeof newValue[key] === 'string' ? ChatVarType.String : ChatVarType.Number,
value: newValue[key],
}
})
setObjectValue(newObjectValue)
}
catch {
// ignore JSON.parse errors
}
}
}
}
if (type === ChatVarType.ArrayString || type === ChatVarType.ArrayNumber) {
if (editInJSON) {
const newValue = (value?.length && value.filter(Boolean).length) ? value.filter(Boolean) : undefined
setValue(newValue)
if (!editorContent)
setEditorContent(JSON.stringify(newValue))
}
else {
setValue(value?.length ? value : [undefined])
}
}
if (type === ChatVarType.ArrayBoolean) {
if (editInJSON)
setEditorContent(JSON.stringify(value.map((item: boolean) => item ? 'True' : 'False')))
}
setEditInJSON(editInJSON)
}
const handleEditorValueChange = (content: string) => {
if (!content) {
setEditorContent(content)
return setValue(undefined)
}
else {
setEditorContent(content)
try {
let newValue = JSON.parse(content)
if (type === ChatVarType.ArrayBoolean) {
newValue = newValue.map((item: string | boolean) => {
if (item === 'True' || item === 'true' || item === true)
return true
if (item === 'False' || item === 'false' || item === false)
return false
return undefined
}).filter((item?: boolean) => item !== undefined)
}
setValue(newValue)
}
catch {
// ignore JSON.parse errors
}
}
}
const handleSave = () => {
if (!checkVariableName(name))
return
const varList = workflowStore.getState().conversationVariables
if (!chatVar && varList.some(chatVar => chatVar.name === name))
return toast.error(t('varKeyError.keyAlreadyExists', { ns: 'appDebug', key: t('chatVariable.modal.name', { ns: 'workflow' }) }))
if (type === ChatVarType.Object && objectValue.some(item => !item.key && !!item.value))
return toast.error(t('chatVariable.modal.objectKeyRequired', { ns: 'workflow' }))
onSave({
id: chatVar ? chatVar.id : uuid4(),
name,
value_type: type,
value: formatValue(value),
description,
})
onClose()
}
useEffect(() => {
if (chatVar) {
setName(chatVar.name)
setType(chatVar.value_type)
setValue(chatVar.value)
setDescription(chatVar.description)
setObjectValue(getObjectValue())
if (chatVar.value_type === ChatVarType.ArrayObject) {
setEditorContent(JSON.stringify(chatVar.value))
setEditInJSON(true)
}
else {
setEditInJSON(false)
}
}
}, [chatVar, getObjectValue])
return (
<div
className={cn('flex h-full w-[360px] flex-col rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-2xl', type === ChatVarType.Object && 'w-[480px]')}
@ -282,135 +88,49 @@ const ChatVariableModal = ({
</div>
</div>
<div className="max-h-[480px] overflow-y-auto px-4 py-2">
{/* name */}
<div className="mb-4">
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-semibold">{t('chatVariable.modal.name', { ns: 'workflow' })}</div>
<div className="flex">
<Input
placeholder={t('chatVariable.modal.namePlaceholder', { ns: 'workflow' }) || ''}
value={name}
onChange={handleVarNameChange}
onBlur={e => checkVariableName(e.target.value)}
type="text"
/>
</div>
</div>
{/* type */}
<div className="mb-4">
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-semibold">{t('chatVariable.modal.type', { ns: 'workflow' })}</div>
<div className="flex">
<VariableTypeSelector
value={type}
list={typeList}
onSelect={handleTypeChange}
popupClassName="w-[327px]"
/>
</div>
</div>
{/* default value */}
<div className="mb-4">
<div className="mb-1 flex h-6 items-center justify-between text-text-secondary system-sm-semibold">
<div>{t('chatVariable.modal.value', { ns: 'workflow' })}</div>
{(type === ChatVarType.ArrayString || type === ChatVarType.ArrayNumber || type === ChatVarType.ArrayBoolean) && (
<Button
variant="ghost"
size="small"
className="text-text-tertiary"
onClick={() => handleEditorChange(!editInJSON)}
>
{editInJSON ? <RiInputField className="mr-1 h-3.5 w-3.5" /> : <RiDraftLine className="mr-1 h-3.5 w-3.5" />}
{editInJSON ? t('chatVariable.modal.oneByOne', { ns: 'workflow' }) : t('chatVariable.modal.editInJSON', { ns: 'workflow' })}
</Button>
)}
{type === ChatVarType.Object && (
<Button
variant="ghost"
size="small"
className="text-text-tertiary"
onClick={() => handleEditorChange(!editInJSON)}
>
{editInJSON ? <RiInputField className="mr-1 h-3.5 w-3.5" /> : <RiDraftLine className="mr-1 h-3.5 w-3.5" />}
{editInJSON ? t('chatVariable.modal.editInForm', { ns: 'workflow' }) : t('chatVariable.modal.editInJSON', { ns: 'workflow' })}
</Button>
)}
</div>
<div className="flex">
{type === ChatVarType.String && (
// Input will remove \n\r, so use Textarea just like description area
<textarea
className="block h-20 w-full resize-none appearance-none rounded-lg border border-transparent bg-components-input-bg-normal p-2 text-components-input-text-filled caret-primary-600 outline-none system-sm-regular placeholder:text-components-input-text-placeholder placeholder:system-sm-regular hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs"
value={value}
placeholder={t('chatVariable.modal.valuePlaceholder', { ns: 'workflow' }) || ''}
onChange={e => setValue(e.target.value)}
/>
)}
{type === ChatVarType.Number && (
<Input
placeholder={t('chatVariable.modal.valuePlaceholder', { ns: 'workflow' }) || ''}
value={value}
onChange={e => setValue(Number(e.target.value))}
type="number"
/>
)}
{type === ChatVarType.Boolean && (
<BoolValue
value={value}
onChange={setValue}
/>
)}
{type === ChatVarType.Object && !editInJSON && (
<ObjectValueList
list={objectValue}
onChange={setObjectValue}
/>
)}
{type === ChatVarType.ArrayString && !editInJSON && (
<ArrayValueList
isString
list={value || [undefined]}
onChange={setValue}
/>
)}
{type === ChatVarType.ArrayNumber && !editInJSON && (
<ArrayValueList
isString={false}
list={value || [undefined]}
onChange={setValue}
/>
)}
{type === ChatVarType.ArrayBoolean && !editInJSON && (
<ArrayBoolList
list={value || [true]}
onChange={setValue}
/>
)}
{editInJSON && (
<div className="w-full rounded-[10px] bg-components-input-bg-normal py-2 pl-3 pr-1" style={{ height: editorMinHeight }}>
<CodeEditor
isExpand
noWrapper
language={CodeLanguage.json}
value={editorContent}
placeholder={<div className="whitespace-pre">{placeholder}</div>}
onChange={handleEditorValueChange}
/>
</div>
)}
</div>
</div>
{/* description */}
<div className="">
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-semibold">{t('chatVariable.modal.description', { ns: 'workflow' })}</div>
<div className="flex">
<textarea
className="block h-20 w-full resize-none appearance-none rounded-lg border border-transparent bg-components-input-bg-normal p-2 text-components-input-text-filled caret-primary-600 outline-none system-sm-regular placeholder:text-components-input-text-placeholder placeholder:system-sm-regular hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs"
value={description}
placeholder={t('chatVariable.modal.descriptionPlaceholder', { ns: 'workflow' }) || ''}
onChange={e => setDescription(e.target.value)}
/>
</div>
</div>
<NameSection
name={name}
onBlur={nextName => validateVariableName({ name: nextName, notify, t })}
onChange={handleNameChange}
placeholder={t('chatVariable.modal.namePlaceholder', { ns: 'workflow' }) || ''}
title={t('chatVariable.modal.name', { ns: 'workflow' })}
/>
<TypeSection
type={type}
list={typeList}
onSelect={handleTypeChange}
title={t('chatVariable.modal.type', { ns: 'workflow' })}
/>
<ValueSection
type={type}
value={value}
objectValue={objectValue}
editInJSON={editInJSON}
editorContent={editorContent}
editorMinHeight={editorMinHeight}
onArrayBoolChange={setValue}
onArrayChange={type === ChatVarType.String || type === ChatVarType.Number ? handleStringOrNumberChange : setValue}
onEditorChange={handleEditorChange}
onEditorValueChange={handleEditorValueChange}
onObjectChange={setObjectValue}
onValueChange={setValue}
placeholder={placeholder}
t={t}
toggleLabelKey={
type === ChatVarType.Object
|| type === ChatVarType.ArrayString
|| type === ChatVarType.ArrayNumber
|| type === ChatVarType.ArrayBoolean
? getEditorToggleLabelKey(type, editInJSON)
: undefined
}
/>
<DescriptionSection
description={description}
onChange={setDescription}
placeholder={t('chatVariable.modal.descriptionPlaceholder', { ns: 'workflow' }) || ''}
title={t('chatVariable.modal.description', { ns: 'workflow' })}
/>
</div>
<div className="flex flex-row-reverse rounded-b-2xl p-4 pt-2">
<div className="flex gap-2">

View File

@ -0,0 +1,127 @@
import type {
AgentLogItemWithChildren,
IterationDurationMap,
LoopDurationMap,
LoopVariableMap,
NodeTracing,
} from '@/types/workflow'
import { act, renderHook } from '@testing-library/react'
import { BlockEnum } from '../../types'
import { useLogs } from '../hooks'
const createNodeTracing = (id: string): NodeTracing => ({
id,
index: 0,
predecessor_node_id: '',
node_id: id,
node_type: BlockEnum.Tool,
title: id,
inputs: {},
inputs_truncated: false,
process_data: {},
process_data_truncated: false,
outputs_truncated: false,
status: 'succeeded',
elapsed_time: 1,
metadata: {
iterator_length: 0,
iterator_index: 0,
loop_length: 0,
loop_index: 0,
},
created_at: 0,
created_by: {
id: 'user-1',
name: 'User',
email: 'user@example.com',
},
finished_at: 1,
})
const createAgentLog = (id: string, children: AgentLogItemWithChildren[] = []): AgentLogItemWithChildren => ({
node_execution_id: `execution-${id}`,
node_id: `node-${id}`,
parent_id: undefined,
label: id,
status: 'success',
data: {},
metadata: {},
message_id: id,
children,
})
describe('useLogs', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should manage retry, iteration, and loop detail panels', () => {
const { result } = renderHook(() => useLogs())
const retryDetail = [createNodeTracing('retry-node')]
const iterationDetail = [[createNodeTracing('iteration-node')]]
const loopDetail = [[createNodeTracing('loop-node')]]
const iterationDurationMap: IterationDurationMap = { 'iteration-node': 2 }
const loopDurationMap: LoopDurationMap = { 'loop-node': 3 }
const loopVariableMap: LoopVariableMap = { 'loop-node': { item: 'value' } }
expect(result.current.showSpecialResultPanel).toBe(false)
act(() => {
result.current.handleShowRetryResultList(retryDetail)
})
expect(result.current.showRetryDetail).toBe(true)
expect(result.current.retryResultList).toEqual(retryDetail)
expect(result.current.showSpecialResultPanel).toBe(true)
act(() => {
result.current.setShowRetryDetailFalse()
result.current.handleShowIterationResultList(iterationDetail, iterationDurationMap)
result.current.handleShowLoopResultList(loopDetail, loopDurationMap, loopVariableMap)
})
expect(result.current.showRetryDetail).toBe(false)
expect(result.current.showIteratingDetail).toBe(true)
expect(result.current.iterationResultList).toEqual(iterationDetail)
expect(result.current.iterationResultDurationMap).toEqual(iterationDurationMap)
expect(result.current.showLoopingDetail).toBe(true)
expect(result.current.loopResultList).toEqual(loopDetail)
expect(result.current.loopResultDurationMap).toEqual(loopDurationMap)
expect(result.current.loopResultVariableMap).toEqual(loopVariableMap)
})
it('should push, trim, and clear agent/tool log navigation state', () => {
const { result } = renderHook(() => useLogs())
const childLog = createAgentLog('child-log')
const rootLog = createAgentLog('root-log', [childLog])
const siblingLog = createAgentLog('sibling-log')
act(() => {
result.current.handleShowAgentOrToolLog(rootLog)
})
expect(result.current.agentOrToolLogItemStack).toEqual([rootLog])
expect(result.current.agentOrToolLogListMap).toEqual({
'root-log': [childLog],
})
expect(result.current.showSpecialResultPanel).toBe(true)
act(() => {
result.current.handleShowAgentOrToolLog(siblingLog)
})
expect(result.current.agentOrToolLogItemStack).toEqual([rootLog, siblingLog])
act(() => {
result.current.handleShowAgentOrToolLog(rootLog)
})
expect(result.current.agentOrToolLogItemStack).toEqual([rootLog])
act(() => {
result.current.handleShowAgentOrToolLog(undefined)
})
expect(result.current.agentOrToolLogItemStack).toEqual([])
})
})

View File

@ -0,0 +1,356 @@
import type { ReactNode } from 'react'
import type { AgentLogItemWithChildren, NodeTracing } from '@/types/workflow'
import { fireEvent, render, screen } from '@testing-library/react'
import { BlockEnum, NodeRunningStatus } from '../../types'
import ResultPanel from '../result-panel'
const mockUseTranslation = vi.hoisted(() => vi.fn())
const mockCodeEditor = vi.hoisted(() => vi.fn())
const mockLargeDataAlert = vi.hoisted(() => vi.fn())
const mockStatusPanel = vi.hoisted(() => vi.fn())
const mockMetaData = vi.hoisted(() => vi.fn())
const mockErrorHandleTip = vi.hoisted(() => vi.fn())
const mockIterationLogTrigger = vi.hoisted(() => vi.fn())
const mockLoopLogTrigger = vi.hoisted(() => vi.fn())
const mockRetryLogTrigger = vi.hoisted(() => vi.fn())
const mockAgentLogTrigger = vi.hoisted(() => vi.fn())
vi.mock('react-i18next', () => ({
useTranslation: () => mockUseTranslation(),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
__esModule: true,
default: (props: {
title: ReactNode
value: unknown
footer?: ReactNode
tip?: ReactNode
}) => {
mockCodeEditor(props)
return (
<section data-testid="code-editor">
<div>{props.title}</div>
<div>{typeof props.value === 'string' ? props.value : JSON.stringify(props.value)}</div>
{props.tip}
{props.footer}
</section>
)
},
}))
vi.mock('@/app/components/workflow/nodes/_base/components/error-handle/error-handle-tip', () => ({
__esModule: true,
default: ({ type }: { type?: string }) => {
mockErrorHandleTip(type)
return <div data-testid="error-handle-tip">{type}</div>
},
}))
vi.mock('@/app/components/workflow/run/iteration-log', () => ({
IterationLogTrigger: (props: {
onShowIterationResultList: (detail: unknown, durationMap: unknown) => void
nodeInfo: { details?: unknown, iterDurationMap?: unknown }
}) => {
mockIterationLogTrigger(props)
return (
<button
type="button"
onClick={() => props.onShowIterationResultList(props.nodeInfo.details, props.nodeInfo.iterDurationMap)}
>
iteration-trigger
</button>
)
},
}))
vi.mock('@/app/components/workflow/run/loop-log', () => ({
LoopLogTrigger: (props: {
onShowLoopResultList: (detail: unknown, durationMap: unknown) => void
nodeInfo: { details?: unknown, loopDurationMap?: unknown }
}) => {
mockLoopLogTrigger(props)
return (
<button
type="button"
onClick={() => props.onShowLoopResultList(props.nodeInfo.details, props.nodeInfo.loopDurationMap)}
>
loop-trigger
</button>
)
},
}))
vi.mock('@/app/components/workflow/run/retry-log', () => ({
RetryLogTrigger: (props: {
onShowRetryResultList: (detail: unknown) => void
nodeInfo: { retryDetail?: unknown }
}) => {
mockRetryLogTrigger(props)
return (
<button
type="button"
onClick={() => props.onShowRetryResultList(props.nodeInfo.retryDetail)}
>
retry-trigger
</button>
)
},
}))
vi.mock('@/app/components/workflow/run/agent-log', () => ({
AgentLogTrigger: (props: {
onShowAgentOrToolLog: (detail: unknown) => void
nodeInfo: { agentLog?: unknown }
}) => {
mockAgentLogTrigger(props)
return (
<button
type="button"
onClick={() => props.onShowAgentOrToolLog(props.nodeInfo.agentLog)}
>
agent-trigger
</button>
)
},
}))
vi.mock('@/app/components/workflow/variable-inspect/large-data-alert', () => ({
__esModule: true,
default: (props: { downloadUrl?: string }) => {
mockLargeDataAlert(props)
return <div data-testid="large-data-alert">{props.downloadUrl ?? 'no-download'}</div>
},
}))
vi.mock('@/app/components/workflow/run/meta', () => ({
__esModule: true,
default: (props: Record<string, unknown>) => {
mockMetaData(props)
return <div data-testid="meta-data">{JSON.stringify(props)}</div>
},
}))
vi.mock('@/app/components/workflow/run/status', () => ({
__esModule: true,
default: (props: Record<string, unknown>) => {
mockStatusPanel(props)
return <div data-testid="status-panel">{JSON.stringify(props)}</div>
},
}))
const createNodeInfo = (overrides: Partial<NodeTracing> = {}): NodeTracing => ({
id: 'trace-node-1',
index: 0,
predecessor_node_id: '',
node_id: 'node-1',
node_type: BlockEnum.Code,
title: 'Code',
inputs: {},
inputs_truncated: false,
process_data: {},
process_data_truncated: false,
outputs_truncated: false,
status: NodeRunningStatus.Succeeded,
elapsed_time: 0,
metadata: {
iterator_length: 0,
iterator_index: 0,
loop_length: 0,
loop_index: 0,
},
created_at: 0,
created_by: {
id: 'user-1',
name: 'User',
email: 'user@example.com',
},
finished_at: 1,
details: undefined,
retryDetail: undefined,
agentLog: undefined,
iterDurationMap: undefined,
loopDurationMap: undefined,
...overrides,
})
const createLogDetail = (id: string): NodeTracing => createNodeInfo({
id: `trace-${id}`,
node_id: id,
title: id,
})
const createAgentLog = (label: string): AgentLogItemWithChildren => ({
node_execution_id: `execution-${label}`,
message_id: `message-${label}`,
node_id: `node-${label}`,
parent_id: undefined,
label,
status: 'success',
data: {},
metadata: {},
children: [],
})
describe('ResultPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseTranslation.mockReturnValue({
t: (key: string) => key,
})
})
it('should render status, editors, alerts, error strategy tip, and metadata', () => {
render(
<ResultPanel
nodeInfo={createNodeInfo()}
inputs={JSON.stringify({ topic: 'AI' })}
inputs_truncated
process_data={JSON.stringify({ step: 1 })}
process_data_truncated
outputs={{ answer: 'done' }}
outputs_truncated
outputs_full_content={{ download_url: 'https://example.com/output.json' }}
status={NodeRunningStatus.Succeeded}
error="boom"
elapsed_time={2.5}
total_tokens={42}
created_at={1710000000}
created_by="Alice"
steps={3}
showSteps
exceptionCounts={1}
execution_metadata={{ error_strategy: 'continue-on-error' }}
isListening
workflowRunId="run-1"
/>,
)
expect(screen.getByTestId('status-panel')).toBeInTheDocument()
expect(screen.getByText('COMMON.INPUT')).toBeInTheDocument()
expect(screen.getByText('COMMON.PROCESSDATA')).toBeInTheDocument()
expect(screen.getByText('COMMON.OUTPUT')).toBeInTheDocument()
expect(screen.getAllByTestId('code-editor')).toHaveLength(3)
expect(screen.getAllByTestId('large-data-alert')).toHaveLength(3)
expect(screen.getByTestId('error-handle-tip')).toHaveTextContent('continue-on-error')
expect(screen.getByTestId('meta-data')).toBeInTheDocument()
expect(mockStatusPanel).toHaveBeenCalledWith(expect.objectContaining({
status: NodeRunningStatus.Succeeded,
time: 2.5,
tokens: 42,
error: 'boom',
exceptionCounts: 1,
isListening: true,
workflowRunId: 'run-1',
}))
expect(mockMetaData).toHaveBeenCalledWith(expect.objectContaining({
status: NodeRunningStatus.Succeeded,
executor: 'Alice',
startTime: 1710000000,
time: 2.5,
tokens: 42,
steps: 3,
showSteps: true,
}))
expect(mockLargeDataAlert).toHaveBeenLastCalledWith(expect.objectContaining({
downloadUrl: 'https://example.com/output.json',
}))
})
it('should render and invoke iteration and loop triggers only when their handlers are provided', () => {
const handleShowIterationResultList = vi.fn()
const handleShowLoopResultList = vi.fn()
const details = [[createLogDetail('iter-1')]]
const { rerender } = render(
<ResultPanel
nodeInfo={createNodeInfo({
node_type: BlockEnum.Iteration,
details,
iterDurationMap: { 0: 3 },
})}
status={NodeRunningStatus.Running}
handleShowIterationResultList={handleShowIterationResultList}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'iteration-trigger' }))
expect(handleShowIterationResultList).toHaveBeenCalledWith(details, { 0: 3 })
rerender(
<ResultPanel
nodeInfo={createNodeInfo({
node_type: BlockEnum.Loop,
details,
loopDurationMap: { 0: 5 },
})}
status={NodeRunningStatus.Running}
handleShowLoopResultList={handleShowLoopResultList}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'loop-trigger' }))
expect(handleShowLoopResultList).toHaveBeenCalledWith(details, { 0: 5 })
})
it('should render retry and agent/tool triggers when the node shape supports them', () => {
const onShowRetryDetail = vi.fn()
const handleShowAgentOrToolLog = vi.fn()
const retryDetail = [createLogDetail('retry-1')]
const agentLog = [createAgentLog('tool-call')]
const { rerender } = render(
<ResultPanel
nodeInfo={createNodeInfo({
node_type: BlockEnum.Code,
retryDetail,
})}
status={NodeRunningStatus.Succeeded}
onShowRetryDetail={onShowRetryDetail}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'retry-trigger' }))
expect(onShowRetryDetail).toHaveBeenCalledWith(retryDetail)
rerender(
<ResultPanel
nodeInfo={createNodeInfo({
node_type: BlockEnum.Agent,
agentLog,
})}
status={NodeRunningStatus.Succeeded}
handleShowAgentOrToolLog={handleShowAgentOrToolLog}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'agent-trigger' }))
expect(handleShowAgentOrToolLog).toHaveBeenCalledWith(agentLog)
rerender(
<ResultPanel
nodeInfo={createNodeInfo({
node_type: BlockEnum.Tool,
agentLog,
})}
status={NodeRunningStatus.Succeeded}
handleShowAgentOrToolLog={handleShowAgentOrToolLog}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'agent-trigger' }))
expect(handleShowAgentOrToolLog).toHaveBeenLastCalledWith(agentLog)
})
it('should still render the output editor while the node is running even without outputs', () => {
render(
<ResultPanel
nodeInfo={createNodeInfo()}
inputs="{}"
status={NodeRunningStatus.Running}
/>,
)
expect(screen.getByText('COMMON.OUTPUT')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,199 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { getHoveredParallelId } from '../get-hovered-parallel-id'
import TracingPanel from '../tracing-panel'
const mockUseTranslation = vi.hoisted(() => vi.fn())
const mockFormatNodeList = vi.hoisted(() => vi.fn())
const mockUseLogs = vi.hoisted(() => vi.fn())
const mockNodePanel = vi.hoisted(() => vi.fn())
const mockSpecialResultPanel = vi.hoisted(() => vi.fn())
vi.mock('react-i18next', () => ({
useTranslation: () => mockUseTranslation(),
}))
vi.mock('@/app/components/workflow/run/utils/format-log', () => ({
__esModule: true,
default: (...args: unknown[]) => mockFormatNodeList(...args),
}))
vi.mock('../hooks', () => ({
useLogs: () => mockUseLogs(),
}))
vi.mock('../node', () => ({
__esModule: true,
default: (props: {
nodeInfo: { id: string }
}) => {
mockNodePanel(props)
return <div data-testid={`node-${props.nodeInfo.id}`}>{props.nodeInfo.id}</div>
},
}))
vi.mock('../special-result-panel', () => ({
__esModule: true,
default: (props: Record<string, unknown>) => {
mockSpecialResultPanel(props)
return <div data-testid="special-result-panel">special</div>
},
}))
describe('TracingPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseTranslation.mockReturnValue({
t: (key: string) => key,
})
mockUseLogs.mockReturnValue({
showSpecialResultPanel: false,
showRetryDetail: false,
setShowRetryDetailFalse: vi.fn(),
retryResultList: [],
handleShowRetryResultList: vi.fn(),
showIteratingDetail: false,
setShowIteratingDetailFalse: vi.fn(),
iterationResultList: [],
iterationResultDurationMap: {},
handleShowIterationResultList: vi.fn(),
showLoopingDetail: false,
setShowLoopingDetailFalse: vi.fn(),
loopResultList: [],
loopResultDurationMap: {},
loopResultVariableMap: {},
handleShowLoopResultList: vi.fn(),
agentOrToolLogItemStack: [],
agentOrToolLogListMap: {},
handleShowAgentOrToolLog: vi.fn(),
})
})
it('should render formatted nodes, preserve branch labels, and collapse parallel groups', () => {
mockFormatNodeList.mockReturnValue([
{
id: 'parallel-1',
parallelDetail: {
isParallelStartNode: true,
parallelTitle: 'Parallel Group',
children: [{
id: 'child-1',
title: 'Child Node',
parallelDetail: {
branchTitle: 'Branch A',
},
}],
},
},
{
id: 'node-2',
title: 'Standalone Node',
parallelDetail: {
branchTitle: 'Branch B',
},
},
])
const parentClick = vi.fn()
const { container } = render(
<div onClick={parentClick}>
<TracingPanel
list={[{ id: 'raw-node' } as never]}
className="custom-class"
hideNodeInfo
hideNodeProcessDetail
/>
</div>,
)
expect(screen.getByText('Parallel Group')).toBeInTheDocument()
expect(screen.getByText('Branch A')).toBeInTheDocument()
expect(screen.getByText('Branch B')).toBeInTheDocument()
expect(screen.getByTestId('node-child-1')).toBeInTheDocument()
expect(screen.getByTestId('node-node-2')).toBeInTheDocument()
fireEvent.click(container.querySelector('.py-2') as HTMLElement)
expect(parentClick).not.toHaveBeenCalled()
const hoverTarget = screen.getByText('Parallel Group').closest('[data-parallel-id="parallel-1"]') as HTMLElement
const nestedParallelTarget = document.createElement('div')
nestedParallelTarget.setAttribute('data-parallel-id', 'parallel-1')
const unrelatedTarget = document.createElement('div')
document.body.appendChild(nestedParallelTarget)
document.body.appendChild(unrelatedTarget)
fireEvent.mouseEnter(hoverTarget)
const sameParallelOut = new MouseEvent('mouseout', { bubbles: true })
Object.defineProperty(sameParallelOut, 'relatedTarget', { value: nestedParallelTarget })
hoverTarget.dispatchEvent(sameParallelOut)
const differentTargetOut = new MouseEvent('mouseout', { bubbles: true })
Object.defineProperty(differentTargetOut, 'relatedTarget', { value: unrelatedTarget })
hoverTarget.dispatchEvent(differentTargetOut)
fireEvent.mouseLeave(hoverTarget)
fireEvent.click(screen.getAllByRole('button')[0])
expect(container.querySelector('[data-parallel-id="parallel-1"] > div:last-child')).toHaveClass('hidden')
fireEvent.click(screen.getAllByRole('button')[0])
expect(container.querySelector('[data-parallel-id="parallel-1"] > div:last-child')).not.toHaveClass('hidden')
expect(mockNodePanel).toHaveBeenCalledWith(expect.objectContaining({
hideInfo: true,
hideProcessDetail: true,
}))
nestedParallelTarget.remove()
unrelatedTarget.remove()
})
it('should switch to the special result panel when the log state requests it', () => {
mockUseLogs.mockReturnValue({
showSpecialResultPanel: true,
showRetryDetail: true,
setShowRetryDetailFalse: vi.fn(),
retryResultList: [{ id: 'retry-1' }],
handleShowRetryResultList: vi.fn(),
showIteratingDetail: true,
setShowIteratingDetailFalse: vi.fn(),
iterationResultList: [[{ id: 'iter-1' }]],
iterationResultDurationMap: { 0: 1 },
handleShowIterationResultList: vi.fn(),
showLoopingDetail: true,
setShowLoopingDetailFalse: vi.fn(),
loopResultList: [[{ id: 'loop-1' }]],
loopResultDurationMap: { 0: 2 },
loopResultVariableMap: { 0: {} },
handleShowLoopResultList: vi.fn(),
agentOrToolLogItemStack: [{ id: 'agent-1' }],
agentOrToolLogListMap: { agent: [] },
handleShowAgentOrToolLog: vi.fn(),
})
render(<TracingPanel list={[]} />)
expect(screen.getByTestId('special-result-panel')).toBeInTheDocument()
expect(mockSpecialResultPanel).toHaveBeenCalledWith(expect.objectContaining({
showRetryDetail: true,
retryResultList: [{ id: 'retry-1' }],
showIteratingDetail: true,
showLoopingDetail: true,
agentOrToolLogItemStack: [{ id: 'agent-1' }],
}))
})
it('should resolve hovered parallel ids from related targets', () => {
const sameParallelTarget = document.createElement('div')
sameParallelTarget.setAttribute('data-parallel-id', 'parallel-1')
document.body.appendChild(sameParallelTarget)
const nestedChild = document.createElement('span')
sameParallelTarget.appendChild(nestedChild)
const unrelatedTarget = document.createElement('div')
expect(getHoveredParallelId(nestedChild)).toBe('parallel-1')
expect(getHoveredParallelId(unrelatedTarget)).toBeNull()
expect(getHoveredParallelId(null)).toBeNull()
sameParallelTarget.remove()
})
})

View File

@ -0,0 +1,10 @@
export const getHoveredParallelId = (relatedTarget: EventTarget | null) => {
const element = relatedTarget as Element | null
if (element && 'closest' in element) {
const closestParallel = element.closest('[data-parallel-id]')
if (closestParallel)
return closestParallel.getAttribute('data-parallel-id')
}
return null
}

View File

@ -1,10 +1,6 @@
'use client'
import type { FC } from 'react'
import type { NodeTracing } from '@/types/workflow'
import {
RiArrowDownSLine,
RiMenu4Line,
} from '@remixicon/react'
import * as React from 'react'
import {
useCallback,
@ -14,6 +10,7 @@ import {
import { useTranslation } from 'react-i18next'
import formatNodeList from '@/app/components/workflow/run/utils/format-log'
import { cn } from '@/utils/classnames'
import { getHoveredParallelId } from './get-hovered-parallel-id'
import { useLogs } from './hooks'
import NodePanel from './node'
import SpecialResultPanel from './special-result-panel'
@ -54,18 +51,7 @@ const TracingPanel: FC<TracingPanelProps> = ({
}, [])
const handleParallelMouseLeave = useCallback((e: React.MouseEvent) => {
const relatedTarget = e.relatedTarget as Element | null
if (relatedTarget && 'closest' in relatedTarget) {
const closestParallel = relatedTarget.closest('[data-parallel-id]')
if (closestParallel)
setHoveredParallel(closestParallel.getAttribute('data-parallel-id'))
else
setHoveredParallel(null)
}
else {
setHoveredParallel(null)
}
setHoveredParallel(getHoveredParallelId(e.relatedTarget))
}, [])
const {
@ -130,7 +116,9 @@ const TracingPanel: FC<TracingPanelProps> = ({
isHovered ? 'rounded border-components-button-primary-border bg-components-button-primary-bg text-text-primary-on-surface' : 'text-text-secondary hover:text-text-primary',
)}
>
{isHovered ? <RiArrowDownSLine className="h-3 w-3" /> : <RiMenu4Line className="h-3 w-3 text-text-tertiary" />}
{isHovered
? <span aria-hidden className="i-ri-arrow-down-s-line h-3 w-3" />
: <span aria-hidden className="i-ri-menu-4-line h-3 w-3 text-text-tertiary" />}
</button>
<div className="flex items-center text-text-secondary system-xs-semibold-uppercase">
<span>{parallelDetail.parallelTitle}</span>

View File

@ -0,0 +1,199 @@
import type { NodeTracing } from '@/types/workflow'
import { BlockEnum } from '@/app/components/workflow/types'
import formatToTracingNodeList from '../index'
const mockFormatAgentNode = vi.hoisted(() => vi.fn())
const mockFormatHumanInputNode = vi.hoisted(() => vi.fn())
const mockFormatRetryNode = vi.hoisted(() => vi.fn())
const mockAddChildrenToLoopNode = vi.hoisted(() => vi.fn())
const mockAddChildrenToIterationNode = vi.hoisted(() => vi.fn())
const mockFormatParallelNode = vi.hoisted(() => vi.fn())
vi.mock('../agent', () => ({
__esModule: true,
default: (...args: unknown[]) => mockFormatAgentNode(...args),
}))
vi.mock('../human-input', () => ({
__esModule: true,
default: (...args: unknown[]) => mockFormatHumanInputNode(...args),
}))
vi.mock('../retry', () => ({
__esModule: true,
default: (...args: unknown[]) => mockFormatRetryNode(...args),
}))
vi.mock('../loop', () => ({
addChildrenToLoopNode: (...args: unknown[]) => mockAddChildrenToLoopNode(...args),
}))
vi.mock('../iteration', () => ({
addChildrenToIterationNode: (...args: unknown[]) => mockAddChildrenToIterationNode(...args),
}))
vi.mock('../parallel', () => ({
__esModule: true,
default: (...args: unknown[]) => mockFormatParallelNode(...args),
}))
const createTrace = (overrides: Partial<NodeTracing> = {}): NodeTracing => ({
id: overrides.id ?? overrides.node_id ?? 'node-1',
index: overrides.index ?? 0,
predecessor_node_id: '',
node_id: overrides.node_id ?? 'node-1',
node_type: overrides.node_type ?? BlockEnum.Tool,
title: overrides.title ?? 'Node',
inputs: {},
inputs_truncated: false,
process_data: {},
process_data_truncated: false,
outputs_truncated: false,
status: overrides.status ?? 'succeeded',
error: overrides.error,
elapsed_time: 1,
execution_metadata: overrides.execution_metadata ?? {
total_tokens: 0,
total_price: 0,
currency: 'USD',
},
metadata: {
iterator_length: 0,
iterator_index: 0,
loop_length: 0,
loop_index: 0,
},
created_at: 0,
created_by: {
id: 'user-1',
name: 'User',
email: 'user@example.com',
},
finished_at: 1,
})
const createExecutionMetadata = (overrides: Partial<NonNullable<NodeTracing['execution_metadata']>> = {}): NonNullable<NodeTracing['execution_metadata']> => ({
total_tokens: 0,
total_price: 0,
currency: 'USD',
...overrides,
})
describe('formatToTracingNodeList', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFormatAgentNode.mockImplementation((list: NodeTracing[]) => list)
mockFormatHumanInputNode.mockImplementation((list: NodeTracing[]) => list)
mockFormatRetryNode.mockImplementation((list: NodeTracing[]) => list)
mockAddChildrenToLoopNode.mockImplementation((item: NodeTracing, children: NodeTracing[]) => ({
...item,
loopChildren: children.map(child => child.node_id),
details: [[{ id: 'loop-detail-row' }]],
}))
mockAddChildrenToIterationNode.mockImplementation((item: NodeTracing, children: NodeTracing[]) => ({
...item,
iterationChildren: children.map(child => child.node_id),
details: [[{ id: 'iteration-detail-row' }]],
}))
mockFormatParallelNode.mockImplementation((list: unknown[]) =>
list.map(item => ({
...(item as Record<string, unknown>),
parallelFormatted: true,
})))
})
it('should sort the input by index and run the formatter pipeline in order', () => {
const t = vi.fn((key: string) => key)
const traces = [
createTrace({ id: 'b', node_id: 'b', title: 'B', index: 2 }),
createTrace({ id: 'a', node_id: 'a', title: 'A', index: 0 }),
createTrace({ id: 'c', node_id: 'c', title: 'C', index: 1 }),
]
const result = formatToTracingNodeList(traces, t)
expect(mockFormatAgentNode).toHaveBeenCalledWith([
expect.objectContaining({ node_id: 'a' }),
expect.objectContaining({ node_id: 'c' }),
expect.objectContaining({ node_id: 'b' }),
])
expect(mockFormatHumanInputNode).toHaveBeenCalledWith(mockFormatAgentNode.mock.results[0].value)
expect(mockFormatRetryNode).toHaveBeenCalledWith(mockFormatHumanInputNode.mock.results[0].value)
expect(mockFormatParallelNode).toHaveBeenLastCalledWith(expect.any(Array), t)
expect(result).toEqual([
expect.objectContaining({ node_id: 'a', parallelFormatted: true }),
expect.objectContaining({ node_id: 'c', parallelFormatted: true }),
expect.objectContaining({ node_id: 'b', parallelFormatted: true }),
])
})
it('should collapse loop and iteration children into parent nodes and propagate child failures', () => {
const t = vi.fn((key: string) => key)
const loopParent = createTrace({
id: 'loop-parent',
node_id: 'loop-parent',
node_type: BlockEnum.Loop,
index: 0,
})
const loopChild = createTrace({
id: 'loop-child',
node_id: 'loop-child',
index: 1,
status: 'failed',
error: 'loop child failed',
execution_metadata: createExecutionMetadata({ loop_id: 'loop-parent' }),
})
const iterationParent = createTrace({
id: 'iteration-parent',
node_id: 'iteration-parent',
node_type: BlockEnum.Iteration,
index: 2,
})
const iterationChild = createTrace({
id: 'iteration-child',
node_id: 'iteration-child',
index: 3,
status: 'failed',
error: 'iteration child failed',
execution_metadata: createExecutionMetadata({ iteration_id: 'iteration-parent' }),
})
const result = formatToTracingNodeList([
loopParent,
loopChild,
iterationParent,
iterationChild,
], t)
expect(mockAddChildrenToLoopNode).toHaveBeenCalledWith(
expect.objectContaining({
node_id: 'loop-parent',
status: 'failed',
error: 'loop child failed',
}),
[expect.objectContaining({ node_id: 'loop-child' })],
)
expect(mockAddChildrenToIterationNode).toHaveBeenCalledWith(
expect.objectContaining({
node_id: 'iteration-parent',
status: 'failed',
error: 'iteration child failed',
}),
[expect.objectContaining({ node_id: 'iteration-child' })],
)
expect(mockFormatParallelNode).toHaveBeenCalledTimes(3)
expect(result).toEqual([
expect.objectContaining({
node_id: 'loop-parent',
loopChildren: ['loop-child'],
parallelFormatted: true,
}),
expect.objectContaining({
node_id: 'iteration-parent',
iterationChildren: ['iteration-child'],
parallelFormatted: true,
}),
])
})
})

View File

@ -1,5 +1,5 @@
import type { FC, ReactElement } from 'react'
import type { I18nKeysByPrefix } from '@/types/i18n'
import type { ComponentType } from 'react'
import type { Node } from './types'
import {
RiAlignBottom,
RiAlignCenter,
@ -8,413 +8,350 @@ import {
RiAlignRight,
RiAlignTop,
} from '@remixicon/react'
import { useClickAway } from 'ahooks'
import { produce } from 'immer'
import {
memo,
useCallback,
useEffect,
useMemo,
useRef,
} from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useReactFlowStore } from 'reactflow'
import { shallow } from 'zustand/shallow'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
import { useCollaborativeWorkflow } from '@/app/components/workflow/hooks/use-collaborative-workflow'
import { useStore as useReactFlowStore, useStoreApi } from 'reactflow'
import {
ContextMenu,
ContextMenuContent,
ContextMenuGroup,
ContextMenuGroupLabel,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/app/components/base/ui/context-menu'
import { useNodesInteractions, useNodesReadOnly, useNodesSyncDraft } from './hooks'
import { useSelectionInteractions } from './hooks/use-selection-interactions'
import { useWorkflowHistory, WorkflowHistoryEvent } from './hooks/use-workflow-history'
import ShortcutsName from './shortcuts-name'
import { useStore, useWorkflowStore } from './store'
enum AlignType {
Left = 'left',
Center = 'center',
Right = 'right',
Top = 'top',
Middle = 'middle',
Bottom = 'bottom',
DistributeHorizontal = 'distributeHorizontal',
DistributeVertical = 'distributeVertical',
const AlignType = {
Bottom: 'bottom',
Center: 'center',
DistributeHorizontal: 'distributeHorizontal',
DistributeVertical: 'distributeVertical',
Left: 'left',
Middle: 'middle',
Right: 'right',
Top: 'top',
} as const
type AlignTypeValue = (typeof AlignType)[keyof typeof AlignType]
type SelectionMenuPosition = {
left: number
top: number
}
type AlignButtonConfig = {
type: AlignType
icon: ReactElement
labelKey: I18nKeysByPrefix<'workflow', 'operator.'>
type ContainerRect = Pick<DOMRect, 'width' | 'height'>
type AlignBounds = {
minX: number
maxX: number
minY: number
maxY: number
}
type AlignButtonProps = {
config: AlignButtonConfig
label: string
onClick: (type: AlignType) => void
position?: 'top' | 'bottom' | 'left' | 'right'
type MenuItem = {
alignType: AlignTypeValue
icon: ComponentType<{ className?: string }>
iconClassName?: string
translationKey: string
}
const AlignButton: FC<AlignButtonProps> = ({ config, label, onClick, position = 'bottom' }) => {
return (
<Tooltip>
<TooltipTrigger
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-text-secondary hover:bg-state-base-hover"
onClick={() => onClick(config.type)}
>
{config.icon}
</TooltipTrigger>
<TooltipContent placement={position}>{label}</TooltipContent>
</Tooltip>
)
type MenuSection = {
titleKey: string
items: MenuItem[]
}
const ALIGN_BUTTONS: AlignButtonConfig[] = [
{ type: AlignType.Left, icon: <RiAlignLeft className="h-4 w-4" />, labelKey: 'alignLeft' },
{ type: AlignType.Center, icon: <RiAlignCenter className="h-4 w-4" />, labelKey: 'alignCenter' },
{ type: AlignType.Right, icon: <RiAlignRight className="h-4 w-4" />, labelKey: 'alignRight' },
{ type: AlignType.DistributeHorizontal, icon: <RiAlignJustify className="h-4 w-4" />, labelKey: 'distributeHorizontal' },
{ type: AlignType.Top, icon: <RiAlignTop className="h-4 w-4" />, labelKey: 'alignTop' },
{ type: AlignType.Middle, icon: <RiAlignCenter className="h-4 w-4 rotate-90" />, labelKey: 'alignMiddle' },
{ type: AlignType.Bottom, icon: <RiAlignBottom className="h-4 w-4" />, labelKey: 'alignBottom' },
{ type: AlignType.DistributeVertical, icon: <RiAlignJustify className="h-4 w-4 rotate-90" />, labelKey: 'distributeVertical' },
const MENU_WIDTH = 240
const MENU_HEIGHT = 380
const menuSections: MenuSection[] = [
{
titleKey: 'operator.vertical',
items: [
{ alignType: AlignType.Top, icon: RiAlignTop, translationKey: 'operator.alignTop' },
{ alignType: AlignType.Middle, icon: RiAlignCenter, iconClassName: 'rotate-90', translationKey: 'operator.alignMiddle' },
{ alignType: AlignType.Bottom, icon: RiAlignBottom, translationKey: 'operator.alignBottom' },
{ alignType: AlignType.DistributeVertical, icon: RiAlignJustify, iconClassName: 'rotate-90', translationKey: 'operator.distributeVertical' },
],
},
{
titleKey: 'operator.horizontal',
items: [
{ alignType: AlignType.Left, icon: RiAlignLeft, translationKey: 'operator.alignLeft' },
{ alignType: AlignType.Center, icon: RiAlignCenter, translationKey: 'operator.alignCenter' },
{ alignType: AlignType.Right, icon: RiAlignRight, translationKey: 'operator.alignRight' },
{ alignType: AlignType.DistributeHorizontal, icon: RiAlignJustify, translationKey: 'operator.distributeHorizontal' },
],
},
]
const getMenuPosition = (
selectionMenu: SelectionMenuPosition | undefined,
containerRect?: ContainerRect | null,
) => {
if (!selectionMenu)
return { left: 0, top: 0 }
let { left, top } = selectionMenu
if (containerRect) {
if (left + MENU_WIDTH > containerRect.width)
left = left - MENU_WIDTH
if (top + MENU_HEIGHT > containerRect.height)
top = top - MENU_HEIGHT
left = Math.max(0, left)
top = Math.max(0, top)
}
return { left, top }
}
const getAlignableNodes = (nodes: Node[], selectedNodes: Node[]) => {
const selectedNodeIds = new Set(selectedNodes.map(node => node.id))
const childNodeIds = new Set<string>()
nodes.forEach((node) => {
if (!node.data._children?.length || !selectedNodeIds.has(node.id))
return
node.data._children.forEach((child) => {
childNodeIds.add(child.nodeId)
})
})
return nodes.filter(node => selectedNodeIds.has(node.id) && !childNodeIds.has(node.id))
}
const getAlignBounds = (nodes: Node[]): AlignBounds | null => {
const validNodes = nodes.filter(node => node.width && node.height)
if (validNodes.length <= 1)
return null
return validNodes.reduce<AlignBounds>((bounds, node) => {
const width = node.width!
const height = node.height!
return {
minX: Math.min(bounds.minX, node.position.x),
maxX: Math.max(bounds.maxX, node.position.x + width),
minY: Math.min(bounds.minY, node.position.y),
maxY: Math.max(bounds.maxY, node.position.y + height),
}
}, {
minX: Number.MAX_SAFE_INTEGER,
maxX: Number.MIN_SAFE_INTEGER,
minY: Number.MAX_SAFE_INTEGER,
maxY: Number.MIN_SAFE_INTEGER,
})
}
const alignNodePosition = (
currentNode: Node,
nodeToAlign: Node,
alignType: AlignTypeValue,
bounds: AlignBounds,
) => {
const width = nodeToAlign.width ?? 0
const height = nodeToAlign.height ?? 0
switch (alignType) {
case AlignType.Left:
currentNode.position.x = bounds.minX
if (currentNode.positionAbsolute)
currentNode.positionAbsolute.x = bounds.minX
break
case AlignType.Center: {
const centerX = bounds.minX + (bounds.maxX - bounds.minX) / 2 - width / 2
currentNode.position.x = centerX
if (currentNode.positionAbsolute)
currentNode.positionAbsolute.x = centerX
break
}
case AlignType.Right: {
const rightX = bounds.maxX - width
currentNode.position.x = rightX
if (currentNode.positionAbsolute)
currentNode.positionAbsolute.x = rightX
break
}
case AlignType.Top:
currentNode.position.y = bounds.minY
if (currentNode.positionAbsolute)
currentNode.positionAbsolute.y = bounds.minY
break
case AlignType.Middle: {
const middleY = bounds.minY + (bounds.maxY - bounds.minY) / 2 - height / 2
currentNode.position.y = middleY
if (currentNode.positionAbsolute)
currentNode.positionAbsolute.y = middleY
break
}
case AlignType.Bottom: {
const bottomY = Math.round(bounds.maxY - height)
currentNode.position.y = bottomY
if (currentNode.positionAbsolute)
currentNode.positionAbsolute.y = bottomY
break
}
}
}
const distributeNodes = (
nodesToAlign: Node[],
nodes: Node[],
alignType: AlignTypeValue,
) => {
const isHorizontal = alignType === AlignType.DistributeHorizontal
const sortedNodes = [...nodesToAlign].sort((a, b) =>
isHorizontal ? a.position.x - b.position.x : a.position.y - b.position.y)
if (sortedNodes.length < 3)
return null
const firstNode = sortedNodes[0]
const lastNode = sortedNodes[sortedNodes.length - 1]
const totalGap = isHorizontal
? lastNode.position.x + (lastNode.width || 0) - firstNode.position.x
: lastNode.position.y + (lastNode.height || 0) - firstNode.position.y
const fixedSpace = sortedNodes.reduce((sum, node) =>
sum + (isHorizontal ? (node.width || 0) : (node.height || 0)), 0)
const spacing = (totalGap - fixedSpace) / (sortedNodes.length - 1)
if (spacing <= 0)
return null
return produce(nodes, (draft) => {
let currentPosition = isHorizontal
? firstNode.position.x + (firstNode.width || 0)
: firstNode.position.y + (firstNode.height || 0)
for (let index = 1; index < sortedNodes.length - 1; index++) {
const nodeToAlign = sortedNodes[index]
const currentNode = draft.find(node => node.id === nodeToAlign.id)
if (!currentNode)
continue
if (isHorizontal) {
const nextX = currentPosition + spacing
currentNode.position.x = nextX
if (currentNode.positionAbsolute)
currentNode.positionAbsolute.x = nextX
currentPosition = nextX + (nodeToAlign.width || 0)
}
else {
const nextY = currentPosition + spacing
currentNode.position.y = nextY
if (currentNode.positionAbsolute)
currentNode.positionAbsolute.y = nextY
currentPosition = nextY + (nodeToAlign.height || 0)
}
}
})
}
const SelectionContextmenu = () => {
const { t } = useTranslation()
const ref = useRef(null)
const { getNodesReadOnly, nodesReadOnly } = useNodesReadOnly()
const { handleSelectionContextmenuCancel } = useSelectionInteractions()
const {
handleNodesCopy,
handleNodesDuplicate,
handleNodesDelete,
handleNodesDuplicate,
} = useNodesInteractions()
const selectionMenu = useStore(s => s.selectionMenu)
// Access React Flow methods
const store = useStoreApi()
const workflowStore = useWorkflowStore()
const collaborativeWorkflow = useCollaborativeWorkflow()
const selectedNodeIds = useReactFlowStore((state) => {
const ids = state.getNodes().filter(node => node.selected).map(node => node.id)
ids.sort()
return ids
}, shallow)
const selectedNodes = useReactFlowStore(state =>
state.getNodes().filter(node => node.selected),
)
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const { saveStateToHistory } = useWorkflowHistory()
const menuRef = useRef<HTMLDivElement>(null)
const menuPosition = useMemo(() => {
if (!selectionMenu)
return { left: 0, top: 0 }
let left = selectionMenu.left
let top = selectionMenu.top
const container = document.querySelector('#workflow-container')
if (container) {
const { width: containerWidth, height: containerHeight } = container.getBoundingClientRect()
const menuWidth = 244
const estimatedMenuHeight = 203
if (left + menuWidth > containerWidth)
left = left - menuWidth
if (top + estimatedMenuHeight > containerHeight)
top = top - estimatedMenuHeight
left = Math.max(0, left)
top = Math.max(0, top)
}
return { left, top }
return getMenuPosition(selectionMenu, container?.getBoundingClientRect())
}, [selectionMenu])
useClickAway(() => {
handleSelectionContextmenuCancel()
}, ref)
useEffect(() => {
if (selectionMenu && selectedNodeIds.length <= 1)
if (selectionMenu && selectedNodes.length <= 1)
handleSelectionContextmenuCancel()
}, [selectionMenu, selectedNodeIds.length, handleSelectionContextmenuCancel])
}, [selectionMenu, selectedNodes.length, handleSelectionContextmenuCancel])
// Handle align nodes logic
const handleAlignNode = useCallback((currentNode: any, nodeToAlign: any, alignType: AlignType, minX: number, maxX: number, minY: number, maxY: number) => {
const width = nodeToAlign.width
const height = nodeToAlign.height
// Calculate new positions based on alignment type
switch (alignType) {
case AlignType.Left:
// For left alignment, align left edge of each node to minX
currentNode.position.x = minX
if (currentNode.positionAbsolute)
currentNode.positionAbsolute.x = minX
break
case AlignType.Center: {
// For center alignment, center each node horizontally in the selection bounds
const centerX = minX + (maxX - minX) / 2 - width / 2
currentNode.position.x = centerX
if (currentNode.positionAbsolute)
currentNode.positionAbsolute.x = centerX
break
}
case AlignType.Right: {
// For right alignment, align right edge of each node to maxX
const rightX = maxX - width
currentNode.position.x = rightX
if (currentNode.positionAbsolute)
currentNode.positionAbsolute.x = rightX
break
}
case AlignType.Top: {
// For top alignment, align top edge of each node to minY
currentNode.position.y = minY
if (currentNode.positionAbsolute)
currentNode.positionAbsolute.y = minY
break
}
case AlignType.Middle: {
// For middle alignment, center each node vertically in the selection bounds
const middleY = minY + (maxY - minY) / 2 - height / 2
currentNode.position.y = middleY
if (currentNode.positionAbsolute)
currentNode.positionAbsolute.y = middleY
break
}
case AlignType.Bottom: {
// For bottom alignment, align bottom edge of each node to maxY
const newY = Math.round(maxY - height)
currentNode.position.y = newY
if (currentNode.positionAbsolute)
currentNode.positionAbsolute.y = newY
break
}
}
}, [])
// Handle distribute nodes logic
const handleDistributeNodes = useCallback((nodesToAlign: any[], nodes: any[], alignType: AlignType) => {
// Sort nodes appropriately
const sortedNodes = [...nodesToAlign].sort((a, b) => {
if (alignType === AlignType.DistributeHorizontal) {
// Sort by left position for horizontal distribution
return a.position.x - b.position.x
}
else {
// Sort by top position for vertical distribution
return a.position.y - b.position.y
}
})
if (sortedNodes.length < 3)
return null // Need at least 3 nodes for distribution
let totalGap = 0
let fixedSpace = 0
if (alignType === AlignType.DistributeHorizontal) {
// Fixed positions - first node's left edge and last node's right edge
const firstNodeLeft = sortedNodes[0].position.x
const lastNodeRight = sortedNodes[sortedNodes.length - 1].position.x + (sortedNodes[sortedNodes.length - 1].width || 0)
// Total available space
totalGap = lastNodeRight - firstNodeLeft
// Space occupied by nodes themselves
fixedSpace = sortedNodes.reduce((sum, node) => sum + (node.width || 0), 0)
}
else {
// Fixed positions - first node's top edge and last node's bottom edge
const firstNodeTop = sortedNodes[0].position.y
const lastNodeBottom = sortedNodes[sortedNodes.length - 1].position.y + (sortedNodes[sortedNodes.length - 1].height || 0)
// Total available space
totalGap = lastNodeBottom - firstNodeTop
// Space occupied by nodes themselves
fixedSpace = sortedNodes.reduce((sum, node) => sum + (node.height || 0), 0)
}
// Available space for gaps
const availableSpace = totalGap - fixedSpace
// Calculate even spacing between node edges
const spacing = availableSpace / (sortedNodes.length - 1)
if (spacing <= 0)
return null // Nodes are overlapping, can't distribute evenly
return produce(nodes, (draft) => {
// Keep first node fixed, position others with even gaps
let currentPosition
if (alignType === AlignType.DistributeHorizontal) {
// Start from first node's right edge
currentPosition = sortedNodes[0].position.x + (sortedNodes[0].width || 0)
}
else {
// Start from first node's bottom edge
currentPosition = sortedNodes[0].position.y + (sortedNodes[0].height || 0)
}
// Skip first node (index 0), it stays in place
for (let i = 1; i < sortedNodes.length - 1; i++) {
const nodeToAlign = sortedNodes[i]
const currentNode = draft.find(n => n.id === nodeToAlign.id)
if (!currentNode)
continue
if (alignType === AlignType.DistributeHorizontal) {
// Position = previous right edge + spacing
const newX: number = currentPosition + spacing
currentNode.position.x = newX
if (currentNode.positionAbsolute)
currentNode.positionAbsolute.x = newX
// Update for next iteration - current node's right edge
currentPosition = newX + (nodeToAlign.width || 0)
}
else {
// Position = previous bottom edge + spacing
const newY: number = currentPosition + spacing
currentNode.position.y = newY
if (currentNode.positionAbsolute)
currentNode.positionAbsolute.y = newY
// Update for next iteration - current node's bottom edge
currentPosition = newY + (nodeToAlign.height || 0)
}
}
})
}, [])
const handleAlignNodes = useCallback((alignType: AlignType) => {
if (getNodesReadOnly() || selectedNodeIds.length <= 1) {
const handleAlignNodes = useCallback((alignType: AlignTypeValue) => {
if (getNodesReadOnly() || selectedNodes.length <= 1) {
handleSelectionContextmenuCancel()
return
}
// Disable node animation state - same as handleNodeDragStart
workflowStore.setState({ nodeAnimation: false })
// Get all current nodes
const { nodes, setNodes } = collaborativeWorkflow.getState()
// Find container nodes and their children
// Container nodes (like Iteration and Loop) have child nodes that should not be aligned independently
// when the container is selected. This prevents child nodes from being moved outside their containers.
const childNodeIds = new Set<string>()
nodes.forEach((node) => {
// Check if this is a container node (Iteration or Loop)
if (node.data._children && node.data._children.length > 0) {
// If container node is selected, add its children to the exclusion set
if (selectedNodeIds.includes(node.id)) {
// Add all its children to the childNodeIds set
node.data._children.forEach((child: { nodeId: string, nodeType: string }) => {
childNodeIds.add(child.nodeId)
})
}
}
})
// Filter out child nodes from the alignment operation
// Only align nodes that are selected AND are not children of container nodes
// This ensures container nodes can be aligned while their children stay in the same relative position
const nodesToAlign = nodes.filter(node =>
selectedNodeIds.includes(node.id) && !childNodeIds.has(node.id))
const nodes = store.getState().getNodes()
const nodesToAlign = getAlignableNodes(nodes, selectedNodes)
if (nodesToAlign.length <= 1) {
handleSelectionContextmenuCancel()
return
}
// Calculate node boundaries for alignment
let minX = Number.MAX_SAFE_INTEGER
let maxX = Number.MIN_SAFE_INTEGER
let minY = Number.MAX_SAFE_INTEGER
let maxY = Number.MIN_SAFE_INTEGER
const bounds = getAlignBounds(nodesToAlign)
if (!bounds) {
handleSelectionContextmenuCancel()
return
}
// Calculate boundaries of selected nodes
const validNodes = nodesToAlign.filter(node => node.width && node.height)
validNodes.forEach((node) => {
const width = node.width!
const height = node.height!
minX = Math.min(minX, node.position.x)
maxX = Math.max(maxX, node.position.x + width)
minY = Math.min(minY, node.position.y)
maxY = Math.max(maxY, node.position.y + height)
})
// Handle distribute nodes logic
if (alignType === AlignType.DistributeHorizontal || alignType === AlignType.DistributeVertical) {
const distributeNodes = handleDistributeNodes(nodesToAlign, nodes, alignType)
if (distributeNodes) {
// Apply node distribution updates
setNodes(distributeNodes)
const distributedNodes = distributeNodes(nodesToAlign, nodes, alignType)
if (distributedNodes) {
store.getState().setNodes(distributedNodes)
handleSelectionContextmenuCancel()
// Clear guide lines
const { setHelpLineHorizontal, setHelpLineVertical } = workflowStore.getState()
setHelpLineHorizontal()
setHelpLineVertical()
// Sync workflow draft
handleSyncWorkflowDraft()
// Save to history
saveStateToHistory(WorkflowHistoryEvent.NodeDragStop)
return // End function execution
return
}
}
const newNodes = produce(nodes, (draft) => {
// Iterate through all selected nodes
const validNodesToAlign = nodesToAlign.filter(node => node.width && node.height)
validNodesToAlign.forEach((nodeToAlign) => {
// Find the corresponding node in draft - consistent with handleNodeDrag
const currentNode = draft.find(n => n.id === nodeToAlign.id)
if (!currentNode)
return
// Use the extracted alignment function
handleAlignNode(currentNode, nodeToAlign, alignType, minX, maxX, minY, maxY)
alignNodePosition(currentNode, nodeToAlign, alignType, bounds)
})
})
// Apply node position updates - consistent with handleNodeDrag and handleNodeDragStop
try {
// Directly use setNodes to update nodes - consistent with handleNodeDrag
setNodes(newNodes)
// Close popup
store.getState().setNodes(newNodes)
handleSelectionContextmenuCancel()
// Clear guide lines - consistent with handleNodeDragStop
const { setHelpLineHorizontal, setHelpLineVertical } = workflowStore.getState()
setHelpLineHorizontal()
setHelpLineVertical()
// Sync workflow draft - consistent with handleNodeDragStop
handleSyncWorkflowDraft()
// Save to history - consistent with handleNodeDragStop
saveStateToHistory(WorkflowHistoryEvent.NodeDragStop)
}
catch (err) {
console.error('Failed to update nodes:', err)
}
}, [collaborativeWorkflow, workflowStore, selectedNodeIds, getNodesReadOnly, handleSyncWorkflowDraft, saveStateToHistory, handleSelectionContextmenuCancel, handleAlignNode, handleDistributeNodes])
}, [store, workflowStore, selectedNodes, getNodesReadOnly, handleSyncWorkflowDraft, saveStateToHistory, handleSelectionContextmenuCancel])
if (!selectionMenu)
return null
@ -422,64 +359,77 @@ const SelectionContextmenu = () => {
return (
<div
className="absolute z-[9]"
data-testid="selection-contextmenu"
style={{
left: menuPosition.left,
top: menuPosition.top,
}}
ref={ref}
>
<div ref={menuRef} className="w-[244px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl">
{!nodesReadOnly && (
<>
<div className="p-1">
<div
className="flex h-8 cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
<ContextMenu
open
onOpenChange={(open) => {
if (!open)
handleSelectionContextmenuCancel()
}}
>
<ContextMenuTrigger>
<span aria-hidden className="block size-px opacity-0" />
</ContextMenuTrigger>
<ContextMenuContent popupClassName="w-[240px]">
{!nodesReadOnly && (
<ContextMenuGroup>
<ContextMenuItem
data-testid="selection-contextmenu-item-copy"
onClick={() => {
handleNodesCopy()
handleSelectionContextmenuCancel()
}}
>
{t('common.copy', { ns: 'workflow' })}
<ShortcutsName keys={['ctrl', 'c']} />
</div>
<div
className="flex h-8 cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
</ContextMenuItem>
<ContextMenuItem
data-testid="selection-contextmenu-item-duplicate"
onClick={() => {
handleNodesDuplicate()
handleSelectionContextmenuCancel()
}}
>
{t('common.duplicate', { ns: 'workflow' })}
<ShortcutsName keys={['ctrl', 'd']} />
</div>
</div>
<div className="h-px bg-divider-regular" />
<div className="p-1">
<div
className="flex h-8 cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-destructive-hover hover:text-text-destructive"
</ContextMenuItem>
<ContextMenuItem
data-testid="selection-contextmenu-item-delete"
onClick={() => {
handleNodesDelete()
handleSelectionContextmenuCancel()
}}
>
{t('operation.delete', { ns: 'common' })}
<ShortcutsName keys={['del']} />
</div>
</div>
<div className="h-px bg-divider-regular" />
</>
)}
<div className="flex items-center justify-between p-1">
{ALIGN_BUTTONS.map(config => (
<AlignButton
key={config.type}
config={config}
label={t(`operator.${config.labelKey}`, { ns: 'workflow' })}
onClick={handleAlignNodes}
/>
</ContextMenuItem>
</ContextMenuGroup>
)}
{menuSections.map((section, sectionIndex) => (
<ContextMenuGroup key={section.titleKey}>
{(sectionIndex > 0 || !nodesReadOnly) && <ContextMenuSeparator />}
<ContextMenuGroupLabel>
{t(section.titleKey, { defaultValue: section.titleKey, ns: 'workflow' })}
</ContextMenuGroupLabel>
{section.items.map((item) => {
const Icon = item.icon
return (
<ContextMenuItem
key={item.alignType}
data-testid={`selection-contextmenu-item-${item.alignType}`}
onClick={() => handleAlignNodes(item.alignType)}
>
<Icon className={`h-4 w-4 ${item.iconClassName ?? ''}`.trim()} />
{t(item.translationKey, { defaultValue: item.translationKey, ns: 'workflow' })}
</ContextMenuItem>
)
})}
</ContextMenuGroup>
))}
</div>
</div>
</ContextMenuContent>
</ContextMenu>
</div>
)
}

View File

@ -0,0 +1,111 @@
import type { CommonNodeType, Node } from './types'
import { load as yamlLoad } from 'js-yaml'
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
import { DSLImportStatus } from '@/models/app'
import { AppModeEnum } from '@/types/app'
import { BlockEnum, SupportUploadFileTypes } from './types'
type ParsedDSL = {
workflow?: {
graph?: {
nodes?: Array<Node<CommonNodeType>>
}
}
}
type WorkflowFileUploadFeatures = {
enabled?: boolean
allowed_file_types?: SupportUploadFileTypes[]
allowed_file_extensions?: string[]
allowed_file_upload_methods?: string[]
number_limits?: number
image?: {
enabled?: boolean
number_limits?: number
transfer_methods?: string[]
}
}
type WorkflowFeatures = {
file_upload?: WorkflowFileUploadFeatures
opening_statement?: string
suggested_questions?: string[]
suggested_questions_after_answer?: { enabled: boolean }
speech_to_text?: { enabled: boolean }
text_to_speech?: { enabled: boolean }
retriever_resource?: { enabled: boolean }
sensitive_word_avoidance?: { enabled: boolean }
}
type ImportNotificationPayload = {
type: 'success' | 'warning'
message: string
children?: string
}
export const getInvalidNodeTypes = (mode?: AppModeEnum) => {
if (mode === AppModeEnum.ADVANCED_CHAT) {
return [
BlockEnum.End,
BlockEnum.TriggerWebhook,
BlockEnum.TriggerSchedule,
BlockEnum.TriggerPlugin,
]
}
return [BlockEnum.Answer]
}
export const validateDSLContent = (content: string, mode?: AppModeEnum) => {
try {
const data = yamlLoad(content) as ParsedDSL
const nodes = data?.workflow?.graph?.nodes ?? []
const invalidNodes = getInvalidNodeTypes(mode)
return !nodes.some((node: Node<CommonNodeType>) => invalidNodes.includes(node?.data?.type))
}
catch {
return false
}
}
export const isImportCompleted = (status: DSLImportStatus) => {
return status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS
}
export const getImportNotificationPayload = (status: DSLImportStatus, t: (key: string, options?: Record<string, unknown>) => string): ImportNotificationPayload => {
return {
type: status === DSLImportStatus.COMPLETED ? 'success' : 'warning',
message: t(status === DSLImportStatus.COMPLETED ? 'common.importSuccess' : 'common.importWarning', { ns: 'workflow' }),
children: status === DSLImportStatus.COMPLETED_WITH_WARNINGS
? t('common.importWarningDetails', { ns: 'workflow' })
: undefined,
}
}
export const normalizeWorkflowFeatures = (features?: WorkflowFeatures) => {
const resolvedFeatures = features ?? {}
return {
file: {
image: {
enabled: !!resolvedFeatures.file_upload?.image?.enabled,
number_limits: resolvedFeatures.file_upload?.image?.number_limits || 3,
transfer_methods: resolvedFeatures.file_upload?.image?.transfer_methods || ['local_file', 'remote_url'],
},
enabled: !!(resolvedFeatures.file_upload?.enabled || resolvedFeatures.file_upload?.image?.enabled),
allowed_file_types: resolvedFeatures.file_upload?.allowed_file_types || [SupportUploadFileTypes.image],
allowed_file_extensions: resolvedFeatures.file_upload?.allowed_file_extensions || FILE_EXTS[SupportUploadFileTypes.image].map(ext => `.${ext}`),
allowed_file_upload_methods: resolvedFeatures.file_upload?.allowed_file_upload_methods || resolvedFeatures.file_upload?.image?.transfer_methods || ['local_file', 'remote_url'],
number_limits: resolvedFeatures.file_upload?.number_limits || resolvedFeatures.file_upload?.image?.number_limits || 3,
},
opening: {
enabled: !!resolvedFeatures.opening_statement,
opening_statement: resolvedFeatures.opening_statement,
suggested_questions: resolvedFeatures.suggested_questions,
},
suggested: resolvedFeatures.suggested_questions_after_answer || { enabled: false },
speech2text: resolvedFeatures.speech_to_text || { enabled: false },
text2speech: resolvedFeatures.text_to_speech || { enabled: false },
citation: resolvedFeatures.retriever_resource || { enabled: false },
moderation: resolvedFeatures.sensitive_word_avoidance || { enabled: false },
}
}

View File

@ -1,16 +1,11 @@
'use client'
import type { MouseEventHandler } from 'react'
import type {
CommonNodeType,
Node,
} from './types'
import {
RiAlertFill,
RiCloseLine,
RiFileDownloadLine,
} from '@remixicon/react'
import { load as yamlLoad } from 'js-yaml'
import {
memo,
useCallback,
@ -34,10 +29,14 @@ import {
importDSLConfirm,
} from '@/service/apps'
import { fetchWorkflowDraft } from '@/service/workflow'
import { AppModeEnum } from '@/types/app'
import { collaborationManager } from './collaboration/core/collaboration-manager'
import { WORKFLOW_DATA_UPDATE } from './constants'
import { BlockEnum } from './types'
import {
getImportNotificationPayload,
isImportCompleted,
normalizeWorkflowFeatures,
validateDSLContent,
} from './update-dsl-modal.helpers'
import { collaborationManager } from './collaboration/core/collaboration-manager'
import {
initialEdges,
initialNodes,
@ -93,15 +92,13 @@ const UpdateDSLModal = ({
} = await fetchWorkflowDraft(`/apps/${app_id}/workflows/draft`)
const { nodes, edges, viewport } = graph
const draftFeatures = features ?? {}
eventEmitter?.emit({
type: WORKFLOW_DATA_UPDATE,
payload: {
nodes: initialNodes(nodes, edges),
edges: initialEdges(edges, nodes),
viewport,
features: draftFeatures,
features: normalizeWorkflowFeatures(features),
hash,
conversation_variables: conversation_variables || [],
environment_variables: environment_variables || [],
@ -109,76 +106,63 @@ const UpdateDSLModal = ({
} as any)
}, [eventEmitter])
const validateDSLContent = (content: string): boolean => {
try {
const data = yamlLoad(content) as any
const nodes = data?.workflow?.graph?.nodes ?? []
const invalidNodes: BlockEnum[] = appDetail?.mode === AppModeEnum.ADVANCED_CHAT
? [
BlockEnum.End,
BlockEnum.TriggerWebhook,
BlockEnum.TriggerSchedule,
BlockEnum.TriggerPlugin,
]
: [BlockEnum.Answer]
const hasInvalidNode = nodes.some((node: Node<CommonNodeType>) => {
const nodeType = node?.data?.type
return nodeType !== undefined && invalidNodes.includes(nodeType)
})
if (hasInvalidNode) {
toast.error(t('common.importFailure', { ns: 'workflow' }))
return false
}
return true
}
catch {
toast.error(t('common.importFailure', { ns: 'workflow' }))
return false
}
}
const isCreatingRef = useRef(false)
const handleCompletedImport = useCallback(async (status: DSLImportStatus, appId?: string) => {
if (!appId) {
toast.error(t('common.importFailure', { ns: 'workflow' }))
return
}
await handleWorkflowUpdate(appId)
collaborationManager.emitWorkflowUpdate(appId)
onImport?.()
const payload = getImportNotificationPayload(status, t)
toast[payload.type](payload.message, payload.children ? { description: payload.children } : undefined)
await handleCheckPluginDependencies(appId)
setLoading(false)
onCancel()
}, [handleCheckPluginDependencies, handleWorkflowUpdate, onCancel, onImport, t])
const handlePendingImport = useCallback((id: string, importedVersion?: string | null, currentVersion?: string | null) => {
setShow(false)
setTimeout(() => {
setShowErrorModal(true)
}, 300)
setVersions({
importedVersion: importedVersion ?? '',
systemVersion: currentVersion ?? '',
})
setImportId(id)
}, [])
const handleImport: MouseEventHandler = useCallback(async () => {
if (isCreatingRef.current)
return
isCreatingRef.current = true
if (!currentFile)
if (!currentFile) {
isCreatingRef.current = false
return
}
try {
if (appDetail && fileContent && validateDSLContent(fileContent)) {
if (appDetail && fileContent && validateDSLContent(fileContent, appDetail.mode)) {
setLoading(true)
const response = await importDSL({ mode: DSLImportMode.YAML_CONTENT, yaml_content: fileContent, app_id: appDetail.id })
const { id, status, app_id, imported_dsl_version, current_dsl_version } = response
if (status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS) {
if (!app_id) {
toast.error(t('common.importFailure', { ns: 'workflow' }))
return
}
handleWorkflowUpdate(app_id)
if (onImport)
onImport()
toast(t(status === DSLImportStatus.COMPLETED ? 'common.importSuccess' : 'common.importWarning', { ns: 'workflow' }), { type: status === DSLImportStatus.COMPLETED ? 'success' : 'warning', description: status === DSLImportStatus.COMPLETED_WITH_WARNINGS && t('common.importWarningDetails', { ns: 'workflow' }) })
await handleCheckPluginDependencies(app_id)
setLoading(false)
onCancel()
if (isImportCompleted(status)) {
await handleCompletedImport(status, app_id)
}
else if (status === DSLImportStatus.PENDING) {
setShow(false)
setTimeout(() => {
setShowErrorModal(true)
}, 300)
setVersions({
importedVersion: imported_dsl_version ?? '',
systemVersion: current_dsl_version ?? '',
})
setImportId(id)
handlePendingImport(id, imported_dsl_version, current_dsl_version)
}
else {
setLoading(false)
toast.error(t('common.importFailure', { ns: 'workflow' }))
}
}
else if (fileContent) {
toast.error(t('common.importFailure', { ns: 'workflow' }))
}
}
// eslint-disable-next-line unused-imports/no-unused-vars
catch (e) {
@ -186,7 +170,7 @@ const UpdateDSLModal = ({
toast.error(t('common.importFailure', { ns: 'workflow' }))
}
isCreatingRef.current = false
}, [currentFile, fileContent, onCancel, t, appDetail, onImport, handleWorkflowUpdate, handleCheckPluginDependencies])
}, [currentFile, fileContent, t, appDetail, handleCompletedImport, handlePendingImport])
const onUpdateDSLConfirm: MouseEventHandler = async () => {
try {
@ -198,20 +182,8 @@ const UpdateDSLModal = ({
const { status, app_id } = response
if (status === DSLImportStatus.COMPLETED) {
if (!app_id) {
toast.error(t('common.importFailure', { ns: 'workflow' }))
return
}
handleWorkflowUpdate(app_id)
// Notify other collaboration clients about the workflow update
collaborationManager.emitWorkflowUpdate(app_id)
await handleCheckPluginDependencies(app_id)
if (onImport)
onImport()
toast.success(t('common.importSuccess', { ns: 'workflow' }))
setLoading(false)
onCancel()
if (isImportCompleted(status)) {
await handleCompletedImport(status, app_id)
}
else if (status === DSLImportStatus.FAILED) {
setLoading(false)

View File

@ -0,0 +1,143 @@
import type { FileUploadConfigResponse } from '@/models/common'
import type { VarInInspect } from '@/types/workflow'
import { fireEvent, render, screen } from '@testing-library/react'
import { ToastContext } from '@/app/components/base/toast/context'
import { VarType } from '@/app/components/workflow/types'
import { VarInInspectType } from '@/types/workflow'
import {
BoolArraySection,
ErrorMessages,
FileEditorSection,
JsonEditorSection,
TextEditorSection,
} from '../value-content-sections'
vi.mock('@/app/components/workflow/nodes/llm/components/json-schema-config-modal/schema-editor', () => ({
default: ({ schema, onUpdate }: { schema: string, onUpdate: (value: string) => void }) => (
<textarea data-testid="schema-editor" value={schema} onChange={event => onUpdate(event.target.value)} />
),
}))
vi.mock('@/next/navigation', () => ({
useParams: () => ({ token: '' }),
}))
describe('value-content sections', () => {
const createFileUploadConfig = (): FileUploadConfigResponse => ({
batch_count_limit: 10,
image_file_batch_limit: 10,
single_chunk_attachment_limit: 10,
attachment_image_file_size_limit: 2,
file_size_limit: 15,
file_upload_limit: 5,
workflow_file_upload_limit: 5,
})
const createVar = (overrides: Partial<VarInInspect>): VarInInspect => ({
id: 'var-1',
name: 'query',
type: VarInInspectType.node,
value_type: VarType.string,
value: '',
...overrides,
} as VarInInspect)
it('should render the text editor section and forward text changes', () => {
const handleTextChange = vi.fn()
render(
<TextEditorSection
currentVar={createVar({ value_type: VarType.string })}
value="hello"
textEditorDisabled={false}
isTruncated={false}
onTextChange={handleTextChange}
/>,
)
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'updated' } })
expect(handleTextChange).toHaveBeenCalledWith('updated')
})
it('should render the textarea editor for non-string values', () => {
const handleTextChange = vi.fn()
render(
<TextEditorSection
currentVar={createVar({ name: 'count', value_type: VarType.number })}
value="12"
textEditorDisabled={false}
isTruncated={false}
onTextChange={handleTextChange}
/>,
)
fireEvent.change(screen.getByRole('textbox'), { target: { value: '24' } })
expect(handleTextChange).toHaveBeenCalledWith('24')
})
it('should update a boolean array item by index', () => {
const onChange = vi.fn()
render(<BoolArraySection values={[true, false]} onChange={onChange} />)
fireEvent.click(screen.getAllByText('True')[1])
expect(onChange).toHaveBeenCalledWith([true, true])
})
it('should render schema editor and error messages', () => {
const onChange = vi.fn()
render(
<>
<JsonEditorSection
hasChunks={false}
valueType={VarType.object}
json="{}"
readonly={false}
isTruncated={false}
onChange={onChange}
/>
<ErrorMessages
parseError={new Error('Broken JSON')}
validationError="Too deep"
/>
</>,
)
fireEvent.change(screen.getByTestId('schema-editor'), { target: { value: '{"foo":1}' } })
expect(onChange).toHaveBeenCalledWith('{"foo":1}')
expect(screen.getByText('Broken JSON')).toBeInTheDocument()
expect(screen.getByText('Too deep')).toBeInTheDocument()
})
it('should render chunk preview when the json editor has chunks', () => {
render(
<JsonEditorSection
hasChunks
schemaType="general_structure"
valueType={VarType.object}
json="{}"
readonly={false}
isTruncated={false}
onChange={vi.fn()}
/>,
)
expect(screen.getByTestId('schema-editor')).toBeInTheDocument()
})
it('should render the file editor section', () => {
render(
<ToastContext.Provider value={{ notify: vi.fn(), close: vi.fn() }}>
<FileEditorSection
currentVar={createVar({ name: 'files', value_type: VarType.file })}
fileValue={[]}
fileUploadConfig={createFileUploadConfig()}
textEditorDisabled={false}
onChange={vi.fn()}
/>
</ToastContext.Provider>,
)
expect(screen.getAllByRole('button').length).toBeGreaterThan(0)
})
})

View File

@ -0,0 +1,48 @@
describe('value-content helpers branch coverage', () => {
afterEach(() => {
vi.resetModules()
vi.clearAllMocks()
})
it('should return validation errors for invalid schemas, over-deep schemas, and draft7 violations', async () => {
const validateSchemaAgainstDraft7 = vi.fn()
const getValidationErrorMessage = vi.fn(() => 'draft7 error')
vi.doMock('@/app/components/workflow/nodes/llm/utils', () => ({
checkJsonSchemaDepth: (schema: Record<string, unknown>) => schema.depth as number,
getValidationErrorMessage,
validateSchemaAgainstDraft7,
}))
vi.doMock('../utils', () => ({
validateJSONSchema: (schema: Record<string, unknown>) => {
if (schema.kind === 'invalid')
return { success: false, error: new Error('schema invalid') }
return { success: true }
},
}))
const { validateInspectJsonValue } = await import('../value-content.helpers')
expect(validateInspectJsonValue('{"kind":"invalid"}', 'object')).toMatchObject({
success: false,
validationError: 'schema invalid',
parseError: null,
})
expect(validateInspectJsonValue('{"depth":99}', 'object')).toMatchObject({
success: false,
validationError: expect.stringContaining('Schema exceeds maximum depth'),
parseError: null,
})
validateSchemaAgainstDraft7.mockReturnValueOnce([{ message: 'broken' }])
expect(validateInspectJsonValue('{"depth":1}', 'object')).toMatchObject({
success: false,
validationError: 'draft7 error',
parseError: null,
})
expect(getValidationErrorMessage).toHaveBeenCalled()
})
})

View File

@ -0,0 +1,80 @@
import type { VarInInspect } from '@/types/workflow'
import { VarType } from '@/app/components/workflow/types'
import { VarInInspectType } from '@/types/workflow'
import {
formatInspectFileValue,
getValueEditorState,
isFileValueUploaded,
validateInspectJsonValue,
} from '../value-content.helpers'
describe('value-content helpers', () => {
const createVar = (overrides: Partial<VarInInspect>): VarInInspect => ({
id: 'var-1',
name: 'query',
type: VarInInspectType.node,
value_type: VarType.string,
value: '',
...overrides,
} as VarInInspect)
it('should derive editor modes from the variable shape', () => {
expect(getValueEditorState(createVar({
type: VarInInspectType.environment,
name: 'api_key',
value_type: VarType.string,
value: 'secret',
}))).toMatchObject({
showTextEditor: true,
textEditorDisabled: true,
showJSONEditor: false,
})
expect(getValueEditorState(createVar({
name: 'payload',
value_type: VarType.object,
value: { foo: 1 },
schemaType: 'general_structure',
}))).toMatchObject({
showJSONEditor: true,
hasChunks: true,
})
expect(getValueEditorState(createVar({
type: VarInInspectType.system,
name: 'files',
value_type: VarType.arrayFile,
value: [],
}))).toMatchObject({
isSysFiles: true,
showFileEditor: true,
showJSONEditor: false,
})
})
it('should format file values and detect upload completion', () => {
expect(formatInspectFileValue(createVar({
name: 'file',
value_type: VarType.file,
value: { id: 'file-1' },
}))).toHaveLength(1)
expect(isFileValueUploaded([{ upload_file_id: 'file-1' }])).toBe(true)
expect(isFileValueUploaded([{ upload_file_id: '' }])).toBe(false)
expect(formatInspectFileValue(createVar({
type: VarInInspectType.system,
name: 'files',
value_type: VarType.arrayFile,
value: [{ id: 'file-2' }],
}))).toHaveLength(1)
})
it('should validate json input and surface parse errors', () => {
expect(validateInspectJsonValue('{"foo":1}', 'object').success).toBe(true)
expect(validateInspectJsonValue('[]', 'array[any]')).toMatchObject({ success: true })
expect(validateInspectJsonValue('{', 'object')).toMatchObject({
success: false,
parseError: expect.any(Error),
})
})
})

View File

@ -0,0 +1,410 @@
import type { VarInInspect } from '@/types/workflow'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { renderWorkflowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import { VarType } from '@/app/components/workflow/types'
import { VarInInspectType } from '@/types/workflow'
import ValueContent from '../value-content'
vi.mock('@/app/components/base/file-uploader/utils', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/base/file-uploader/utils')>()
return {
...actual,
getProcessedFiles: (files: unknown[]) => files,
}
})
vi.mock('@/app/components/workflow/nodes/llm/components/json-schema-config-modal/schema-editor', () => ({
default: ({ schema, onUpdate }: { schema: string, onUpdate: (value: string) => void }) => (
<textarea data-testid="json-editor" value={schema} onChange={event => onUpdate(event.target.value)} />
),
}))
vi.mock('../value-content-sections', () => ({
TextEditorSection: ({
value,
onTextChange,
}: {
value: string
onTextChange: (value: string) => void
}) => <textarea aria-label="value-text-editor" value={value ?? ''} onChange={event => onTextChange(event.target.value)} />,
BoolArraySection: ({
onChange,
}: {
onChange: (value: boolean[]) => void
}) => <button onClick={() => onChange([true, true])}>bool-array-editor</button>,
JsonEditorSection: ({
json,
onChange,
}: {
json: string
onChange: (value: string) => void
}) => <textarea data-testid="json-editor" value={json} onChange={event => onChange(event.target.value)} />,
FileEditorSection: ({
onChange,
}: {
onChange: (files: Array<Record<string, unknown>>) => void
}) => (
<div>
<button onClick={() => onChange([{ upload_file_id: '' }])}>file-pending</button>
<button onClick={() => onChange([{ upload_file_id: 'file-1', name: 'report.pdf' }])}>file-uploaded</button>
<button onClick={() => onChange([
{ upload_file_id: 'file-1', name: 'a.pdf' },
{ upload_file_id: 'file-2', name: 'b.pdf' },
])}
>
file-array-uploaded
</button>
</div>
),
ErrorMessages: ({
parseError,
validationError,
}: {
parseError: Error | null
validationError: string
}) => (
<div>
{parseError && <div>{parseError.message}</div>}
{validationError && <div>{validationError}</div>}
</div>
),
}))
vi.mock('@/next/navigation', () => ({
useParams: () => ({ token: '' }),
}))
describe('ValueContent', () => {
const createVar = (overrides: Partial<VarInInspect>): VarInInspect => ({
id: 'var-default',
name: 'query',
type: VarInInspectType.node,
value_type: VarType.string,
value: '',
...overrides,
} as VarInInspect)
beforeEach(() => {
vi.clearAllMocks()
})
it('should debounce text changes for string variables', async () => {
const handleValueChange = vi.fn()
renderWorkflowComponent(
<ValueContent
currentVar={createVar({
id: 'var-1',
value_type: VarType.string,
value: 'hello',
})}
handleValueChange={handleValueChange}
isTruncated={false}
/>,
{
initialStoreState: {
fileUploadConfig: {
workflow_file_upload_limit: 5,
} as never,
},
},
)
fireEvent.change(screen.getByLabelText('value-text-editor'), { target: { value: 'updated' } })
await waitFor(() => {
expect(handleValueChange).toHaveBeenCalledWith('var-1', 'updated')
})
})
it('should surface parse errors from invalid json input', async () => {
renderWorkflowComponent(
<ValueContent
currentVar={createVar({
id: 'var-2',
name: 'payload',
value_type: VarType.object,
value: { foo: 1 },
})}
handleValueChange={vi.fn()}
isTruncated={false}
/>,
{
initialStoreState: {
fileUploadConfig: {
workflow_file_upload_limit: 5,
} as never,
},
},
)
fireEvent.change(screen.getByTestId('json-editor'), { target: { value: '{' } })
await waitFor(() => {
expect(screen.getByText(/json/i)).toBeInTheDocument()
})
})
it('should debounce numeric changes', async () => {
const handleValueChange = vi.fn()
renderWorkflowComponent(
<ValueContent
currentVar={createVar({
id: 'var-3',
name: 'count',
value_type: VarType.number,
value: 1,
})}
handleValueChange={handleValueChange}
isTruncated={false}
/>,
{
initialStoreState: {
fileUploadConfig: {
workflow_file_upload_limit: 5,
} as never,
},
},
)
fireEvent.change(screen.getByLabelText('value-text-editor'), { target: { value: '24.5' } })
await waitFor(() => {
expect(handleValueChange).toHaveBeenCalledWith('var-3', 24.5)
})
expect(handleValueChange).toHaveBeenCalledTimes(1)
})
it('should update boolean values', async () => {
const handleValueChange = vi.fn()
renderWorkflowComponent(
<ValueContent
currentVar={createVar({
id: 'var-4',
name: 'enabled',
value_type: VarType.boolean,
value: false,
})}
handleValueChange={handleValueChange}
isTruncated={false}
/>,
{
initialStoreState: {
fileUploadConfig: {
workflow_file_upload_limit: 5,
} as never,
},
},
)
fireEvent.click(screen.getByText('True'))
await waitFor(() => {
expect(handleValueChange).toHaveBeenCalledWith('var-4', true)
})
})
it('should not emit changes when the content is truncated', async () => {
const handleValueChange = vi.fn()
renderWorkflowComponent(
<ValueContent
currentVar={createVar({
id: 'var-5',
value_type: VarType.string,
value: 'hello',
})}
handleValueChange={handleValueChange}
isTruncated
/>,
{
initialStoreState: {
fileUploadConfig: {
workflow_file_upload_limit: 5,
} as never,
},
},
)
fireEvent.change(screen.getByLabelText('value-text-editor'), { target: { value: 'updated' } })
await waitFor(() => {
expect(handleValueChange).not.toHaveBeenCalled()
})
})
it('should update boolean array values', async () => {
const handleValueChange = vi.fn()
renderWorkflowComponent(
<ValueContent
currentVar={createVar({
id: 'var-6',
name: 'flags',
value_type: VarType.arrayBoolean,
value: [true, false],
})}
handleValueChange={handleValueChange}
isTruncated={false}
/>,
{
initialStoreState: {
fileUploadConfig: {
workflow_file_upload_limit: 5,
} as never,
},
},
)
fireEvent.click(screen.getByText('bool-array-editor'))
await waitFor(() => {
expect(handleValueChange).toHaveBeenCalledWith('var-6', [true, true])
})
})
it('should parse valid json values', async () => {
const handleValueChange = vi.fn()
renderWorkflowComponent(
<ValueContent
currentVar={createVar({
id: 'var-7',
name: 'payload',
value_type: VarType.object,
value: { foo: 1 },
})}
handleValueChange={handleValueChange}
isTruncated={false}
/>,
{
initialStoreState: {
fileUploadConfig: {
workflow_file_upload_limit: 5,
} as never,
},
},
)
fireEvent.change(screen.getByTestId('json-editor'), { target: { value: '{"foo":2}' } })
await waitFor(() => {
expect(handleValueChange).toHaveBeenCalledWith('var-7', { foo: 2 })
})
})
it('should update uploaded single file values and ignore pending uploads', async () => {
const handleValueChange = vi.fn()
renderWorkflowComponent(
<ValueContent
currentVar={createVar({
id: 'var-8',
name: 'files',
value_type: VarType.file,
value: null,
})}
handleValueChange={handleValueChange}
isTruncated={false}
/>,
{
initialStoreState: {
fileUploadConfig: {
workflow_file_upload_limit: 5,
} as never,
},
},
)
fireEvent.click(screen.getByText('file-pending'))
await waitFor(() => {
expect(handleValueChange).not.toHaveBeenCalled()
})
fireEvent.click(screen.getByText('file-uploaded'))
await waitFor(() => {
expect(handleValueChange).toHaveBeenCalledWith('var-8', expect.objectContaining({ upload_file_id: 'file-1' }))
})
})
it('should update uploaded file arrays and react to resize observer changes', async () => {
const handleValueChange = vi.fn()
const observe = vi.fn()
const disconnect = vi.fn()
const originalResizeObserver = globalThis.ResizeObserver
const originalClientHeight = Object.getOwnPropertyDescriptor(HTMLDivElement.prototype, 'clientHeight')
Object.defineProperty(HTMLDivElement.prototype, 'clientHeight', {
configurable: true,
get: () => 120,
})
class MockResizeObserver {
callback: ResizeObserverCallback
constructor(callback: ResizeObserverCallback) {
this.callback = callback
}
observe = (target: Element) => {
observe(target)
this.callback([{
borderBoxSize: [{ blockSize: 20 }],
} as unknown as ResizeObserverEntry], this as unknown as ResizeObserver)
}
disconnect = disconnect
}
vi.stubGlobal('ResizeObserver', MockResizeObserver as unknown as typeof ResizeObserver)
renderWorkflowComponent(
<ValueContent
currentVar={createVar({
id: 'var-9',
name: 'files',
type: VarInInspectType.system,
value_type: VarType.arrayFile,
value: [],
})}
handleValueChange={handleValueChange}
isTruncated={false}
/>,
{
initialStoreState: {
fileUploadConfig: {
workflow_file_upload_limit: 5,
} as never,
},
},
)
fireEvent.click(screen.getByText('file-array-uploaded'))
await waitFor(() => {
expect(handleValueChange).toHaveBeenCalledWith('var-9', expect.arrayContaining([
expect.objectContaining({ upload_file_id: 'file-1' }),
expect.objectContaining({ upload_file_id: 'file-2' }),
]))
})
expect(observe).toHaveBeenCalled()
expect(document.querySelector('[style="height: 100px;"]')).toBeInTheDocument()
if (originalClientHeight)
Object.defineProperty(HTMLDivElement.prototype, 'clientHeight', originalClientHeight)
else
delete (HTMLDivElement.prototype as { clientHeight?: number }).clientHeight
if (originalResizeObserver)
vi.stubGlobal('ResizeObserver', originalResizeObserver)
else
vi.unstubAllGlobals()
expect(disconnect).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,190 @@
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { FileUploadConfigResponse } from '@/models/common'
import type { VarInInspect } from '@/types/workflow'
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
import Textarea from '@/app/components/base/textarea'
import ErrorMessage from '@/app/components/workflow/nodes/llm/components/json-schema-config-modal/error-message'
import SchemaEditor from '@/app/components/workflow/nodes/llm/components/json-schema-config-modal/schema-editor'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
import { cn } from '@/utils/classnames'
import { PreviewMode } from '../../base/features/types'
import BoolValue from '../panel/chat-variable-panel/components/bool-value'
import DisplayContent from './display-content'
import LargeDataAlert from './large-data-alert'
import { PreviewType } from './types'
type TextEditorSectionProps = {
currentVar: VarInInspect
value: unknown
textEditorDisabled: boolean
isTruncated: boolean
onTextChange: (value: string) => void
}
export const TextEditorSection = ({
currentVar,
value,
textEditorDisabled,
isTruncated,
onTextChange,
}: TextEditorSectionProps) => {
return (
<>
{isTruncated && <LargeDataAlert className="absolute left-3 right-3 top-1" />}
{currentVar.value_type === 'string'
? (
<DisplayContent
previewType={PreviewType.Markdown}
varType={currentVar.value_type}
mdString={typeof value === 'string' ? value : String(value ?? '')}
readonly={textEditorDisabled}
handleTextChange={onTextChange}
className={cn(isTruncated && 'pt-[36px]')}
/>
)
: (
<Textarea
readOnly={textEditorDisabled}
disabled={textEditorDisabled || isTruncated}
className={cn('h-full', isTruncated && 'pt-[48px]')}
value={typeof value === 'number' ? value : String(value ?? '')}
onChange={e => onTextChange(e.target.value)}
/>
)}
</>
)
}
type BoolArraySectionProps = {
values: boolean[]
onChange: (nextValue: boolean[]) => void
}
export const BoolArraySection = ({
values,
onChange,
}: BoolArraySectionProps) => {
return (
<div className="w-[295px] space-y-1">
{values.map((value, index) => (
<BoolValue
key={`${index}-${String(value)}`}
value={value}
onChange={(newValue) => {
const nextValue = [...values]
nextValue[index] = newValue
onChange(nextValue)
}}
/>
))}
</div>
)
}
type JsonEditorSectionProps = {
hasChunks: boolean
schemaType?: string
valueType: VarInInspect['value_type']
json: string
readonly: boolean
isTruncated: boolean
onChange: (value: string) => void
}
export const JsonEditorSection = ({
hasChunks,
schemaType,
valueType,
json,
readonly,
isTruncated,
onChange,
}: JsonEditorSectionProps) => {
if (hasChunks) {
return (
<DisplayContent
previewType={PreviewType.Chunks}
varType={valueType}
schemaType={schemaType ?? ''}
jsonString={json ?? '{}'}
readonly={readonly}
handleEditorChange={onChange}
/>
)
}
return (
<SchemaEditor
readonly={readonly || isTruncated}
className="overflow-y-auto"
hideTopMenu
schema={json}
onUpdate={onChange}
isTruncated={isTruncated}
/>
)
}
type FileEditorSectionProps = {
currentVar: VarInInspect
fileValue: FileEntity[]
fileUploadConfig?: FileUploadConfigResponse
textEditorDisabled: boolean
onChange: (files: FileEntity[]) => void
}
export const FileEditorSection = ({
currentVar,
fileValue,
fileUploadConfig,
textEditorDisabled,
onChange,
}: FileEditorSectionProps) => {
return (
<div className="max-w-[460px]">
<FileUploaderInAttachmentWrapper
value={fileValue}
onChange={onChange}
fileConfig={{
allowed_file_types: [
SupportUploadFileTypes.image,
SupportUploadFileTypes.document,
SupportUploadFileTypes.audio,
SupportUploadFileTypes.video,
],
allowed_file_extensions: [
...FILE_EXTS[SupportUploadFileTypes.image],
...FILE_EXTS[SupportUploadFileTypes.document],
...FILE_EXTS[SupportUploadFileTypes.audio],
...FILE_EXTS[SupportUploadFileTypes.video],
],
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
number_limits: currentVar.value_type === 'file' ? 1 : fileUploadConfig?.workflow_file_upload_limit || 5,
fileUploadConfig,
preview_config: {
mode: PreviewMode.NewPage,
file_type_list: ['application/pdf'],
},
}}
isDisabled={textEditorDisabled}
/>
</div>
)
}
export const ErrorMessages = ({
parseError,
validationError,
}: {
parseError: Error | null
validationError: string
}) => {
return (
<>
{parseError && <ErrorMessage className="mt-1" message={parseError.message} />}
{validationError && <ErrorMessage className="mt-1" message={validationError} />}
</>
)
}

View File

@ -0,0 +1,77 @@
import type { VarInInspect } from '@/types/workflow'
import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils'
import {
checkJsonSchemaDepth,
getValidationErrorMessage,
validateSchemaAgainstDraft7,
} from '@/app/components/workflow/nodes/llm/utils'
import { JSON_SCHEMA_MAX_DEPTH } from '@/config'
import { VarInInspectType } from '@/types/workflow'
import { CHUNK_SCHEMA_TYPES } from './types'
import { validateJSONSchema } from './utils'
type UploadedFileLike = {
upload_file_id?: string
}
export const getValueEditorState = (currentVar: VarInInspect) => {
const showTextEditor = currentVar.value_type === 'secret' || currentVar.value_type === 'string' || currentVar.value_type === 'number'
const showBoolEditor = typeof currentVar.value === 'boolean'
const showBoolArrayEditor = Array.isArray(currentVar.value) && currentVar.value.every(v => typeof v === 'boolean')
const isSysFiles = currentVar.type === VarInInspectType.system && currentVar.name === 'files'
const showJSONEditor = !isSysFiles && ['object', 'array[string]', 'array[number]', 'array[object]', 'array[message]', 'array[any]'].includes(currentVar.value_type)
const showFileEditor = isSysFiles || currentVar.value_type === 'file' || currentVar.value_type === 'array[file]'
const textEditorDisabled = currentVar.type === VarInInspectType.environment || (currentVar.type === VarInInspectType.system && currentVar.name !== 'query' && currentVar.name !== 'files')
const JSONEditorDisabled = currentVar.value_type === 'array[any]'
const hasChunks = !!currentVar.schemaType && CHUNK_SCHEMA_TYPES.includes(currentVar.schemaType)
return {
showTextEditor,
showBoolEditor,
showBoolArrayEditor,
isSysFiles,
showJSONEditor,
showFileEditor,
textEditorDisabled,
JSONEditorDisabled,
hasChunks,
}
}
export const formatInspectFileValue = (currentVar: VarInInspect) => {
if (currentVar.value_type === 'file')
return currentVar.value ? getProcessedFilesFromResponse([currentVar.value]) : []
if (currentVar.value_type === 'array[file]' || (currentVar.type === VarInInspectType.system && currentVar.name === 'files'))
return currentVar.value && currentVar.value.length > 0 ? getProcessedFilesFromResponse(currentVar.value) : []
return []
}
export const validateInspectJsonValue = (value: string, type: string) => {
try {
const newJSONSchema = JSON.parse(value)
const result = validateJSONSchema(newJSONSchema, type)
if (!result.success)
return { success: false, validationError: result.error.message, parseError: null }
if (type === 'object' || type === 'array[object]') {
const schemaDepth = checkJsonSchemaDepth(newJSONSchema)
if (schemaDepth > JSON_SCHEMA_MAX_DEPTH)
return { success: false, validationError: `Schema exceeds maximum depth of ${JSON_SCHEMA_MAX_DEPTH}.`, parseError: null }
const validationErrors = validateSchemaAgainstDraft7(newJSONSchema)
if (validationErrors.length > 0)
return { success: false, validationError: getValidationErrorMessage(validationErrors), parseError: null }
}
return { success: true, validationError: '', parseError: null }
}
catch (error) {
return {
success: false,
validationError: '',
parseError: error instanceof Error ? error : new Error('Invalid JSON'),
}
}
}
export const isFileValueUploaded = (fileList: UploadedFileLike[]) => fileList.every(file => file.upload_file_id)

View File

@ -1,34 +1,26 @@
import type { VarInspectValue } from './value-types'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { FileResponse, VarInInspect } from '@/types/workflow'
import type { VarInInspect } from '@/types/workflow'
import { useDebounceFn } from 'ahooks'
import * as React from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
import { getProcessedFiles, getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils'
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
import Textarea from '@/app/components/base/textarea'
import ErrorMessage from '@/app/components/workflow/nodes/llm/components/json-schema-config-modal/error-message'
import SchemaEditor from '@/app/components/workflow/nodes/llm/components/json-schema-config-modal/schema-editor'
import {
checkJsonSchemaDepth,
getValidationErrorMessage,
validateSchemaAgainstDraft7,
} from '@/app/components/workflow/nodes/llm/utils'
import { getProcessedFiles } from '@/app/components/base/file-uploader/utils'
import { useStore } from '@/app/components/workflow/store'
import { SupportUploadFileTypes, VarType } from '@/app/components/workflow/types'
import {
validateJSONSchema,
} from '@/app/components/workflow/variable-inspect/utils'
import { JSON_SCHEMA_MAX_DEPTH } from '@/config'
import { TransferMethod } from '@/types/app'
import { VarInInspectType } from '@/types/workflow'
import { cn } from '@/utils/classnames'
import { PreviewMode } from '../../base/features/types'
import BoolValue from '../panel/chat-variable-panel/components/bool-value'
import DisplayContent from './display-content'
import LargeDataAlert from './large-data-alert'
import { CHUNK_SCHEMA_TYPES, PreviewType } from './types'
import {
BoolArraySection,
ErrorMessages,
FileEditorSection,
JsonEditorSection,
TextEditorSection,
} from './value-content-sections'
import {
formatInspectFileValue,
getValueEditorState,
isFileValueUploaded,
validateInspectJsonValue,
} from './value-content.helpers'
type Props = {
currentVar: VarInInspect
@ -36,35 +28,6 @@ type Props = {
isTruncated: boolean
}
const textValueTypes = new Set<VarType>([VarType.secret, VarType.string, VarType.number])
const jsonValueTypes = new Set<VarType>([
VarType.object,
VarType.arrayString,
VarType.arrayNumber,
VarType.arrayObject,
VarType.arrayMessage,
VarType.arrayAny,
])
const fileValueTypes = new Set<VarType>([VarType.file, VarType.arrayFile])
type EditorState = {
textValue: string | number
jsonValue: string
fileValue: FileEntity[]
}
const formatFileValue = (value: VarInInspect, isSysFiles: boolean): FileEntity[] => {
if (value.value_type === VarType.file) {
const v = value.value
return v != null ? getProcessedFilesFromResponse([v as unknown as FileResponse]) : []
}
if (value.value_type === VarType.arrayFile || (value.type === VarInInspectType.system && isSysFiles)) {
const v = value.value
return Array.isArray(v) && v.length > 0 ? getProcessedFilesFromResponse(v as unknown as FileResponse[]) : []
}
return []
}
const ValueContent = ({
currentVar,
handleValueChange,
@ -73,129 +36,82 @@ const ValueContent = ({
const contentContainerRef = useRef<HTMLDivElement>(null)
const errorMessageRef = useRef<HTMLDivElement>(null)
const [editorHeight, setEditorHeight] = useState(0)
const showTextEditor = textValueTypes.has(currentVar.value_type)
const showBoolEditor = typeof currentVar.value === 'boolean'
const showBoolArrayEditor = Array.isArray(currentVar.value) && currentVar.value.every(v => typeof v === 'boolean')
const isSysFiles = currentVar.type === VarInInspectType.system && currentVar.name === 'files'
const showJSONEditor = !isSysFiles && jsonValueTypes.has(currentVar.value_type)
const showFileEditor = isSysFiles || fileValueTypes.has(currentVar.value_type)
const textEditorDisabled = currentVar.type === VarInInspectType.environment || (currentVar.type === VarInInspectType.system && currentVar.name !== 'query' && currentVar.name !== 'files')
const JSONEditorDisabled = currentVar.value_type === VarType.arrayAny
const {
showTextEditor,
showBoolEditor,
showBoolArrayEditor,
isSysFiles,
showJSONEditor,
showFileEditor,
textEditorDisabled,
JSONEditorDisabled,
hasChunks,
} = useMemo(() => getValueEditorState(currentVar), [currentVar])
const fileUploadConfig = useStore(s => s.fileUploadConfig)
const hasChunks = useMemo(() => {
if (!currentVar.schemaType)
return false
return CHUNK_SCHEMA_TYPES.includes(currentVar.schemaType)
}, [currentVar.schemaType])
const initialEditorState = useMemo<EditorState>(() => {
const textValue = showTextEditor
? (
currentVar.value_type === VarType.number
? JSON.stringify(currentVar.value ?? '')
: (typeof currentVar.value === 'string' || typeof currentVar.value === 'number' ? currentVar.value : String(currentVar.value ?? ''))
)
: ''
const jsonValue = showJSONEditor
? (currentVar.value != null ? JSON.stringify(currentVar.value, null, 2) : '')
: ''
const fileValue = showFileEditor
? formatFileValue(currentVar, isSysFiles)
: []
return {
textValue,
jsonValue,
fileValue,
}
}, [currentVar, isSysFiles, showFileEditor, showJSONEditor, showTextEditor])
const [editorState, setEditorState] = useState<EditorState>(initialEditorState)
const [value, setValue] = useState<unknown>()
const [json, setJson] = useState('')
const [parseError, setParseError] = useState<Error | null>(null)
const [validationError, setValidationError] = useState<string>('')
const { textValue, jsonValue, fileValue } = editorState
const [fileValue, setFileValue] = useState<FileEntity[]>(() => formatInspectFileValue(currentVar))
const { run: debounceValueChange } = useDebounceFn(handleValueChange, { wait: 500 })
// update default value when id or value changed
useEffect(() => {
setEditorState(initialEditorState)
}, [initialEditorState])
if (showTextEditor) {
if (currentVar.value_type === 'number')
return setValue(JSON.stringify(currentVar.value))
if (!currentVar.value)
return setValue('')
setValue(currentVar.value)
}
if (showJSONEditor)
setJson(currentVar.value != null ? JSON.stringify(currentVar.value, null, 2) : '')
if (showFileEditor)
setFileValue(formatInspectFileValue(currentVar))
}, [currentVar.id, currentVar.value])
const handleTextChange = (value: string) => {
if (isTruncated)
return
if (currentVar.value_type === VarType.string)
setEditorState(prev => ({ ...prev, textValue: value }))
if (currentVar.value_type === 'string')
setValue(value)
if (currentVar.value_type === VarType.number) {
if (/^-?\d+(\.)?(\d+)?$/.test(value))
setEditorState(prev => ({ ...prev, textValue: Number.parseFloat(value) }))
if (currentVar.value_type === 'number') {
if (/^-?\d+(?:\.\d+)?$/.test(value))
setValue(Number.parseFloat(value))
}
const newValue = currentVar.value_type === VarType.number ? Number.parseFloat(value) : value
const newValue = currentVar.value_type === 'number' ? Number.parseFloat(value) : value
debounceValueChange(currentVar.id, newValue)
}
const jsonValueValidate = (value: string, type: string) => {
try {
const newJSONSchema = JSON.parse(value)
setParseError(null)
const result = validateJSONSchema(newJSONSchema, type)
if (!result.success) {
setValidationError(result.error.message)
return false
}
if (type === 'object' || type === 'array[object]') {
const schemaDepth = checkJsonSchemaDepth(newJSONSchema)
if (schemaDepth > JSON_SCHEMA_MAX_DEPTH) {
setValidationError(`Schema exceeds maximum depth of ${JSON_SCHEMA_MAX_DEPTH}.`)
return false
}
const validationErrors = validateSchemaAgainstDraft7(newJSONSchema)
if (validationErrors.length > 0) {
setValidationError(getValidationErrorMessage(validationErrors))
return false
}
}
setValidationError('')
return true
}
catch (error) {
setValidationError('')
if (error instanceof Error) {
setParseError(error)
return false
}
else {
setParseError(new Error('Invalid JSON'))
return false
}
}
const result = validateInspectJsonValue(value, type)
setParseError(result.parseError)
setValidationError(result.validationError)
return result.success
}
const handleEditorChange = (value: string) => {
if (isTruncated)
return
setEditorState(prev => ({ ...prev, jsonValue: value }))
setJson(value)
if (jsonValueValidate(value, currentVar.value_type)) {
const parsed = JSON.parse(value)
debounceValueChange(currentVar.id, parsed)
}
}
type ProcessedFile = ReturnType<typeof getProcessedFiles>[number]
const fileValueValidate = (fileList: ProcessedFile[]) => fileList.every(file => file.upload_file_id)
const handleFileChange = (value: FileEntity[]) => {
setEditorState(prev => ({ ...prev, fileValue: value }))
setFileValue(value)
const processedFiles = getProcessedFiles(value)
// check every file upload progress
// invoke update api after every file uploaded
if (!fileValueValidate(processedFiles))
if (!isFileValueUploaded(processedFiles))
return
if (currentVar.value_type === VarType.file)
if (currentVar.value_type === 'file')
debounceValueChange(currentVar.id, processedFiles[0])
if (currentVar.value_type === VarType.arrayFile || isSysFiles)
if (currentVar.value_type === 'array[file]' || isSysFiles)
debounceValueChange(currentVar.id, processedFiles)
}
@ -207,7 +123,7 @@ const ValueContent = ({
const borderBoxSize = Array.isArray(entry.borderBoxSize)
? entry.borderBoxSize[0]
: entry.borderBoxSize
const errorHeight = borderBoxSize?.blockSize ?? entry.contentRect.height
const errorHeight = borderBoxSize?.blockSize ?? entry.contentRect?.height ?? 0
const containerHeight = contentContainerRef.current?.clientHeight ?? 0
setEditorHeight(Math.max(containerHeight - errorHeight, 0))
}
@ -226,37 +142,20 @@ const ValueContent = ({
>
<div className={cn('relative grow')} style={{ height: `${editorHeight}px` }}>
{showTextEditor && (
<>
{isTruncated && <LargeDataAlert className="absolute left-3 right-3 top-1" />}
{
currentVar.value_type === VarType.string
? (
<DisplayContent
previewType={PreviewType.Markdown}
varType={currentVar.value_type}
mdString={typeof textValue === 'string' ? textValue : String(textValue)}
readonly={textEditorDisabled}
handleTextChange={handleTextChange}
className={cn(isTruncated && 'pt-[36px]')}
/>
)
: (
<Textarea
readOnly={textEditorDisabled}
disabled={textEditorDisabled || isTruncated}
className={cn('h-full', isTruncated && 'pt-[48px]')}
value={textValue}
onChange={e => handleTextChange(e.target.value)}
/>
)
}
</>
<TextEditorSection
currentVar={currentVar}
value={value}
textEditorDisabled={textEditorDisabled}
isTruncated={isTruncated}
onTextChange={handleTextChange}
/>
)}
{showBoolEditor && (
<div className="w-[295px]">
<BoolValue
value={currentVar.value as boolean}
onChange={(newValue) => {
setValue(newValue)
debounceValueChange(currentVar.id, newValue)
}}
/>
@ -264,78 +163,41 @@ const ValueContent = ({
)}
{
showBoolArrayEditor && (
<div className="w-[295px] space-y-1">
{(currentVar.value as boolean[]).map((v: boolean, i: number) => (
<BoolValue
key={i}
value={v}
onChange={(newValue) => {
const newArray = [...(currentVar.value as boolean[])]
newArray[i] = newValue
debounceValueChange(currentVar.id, newArray)
}}
/>
))}
</div>
<BoolArraySection
values={currentVar.value as boolean[]}
onChange={(newArray) => {
setValue(newArray)
debounceValueChange(currentVar.id, newArray)
}}
/>
)
}
{showJSONEditor && (
hasChunks
? (
<DisplayContent
previewType={PreviewType.Chunks}
varType={currentVar.value_type}
schemaType={currentVar.schemaType ?? ''}
jsonString={jsonValue ?? '{}'}
readonly={JSONEditorDisabled}
handleEditorChange={handleEditorChange}
/>
)
: (
<SchemaEditor
readonly={JSONEditorDisabled || isTruncated}
className="overflow-y-auto"
hideTopMenu
schema={jsonValue}
onUpdate={handleEditorChange}
isTruncated={isTruncated}
/>
)
<JsonEditorSection
hasChunks={hasChunks}
schemaType={currentVar.schemaType}
valueType={currentVar.value_type}
json={json}
readonly={JSONEditorDisabled}
isTruncated={isTruncated}
onChange={handleEditorChange}
/>
)}
{showFileEditor && (
<div className="max-w-[460px]">
<FileUploaderInAttachmentWrapper
value={fileValue}
onChange={handleFileChange}
fileConfig={{
allowed_file_types: [
SupportUploadFileTypes.image,
SupportUploadFileTypes.document,
SupportUploadFileTypes.audio,
SupportUploadFileTypes.video,
],
allowed_file_extensions: [
...FILE_EXTS[SupportUploadFileTypes.image],
...FILE_EXTS[SupportUploadFileTypes.document],
...FILE_EXTS[SupportUploadFileTypes.audio],
...FILE_EXTS[SupportUploadFileTypes.video],
],
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
number_limits: currentVar.value_type === VarType.file ? 1 : fileUploadConfig?.workflow_file_upload_limit || 5,
fileUploadConfig,
preview_config: {
mode: PreviewMode.NewPage,
file_type_list: ['application/pdf'],
},
}}
isDisabled={textEditorDisabled}
/>
</div>
<FileEditorSection
currentVar={currentVar}
fileValue={fileValue}
fileUploadConfig={fileUploadConfig}
textEditorDisabled={textEditorDisabled}
onChange={handleFileChange}
/>
)}
</div>
<div ref={errorMessageRef} className="shrink-0">
{parseError && <ErrorMessage className="mt-1" message={parseError.message} />}
{validationError && <ErrorMessage className="mt-1" message={validationError} />}
<ErrorMessages
parseError={parseError}
validationError={validationError}
/>
</div>
</div>
)

View File

@ -9,6 +9,7 @@ import { getLocaleOnServer } from '@/i18n-config/server'
import { ToastProvider } from './components/base/toast'
import { ToastHost } from './components/base/ui/toast'
import { TooltipProvider } from './components/base/ui/tooltip'
import PartnerStackCookieRecorder from './components/billing/partner-stack/cookie-recorder'
import { AgentationLoader } from './components/devtools/agentation-loader'
import { ReactScanLoader } from './components/devtools/react-scan/loader'
import { I18nServerProvider } from './components/provider/i18n-server'
@ -67,6 +68,7 @@ const LocaleLayout = async ({
<TanstackQueryInitializer>
<I18nServerProvider>
<ToastHost timeout={5000} limit={3} />
<PartnerStackCookieRecorder />
<ToastProvider>
<GlobalPublicStoreProvider>
<TooltipProvider delay={300} closeDelay={200}>

View File

@ -1,18 +1,11 @@
'use client'
import { useEffect } from 'react'
import { useSearchParams } from '@/next/navigation'
import usePSInfo from '../components/billing/partner-stack/use-ps-info'
import NormalForm from './normal-form'
import OneMoreStep from './one-more-step'
const SignIn = () => {
const searchParams = useSearchParams()
const step = searchParams.get('step')
const { saveOrUpdate } = usePSInfo()
useEffect(() => {
saveOrUpdate()
}, [])
if (step === 'next')
return <OneMoreStep />

View File

@ -4702,7 +4702,7 @@
},
"app/components/workflow/nodes/_base/components/before-run-form/index.tsx": {
"ts/no-explicit-any": {
"count": 11
"count": 5
}
},
"app/components/workflow/nodes/_base/components/config-vision.tsx": {
@ -4765,9 +4765,6 @@
"app/components/workflow/nodes/_base/components/form-input-item.tsx": {
"no-restricted-imports": {
"count": 1
},
"react/unsupported-syntax": {
"count": 1
}
},
"app/components/workflow/nodes/_base/components/form-input-type-switch.tsx": {
@ -4906,13 +4903,7 @@
},
"app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx": {
"no-restricted-imports": {
"count": 2
},
"react/set-state-in-effect": {
"count": 1
},
"ts/no-explicit-any": {
"count": 3
}
},
"app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx": {
@ -4986,14 +4977,6 @@
"count": 2
}
},
"app/components/workflow/nodes/_base/node.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 3
}
},
"app/components/workflow/nodes/_base/types.ts": {
"erasable-syntax-only/enums": {
"count": 1
@ -6152,14 +6135,6 @@
"count": 1
}
},
"app/components/workflow/panel/chat-variable-panel/components/variable-modal.tsx": {
"react/set-state-in-effect": {
"count": 8
},
"ts/no-explicit-any": {
"count": 3
}
},
"app/components/workflow/panel/chat-variable-panel/components/variable-type-select.tsx": {
"no-restricted-imports": {
"count": 1
@ -6280,11 +6255,6 @@
"count": 1
}
},
"app/components/workflow/run/output-panel.tsx": {
"ts/no-explicit-any": {
"count": 3
}
},
"app/components/workflow/run/result-panel.tsx": {
"ts/no-explicit-any": {
"count": 4
@ -6328,14 +6298,6 @@
"count": 2
}
},
"app/components/workflow/selection-contextmenu.tsx": {
"erasable-syntax-only/enums": {
"count": 1
},
"ts/no-explicit-any": {
"count": 4
}
},
"app/components/workflow/skill/editor/skill-editor/plugins/file-picker-block.tsx": {
"no-restricted-imports": {
"count": 1
@ -6380,8 +6342,11 @@
"no-restricted-imports": {
"count": 1
},
"perfectionist/sort-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 2
"count": 1
}
},
"app/components/workflow/utils/data-source.ts": {
@ -6452,13 +6417,7 @@
},
"app/components/workflow/variable-inspect/value-content.tsx": {
"react/set-state-in-effect": {
"count": 1
},
"regexp/no-super-linear-backtracking": {
"count": 1
},
"regexp/no-unused-capturing-group": {
"count": 2
"count": 5
}
},
"app/components/workflow/workflow-history-store.tsx": {