Merge branch 'main' into yanli/fix-iter-log

This commit is contained in:
Stephen Zhou 2026-03-25 18:55:47 +08:00 committed by GitHub
commit ead33f2914
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
115 changed files with 18846 additions and 2878 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,350 @@
import type { ReactNode } from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import WorkflowApp from '../index'
const mockSetTriggerStatuses = vi.fn()
const mockSetInputs = vi.fn()
const mockSetShowInputsPanel = vi.fn()
const mockSetShowDebugAndPreviewPanel = vi.fn()
const mockWorkflowStoreSetState = vi.fn()
const mockDebouncedCancel = vi.fn()
const mockFetchRunDetail = vi.fn()
const mockInitialNodes = vi.fn()
const mockInitialEdges = vi.fn()
const mockGetWorkflowRunAndTraceUrl = vi.fn()
let appStoreState: {
appDetail?: {
id: string
mode: string
}
}
let workflowInitState: {
data: {
graph: {
nodes: Array<Record<string, unknown>>
edges: Array<Record<string, unknown>>
viewport: { x: number, y: number, zoom: number }
}
features: Record<string, unknown>
} | null
isLoading: boolean
fileUploadConfigResponse: Record<string, unknown> | null
}
let appContextState: {
isLoadingCurrentWorkspace: boolean
currentWorkspace: {
id?: string
}
}
let appTriggersState: {
data?: {
data: Array<{
node_id: string
status: string
}>
}
}
let searchParamsValue: string | null = null
const mockWorkflowStore = {
setState: mockWorkflowStoreSetState,
getState: () => ({
setInputs: mockSetInputs,
setShowInputsPanel: mockSetShowInputsPanel,
setShowDebugAndPreviewPanel: mockSetShowDebugAndPreviewPanel,
debouncedSyncWorkflowDraft: {
cancel: mockDebouncedCancel,
},
}),
}
vi.mock('@/app/components/app/store', () => ({
useStore: <T,>(selector: (state: typeof appStoreState) => T) => selector(appStoreState),
}))
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => mockWorkflowStore,
}))
vi.mock('@/app/components/workflow/store/trigger-status', () => ({
useTriggerStatusStore: () => ({
setTriggerStatuses: mockSetTriggerStatuses,
}),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => appContextState,
}))
vi.mock('@/next/navigation', () => ({
useSearchParams: () => ({
get: (key: string) => (key === 'replayRunId' ? searchParamsValue : null),
}),
}))
vi.mock('@/service/log', () => ({
fetchRunDetail: (...args: unknown[]) => mockFetchRunDetail(...args),
}))
vi.mock('@/service/use-tools', () => ({
useAppTriggers: () => appTriggersState,
}))
vi.mock('@/app/components/workflow-app/hooks/use-workflow-init', () => ({
useWorkflowInit: () => workflowInitState,
}))
vi.mock('@/app/components/workflow-app/hooks/use-get-run-and-trace-url', () => ({
useGetRunAndTraceUrl: () => ({
getWorkflowRunAndTraceUrl: mockGetWorkflowRunAndTraceUrl,
}),
}))
vi.mock('@/app/components/workflow/utils', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/workflow/utils')>()
return {
...actual,
initialNodes: (...args: unknown[]) => mockInitialNodes(...args),
initialEdges: (...args: unknown[]) => mockInitialEdges(...args),
}
})
vi.mock('@/app/components/base/loading', () => ({
default: () => <div data-testid="loading">loading</div>,
}))
vi.mock('@/app/components/base/features', () => ({
FeaturesProvider: ({
features,
children,
}: {
features: Record<string, unknown>
children: ReactNode
}) => (
<div data-testid="features-provider" data-features={JSON.stringify(features)}>
{children}
</div>
),
}))
vi.mock('@/app/components/workflow', () => ({
default: ({
nodes,
edges,
children,
}: {
nodes: Array<Record<string, unknown>>
edges: Array<Record<string, unknown>>
children: ReactNode
}) => (
<div data-testid="workflow-default-context" data-nodes={JSON.stringify(nodes)} data-edges={JSON.stringify(edges)}>
{children}
</div>
),
}))
vi.mock('@/app/components/workflow/context', () => ({
WorkflowContextProvider: ({
children,
}: {
injectWorkflowStoreSliceFn: unknown
children: ReactNode
}) => (
<div data-testid="workflow-context-provider">{children}</div>
),
}))
vi.mock('@/app/components/workflow-app/components/workflow-main', () => ({
default: ({
nodes,
edges,
viewport,
}: {
nodes: Array<Record<string, unknown>>
edges: Array<Record<string, unknown>>
viewport: Record<string, unknown>
}) => (
<div
data-testid="workflow-app-main"
data-nodes={JSON.stringify(nodes)}
data-edges={JSON.stringify(edges)}
data-viewport={JSON.stringify(viewport)}
/>
),
}))
describe('WorkflowApp', () => {
beforeEach(() => {
vi.clearAllMocks()
appStoreState = {
appDetail: {
id: 'app-1',
mode: 'workflow',
},
}
workflowInitState = {
data: {
graph: {
nodes: [{ id: 'raw-node' }],
edges: [{ id: 'raw-edge' }],
viewport: { x: 1, y: 2, zoom: 3 },
},
features: {
file_upload: {
enabled: true,
},
},
},
isLoading: false,
fileUploadConfigResponse: { enabled: true },
}
appContextState = {
isLoadingCurrentWorkspace: false,
currentWorkspace: { id: 'workspace-1' },
}
appTriggersState = {}
searchParamsValue = null
mockFetchRunDetail.mockResolvedValue({ inputs: null })
mockInitialNodes.mockReturnValue([{ id: 'node-1' }])
mockInitialEdges.mockReturnValue([{ id: 'edge-1' }])
mockGetWorkflowRunAndTraceUrl.mockReturnValue({ runUrl: '/runs/run-1' })
})
it('should render the loading shell while workflow data is still loading', () => {
workflowInitState = {
data: null,
isLoading: true,
fileUploadConfigResponse: null,
}
render(<WorkflowApp />)
expect(screen.getByTestId('loading')).toBeInTheDocument()
expect(screen.queryByTestId('workflow-app-main')).not.toBeInTheDocument()
})
it('should render the workflow app shell and sync trigger statuses when data is ready', () => {
appTriggersState = {
data: {
data: [
{ node_id: 'trigger-enabled', status: 'enabled' },
{ node_id: 'trigger-disabled', status: 'paused' },
],
},
}
render(<WorkflowApp />)
expect(screen.getByTestId('workflow-context-provider')).toBeInTheDocument()
expect(screen.getByTestId('workflow-default-context')).toHaveAttribute('data-nodes', JSON.stringify([{ id: 'node-1' }]))
expect(screen.getByTestId('workflow-default-context')).toHaveAttribute('data-edges', JSON.stringify([{ id: 'edge-1' }]))
expect(screen.getByTestId('workflow-app-main')).toHaveAttribute('data-viewport', JSON.stringify({ x: 1, y: 2, zoom: 3 }))
expect(screen.getByTestId('features-provider')).toBeInTheDocument()
expect(mockSetTriggerStatuses).toHaveBeenCalledWith({
'trigger-enabled': 'enabled',
'trigger-disabled': 'disabled',
})
})
it('should not sync trigger statuses when trigger data is unavailable', () => {
render(<WorkflowApp />)
expect(screen.getByTestId('workflow-app-main')).toBeInTheDocument()
expect(mockSetTriggerStatuses).not.toHaveBeenCalled()
})
it('should replay workflow inputs from replayRunId and clean up workflow state on unmount', async () => {
searchParamsValue = 'run-1'
mockFetchRunDetail.mockResolvedValue({
inputs: '{"sys.query":"hidden","foo":"bar","count":2,"flag":true,"obj":{"nested":true},"nil":null}',
})
const { unmount } = render(<WorkflowApp />)
await waitFor(() => {
expect(mockFetchRunDetail).toHaveBeenCalledWith('/runs/run-1')
expect(mockSetInputs).toHaveBeenCalledWith({
foo: 'bar',
count: 2,
flag: true,
obj: '{"nested":true}',
nil: '',
})
expect(mockSetShowInputsPanel).toHaveBeenCalledWith(true)
expect(mockSetShowDebugAndPreviewPanel).toHaveBeenCalledWith(true)
})
unmount()
expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ isWorkflowDataLoaded: false })
expect(mockDebouncedCancel).toHaveBeenCalled()
})
it('should skip replay lookups when replayRunId is missing', () => {
render(<WorkflowApp />)
expect(mockGetWorkflowRunAndTraceUrl).not.toHaveBeenCalled()
expect(mockFetchRunDetail).not.toHaveBeenCalled()
expect(mockSetInputs).not.toHaveBeenCalled()
})
it('should skip replay fetches when the resolved run url is empty', async () => {
searchParamsValue = 'run-1'
mockGetWorkflowRunAndTraceUrl.mockReturnValue({ runUrl: '' })
render(<WorkflowApp />)
await waitFor(() => {
expect(mockGetWorkflowRunAndTraceUrl).toHaveBeenCalledWith('run-1')
})
expect(mockFetchRunDetail).not.toHaveBeenCalled()
expect(mockSetInputs).not.toHaveBeenCalled()
})
it('should stop replay recovery when workflow run inputs cannot be parsed', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
searchParamsValue = 'run-1'
mockFetchRunDetail.mockResolvedValue({
inputs: '{invalid-json}',
})
render(<WorkflowApp />)
await waitFor(() => {
expect(mockFetchRunDetail).toHaveBeenCalledWith('/runs/run-1')
})
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to parse workflow run inputs',
expect.any(Error),
)
expect(mockSetInputs).not.toHaveBeenCalled()
expect(mockSetShowInputsPanel).not.toHaveBeenCalled()
expect(mockSetShowDebugAndPreviewPanel).not.toHaveBeenCalled()
consoleErrorSpy.mockRestore()
})
it('should ignore replay inputs when they only contain sys variables', async () => {
searchParamsValue = 'run-1'
mockFetchRunDetail.mockResolvedValue({
inputs: '{"sys.query":"hidden","sys.user_id":"u-1"}',
})
render(<WorkflowApp />)
await waitFor(() => {
expect(mockFetchRunDetail).toHaveBeenCalledWith('/runs/run-1')
})
expect(mockSetInputs).not.toHaveBeenCalled()
expect(mockSetShowInputsPanel).not.toHaveBeenCalled()
expect(mockSetShowDebugAndPreviewPanel).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,90 @@
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
import {
buildInitialFeatures,
buildTriggerStatusMap,
coerceReplayUserInputs,
} from '../utils'
describe('workflow-app utils', () => {
it('should map trigger statuses to enabled and disabled states', () => {
expect(buildTriggerStatusMap([
{ node_id: 'node-1', status: 'enabled' },
{ node_id: 'node-2', status: 'disabled' },
{ node_id: 'node-3', status: 'paused' },
])).toEqual({
'node-1': 'enabled',
'node-2': 'disabled',
'node-3': 'disabled',
})
})
it('should coerce replay run inputs, omit sys keys, and stringify complex values', () => {
expect(coerceReplayUserInputs({
'sys.query': 'hidden',
'query': 'hello',
'count': 3,
'enabled': true,
'nullable': null,
'metadata': { nested: true },
})).toEqual({
query: 'hello',
count: 3,
enabled: true,
nullable: '',
metadata: '{"nested":true}',
})
expect(coerceReplayUserInputs('invalid')).toBeNull()
expect(coerceReplayUserInputs(null)).toBeNull()
})
it('should build initial features with file-upload and feature fallbacks', () => {
const result = buildInitialFeatures({
file_upload: {
enabled: true,
allowed_file_types: [SupportUploadFileTypes.image],
allowed_file_extensions: ['.png'],
allowed_file_upload_methods: [TransferMethod.local_file],
number_limits: 2,
image: {
enabled: true,
number_limits: 5,
transfer_methods: [TransferMethod.remote_url],
},
},
opening_statement: 'hello',
suggested_questions: ['Q1'],
suggested_questions_after_answer: { enabled: true },
speech_to_text: { enabled: true },
text_to_speech: { enabled: true },
retriever_resource: { enabled: true },
sensitive_word_avoidance: { enabled: true },
}, { enabled: true } as never)
expect(result).toMatchObject({
file: {
enabled: true,
allowed_file_types: [SupportUploadFileTypes.image],
allowed_file_extensions: ['.png'],
allowed_file_upload_methods: [TransferMethod.local_file],
number_limits: 2,
fileUploadConfig: { enabled: true },
image: {
enabled: true,
number_limits: 5,
transfer_methods: [TransferMethod.remote_url],
},
},
opening: {
enabled: true,
opening_statement: 'hello',
suggested_questions: ['Q1'],
},
suggested: { enabled: true },
speech2text: { enabled: true },
text2speech: { enabled: true },
citation: { enabled: true },
moderation: { enabled: true },
})
})
})

View File

@ -0,0 +1,494 @@
import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { DSL_EXPORT_CHECK } from '@/app/components/workflow/constants'
import { BlockEnum } from '@/app/components/workflow/types'
import WorkflowChildren from '../workflow-children'
type WorkflowStoreState = {
showFeaturesPanel: boolean
showImportDSLModal: boolean
setShowImportDSLModal: (show: boolean) => void
showOnboarding: boolean
setShowOnboarding: (show: boolean) => void
setHasSelectedStartNode: (selected: boolean) => void
setShouldAutoOpenStartNodeSelector: (open: boolean) => void
}
type TriggerPluginConfig = {
plugin_id: string
provider_name: string
provider_type: string
event_name: string
event_label: string
event_description: string
output_schema: Record<string, unknown>
paramSchemas: Array<Record<string, unknown>>
params: Record<string, unknown>
subscription_id: string
plugin_unique_identifier: string
is_team_authorization: boolean
meta?: Record<string, unknown>
}
const mockSetShowImportDSLModal = vi.fn()
const mockSetShowOnboarding = vi.fn()
const mockSetHasSelectedStartNode = vi.fn()
const mockSetShouldAutoOpenStartNodeSelector = vi.fn()
const mockSetNodes = vi.fn()
const mockSetEdges = vi.fn()
const mockHandleSyncWorkflowDraft = vi.fn()
const mockHandleOnboardingClose = vi.fn()
const mockHandlePaneContextmenuCancel = vi.fn()
const mockHandleExportDSL = vi.fn()
const mockExportCheck = vi.fn()
const mockAutoGenerateWebhookUrl = vi.fn()
let workflowStoreState: WorkflowStoreState
let eventSubscription: ((value: { type: string, payload: { data: Array<Record<string, unknown>> } }) => void) | null = null
let lastGenerateNodeInput: Record<string, unknown> | null = null
vi.mock('reactflow', () => ({
useStoreApi: () => ({
getState: () => ({
setNodes: mockSetNodes,
setEdges: mockSetEdges,
}),
}),
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: <T,>(selector: (state: WorkflowStoreState) => T) => selector(workflowStoreState),
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: {
useSubscription: (callback: typeof eventSubscription) => {
eventSubscription = callback
},
},
}),
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useAutoGenerateWebhookUrl: () => mockAutoGenerateWebhookUrl,
useDSL: () => ({
exportCheck: mockExportCheck,
handleExportDSL: mockHandleExportDSL,
}),
usePanelInteractions: () => ({
handlePaneContextmenuCancel: mockHandlePaneContextmenuCancel,
}),
}))
vi.mock('@/app/components/workflow/hooks/use-nodes-sync-draft', () => ({
useNodesSyncDraft: () => ({
handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft,
}),
}))
vi.mock('@/app/components/workflow/utils', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/workflow/utils')>()
return {
...actual,
generateNewNode: (args: Record<string, unknown>) => {
lastGenerateNodeInput = args
return {
newNode: {
id: 'new-node-id',
position: args.position,
data: args.data,
},
}
},
}
})
vi.mock('@/app/components/workflow-app/hooks', () => ({
useAvailableNodesMetaData: () => ({
nodesMap: {
[BlockEnum.Start]: {
defaultValue: {
title: 'Start Title',
desc: 'Start description',
config: {
image: false,
},
},
},
[BlockEnum.TriggerPlugin]: {
defaultValue: {
title: 'Plugin title',
desc: 'Plugin description',
config: {
baseConfig: 'base',
},
},
},
},
}),
}))
vi.mock('@/app/components/workflow-app/hooks/use-auto-onboarding', () => ({
useAutoOnboarding: () => ({
handleOnboardingClose: mockHandleOnboardingClose,
}),
}))
vi.mock('@/app/components/workflow/plugin-dependency', () => ({
default: () => <div data-testid="plugin-dependency">plugin-dependency</div>,
}))
vi.mock('@/app/components/workflow-app/components/workflow-header', () => ({
default: () => <div data-testid="workflow-header">workflow-header</div>,
}))
vi.mock('@/app/components/workflow-app/components/workflow-panel', () => ({
default: () => <div data-testid="workflow-panel">workflow-panel</div>,
}))
vi.mock('@/next/dynamic', async () => {
const ReactModule = await import('react')
return {
default: (
loader: () => Promise<{ default: React.ComponentType<Record<string, unknown>> }>,
) => {
const DynamicComponent = (props: Record<string, unknown>) => {
const [Loaded, setLoaded] = ReactModule.useState<React.ComponentType<Record<string, unknown>> | null>(null)
ReactModule.useEffect(() => {
let mounted = true
loader().then((mod) => {
if (mounted)
setLoaded(() => mod.default)
})
return () => {
mounted = false
}
}, [])
return Loaded ? <Loaded {...props} /> : null
}
return DynamicComponent
},
}
})
vi.mock('@/app/components/workflow/features', () => ({
default: () => <div data-testid="workflow-features">features</div>,
}))
vi.mock('@/app/components/workflow/update-dsl-modal', () => ({
default: ({
onCancel,
onBackup,
onImport,
}: {
onCancel: () => void
onBackup: () => void
onImport: () => void
}) => (
<div data-testid="update-dsl-modal">
<button type="button" onClick={onCancel}>cancel-import-dsl</button>
<button type="button" onClick={onBackup}>backup-dsl</button>
<button type="button" onClick={onImport}>import-dsl</button>
</div>
),
}))
vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({
default: ({
envList,
onConfirm,
onClose,
}: {
envList: Array<Record<string, unknown>>
onConfirm: () => void
onClose: () => void
}) => (
<div data-testid="dsl-export-confirm-modal" data-env-count={String(envList.length)}>
<button type="button" onClick={onConfirm}>confirm-export-dsl</button>
<button type="button" onClick={onClose}>close-export-dsl</button>
</div>
),
}))
vi.mock('@/app/components/workflow-app/components/workflow-onboarding-modal', () => ({
default: ({
onClose,
onSelectStartNode,
}: {
isShow: boolean
onClose: () => void
onSelectStartNode: (nodeType: BlockEnum, config?: TriggerPluginConfig) => void
}) => (
<div data-testid="workflow-onboarding-modal">
<button type="button" onClick={onClose}>close-onboarding</button>
<button type="button" onClick={() => onSelectStartNode(BlockEnum.Start)}>select-start-node</button>
<button
type="button"
onClick={() => onSelectStartNode(BlockEnum.Start, {
title: 'Configured Start Title',
desc: 'Configured Start Description',
config: { image: true, custom: 'config' },
extra: 'field',
} as never)}
>
select-start-node-with-config
</button>
<button
type="button"
onClick={() => onSelectStartNode(BlockEnum.TriggerPlugin, {
plugin_id: 'plugin-id',
provider_name: 'provider-name',
provider_type: 'tool',
event_name: 'event-name',
event_label: 'Event Label',
event_description: 'Event Description',
output_schema: { output: true },
paramSchemas: [{ name: 'api_key' }],
params: { token: 'abc' },
subscription_id: 'subscription-id',
plugin_unique_identifier: 'plugin-unique',
is_team_authorization: true,
meta: { source: 'plugin' },
})}
>
select-trigger-plugin
</button>
<button
type="button"
onClick={() => onSelectStartNode(BlockEnum.TriggerPlugin, {
plugin_id: 'plugin-id-2',
provider_name: 'provider-name-2',
provider_type: 'tool',
event_name: 'event-name-2',
event_label: '',
event_description: '',
output_schema: {},
paramSchemas: undefined,
params: {},
subscription_id: 'subscription-id-2',
plugin_unique_identifier: 'plugin-unique-2',
is_team_authorization: false,
} as never)}
>
select-trigger-plugin-fallback
</button>
</div>
),
}))
describe('WorkflowChildren', () => {
beforeEach(() => {
vi.clearAllMocks()
workflowStoreState = {
showFeaturesPanel: false,
showImportDSLModal: false,
setShowImportDSLModal: mockSetShowImportDSLModal,
showOnboarding: false,
setShowOnboarding: mockSetShowOnboarding,
setHasSelectedStartNode: mockSetHasSelectedStartNode,
setShouldAutoOpenStartNodeSelector: mockSetShouldAutoOpenStartNodeSelector,
}
eventSubscription = null
lastGenerateNodeInput = null
mockHandleSyncWorkflowDraft.mockImplementation((_force?: boolean, _notRefresh?: boolean, callback?: { onSuccess?: () => void }) => {
callback?.onSuccess?.()
})
})
it('should render feature panel, import modal actions, and default workflow chrome', async () => {
const user = userEvent.setup()
workflowStoreState = {
...workflowStoreState,
showFeaturesPanel: true,
showImportDSLModal: true,
}
render(<WorkflowChildren />)
expect(screen.getByTestId('plugin-dependency')).toBeInTheDocument()
expect(screen.getByTestId('workflow-header')).toBeInTheDocument()
expect(screen.getByTestId('workflow-panel')).toBeInTheDocument()
expect(await screen.findByTestId('workflow-features')).toBeInTheDocument()
expect(screen.getByTestId('update-dsl-modal')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: /cancel-import-dsl/i }))
await user.click(screen.getByRole('button', { name: /backup-dsl/i }))
await user.click(screen.getByRole('button', { name: /^import-dsl$/i }))
expect(mockSetShowImportDSLModal).toHaveBeenCalledWith(false)
expect(mockExportCheck).toHaveBeenCalled()
expect(mockHandlePaneContextmenuCancel).toHaveBeenCalled()
})
it('should react to DSL export check events by showing the confirm modal and closing it', async () => {
const user = userEvent.setup()
render(<WorkflowChildren />)
await act(async () => {
eventSubscription?.({
type: DSL_EXPORT_CHECK,
payload: {
data: [{ id: 'env-1' }, { id: 'env-2' }],
},
})
})
expect(await screen.findByTestId('dsl-export-confirm-modal')).toHaveAttribute('data-env-count', '2')
await user.click(screen.getByRole('button', { name: /confirm-export-dsl/i }))
await user.click(screen.getByRole('button', { name: /close-export-dsl/i }))
expect(mockHandleExportDSL).toHaveBeenCalled()
expect(screen.queryByTestId('dsl-export-confirm-modal')).not.toBeInTheDocument()
})
it('should ignore unrelated workflow events when listening for DSL export checks', async () => {
render(<WorkflowChildren />)
await act(async () => {
eventSubscription?.({
type: 'UNRELATED_EVENT',
payload: {
data: [{ id: 'env-1' }],
},
})
})
expect(screen.queryByTestId('dsl-export-confirm-modal')).not.toBeInTheDocument()
})
it('should close onboarding through the onboarding hook callback', async () => {
const user = userEvent.setup()
workflowStoreState = {
...workflowStoreState,
showOnboarding: true,
}
render(<WorkflowChildren />)
expect(await screen.findByTestId('workflow-onboarding-modal')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: /close-onboarding/i }))
expect(mockHandleOnboardingClose).toHaveBeenCalled()
})
it('should create a start node, sync draft, and auto-generate webhook url after selecting a start node', async () => {
const user = userEvent.setup()
workflowStoreState = {
...workflowStoreState,
showOnboarding: true,
}
render(<WorkflowChildren />)
await user.click(await screen.findByRole('button', { name: /^select-start-node$/i }))
expect(lastGenerateNodeInput).toMatchObject({
data: {
title: 'Start Title',
desc: 'Start description',
config: {
image: false,
},
},
})
expect(mockSetNodes).toHaveBeenCalledWith([expect.objectContaining({ id: 'new-node-id' })])
expect(mockSetEdges).toHaveBeenCalledWith([])
expect(mockSetShowOnboarding).toHaveBeenCalledWith(false)
expect(mockSetHasSelectedStartNode).toHaveBeenCalledWith(true)
expect(mockSetShouldAutoOpenStartNodeSelector).toHaveBeenCalledWith(true)
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledWith(true, false, expect.any(Object))
expect(mockAutoGenerateWebhookUrl).toHaveBeenCalledWith('new-node-id')
})
it('should merge non-trigger start node config directly into the default node data', async () => {
const user = userEvent.setup()
workflowStoreState = {
...workflowStoreState,
showOnboarding: true,
}
render(<WorkflowChildren />)
await user.click(await screen.findByRole('button', { name: /select-start-node-with-config/i }))
expect(lastGenerateNodeInput).toMatchObject({
data: {
title: 'Configured Start Title',
desc: 'Configured Start Description',
config: {
image: true,
custom: 'config',
},
extra: 'field',
},
})
})
it('should merge trigger plugin defaults and config before creating the node', async () => {
const user = userEvent.setup()
workflowStoreState = {
...workflowStoreState,
showOnboarding: true,
}
render(<WorkflowChildren />)
await user.click(await screen.findByRole('button', { name: /^select-trigger-plugin$/i }))
expect(lastGenerateNodeInput).toMatchObject({
data: {
plugin_id: 'plugin-id',
provider_id: 'provider-name',
provider_name: 'provider-name',
provider_type: 'tool',
event_name: 'event-name',
event_label: 'Event Label',
event_description: 'Event Description',
title: 'Event Label',
desc: 'Event Description',
output_schema: { output: true },
parameters_schema: [{ name: 'api_key' }],
config: {
baseConfig: 'base',
token: 'abc',
},
subscription_id: 'subscription-id',
plugin_unique_identifier: 'plugin-unique',
is_team_authorization: true,
meta: { source: 'plugin' },
},
})
})
it('should fall back to plugin default title and description when trigger labels are missing', async () => {
const user = userEvent.setup()
workflowStoreState = {
...workflowStoreState,
showOnboarding: true,
}
render(<WorkflowChildren />)
await user.click(await screen.findByRole('button', { name: /select-trigger-plugin-fallback/i }))
expect(lastGenerateNodeInput).toMatchObject({
data: {
title: 'Plugin title',
desc: 'Plugin description',
parameters_schema: [],
config: {
baseConfig: 'base',
},
},
})
})
})

View File

