mirror of
https://github.com/langgenius/dify.git
synced 2026-08-12 18:59:49 +08:00
Merge branch 'main' into yanli/fix-iter-log
This commit is contained in:
commit
ead33f2914
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
350
web/app/components/workflow-app/__tests__/index.spec.tsx
Normal file
350
web/app/components/workflow-app/__tests__/index.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
90
web/app/components/workflow-app/__tests__/utils.spec.ts
Normal file
90
web/app/components/workflow-app/__tests__/utils.spec.ts
Normal 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 },
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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',
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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 } },
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
206
web/app/components/workflow-app/hooks/__tests__/use-DSL.spec.ts
Normal file
206
web/app/components/workflow-app/hooks/__tests__/use-DSL.spec.ts
Normal 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
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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),
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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',
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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([])
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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#}}',
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
443
web/app/components/workflow-app/hooks/use-workflow-run-utils.ts
Normal file
443
web/app/components/workflow-app/hooks/use-workflow-run-utils.ts
Normal 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()
|
||||
}
|
||||
@ -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])
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
107
web/app/components/workflow-app/utils.ts
Normal file
107
web/app/components/workflow-app/utils.ts
Normal 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 },
|
||||
}
|
||||
}
|
||||
@ -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 })
|
||||
})
|
||||
})
|
||||
235
web/app/components/workflow/__tests__/custom-edge.spec.tsx
Normal file
235
web/app/components/workflow/__tests__/custom-edge.spec.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
114
web/app/components/workflow/__tests__/node-contextmenu.spec.tsx
Normal file
114
web/app/components/workflow/__tests__/node-contextmenu.spec.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
151
web/app/components/workflow/__tests__/panel-contextmenu.spec.tsx
Normal file
151
web/app/components/workflow/__tests__/panel-contextmenu.spec.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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 })
|
||||
})
|
||||
})
|
||||
})
|
||||
365
web/app/components/workflow/__tests__/update-dsl-modal.spec.tsx
Normal file
365
web/app/components/workflow/__tests__/update-dsl-modal.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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: [],
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
218
web/app/components/workflow/nodes/_base/__tests__/node.spec.tsx
Normal file
218
web/app/components/workflow/nodes/_base/__tests__/node.spec.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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'],
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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'])
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
@ -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 (
|
||||
|
||||
@ -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 || ''
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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' })])
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
@ -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
|
||||
@ -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={{
|
||||
|
||||
@ -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)),
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -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 (
|
||||
<>
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
}
|
||||
@ -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({
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
94
web/app/components/workflow/nodes/_base/node-sections.tsx
Normal file
94
web/app/components/workflow/nodes/_base/node-sections.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
32
web/app/components/workflow/nodes/_base/node.helpers.tsx
Normal file
32
web/app/components/workflow/nodes/_base/node.helpers.tsx
Normal 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
|
||||
}
|
||||
@ -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
|
||||
? (
|
||||
|
||||
@ -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
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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',
|
||||
}))
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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('Dify’s 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' },
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}))
|
||||
})
|
||||
})
|
||||
@ -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'])
|
||||
})
|
||||
})
|
||||
@ -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',
|
||||
}))
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
|
||||
@ -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([])
|
||||
})
|
||||
})
|
||||
@ -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 () => {
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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',
|
||||
}))
|
||||
})
|
||||
})
|
||||
@ -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('')
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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'
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
@ -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">
|
||||
|
||||
127
web/app/components/workflow/run/__tests__/hooks.spec.ts
Normal file
127
web/app/components/workflow/run/__tests__/hooks.spec.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
356
web/app/components/workflow/run/__tests__/result-panel.spec.tsx
Normal file
356
web/app/components/workflow/run/__tests__/result-panel.spec.tsx
Normal 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
Loading…
Reference in New Issue
Block a user