@ -0,0 +1,277 @@
import type { ReactNode } from 'react'
import type { WorkflowProps } from '@/app/components/workflow'
import { fireEvent, render, screen } from '@testing-library/react'
import WorkflowMain from '../workflow-main'
const mockSetFeatures = vi.fn()
const mockSetConversationVariables = vi.fn()
const mockSetEnvironmentVariables = vi.fn()
const hookFns = {
doSyncWorkflowDraft: vi.fn(),
syncWorkflowDraftWhenPageClose: vi.fn(),
handleRefreshWorkflowDraft: vi.fn(),
handleBackupDraft: vi.fn(),
handleLoadBackupDraft: vi.fn(),
handleRestoreFromPublishedWorkflow: vi.fn(),
handleRun: vi.fn(),
handleStopRun: vi.fn(),
handleStartWorkflowRun: vi.fn(),
handleWorkflowStartRunInChatflow: vi.fn(),
handleWorkflowStartRunInWorkflow: vi.fn(),
handleWorkflowTriggerScheduleRunInWorkflow: vi.fn(),
handleWorkflowTriggerWebhookRunInWorkflow: vi.fn(),
handleWorkflowTriggerPluginRunInWorkflow: vi.fn(),
handleWorkflowRunAllTriggersInWorkflow: vi.fn(),
getWorkflowRunAndTraceUrl: vi.fn(),
exportCheck: vi.fn(),
handleExportDSL: vi.fn(),
fetchInspectVars: vi.fn(),
hasNodeInspectVars: vi.fn(),
hasSetInspectVar: vi.fn(),
fetchInspectVarValue: vi.fn(),
editInspectVarValue: vi.fn(),
renameInspectVarName: vi.fn(),
appendNodeInspectVars: vi.fn(),
deleteInspectVar: vi.fn(),
deleteNodeInspectorVars: vi.fn(),
deleteAllInspectorVars: vi.fn(),
isInspectVarEdited: vi.fn(),
resetToLastRunVar: vi.fn(),
invalidateSysVarValues: vi.fn(),
resetConversationVar: vi.fn(),
invalidateConversationVarValues: vi.fn(),
}
let capturedContextProps: Record<string, unknown> | null = null
type MockWorkflowWithInnerContextProps = Pick<WorkflowProps, 'nodes' | 'edges' | 'viewport' | 'onWorkflowDataUpdate'> & {
hooksStore?: Record<string, unknown>
children?: ReactNode
}
vi.mock('@/app/components/base/features/hooks', () => ({
useFeaturesStore: () => ({
getState: () => ({
setFeatures: mockSetFeatures,
}),
}),
}))
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => ({
getState: () => ({
setConversationVariables: mockSetConversationVariables,
setEnvironmentVariables: mockSetEnvironmentVariables,
}),
}),
}))
vi.mock('@/app/components/workflow', () => ({
WorkflowWithInnerContext: ({
nodes,
edges,
viewport,
onWorkflowDataUpdate,
hooksStore,
children,
}: MockWorkflowWithInnerContextProps) => {
capturedContextProps = {
nodes,
edges,
viewport,
hooksStore,
}
return (
<div data-testid="workflow-inner-context">
<button
type="button"
onClick={() => onWorkflowDataUpdate?.({
features: { file: { enabled: true } },
conversation_variables: [{ id: 'conversation-1' }],
environment_variables: [{ id: 'env-1' }],
})}
>
update-workflow-data
</button>
<button
type="button"
onClick={() => onWorkflowDataUpdate?.({
conversation_variables: [{ id: 'conversation-only' }],
})}
>
update-conversation-only
</button>
<button
type="button"
onClick={() => onWorkflowDataUpdate?.({})}
>
update-empty-payload
</button>
{children}
</div>
)
},
}))
vi.mock('@/app/components/workflow-app/hooks', () => ({
useAvailableNodesMetaData: () => ({ nodes: [{ id: 'start' }], nodesMap: { start: { id: 'start' } } }),
useConfigsMap: () => ({ flowId: 'app-1', flowType: 'app-flow', fileSettings: { enabled: true } }),
useDSL: () => ({ exportCheck: hookFns.exportCheck, handleExportDSL: hookFns.handleExportDSL }),
useGetRunAndTraceUrl: () => ({ getWorkflowRunAndTraceUrl: hookFns.getWorkflowRunAndTraceUrl }),
useInspectVarsCrud: () => ({
hasNodeInspectVars: hookFns.hasNodeInspectVars,
hasSetInspectVar: hookFns.hasSetInspectVar,
fetchInspectVarValue: hookFns.fetchInspectVarValue,
editInspectVarValue: hookFns.editInspectVarValue,
renameInspectVarName: hookFns.renameInspectVarName,
appendNodeInspectVars: hookFns.appendNodeInspectVars,
deleteInspectVar: hookFns.deleteInspectVar,
deleteNodeInspectorVars: hookFns.deleteNodeInspectorVars,
deleteAllInspectorVars: hookFns.deleteAllInspectorVars,
isInspectVarEdited: hookFns.isInspectVarEdited,
resetToLastRunVar: hookFns.resetToLastRunVar,
invalidateSysVarValues: hookFns.invalidateSysVarValues,
resetConversationVar: hookFns.resetConversationVar,
invalidateConversationVarValues: hookFns.invalidateConversationVarValues,
}),
useNodesSyncDraft: () => ({
doSyncWorkflowDraft: hookFns.doSyncWorkflowDraft,
syncWorkflowDraftWhenPageClose: hookFns.syncWorkflowDraftWhenPageClose,
}),
useSetWorkflowVarsWithValue: () => ({
fetchInspectVars: hookFns.fetchInspectVars,
}),
useWorkflowRefreshDraft: () => ({ handleRefreshWorkflowDraft: hookFns.handleRefreshWorkflowDraft }),
useWorkflowRun: () => ({
handleBackupDraft: hookFns.handleBackupDraft,
handleLoadBackupDraft: hookFns.handleLoadBackupDraft,
handleRestoreFromPublishedWorkflow: hookFns.handleRestoreFromPublishedWorkflow,
handleRun: hookFns.handleRun,
handleStopRun: hookFns.handleStopRun,
}),
useWorkflowStartRun: () => ({
handleStartWorkflowRun: hookFns.handleStartWorkflowRun,
handleWorkflowStartRunInChatflow: hookFns.handleWorkflowStartRunInChatflow,
handleWorkflowStartRunInWorkflow: hookFns.handleWorkflowStartRunInWorkflow,
handleWorkflowTriggerScheduleRunInWorkflow: hookFns.handleWorkflowTriggerScheduleRunInWorkflow,
handleWorkflowTriggerWebhookRunInWorkflow: hookFns.handleWorkflowTriggerWebhookRunInWorkflow,
handleWorkflowTriggerPluginRunInWorkflow: hookFns.handleWorkflowTriggerPluginRunInWorkflow,
handleWorkflowRunAllTriggersInWorkflow: hookFns.handleWorkflowRunAllTriggersInWorkflow,
}),
}))
vi.mock('../workflow-children', () => ({
default: () => <div data-testid="workflow-children">workflow-children</div>,
}))
describe('WorkflowMain', () => {
beforeEach(() => {
vi.clearAllMocks()
capturedContextProps = null
})
it('should render the inner workflow context with children and forwarded graph props', () => {
const nodes = [{ id: 'node-1' }]
const edges = [{ id: 'edge-1' }]
const viewport = { x: 1, y: 2, zoom: 1.5 }
render(
<WorkflowMain
nodes={nodes as never}
edges={edges as never}
viewport={viewport}
/>,
)
expect(screen.getByTestId('workflow-inner-context')).toBeInTheDocument()
expect(screen.getByTestId('workflow-children')).toBeInTheDocument()
expect(capturedContextProps).toMatchObject({
nodes,
edges,
viewport,
})
})
it('should update features and workflow variables when workflow data changes', () => {
render(
<WorkflowMain
nodes={[]}
edges={[]}
viewport={{ x: 0, y: 0, zoom: 1 }}
/>,
)
fireEvent.click(screen.getByRole('button', { name: /update-workflow-data/i }))
expect(mockSetFeatures).toHaveBeenCalledWith({ file: { enabled: true } })
expect(mockSetConversationVariables).toHaveBeenCalledWith([{ id: 'conversation-1' }])
expect(mockSetEnvironmentVariables).toHaveBeenCalledWith([{ id: 'env-1' }])
})
it('should only update the workflow store slices present in the payload', () => {
render(
<WorkflowMain
nodes={[]}
edges={[]}
viewport={{ x: 0, y: 0, zoom: 1 }}
/>,
)
fireEvent.click(screen.getByRole('button', { name: /update-conversation-only/i }))
expect(mockSetConversationVariables).toHaveBeenCalledWith([{ id: 'conversation-only' }])
expect(mockSetFeatures).not.toHaveBeenCalled()
expect(mockSetEnvironmentVariables).not.toHaveBeenCalled()
})
it('should ignore empty workflow data updates', () => {
render(
<WorkflowMain
nodes={[]}
edges={[]}
viewport={{ x: 0, y: 0, zoom: 1 }}
/>,
)
fireEvent.click(screen.getByRole('button', { name: /update-empty-payload/i }))
expect(mockSetFeatures).not.toHaveBeenCalled()
expect(mockSetConversationVariables).not.toHaveBeenCalled()
expect(mockSetEnvironmentVariables).not.toHaveBeenCalled()
})
it('should expose the composed workflow action hooks through hooksStore', () => {
render(
<WorkflowMain
nodes={[]}
edges={[]}
viewport={{ x: 0, y: 0, zoom: 1 }}
/>,
)
expect(capturedContextProps?.hooksStore).toMatchObject({
syncWorkflowDraftWhenPageClose: hookFns.syncWorkflowDraftWhenPageClose,
doSyncWorkflowDraft: hookFns.doSyncWorkflowDraft,
handleRefreshWorkflowDraft: hookFns.handleRefreshWorkflowDraft,
handleBackupDraft: hookFns.handleBackupDraft,
handleLoadBackupDraft: hookFns.handleLoadBackupDraft,
handleRestoreFromPublishedWorkflow: hookFns.handleRestoreFromPublishedWorkflow,
handleRun: hookFns.handleRun,
handleStopRun: hookFns.handleStopRun,
handleStartWorkflowRun: hookFns.handleStartWorkflowRun,
handleWorkflowStartRunInChatflow: hookFns.handleWorkflowStartRunInChatflow,
handleWorkflowStartRunInWorkflow: hookFns.handleWorkflowStartRunInWorkflow,
handleWorkflowTriggerScheduleRunInWorkflow: hookFns.handleWorkflowTriggerScheduleRunInWorkflow,
handleWorkflowTriggerWebhookRunInWorkflow: hookFns.handleWorkflowTriggerWebhookRunInWorkflow,
handleWorkflowTriggerPluginRunInWorkflow: hookFns.handleWorkflowTriggerPluginRunInWorkflow,
handleWorkflowRunAllTriggersInWorkflow: hookFns.handleWorkflowRunAllTriggersInWorkflow,
availableNodesMetaData: { nodes: [{ id: 'start' }], nodesMap: { start: { id: 'start' } } },
getWorkflowRunAndTraceUrl: hookFns.getWorkflowRunAndTraceUrl,
exportCheck: hookFns.exportCheck,
handleExportDSL: hookFns.handleExportDSL,
fetchInspectVars: hookFns.fetchInspectVars,
configsMap: { flowId: 'app-1', flowType: 'app-flow', fileSettings: { enabled: true } },
})
})
})

View File

@ -0,0 +1,214 @@
import type { ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import WorkflowPanel from '../workflow-panel'
type AppStoreState = {
appDetail?: {
id?: string
workflow?: {
id?: string
}
}
currentLogItem?: { id: string }
setCurrentLogItem: (item?: { id: string }) => void
showMessageLogModal: boolean
setShowMessageLogModal: (show: boolean) => void
currentLogModalActiveTab?: string
}
type WorkflowStoreState = {
historyWorkflowData?: Record<string, unknown>
showDebugAndPreviewPanel: boolean
showChatVariablePanel: boolean
showGlobalVariablePanel: boolean
}
const mockUseIsChatMode = vi.fn()
const mockSetCurrentLogItem = vi.fn()
const mockSetShowMessageLogModal = vi.fn()
let appStoreState: AppStoreState
let workflowStoreState: WorkflowStoreState
vi.mock('@/app/components/app/store', () => ({
useStore: <T,>(selector: (state: AppStoreState) => T) => selector(appStoreState),
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: <T,>(selector: (state: WorkflowStoreState) => T) => selector(workflowStoreState),
}))
vi.mock('@/app/components/workflow/panel', () => ({
default: ({
components,
versionHistoryPanelProps,
}: {
components?: {
left?: ReactNode
right?: ReactNode
}
versionHistoryPanelProps?: {
getVersionListUrl: string
deleteVersionUrl: (versionId: string) => string
restoreVersionUrl: (versionId: string) => string
updateVersionUrl: (versionId: string) => string
latestVersionId?: string
}
}) => (
<div
data-testid="panel"
data-version-list-url={versionHistoryPanelProps?.getVersionListUrl ?? ''}
data-delete-version-url={versionHistoryPanelProps?.deleteVersionUrl('version-1') ?? ''}
data-restore-version-url={versionHistoryPanelProps?.restoreVersionUrl('version-1') ?? ''}
data-update-version-url={versionHistoryPanelProps?.updateVersionUrl('version-1') ?? ''}
data-latest-version-id={versionHistoryPanelProps?.latestVersionId ?? ''}
>
<div data-testid="panel-left">{components?.left}</div>
<div data-testid="panel-right">{components?.right}</div>
</div>
),
}))
vi.mock('@/next/dynamic', () => ({
default: (loader: () => Promise<{ default: React.ComponentType<Record<string, unknown>> }>) => {
const LazyComp = React.lazy(loader)
return function DynamicWrapper(props: Record<string, unknown>) {
return React.createElement(
React.Suspense,
{ fallback: null },
React.createElement(LazyComp, props),
)
}
},
}))
vi.mock('@/app/components/base/message-log-modal', () => ({
default: ({
currentLogItem,
defaultTab,
onCancel,
}: {
currentLogItem?: { id: string }
defaultTab?: string
onCancel: () => void
}) => (
<div data-testid="message-log-modal" data-current-log-id={currentLogItem?.id ?? ''} data-default-tab={defaultTab ?? ''}>
<button type="button" onClick={onCancel}>close-message-log</button>
</div>
),
}))
vi.mock('@/app/components/workflow/panel/record', () => ({
default: () => <div data-testid="record-panel">record</div>,
}))
vi.mock('@/app/components/workflow/panel/chat-record', () => ({
default: () => <div data-testid="chat-record-panel">chat-record</div>,
}))
vi.mock('@/app/components/workflow/panel/debug-and-preview', () => ({
default: () => <div data-testid="debug-and-preview-panel">debug</div>,
}))
vi.mock('@/app/components/workflow/panel/workflow-preview', () => ({
default: () => <div data-testid="workflow-preview-panel">preview</div>,
}))
vi.mock('@/app/components/workflow/panel/chat-variable-panel', () => ({
default: () => <div data-testid="chat-variable-panel">chat-variable</div>,
}))
vi.mock('@/app/components/workflow/panel/global-variable-panel', () => ({
default: () => <div data-testid="global-variable-panel">global-variable</div>,
}))
vi.mock('@/app/components/workflow-app/hooks', () => ({
useIsChatMode: () => mockUseIsChatMode(),
}))
describe('WorkflowPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
appStoreState = {
appDetail: {
id: 'app-123',
workflow: {
id: 'workflow-version-id',
},
},
currentLogItem: { id: 'log-1' },
setCurrentLogItem: mockSetCurrentLogItem,
showMessageLogModal: false,
setShowMessageLogModal: mockSetShowMessageLogModal,
currentLogModalActiveTab: 'detail',
}
workflowStoreState = {
historyWorkflowData: undefined,
showDebugAndPreviewPanel: false,
showChatVariablePanel: false,
showGlobalVariablePanel: false,
}
mockUseIsChatMode.mockReturnValue(false)
})
it('should configure workflow version history urls and latest version id for the panel shell', async () => {
render(<WorkflowPanel />)
const panel = await screen.findByTestId('panel')
expect(panel).toHaveAttribute('data-version-list-url', '/apps/app-123/workflows')
expect(panel).toHaveAttribute('data-delete-version-url', '/apps/app-123/workflows/version-1')
expect(panel).toHaveAttribute('data-restore-version-url', '/apps/app-123/workflows/version-1/restore')
expect(panel).toHaveAttribute('data-update-version-url', '/apps/app-123/workflows/version-1')
expect(panel).toHaveAttribute('data-latest-version-id', 'workflow-version-id')
})
it('should render and close the message log modal from the left panel slot', async () => {
const user = userEvent.setup()
appStoreState = {
...appStoreState,
showMessageLogModal: true,
}
render(<WorkflowPanel />)
expect(await screen.findByTestId('message-log-modal')).toHaveAttribute('data-current-log-id', 'log-1')
expect(screen.getByTestId('message-log-modal')).toHaveAttribute('data-default-tab', 'detail')
await user.click(screen.getByRole('button', { name: /close-message-log/i }))
expect(mockSetCurrentLogItem).toHaveBeenCalledWith()
expect(mockSetShowMessageLogModal).toHaveBeenCalledWith(false)
})
it('should switch right-side workflow panels based on chat mode and workflow state', async () => {
workflowStoreState = {
historyWorkflowData: { id: 'history-1' },
showDebugAndPreviewPanel: true,
showChatVariablePanel: true,
showGlobalVariablePanel: true,
}
mockUseIsChatMode.mockReturnValue(true)
const { unmount } = render(<WorkflowPanel />)
expect(await screen.findByTestId('chat-record-panel')).toBeInTheDocument()
expect(screen.getByTestId('debug-and-preview-panel')).toBeInTheDocument()
expect(screen.getByTestId('chat-variable-panel')).toBeInTheDocument()
expect(screen.getByTestId('global-variable-panel')).toBeInTheDocument()
expect(screen.queryByTestId('record-panel')).not.toBeInTheDocument()
expect(screen.queryByTestId('workflow-preview-panel')).not.toBeInTheDocument()
unmount()
mockUseIsChatMode.mockReturnValue(false)
render(<WorkflowPanel />)
expect(await screen.findByTestId('record-panel')).toBeInTheDocument()
expect(screen.getByTestId('workflow-preview-panel')).toBeInTheDocument()
expect(screen.getByTestId('global-variable-panel')).toBeInTheDocument()
expect(screen.queryByTestId('chat-record-panel')).not.toBeInTheDocument()
expect(screen.queryByTestId('debug-and-preview-panel')).not.toBeInTheDocument()
expect(screen.queryByTestId('chat-variable-panel')).not.toBeInTheDocument()
})
})

View File

@ -149,6 +149,7 @@ const createProviderContext = ({
const renderWithToast = (ui: ReactElement) => {
return render(
// eslint-disable-next-line react/no-context-provider
<ToastContext.Provider value={{ notify: mockNotify, close: vi.fn() }}>
{ui}
</ToastContext.Provider>,
@ -445,6 +446,27 @@ describe('FeaturesTrigger', () => {
})
})
it('should skip success side effects when publish mutation returns no workflow version', async () => {
// Arrange
const user = userEvent.setup()
mockPublishWorkflow.mockResolvedValue(null)
renderWithToast(<FeaturesTrigger />)
// Act
await user.click(screen.getByRole('button', { name: 'publisher-publish' }))
// Assert
await waitFor(() => {
expect(mockPublishWorkflow).toHaveBeenCalled()
})
expect(mockNotify).not.toHaveBeenCalledWith({ type: 'success', message: 'common.api.actionSuccess' })
expect(mockUpdatePublishedWorkflow).not.toHaveBeenCalled()
expect(mockInvalidateAppTriggers).not.toHaveBeenCalled()
expect(mockSetPublishedAt).not.toHaveBeenCalled()
expect(mockSetLastPublishedHasUserInput).not.toHaveBeenCalled()
expect(mockResetWorkflowVersionHistory).not.toHaveBeenCalled()
})
it('should log error when app detail refresh fails after publish', async () => {
// Arrange
const user = userEvent.setup()

View File

@ -0,0 +1,18 @@
import * as hooks from '../index'
describe('workflow-app hooks index', () => {
it('should re-export workflow-app hooks', () => {
expect(hooks.useAvailableNodesMetaData).toBeTypeOf('function')
expect(hooks.useConfigsMap).toBeTypeOf('function')
expect(hooks.useDSL).toBeTypeOf('function')
expect(hooks.useGetRunAndTraceUrl).toBeTypeOf('function')
expect(hooks.useInspectVarsCrud).toBeTypeOf('function')
expect(hooks.useIsChatMode).toBeTypeOf('function')
expect(hooks.useNodesSyncDraft).toBeTypeOf('function')
expect(hooks.useWorkflowInit).toBeTypeOf('function')
expect(hooks.useWorkflowRefreshDraft).toBeTypeOf('function')
expect(hooks.useWorkflowRun).toBeTypeOf('function')
expect(hooks.useWorkflowStartRun).toBeTypeOf('function')
expect(hooks.useWorkflowTemplate).toBeTypeOf('function')
})
})

View File

@ -0,0 +1,206 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { DSL_EXPORT_CHECK } from '@/app/components/workflow/constants'
import { useDSL } from '../use-DSL'
const mockNotify = vi.fn()
const mockEmit = vi.fn()
const mockDoSyncWorkflowDraft = vi.fn()
const mockExportAppConfig = vi.fn()
const mockFetchWorkflowDraft = vi.fn()
const mockDownloadBlob = vi.fn()
let appStoreState: {
appDetail?: {
id: string
name: string
}
}
vi.mock('@/app/components/base/toast/context', () => ({
useToastContext: () => ({ notify: mockNotify }),
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: {
emit: mockEmit,
},
}),
}))
vi.mock('@/app/components/app/store', () => ({
useStore: <T>(selector: (state: typeof appStoreState) => T) => selector(appStoreState),
}))
vi.mock('../use-nodes-sync-draft', () => ({
useNodesSyncDraft: () => ({
doSyncWorkflowDraft: mockDoSyncWorkflowDraft,
}),
}))
vi.mock('@/service/apps', () => ({
exportAppConfig: (...args: unknown[]) => mockExportAppConfig(...args),
}))
vi.mock('@/service/workflow', () => ({
fetchWorkflowDraft: (...args: unknown[]) => mockFetchWorkflowDraft(...args),
}))
vi.mock('@/utils/download', () => ({
downloadBlob: (...args: unknown[]) => mockDownloadBlob(...args),
}))
const createDeferred = <T>() => {
let resolve!: (value: T) => void
const promise = new Promise<T>((res) => {
resolve = res
})
return { promise, resolve }
}
describe('useDSL', () => {
beforeEach(() => {
vi.clearAllMocks()
appStoreState = {
appDetail: {
id: 'app-1',
name: 'Workflow App',
},
}
mockDoSyncWorkflowDraft.mockResolvedValue(undefined)
mockExportAppConfig.mockResolvedValue({ data: 'yaml-content' })
mockFetchWorkflowDraft.mockResolvedValue({ environment_variables: [] })
})
it('should export workflow dsl and download the yaml blob when no secret env is present', async () => {
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.exportCheck()
})
expect(mockFetchWorkflowDraft).toHaveBeenCalledWith('/apps/app-1/workflows/draft')
expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
expect(mockExportAppConfig).toHaveBeenCalledWith({
appID: 'app-1',
include: false,
workflowID: undefined,
})
expect(mockDownloadBlob).toHaveBeenCalledWith(expect.objectContaining({
data: expect.any(Blob),
fileName: 'Workflow App.yml',
}))
})
it('should forward include and workflow id arguments when exporting dsl directly', async () => {
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.handleExportDSL(true, 'workflow-1')
})
expect(mockExportAppConfig).toHaveBeenCalledWith({
appID: 'app-1',
include: true,
workflowID: 'workflow-1',
})
})
it('should emit DSL_EXPORT_CHECK when secret environment variables exist', async () => {
const secretVars = [{ id: 'env-1', value_type: 'secret', value: 'secret-token' }]
mockFetchWorkflowDraft.mockResolvedValue({ environment_variables: secretVars })
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.exportCheck()
})
expect(mockEmit).toHaveBeenCalledWith({
type: DSL_EXPORT_CHECK,
payload: {
data: secretVars,
},
})
expect(mockExportAppConfig).not.toHaveBeenCalled()
})
it('should return early when app detail is unavailable', async () => {
appStoreState = {}
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.exportCheck()
await result.current.handleExportDSL()
})
expect(mockFetchWorkflowDraft).not.toHaveBeenCalled()
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
expect(mockExportAppConfig).not.toHaveBeenCalled()
expect(mockEmit).not.toHaveBeenCalled()
})
it('should notify when export fails', async () => {
mockExportAppConfig.mockRejectedValue(new Error('export failed'))
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.handleExportDSL()
})
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'app.exportFailed',
})
})
})
it('should notify when exportCheck cannot load the workflow draft', async () => {
mockFetchWorkflowDraft.mockRejectedValue(new Error('draft fetch failed'))
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.exportCheck()
})
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'app.exportFailed',
})
})
expect(mockExportAppConfig).not.toHaveBeenCalled()
})
it('should ignore repeated export attempts while an export is already in progress', async () => {
const deferred = createDeferred<{ data: string }>()
mockExportAppConfig.mockReturnValue(deferred.promise)
const { result } = renderHook(() => useDSL())
let firstExportPromise!: Promise<void>
act(() => {
firstExportPromise = result.current.handleExportDSL()
})
await waitFor(() => {
expect(mockDoSyncWorkflowDraft).toHaveBeenCalledTimes(1)
expect(mockExportAppConfig).toHaveBeenCalledTimes(1)
})
act(() => {
void result.current.handleExportDSL()
})
expect(mockExportAppConfig).toHaveBeenCalledTimes(1)
await act(async () => {
deferred.resolve({ data: 'yaml-content' })
await firstExportPromise
})
})
})

View File

@ -0,0 +1,118 @@
import { act, renderHook } from '@testing-library/react'
import { useAutoOnboarding } from '../use-auto-onboarding'
const mockGetNodes = vi.fn()
const mockWorkflowStore = {
getState: vi.fn(),
}
const mockSetShowOnboarding = vi.fn()
const mockSetHasShownOnboarding = vi.fn()
const mockSetShouldAutoOpenStartNodeSelector = vi.fn()
const mockSetHasSelectedStartNode = vi.fn()
vi.mock('reactflow', () => ({
useStoreApi: () => ({
getState: () => ({
getNodes: mockGetNodes,
}),
}),
}))
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => mockWorkflowStore,
}))
describe('useAutoOnboarding', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
mockGetNodes.mockReturnValue([])
mockWorkflowStore.getState.mockReturnValue({
showOnboarding: false,
hasShownOnboarding: false,
notInitialWorkflow: false,
setShowOnboarding: mockSetShowOnboarding,
setHasShownOnboarding: mockSetHasShownOnboarding,
setShouldAutoOpenStartNodeSelector: mockSetShouldAutoOpenStartNodeSelector,
hasSelectedStartNode: false,
setHasSelectedStartNode: mockSetHasSelectedStartNode,
})
})
afterEach(() => {
vi.useRealTimers()
})
it('should open onboarding after the delayed empty-canvas check on mount', () => {
renderHook(() => useAutoOnboarding())
act(() => {
vi.advanceTimersByTime(500)
})
expect(mockSetShowOnboarding).toHaveBeenCalledWith(true)
expect(mockSetHasShownOnboarding).toHaveBeenCalledWith(true)
expect(mockSetShouldAutoOpenStartNodeSelector).toHaveBeenCalledWith(true)
})
it('should skip auto onboarding when it is already visible or the workflow is not initial', () => {
mockWorkflowStore.getState.mockReturnValue({
showOnboarding: true,
hasShownOnboarding: false,
notInitialWorkflow: true,
setShowOnboarding: mockSetShowOnboarding,
setHasShownOnboarding: mockSetHasShownOnboarding,
setShouldAutoOpenStartNodeSelector: mockSetShouldAutoOpenStartNodeSelector,
hasSelectedStartNode: false,
setHasSelectedStartNode: mockSetHasSelectedStartNode,
})
renderHook(() => useAutoOnboarding())
act(() => {
vi.advanceTimersByTime(500)
})
expect(mockSetShowOnboarding).not.toHaveBeenCalled()
expect(mockSetHasShownOnboarding).not.toHaveBeenCalled()
expect(mockSetShouldAutoOpenStartNodeSelector).not.toHaveBeenCalled()
})
it('should close onboarding and reset selected start node state when one was chosen', () => {
mockWorkflowStore.getState.mockReturnValue({
showOnboarding: false,
hasShownOnboarding: true,
notInitialWorkflow: false,
setShowOnboarding: mockSetShowOnboarding,
setHasShownOnboarding: mockSetHasShownOnboarding,
setShouldAutoOpenStartNodeSelector: mockSetShouldAutoOpenStartNodeSelector,
hasSelectedStartNode: true,
setHasSelectedStartNode: mockSetHasSelectedStartNode,
})
const { result } = renderHook(() => useAutoOnboarding())
act(() => {
result.current.handleOnboardingClose()
})
expect(mockSetShowOnboarding).toHaveBeenCalledWith(false)
expect(mockSetHasShownOnboarding).toHaveBeenCalledWith(true)
expect(mockSetHasSelectedStartNode).toHaveBeenCalledWith(false)
expect(mockSetShouldAutoOpenStartNodeSelector).not.toHaveBeenCalled()
})
it('should close onboarding and disable auto-open when no start node was selected', () => {
const { result } = renderHook(() => useAutoOnboarding())
act(() => {
result.current.handleOnboardingClose()
})
expect(mockSetShowOnboarding).toHaveBeenCalledWith(false)
expect(mockSetHasShownOnboarding).toHaveBeenCalledWith(true)
expect(mockSetShouldAutoOpenStartNodeSelector).toHaveBeenCalledWith(false)
expect(mockSetHasSelectedStartNode).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,49 @@
import { renderHook } from '@testing-library/react'
import { BlockEnum } from '@/app/components/workflow/types'
import { useAvailableNodesMetaData } from '../use-available-nodes-meta-data'
const mockUseIsChatMode = vi.fn()
vi.mock('@/app/components/workflow-app/hooks/use-is-chat-mode', () => ({
useIsChatMode: () => mockUseIsChatMode(),
}))
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `/docs${path}`,
}))
describe('useAvailableNodesMetaData', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should include chat-specific nodes and make the start node undeletable in chat mode', () => {
mockUseIsChatMode.mockReturnValue(true)
const { result } = renderHook(() => useAvailableNodesMetaData())
expect(result.current.nodesMap?.[BlockEnum.Start]?.metaData.isUndeletable).toBe(true)
expect(result.current.nodesMap?.[BlockEnum.Answer]).toBeDefined()
expect(result.current.nodesMap?.[BlockEnum.End]).toBeUndefined()
expect(result.current.nodesMap?.[BlockEnum.TriggerWebhook]).toBeUndefined()
expect(result.current.nodesMap?.[BlockEnum.VariableAssigner]).toBe(result.current.nodesMap?.[BlockEnum.VariableAggregator])
expect(result.current.nodesMap?.[BlockEnum.Start]?.metaData.helpLinkUri).toContain('/docs/use-dify/nodes/')
})
it('should include workflow-specific trigger and end nodes outside chat mode', () => {
mockUseIsChatMode.mockReturnValue(false)
const { result } = renderHook(() => useAvailableNodesMetaData())
expect(result.current.nodesMap?.[BlockEnum.Start]?.metaData.isUndeletable).toBe(false)
expect(result.current.nodesMap?.[BlockEnum.End]).toBeDefined()
expect(result.current.nodesMap?.[BlockEnum.TriggerWebhook]).toBeDefined()
expect(result.current.nodesMap?.[BlockEnum.TriggerSchedule]).toBeDefined()
expect(result.current.nodesMap?.[BlockEnum.TriggerPlugin]).toBeDefined()
expect(result.current.nodesMap?.[BlockEnum.Answer]).toBeUndefined()
expect(result.current.nodesMap?.[BlockEnum.Start]?.defaultValue).toMatchObject({
type: BlockEnum.Start,
title: 'workflow.blocks.start',
})
})
})

View File

@ -0,0 +1,40 @@
import { renderHook } from '@testing-library/react'
import { FlowType } from '@/types/common'
import { useConfigsMap } from '../use-configs-map'
const mockUseFeatures = vi.fn()
vi.mock('@/app/components/base/features/hooks', () => ({
useFeatures: (selector: (state: { features: { file: Record<string, unknown> } }) => unknown) => mockUseFeatures(selector),
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: <T>(selector: (state: { appId: string }) => T) => selector({ appId: 'app-1' }),
}))
describe('useConfigsMap', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseFeatures.mockImplementation((selector: (state: { features: { file: Record<string, unknown> } }) => unknown) => selector({
features: {
file: {
enabled: true,
number_limits: 3,
},
},
}))
})
it('should map workflow app id and feature file settings into inspect-var configs', () => {
const { result } = renderHook(() => useConfigsMap())
expect(result.current).toEqual({
flowId: 'app-1',
flowType: FlowType.appFlow,
fileSettings: {
enabled: true,
number_limits: 3,
},
})
})
})

View File

@ -0,0 +1,28 @@
import { renderHook } from '@testing-library/react'
import { useGetRunAndTraceUrl } from '../use-get-run-and-trace-url'
const mockWorkflowStore = {
getState: vi.fn(),
}
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => mockWorkflowStore,
}))
describe('useGetRunAndTraceUrl', () => {
beforeEach(() => {
vi.clearAllMocks()
mockWorkflowStore.getState.mockReturnValue({
appId: 'app-123',
})
})
it('should build workflow run and trace urls from the current app id', () => {
const { result } = renderHook(() => useGetRunAndTraceUrl())
expect(result.current.getWorkflowRunAndTraceUrl('run-1')).toEqual({
runUrl: '/apps/app-123/workflow-runs/run-1',
traceUrl: '/apps/app-123/workflow-runs/run-1/node-executions',
})
})
})

View File

@ -0,0 +1,44 @@
import { renderHook } from '@testing-library/react'
import { useInspectVarsCrud } from '../use-inspect-vars-crud'
const mockUseInspectVarsCrudCommon = vi.fn()
const mockUseConfigsMap = vi.fn()
vi.mock('@/app/components/workflow/hooks/use-inspect-vars-crud-common', () => ({
useInspectVarsCrudCommon: (...args: unknown[]) => mockUseInspectVarsCrudCommon(...args),
}))
vi.mock('@/app/components/workflow-app/hooks/use-configs-map', () => ({
useConfigsMap: () => mockUseConfigsMap(),
}))
describe('useInspectVarsCrud', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseConfigsMap.mockReturnValue({
flowId: 'app-1',
flowType: 'app-flow',
fileSettings: { enabled: true },
})
mockUseInspectVarsCrudCommon.mockReturnValue({
fetchInspectVarValue: vi.fn(),
editInspectVarValue: vi.fn(),
deleteInspectVar: vi.fn(),
})
})
it('should call the shared inspect vars hook with workflow-app configs and return its api', () => {
const { result } = renderHook(() => useInspectVarsCrud())
expect(mockUseInspectVarsCrudCommon).toHaveBeenCalledWith({
flowId: 'app-1',
flowType: 'app-flow',
fileSettings: { enabled: true },
})
expect(result.current).toEqual({
fetchInspectVarValue: expect.any(Function),
editInspectVarValue: expect.any(Function),
deleteInspectVar: expect.any(Function),
})
})
})

View File

@ -4,42 +4,57 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useNodesSyncDraft } from '../use-nodes-sync-draft'
const mockGetNodes = vi.fn()
const mockPostWithKeepalive = vi.fn()
const mockSetSyncWorkflowDraftHash = vi.fn()
const mockSetDraftUpdatedAt = vi.fn()
const mockGetNodesReadOnly = vi.fn()
let reactFlowState: {
getNodes: typeof mockGetNodes
edges: Array<Record<string, unknown>>
transform: [number, number, number]
}
let workflowStoreState: {
appId: string
isWorkflowDataLoaded: boolean
syncWorkflowDraftHash: string | null
environmentVariables: Array<Record<string, unknown>>
conversationVariables: Array<Record<string, unknown>>
setSyncWorkflowDraftHash: typeof mockSetSyncWorkflowDraftHash
setDraftUpdatedAt: typeof mockSetDraftUpdatedAt
}
let featuresState: {
features: {
opening: { enabled: boolean, opening_statement: string, suggested_questions: string[] }
suggested: Record<string, unknown>
text2speech: Record<string, unknown>
speech2text: Record<string, unknown>
citation: Record<string, unknown>
moderation: Record<string, unknown>
file: Record<string, unknown>
}
}
vi.mock('reactflow', () => ({
useStoreApi: () => ({ getState: () => ({ getNodes: mockGetNodes, edges: [], transform: [0, 0, 1] }) }),
useStoreApi: () => ({ getState: () => reactFlowState }),
}))
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => ({
getState: () => ({
appId: 'app-1',
isWorkflowDataLoaded: true,
syncWorkflowDraftHash: 'hash-123',
environmentVariables: [],
conversationVariables: [],
setSyncWorkflowDraftHash: vi.fn(),
setDraftUpdatedAt: vi.fn(),
}),
getState: () => workflowStoreState,
}),
}))
vi.mock('@/app/components/base/features/hooks', () => ({
useFeaturesStore: () => ({
getState: () => ({
features: {
opening: { enabled: false, opening_statement: '', suggested_questions: [] },
suggested: {},
text2speech: {},
speech2text: {},
citation: {},
moderation: {},
file: {},
},
}),
getState: () => featuresState,
}),
}))
vi.mock('@/app/components/workflow/hooks/use-workflow', () => ({
useNodesReadOnly: () => ({ getNodesReadOnly: () => false }),
useNodesReadOnly: () => ({ getNodesReadOnly: mockGetNodesReadOnly }),
}))
vi.mock('@/app/components/workflow/hooks/use-serial-async-callback', () => ({
@ -55,7 +70,7 @@ vi.mock('@/service/workflow', () => ({
syncWorkflowDraft: (p: unknown) => mockSyncWorkflowDraft(p),
}))
vi.mock('@/service/fetch', () => ({ postWithKeepalive: vi.fn() }))
vi.mock('@/service/fetch', () => ({ postWithKeepalive: (...args: unknown[]) => mockPostWithKeepalive(...args) }))
vi.mock('@/config', () => ({ API_PREFIX: '/api' }))
const mockHandleRefreshWorkflowDraft = vi.fn()
@ -66,6 +81,32 @@ vi.mock('@/app/components/workflow-app/hooks', () => ({
describe('useNodesSyncDraft — handleRefreshWorkflowDraft(true) on 409', () => {
beforeEach(() => {
vi.clearAllMocks()
reactFlowState = {
getNodes: mockGetNodes,
edges: [],
transform: [0, 0, 1],
}
workflowStoreState = {
appId: 'app-1',
isWorkflowDataLoaded: true,
syncWorkflowDraftHash: 'hash-123',
environmentVariables: [],
conversationVariables: [],
setSyncWorkflowDraftHash: mockSetSyncWorkflowDraftHash,
setDraftUpdatedAt: mockSetDraftUpdatedAt,
}
featuresState = {
features: {
opening: { enabled: false, opening_statement: '', suggested_questions: [] },
suggested: {},
text2speech: {},
speech2text: {},
citation: {},
moderation: {},
file: {},
},
}
mockGetNodesReadOnly.mockReturnValue(false)
mockGetNodes.mockReturnValue([{ id: 'n1', position: { x: 0, y: 0 }, data: { type: 'start' } }])
mockSyncWorkflowDraft.mockResolvedValue({ hash: 'new', updated_at: 1 })
})
@ -122,4 +163,102 @@ describe('useNodesSyncDraft — handleRefreshWorkflowDraft(true) on 409', () =>
}),
}))
})
it('should strip temp entities and private data, use the latest hash, and invoke success callbacks', async () => {
reactFlowState = {
...reactFlowState,
edges: [
{ id: 'edge-1', source: 'n1', target: 'n2', data: { _isTemp: false, _private: 'drop', stable: 'keep' } },
{ id: 'temp-edge', source: 'n2', target: 'n3', data: { _isTemp: true } },
],
transform: [10, 20, 1.5],
}
mockGetNodes.mockReturnValue([
{ id: 'n1', position: { x: 0, y: 0 }, data: { type: 'start', _tempField: 'drop', label: 'Start' } },
{ id: 'temp-node', position: { x: 1, y: 1 }, data: { type: 'answer', _isTempNode: true } },
])
workflowStoreState = {
...workflowStoreState,
syncWorkflowDraftHash: 'latest-hash',
environmentVariables: [{ id: 'env-1', value: 'env' }],
conversationVariables: [{ id: 'conversation-1', value: 'conversation' }],
}
featuresState = {
features: {
opening: { enabled: true, opening_statement: 'Hello', suggested_questions: ['Q1'] },
suggested: { enabled: true },
text2speech: { enabled: true },
speech2text: { enabled: true },
citation: { enabled: true },
moderation: { enabled: false },
file: { enabled: true },
},
}
const callbacks = {
onSuccess: vi.fn(),
onError: vi.fn(),
onSettled: vi.fn(),
}
const { result } = renderHook(() => useNodesSyncDraft())
await act(async () => {
await result.current.doSyncWorkflowDraft(false, callbacks)
})
expect(mockSyncWorkflowDraft).toHaveBeenCalledWith({
url: '/apps/app-1/workflows/draft',
params: {
graph: {
nodes: [{ id: 'n1', position: { x: 0, y: 0 }, data: { type: 'start', label: 'Start' } }],
edges: [{ id: 'edge-1', source: 'n1', target: 'n2', data: { stable: 'keep' } }],
viewport: { x: 10, y: 20, zoom: 1.5 },
},
features: {
opening_statement: 'Hello',
suggested_questions: ['Q1'],
suggested_questions_after_answer: { enabled: true },
text_to_speech: { enabled: true },
speech_to_text: { enabled: true },
retriever_resource: { enabled: true },
sensitive_word_avoidance: { enabled: false },
file_upload: { enabled: true },
},
environment_variables: [{ id: 'env-1', value: 'env' }],
conversation_variables: [{ id: 'conversation-1', value: 'conversation' }],
hash: 'latest-hash',
},
})
expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('new')
expect(mockSetDraftUpdatedAt).toHaveBeenCalledWith(1)
expect(callbacks.onSuccess).toHaveBeenCalled()
expect(callbacks.onError).not.toHaveBeenCalled()
expect(callbacks.onSettled).toHaveBeenCalled()
})
it('should post workflow draft with keepalive when the page closes', () => {
reactFlowState = {
...reactFlowState,
transform: [1, 2, 3],
}
workflowStoreState = {
...workflowStoreState,
environmentVariables: [{ id: 'env-1' }],
conversationVariables: [{ id: 'conversation-1' }],
}
const { result } = renderHook(() => useNodesSyncDraft())
act(() => {
result.current.syncWorkflowDraftWhenPageClose()
})
expect(mockPostWithKeepalive).toHaveBeenCalledWith('/api/apps/app-1/workflows/draft', expect.objectContaining({
graph: expect.objectContaining({
viewport: { x: 1, y: 2, zoom: 3 },
}),
hash: 'hash-123',
}))
})
})

View File

@ -1,5 +1,6 @@
import { renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { BlockEnum } from '@/app/components/workflow/types'
import { useWorkflowInit } from '../use-workflow-init'
@ -11,6 +12,21 @@ const mockSetLastPublishedHasUserInput = vi.fn()
const mockSetFileUploadConfig = vi.fn()
const mockWorkflowStoreSetState = vi.fn()
const mockWorkflowStoreGetState = vi.fn()
const mockFetchNodesDefaultConfigs = vi.fn()
const mockFetchPublishedWorkflow = vi.fn()
let appStoreState: {
appDetail: {
id: string
name: string
mode: string
}
}
let workflowConfigState: {
data: Record<string, unknown> | null
isLoading: boolean
}
vi.mock('@/app/components/workflow/store', () => ({
useStore: <T>(selector: (state: { setSyncWorkflowDraftHash: ReturnType<typeof vi.fn> }) => T): T =>
@ -22,8 +38,8 @@ vi.mock('@/app/components/workflow/store', () => ({
}))
vi.mock('@/app/components/app/store', () => ({
useStore: <T>(selector: (state: { appDetail: { id: string, name: string, mode: string } }) => T): T =>
selector({ appDetail: { id: 'app-1', name: 'Test', mode: 'workflow' } }),
useStore: <T>(selector: (state: typeof appStoreState) => T): T =>
selector(appStoreState),
}))
vi.mock('../use-workflow-template', () => ({
@ -31,7 +47,11 @@ vi.mock('../use-workflow-template', () => ({
}))
vi.mock('@/service/use-workflow', () => ({
useWorkflowConfig: () => ({ data: null, isLoading: false }),
useWorkflowConfig: (_url: string, onSuccess: (config: Record<string, unknown>) => void) => {
if (workflowConfigState.data)
onSuccess(workflowConfigState.data)
return workflowConfigState
},
}))
const mockFetchWorkflowDraft = vi.fn()
@ -40,8 +60,8 @@ const mockSyncWorkflowDraft = vi.fn()
vi.mock('@/service/workflow', () => ({
fetchWorkflowDraft: (...args: unknown[]) => mockFetchWorkflowDraft(...args),
syncWorkflowDraft: (...args: unknown[]) => mockSyncWorkflowDraft(...args),
fetchNodesDefaultConfigs: () => Promise.resolve([]),
fetchPublishedWorkflow: () => Promise.resolve({ created_at: 0, graph: { nodes: [], edges: [] } }),
fetchNodesDefaultConfigs: (...args: unknown[]) => mockFetchNodesDefaultConfigs(...args),
fetchPublishedWorkflow: (...args: unknown[]) => mockFetchPublishedWorkflow(...args),
}))
const notExistError = () => ({
@ -68,6 +88,10 @@ const draftResponse = {
describe('useWorkflowInit — hash fix (draft_workflow_not_exist)', () => {
beforeEach(() => {
vi.clearAllMocks()
appStoreState = {
appDetail: { id: 'app-1', name: 'Test', mode: 'workflow' },
}
workflowConfigState = { data: null, isLoading: false }
mockWorkflowStoreGetState.mockReturnValue({
setDraftUpdatedAt: mockSetDraftUpdatedAt,
setToolPublished: mockSetToolPublished,
@ -75,6 +99,8 @@ describe('useWorkflowInit — hash fix (draft_workflow_not_exist)', () => {
setLastPublishedHasUserInput: mockSetLastPublishedHasUserInput,
setFileUploadConfig: mockSetFileUploadConfig,
})
mockFetchNodesDefaultConfigs.mockResolvedValue([])
mockFetchPublishedWorkflow.mockResolvedValue({ created_at: 0, graph: { nodes: [], edges: [] } })
mockFetchWorkflowDraft
.mockRejectedValueOnce(notExistError())
.mockResolvedValueOnce(draftResponse)
@ -104,4 +130,77 @@ describe('useWorkflowInit — hash fix (draft_workflow_not_exist)', () => {
expect(order).toContain('hash:new-hash')
expect(order.indexOf('hash:new-hash')).toBeLessThan(order.indexOf('fetch:2'))
})
it('should hydrate draft state, preload defaults, and derive published workflow metadata on success', async () => {
workflowConfigState = {
data: { enabled: true, sizeLimit: 20 },
isLoading: false,
}
mockFetchWorkflowDraft.mockReset().mockResolvedValue({
...draftResponse,
updated_at: 9,
tool_published: true,
environment_variables: [
{ id: 'env-secret', value_type: 'secret', value: 'top-secret', name: 'SECRET' },
{ id: 'env-plain', value_type: 'text', value: 'visible', name: 'PLAIN' },
],
conversation_variables: [{ id: 'conversation-1' }],
})
mockFetchNodesDefaultConfigs.mockResolvedValue([
{ type: 'start', config: { title: 'Start Config' } },
{ type: 'start', config: { title: 'Ignored Duplicate' } },
])
mockFetchPublishedWorkflow.mockResolvedValue({
created_at: 99,
graph: {
nodes: [{ id: 'start', data: { type: BlockEnum.Start } }],
edges: [{ source: 'start', target: 'end' }],
},
})
const { result } = renderHook(() => useWorkflowInit())
await waitFor(() => {
expect(result.current.data?.hash).toBe('server-hash')
})
expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ appId: 'app-1', appName: 'Test' })
expect(mockWorkflowStoreSetState).toHaveBeenCalledWith(expect.objectContaining({
envSecrets: { 'env-secret': 'top-secret' },
environmentVariables: [
{ id: 'env-secret', value_type: 'secret', value: '[__HIDDEN__]', name: 'SECRET' },
{ id: 'env-plain', value_type: 'text', value: 'visible', name: 'PLAIN' },
],
conversationVariables: [{ id: 'conversation-1' }],
isWorkflowDataLoaded: true,
}))
expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({
nodesDefaultConfigs: {
start: { title: 'Start Config' },
},
})
expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('server-hash')
expect(mockSetDraftUpdatedAt).toHaveBeenCalledWith(9)
expect(mockSetToolPublished).toHaveBeenCalledWith(true)
expect(mockSetPublishedAt).toHaveBeenCalledWith(99)
expect(mockSetLastPublishedHasUserInput).toHaveBeenCalledWith(true)
expect(mockSetFileUploadConfig).toHaveBeenCalledWith({ enabled: true, sizeLimit: 20 })
expect(result.current.fileUploadConfigResponse).toEqual({ enabled: true, sizeLimit: 20 })
expect(result.current.isLoading).toBe(false)
})
it('should fall back to no published user input when preload requests fail', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
mockFetchWorkflowDraft.mockReset().mockResolvedValue(draftResponse)
mockFetchNodesDefaultConfigs.mockRejectedValue(new Error('preload failed'))
renderHook(() => useWorkflowInit())
await waitFor(() => {
expect(mockSetLastPublishedHasUserInput).toHaveBeenCalledWith(false)
})
expect(consoleErrorSpy).toHaveBeenCalled()
consoleErrorSpy.mockRestore()
})
})

View File

@ -1,24 +1,32 @@
import { act, renderHook } from '@testing-library/react'
import { act, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useWorkflowRefreshDraft } from '../use-workflow-refresh-draft'
const mockHandleUpdateWorkflowCanvas = vi.fn()
const mockSetSyncWorkflowDraftHash = vi.fn()
const mockSetIsSyncingWorkflowDraft = vi.fn()
const mockSetEnvironmentVariables = vi.fn()
const mockSetEnvSecrets = vi.fn()
const mockSetConversationVariables = vi.fn()
const mockSetIsWorkflowDataLoaded = vi.fn()
const mockCancel = vi.fn()
let workflowStoreState: {
appId: string
isWorkflowDataLoaded: boolean
debouncedSyncWorkflowDraft?: { cancel: () => void }
setSyncWorkflowDraftHash: typeof mockSetSyncWorkflowDraftHash
setIsSyncingWorkflowDraft: typeof mockSetIsSyncingWorkflowDraft
setEnvironmentVariables: typeof mockSetEnvironmentVariables
setEnvSecrets: typeof mockSetEnvSecrets
setConversationVariables: typeof mockSetConversationVariables
setIsWorkflowDataLoaded: typeof mockSetIsWorkflowDataLoaded
}
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => ({
getState: () => ({
appId: 'app-1',
isWorkflowDataLoaded: true,
debouncedSyncWorkflowDraft: undefined,
setSyncWorkflowDraftHash: mockSetSyncWorkflowDraftHash,
setIsSyncingWorkflowDraft: vi.fn(),
setEnvironmentVariables: vi.fn(),
setEnvSecrets: vi.fn(),
setConversationVariables: vi.fn(),
setIsWorkflowDataLoaded: vi.fn(),
}),
getState: () => workflowStoreState,
}),
}))
@ -41,6 +49,17 @@ const draftResponse = {
describe('useWorkflowRefreshDraft — notUpdateCanvas parameter', () => {
beforeEach(() => {
vi.clearAllMocks()
workflowStoreState = {
appId: 'app-1',
isWorkflowDataLoaded: true,
debouncedSyncWorkflowDraft: undefined,
setSyncWorkflowDraftHash: mockSetSyncWorkflowDraftHash,
setIsSyncingWorkflowDraft: mockSetIsSyncingWorkflowDraft,
setEnvironmentVariables: mockSetEnvironmentVariables,
setEnvSecrets: mockSetEnvSecrets,
setConversationVariables: mockSetConversationVariables,
setIsWorkflowDataLoaded: mockSetIsWorkflowDataLoaded,
}
mockFetchWorkflowDraft.mockResolvedValue(draftResponse)
})
@ -75,6 +94,67 @@ describe('useWorkflowRefreshDraft — notUpdateCanvas parameter', () => {
await act(async () => {
result.current.handleRefreshWorkflowDraft(true)
})
expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('server-hash')
await waitFor(() => {
expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('server-hash')
})
})
it('should cancel pending draft sync, use fallback viewport, and persist masked secrets', async () => {
workflowStoreState = {
...workflowStoreState,
debouncedSyncWorkflowDraft: { cancel: mockCancel },
}
mockFetchWorkflowDraft.mockResolvedValue({
hash: 'server-hash',
graph: {
nodes: [{ id: 'n1' }],
edges: [],
},
environment_variables: [
{ id: 'env-secret', value_type: 'secret', value: 'top-secret', name: 'SECRET' },
{ id: 'env-plain', value_type: 'text', value: 'visible', name: 'PLAIN' },
],
conversation_variables: [{ id: 'conversation-1' }],
})
const { result } = renderHook(() => useWorkflowRefreshDraft())
act(() => {
result.current.handleRefreshWorkflowDraft()
})
await waitFor(() => {
expect(mockCancel).toHaveBeenCalled()
expect(mockHandleUpdateWorkflowCanvas).toHaveBeenCalledWith({
nodes: [{ id: 'n1' }],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
})
expect(mockSetEnvSecrets).toHaveBeenCalledWith({
'env-secret': 'top-secret',
})
expect(mockSetEnvironmentVariables).toHaveBeenCalledWith([
{ id: 'env-secret', value_type: 'secret', value: '[__HIDDEN__]', name: 'SECRET' },
{ id: 'env-plain', value_type: 'text', value: 'visible', name: 'PLAIN' },
])
expect(mockSetConversationVariables).toHaveBeenCalledWith([{ id: 'conversation-1' }])
})
})
it('should restore loaded state when refresh fails after workflow data was already loaded', async () => {
mockFetchWorkflowDraft.mockRejectedValue(new Error('refresh failed'))
const { result } = renderHook(() => useWorkflowRefreshDraft())
act(() => {
result.current.handleRefreshWorkflowDraft()
})
await waitFor(() => {
expect(mockSetIsWorkflowDataLoaded).toHaveBeenNthCalledWith(1, false)
expect(mockSetIsWorkflowDataLoaded).toHaveBeenNthCalledWith(2, true)
expect(mockSetIsSyncingWorkflowDraft).toHaveBeenCalledWith(true)
expect(mockSetIsSyncingWorkflowDraft).toHaveBeenLastCalledWith(false)
})
})
})

View File

@ -0,0 +1,451 @@
import type AudioPlayer from '@/app/components/base/audio-btn/audio'
import { createBaseWorkflowRunCallbacks, createFinalWorkflowRunCallbacks } from '../use-workflow-run-callbacks'
const {
mockSseGet,
mockResetMsgId,
} = vi.hoisted(() => ({
mockSseGet: vi.fn(),
mockResetMsgId: vi.fn(),
}))
vi.mock('@/service/base', () => ({
sseGet: mockSseGet,
}))
vi.mock('@/app/components/base/audio-btn/audio.player.manager', () => ({
AudioPlayerManager: {
getInstance: () => ({
resetMsgId: mockResetMsgId,
}),
},
}))
const createHandlers = () => ({
handleWorkflowStarted: vi.fn(),
handleWorkflowFinished: vi.fn(),
handleWorkflowFailed: vi.fn(),
handleWorkflowNodeStarted: vi.fn(),
handleWorkflowNodeFinished: vi.fn(),
handleWorkflowNodeHumanInputRequired: vi.fn(),
handleWorkflowNodeHumanInputFormFilled: vi.fn(),
handleWorkflowNodeHumanInputFormTimeout: vi.fn(),
handleWorkflowNodeIterationStarted: vi.fn(),
handleWorkflowNodeIterationNext: vi.fn(),
handleWorkflowNodeIterationFinished: vi.fn(),
handleWorkflowNodeLoopStarted: vi.fn(),
handleWorkflowNodeLoopNext: vi.fn(),
handleWorkflowNodeLoopFinished: vi.fn(),
handleWorkflowNodeRetry: vi.fn(),
handleWorkflowAgentLog: vi.fn(),
handleWorkflowTextChunk: vi.fn(),
handleWorkflowTextReplace: vi.fn(),
handleWorkflowPaused: vi.fn(),
})
const createUserCallbacks = () => ({
onWorkflowStarted: vi.fn(),
onWorkflowFinished: vi.fn(),
onNodeStarted: vi.fn(),
onNodeFinished: vi.fn(),
onIterationStart: vi.fn(),
onIterationNext: vi.fn(),
onIterationFinish: vi.fn(),
onLoopStart: vi.fn(),
onLoopNext: vi.fn(),
onLoopFinish: vi.fn(),
onNodeRetry: vi.fn(),
onAgentLog: vi.fn(),
onError: vi.fn(),
onWorkflowPaused: vi.fn(),
onHumanInputRequired: vi.fn(),
onHumanInputFormFilled: vi.fn(),
onHumanInputFormTimeout: vi.fn(),
onCompleted: vi.fn(),
})
describe('useWorkflowRun callbacks helpers', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should create base callbacks that wrap workflow events, errors, pause continuation, and lazy tts playback', () => {
const handlers = createHandlers()
const clearAbortController = vi.fn()
const clearListeningState = vi.fn()
const invalidateRunHistory = vi.fn()
const fetchInspectVars = vi.fn()
const invalidAllLastRun = vi.fn()
const trackWorkflowRunFailed = vi.fn()
const userOnWorkflowFinished = vi.fn()
const userOnError = vi.fn()
const userOnWorkflowPaused = vi.fn()
const player = {
playAudioWithAudio: vi.fn(),
} as unknown as AudioPlayer
const getOrCreatePlayer = vi.fn<() => AudioPlayer | null>(() => player)
const callbacks = createBaseWorkflowRunCallbacks({
clientWidth: 320,
clientHeight: 240,
runHistoryUrl: '/apps/app-1/workflow-runs',
isInWorkflowDebug: true,
fetchInspectVars,
invalidAllLastRun,
invalidateRunHistory,
clearAbortController,
clearListeningState,
trackWorkflowRunFailed,
handlers,
callbacks: {
onWorkflowFinished: userOnWorkflowFinished,
onError: userOnError,
onWorkflowPaused: userOnWorkflowPaused,
},
restCallback: {},
getOrCreatePlayer,
})
callbacks.onWorkflowFinished?.({ workflow_run_id: 'run-1' } as never)
expect(clearListeningState).toHaveBeenCalled()
expect(handlers.handleWorkflowFinished).toHaveBeenCalled()
expect(invalidateRunHistory).toHaveBeenCalledWith('/apps/app-1/workflow-runs')
expect(userOnWorkflowFinished).toHaveBeenCalled()
expect(fetchInspectVars).toHaveBeenCalledWith({})
expect(invalidAllLastRun).toHaveBeenCalled()
callbacks.onError?.({ error: 'failed', node_type: 'llm' } as never)
expect(clearAbortController).toHaveBeenCalled()
expect(handlers.handleWorkflowFailed).toHaveBeenCalled()
expect(userOnError).toHaveBeenCalled()
expect(trackWorkflowRunFailed).toHaveBeenCalledWith({ error: 'failed', node_type: 'llm' })
callbacks.onTTSChunk?.('message-1', 'audio-chunk')
expect(getOrCreatePlayer).toHaveBeenCalled()
expect(player.playAudioWithAudio).toHaveBeenCalledWith('audio-chunk', true)
expect(mockResetMsgId).toHaveBeenCalledWith('message-1')
callbacks.onWorkflowPaused?.({ workflow_run_id: 'run-2' } as never)
expect(handlers.handleWorkflowPaused).toHaveBeenCalled()
expect(userOnWorkflowPaused).toHaveBeenCalled()
expect(mockSseGet).toHaveBeenCalledWith('/workflow/run-2/events', {}, callbacks)
})
it('should create final callbacks that preserve rest callback override order and eager abort-controller wiring', () => {
const handlers = createHandlers()
const restOnNodeStarted = vi.fn()
const setAbortController = vi.fn()
const player = {
playAudioWithAudio: vi.fn(),
} as unknown as AudioPlayer
const baseSseOptions = createBaseWorkflowRunCallbacks({
clientWidth: 320,
clientHeight: 240,
runHistoryUrl: '/apps/app-1/workflow-runs',
isInWorkflowDebug: false,
fetchInspectVars: vi.fn(),
invalidAllLastRun: vi.fn(),
invalidateRunHistory: vi.fn(),
clearAbortController: vi.fn(),
clearListeningState: vi.fn(),
trackWorkflowRunFailed: vi.fn(),
handlers,
callbacks: {},
restCallback: {},
getOrCreatePlayer: vi.fn<() => AudioPlayer | null>(() => player),
})
const finalCallbacks = createFinalWorkflowRunCallbacks({
clientWidth: 320,
clientHeight: 240,
runHistoryUrl: '/apps/app-1/workflow-runs',
isInWorkflowDebug: false,
fetchInspectVars: vi.fn(),
invalidAllLastRun: vi.fn(),
invalidateRunHistory: vi.fn(),
clearAbortController: vi.fn(),
clearListeningState: vi.fn(),
trackWorkflowRunFailed: vi.fn(),
handlers,
callbacks: {},
restCallback: {
onNodeStarted: restOnNodeStarted,
},
baseSseOptions,
player,
setAbortController,
})
const controller = new AbortController()
finalCallbacks.getAbortController?.(controller)
expect(setAbortController).toHaveBeenCalledWith(controller)
finalCallbacks.onNodeStarted?.({ node_id: 'node-1' } as never)
expect(restOnNodeStarted).toHaveBeenCalled()
expect(handlers.handleWorkflowNodeStarted).not.toHaveBeenCalled()
finalCallbacks.onTTSChunk?.('message-2', 'audio-chunk')
expect(player.playAudioWithAudio).toHaveBeenCalledWith('audio-chunk', true)
expect(mockResetMsgId).toHaveBeenCalledWith('message-2')
})
it('should route base workflow events through handlers, user callbacks, and pause continuation with the same callback object', async () => {
const handlers = createHandlers()
const userCallbacks = createUserCallbacks()
const clearAbortController = vi.fn()
const clearListeningState = vi.fn()
const invalidateRunHistory = vi.fn()
const fetchInspectVars = vi.fn()
const invalidAllLastRun = vi.fn()
const trackWorkflowRunFailed = vi.fn()
const player = {
playAudioWithAudio: vi.fn(),
} as unknown as AudioPlayer
const callbacks = createBaseWorkflowRunCallbacks({
clientWidth: 640,
clientHeight: 360,
runHistoryUrl: '/apps/app-1/workflow-runs',
isInWorkflowDebug: true,
fetchInspectVars,
invalidAllLastRun,
invalidateRunHistory,
clearAbortController,
clearListeningState,
trackWorkflowRunFailed,
handlers,
callbacks: userCallbacks,
restCallback: {},
getOrCreatePlayer: vi.fn<() => AudioPlayer | null>(() => player),
})
callbacks.onWorkflowStarted?.({ workflow_run_id: 'run-1' } as never)
callbacks.onNodeStarted?.({ node_id: 'node-1' } as never)
callbacks.onNodeFinished?.({ node_id: 'node-1' } as never)
callbacks.onIterationStart?.({ node_id: 'node-1' } as never)
callbacks.onIterationNext?.({ node_id: 'node-1' } as never)
callbacks.onIterationFinish?.({ node_id: 'node-1' } as never)
callbacks.onLoopStart?.({ node_id: 'node-1' } as never)
callbacks.onLoopNext?.({ node_id: 'node-1' } as never)
callbacks.onLoopFinish?.({ node_id: 'node-1' } as never)
callbacks.onNodeRetry?.({ node_id: 'node-1' } as never)
callbacks.onAgentLog?.({ node_id: 'node-1' } as never)
callbacks.onTextChunk?.({ data: 'chunk' } as never)
callbacks.onTextReplace?.({ text: 'replacement' } as never)
callbacks.onHumanInputRequired?.({ node_id: 'node-1' } as never)
callbacks.onHumanInputFormFilled?.({ node_id: 'node-1' } as never)
callbacks.onHumanInputFormTimeout?.({ node_id: 'node-1' } as never)
callbacks.onWorkflowFinished?.({ workflow_run_id: 'run-1' } as never)
await callbacks.onCompleted?.(false, '')
callbacks.onTTSChunk?.('message-1', 'audio-chunk')
callbacks.onTTSEnd?.('message-1', 'audio-finished')
callbacks.onWorkflowPaused?.({ workflow_run_id: 'run-2' } as never)
callbacks.onError?.({ error: 'failed', node_type: 'llm' } as never, '500')
expect(handlers.handleWorkflowStarted).toHaveBeenCalled()
expect(userCallbacks.onWorkflowStarted).toHaveBeenCalled()
expect(handlers.handleWorkflowNodeStarted).toHaveBeenCalledWith(
{ node_id: 'node-1' },
{ clientWidth: 640, clientHeight: 360 },
)
expect(userCallbacks.onNodeStarted).toHaveBeenCalled()
expect(handlers.handleWorkflowNodeFinished).toHaveBeenCalled()
expect(userCallbacks.onNodeFinished).toHaveBeenCalled()
expect(handlers.handleWorkflowNodeIterationStarted).toHaveBeenCalledWith(
{ node_id: 'node-1' },
{ clientWidth: 640, clientHeight: 360 },
)
expect(userCallbacks.onIterationStart).toHaveBeenCalled()
expect(handlers.handleWorkflowNodeIterationNext).toHaveBeenCalled()
expect(userCallbacks.onIterationNext).toHaveBeenCalled()
expect(handlers.handleWorkflowNodeIterationFinished).toHaveBeenCalled()
expect(userCallbacks.onIterationFinish).toHaveBeenCalled()
expect(handlers.handleWorkflowNodeLoopStarted).toHaveBeenCalledWith(
{ node_id: 'node-1' },
{ clientWidth: 640, clientHeight: 360 },
)
expect(userCallbacks.onLoopStart).toHaveBeenCalled()
expect(handlers.handleWorkflowNodeLoopNext).toHaveBeenCalled()
expect(userCallbacks.onLoopNext).toHaveBeenCalled()
expect(handlers.handleWorkflowNodeLoopFinished).toHaveBeenCalled()
expect(userCallbacks.onLoopFinish).toHaveBeenCalled()
expect(handlers.handleWorkflowNodeRetry).toHaveBeenCalled()
expect(userCallbacks.onNodeRetry).toHaveBeenCalled()
expect(handlers.handleWorkflowAgentLog).toHaveBeenCalled()
expect(userCallbacks.onAgentLog).toHaveBeenCalled()
expect(handlers.handleWorkflowTextChunk).toHaveBeenCalled()
expect(handlers.handleWorkflowTextReplace).toHaveBeenCalled()
expect(handlers.handleWorkflowNodeHumanInputRequired).toHaveBeenCalled()
expect(userCallbacks.onHumanInputRequired).toHaveBeenCalled()
expect(handlers.handleWorkflowNodeHumanInputFormFilled).toHaveBeenCalled()
expect(userCallbacks.onHumanInputFormFilled).toHaveBeenCalled()
expect(handlers.handleWorkflowNodeHumanInputFormTimeout).toHaveBeenCalled()
expect(userCallbacks.onHumanInputFormTimeout).toHaveBeenCalled()
expect(clearListeningState).toHaveBeenCalled()
expect(handlers.handleWorkflowFinished).toHaveBeenCalled()
expect(userCallbacks.onWorkflowFinished).toHaveBeenCalled()
expect(fetchInspectVars).toHaveBeenCalledWith({})
expect(invalidAllLastRun).toHaveBeenCalled()
expect(userCallbacks.onCompleted).toHaveBeenCalledWith(false, '')
expect(player.playAudioWithAudio).toHaveBeenCalledWith('audio-chunk', true)
expect(player.playAudioWithAudio).toHaveBeenCalledWith('audio-finished', false)
expect(mockResetMsgId).toHaveBeenCalledWith('message-1')
expect(handlers.handleWorkflowPaused).toHaveBeenCalled()
expect(userCallbacks.onWorkflowPaused).toHaveBeenCalled()
expect(mockSseGet).toHaveBeenCalledWith('/workflow/run-2/events', {}, callbacks)
expect(clearAbortController).toHaveBeenCalled()
expect(handlers.handleWorkflowFailed).toHaveBeenCalled()
expect(userCallbacks.onError).toHaveBeenCalledWith({ error: 'failed', node_type: 'llm' }, '500')
expect(trackWorkflowRunFailed).toHaveBeenCalledWith({ error: 'failed', node_type: 'llm' })
expect(invalidateRunHistory).toHaveBeenCalledWith('/apps/app-1/workflow-runs')
})
it('should skip base debug-only side effects and audio playback when debug mode is off or audio is empty', () => {
const handlers = createHandlers()
const fetchInspectVars = vi.fn()
const invalidAllLastRun = vi.fn()
const getOrCreatePlayer = vi.fn<() => AudioPlayer | null>(() => null)
const callbacks = createBaseWorkflowRunCallbacks({
clientWidth: 320,
clientHeight: 240,
runHistoryUrl: '/apps/app-1/workflow-runs',
isInWorkflowDebug: false,
fetchInspectVars,
invalidAllLastRun,
invalidateRunHistory: vi.fn(),
clearAbortController: vi.fn(),
clearListeningState: vi.fn(),
trackWorkflowRunFailed: vi.fn(),
handlers,
callbacks: {},
restCallback: {},
getOrCreatePlayer,
})
callbacks.onWorkflowFinished?.({ workflow_run_id: 'run-1' } as never)
callbacks.onTTSChunk?.('message-1', '')
callbacks.onTTSEnd?.('message-1', 'audio-finished')
expect(fetchInspectVars).not.toHaveBeenCalled()
expect(invalidAllLastRun).not.toHaveBeenCalled()
expect(getOrCreatePlayer).toHaveBeenCalledTimes(1)
expect(mockResetMsgId).not.toHaveBeenCalled()
})
it('should route final workflow events through handlers and continue paused runs with final callbacks', async () => {
const handlers = createHandlers()
const userCallbacks = createUserCallbacks()
const fetchInspectVars = vi.fn()
const invalidAllLastRun = vi.fn()
const invalidateRunHistory = vi.fn()
const setAbortController = vi.fn()
const player = {
playAudioWithAudio: vi.fn(),
} as unknown as AudioPlayer
const baseSseOptions = createBaseWorkflowRunCallbacks({
clientWidth: 480,
clientHeight: 320,
runHistoryUrl: '/apps/app-1/workflow-runs',
isInWorkflowDebug: false,
fetchInspectVars: vi.fn(),
invalidAllLastRun: vi.fn(),
invalidateRunHistory: vi.fn(),
clearAbortController: vi.fn(),
clearListeningState: vi.fn(),
trackWorkflowRunFailed: vi.fn(),
handlers,
callbacks: {},
restCallback: {},
getOrCreatePlayer: vi.fn<() => AudioPlayer | null>(() => player),
})
const finalCallbacks = createFinalWorkflowRunCallbacks({
clientWidth: 480,
clientHeight: 320,
runHistoryUrl: '/apps/app-1/workflow-runs',
isInWorkflowDebug: true,
fetchInspectVars,
invalidAllLastRun,
invalidateRunHistory,
clearAbortController: vi.fn(),
clearListeningState: vi.fn(),
trackWorkflowRunFailed: vi.fn(),
handlers,
callbacks: userCallbacks,
restCallback: {},
baseSseOptions,
player,
setAbortController,
})
finalCallbacks.getAbortController?.(new AbortController())
finalCallbacks.onWorkflowFinished?.({ workflow_run_id: 'run-1' } as never)
finalCallbacks.onNodeStarted?.({ node_id: 'node-1' } as never)
finalCallbacks.onNodeFinished?.({ node_id: 'node-1' } as never)
finalCallbacks.onIterationStart?.({ node_id: 'node-1' } as never)
finalCallbacks.onIterationNext?.({ node_id: 'node-1' } as never)
finalCallbacks.onIterationFinish?.({ node_id: 'node-1' } as never)
finalCallbacks.onLoopStart?.({ node_id: 'node-1' } as never)
finalCallbacks.onLoopNext?.({ node_id: 'node-1' } as never)
finalCallbacks.onLoopFinish?.({ node_id: 'node-1' } as never)
finalCallbacks.onNodeRetry?.({ node_id: 'node-1' } as never)
finalCallbacks.onAgentLog?.({ node_id: 'node-1' } as never)
finalCallbacks.onTextChunk?.({ data: 'chunk' } as never)
finalCallbacks.onTextReplace?.({ text: 'replacement' } as never)
finalCallbacks.onHumanInputRequired?.({ node_id: 'node-1' } as never)
finalCallbacks.onHumanInputFormFilled?.({ node_id: 'node-1' } as never)
finalCallbacks.onHumanInputFormTimeout?.({ node_id: 'node-1' } as never)
finalCallbacks.onWorkflowPaused?.({ workflow_run_id: 'run-2' } as never)
finalCallbacks.onTTSChunk?.('message-2', 'audio-chunk')
finalCallbacks.onTTSEnd?.('message-2', 'audio-finished')
await finalCallbacks.onCompleted?.(true, 'done')
finalCallbacks.onError?.({ error: 'failed' } as never, '500')
expect(setAbortController).toHaveBeenCalled()
expect(handlers.handleWorkflowFinished).toHaveBeenCalled()
expect(userCallbacks.onWorkflowFinished).toHaveBeenCalled()
expect(fetchInspectVars).toHaveBeenCalledWith({})
expect(invalidAllLastRun).toHaveBeenCalled()
expect(handlers.handleWorkflowNodeStarted).toHaveBeenCalledWith(
{ node_id: 'node-1' },
{ clientWidth: 480, clientHeight: 320 },
)
expect(handlers.handleWorkflowNodeIterationStarted).toHaveBeenCalledWith(
{ node_id: 'node-1' },
{ clientWidth: 480, clientHeight: 320 },
)
expect(handlers.handleWorkflowNodeLoopStarted).toHaveBeenCalledWith(
{ node_id: 'node-1' },
{ clientWidth: 480, clientHeight: 320 },
)
expect(userCallbacks.onNodeStarted).toHaveBeenCalled()
expect(userCallbacks.onNodeFinished).toHaveBeenCalled()
expect(userCallbacks.onIterationStart).toHaveBeenCalled()
expect(userCallbacks.onIterationNext).toHaveBeenCalled()
expect(userCallbacks.onIterationFinish).toHaveBeenCalled()
expect(userCallbacks.onLoopStart).toHaveBeenCalled()
expect(userCallbacks.onLoopNext).toHaveBeenCalled()
expect(userCallbacks.onLoopFinish).toHaveBeenCalled()
expect(userCallbacks.onNodeRetry).toHaveBeenCalled()
expect(userCallbacks.onAgentLog).toHaveBeenCalled()
expect(handlers.handleWorkflowTextChunk).toHaveBeenCalled()
expect(handlers.handleWorkflowTextReplace).toHaveBeenCalled()
expect(handlers.handleWorkflowNodeHumanInputRequired).toHaveBeenCalled()
expect(userCallbacks.onHumanInputRequired).toHaveBeenCalled()
expect(handlers.handleWorkflowNodeHumanInputFormFilled).toHaveBeenCalled()
expect(userCallbacks.onHumanInputFormFilled).toHaveBeenCalled()
expect(handlers.handleWorkflowNodeHumanInputFormTimeout).toHaveBeenCalled()
expect(userCallbacks.onHumanInputFormTimeout).toHaveBeenCalled()
expect(handlers.handleWorkflowPaused).toHaveBeenCalled()
expect(userCallbacks.onWorkflowPaused).toHaveBeenCalled()
expect(mockSseGet).toHaveBeenCalledWith('/workflow/run-2/events', {}, finalCallbacks)
expect(player.playAudioWithAudio).toHaveBeenCalledWith('audio-chunk', true)
expect(player.playAudioWithAudio).toHaveBeenCalledWith('audio-finished', false)
expect(handlers.handleWorkflowFailed).toHaveBeenCalled()
expect(userCallbacks.onError).toHaveBeenCalledWith({ error: 'failed' }, '500')
expect(invalidateRunHistory).toHaveBeenCalledWith('/apps/app-1/workflow-runs')
})
})

View File

@ -0,0 +1,431 @@
import { TriggerType } from '@/app/components/workflow/header/test-run-menu'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import { AppModeEnum } from '@/types/app'
import {
applyRunningStateForMode,
applyStoppedState,
buildListeningTriggerNodeIds,
buildRunHistoryUrl,
buildTTSConfig,
buildWorkflowRunRequestBody,
clearListeningState,
clearWindowDebugControllers,
createFailedWorkflowState,
createRunningWorkflowState,
createStoppedWorkflowState,
mapPublishedWorkflowFeatures,
normalizePublishedWorkflowNodes,
resolveWorkflowRunUrl,
runTriggerDebug,
validateWorkflowRunRequest,
} from '../use-workflow-run-utils'
const {
mockPost,
mockHandleStream,
mockToastError,
} = vi.hoisted(() => ({
mockPost: vi.fn(),
mockHandleStream: vi.fn(),
mockToastError: vi.fn(),
}))
vi.mock('@/service/base', () => ({
post: mockPost,
handleStream: mockHandleStream,
}))
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
error: mockToastError,
},
}))
const createListeningActions = () => ({
setWorkflowRunningData: vi.fn(),
setIsListening: vi.fn(),
setShowVariableInspectPanel: vi.fn(),
setListeningTriggerType: vi.fn(),
setListeningTriggerNodeIds: vi.fn(),
setListeningTriggerIsAll: vi.fn(),
setListeningTriggerNodeId: vi.fn(),
})
describe('useWorkflowRun utils', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should resolve run history urls and run endpoints for workflow modes', () => {
expect(buildRunHistoryUrl({ id: 'app-1', mode: AppModeEnum.WORKFLOW })).toBe('/apps/app-1/workflow-runs')
expect(buildRunHistoryUrl({ id: 'app-1', mode: AppModeEnum.ADVANCED_CHAT })).toBe('/apps/app-1/advanced-chat/workflow-runs')
expect(resolveWorkflowRunUrl({ id: 'app-1', mode: AppModeEnum.WORKFLOW }, TriggerType.UserInput, true)).toBe('/apps/app-1/workflows/draft/run')
expect(resolveWorkflowRunUrl({ id: 'app-1', mode: AppModeEnum.ADVANCED_CHAT }, TriggerType.UserInput, false)).toBe('/apps/app-1/advanced-chat/workflows/draft/run')
expect(resolveWorkflowRunUrl({ id: 'app-1', mode: AppModeEnum.WORKFLOW }, TriggerType.Schedule, true)).toBe('/apps/app-1/workflows/draft/trigger/run')
expect(resolveWorkflowRunUrl({ id: 'app-1', mode: AppModeEnum.WORKFLOW }, TriggerType.All, true)).toBe('/apps/app-1/workflows/draft/trigger/run-all')
})
it('should build request bodies and validation errors for trigger runs', () => {
expect(buildWorkflowRunRequestBody(TriggerType.Schedule, {}, { scheduleNodeId: 'schedule-1' })).toEqual({ node_id: 'schedule-1' })
expect(buildWorkflowRunRequestBody(TriggerType.Webhook, {}, { webhookNodeId: 'webhook-1' })).toEqual({ node_id: 'webhook-1' })
expect(buildWorkflowRunRequestBody(TriggerType.Plugin, {}, { pluginNodeId: 'plugin-1' })).toEqual({ node_id: 'plugin-1' })
expect(buildWorkflowRunRequestBody(TriggerType.All, {}, { allNodeIds: ['trigger-1', 'trigger-2'] })).toEqual({ node_ids: ['trigger-1', 'trigger-2'] })
expect(buildWorkflowRunRequestBody(TriggerType.UserInput, { inputs: { query: 'hello' } })).toEqual({ inputs: { query: 'hello' } })
expect(validateWorkflowRunRequest(TriggerType.Schedule)).toBe('handleRun: schedule trigger run requires node id')
expect(validateWorkflowRunRequest(TriggerType.Webhook)).toBe('handleRun: webhook trigger run requires node id')
expect(validateWorkflowRunRequest(TriggerType.Plugin)).toBe('handleRun: plugin trigger run requires node id')
expect(validateWorkflowRunRequest(TriggerType.All)).toBe('')
expect(validateWorkflowRunRequest(TriggerType.All, { allNodeIds: [] })).toBe('')
})
it('should return empty trigger urls when app id is missing and keep user-input urls empty outside workflow debug', () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
expect(resolveWorkflowRunUrl(undefined, TriggerType.Plugin, true)).toBe('')
expect(resolveWorkflowRunUrl(undefined, TriggerType.All, true)).toBe('')
expect(resolveWorkflowRunUrl({ id: 'app-1', mode: AppModeEnum.WORKFLOW }, TriggerType.UserInput, false)).toBe('')
expect(consoleErrorSpy).toHaveBeenCalledWith('handleRun: missing app id for trigger plugin run')
expect(consoleErrorSpy).toHaveBeenCalledWith('handleRun: missing app id for trigger run all')
consoleErrorSpy.mockRestore()
})
it('should configure listening state for trigger and non-trigger modes', () => {
const triggerActions = createListeningActions()
applyRunningStateForMode(triggerActions, TriggerType.All, { allNodeIds: ['trigger-1', 'trigger-2'] })
expect(triggerActions.setIsListening).toHaveBeenCalledWith(true)
expect(triggerActions.setShowVariableInspectPanel).toHaveBeenCalledWith(true)
expect(triggerActions.setListeningTriggerIsAll).toHaveBeenCalledWith(true)
expect(triggerActions.setListeningTriggerNodeIds).toHaveBeenCalledWith(['trigger-1', 'trigger-2'])
expect(triggerActions.setWorkflowRunningData).toHaveBeenCalledWith(createRunningWorkflowState())
const normalActions = createListeningActions()
applyRunningStateForMode(normalActions, TriggerType.UserInput)
expect(normalActions.setIsListening).toHaveBeenCalledWith(false)
expect(normalActions.setListeningTriggerType).toHaveBeenCalledWith(null)
expect(normalActions.setListeningTriggerNodeId).toHaveBeenCalledWith(null)
expect(normalActions.setListeningTriggerNodeIds).toHaveBeenCalledWith([])
expect(normalActions.setListeningTriggerIsAll).toHaveBeenCalledWith(false)
expect(normalActions.setWorkflowRunningData).toHaveBeenCalledWith(createRunningWorkflowState())
})
it('should clear listening state, stop state, and remove debug controllers', () => {
const listeningActions = createListeningActions()
clearListeningState(listeningActions)
expect(listeningActions.setIsListening).toHaveBeenCalledWith(false)
expect(listeningActions.setListeningTriggerType).toHaveBeenCalledWith(null)
expect(listeningActions.setListeningTriggerNodeId).toHaveBeenCalledWith(null)
expect(listeningActions.setListeningTriggerNodeIds).toHaveBeenCalledWith([])
expect(listeningActions.setListeningTriggerIsAll).toHaveBeenCalledWith(false)
const stoppedActions = createListeningActions()
applyStoppedState(stoppedActions)
expect(stoppedActions.setWorkflowRunningData).toHaveBeenCalledWith(createStoppedWorkflowState())
expect(stoppedActions.setShowVariableInspectPanel).toHaveBeenCalledWith(true)
const controllerTarget = {
__webhookDebugAbortController: { abort: vi.fn() },
__pluginDebugAbortController: { abort: vi.fn() },
__scheduleDebugAbortController: { abort: vi.fn() },
__allTriggersDebugAbortController: { abort: vi.fn() },
}
clearWindowDebugControllers(controllerTarget)
expect(controllerTarget).toEqual({})
})
it('should derive listening node ids, tts config, and published workflow mappings', () => {
expect(buildListeningTriggerNodeIds(TriggerType.Webhook, { webhookNodeId: 'webhook-1' })).toEqual(['webhook-1'])
expect(buildListeningTriggerNodeIds(TriggerType.Schedule, { scheduleNodeId: 'schedule-1' })).toEqual(['schedule-1'])
expect(buildListeningTriggerNodeIds(TriggerType.Plugin, { pluginNodeId: 'plugin-1' })).toEqual(['plugin-1'])
expect(buildListeningTriggerNodeIds(TriggerType.All, { allNodeIds: ['trigger-1', 'trigger-2'] })).toEqual(['trigger-1', 'trigger-2'])
expect(buildTTSConfig({ token: 'public-token' }, '/apps/app-1')).toEqual({
ttsUrl: '/text-to-audio',
ttsIsPublic: true,
})
expect(buildTTSConfig({ appId: 'app-1' }, '/explore/installed/app-1')).toEqual({
ttsUrl: '/installed-apps/app-1/text-to-audio',
ttsIsPublic: false,
})
expect(buildTTSConfig({ appId: 'app-1' }, '/apps/app-1/workflow')).toEqual({
ttsUrl: '/apps/app-1/text-to-audio',
ttsIsPublic: false,
})
const publishedWorkflow = {
graph: {
nodes: [{ id: 'node-1', selected: true, data: { selected: true, title: 'Start' } }],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
features: {
opening_statement: 'hello',
suggested_questions: ['Q1'],
suggested_questions_after_answer: { enabled: true },
text_to_speech: { enabled: true },
speech_to_text: { enabled: true },
retriever_resource: { enabled: true },
sensitive_word_avoidance: { enabled: true },
file_upload: { enabled: true },
},
} as never
expect(normalizePublishedWorkflowNodes(publishedWorkflow)).toEqual([
{ id: 'node-1', selected: false, data: { selected: false, title: 'Start' } },
])
expect(mapPublishedWorkflowFeatures(publishedWorkflow)).toMatchObject({
opening: {
enabled: true,
opening_statement: 'hello',
suggested_questions: ['Q1'],
},
suggested: { enabled: true },
text2speech: { enabled: true },
speech2text: { enabled: true },
citation: { enabled: true },
moderation: { enabled: true },
file: { enabled: true },
})
})
it('should handle trigger debug null and invalid json responses as request failures', async () => {
const clearAbortController = vi.fn()
const clearListeningStateSpy = vi.fn()
const setAbortController = vi.fn()
const setWorkflowRunningData = vi.fn()
const controllerTarget: Record<string, unknown> = {}
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
mockPost.mockResolvedValueOnce(null)
await runTriggerDebug({
debugType: TriggerType.Webhook,
url: '/apps/app-1/workflows/draft/trigger/run',
requestBody: { node_id: 'webhook-1' },
baseSseOptions: {},
controllerTarget,
setAbortController,
clearAbortController,
clearListeningState: clearListeningStateSpy,
setWorkflowRunningData,
})
expect(mockToastError).toHaveBeenCalledWith('Webhook debug request failed')
expect(clearAbortController).toHaveBeenCalledTimes(1)
expect(clearListeningStateSpy).not.toHaveBeenCalled()
mockPost.mockResolvedValueOnce(new Response('{invalid-json}', {
headers: { 'content-type': 'application/json' },
}))
await runTriggerDebug({
debugType: TriggerType.Schedule,
url: '/apps/app-1/workflows/draft/trigger/run',
requestBody: { node_id: 'schedule-1' },
baseSseOptions: {},
controllerTarget,
setAbortController,
clearAbortController,
clearListeningState: clearListeningStateSpy,
setWorkflowRunningData,
})
expect(consoleErrorSpy).toHaveBeenCalledWith(
'handleRun: schedule debug response parse error',
expect.any(Error),
)
expect(mockToastError).toHaveBeenCalledWith('Schedule debug request failed')
expect(clearAbortController).toHaveBeenCalledTimes(2)
expect(clearListeningStateSpy).toHaveBeenCalledTimes(1)
expect(setWorkflowRunningData).not.toHaveBeenCalled()
consoleErrorSpy.mockRestore()
})
it('should handle trigger debug json failures and stream responses', async () => {
const clearAbortController = vi.fn()
const clearListeningStateSpy = vi.fn()
const setAbortController = vi.fn()
const setWorkflowRunningData = vi.fn()
const controllerTarget: Record<string, unknown> = {}
const baseSseOptions = {
onData: vi.fn(),
onCompleted: vi.fn(),
}
mockPost.mockResolvedValueOnce(new Response(JSON.stringify({ message: 'Webhook failed' }), {
headers: { 'content-type': 'application/json' },
}))
await runTriggerDebug({
debugType: TriggerType.Webhook,
url: '/apps/app-1/workflows/draft/trigger/run',
requestBody: { node_id: 'webhook-1' },
baseSseOptions,
controllerTarget,
setAbortController,
clearAbortController,
clearListeningState: clearListeningStateSpy,
setWorkflowRunningData,
})
expect(setAbortController).toHaveBeenCalledTimes(1)
expect(mockToastError).toHaveBeenCalledWith('Webhook failed')
expect(clearAbortController).toHaveBeenCalled()
expect(clearListeningStateSpy).toHaveBeenCalled()
expect(setWorkflowRunningData).toHaveBeenCalledWith(createFailedWorkflowState('Webhook failed'))
mockPost.mockResolvedValueOnce(new Response('data: ok', {
headers: { 'content-type': 'text/event-stream' },
}))
await runTriggerDebug({
debugType: TriggerType.Plugin,
url: '/apps/app-1/workflows/draft/trigger/run',
requestBody: { node_id: 'plugin-1' },
baseSseOptions,
controllerTarget,
setAbortController,
clearAbortController,
clearListeningState: clearListeningStateSpy,
setWorkflowRunningData,
})
expect(clearListeningStateSpy).toHaveBeenCalledTimes(2)
expect(mockHandleStream).toHaveBeenCalledTimes(1)
})
it('should retry waiting trigger debug responses until a stream is returned', async () => {
vi.useFakeTimers()
const clearAbortController = vi.fn()
const clearListeningStateSpy = vi.fn()
const setAbortController = vi.fn()
const setWorkflowRunningData = vi.fn()
const controllerTarget: Record<string, unknown> = {}
const baseSseOptions = {
onData: vi.fn(),
onCompleted: vi.fn(),
}
mockPost
.mockResolvedValueOnce(new Response(JSON.stringify({ status: 'waiting', retry_in: 1 }), {
headers: { 'content-type': 'application/json' },
}))
.mockResolvedValueOnce(new Response('data: ok', {
headers: { 'content-type': 'text/event-stream' },
}))
const runPromise = runTriggerDebug({
debugType: TriggerType.All,
url: '/apps/app-1/workflows/draft/trigger/run-all',
requestBody: { node_ids: ['trigger-1'] },
baseSseOptions,
controllerTarget,
setAbortController,
clearAbortController,
clearListeningState: clearListeningStateSpy,
setWorkflowRunningData,
})
await vi.advanceTimersByTimeAsync(1)
await runPromise
expect(mockPost).toHaveBeenCalledTimes(2)
expect(clearListeningStateSpy).toHaveBeenCalledTimes(1)
expect(mockHandleStream).toHaveBeenCalledTimes(1)
vi.useRealTimers()
})
it('should stop trigger debug processing when the controller aborts before handling the response', async () => {
const clearAbortController = vi.fn()
const clearListeningStateSpy = vi.fn()
const setWorkflowRunningData = vi.fn()
const controllerTarget: Record<string, unknown> = {}
mockPost.mockResolvedValueOnce(new Response('data: ok', {
headers: { 'content-type': 'text/event-stream' },
}))
await runTriggerDebug({
debugType: TriggerType.Plugin,
url: '/apps/app-1/workflows/draft/trigger/run',
requestBody: { node_id: 'plugin-1' },
baseSseOptions: {},
controllerTarget,
setAbortController: (controller) => {
controller?.abort()
},
clearAbortController,
clearListeningState: clearListeningStateSpy,
setWorkflowRunningData,
})
expect(mockHandleStream).not.toHaveBeenCalled()
expect(mockToastError).not.toHaveBeenCalled()
expect(clearAbortController).not.toHaveBeenCalled()
expect(clearListeningStateSpy).not.toHaveBeenCalled()
expect(setWorkflowRunningData).not.toHaveBeenCalled()
})
it('should handle Response and non-Response trigger debug exceptions correctly', async () => {
const clearAbortController = vi.fn()
const clearListeningStateSpy = vi.fn()
const setAbortController = vi.fn()
const setWorkflowRunningData = vi.fn()
const controllerTarget: Record<string, unknown> = {}
mockPost.mockRejectedValueOnce(new Response(JSON.stringify({ error: 'Plugin failed' }), {
headers: { 'content-type': 'application/json' },
}))
await runTriggerDebug({
debugType: TriggerType.Plugin,
url: '/apps/app-1/workflows/draft/trigger/run',
requestBody: { node_id: 'plugin-1' },
baseSseOptions: {},
controllerTarget,
setAbortController,
clearAbortController,
clearListeningState: clearListeningStateSpy,
setWorkflowRunningData,
})
expect(mockToastError).toHaveBeenCalledWith('Plugin failed')
expect(clearAbortController).toHaveBeenCalledTimes(1)
expect(setWorkflowRunningData).toHaveBeenCalledWith(createFailedWorkflowState('Plugin failed'))
expect(clearListeningStateSpy).toHaveBeenCalledTimes(1)
mockPost.mockRejectedValueOnce(new Error('network failed'))
await runTriggerDebug({
debugType: TriggerType.Plugin,
url: '/apps/app-1/workflows/draft/trigger/run',
requestBody: { node_id: 'plugin-1' },
baseSseOptions: {},
controllerTarget,
setAbortController,
clearAbortController,
clearListeningState: clearListeningStateSpy,
setWorkflowRunningData,
})
expect(clearAbortController).toHaveBeenCalledTimes(1)
expect(setWorkflowRunningData).toHaveBeenCalledTimes(1)
expect(clearListeningStateSpy).toHaveBeenCalledTimes(2)
})
it('should expose the canonical workflow state factories', () => {
expect(createRunningWorkflowState().result.status).toBe(WorkflowRunningStatus.Running)
expect(createStoppedWorkflowState().result.status).toBe(WorkflowRunningStatus.Stopped)
expect(createFailedWorkflowState('failed').result.status).toBe(WorkflowRunningStatus.Failed)
})
})

View File

@ -0,0 +1,592 @@
import { act, renderHook } from '@testing-library/react'
import { TriggerType } from '@/app/components/workflow/header/test-run-menu'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import { useWorkflowRun } from '../use-workflow-run'
type DebugAbortControllerRef = {
abort: () => void
}
type DebugControllerWindow = Window & {
__webhookDebugAbortController?: DebugAbortControllerRef
__pluginDebugAbortController?: DebugAbortControllerRef
__scheduleDebugAbortController?: DebugAbortControllerRef
__allTriggersDebugAbortController?: DebugAbortControllerRef
}
type WorkflowStoreState = {
backupDraft?: unknown
environmentVariables?: unknown
setBackupDraft?: (value: unknown) => void
setEnvironmentVariables?: (value: unknown) => void
setWorkflowRunningData?: (value: unknown) => void
setIsListening?: (value: boolean) => void
setShowVariableInspectPanel?: (value: boolean) => void
setListeningTriggerType?: (value: unknown) => void
setListeningTriggerNodeIds?: (value: string[]) => void
setListeningTriggerIsAll?: (value: boolean) => void
setListeningTriggerNodeId?: (value: string | null) => void
}
const mocks = vi.hoisted(() => {
const appStoreState = {
appDetail: {
id: 'app-1',
mode: 'workflow',
name: 'Workflow App',
},
}
const reactFlowStoreState = {
edges: [{ id: 'edge-1' }],
getNodes: vi.fn(),
setNodes: vi.fn(),
}
const workflowStoreState: WorkflowStoreState = {}
const workflowStoreSetState = vi.fn((partial: Record<string, unknown>) => {
Object.assign(workflowStoreState, partial)
})
const featuresStoreState = {
features: {
file: {
enabled: true,
},
},
}
const featuresStoreSetState = vi.fn((partial: Record<string, unknown>) => {
Object.assign(featuresStoreState, partial)
})
return {
appStoreState,
reactFlowStoreState,
workflowStoreState,
workflowStoreSetState,
featuresStoreState,
featuresStoreSetState,
mockGetViewport: vi.fn(),
mockDoSyncWorkflowDraft: vi.fn(),
mockHandleUpdateWorkflowCanvas: vi.fn(),
mockFetchInspectVars: vi.fn(),
mockInvalidateAllLastRun: vi.fn(),
mockInvalidateRunHistory: vi.fn(),
mockSsePost: vi.fn(),
mockSseGet: vi.fn(),
mockHandleStream: vi.fn(),
mockPost: vi.fn(),
mockStopWorkflowRun: vi.fn(),
mockTrackEvent: vi.fn(),
mockGetAudioPlayer: vi.fn(),
mockResetMsgId: vi.fn(),
mockCreateBaseWorkflowRunCallbacks: vi.fn(),
mockCreateFinalWorkflowRunCallbacks: vi.fn(),
runEventHandlers: {
handleWorkflowStarted: vi.fn(),
handleWorkflowFinished: vi.fn(),
handleWorkflowFailed: vi.fn(),
handleWorkflowNodeStarted: vi.fn(),
handleWorkflowNodeFinished: vi.fn(),
handleWorkflowNodeHumanInputRequired: vi.fn(),
handleWorkflowNodeHumanInputFormFilled: vi.fn(),
handleWorkflowNodeHumanInputFormTimeout: vi.fn(),
handleWorkflowNodeIterationStarted: vi.fn(),
handleWorkflowNodeIterationNext: vi.fn(),
handleWorkflowNodeIterationFinished: vi.fn(),
handleWorkflowNodeLoopStarted: vi.fn(),
handleWorkflowNodeLoopNext: vi.fn(),
handleWorkflowNodeLoopFinished: vi.fn(),
handleWorkflowNodeRetry: vi.fn(),
handleWorkflowAgentLog: vi.fn(),
handleWorkflowTextChunk: vi.fn(),
handleWorkflowTextReplace: vi.fn(),
handleWorkflowPaused: vi.fn(),
},
}
})
vi.mock('reactflow', () => ({
useStoreApi: () => ({
getState: () => mocks.reactFlowStoreState,
}),
useReactFlow: () => ({
getViewport: mocks.mockGetViewport,
}),
}))
vi.mock('@/app/components/app/store', () => {
const useStore = Object.assign(vi.fn(), {
getState: () => mocks.appStoreState,
})
return {
useStore,
}
})
vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: mocks.mockTrackEvent,
}))
vi.mock('@/app/components/base/audio-btn/audio.player.manager', () => ({
AudioPlayerManager: {
getInstance: () => ({
getAudioPlayer: mocks.mockGetAudioPlayer,
resetMsgId: mocks.mockResetMsgId,
}),
},
}))
vi.mock('@/app/components/base/features/hooks', () => ({
useFeaturesStore: () => ({
getState: () => mocks.featuresStoreState,
setState: mocks.featuresStoreSetState,
}),
}))
vi.mock('@/app/components/workflow/hooks/use-workflow-interactions', () => ({
useWorkflowUpdate: () => ({
handleUpdateWorkflowCanvas: mocks.mockHandleUpdateWorkflowCanvas,
}),
}))
vi.mock('@/app/components/workflow/hooks/use-workflow-run-event/use-workflow-run-event', () => ({
useWorkflowRunEvent: () => mocks.runEventHandlers,
}))
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => ({
getState: () => mocks.workflowStoreState,
setState: mocks.workflowStoreSetState,
}),
}))
vi.mock('@/next/navigation', () => ({
usePathname: () => '/apps/app-1/workflow',
}))
vi.mock('@/service/base', () => ({
ssePost: mocks.mockSsePost,
sseGet: mocks.mockSseGet,
post: mocks.mockPost,
handleStream: mocks.mockHandleStream,
}))
vi.mock('@/service/use-workflow', () => ({
useInvalidAllLastRun: () => mocks.mockInvalidateAllLastRun,
useInvalidateWorkflowRunHistory: () => mocks.mockInvalidateRunHistory,
useInvalidateConversationVarValues: () => vi.fn(),
useInvalidateSysVarValues: () => vi.fn(),
}))
vi.mock('@/service/workflow', () => ({
stopWorkflowRun: mocks.mockStopWorkflowRun,
}))
vi.mock('@/app/components/workflow/hooks/use-fetch-workflow-inspect-vars', () => ({
useSetWorkflowVarsWithValue: () => ({
fetchInspectVars: mocks.mockFetchInspectVars,
}),
}))
vi.mock('../use-configs-map', () => ({
useConfigsMap: () => ({
flowId: 'flow-1',
flowType: 'workflow',
}),
}))
vi.mock('../use-nodes-sync-draft', () => ({
useNodesSyncDraft: () => ({
doSyncWorkflowDraft: mocks.mockDoSyncWorkflowDraft,
}),
}))
vi.mock('../use-workflow-run-callbacks', async (importOriginal) => {
const actual = await importOriginal<typeof import('../use-workflow-run-callbacks')>()
return {
...actual,
createBaseWorkflowRunCallbacks: vi.fn((params) => {
mocks.mockCreateBaseWorkflowRunCallbacks(params)
return actual.createBaseWorkflowRunCallbacks(params)
}),
createFinalWorkflowRunCallbacks: vi.fn((params) => {
mocks.mockCreateFinalWorkflowRunCallbacks(params)
return actual.createFinalWorkflowRunCallbacks(params)
}),
}
})
const createWorkflowStoreState = () => ({
backupDraft: undefined,
environmentVariables: [{ id: 'env-current', value: 'secret' }],
setBackupDraft: vi.fn((value: unknown) => {
mocks.workflowStoreState.backupDraft = value
}),
setEnvironmentVariables: vi.fn((value: unknown) => {
mocks.workflowStoreState.environmentVariables = value
}),
setWorkflowRunningData: vi.fn(),
setIsListening: vi.fn(),
setShowVariableInspectPanel: vi.fn(),
setListeningTriggerType: vi.fn(),
setListeningTriggerNodeIds: vi.fn(),
setListeningTriggerIsAll: vi.fn(),
setListeningTriggerNodeId: vi.fn(),
})
describe('useWorkflowRun', () => {
beforeEach(() => {
vi.clearAllMocks()
document.body.innerHTML = '<div id="workflow-container"></div>'
const workflowContainer = document.getElementById('workflow-container')!
Object.defineProperty(workflowContainer, 'clientWidth', { value: 960, configurable: true })
Object.defineProperty(workflowContainer, 'clientHeight', { value: 540, configurable: true })
mocks.reactFlowStoreState.getNodes.mockReturnValue([
{ id: 'node-1', data: { selected: true, _runningStatus: 'running' } },
])
mocks.mockGetViewport.mockReturnValue({ x: 1, y: 2, zoom: 1.5 })
mocks.mockDoSyncWorkflowDraft.mockResolvedValue(undefined)
mocks.mockPost.mockResolvedValue(new Response('data: ok', {
headers: { 'content-type': 'text/event-stream' },
}))
mocks.mockGetAudioPlayer.mockReturnValue({
playAudioWithAudio: vi.fn(),
})
mocks.workflowStoreState.backupDraft = undefined
Object.assign(mocks.workflowStoreState, createWorkflowStoreState())
mocks.workflowStoreSetState.mockImplementation((partial: Record<string, unknown>) => {
Object.assign(mocks.workflowStoreState, partial)
})
mocks.featuresStoreState.features = {
file: {
enabled: true,
},
}
})
it('should backup the current draft once and skip subsequent backups until it is cleared', () => {
const { result } = renderHook(() => useWorkflowRun())
act(() => {
result.current.handleBackupDraft()
result.current.handleBackupDraft()
})
expect(mocks.workflowStoreState.setBackupDraft).toHaveBeenCalledTimes(1)
expect(mocks.workflowStoreState.setBackupDraft).toHaveBeenCalledWith({
nodes: [{ id: 'node-1', data: { selected: true, _runningStatus: 'running' } }],
edges: [{ id: 'edge-1' }],
viewport: { x: 1, y: 2, zoom: 1.5 },
features: { file: { enabled: true } },
environmentVariables: [{ id: 'env-current', value: 'secret' }],
})
expect(mocks.mockDoSyncWorkflowDraft).toHaveBeenCalledTimes(1)
})
it('should load a backup draft into canvas, environment variables, and features state', () => {
mocks.workflowStoreState.backupDraft = {
nodes: [{ id: 'backup-node' }],
edges: [{ id: 'backup-edge' }],
viewport: { x: 0, y: 0, zoom: 2 },
features: { opening: { enabled: true } },
environmentVariables: [{ id: 'env-backup', value: 'value' }],
}
const { result } = renderHook(() => useWorkflowRun())
act(() => {
result.current.handleLoadBackupDraft()
})
expect(mocks.mockHandleUpdateWorkflowCanvas).toHaveBeenCalledWith({
nodes: [{ id: 'backup-node' }],
edges: [{ id: 'backup-edge' }],
viewport: { x: 0, y: 0, zoom: 2 },
})
expect(mocks.workflowStoreState.setEnvironmentVariables).toHaveBeenCalledWith([{ id: 'env-backup', value: 'value' }])
expect(mocks.featuresStoreSetState).toHaveBeenCalledWith({
features: { opening: { enabled: true } },
})
expect(mocks.workflowStoreState.setBackupDraft).toHaveBeenCalledWith(undefined)
})
it('should prepare the graph and dispatch a workflow run through ssePost for user-input mode', async () => {
const { result } = renderHook(() => useWorkflowRun())
await act(async () => {
await result.current.handleRun({ inputs: { query: 'hello' } })
})
expect(mocks.reactFlowStoreState.setNodes).toHaveBeenCalledWith([
{ id: 'node-1', data: { selected: false, _runningStatus: undefined } },
])
expect(mocks.mockDoSyncWorkflowDraft).toHaveBeenCalled()
expect(mocks.workflowStoreSetState).toHaveBeenCalledWith({ historyWorkflowData: undefined })
expect(mocks.workflowStoreState.setIsListening).toHaveBeenCalledWith(false)
expect(mocks.workflowStoreState.setListeningTriggerType).toHaveBeenCalledWith(null)
expect(mocks.workflowStoreState.setListeningTriggerNodeId).toHaveBeenCalledWith(null)
expect(mocks.workflowStoreState.setListeningTriggerNodeIds).toHaveBeenCalledWith([])
expect(mocks.workflowStoreState.setListeningTriggerIsAll).toHaveBeenCalledWith(false)
expect(mocks.workflowStoreState.setWorkflowRunningData).toHaveBeenCalledWith(expect.objectContaining({
result: expect.objectContaining({
status: WorkflowRunningStatus.Running,
}),
}))
expect(mocks.mockSsePost).toHaveBeenCalledWith(
'/apps/app-1/workflows/draft/run',
{ body: { inputs: { query: 'hello' } } },
expect.objectContaining({
getAbortController: expect.any(Function),
}),
)
})
it.each([
{
title: 'schedule',
params: {},
options: { mode: TriggerType.Schedule, scheduleNodeId: 'schedule-1' },
expectedUrl: '/apps/app-1/workflows/draft/trigger/run',
expectedBody: { node_id: 'schedule-1' },
expectedNodeIds: ['schedule-1'],
expectedIsAll: false,
},
{
title: 'webhook',
params: { node_id: 'webhook-1' },
options: { mode: TriggerType.Webhook, webhookNodeId: 'webhook-1' },
expectedUrl: '/apps/app-1/workflows/draft/trigger/run',
expectedBody: { node_id: 'webhook-1' },
expectedNodeIds: ['webhook-1'],
expectedIsAll: false,
},
{
title: 'plugin',
params: { node_id: 'plugin-1' },
options: { mode: TriggerType.Plugin, pluginNodeId: 'plugin-1' },
expectedUrl: '/apps/app-1/workflows/draft/trigger/run',
expectedBody: { node_id: 'plugin-1' },
expectedNodeIds: ['plugin-1'],
expectedIsAll: false,
},
{
title: 'all',
params: { node_ids: ['trigger-1', 'trigger-2'] },
options: { mode: TriggerType.All, allNodeIds: ['trigger-1', 'trigger-2'] },
expectedUrl: '/apps/app-1/workflows/draft/trigger/run-all',
expectedBody: { node_ids: ['trigger-1', 'trigger-2'] },
expectedNodeIds: ['trigger-1', 'trigger-2'],
expectedIsAll: true,
},
])('should dispatch $title trigger runs through the debug runner integration', async ({
params,
options,
expectedUrl,
expectedBody,
expectedNodeIds,
expectedIsAll,
}) => {
const { result } = renderHook(() => useWorkflowRun())
await act(async () => {
await result.current.handleRun(params, undefined, options)
})
expect(mocks.mockPost).toHaveBeenCalledWith(
expectedUrl,
expect.objectContaining({
body: expectedBody,
signal: expect.any(AbortSignal),
}),
{ needAllResponseContent: true },
)
expect(mocks.workflowStoreState.setIsListening).toHaveBeenCalledWith(true)
expect(mocks.workflowStoreState.setListeningTriggerNodeIds).toHaveBeenCalledWith(expectedNodeIds)
expect(mocks.workflowStoreState.setListeningTriggerIsAll).toHaveBeenCalledWith(expectedIsAll)
expect(mocks.mockSsePost).not.toHaveBeenCalled()
})
it('should expose the workflow-failed tracker through the callback factory context', async () => {
const { result } = renderHook(() => useWorkflowRun())
await act(async () => {
await result.current.handleRun({ inputs: { query: 'hello' } })
})
const baseCallbackFactoryContext = mocks.mockCreateBaseWorkflowRunCallbacks.mock.calls.at(-1)?.[0] as {
trackWorkflowRunFailed: (params: { error?: string, node_type?: string }) => void
}
baseCallbackFactoryContext.trackWorkflowRunFailed({ error: 'failed', node_type: 'llm' })
expect(mocks.mockTrackEvent).toHaveBeenCalledWith('workflow_run_failed', {
workflow_id: 'flow-1',
reason: 'failed',
node_type: 'llm',
})
})
it('should lazily create audio players with the correct public and private tts urls', async () => {
const { result } = renderHook(() => useWorkflowRun())
await act(async () => {
await result.current.handleRun({ token: 'public-token' })
})
const publicBaseCallbackFactoryContext = mocks.mockCreateBaseWorkflowRunCallbacks.mock.calls.at(-1)?.[0] as {
getOrCreatePlayer: () => unknown
}
publicBaseCallbackFactoryContext.getOrCreatePlayer()
expect(mocks.mockGetAudioPlayer).toHaveBeenCalledWith(
'/text-to-audio',
true,
expect.any(String),
'none',
'none',
expect.any(Function),
)
mocks.mockSsePost.mockClear()
mocks.mockGetAudioPlayer.mockClear()
await act(async () => {
await result.current.handleRun({ appId: 'app-2' })
})
const privateBaseCallbackFactoryContext = mocks.mockCreateBaseWorkflowRunCallbacks.mock.calls.at(-1)?.[0] as {
getOrCreatePlayer: () => unknown
}
privateBaseCallbackFactoryContext.getOrCreatePlayer()
expect(mocks.mockGetAudioPlayer).toHaveBeenCalledWith(
'/apps/app-2/text-to-audio',
false,
expect.any(String),
'none',
'none',
expect.any(Function),
)
})
it('should stop workflow runs by task id or by aborting active debug controllers', async () => {
const { result } = renderHook(() => useWorkflowRun())
await act(async () => {
await result.current.handleRun({ inputs: { query: 'hello' } })
})
act(() => {
result.current.handleStopRun('task-1')
})
expect(mocks.mockStopWorkflowRun).toHaveBeenCalledWith('/apps/app-1/workflow-runs/tasks/task-1/stop')
expect(mocks.workflowStoreState.setWorkflowRunningData).toHaveBeenCalledWith(expect.objectContaining({
result: expect.objectContaining({
status: WorkflowRunningStatus.Stopped,
}),
}))
const webhookAbort = vi.fn()
const pluginAbort = vi.fn()
const scheduleAbort = vi.fn()
const allTriggersAbort = vi.fn()
const windowWithDebugControllers = window as DebugControllerWindow
windowWithDebugControllers.__webhookDebugAbortController = { abort: webhookAbort }
windowWithDebugControllers.__pluginDebugAbortController = { abort: pluginAbort }
windowWithDebugControllers.__scheduleDebugAbortController = { abort: scheduleAbort }
windowWithDebugControllers.__allTriggersDebugAbortController = { abort: allTriggersAbort }
const refController = new AbortController()
const refAbortSpy = vi.spyOn(refController, 'abort')
const { getAbortController } = mocks.mockSsePost.mock.calls.at(-1)?.[2] as {
getAbortController?: (controller: AbortController) => void
}
getAbortController?.(refController)
act(() => {
result.current.handleStopRun('')
})
expect(webhookAbort).toHaveBeenCalled()
expect(pluginAbort).toHaveBeenCalled()
expect(scheduleAbort).toHaveBeenCalled()
expect(allTriggersAbort).toHaveBeenCalled()
expect(refAbortSpy).toHaveBeenCalled()
})
it('should restore published workflow graph, features, and environment variables', () => {
const { result } = renderHook(() => useWorkflowRun())
act(() => {
result.current.handleRestoreFromPublishedWorkflow({
graph: {
nodes: [{ id: 'published-node', selected: true, data: { selected: true, label: 'Published' } }],
edges: [{ id: 'published-edge' }],
viewport: { x: 10, y: 20, zoom: 0.8 },
},
features: {
opening_statement: 'hello',
suggested_questions: ['Q1'],
suggested_questions_after_answer: { enabled: true },
text_to_speech: { enabled: true },
speech_to_text: { enabled: true },
retriever_resource: { enabled: true },
sensitive_word_avoidance: { enabled: true },
file_upload: { enabled: true },
},
environment_variables: [{ id: 'env-published', value: 'value' }],
} as never)
})
expect(mocks.mockHandleUpdateWorkflowCanvas).toHaveBeenCalledWith({
nodes: [{ id: 'published-node', selected: false, data: { selected: false, label: 'Published' } }],
edges: [{ id: 'published-edge' }],
viewport: { x: 10, y: 20, zoom: 0.8 },
})
expect(mocks.featuresStoreSetState).toHaveBeenCalledWith({
features: expect.objectContaining({
opening: expect.objectContaining({
enabled: true,
opening_statement: 'hello',
}),
file: { enabled: true },
}),
})
expect(mocks.workflowStoreState.setEnvironmentVariables).toHaveBeenCalledWith([{ id: 'env-published', value: 'value' }])
})
it('should restore published workflows with empty environment variables as an empty list', () => {
const { result } = renderHook(() => useWorkflowRun())
act(() => {
result.current.handleRestoreFromPublishedWorkflow({
graph: {
nodes: [{ id: 'published-node', selected: true, data: { selected: true, label: 'Published' } }],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
features: {
opening_statement: '',
suggested_questions: [],
suggested_questions_after_answer: { enabled: false },
text_to_speech: { enabled: false },
speech_to_text: { enabled: false },
retriever_resource: { enabled: false },
sensitive_word_avoidance: { enabled: false },
file_upload: { enabled: false },
},
} as never)
})
expect(mocks.featuresStoreSetState).toHaveBeenCalledWith({
features: expect.objectContaining({
opening: expect.objectContaining({ enabled: false }),
file: { enabled: false },
}),
})
expect(mocks.workflowStoreState.setEnvironmentVariables).toHaveBeenCalledWith([])
})
})

View File

@ -0,0 +1,391 @@
import { act, renderHook } from '@testing-library/react'
import { TriggerType } from '@/app/components/workflow/header/test-run-menu'
import {
BlockEnum,
WorkflowRunningStatus,
} from '@/app/components/workflow/types'
import { useWorkflowStartRun } from '../use-workflow-start-run'
const mockGetNodes = vi.fn()
const mockGetFeaturesState = vi.fn()
const mockHandleCancelDebugAndPreviewPanel = vi.fn()
const mockHandleRun = vi.fn()
const mockDoSyncWorkflowDraft = vi.fn()
const mockUseIsChatMode = vi.fn()
const mockSetShowDebugAndPreviewPanel = vi.fn()
const mockSetShowInputsPanel = vi.fn()
const mockSetShowEnvPanel = vi.fn()
const mockSetShowGlobalVariablePanel = vi.fn()
const mockSetShowChatVariablePanel = vi.fn()
const mockSetListeningTriggerType = vi.fn()
const mockSetListeningTriggerNodeId = vi.fn()
const mockSetListeningTriggerNodeIds = vi.fn()
const mockSetListeningTriggerIsAll = vi.fn()
const mockSetHistoryWorkflowData = vi.fn()
let workflowStoreState: Record<string, unknown>
vi.mock('reactflow', () => ({
useStoreApi: () => ({
getState: () => ({
getNodes: mockGetNodes,
}),
}),
}))
vi.mock('@/app/components/base/features/hooks', () => ({
useFeaturesStore: () => ({
getState: mockGetFeaturesState,
}),
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useWorkflowInteractions: () => ({
handleCancelDebugAndPreviewPanel: mockHandleCancelDebugAndPreviewPanel,
}),
}))
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => ({
getState: () => workflowStoreState,
}),
}))
vi.mock('@/app/components/workflow-app/hooks', () => ({
useIsChatMode: () => mockUseIsChatMode(),
useNodesSyncDraft: () => ({
doSyncWorkflowDraft: mockDoSyncWorkflowDraft,
}),
useWorkflowRun: () => ({
handleRun: mockHandleRun,
}),
}))
const createWorkflowStoreState = (overrides: Record<string, unknown> = {}) => ({
workflowRunningData: undefined,
showDebugAndPreviewPanel: false,
setShowDebugAndPreviewPanel: mockSetShowDebugAndPreviewPanel,
setShowInputsPanel: mockSetShowInputsPanel,
setShowEnvPanel: mockSetShowEnvPanel,
setShowGlobalVariablePanel: mockSetShowGlobalVariablePanel,
setShowChatVariablePanel: mockSetShowChatVariablePanel,
setListeningTriggerType: mockSetListeningTriggerType,
setListeningTriggerNodeId: mockSetListeningTriggerNodeId,
setListeningTriggerNodeIds: mockSetListeningTriggerNodeIds,
setListeningTriggerIsAll: mockSetListeningTriggerIsAll,
setHistoryWorkflowData: mockSetHistoryWorkflowData,
...overrides,
})
describe('useWorkflowStartRun', () => {
beforeEach(() => {
vi.clearAllMocks()
workflowStoreState = createWorkflowStoreState()
mockGetNodes.mockReturnValue([
{ id: 'start-1', data: { type: BlockEnum.Start, variables: [] } },
])
mockGetFeaturesState.mockReturnValue({
features: {
file: {
image: {
enabled: false,
},
},
},
})
mockDoSyncWorkflowDraft.mockResolvedValue(undefined)
mockUseIsChatMode.mockReturnValue(false)
})
it('should run the workflow immediately when there are no start variables and no image upload input', async () => {
const { result } = renderHook(() => useWorkflowStartRun())
await act(async () => {
await result.current.handleWorkflowStartRunInWorkflow()
})
expect(mockSetShowEnvPanel).toHaveBeenCalledWith(false)
expect(mockSetShowGlobalVariablePanel).toHaveBeenCalledWith(false)
expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
expect(mockHandleRun).toHaveBeenCalledWith({ inputs: {}, files: [] })
expect(mockSetShowDebugAndPreviewPanel).toHaveBeenCalledWith(true)
expect(mockSetShowInputsPanel).toHaveBeenCalledWith(false)
})
it('should open the input panel instead of running immediately when start inputs are required', async () => {
mockGetNodes.mockReturnValue([
{ id: 'start-1', data: { type: BlockEnum.Start, variables: [{ name: 'query' }] } },
])
const { result } = renderHook(() => useWorkflowStartRun())
await act(async () => {
await result.current.handleWorkflowStartRunInWorkflow()
})
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
expect(mockHandleRun).not.toHaveBeenCalled()
expect(mockSetShowDebugAndPreviewPanel).toHaveBeenCalledWith(true)
expect(mockSetShowInputsPanel).toHaveBeenCalledWith(true)
})
it('should open the input panel when image upload is enabled even without start variables', async () => {
mockGetFeaturesState.mockReturnValue({
features: {
file: {
image: {
enabled: true,
},
},
},
})
const { result } = renderHook(() => useWorkflowStartRun())
await act(async () => {
await result.current.handleWorkflowStartRunInWorkflow()
})
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
expect(mockHandleRun).not.toHaveBeenCalled()
expect(mockSetShowDebugAndPreviewPanel).toHaveBeenCalledWith(true)
expect(mockSetShowInputsPanel).toHaveBeenCalledWith(true)
})
it('should cancel the current debug panel instead of starting another workflow when one is already open', async () => {
workflowStoreState = createWorkflowStoreState({
showDebugAndPreviewPanel: true,
})
const { result } = renderHook(() => useWorkflowStartRun())
await act(async () => {
await result.current.handleWorkflowStartRunInWorkflow()
})
expect(mockHandleCancelDebugAndPreviewPanel).toHaveBeenCalled()
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
expect(mockHandleRun).not.toHaveBeenCalled()
})
it('should short-circuit workflow start when a run is already in progress', async () => {
workflowStoreState = createWorkflowStoreState({
workflowRunningData: {
result: {
status: WorkflowRunningStatus.Running,
},
},
})
const { result } = renderHook(() => useWorkflowStartRun())
await act(async () => {
await result.current.handleWorkflowStartRunInWorkflow()
})
expect(mockSetShowEnvPanel).not.toHaveBeenCalled()
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
expect(mockHandleRun).not.toHaveBeenCalled()
})
it('should configure schedule trigger runs and execute the workflow with schedule options', async () => {
mockGetNodes.mockReturnValue([
{ id: 'schedule-1', data: { type: BlockEnum.TriggerSchedule } },
])
const { result } = renderHook(() => useWorkflowStartRun())
await act(async () => {
await result.current.handleWorkflowTriggerScheduleRunInWorkflow('schedule-1')
})
expect(mockSetShowEnvPanel).toHaveBeenCalledWith(false)
expect(mockSetShowGlobalVariablePanel).toHaveBeenCalledWith(false)
expect(mockSetListeningTriggerType).toHaveBeenCalledWith(BlockEnum.TriggerSchedule)
expect(mockSetListeningTriggerNodeId).toHaveBeenCalledWith('schedule-1')
expect(mockSetListeningTriggerNodeIds).toHaveBeenCalledWith(['schedule-1'])
expect(mockSetListeningTriggerIsAll).toHaveBeenCalledWith(false)
expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
expect(mockHandleRun).toHaveBeenCalledWith(
{},
undefined,
{
mode: TriggerType.Schedule,
scheduleNodeId: 'schedule-1',
},
)
expect(mockSetShowDebugAndPreviewPanel).toHaveBeenCalledWith(true)
expect(mockSetShowInputsPanel).toHaveBeenCalledWith(false)
})
it('should cancel schedule trigger execution when the debug panel is already open', async () => {
workflowStoreState = createWorkflowStoreState({
showDebugAndPreviewPanel: true,
})
mockGetNodes.mockReturnValue([
{ id: 'schedule-1', data: { type: BlockEnum.TriggerSchedule } },
])
const { result } = renderHook(() => useWorkflowStartRun())
await act(async () => {
await result.current.handleWorkflowTriggerScheduleRunInWorkflow('schedule-1')
})
expect(mockHandleCancelDebugAndPreviewPanel).toHaveBeenCalled()
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
expect(mockHandleRun).not.toHaveBeenCalled()
})
it.each([
{
title: 'schedule',
invoke: (hook: ReturnType<typeof useWorkflowStartRun>) => hook.handleWorkflowTriggerScheduleRunInWorkflow(undefined),
},
{
title: 'webhook',
invoke: (hook: ReturnType<typeof useWorkflowStartRun>) => hook.handleWorkflowTriggerWebhookRunInWorkflow({ nodeId: '' }),
},
{
title: 'plugin',
invoke: (hook: ReturnType<typeof useWorkflowStartRun>) => hook.handleWorkflowTriggerPluginRunInWorkflow(''),
},
])('should ignore $title trigger execution when the node id is empty', async ({ invoke }) => {
const { result } = renderHook(() => useWorkflowStartRun())
await act(async () => {
await invoke(result.current)
})
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
expect(mockHandleRun).not.toHaveBeenCalled()
})
it.each([
{
title: 'schedule',
warnMessage: 'handleWorkflowTriggerScheduleRunInWorkflow: schedule node not found',
invoke: (hook: ReturnType<typeof useWorkflowStartRun>) => hook.handleWorkflowTriggerScheduleRunInWorkflow('schedule-missing'),
},
{
title: 'webhook',
warnMessage: 'handleWorkflowTriggerWebhookRunInWorkflow: webhook node not found',
invoke: (hook: ReturnType<typeof useWorkflowStartRun>) => hook.handleWorkflowTriggerWebhookRunInWorkflow({ nodeId: 'webhook-missing' }),
},
{
title: 'plugin',
warnMessage: 'handleWorkflowTriggerPluginRunInWorkflow: plugin node not found',
invoke: (hook: ReturnType<typeof useWorkflowStartRun>) => hook.handleWorkflowTriggerPluginRunInWorkflow('plugin-missing'),
},
])('should warn when the $title trigger node cannot be found', async ({ warnMessage, invoke }) => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
mockGetNodes.mockReturnValue([{ id: 'other-node', data: { type: BlockEnum.Start } }])
const { result } = renderHook(() => useWorkflowStartRun())
await act(async () => {
await invoke(result.current)
})
expect(consoleWarnSpy).toHaveBeenCalledWith(warnMessage, expect.stringContaining('missing'))
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
expect(mockHandleRun).not.toHaveBeenCalled()
consoleWarnSpy.mockRestore()
})
it.each([
{
title: 'webhook',
nodeId: 'webhook-1',
nodeType: BlockEnum.TriggerWebhook,
invoke: (hook: ReturnType<typeof useWorkflowStartRun>) => hook.handleWorkflowTriggerWebhookRunInWorkflow({ nodeId: 'webhook-1' }),
expectedParams: { node_id: 'webhook-1' },
expectedOptions: { mode: TriggerType.Webhook, webhookNodeId: 'webhook-1' },
},
{
title: 'plugin',
nodeId: 'plugin-1',
nodeType: BlockEnum.TriggerPlugin,
invoke: (hook: ReturnType<typeof useWorkflowStartRun>) => hook.handleWorkflowTriggerPluginRunInWorkflow('plugin-1'),
expectedParams: { node_id: 'plugin-1' },
expectedOptions: { mode: TriggerType.Plugin, pluginNodeId: 'plugin-1' },
},
])('should configure $title trigger runs with node-specific options', async ({ nodeId, nodeType, invoke, expectedParams, expectedOptions }) => {
mockGetNodes.mockReturnValue([
{ id: nodeId, data: { type: nodeType } },
])
const { result } = renderHook(() => useWorkflowStartRun())
await act(async () => {
await invoke(result.current)
})
expect(mockSetShowEnvPanel).toHaveBeenCalledWith(false)
expect(mockSetShowGlobalVariablePanel).toHaveBeenCalledWith(false)
expect(mockSetShowDebugAndPreviewPanel).toHaveBeenCalledWith(true)
expect(mockSetShowInputsPanel).toHaveBeenCalledWith(false)
expect(mockSetListeningTriggerType).toHaveBeenCalledWith(nodeType)
expect(mockSetListeningTriggerNodeId).toHaveBeenCalledWith(nodeId)
expect(mockSetListeningTriggerNodeIds).toHaveBeenCalledWith([nodeId])
expect(mockSetListeningTriggerIsAll).toHaveBeenCalledWith(false)
expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
expect(mockHandleRun).toHaveBeenCalledWith(expectedParams, undefined, expectedOptions)
})
it('should run all triggers and mark the listener state as global', async () => {
const { result } = renderHook(() => useWorkflowStartRun())
await act(async () => {
await result.current.handleWorkflowRunAllTriggersInWorkflow(['trigger-1', 'trigger-2'])
})
expect(mockSetShowEnvPanel).toHaveBeenCalledWith(false)
expect(mockSetShowGlobalVariablePanel).toHaveBeenCalledWith(false)
expect(mockSetShowInputsPanel).toHaveBeenCalledWith(false)
expect(mockSetListeningTriggerIsAll).toHaveBeenCalledWith(true)
expect(mockSetListeningTriggerNodeIds).toHaveBeenCalledWith(['trigger-1', 'trigger-2'])
expect(mockSetListeningTriggerNodeId).toHaveBeenCalledWith(null)
expect(mockSetShowDebugAndPreviewPanel).toHaveBeenCalledWith(true)
expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
expect(mockHandleRun).toHaveBeenCalledWith(
{ node_ids: ['trigger-1', 'trigger-2'] },
undefined,
{
mode: TriggerType.All,
allNodeIds: ['trigger-1', 'trigger-2'],
},
)
})
it('should ignore run-all requests when there are no trigger nodes', async () => {
const { result } = renderHook(() => useWorkflowStartRun())
await act(async () => {
await result.current.handleWorkflowRunAllTriggersInWorkflow([])
})
expect(mockSetListeningTriggerIsAll).not.toHaveBeenCalled()
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
expect(mockHandleRun).not.toHaveBeenCalled()
})
it('should route handleStartWorkflowRun to the chatflow path when chat mode is enabled', async () => {
mockUseIsChatMode.mockReturnValue(true)
const { result } = renderHook(() => useWorkflowStartRun())
await act(async () => {
result.current.handleStartWorkflowRun()
})
expect(mockSetShowEnvPanel).toHaveBeenCalledWith(false)
expect(mockSetShowChatVariablePanel).toHaveBeenCalledWith(false)
expect(mockSetShowGlobalVariablePanel).toHaveBeenCalledWith(false)
expect(mockSetShowDebugAndPreviewPanel).toHaveBeenCalledWith(true)
expect(mockSetHistoryWorkflowData).toHaveBeenCalledWith(undefined)
expect(mockHandleRun).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,82 @@
import { renderHook } from '@testing-library/react'
import { useWorkflowTemplate } from '../use-workflow-template'
const mockUseIsChatMode = vi.fn()
let generateNewNodeCalls: Array<Record<string, unknown>> = []
vi.mock('@/app/components/workflow-app/hooks/use-is-chat-mode', () => ({
useIsChatMode: () => mockUseIsChatMode(),
}))
vi.mock('@/app/components/workflow/utils', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/workflow/utils')>()
return {
...actual,
generateNewNode: (args: { id?: string, data: Record<string, unknown>, position: Record<string, unknown> }) => {
generateNewNodeCalls.push(args)
return {
newNode: {
id: args.id ?? `generated-${generateNewNodeCalls.length}`,
data: args.data,
position: args.position,
},
}
},
}
})
describe('useWorkflowTemplate', () => {
beforeEach(() => {
vi.clearAllMocks()
generateNewNodeCalls = []
})
it('should return only the start node template in workflow mode', () => {
mockUseIsChatMode.mockReturnValue(false)
const { result } = renderHook(() => useWorkflowTemplate())
expect(result.current.nodes).toHaveLength(1)
expect(result.current.edges).toEqual([])
expect(generateNewNodeCalls).toHaveLength(1)
})
it('should build start, llm, and answer templates with linked edges in chat mode', () => {
mockUseIsChatMode.mockReturnValue(true)
const { result } = renderHook(() => useWorkflowTemplate())
expect(result.current.nodes).toHaveLength(3)
expect(result.current.nodes.map(node => node.id)).toEqual(['generated-1', 'llm', 'answer'])
expect(result.current.edges).toEqual([
{
id: 'generated-1-llm',
source: 'generated-1',
sourceHandle: 'source',
target: 'llm',
targetHandle: 'target',
},
{
id: 'llm-answer',
source: 'llm',
sourceHandle: 'source',
target: 'answer',
targetHandle: 'target',
},
])
expect(generateNewNodeCalls).toHaveLength(3)
expect(generateNewNodeCalls[0].data).toMatchObject({
type: 'start',
title: 'workflow.blocks.start',
})
expect(generateNewNodeCalls[1].data).toMatchObject({
type: 'llm',
title: 'workflow.blocks.llm',
})
expect(generateNewNodeCalls[2].data).toMatchObject({
type: 'answer',
title: 'workflow.blocks.answer',
answer: '{{#llm.text#}}',
})
})
})

View File

@ -0,0 +1,470 @@
import type AudioPlayer from '@/app/components/base/audio-btn/audio'
import type { IOtherOptions } from '@/service/base'
import { AudioPlayerManager } from '@/app/components/base/audio-btn/audio.player.manager'
import { sseGet } from '@/service/base'
type ContainerSize = {
clientWidth: number
clientHeight: number
}
type WorkflowRunEventHandlers = {
handleWorkflowStarted: NonNullable<IOtherOptions['onWorkflowStarted']>
handleWorkflowFinished: NonNullable<IOtherOptions['onWorkflowFinished']>
handleWorkflowFailed: () => void
handleWorkflowNodeStarted: (params: Parameters<NonNullable<IOtherOptions['onNodeStarted']>>[0], containerParams: ContainerSize) => void
handleWorkflowNodeFinished: NonNullable<IOtherOptions['onNodeFinished']>
handleWorkflowNodeHumanInputRequired: NonNullable<IOtherOptions['onHumanInputRequired']>
handleWorkflowNodeHumanInputFormFilled: NonNullable<IOtherOptions['onHumanInputFormFilled']>
handleWorkflowNodeHumanInputFormTimeout: NonNullable<IOtherOptions['onHumanInputFormTimeout']>
handleWorkflowNodeIterationStarted: (params: Parameters<NonNullable<IOtherOptions['onIterationStart']>>[0], containerParams: ContainerSize) => void
handleWorkflowNodeIterationNext: NonNullable<IOtherOptions['onIterationNext']>
handleWorkflowNodeIterationFinished: NonNullable<IOtherOptions['onIterationFinish']>
handleWorkflowNodeLoopStarted: (params: Parameters<NonNullable<IOtherOptions['onLoopStart']>>[0], containerParams: ContainerSize) => void
handleWorkflowNodeLoopNext: NonNullable<IOtherOptions['onLoopNext']>
handleWorkflowNodeLoopFinished: NonNullable<IOtherOptions['onLoopFinish']>
handleWorkflowNodeRetry: NonNullable<IOtherOptions['onNodeRetry']>
handleWorkflowAgentLog: NonNullable<IOtherOptions['onAgentLog']>
handleWorkflowTextChunk: NonNullable<IOtherOptions['onTextChunk']>
handleWorkflowTextReplace: NonNullable<IOtherOptions['onTextReplace']>
handleWorkflowPaused: () => void
}
type UserCallbackHandlers = {
onWorkflowStarted?: IOtherOptions['onWorkflowStarted']
onWorkflowFinished?: IOtherOptions['onWorkflowFinished']
onNodeStarted?: IOtherOptions['onNodeStarted']
onNodeFinished?: IOtherOptions['onNodeFinished']
onIterationStart?: IOtherOptions['onIterationStart']
onIterationNext?: IOtherOptions['onIterationNext']
onIterationFinish?: IOtherOptions['onIterationFinish']
onLoopStart?: IOtherOptions['onLoopStart']
onLoopNext?: IOtherOptions['onLoopNext']
onLoopFinish?: IOtherOptions['onLoopFinish']
onNodeRetry?: IOtherOptions['onNodeRetry']
onAgentLog?: IOtherOptions['onAgentLog']
onError?: IOtherOptions['onError']
onWorkflowPaused?: IOtherOptions['onWorkflowPaused']
onHumanInputRequired?: IOtherOptions['onHumanInputRequired']
onHumanInputFormFilled?: IOtherOptions['onHumanInputFormFilled']
onHumanInputFormTimeout?: IOtherOptions['onHumanInputFormTimeout']
onCompleted?: IOtherOptions['onCompleted']
}
type CallbackContext = {
clientWidth: number
clientHeight: number
runHistoryUrl: string
isInWorkflowDebug: boolean
fetchInspectVars: (params: Record<string, never>) => void
invalidAllLastRun: () => void
invalidateRunHistory: (url: string) => void
clearAbortController: () => void
clearListeningState: () => void
trackWorkflowRunFailed: (params: unknown) => void
handlers: WorkflowRunEventHandlers
callbacks: UserCallbackHandlers
restCallback: IOtherOptions
}
type BaseCallbacksContext = CallbackContext & {
getOrCreatePlayer: () => AudioPlayer | null
}
type FinalCallbacksContext = CallbackContext & {
baseSseOptions: IOtherOptions
player: AudioPlayer | null
setAbortController: (controller: AbortController) => void
}
export const createBaseWorkflowRunCallbacks = ({
clientWidth,
clientHeight,
runHistoryUrl,
isInWorkflowDebug,
fetchInspectVars,
invalidAllLastRun,
invalidateRunHistory,
clearAbortController,
clearListeningState,
trackWorkflowRunFailed,
handlers,
callbacks,
restCallback,
getOrCreatePlayer,
}: BaseCallbacksContext): IOtherOptions => {
const {
handleWorkflowStarted,
handleWorkflowFinished,
handleWorkflowFailed,
handleWorkflowNodeStarted,
handleWorkflowNodeFinished,
handleWorkflowNodeHumanInputRequired,
handleWorkflowNodeHumanInputFormFilled,
handleWorkflowNodeHumanInputFormTimeout,
handleWorkflowNodeIterationStarted,
handleWorkflowNodeIterationNext,
handleWorkflowNodeIterationFinished,
handleWorkflowNodeLoopStarted,
handleWorkflowNodeLoopNext,
handleWorkflowNodeLoopFinished,
handleWorkflowNodeRetry,
handleWorkflowAgentLog,
handleWorkflowTextChunk,
handleWorkflowTextReplace,
handleWorkflowPaused,
} = handlers
const {
onWorkflowStarted,
onWorkflowFinished,
onNodeStarted,
onNodeFinished,
onIterationStart,
onIterationNext,
onIterationFinish,
onLoopStart,
onLoopNext,
onLoopFinish,
onNodeRetry,
onAgentLog,
onError,
onWorkflowPaused,
onHumanInputRequired,
onHumanInputFormFilled,
onHumanInputFormTimeout,
onCompleted,
} = callbacks
const wrappedOnError: IOtherOptions['onError'] = (params, code) => {
clearAbortController()
handleWorkflowFailed()
invalidateRunHistory(runHistoryUrl)
clearListeningState()
if (onError)
onError(params, code)
trackWorkflowRunFailed(params)
}
const wrappedOnCompleted: IOtherOptions['onCompleted'] = async (hasError, errorMessage) => {
clearAbortController()
clearListeningState()
if (onCompleted)
onCompleted(hasError, errorMessage)
}
const baseSseOptions: IOtherOptions = {
...restCallback,
onWorkflowStarted: (params) => {
handleWorkflowStarted(params)
invalidateRunHistory(runHistoryUrl)
if (onWorkflowStarted)
onWorkflowStarted(params)
},
onWorkflowFinished: (params) => {
clearListeningState()
handleWorkflowFinished(params)
invalidateRunHistory(runHistoryUrl)
if (onWorkflowFinished)
onWorkflowFinished(params)
if (isInWorkflowDebug) {
fetchInspectVars({})
invalidAllLastRun()
}
},
onNodeStarted: (params) => {
handleWorkflowNodeStarted(params, { clientWidth, clientHeight })
if (onNodeStarted)
onNodeStarted(params)
},
onNodeFinished: (params) => {
handleWorkflowNodeFinished(params)
if (onNodeFinished)
onNodeFinished(params)
},
onIterationStart: (params) => {
handleWorkflowNodeIterationStarted(params, { clientWidth, clientHeight })
if (onIterationStart)
onIterationStart(params)
},
onIterationNext: (params) => {
handleWorkflowNodeIterationNext(params)
if (onIterationNext)
onIterationNext(params)
},
onIterationFinish: (params) => {
handleWorkflowNodeIterationFinished(params)
if (onIterationFinish)
onIterationFinish(params)
},
onLoopStart: (params) => {
handleWorkflowNodeLoopStarted(params, { clientWidth, clientHeight })
if (onLoopStart)
onLoopStart(params)
},
onLoopNext: (params) => {
handleWorkflowNodeLoopNext(params)
if (onLoopNext)
onLoopNext(params)
},
onLoopFinish: (params) => {
handleWorkflowNodeLoopFinished(params)
if (onLoopFinish)
onLoopFinish(params)
},
onNodeRetry: (params) => {
handleWorkflowNodeRetry(params)
if (onNodeRetry)
onNodeRetry(params)
},
onAgentLog: (params) => {
handleWorkflowAgentLog(params)
if (onAgentLog)
onAgentLog(params)
},
onTextChunk: (params) => {
handleWorkflowTextChunk(params)
},
onTextReplace: (params) => {
handleWorkflowTextReplace(params)
},
onTTSChunk: (messageId: string, audio: string) => {
if (!audio || audio === '')
return
const audioPlayer = getOrCreatePlayer()
if (audioPlayer) {
audioPlayer.playAudioWithAudio(audio, true)
AudioPlayerManager.getInstance().resetMsgId(messageId)
}
},
onTTSEnd: (_messageId: string, audio: string) => {
const audioPlayer = getOrCreatePlayer()
if (audioPlayer)
audioPlayer.playAudioWithAudio(audio, false)
},
onWorkflowPaused: (params) => {
handleWorkflowPaused()
invalidateRunHistory(runHistoryUrl)
if (onWorkflowPaused)
onWorkflowPaused(params)
const url = `/workflow/${params.workflow_run_id}/events`
sseGet(url, {}, baseSseOptions)
},
onHumanInputRequired: (params) => {
handleWorkflowNodeHumanInputRequired(params)
if (onHumanInputRequired)
onHumanInputRequired(params)
},
onHumanInputFormFilled: (params) => {
handleWorkflowNodeHumanInputFormFilled(params)
if (onHumanInputFormFilled)
onHumanInputFormFilled(params)
},
onHumanInputFormTimeout: (params) => {
handleWorkflowNodeHumanInputFormTimeout(params)
if (onHumanInputFormTimeout)
onHumanInputFormTimeout(params)
},
onError: wrappedOnError,
onCompleted: wrappedOnCompleted,
}
return baseSseOptions
}
export const createFinalWorkflowRunCallbacks = ({
clientWidth,
clientHeight,
runHistoryUrl,
isInWorkflowDebug,
fetchInspectVars,
invalidAllLastRun,
invalidateRunHistory,
clearAbortController: _clearAbortController,
clearListeningState: _clearListeningState,
trackWorkflowRunFailed: _trackWorkflowRunFailed,
handlers,
callbacks,
restCallback,
baseSseOptions,
player,
setAbortController,
}: FinalCallbacksContext): IOtherOptions => {
const {
handleWorkflowFinished,
handleWorkflowFailed,
handleWorkflowNodeStarted,
handleWorkflowNodeFinished,
handleWorkflowNodeHumanInputRequired,
handleWorkflowNodeHumanInputFormFilled,
handleWorkflowNodeHumanInputFormTimeout,
handleWorkflowNodeIterationStarted,
handleWorkflowNodeIterationNext,
handleWorkflowNodeIterationFinished,
handleWorkflowNodeLoopStarted,
handleWorkflowNodeLoopNext,
handleWorkflowNodeLoopFinished,
handleWorkflowNodeRetry,
handleWorkflowAgentLog,
handleWorkflowTextChunk,
handleWorkflowTextReplace,
handleWorkflowPaused,
} = handlers
const {
onWorkflowFinished,
onNodeStarted,
onNodeFinished,
onIterationStart,
onIterationNext,
onIterationFinish,
onLoopStart,
onLoopNext,
onLoopFinish,
onNodeRetry,
onAgentLog,
onError,
onWorkflowPaused,
onHumanInputRequired,
onHumanInputFormFilled,
onHumanInputFormTimeout,
} = callbacks
const finalCallbacks: IOtherOptions = {
...baseSseOptions,
getAbortController: (controller: AbortController) => {
setAbortController(controller)
},
onWorkflowFinished: (params) => {
handleWorkflowFinished(params)
invalidateRunHistory(runHistoryUrl)
if (onWorkflowFinished)
onWorkflowFinished(params)
if (isInWorkflowDebug) {
fetchInspectVars({})
invalidAllLastRun()
}
},
onError: (params, code) => {
handleWorkflowFailed()
invalidateRunHistory(runHistoryUrl)
if (onError)
onError(params, code)
},
onNodeStarted: (params) => {
handleWorkflowNodeStarted(params, { clientWidth, clientHeight })
if (onNodeStarted)
onNodeStarted(params)
},
onNodeFinished: (params) => {
handleWorkflowNodeFinished(params)
if (onNodeFinished)
onNodeFinished(params)
},
onIterationStart: (params) => {
handleWorkflowNodeIterationStarted(params, { clientWidth, clientHeight })
if (onIterationStart)
onIterationStart(params)
},
onIterationNext: (params) => {
handleWorkflowNodeIterationNext(params)
if (onIterationNext)
onIterationNext(params)
},
onIterationFinish: (params) => {
handleWorkflowNodeIterationFinished(params)
if (onIterationFinish)
onIterationFinish(params)
},
onLoopStart: (params) => {
handleWorkflowNodeLoopStarted(params, { clientWidth, clientHeight })
if (onLoopStart)
onLoopStart(params)
},
onLoopNext: (params) => {
handleWorkflowNodeLoopNext(params)
if (onLoopNext)
onLoopNext(params)
},
onLoopFinish: (params) => {
handleWorkflowNodeLoopFinished(params)
if (onLoopFinish)
onLoopFinish(params)
},
onNodeRetry: (params) => {
handleWorkflowNodeRetry(params)
if (onNodeRetry)
onNodeRetry(params)
},
onAgentLog: (params) => {
handleWorkflowAgentLog(params)
if (onAgentLog)
onAgentLog(params)
},
onTextChunk: (params) => {
handleWorkflowTextChunk(params)
},
onTextReplace: (params) => {
handleWorkflowTextReplace(params)
},
onTTSChunk: (messageId: string, audio: string) => {
if (!audio || audio === '')
return
player?.playAudioWithAudio(audio, true)
AudioPlayerManager.getInstance().resetMsgId(messageId)
},
onTTSEnd: (_messageId: string, audio: string) => {
player?.playAudioWithAudio(audio, false)
},
onWorkflowPaused: (params) => {
handleWorkflowPaused()
invalidateRunHistory(runHistoryUrl)
if (onWorkflowPaused)
onWorkflowPaused(params)
const url = `/workflow/${params.workflow_run_id}/events`
sseGet(url, {}, finalCallbacks)
},
onHumanInputRequired: (params) => {
handleWorkflowNodeHumanInputRequired(params)
if (onHumanInputRequired)
onHumanInputRequired(params)
},
onHumanInputFormFilled: (params) => {
handleWorkflowNodeHumanInputFormFilled(params)
if (onHumanInputFormFilled)
onHumanInputFormFilled(params)
},
onHumanInputFormTimeout: (params) => {
handleWorkflowNodeHumanInputFormTimeout(params)
if (onHumanInputFormTimeout)
onHumanInputFormTimeout(params)
},
...restCallback,
}
return finalCallbacks
}

View File

@ -0,0 +1,443 @@
import type { Features as FeaturesData } from '@/app/components/base/features/types'
import type { TriggerNodeType } from '@/app/components/workflow/types'
import type { IOtherOptions } from '@/service/base'
import type { VersionHistory } from '@/types/workflow'
import { noop } from 'es-toolkit/function'
import { toast } from '@/app/components/base/ui/toast'
import { TriggerType } from '@/app/components/workflow/header/test-run-menu'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import { handleStream, post } from '@/service/base'
import { ContentType } from '@/service/fetch'
import { AppModeEnum } from '@/types/app'
export type HandleRunMode = TriggerType
export type HandleRunOptions = {
mode?: HandleRunMode
scheduleNodeId?: string
webhookNodeId?: string
pluginNodeId?: string
allNodeIds?: string[]
}
export type DebuggableTriggerType = Exclude<TriggerType, TriggerType.UserInput>
type AppDetailLike = {
id?: string
mode?: AppModeEnum
}
type TTSParamsLike = {
token?: string
appId?: string
}
type ListeningStateActions = {
setWorkflowRunningData: (data: ReturnType<typeof createRunningWorkflowState> | ReturnType<typeof createFailedWorkflowState> | ReturnType<typeof createStoppedWorkflowState>) => void
setIsListening: (value: boolean) => void
setShowVariableInspectPanel: (value: boolean) => void
setListeningTriggerType: (value: TriggerNodeType | null) => void
setListeningTriggerNodeIds: (value: string[]) => void
setListeningTriggerIsAll: (value: boolean) => void
setListeningTriggerNodeId: (value: string | null) => void
}
type TriggerDebugRunnerOptions = {
debugType: DebuggableTriggerType
url: string
requestBody: unknown
baseSseOptions: IOtherOptions
controllerTarget: Record<string, unknown>
setAbortController: (controller: AbortController | null) => void
clearAbortController: () => void
clearListeningState: () => void
setWorkflowRunningData: ListeningStateActions['setWorkflowRunningData']
}
export const controllerKeyMap: Record<DebuggableTriggerType, string> = {
[TriggerType.Webhook]: '__webhookDebugAbortController',
[TriggerType.Plugin]: '__pluginDebugAbortController',
[TriggerType.All]: '__allTriggersDebugAbortController',
[TriggerType.Schedule]: '__scheduleDebugAbortController',
}
export const debugLabelMap: Record<DebuggableTriggerType, string> = {
[TriggerType.Webhook]: 'Webhook',
[TriggerType.Plugin]: 'Plugin',
[TriggerType.All]: 'All',
[TriggerType.Schedule]: 'Schedule',
}
export const createRunningWorkflowState = () => {
return {
result: {
status: WorkflowRunningStatus.Running,
inputs_truncated: false,
process_data_truncated: false,
outputs_truncated: false,
},
tracing: [],
resultText: '',
}
}
export const createStoppedWorkflowState = () => {
return {
result: {
status: WorkflowRunningStatus.Stopped,
inputs_truncated: false,
process_data_truncated: false,
outputs_truncated: false,
},
tracing: [],
resultText: '',
}
}
export const createFailedWorkflowState = (error: string) => {
return {
result: {
status: WorkflowRunningStatus.Failed,
error,
inputs_truncated: false,
process_data_truncated: false,
outputs_truncated: false,
},
tracing: [],
}
}
export const buildRunHistoryUrl = (appDetail?: AppDetailLike) => {
return appDetail?.mode === AppModeEnum.ADVANCED_CHAT
? `/apps/${appDetail.id}/advanced-chat/workflow-runs`
: `/apps/${appDetail?.id}/workflow-runs`
}
export const resolveWorkflowRunUrl = (
appDetail: AppDetailLike | undefined,
runMode: HandleRunMode,
isInWorkflowDebug: boolean,
) => {
if (runMode === TriggerType.Plugin || runMode === TriggerType.Webhook || runMode === TriggerType.Schedule) {
if (!appDetail?.id) {
console.error('handleRun: missing app id for trigger plugin run')
return ''
}
return `/apps/${appDetail.id}/workflows/draft/trigger/run`
}
if (runMode === TriggerType.All) {
if (!appDetail?.id) {
console.error('handleRun: missing app id for trigger run all')
return ''
}
return `/apps/${appDetail.id}/workflows/draft/trigger/run-all`
}
if (appDetail?.mode === AppModeEnum.ADVANCED_CHAT)
return `/apps/${appDetail.id}/advanced-chat/workflows/draft/run`
if (isInWorkflowDebug && appDetail?.id)
return `/apps/${appDetail.id}/workflows/draft/run`
return ''
}
export const buildWorkflowRunRequestBody = (
runMode: HandleRunMode,
resolvedParams: Record<string, unknown>,
options?: HandleRunOptions,
) => {
if (runMode === TriggerType.Schedule)
return { node_id: options?.scheduleNodeId }
if (runMode === TriggerType.Webhook)
return { node_id: options?.webhookNodeId }
if (runMode === TriggerType.Plugin)
return { node_id: options?.pluginNodeId }
if (runMode === TriggerType.All)
return { node_ids: options?.allNodeIds }
return resolvedParams
}
export const validateWorkflowRunRequest = (
runMode: HandleRunMode,
options?: HandleRunOptions,
) => {
if (runMode === TriggerType.Schedule && !options?.scheduleNodeId)
return 'handleRun: schedule trigger run requires node id'
if (runMode === TriggerType.Webhook && !options?.webhookNodeId)
return 'handleRun: webhook trigger run requires node id'
if (runMode === TriggerType.Plugin && !options?.pluginNodeId)
return 'handleRun: plugin trigger run requires node id'
if (runMode === TriggerType.All && !options?.allNodeIds && options?.allNodeIds?.length === 0)
return 'handleRun: all trigger run requires node ids'
return ''
}
export const isDebuggableTriggerType = (
runMode: HandleRunMode,
): runMode is DebuggableTriggerType => {
return (
runMode === TriggerType.Schedule
|| runMode === TriggerType.Webhook
|| runMode === TriggerType.Plugin
|| runMode === TriggerType.All
)
}
export const buildListeningTriggerNodeIds = (
runMode: DebuggableTriggerType,
options?: HandleRunOptions,
) => {
if (runMode === TriggerType.All)
return options?.allNodeIds ?? []
if (runMode === TriggerType.Webhook && options?.webhookNodeId)
return [options.webhookNodeId]
if (runMode === TriggerType.Schedule && options?.scheduleNodeId)
return [options.scheduleNodeId]
if (runMode === TriggerType.Plugin && options?.pluginNodeId)
return [options.pluginNodeId]
return []
}
export const applyRunningStateForMode = (
actions: ListeningStateActions,
runMode: HandleRunMode,
options?: HandleRunOptions,
) => {
if (isDebuggableTriggerType(runMode)) {
actions.setIsListening(true)
actions.setShowVariableInspectPanel(true)
actions.setListeningTriggerIsAll(runMode === TriggerType.All)
actions.setListeningTriggerNodeIds(buildListeningTriggerNodeIds(runMode, options))
actions.setWorkflowRunningData(createRunningWorkflowState())
return
}
actions.setIsListening(false)
actions.setListeningTriggerType(null)
actions.setListeningTriggerNodeId(null)
actions.setListeningTriggerNodeIds([])
actions.setListeningTriggerIsAll(false)
actions.setWorkflowRunningData(createRunningWorkflowState())
}
export const clearListeningState = (actions: Pick<ListeningStateActions, 'setIsListening' | 'setListeningTriggerType' | 'setListeningTriggerNodeId' | 'setListeningTriggerNodeIds' | 'setListeningTriggerIsAll'>) => {
actions.setIsListening(false)
actions.setListeningTriggerType(null)
actions.setListeningTriggerNodeId(null)
actions.setListeningTriggerNodeIds([])
actions.setListeningTriggerIsAll(false)
}
export const applyStoppedState = (actions: Pick<ListeningStateActions, 'setWorkflowRunningData' | 'setIsListening' | 'setShowVariableInspectPanel' | 'setListeningTriggerType' | 'setListeningTriggerNodeId'>) => {
actions.setWorkflowRunningData(createStoppedWorkflowState())
actions.setIsListening(false)
actions.setListeningTriggerType(null)
actions.setListeningTriggerNodeId(null)
actions.setShowVariableInspectPanel(true)
}
export const clearWindowDebugControllers = (controllerTarget: Record<string, unknown>) => {
delete controllerTarget.__webhookDebugAbortController
delete controllerTarget.__pluginDebugAbortController
delete controllerTarget.__scheduleDebugAbortController
delete controllerTarget.__allTriggersDebugAbortController
}
export const buildTTSConfig = (resolvedParams: TTSParamsLike, pathname: string) => {
let ttsUrl = ''
let ttsIsPublic = false
if (resolvedParams.token) {
ttsUrl = '/text-to-audio'
ttsIsPublic = true
}
else if (resolvedParams.appId) {
if (pathname.search('explore/installed') > -1)
ttsUrl = `/installed-apps/${resolvedParams.appId}/text-to-audio`
else
ttsUrl = `/apps/${resolvedParams.appId}/text-to-audio`
}
return {
ttsUrl,
ttsIsPublic,
}
}
export const mapPublishedWorkflowFeatures = (publishedWorkflow: VersionHistory): FeaturesData => {
return {
opening: {
enabled: !!publishedWorkflow.features.opening_statement || !!publishedWorkflow.features.suggested_questions.length,
opening_statement: publishedWorkflow.features.opening_statement,
suggested_questions: publishedWorkflow.features.suggested_questions,
},
suggested: publishedWorkflow.features.suggested_questions_after_answer,
text2speech: publishedWorkflow.features.text_to_speech,
speech2text: publishedWorkflow.features.speech_to_text,
citation: publishedWorkflow.features.retriever_resource,
moderation: publishedWorkflow.features.sensitive_word_avoidance,
file: publishedWorkflow.features.file_upload,
}
}
export const normalizePublishedWorkflowNodes = (publishedWorkflow: VersionHistory) => {
return publishedWorkflow.graph.nodes.map(node => ({
...node,
selected: false,
data: {
...node.data,
selected: false,
},
}))
}
export const waitWithAbort = (signal: AbortSignal, delay: number) => new Promise<void>((resolve) => {
const timer = window.setTimeout(resolve, delay)
signal.addEventListener('abort', () => {
clearTimeout(timer)
resolve()
}, { once: true })
})
export const runTriggerDebug = async ({
debugType,
url,
requestBody,
baseSseOptions,
controllerTarget,
setAbortController,
clearAbortController,
clearListeningState,
setWorkflowRunningData,
}: TriggerDebugRunnerOptions) => {
const controller = new AbortController()
setAbortController(controller)
const controllerKey = controllerKeyMap[debugType]
controllerTarget[controllerKey] = controller
const debugLabel = debugLabelMap[debugType]
const poll = async (): Promise<void> => {
try {
const response = await post<Response>(url, {
body: requestBody,
signal: controller.signal,
}, {
needAllResponseContent: true,
})
if (controller.signal.aborted)
return
if (!response) {
const message = `${debugLabel} debug request failed`
toast.error(message)
clearAbortController()
return
}
const contentType = response.headers.get('content-type') || ''
if (contentType.includes(ContentType.json)) {
let data: Record<string, unknown> | null = null
try {
data = await response.json() as Record<string, unknown>
}
catch (jsonError) {
console.error(`handleRun: ${debugLabel.toLowerCase()} debug response parse error`, jsonError)
toast.error(`${debugLabel} debug request failed`)
clearAbortController()
clearListeningState()
return
}
if (controller.signal.aborted)
return
if (data?.status === 'waiting') {
const delay = Number(data.retry_in) || 2000
await waitWithAbort(controller.signal, delay)
if (controller.signal.aborted)
return
await poll()
return
}
const errorMessage = typeof data?.message === 'string' ? data.message : `${debugLabel} debug failed`
toast.error(errorMessage)
clearAbortController()
setWorkflowRunningData(createFailedWorkflowState(errorMessage))
clearListeningState()
return
}
clearListeningState()
handleStream(
response,
baseSseOptions.onData ?? noop,
baseSseOptions.onCompleted,
baseSseOptions.onThought,
baseSseOptions.onMessageEnd,
baseSseOptions.onMessageReplace,
baseSseOptions.onFile,
baseSseOptions.onWorkflowStarted,
baseSseOptions.onWorkflowFinished,
baseSseOptions.onNodeStarted,
baseSseOptions.onNodeFinished,
baseSseOptions.onIterationStart,
baseSseOptions.onIterationNext,
baseSseOptions.onIterationFinish,
baseSseOptions.onLoopStart,
baseSseOptions.onLoopNext,
baseSseOptions.onLoopFinish,
baseSseOptions.onNodeRetry,
baseSseOptions.onParallelBranchStarted,
baseSseOptions.onParallelBranchFinished,
baseSseOptions.onTextChunk,
baseSseOptions.onTTSChunk,
baseSseOptions.onTTSEnd,
baseSseOptions.onTextReplace,
baseSseOptions.onAgentLog,
baseSseOptions.onHumanInputRequired,
baseSseOptions.onHumanInputFormFilled,
baseSseOptions.onHumanInputFormTimeout,
baseSseOptions.onWorkflowPaused,
baseSseOptions.onDataSourceNodeProcessing,
baseSseOptions.onDataSourceNodeCompleted,
baseSseOptions.onDataSourceNodeError,
)
}
catch (error) {
if (controller.signal.aborted)
return
if (error instanceof Response) {
const data = await error.clone().json() as Record<string, unknown>
const errorMessage = typeof data?.error === 'string' ? data.error : ''
toast.error(errorMessage)
clearAbortController()
setWorkflowRunningData(createFailedWorkflowState(errorMessage))
}
clearListeningState()
}
}
await poll()
}

View File

@ -1,3 +1,4 @@
import type { HandleRunOptions } from './use-workflow-run-utils'
import type AudioPlayer from '@/app/components/base/audio-btn/audio'
import type { Node } from '@/app/components/workflow/types'
import type { IOtherOptions } from '@/service/base'
@ -14,46 +15,38 @@ import { useStore as useAppStore } from '@/app/components/app/store'
import { trackEvent } from '@/app/components/base/amplitude'
import { AudioPlayerManager } from '@/app/components/base/audio-btn/audio.player.manager'
import { useFeaturesStore } from '@/app/components/base/features/hooks'
import Toast from '@/app/components/base/toast'
import { TriggerType } from '@/app/components/workflow/header/test-run-menu'
import { useWorkflowUpdate } from '@/app/components/workflow/hooks/use-workflow-interactions'
import { useWorkflowRunEvent } from '@/app/components/workflow/hooks/use-workflow-run-event/use-workflow-run-event'
import { useWorkflowStore } from '@/app/components/workflow/store'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import { usePathname } from '@/next/navigation'
import { handleStream, post, sseGet, ssePost } from '@/service/base'
import { ContentType } from '@/service/fetch'
import { ssePost } from '@/service/base'
import { useInvalidAllLastRun, useInvalidateWorkflowRunHistory } from '@/service/use-workflow'
import { stopWorkflowRun } from '@/service/workflow'
import { AppModeEnum } from '@/types/app'
import { useSetWorkflowVarsWithValue } from '../../workflow/hooks/use-fetch-workflow-inspect-vars'
import { useConfigsMap } from './use-configs-map'
import { useNodesSyncDraft } from './use-nodes-sync-draft'
import {
createBaseWorkflowRunCallbacks,
createFinalWorkflowRunCallbacks,
} from './use-workflow-run-callbacks'
import {
applyRunningStateForMode,
applyStoppedState,
buildRunHistoryUrl,
buildTTSConfig,
buildWorkflowRunRequestBody,
clearListeningState,
clearWindowDebugControllers,
type HandleRunMode = TriggerType
type HandleRunOptions = {
mode?: HandleRunMode
scheduleNodeId?: string
webhookNodeId?: string
pluginNodeId?: string
allNodeIds?: string[]
}
type DebuggableTriggerType = Exclude<TriggerType, TriggerType.UserInput>
const controllerKeyMap: Record<DebuggableTriggerType, string> = {
[TriggerType.Webhook]: '__webhookDebugAbortController',
[TriggerType.Plugin]: '__pluginDebugAbortController',
[TriggerType.All]: '__allTriggersDebugAbortController',
[TriggerType.Schedule]: '__scheduleDebugAbortController',
}
const debugLabelMap: Record<DebuggableTriggerType, string> = {
[TriggerType.Webhook]: 'Webhook',
[TriggerType.Plugin]: 'Plugin',
[TriggerType.All]: 'All',
[TriggerType.Schedule]: 'Schedule',
}
isDebuggableTriggerType,
mapPublishedWorkflowFeatures,
normalizePublishedWorkflowNodes,
resolveWorkflowRunUrl,
runTriggerDebug,
validateWorkflowRunRequest,
} from './use-workflow-run-utils'
export const useWorkflowRun = () => {
const store = useStoreApi()
@ -152,7 +145,7 @@ export const useWorkflowRun = () => {
callback?: IOtherOptions,
options?: HandleRunOptions,
) => {
const runMode: HandleRunMode = options?.mode ?? TriggerType.UserInput
const runMode = options?.mode ?? TriggerType.UserInput
const resolvedParams = params ?? {}
const {
getNodes,
@ -190,9 +183,7 @@ export const useWorkflowRun = () => {
} = callback || {}
workflowStore.setState({ historyWorkflowData: undefined })
const appDetail = useAppStore.getState().appDetail
const runHistoryUrl = appDetail?.mode === AppModeEnum.ADVANCED_CHAT
? `/apps/${appDetail.id}/advanced-chat/workflow-runs`
: `/apps/${appDetail?.id}/workflow-runs`
const runHistoryUrl = buildRunHistoryUrl(appDetail)
const workflowContainer = document.getElementById('workflow-container')
const {
@ -202,65 +193,15 @@ export const useWorkflowRun = () => {
const isInWorkflowDebug = appDetail?.mode === AppModeEnum.WORKFLOW
let url = ''
if (runMode === TriggerType.Plugin || runMode === TriggerType.Webhook || runMode === TriggerType.Schedule) {
if (!appDetail?.id) {
console.error('handleRun: missing app id for trigger plugin run')
return
}
url = `/apps/${appDetail.id}/workflows/draft/trigger/run`
}
else if (runMode === TriggerType.All) {
if (!appDetail?.id) {
console.error('handleRun: missing app id for trigger run all')
return
}
url = `/apps/${appDetail.id}/workflows/draft/trigger/run-all`
}
else if (appDetail?.mode === AppModeEnum.ADVANCED_CHAT) {
url = `/apps/${appDetail.id}/advanced-chat/workflows/draft/run`
}
else if (isInWorkflowDebug && appDetail?.id) {
url = `/apps/${appDetail.id}/workflows/draft/run`
}
let requestBody = {}
if (runMode === TriggerType.Schedule)
requestBody = { node_id: options?.scheduleNodeId }
else if (runMode === TriggerType.Webhook)
requestBody = { node_id: options?.webhookNodeId }
else if (runMode === TriggerType.Plugin)
requestBody = { node_id: options?.pluginNodeId }
else if (runMode === TriggerType.All)
requestBody = { node_ids: options?.allNodeIds }
else
requestBody = resolvedParams
const url = resolveWorkflowRunUrl(appDetail, runMode, isInWorkflowDebug)
const requestBody = buildWorkflowRunRequestBody(runMode, resolvedParams, options)
if (!url)
return
if (runMode === TriggerType.Schedule && !options?.scheduleNodeId) {
console.error('handleRun: schedule trigger run requires node id')
return
}
if (runMode === TriggerType.Webhook && !options?.webhookNodeId) {
console.error('handleRun: webhook trigger run requires node id')
return
}
if (runMode === TriggerType.Plugin && !options?.pluginNodeId) {
console.error('handleRun: plugin trigger run requires node id')
return
}
if (runMode === TriggerType.All && !options?.allNodeIds && options?.allNodeIds?.length === 0) {
console.error('handleRun: all trigger run requires node ids')
const validationMessage = validateWorkflowRunRequest(runMode, options)
if (validationMessage) {
console.error(validationMessage)
return
}
@ -277,66 +218,17 @@ export const useWorkflowRun = () => {
setListeningTriggerNodeId,
} = workflowStore.getState()
if (
runMode === TriggerType.Webhook
|| runMode === TriggerType.Plugin
|| runMode === TriggerType.All
|| runMode === TriggerType.Schedule
) {
setIsListening(true)
setShowVariableInspectPanel(true)
setListeningTriggerIsAll(runMode === TriggerType.All)
if (runMode === TriggerType.All)
setListeningTriggerNodeIds(options?.allNodeIds ?? [])
else if (runMode === TriggerType.Webhook && options?.webhookNodeId)
setListeningTriggerNodeIds([options.webhookNodeId])
else if (runMode === TriggerType.Schedule && options?.scheduleNodeId)
setListeningTriggerNodeIds([options.scheduleNodeId])
else if (runMode === TriggerType.Plugin && options?.pluginNodeId)
setListeningTriggerNodeIds([options.pluginNodeId])
else
setListeningTriggerNodeIds([])
setWorkflowRunningData({
result: {
status: WorkflowRunningStatus.Running,
inputs_truncated: false,
process_data_truncated: false,
outputs_truncated: false,
},
tracing: [],
resultText: '',
})
}
else {
setIsListening(false)
setListeningTriggerType(null)
setListeningTriggerNodeId(null)
setListeningTriggerNodeIds([])
setListeningTriggerIsAll(false)
setWorkflowRunningData({
result: {
status: WorkflowRunningStatus.Running,
inputs_truncated: false,
process_data_truncated: false,
outputs_truncated: false,
},
tracing: [],
resultText: '',
})
}
applyRunningStateForMode({
setWorkflowRunningData,
setIsListening,
setShowVariableInspectPanel,
setListeningTriggerType,
setListeningTriggerNodeIds,
setListeningTriggerIsAll,
setListeningTriggerNodeId,
}, runMode, options)
let ttsUrl = ''
let ttsIsPublic = false
if (resolvedParams.token) {
ttsUrl = '/text-to-audio'
ttsIsPublic = true
}
else if (resolvedParams.appId) {
if (pathname.search('explore/installed') > -1)
ttsUrl = `/installed-apps/${resolvedParams.appId}/text-to-audio`
else
ttsUrl = `/apps/${resolvedParams.appId}/text-to-audio`
}
const { ttsUrl, ttsIsPublic } = buildTTSConfig(resolvedParams, pathname)
// Lazy initialization: Only create AudioPlayer when TTS is actually needed
// This prevents opening audio channel unnecessarily
let player: AudioPlayer | null = null
@ -349,497 +241,121 @@ export const useWorkflowRun = () => {
const clearAbortController = () => {
abortControllerRef.current = null
delete (window as any).__webhookDebugAbortController
delete (window as any).__pluginDebugAbortController
delete (window as any).__scheduleDebugAbortController
delete (window as any).__allTriggersDebugAbortController
clearWindowDebugControllers(window as unknown as Record<string, unknown>)
}
const clearListeningState = () => {
const clearListeningStateInStore = () => {
const state = workflowStore.getState()
state.setIsListening(false)
state.setListeningTriggerType(null)
state.setListeningTriggerNodeId(null)
state.setListeningTriggerNodeIds([])
state.setListeningTriggerIsAll(false)
clearListeningState({
setIsListening: state.setIsListening,
setListeningTriggerType: state.setListeningTriggerType,
setListeningTriggerNodeId: state.setListeningTriggerNodeId,
setListeningTriggerNodeIds: state.setListeningTriggerNodeIds,
setListeningTriggerIsAll: state.setListeningTriggerIsAll,
})
}
const wrappedOnError = (params: any) => {
clearAbortController()
handleWorkflowFailed()
invalidateRunHistory(runHistoryUrl)
clearListeningState()
if (onError)
onError(params)
trackEvent('workflow_run_failed', { workflow_id: flowId, reason: params.error, node_type: params.node_type })
const workflowRunEventHandlers = {
handleWorkflowStarted,
handleWorkflowFinished,
handleWorkflowFailed,
handleWorkflowNodeStarted,
handleWorkflowNodeFinished,
handleWorkflowNodeHumanInputRequired,
handleWorkflowNodeHumanInputFormFilled,
handleWorkflowNodeHumanInputFormTimeout,
handleWorkflowNodeIterationStarted,
handleWorkflowNodeIterationNext,
handleWorkflowNodeIterationFinished,
handleWorkflowNodeLoopStarted,
handleWorkflowNodeLoopNext,
handleWorkflowNodeLoopFinished,
handleWorkflowNodeRetry,
handleWorkflowAgentLog,
handleWorkflowTextChunk,
handleWorkflowTextReplace,
handleWorkflowPaused,
}
const userCallbacks = {
onWorkflowStarted,
onWorkflowFinished,
onNodeStarted,
onNodeFinished,
onIterationStart,
onIterationNext,
onIterationFinish,
onLoopStart,
onLoopNext,
onLoopFinish,
onNodeRetry,
onAgentLog,
onError,
onWorkflowPaused,
onHumanInputRequired,
onHumanInputFormFilled,
onHumanInputFormTimeout,
onCompleted,
}
const wrappedOnCompleted: IOtherOptions['onCompleted'] = async (hasError?: boolean, errorMessage?: string) => {
clearAbortController()
clearListeningState()
if (onCompleted)
onCompleted(hasError, errorMessage)
const trackWorkflowRunFailed = (eventParams: unknown) => {
const payload = eventParams as { error?: string, node_type?: string }
trackEvent('workflow_run_failed', { workflow_id: flowId, reason: payload?.error, node_type: payload?.node_type })
}
const baseSseOptions: IOtherOptions = {
...restCallback,
onWorkflowStarted: (params) => {
handleWorkflowStarted(params)
invalidateRunHistory(runHistoryUrl)
if (onWorkflowStarted)
onWorkflowStarted(params)
},
onWorkflowFinished: (params) => {
clearListeningState()
handleWorkflowFinished(params)
invalidateRunHistory(runHistoryUrl)
if (onWorkflowFinished)
onWorkflowFinished(params)
if (isInWorkflowDebug) {
fetchInspectVars({})
invalidAllLastRun()
}
},
onNodeStarted: (params) => {
handleWorkflowNodeStarted(
params,
{
clientWidth,
clientHeight,
},
)
if (onNodeStarted)
onNodeStarted(params)
},
onNodeFinished: (params) => {
handleWorkflowNodeFinished(params)
if (onNodeFinished)
onNodeFinished(params)
},
onIterationStart: (params) => {
handleWorkflowNodeIterationStarted(
params,
{
clientWidth,
clientHeight,
},
)
if (onIterationStart)
onIterationStart(params)
},
onIterationNext: (params) => {
handleWorkflowNodeIterationNext(params)
if (onIterationNext)
onIterationNext(params)
},
onIterationFinish: (params) => {
handleWorkflowNodeIterationFinished(params)
if (onIterationFinish)
onIterationFinish(params)
},
onLoopStart: (params) => {
handleWorkflowNodeLoopStarted(
params,
{
clientWidth,
clientHeight,
},
)
if (onLoopStart)
onLoopStart(params)
},
onLoopNext: (params) => {
handleWorkflowNodeLoopNext(params)
if (onLoopNext)
onLoopNext(params)
},
onLoopFinish: (params) => {
handleWorkflowNodeLoopFinished(params)
if (onLoopFinish)
onLoopFinish(params)
},
onNodeRetry: (params) => {
handleWorkflowNodeRetry(params)
if (onNodeRetry)
onNodeRetry(params)
},
onAgentLog: (params) => {
handleWorkflowAgentLog(params)
if (onAgentLog)
onAgentLog(params)
},
onTextChunk: (params) => {
handleWorkflowTextChunk(params)
},
onTextReplace: (params) => {
handleWorkflowTextReplace(params)
},
onTTSChunk: (messageId: string, audio: string) => {
if (!audio || audio === '')
return
const audioPlayer = getOrCreatePlayer()
if (audioPlayer) {
audioPlayer.playAudioWithAudio(audio, true)
AudioPlayerManager.getInstance().resetMsgId(messageId)
}
},
onTTSEnd: (messageId: string, audio: string) => {
const audioPlayer = getOrCreatePlayer()
if (audioPlayer)
audioPlayer.playAudioWithAudio(audio, false)
},
onWorkflowPaused: (params) => {
handleWorkflowPaused()
invalidateRunHistory(runHistoryUrl)
if (onWorkflowPaused)
onWorkflowPaused(params)
const url = `/workflow/${params.workflow_run_id}/events`
sseGet(
url,
{},
baseSseOptions,
)
},
onHumanInputRequired: (params) => {
handleWorkflowNodeHumanInputRequired(params)
if (onHumanInputRequired)
onHumanInputRequired(params)
},
onHumanInputFormFilled: (params) => {
handleWorkflowNodeHumanInputFormFilled(params)
if (onHumanInputFormFilled)
onHumanInputFormFilled(params)
},
onHumanInputFormTimeout: (params) => {
handleWorkflowNodeHumanInputFormTimeout(params)
if (onHumanInputFormTimeout)
onHumanInputFormTimeout(params)
},
onError: wrappedOnError,
onCompleted: wrappedOnCompleted,
}
const waitWithAbort = (signal: AbortSignal, delay: number) => new Promise<void>((resolve) => {
const timer = window.setTimeout(resolve, delay)
signal.addEventListener('abort', () => {
clearTimeout(timer)
resolve()
}, { once: true })
const baseSseOptions = createBaseWorkflowRunCallbacks({
clientWidth,
clientHeight,
runHistoryUrl,
isInWorkflowDebug,
fetchInspectVars,
invalidAllLastRun,
invalidateRunHistory,
clearAbortController,
clearListeningState: clearListeningStateInStore,
trackWorkflowRunFailed,
handlers: workflowRunEventHandlers,
callbacks: userCallbacks,
restCallback,
getOrCreatePlayer,
})
const runTriggerDebug = async (debugType: DebuggableTriggerType) => {
const controller = new AbortController()
abortControllerRef.current = controller
const controllerKey = controllerKeyMap[debugType]
; (window as any)[controllerKey] = controller
const debugLabel = debugLabelMap[debugType]
const poll = async (): Promise<void> => {
try {
const response = await post<Response>(url, {
body: requestBody,
signal: controller.signal,
}, {
needAllResponseContent: true,
})
if (controller.signal.aborted)
return
if (!response) {
const message = `${debugLabel} debug request failed`
Toast.notify({ type: 'error', message })
clearAbortController()
return
}
const contentType = response.headers.get('content-type') || ''
if (contentType.includes(ContentType.json)) {
let data: any = null
try {
data = await response.json()
}
catch (jsonError) {
console.error(`handleRun: ${debugLabel.toLowerCase()} debug response parse error`, jsonError)
Toast.notify({ type: 'error', message: `${debugLabel} debug request failed` })
clearAbortController()
clearListeningState()
return
}
if (controller.signal.aborted)
return
if (data?.status === 'waiting') {
const delay = Number(data.retry_in) || 2000
await waitWithAbort(controller.signal, delay)
if (controller.signal.aborted)
return
await poll()
return
}
const errorMessage = data?.message || `${debugLabel} debug failed`
Toast.notify({ type: 'error', message: errorMessage })
clearAbortController()
setWorkflowRunningData({
result: {
status: WorkflowRunningStatus.Failed,
error: errorMessage,
inputs_truncated: false,
process_data_truncated: false,
outputs_truncated: false,
},
tracing: [],
})
clearListeningState()
return
}
clearListeningState()
handleStream(
response,
baseSseOptions.onData ?? noop,
baseSseOptions.onCompleted,
baseSseOptions.onThought,
baseSseOptions.onMessageEnd,
baseSseOptions.onMessageReplace,
baseSseOptions.onFile,
baseSseOptions.onWorkflowStarted,
baseSseOptions.onWorkflowFinished,
baseSseOptions.onNodeStarted,
baseSseOptions.onNodeFinished,
baseSseOptions.onIterationStart,
baseSseOptions.onIterationNext,
baseSseOptions.onIterationFinish,
baseSseOptions.onLoopStart,
baseSseOptions.onLoopNext,
baseSseOptions.onLoopFinish,
baseSseOptions.onNodeRetry,
baseSseOptions.onParallelBranchStarted,
baseSseOptions.onParallelBranchFinished,
baseSseOptions.onTextChunk,
baseSseOptions.onTTSChunk,
baseSseOptions.onTTSEnd,
baseSseOptions.onTextReplace,
baseSseOptions.onAgentLog,
baseSseOptions.onHumanInputRequired,
baseSseOptions.onHumanInputFormFilled,
baseSseOptions.onHumanInputFormTimeout,
baseSseOptions.onWorkflowPaused,
baseSseOptions.onDataSourceNodeProcessing,
baseSseOptions.onDataSourceNodeCompleted,
baseSseOptions.onDataSourceNodeError,
)
}
catch (error) {
if (controller.signal.aborted)
return
if (error instanceof Response) {
const data = await error.clone().json() as Record<string, any>
const { error: respError } = data || {}
Toast.notify({ type: 'error', message: respError })
clearAbortController()
setWorkflowRunningData({
result: {
status: WorkflowRunningStatus.Failed,
error: respError,
inputs_truncated: false,
process_data_truncated: false,
outputs_truncated: false,
},
tracing: [],
})
}
clearListeningState()
}
}
await poll()
}
if (runMode === TriggerType.Schedule) {
await runTriggerDebug(TriggerType.Schedule)
if (isDebuggableTriggerType(runMode)) {
await runTriggerDebug({
debugType: runMode,
url,
requestBody,
baseSseOptions,
controllerTarget: window as unknown as Record<string, unknown>,
setAbortController: (controller) => {
abortControllerRef.current = controller
},
clearAbortController,
clearListeningState: clearListeningStateInStore,
setWorkflowRunningData,
})
return
}
if (runMode === TriggerType.Webhook) {
await runTriggerDebug(TriggerType.Webhook)
return
}
if (runMode === TriggerType.Plugin) {
await runTriggerDebug(TriggerType.Plugin)
return
}
if (runMode === TriggerType.All) {
await runTriggerDebug(TriggerType.All)
return
}
const finalCallbacks: IOtherOptions = {
...baseSseOptions,
getAbortController: (controller: AbortController) => {
const finalCallbacks = createFinalWorkflowRunCallbacks({
clientWidth,
clientHeight,
runHistoryUrl,
isInWorkflowDebug,
fetchInspectVars,
invalidAllLastRun,
invalidateRunHistory,
clearAbortController,
clearListeningState: clearListeningStateInStore,
trackWorkflowRunFailed,
handlers: workflowRunEventHandlers,
callbacks: userCallbacks,
restCallback,
baseSseOptions,
player,
setAbortController: (controller) => {
abortControllerRef.current = controller
},
onWorkflowFinished: (params) => {
handleWorkflowFinished(params)
invalidateRunHistory(runHistoryUrl)
if (onWorkflowFinished)
onWorkflowFinished(params)
if (isInWorkflowDebug) {
fetchInspectVars({})
invalidAllLastRun()
}
},
onError: (params) => {
handleWorkflowFailed()
invalidateRunHistory(runHistoryUrl)
if (onError)
onError(params)
},
onNodeStarted: (params) => {
handleWorkflowNodeStarted(
params,
{
clientWidth,
clientHeight,
},
)
if (onNodeStarted)
onNodeStarted(params)
},
onNodeFinished: (params) => {
handleWorkflowNodeFinished(params)
if (onNodeFinished)
onNodeFinished(params)
},
onIterationStart: (params) => {
handleWorkflowNodeIterationStarted(
params,
{
clientWidth,
clientHeight,
},
)
if (onIterationStart)
onIterationStart(params)
},
onIterationNext: (params) => {
handleWorkflowNodeIterationNext(params)
if (onIterationNext)
onIterationNext(params)
},
onIterationFinish: (params) => {
handleWorkflowNodeIterationFinished(params)
if (onIterationFinish)
onIterationFinish(params)
},
onLoopStart: (params) => {
handleWorkflowNodeLoopStarted(
params,
{
clientWidth,
clientHeight,
},
)
if (onLoopStart)
onLoopStart(params)
},
onLoopNext: (params) => {
handleWorkflowNodeLoopNext(params)
if (onLoopNext)
onLoopNext(params)
},
onLoopFinish: (params) => {
handleWorkflowNodeLoopFinished(params)
if (onLoopFinish)
onLoopFinish(params)
},
onNodeRetry: (params) => {
handleWorkflowNodeRetry(params)
if (onNodeRetry)
onNodeRetry(params)
},
onAgentLog: (params) => {
handleWorkflowAgentLog(params)
if (onAgentLog)
onAgentLog(params)
},
onTextChunk: (params) => {
handleWorkflowTextChunk(params)
},
onTextReplace: (params) => {
handleWorkflowTextReplace(params)
},
onTTSChunk: (messageId: string, audio: string) => {
if (!audio || audio === '')
return
player?.playAudioWithAudio(audio, true)
AudioPlayerManager.getInstance().resetMsgId(messageId)
},
onTTSEnd: (messageId: string, audio: string) => {
player?.playAudioWithAudio(audio, false)
},
onWorkflowPaused: (params) => {
handleWorkflowPaused()
invalidateRunHistory(runHistoryUrl)
if (onWorkflowPaused)
onWorkflowPaused(params)
const url = `/workflow/${params.workflow_run_id}/events`
sseGet(
url,
{},
finalCallbacks,
)
},
onHumanInputRequired: (params) => {
handleWorkflowNodeHumanInputRequired(params)
if (onHumanInputRequired)
onHumanInputRequired(params)
},
onHumanInputFormFilled: (params) => {
handleWorkflowNodeHumanInputFormFilled(params)
if (onHumanInputFormFilled)
onHumanInputFormFilled(params)
},
onHumanInputFormTimeout: (params) => {
handleWorkflowNodeHumanInputFormTimeout(params)
if (onHumanInputFormTimeout)
onHumanInputFormTimeout(params)
},
...restCallback,
}
})
ssePost(
url,
@ -860,20 +376,13 @@ export const useWorkflowRun = () => {
setListeningTriggerNodeId,
} = workflowStore.getState()
setWorkflowRunningData({
result: {
status: WorkflowRunningStatus.Stopped,
inputs_truncated: false,
process_data_truncated: false,
outputs_truncated: false,
},
tracing: [],
resultText: '',
applyStoppedState({
setWorkflowRunningData,
setIsListening,
setShowVariableInspectPanel,
setListeningTriggerType,
setListeningTriggerNodeId,
})
setIsListening(false)
setListeningTriggerType(null)
setListeningTriggerNodeId(null)
setShowVariableInspectPanel(true)
}
if (taskId) {
@ -909,7 +418,7 @@ export const useWorkflowRun = () => {
}, [workflowStore])
const handleRestoreFromPublishedWorkflow = useCallback((publishedWorkflow: VersionHistory) => {
const nodes = publishedWorkflow.graph.nodes.map(node => ({ ...node, selected: false, data: { ...node.data, selected: false } }))
const nodes = normalizePublishedWorkflowNodes(publishedWorkflow)
const edges = publishedWorkflow.graph.edges
const viewport = publishedWorkflow.graph.viewport!
handleUpdateWorkflowCanvas({
@ -917,21 +426,7 @@ export const useWorkflowRun = () => {
edges,
viewport,
})
const mappedFeatures = {
opening: {
enabled: !!publishedWorkflow.features.opening_statement || !!publishedWorkflow.features.suggested_questions.length,
opening_statement: publishedWorkflow.features.opening_statement,
suggested_questions: publishedWorkflow.features.suggested_questions,
},
suggested: publishedWorkflow.features.suggested_questions_after_answer,
text2speech: publishedWorkflow.features.text_to_speech,
speech2text: publishedWorkflow.features.speech_to_text,
citation: publishedWorkflow.features.retriever_resource,
moderation: publishedWorkflow.features.sensitive_word_avoidance,
file: publishedWorkflow.features.file_upload,
}
featuresStore?.setState({ features: mappedFeatures })
featuresStore?.setState({ features: mapPublishedWorkflowFeatures(publishedWorkflow) })
workflowStore.getState().setEnvironmentVariables(publishedWorkflow.environment_variables || [])
}, [featuresStore, handleUpdateWorkflowCanvas, workflowStore])

View File

@ -9,16 +9,12 @@ import {
import { useStore as useAppStore } from '@/app/components/app/store'
import { FeaturesProvider } from '@/app/components/base/features'
import Loading from '@/app/components/base/loading'
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
import WorkflowWithDefaultContext from '@/app/components/workflow'
import {
WorkflowContextProvider,
} from '@/app/components/workflow/context'
import { useWorkflowStore } from '@/app/components/workflow/store'
import { useTriggerStatusStore } from '@/app/components/workflow/store/trigger-status'
import {
SupportUploadFileTypes,
} from '@/app/components/workflow/types'
import {
initialEdges,
initialNodes,
@ -35,6 +31,11 @@ import {
useWorkflowInit,
} from './hooks/use-workflow-init'
import { createWorkflowSlice } from './store/workflow/workflow-slice'
import {
buildInitialFeatures,
buildTriggerStatusMap,
coerceReplayUserInputs,
} from './utils'
const WorkflowAppWithAdditionalContext = () => {
const {
@ -58,13 +59,7 @@ const WorkflowAppWithAdditionalContext = () => {
// Sync trigger statuses to store when data loads
useEffect(() => {
if (triggersResponse?.data) {
// Map API status to EntryNodeStatus: 'enabled' stays 'enabled', all others become 'disabled'
const statusMap = triggersResponse.data.reduce((acc, trigger) => {
acc[trigger.node_id] = trigger.status === 'enabled' ? 'enabled' : 'disabled'
return acc
}, {} as Record<string, 'enabled' | 'disabled'>)
setTriggerStatuses(statusMap)
setTriggerStatuses(buildTriggerStatusMap(triggersResponse.data))
}
}, [triggersResponse?.data, setTriggerStatuses])
@ -108,49 +103,21 @@ const WorkflowAppWithAdditionalContext = () => {
fetchRunDetail(runUrl).then((res) => {
const { setInputs, setShowInputsPanel, setShowDebugAndPreviewPanel } = workflowStore.getState()
const rawInputs = res.inputs
let parsedInputs: Record<string, unknown> | null = null
let parsedInputs: unknown = rawInputs
if (typeof rawInputs === 'string') {
try {
const maybeParsed = JSON.parse(rawInputs) as unknown
if (maybeParsed && typeof maybeParsed === 'object' && !Array.isArray(maybeParsed))
parsedInputs = maybeParsed as Record<string, unknown>
parsedInputs = JSON.parse(rawInputs) as unknown
}
catch (error) {
console.error('Failed to parse workflow run inputs', error)
return
}
}
else if (rawInputs && typeof rawInputs === 'object' && !Array.isArray(rawInputs)) {
parsedInputs = rawInputs as Record<string, unknown>
}
if (!parsedInputs)
return
const userInputs = coerceReplayUserInputs(parsedInputs)
const userInputs: Record<string, string | number | boolean> = {}
Object.entries(parsedInputs).forEach(([key, value]) => {
if (key.startsWith('sys.'))
return
if (value == null) {
userInputs[key] = ''
return
}
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
userInputs[key] = value
return
}
try {
userInputs[key] = JSON.stringify(value)
}
catch {
userInputs[key] = String(value)
}
})
if (!Object.keys(userInputs).length)
if (!userInputs || !Object.keys(userInputs).length)
return
setInputs(userInputs)
@ -167,32 +134,7 @@ const WorkflowAppWithAdditionalContext = () => {
)
}
const features = data.features || {}
const initialFeatures: FeaturesData = {
file: {
image: {
enabled: !!features.file_upload?.image?.enabled,
number_limits: features.file_upload?.image?.number_limits || 3,
transfer_methods: features.file_upload?.image?.transfer_methods || ['local_file', 'remote_url'],
},
enabled: !!(features.file_upload?.enabled || features.file_upload?.image?.enabled),
allowed_file_types: features.file_upload?.allowed_file_types || [SupportUploadFileTypes.image],
allowed_file_extensions: features.file_upload?.allowed_file_extensions || FILE_EXTS[SupportUploadFileTypes.image].map(ext => `.${ext}`),
allowed_file_upload_methods: features.file_upload?.allowed_file_upload_methods || features.file_upload?.image?.transfer_methods || ['local_file', 'remote_url'],
number_limits: features.file_upload?.number_limits || features.file_upload?.image?.number_limits || 3,
fileUploadConfig: fileUploadConfigResponse,
},
opening: {
enabled: !!features.opening_statement,
opening_statement: features.opening_statement,
suggested_questions: features.suggested_questions,
},
suggested: features.suggested_questions_after_answer || { enabled: false },
speech2text: features.speech_to_text || { enabled: false },
text2speech: features.text_to_speech || { enabled: false },
citation: features.retriever_resource || { enabled: false },
moderation: features.sensitive_word_avoidance || { enabled: false },
}
const initialFeatures: FeaturesData = buildInitialFeatures(data.features, fileUploadConfigResponse)
return (
<WorkflowWithDefaultContext

View File

@ -0,0 +1,44 @@
import { createStore } from 'zustand/vanilla'
import { createWorkflowSlice } from '../workflow-slice'
describe('createWorkflowSlice', () => {
it('should initialize workflow slice state with expected defaults', () => {
const store = createStore(createWorkflowSlice)
const state = store.getState()
expect(state.appId).toBe('')
expect(state.appName).toBe('')
expect(state.notInitialWorkflow).toBe(false)
expect(state.shouldAutoOpenStartNodeSelector).toBe(false)
expect(state.nodesDefaultConfigs).toEqual({})
expect(state.showOnboarding).toBe(false)
expect(state.hasSelectedStartNode).toBe(false)
expect(state.hasShownOnboarding).toBe(false)
})
it('should update every workflow slice field through its setters', () => {
const store = createStore(createWorkflowSlice)
store.setState({
appId: 'app-1',
appName: 'Workflow App',
})
store.getState().setNotInitialWorkflow(true)
store.getState().setShouldAutoOpenStartNodeSelector(true)
store.getState().setNodesDefaultConfigs({ start: { title: 'Start' } })
store.getState().setShowOnboarding(true)
store.getState().setHasSelectedStartNode(true)
store.getState().setHasShownOnboarding(true)
expect(store.getState()).toMatchObject({
appId: 'app-1',
appName: 'Workflow App',
notInitialWorkflow: true,
shouldAutoOpenStartNodeSelector: true,
nodesDefaultConfigs: { start: { title: 'Start' } },
showOnboarding: true,
hasSelectedStartNode: true,
hasShownOnboarding: true,
})
})
})

View File

@ -0,0 +1,107 @@
import type { Features as FeaturesData } from '@/app/components/base/features/types'
import type { FileUploadConfigResponse } from '@/models/common'
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
type TriggerStatusLike = {
node_id: string
status: string
}
type FileUploadFeatureLike = {
enabled?: boolean
allowed_file_types?: SupportUploadFileTypes[]
allowed_file_extensions?: string[]
allowed_file_upload_methods?: TransferMethod[]
number_limits?: number
image?: {
enabled?: boolean
number_limits?: number
transfer_methods?: TransferMethod[]
}
}
type WorkflowFeaturesLike = {
file_upload?: FileUploadFeatureLike
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 }
}
export const buildTriggerStatusMap = (triggers: TriggerStatusLike[]) => {
return triggers.reduce<Record<string, 'enabled' | 'disabled'>>((acc, trigger) => {
acc[trigger.node_id] = trigger.status === 'enabled' ? 'enabled' : 'disabled'
return acc
}, {})
}
export const coerceReplayUserInputs = (rawInputs: unknown): Record<string, string | number | boolean> | null => {
if (!rawInputs || typeof rawInputs !== 'object' || Array.isArray(rawInputs))
return null
const userInputs: Record<string, string | number | boolean> = {}
Object.entries(rawInputs as Record<string, unknown>).forEach(([key, value]) => {
if (key.startsWith('sys.'))
return
if (value == null) {
userInputs[key] = ''
return
}
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
userInputs[key] = value
return
}
try {
userInputs[key] = JSON.stringify(value)
}
catch {
userInputs[key] = String(value)
}
})
return userInputs
}
export const buildInitialFeatures = (
featuresSource: WorkflowFeaturesLike | null | undefined,
fileUploadConfigResponse: FileUploadConfigResponse | undefined,
): FeaturesData => {
const features = featuresSource || {}
const fileUpload = features.file_upload
const imageUpload = fileUpload?.image
return {
file: {
image: {
enabled: !!imageUpload?.enabled,
number_limits: imageUpload?.number_limits || 3,
transfer_methods: imageUpload?.transfer_methods || [TransferMethod.local_file, TransferMethod.remote_url],
},
enabled: !!(fileUpload?.enabled || imageUpload?.enabled),
allowed_file_types: fileUpload?.allowed_file_types || [SupportUploadFileTypes.image],
allowed_file_extensions: fileUpload?.allowed_file_extensions || FILE_EXTS[SupportUploadFileTypes.image].map(ext => `.${ext}`),
allowed_file_upload_methods: fileUpload?.allowed_file_upload_methods || imageUpload?.transfer_methods || [TransferMethod.local_file, TransferMethod.remote_url],
number_limits: fileUpload?.number_limits || imageUpload?.number_limits || 3,
fileUploadConfig: fileUploadConfigResponse,
},
opening: {
enabled: !!features.opening_statement,
opening_statement: features.opening_statement,
suggested_questions: features.suggested_questions,
},
suggested: features.suggested_questions_after_answer || { enabled: false },
speech2text: features.speech_to_text || { enabled: false },
text2speech: features.text_to_speech || { enabled: false },
citation: features.retriever_resource || { enabled: false },
moderation: features.sensitive_word_avoidance || { enabled: false },
}
}

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

@ -1820,21 +1820,26 @@ export const useNodesInteractions = () => {
newChildren.push(newLoopStartNode!)
}
else {
// single node paste
// Paste a single regular node. Loop/Iteration nodes are handled above.
const selectedNode = nodes.find(node => node.selected)
let pastedToNestedBlock = false
if (selectedNode) {
// Keep this list aligned with availableBlocksFilter(inContainer)
// in use-available-blocks.ts.
const commonNestedDisallowPasteNodes = [
// end node only can be placed outermost layer
BlockEnum.End,
BlockEnum.Iteration,
BlockEnum.Loop,
BlockEnum.DataSource,
BlockEnum.KnowledgeBase,
BlockEnum.HumanInput,
]
// handle disallow paste node
if (commonNestedDisallowPasteNodes.includes(nodeToPaste.data.type))
return
// handle paste to nested block
// If a Loop/Iteration container is selected, paste into it as a child.
if (selectedNode.data.type === BlockEnum.Iteration || selectedNode.data.type === BlockEnum.Loop) {
const isIteration = selectedNode.data.type === BlockEnum.Iteration
@ -1849,10 +1854,10 @@ export const useNodesInteractions = () => {
x: newNode.position.x,
y: newNode.position.y,
}
// set position base on parent node
// Rebase position into the selected container coordinate system.
newNode.position = getNestedNodePosition(newNode, selectedNode)
// update parent children array like native add
// Mirror native add behavior by appending parent._children.
parentChildrenToAppend.push({ parentId: selectedNode.id, childId: newNode.id, childType: newNode.data.type })
pastedToNestedBlock = true

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,218 @@
import type { PropsWithChildren } from 'react'
import type { CommonNodeType } from '@/app/components/workflow/types'
import { fireEvent, 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 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,58 @@
import { renderHook } from '@testing-library/react'
import useNodeResizeObserver from '../use-node-resize-observer'
describe('useNodeResizeObserver', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
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,410 @@
import type { ComponentProps } from 'react'
import type { CredentialFormSchema, FormOption } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import { VarKindType } from '../../types'
import FormInputItem from '../form-input-item'
const {
mockFetchDynamicOptions,
mockTriggerDynamicOptionsState,
} = vi.hoisted(() => ({
mockFetchDynamicOptions: vi.fn(),
mockTriggerDynamicOptionsState: {
data: undefined as { options: FormOption[] } | undefined,
isLoading: false,
},
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useLanguage: () => 'en_US',
}))
vi.mock('@/service/use-plugins', () => ({
useFetchDynamicOptions: () => ({
mutateAsync: mockFetchDynamicOptions,
}),
}))
vi.mock('@/service/use-triggers', () => ({
useTriggerPluginDynamicOptions: () => mockTriggerDynamicOptionsState,
}))
vi.mock('@/app/components/plugins/plugin-detail-panel/app-selector', () => ({
default: ({ onSelect }: { onSelect: (value: string) => void }) => (
<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(
<FormInputItem
readOnly={false}
nodeId="node-1"
schema={createSchema()}
value={{
field: {
type: VarKindType.constant,
value: '',
},
}}
onChange={onChange}
{...props}
/>,
{
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,125 @@
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, required: true })],
values: createValues({ file: [] }),
})], [{}], t)).toContain('errorMsg.fieldRequired')
expect(getFormErrorMessage([createForm({
inputs: [createInput({ variable: 'files', label: 'Files', type: InputVarType.multiFiles, required: true })],
values: createValues({ files: [] }),
})], [{}], 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,114 @@
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
|| input.type === InputVarType.multiFiles
|| input.type === InputVarType.singleFile)
&& 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].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,27 +1,20 @@
'use client'
import type { FC } from 'react'
import type { ResourceVarInputs } from '../types'
import type { CredentialFormSchema, FormOption } 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 { useEffect, useMemo, useState } from 'react'
import CheckboxList from '@/app/components/base/checkbox-list'
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 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 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'
@ -29,6 +22,24 @@ 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 Props = {
@ -66,33 +77,34 @@ const FormInputItem: FC<Props> = ({
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 {
placeholder,
variable,
type,
_type,
default: defaultValue,
defaultValue,
isAppSelector,
isBoolean,
isCheckbox,
isConstant,
isDynamicSelect,
isModelSelector,
isMultipleSelect,
isNumber,
isSelect,
isShowJSONEditor,
isString,
options,
multiple,
placeholder,
scope,
} = schema as any
showTypeSwitch,
showVariableSelector,
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 = 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)
const { availableVars, availableNodesWithParent } = useAvailableVarList(nodeId, {
onlyLeafNodeVar: false,
@ -101,56 +113,6 @@ const FormInputItem: FC<Props> = ({
},
})
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: any) => varPayload.type === VarType.number
else if (isString)
return (varPayload: any) => [VarType.string, VarType.number, VarType.secret].includes(varPayload.type)
else if (isFile)
return (varPayload: any) => [VarType.file, VarType.arrayFile].includes(varPayload.type)
else if (isBoolean)
return (varPayload: any) => varPayload.type === VarType.boolean
else if (isObject)
return (varPayload: any) => varPayload.type === VarType.object
else if (isArray)
return (varPayload: any) => [VarType.array, VarType.arrayString, VarType.arrayNumber, VarType.arrayObject].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
}
// Fetch dynamic options hook for tools
const { mutateAsync: fetchDynamicOptions } = useFetchDynamicOptions(
currentProvider?.plugin_id || '',
@ -238,30 +200,12 @@ const FormInputItem: FC<Props> = ({
...value,
[variable]: {
...varInput,
type: getVarKindType(),
type: getVarKindType(formState),
value: isNumber ? Number.parseFloat(newValue) : newValue,
},
})
}
const getSelectedLabels = (selectedValues: any[]) => {
if (!selectedValues || selectedValues.length === 0)
return ''
const optionsList = isDynamicSelect ? (dynamicOptions || options || []) : (options || [])
const selectedOptions = optionsList.filter((opt: any) =>
selectedValues.includes(opt.value),
)
if (selectedOptions.length <= 2) {
return selectedOptions
.map((opt: any) => opt.label?.[language] || opt.label?.en_US || opt.value)
.join(', ')
}
return `${selectedOptions.length} selected`
}
const handleAppOrModelSelect = (newValue: any) => {
onChange({
...value,
@ -278,38 +222,44 @@ const FormInputItem: FC<Props> = ({
[variable]: {
...varInput,
type: VarKindType.variable,
value: newValue || '',
value: normalizeVariableSelectorValue(newValue),
},
})
}
const availableCheckboxOptions = useMemo(() => (
(options || []).filter((option: { show_on?: Array<{ variable: string, value: any }> }) => {
if (option.show_on?.length)
return option.show_on.every(showOnItem => value[showOnItem.variable]?.value === showOnItem.value || value[showOnItem.variable] === showOnItem.value)
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({
@ -343,7 +293,7 @@ const FormInputItem: FC<Props> = ({
<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}
/>
@ -368,20 +318,11 @@ const FormInputItem: FC<Props> = ({
<SimpleSelect
wrapperClassName="h-8 grow"
disabled={readOnly}
defaultValue={varInput?.value}
items={options.filter((option: { show_on: any[] }) => {
if (option.show_on.length)
return option.show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value)
return true
}).map((option: { value: any, label: { [x: string]: any, en_US: any }, icon?: string }) => ({
value: option.value,
name: option.label[language] || option.label.en_US,
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: any) => opt.icon)
renderOption={hasOptionIcon(visibleSelectOptions)
? ({ item }) => (
<div className="flex items-center">
{item.icon && (
@ -394,74 +335,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('system-sm-regular block truncate text-left', 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: { show_on: any[] }) => {
if (option.show_on?.length)
return option.show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value)
return true
}).map((option: { value: any, label: { [x: string]: any, en_US: any }, icon?: string }) => (
<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')}>
{option.label[language] || option.label.en_US}
</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: { show_on?: any[] }) => {
if (option.show_on?.length)
return option.show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value)
return true
}).map((option: { value: any, label: { [x: string]: any, en_US: any }, icon?: string }) => ({
value: option.value,
name: option.label[language] || option.label.en_US,
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 }) => (
@ -475,83 +363,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('system-sm-regular block truncate text-left', 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: { show_on?: any[] }) => {
if (option.show_on?.length)
return option.show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value)
return true
}).map((option: { value: any, label: { [x: string]: any, en_US: any }, icon?: string }) => (
<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')}>
{option.label[language] || option.label.en_US}
</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={varInput?.value as any}
isExpand
isInNode
language={CodeLanguage.json}
onChange={handleValueChange}
className="w-full"
placeholder={<div className="whitespace-pre">{placeholder?.[language] || placeholder?.en_US}</div>}
/>
</div>
<JsonEditorField
value={(varInput?.value as string) || ''}
onChange={handleValueChange}
placeholder={<div className="whitespace-pre">{placeholder?.[language] || placeholder?.en_US}</div>}
/>
)}
{isAppSelector && (
<AppSelector
@ -581,9 +408,9 @@ const FormInputItem: FC<Props> = ({
nodeId={nodeId}
value={varInput?.value || []}
onChange={value => handleVariableSelectorChange(value, variable)}
filterVar={getFilterVar()}
filterVar={getFilterVar(formState)}
schema={schema}
valueTypePlaceHolder={targetVarType()}
valueTypePlaceHolder={getTargetVarType(formState)}
currentTool={currentTool}
currentProvider={currentProvider}
isFilterFileVar={isBoolean}

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,236 @@
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, VarType } 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({ type: BlockEnum.Code }, ['env', 'API_KEY'], 'API_KEY')
expect(meta).toMatchObject({
isEnv: true,
isValidVar: true,
isException: 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({ type: VarType.string }, ['conversation', 'name'], '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,221 @@
'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, ValueSelector } from '@/app/components/workflow/types'
import { VAR_SHOW_NAME_MAP } from '@/app/components/workflow/constants'
import { getNodeInfoById, isConversationVar, isENV, isGlobalVar, isRagVariableVar, isSystemVar } 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 = (
outputVarNode: { type?: string } | null,
value: ValueSelector | string,
varName: string,
) => {
const selector = value as ValueSelector
const isEnv = isENV(selector)
const isChatVar = isConversationVar(selector)
const isGlobal = isGlobalVar(selector)
const isRagVar = isRagVariableVar(selector)
const isValidVar = Boolean(outputVarNode) || isEnv || isChatVar || isGlobal || isRagVar
return {
isChatVar,
isEnv,
isGlobal,
isRagVar,
isValidVar,
isException: Boolean(varName && outputVarNode?.type),
}
}
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,315 @@
'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 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>
inTable?: boolean
isAddBtnTrigger?: boolean
isConstant: boolean
isException: boolean
isFocus: boolean
isInTable?: boolean
isJustShowValue?: 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,
isException,
isFocus,
isInTable,
isJustShowValue,
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]">
{'type' in (outputVarNode || {}) && outputVarNode?.type && (
<div className="h-3 w-3" />
)}
</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', isException && 'text-text-warning')}
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>
{!('title' in (outputVarNode || {})) && <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, 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
@ -141,17 +139,17 @@ 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 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 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
@ -164,72 +162,41 @@ const VarReferencePicker: FC<Props> = ({
const [open, setOpen] = useState(false)
useEffect(() => {
onOpen()
}, [open])
const hasValue = !isConstant && value.length > 0
}, [open, onOpen])
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)
@ -302,39 +269,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 = Boolean(outputVarNode) || isEnv || isChatVar || isGlobal || isRagVar
const isException = isExceptionVariable(varName, outputVarNode?.type)
return {
isEnv,
isChatVar,
isGlobal,
isRagVar,
isValidVar,
isException,
}
}, [value, outputVarNode, varName])
const { isEnv, isChatVar, isGlobal, isRagVar, isValidVar } = useMemo(
() => getVariableMeta(outputVarNode, value, varName),
[outputVarNode, value, varName],
)
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}
@ -344,7 +300,7 @@ const VarReferencePicker: FC<Props> = ({
/>
)
}
if (!isValidVar && hasValue)
if (tooltipType === 'invalid-variable')
return t('errorMsg.invalidVariable', { ns: 'workflow' })
return null
@ -359,7 +315,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)
@ -370,58 +326,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}
@ -429,204 +352,52 @@ 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="radius-md flex h-8 items-center bg-components-input-bg-normal px-2">
<div className="system-sm-regular mr-1 text-components-input-text-filled">{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="system-xs-regular ml-0.5 truncate text-center capitalize text-text-tertiary"
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'} system-sm-regular text-ellipsis`}>
{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}
isException={isException}
isFocus={isFocus}
isInTable={isInTable}
isJustShowValue={isJustShowValue}
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,15 +17,19 @@ 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'
import { cn } from '@/utils/classnames'
import { checkKeys } from '@/utils/var'
import { Type } from '../../../llm/types'
import ManageInputField from './manage-input-field'
import { isSpecialVar, varTypeToStructType } from './utils'
import { varTypeToStructType } from './utils'
import {
filterReferenceVars,
getValueSelector,
getVariableCategory,
getVariableDisplayName,
} from './var-reference-vars.helpers'
type ItemProps = {
nodeId: string
@ -84,17 +88,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)
@ -150,30 +147,26 @@ const Item: FC<ItemProps> = ({
const handleChosen = (e: React.MouseEvent) => {
e.stopPropagation()
e.nativeEvent.stopImmediatePropagation()
if (!isSupportFileVar && isFile)
return
const valueSelector = getValueSelector({
itemData,
isFlat,
isSupportFileVar,
isFile,
isSys,
isEnv,
isChatVar,
isRagVariable,
nodeId,
objPath,
})
if (isFlat) {
onChange([itemData.variable], itemData)
}
else if (isSys || isEnv || isChatVar || isRagVariable) { // system variable | environment variable | conversation variable
onChange([...objPath, ...itemData.variable.split('.')], itemData)
}
else {
onChange([nodeId, ...objPath, itemData.variable], itemData)
}
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}
@ -290,30 +283,7 @@ const VarReferenceVars: FC<Props> = ({
}
}
const filteredVars = vars.filter((v) => {
const children = v.vars.filter(v => checkKeys([v.variable], false).isValid || isSpecialVar(v.variable.split('.')[0]))
return children.length > 0
}).filter((node) => {
if (!searchText)
return node
const children = node.vars.filter((v) => {
const searchTextLower = searchText.toLowerCase()
return v.variable.toLowerCase().includes(searchTextLower) || node.title.toLowerCase().includes(searchTextLower)
})
return children.length > 0
}).map((node) => {
let vars = node.vars.filter(v => checkKeys([v.variable], false).isValid || isSpecialVar(v.variable.split('.')[0]))
if (searchText) {
const searchTextLower = searchText.toLowerCase()
if (!node.title.toLowerCase().includes(searchTextLower))
vars = vars.filter(v => v.variable.toLowerCase().includes(searchText.toLowerCase()))
}
return {
...node,
vars,
}
})
const filteredVars = useMemo(() => filterReferenceVars(vars, searchText), [vars, searchText])
return (
<>

View File

@ -0,0 +1,86 @@
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 null for unsupported custom run form nodes', () => {
const form = getCustomRunForm({
...createCustomRunFormProps({ type: BlockEnum.Tool }),
})
expect(form).toBeNull()
})
})
})

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,73 @@
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 null
}
}
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,
@ -47,8 +46,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'
@ -63,7 +60,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'
@ -74,28 +70,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']
@ -124,17 +112,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}`)
@ -162,15 +146,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()
@ -284,21 +262,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(() => {
@ -321,10 +295,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,17 +2,14 @@ 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,
useMemo,
useRef,
} from 'react'
import { useTranslation } from 'react-i18next'
import Tooltip from '@/app/components/base/tooltip'
import BlockIcon from '@/app/components/workflow/block-icon'
import { ToolTypeEnum } from '@/app/components/workflow/block-selector/types'
import { useNodesReadOnly, useToolIcon } from '@/app/components/workflow/hooks'
@ -23,7 +20,6 @@ import { useNodeLoopInteractions } from '@/app/components/workflow/nodes/loop/us
import CopyID from '@/app/components/workflow/nodes/tool/components/copy-id'
import {
BlockEnum,
isTriggerNode,
NodeRunningStatus,
} from '@/app/components/workflow/types'
import { hasErrorHandleNode, hasRetryNode } from '@/app/components/workflow/utils'
@ -38,6 +34,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
@ -65,59 +73,34 @@ const BaseNode: FC<BaseNodeProps> = ({
const { shouldDim: pluginDimmed, isChecking: pluginIsChecking, isMissing: pluginIsMissing, canInstall: pluginCanInstall, uniqueIdentifier: pluginUniqueIdentifier } = useNodePluginInstallation(data)
const pluginInstallLocked = !pluginIsChecking && pluginIsMissing && pluginCanInstall && Boolean(pluginUniqueIdentifier)
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 (
@ -145,8 +128,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) && (
@ -174,8 +157,8 @@ const BaseNode: FC<BaseNodeProps> = ({
className={cn(
'group relative pb-1 shadow-xs',
'rounded-[15px] border border-transparent',
(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',
!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',
@ -239,7 +222,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
@ -255,72 +238,19 @@ 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>
)
}
</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 any)}
/>
{
hasRetryNode(data.type) && (
<RetryOnNode
@ -337,13 +267,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 || ''} />
@ -354,7 +278,7 @@ const BaseNode: FC<BaseNodeProps> = ({
)
const isStartNode = data.type === BlockEnum.Start
const isEntryNode = isTriggerNode(data.type as any) || isStartNode
const isEntryNode = isEntryWorkflowNode(data.type)
return isEntryNode
? (

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

@ -1,4 +1,4 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import GenericTable from '../generic-table'
@ -50,8 +50,19 @@ const advancedColumns = [
describe('GenericTable', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useRealTimers()
})
const selectOption = async (triggerName: string, optionName: string) => {
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: triggerName }))
})
await act(async () => {
fireEvent.click(await screen.findByRole('option', { name: optionName }))
})
}
it('should render an empty editable row and append a configured row when typing into the virtual row', async () => {
const onChange = vi.fn()
@ -143,11 +154,11 @@ describe('GenericTable', () => {
<ControlledTable />,
)
await user.click(screen.getByRole('button', { name: 'Choose method' }))
await user.click(await screen.findByText('POST'))
await selectOption('Choose method', 'POST')
await waitFor(() => {
expect(onChange).toHaveBeenCalledWith([{ method: 'post', preview: '' }])
expect(screen.getByRole('button', { name: 'POST' })).toBeInTheDocument()
})
onChange.mockClear()

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,224 @@
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 keep valid object rows when switching to json mode from form mode', () => {
const { result } = renderHook(() => useVariableModalState(createOptions()))
act(() => {
result.current.handleTypeChange(ChatVarType.Object)
result.current.setObjectValue([
{ key: '', type: ChatVarType.String, value: undefined },
{ key: 'timeout', type: ChatVarType.Number, value: 30 },
])
result.current.handleEditorChange(true)
})
expect(result.current.editInJSON).toBe(true)
expect(result.current.value).toEqual({ timeout: 30 })
expect(result.current.editorContent).toBe(JSON.stringify({ timeout: 30 }))
})
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 preserve zero values when switching number arrays into json mode', () => {
const { result } = renderHook(() => useVariableModalState(createOptions()))
act(() => {
result.current.handleTypeChange(ChatVarType.ArrayNumber)
result.current.setValue([0, 2, undefined])
result.current.handleEditorChange(true)
})
expect(result.current.editInJSON).toBe(true)
expect(result.current.value).toEqual([0, 2])
expect(result.current.editorContent).toBe(JSON.stringify([0, 2]))
})
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,138 @@
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(formatObjectValueFromList([
{ key: 'count', type: ChatVarType.Number, value: 0 },
{ key: 'label', type: ChatVarType.String, value: '' },
])).toEqual({ count: 0, label: null })
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.ArrayNumber,
value: [0, 1, undefined, null, ''] as unknown as Array<number | undefined>,
})).toEqual([0, 1])
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.ArrayBoolean,
})).toThrow('JSON array')
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,218 @@
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('appDebug.varKeyError.keyAlreadyExists:{"key":"workflow.chatVariable.modal.name"}')
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()
const timeoutInput = screen.getByDisplayValue('30') as HTMLInputElement
await user.clear(screen.getByDisplayValue('secret'))
await user.clear(timeoutInput)
await user.type(timeoutInput, '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: 5,
},
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: '',
})
})
it('should keep the number input empty while editing after the user clears it', async () => {
const user = userEvent.setup()
renderVariableModal({
chatVar: {
id: 'var-4',
name: 'timeout',
description: '',
value_type: ChatVarType.Number,
value: 3,
},
})
const input = screen.getByDisplayValue('3') as HTMLInputElement
await user.clear(input)
expect(input.value).toBe('')
})
})

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.some(item => item.key) ? formatObjectValueFromList(prev.objectValue) : undefined
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 compactValues = Array.isArray(prev.value)
? prev.value.filter(item => item !== null && item !== undefined && item !== '')
: []
const nextValue = compactValues.length
? compactValues
: 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 !== undefined && 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

@ -0,0 +1,174 @@
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 === '' || curr.value === undefined ? null : curr.value
return acc
}, {})
}
export const formatChatVariableValue = ({
editInJSON,
objectValue,
type,
value,
}: {
editInJSON: boolean
objectValue: ObjectValueItem[]
type: ChatVarType
value: unknown
}) => {
const compactArrayValue = (items: unknown[]) =>
items.filter(item => item !== null && item !== undefined && item !== '')
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) ? compactArrayValue(value) : []
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
if (!Array.isArray(parsed))
throw new TypeError('ArrayBoolean editor content must be a JSON array')
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,220 @@
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) => {
const rawValue = e.target.value
onArrayChange([rawValue === '' ? undefined : Number(rawValue)])
}}
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,211 +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(() => {
if (!chatVar || Object.keys(chatVar.value).length === 0)
return [DEFAULT_OBJECT_VALUE]
return Object.keys(chatVar.value).map((key) => {
return {
key,
type: typeof chatVar.value[key] === 'string' ? ChatVarType.String : ChatVarType.Number,
value: chatVar.value[key],
}
})
}, [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]')}
@ -278,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()
})
})

Some files were not shown because too many files have changed in this diff Show More