feat(workflow): implement DSL modal helpers and refactor update DSL modal

- Added helper functions for DSL validation, import status handling, and feature normalization.
- Refactored the UpdateDSLModal component to utilize the new helper functions, improving readability and maintainability.
- Introduced unit tests for the new helper functions to ensure correctness and reliability.
- Enhanced the node components with new utility functions for managing node states and rendering logic.
This commit is contained in:
CodingOnStar 2026-03-24 16:16:54 +08:00
parent 508350ec6a
commit 6633f5aef8
31 changed files with 3811 additions and 687 deletions

View File

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

View File

@ -0,0 +1,373 @@
import type { EventEmitter } from 'ahooks/lib/useEventEmitter'
import type { EventEmitterValue } from '@/context/event-emitter'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { ToastContext } from '@/app/components/base/toast/context'
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 mockNotify = vi.fn()
const mockEmit = 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 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(
<ToastContext.Provider value={{ notify: mockNotify, close: vi.fn() }}>
<EventEmitterContext.Provider value={{ eventEmitter }}>
<UpdateDSLModal {...props} />
</EventEmitterContext.Provider>
</ToastContext.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(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
}))
})
})
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(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
}))
})
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(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
}))
})
})
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(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
}))
})
})
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(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
}))
})
})
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(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
}))
})
})
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(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
}))
})
})
})

View File

@ -0,0 +1,41 @@
import type { TFunction } from 'i18next'
import { render, screen } from '@testing-library/react'
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()
})
})

View File

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

View File

@ -0,0 +1,187 @@
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()
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: vi.fn(),
}),
}))
vi.mock('@/app/components/workflow/nodes/loop/use-interactions', () => ({
useNodeLoopInteractions: () => ({
handleNodeLoopChildSizeChange: vi.fn(),
}),
}))
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)
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()
})
})

View File

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

View File

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

View File

@ -0,0 +1,226 @@
import type { Props as FormProps } from '../form'
import type { BeforeRunFormProps } from '../index'
import { fireEvent, render, screen } from '@testing-library/react'
import Toast from '@/app/components/base/toast'
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
import BeforeRunForm from '../index'
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 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', () => {
const notifySpy = vi.spyOn(Toast, 'notify').mockImplementation(vi.fn())
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(notifySpy).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
}))
})
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', () => {
const notifySpy = vi.spyOn(Toast, 'notify').mockImplementation(vi.fn())
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(notifySpy).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
}))
})
})

View File

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

View File

@ -9,14 +9,19 @@ import * as React from 'react'
import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import { getProcessedFiles } from '@/app/components/base/file-uploader/utils'
import Toast from '@/app/components/base/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,43 +66,10 @@ 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.notify({
message: errMsg,
@ -133,19 +78,7 @@ const BeforeRunForm: FC<BeforeRunFormProps> = ({
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.notify({
message: t('errorMsg.invalidJson', { ns: 'workflow', field: parseErrorJsonField }),
@ -171,13 +104,13 @@ const BeforeRunForm: FC<BeforeRunFormProps> = ({
if (hasRun.current)
return
hasRun.current = true
if (filteredExistVarForms.length === 0 && !isHumanInput)
if (shouldAutoRunBeforeRunForm(filteredExistVarForms, isHumanInput))
onRun({})
if (filteredExistVarForms.length === 0 && isHumanInput)
if (shouldAutoShowGeneratedForm(filteredExistVarForms, isHumanInput))
handleShowGeneratedForm?.({})
}, [filteredExistVarForms, handleShowGeneratedForm, isHumanInput, onRun])
if (filteredExistVarForms.length === 0 && !isHumanInput)
if (shouldAutoRunBeforeRunForm(filteredExistVarForms, isHumanInput))
return null
return (

View File

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

View File

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

View File

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

View File

@ -17,15 +17,19 @@ import {
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { VAR_SHOW_NAME_MAP } from '@/app/components/workflow/constants'
import PickerStructurePanel from '@/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/picker'
import { VariableIconWithColor } from '@/app/components/workflow/nodes/_base/components/variable/variable-label'
import { VarType } from '@/app/components/workflow/types'
import { cn } from '@/utils/classnames'
import { checkKeys } from '@/utils/var'
import { Type } from '../../../llm/types'
import ManageInputField from './manage-input-field'
import { isSpecialVar, varTypeToStructType } from './utils'
import { varTypeToStructType } from './utils'
import {
filterReferenceVars,
getValueSelector,
getVariableCategory,
getVariableDisplayName,
} from './var-reference-vars.helpers'
type ItemProps = {
nodeId: string
@ -84,17 +88,10 @@ const Item: FC<ItemProps> = ({
}
}, [isFlat, isInCodeGeneratorInstructionEditor, itemData.variable])
const varName = useMemo(() => {
if (VAR_SHOW_NAME_MAP[itemData.variable])
return VAR_SHOW_NAME_MAP[itemData.variable]
if (!isFlat)
return itemData.variable
if (itemData.variable === 'current')
return isInCodeGeneratorInstructionEditor ? 'current_code' : 'current_prompt'
return itemData.variable
}, [isFlat, isInCodeGeneratorInstructionEditor, itemData.variable])
const varName = useMemo(
() => getVariableDisplayName(itemData.variable, !!isFlat, isInCodeGeneratorInstructionEditor),
[isFlat, isInCodeGeneratorInstructionEditor, itemData.variable],
)
const objStructuredOutput: StructuredOutput | null = useMemo(() => {
if (!isObj)
@ -150,30 +147,26 @@ const Item: FC<ItemProps> = ({
const handleChosen = (e: React.MouseEvent) => {
e.stopPropagation()
e.nativeEvent.stopImmediatePropagation()
if (!isSupportFileVar && isFile)
return
const valueSelector = getValueSelector({
itemData,
isFlat,
isSupportFileVar,
isFile,
isSys,
isEnv,
isChatVar,
isRagVariable,
nodeId,
objPath,
})
if (isFlat) {
onChange([itemData.variable], itemData)
}
else if (isSys || isEnv || isChatVar || isRagVariable) { // system variable | environment variable | conversation variable
onChange([...objPath, ...itemData.variable.split('.')], itemData)
}
else {
onChange([nodeId, ...objPath, itemData.variable], itemData)
}
if (valueSelector)
onChange(valueSelector, itemData)
}
const variableCategory = useMemo(() => {
if (isEnv)
return 'environment'
if (isChatVar)
return 'conversation'
if (isLoopVar)
return 'loop'
if (isRagVariable)
return 'rag'
return 'system'
}, [isEnv, isChatVar, isSys, isLoopVar, isRagVariable])
const variableCategory = useMemo(
() => getVariableCategory({ isEnv, isChatVar, isLoopVar, isRagVariable }),
[isEnv, isChatVar, isLoopVar, isRagVariable],
)
return (
<PortalToFollowElem
open={open}
@ -290,30 +283,7 @@ const VarReferenceVars: FC<Props> = ({
}
}
const filteredVars = vars.filter((v) => {
const children = v.vars.filter(v => checkKeys([v.variable], false).isValid || isSpecialVar(v.variable.split('.')[0]))
return children.length > 0
}).filter((node) => {
if (!searchText)
return node
const children = node.vars.filter((v) => {
const searchTextLower = searchText.toLowerCase()
return v.variable.toLowerCase().includes(searchTextLower) || node.title.toLowerCase().includes(searchTextLower)
})
return children.length > 0
}).map((node) => {
let vars = node.vars.filter(v => checkKeys([v.variable], false).isValid || isSpecialVar(v.variable.split('.')[0]))
if (searchText) {
const searchTextLower = searchText.toLowerCase()
if (!node.title.toLowerCase().includes(searchTextLower))
vars = vars.filter(v => v.variable.toLowerCase().includes(searchText.toLowerCase()))
}
return {
...node,
vars,
}
})
const filteredVars = useMemo(() => filterReferenceVars(vars, searchText), [vars, searchText])
return (
<>

View File

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

View File

@ -1,146 +1,620 @@
/**
* 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'
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', () => {
let BasePanel: typeof import('../index').default
beforeAll(async () => {
BasePanel = (await import('../index')).default
})
beforeEach(() => {
vi.clearAllMocks()
mockShowMessageLogModal = false
mockBuiltInTools = [{
id: 'provider/tool',
name: 'Tool',
type: 'builtin',
allow_delete: true,
}]
mockTriggerPlugins = []
mockLogsState.showSpecialResultPanel = false
mockLastRunState.isShowSingleRun = false
mockLastRunState.tabType = 'settings'
})
it('should render the settings panel and wire title, description, run, and close actions', async () => {
const { container } = renderWorkflowComponent(
<BasePanel id="node-1" data={createData() as never}>
<div>panel-child</div>
</BasePanel>,
{
initialStoreState: {
showSingleRunPanel: false,
workflowCanvasWidth: 1200,
nodePanelWidth: 480,
otherPanelWidth: 200,
buildInTools: [],
dataSourceList: [],
},
},
)
expect(screen.getByText('panel-child')).toBeInTheDocument()
expect(screen.getByText('authorized-in-node')).toBeInTheDocument()
fireEvent.blur(screen.getByDisplayValue('Tool Node'), { target: { value: 'Updated title' } })
fireEvent.change(screen.getByDisplayValue('Node description'), { target: { value: 'Updated description' } })
await waitFor(() => {
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenCalled()
})
expect(mockSaveStateToHistory).toHaveBeenCalled()
fireEvent.click(screen.getByText('authorized-in-node'))
it('should not save system compression to localStorage', () => {
const manager = createPanelWidthManager(storageKey)
const clickableItems = container.querySelectorAll('.cursor-pointer')
fireEvent.click(clickableItems[0] as HTMLElement)
fireEvent.click(clickableItems[clickableItems.length - 1] as HTMLElement)
const result = manager.updateWidth(200, 'system')
expect(mockHandleSingleRun).toHaveBeenCalledTimes(1)
expect(mockHandleNodeSelect).toHaveBeenCalledWith('node-1', true)
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ credential_id: 'credential-1' }),
}))
})
expect(result).toBe(400) // Respects minimum width
expect(localStorage.setItem).not.toHaveBeenCalled()
})
it('should render the special result panel when logs request it', () => {
mockLogsState.showSpecialResultPanel = true
it('should enforce minimum width of 400px', () => {
const manager = createPanelWidthManager(storageKey)
renderWorkflowComponent(
<BasePanel id="node-1" data={createData() as never}>
<div>panel-child</div>
</BasePanel>,
{
initialStoreState: {
nodePanelWidth: 480,
otherPanelWidth: 200,
},
},
)
// User tries to set below minimum
const userResult = manager.updateWidth(300, 'user')
expect(userResult).toBe(400)
expect(localStorage.setItem).toHaveBeenCalledWith(storageKey, '400')
expect(screen.getByText('special-result-panel')).toBeInTheDocument()
})
// System compression below minimum
const systemResult = manager.updateWidth(150, 'system')
expect(systemResult).toBe(400)
expect(localStorage.setItem).toHaveBeenCalledTimes(1) // Only user call
})
it('should render last-run content when the tab switches', () => {
mockLastRunState.tabType = 'lastRun'
it('should preserve user preferences during system compression', () => {
localStorage.setItem(storageKey, '600')
const manager = createPanelWidthManager(storageKey)
renderWorkflowComponent(
<BasePanel id="node-1" data={createData() as never}>
<div>panel-child</div>
</BasePanel>,
{
initialStoreState: {
nodePanelWidth: 480,
otherPanelWidth: 200,
},
},
)
// System compresses panel
manager.updateWidth(200, 'system')
expect(screen.getByText('last-run-panel')).toBeInTheDocument()
})
// User preference should remain unchanged
expect(localStorage.getItem(storageKey)).toBe('600')
it('should render the plain tab layout and allow last-run status updates', async () => {
mockLastRunState.tabType = 'lastRun'
renderWorkflowComponent(
<BasePanel id="node-plain" data={createData({ type: 'custom' }) as never}>
<div>panel-child</div>
</BasePanel>,
{
initialStoreState: {
nodePanelWidth: 480,
otherPanelWidth: 200,
},
},
)
expect(screen.queryByText('authorized-in-node')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('last-run-update-status'))
await waitFor(() => {
expect(mockHandleNodeDataUpdate).toHaveBeenCalledWith(expect.objectContaining({
id: 'node-plain',
data: expect.objectContaining({
_singleRunningStatus: NodeRunningStatus.Running,
}),
}))
})
})
describe('Bug Scenario Reproduction', () => {
it('should reproduce original bug behavior (for comparison)', () => {
const storageKey = 'workflow-node-panel-width'
it('should mark the last run as paused after a running single-run completes', async () => {
mockLastRunState.tabType = 'lastRun'
// Original buggy behavior - always saves regardless of source
const buggyUpdate = (width: number) => {
localStorage.setItem(storageKey, `${width}`)
return Math.max(400, width)
}
const { rerender } = renderWorkflowComponent(
<BasePanel id="node-pause" data={createData({ _singleRunningStatus: NodeRunningStatus.Running }) as never}>
<div>panel-child</div>
</BasePanel>,
{
initialStoreState: {
nodePanelWidth: 480,
otherPanelWidth: 200,
},
},
)
localStorage.setItem(storageKey, '500') // User preference
buggyUpdate(200) // System compression pollutes localStorage
expect(screen.getByText('active')).toBeInTheDocument()
expect(localStorage.getItem(storageKey)).toBe('200') // Bug: corrupted state
})
rerender(
<BasePanel id="node-pause" data={createData({ _isSingleRun: true, _singleRunningStatus: undefined }) as never}>
<div>panel-child</div>
</BasePanel>,
)
it('should verify fix prevents localStorage pollution', () => {
const storageKey = 'workflow-node-panel-width'
const manager = createPanelWidthManager(storageKey)
localStorage.setItem(storageKey, '500') // User preference
manager.updateWidth(200, 'system') // System compression
expect(localStorage.getItem(storageKey)).toBe('500') // Fix: preserved state
await waitFor(() => {
expect(screen.getByText('paused')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle multiple rapid operations correctly', () => {
const manager = createPanelWidthManager('workflow-node-panel-width')
it('should render custom data source single run form for supported nodes', () => {
mockLastRunState.isShowSingleRun = true
// Rapid system adjustments
manager.updateWidth(300, 'system')
manager.updateWidth(250, 'system')
manager.updateWidth(180, 'system')
renderWorkflowComponent(
<BasePanel id="node-1" data={createData({ type: BlockEnum.DataSource }) as never}>
<div>panel-child</div>
</BasePanel>,
{
initialStoreState: {
nodePanelWidth: 480,
otherPanelWidth: 200,
},
},
)
// Single user adjustment
manager.updateWidth(550, 'user')
expect(localStorage.setItem).toHaveBeenCalledTimes(1)
expect(localStorage.setItem).toHaveBeenCalledWith('workflow-node-panel-width', '550')
})
it('should handle corrupted localStorage gracefully', () => {
localStorage.setItem('workflow-node-panel-width', '150') // Below minimum
const manager = createPanelWidthManager('workflow-node-panel-width')
const storedWidth = manager.getStoredWidth()
expect(storedWidth).toBe(150) // Returns raw value
// User can correct the preference
const correctedWidth = manager.updateWidth(500, 'user')
expect(correctedWidth).toBe(500)
expect(localStorage.getItem('workflow-node-panel-width')).toBe('500')
})
expect(screen.getByText('data-source-before-run-form')).toBeInTheDocument()
})
describe('TypeScript Type Safety', () => {
it('should enforce source parameter type', () => {
const manager = createPanelWidthManager('workflow-node-panel-width')
it('should render data source authorization controls and jump to the settings modal', () => {
renderWorkflowComponent(
<BasePanel id="node-1" data={createData({ type: BlockEnum.DataSource, plugin_id: 'source-1', provider_type: 'remote' }) as never}>
<div>panel-child</div>
</BasePanel>,
{
initialStoreState: {
nodePanelWidth: 480,
otherPanelWidth: 200,
dataSourceList: [createDataSourceCollection({ is_authorized: false })],
},
},
)
// Valid source values
manager.updateWidth(500, 'user')
manager.updateWidth(500, 'system')
fireEvent.click(screen.getByText('authorized-in-datasource-node'))
// Default to 'user'
manager.updateWidth(500)
expect(mockSetShowAccountSettingModal).toHaveBeenCalled()
})
expect(localStorage.setItem).toHaveBeenCalledTimes(2) // user + default
it('should react to pending single run actions', () => {
renderWorkflowComponent(
<BasePanel id="node-1" data={createData() as never}>
<div>panel-child</div>
</BasePanel>,
{
initialStoreState: {
nodePanelWidth: 480,
otherPanelWidth: 200,
pendingSingleRun: {
nodeId: 'node-1',
action: 'run',
},
},
},
)
expect(mockHandleSingleRun).toHaveBeenCalledTimes(1)
renderWorkflowComponent(
<BasePanel id="node-1" data={createData() as never}>
<div>panel-child</div>
</BasePanel>,
{
initialStoreState: {
nodePanelWidth: 480,
otherPanelWidth: 200,
pendingSingleRun: {
nodeId: 'node-1',
action: 'stop',
},
},
},
)
expect(mockHandleStop).toHaveBeenCalledTimes(1)
})
it('should load trigger plugin details when the selected node is a trigger plugin', async () => {
mockTriggerPlugins = [{
id: 'trigger-1',
name: 'trigger-name',
plugin_id: 'plugin-id',
plugin_unique_identifier: 'plugin-uid',
label: {
en_US: 'Trigger Name',
},
declaration: {},
subscription_schema: [],
subscription_constructor: {},
}]
renderWorkflowComponent(
<BasePanel id="node-1" data={createData({ type: BlockEnum.TriggerPlugin, plugin_id: 'plugin-id' }) as never}>
<div>panel-child</div>
</BasePanel>,
{
initialStoreState: {
nodePanelWidth: 480,
otherPanelWidth: 200,
},
},
)
await waitFor(() => {
expect(mockSetDetail).toHaveBeenCalledWith(expect.objectContaining({
id: 'trigger-1',
name: 'Trigger Name',
}))
})
fireEvent.click(screen.getByText('change-subscription'))
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenCalledWith(
{ id: 'node-1', data: { subscription_id: 'subscription-1' } },
expect.objectContaining({ sync: true }),
)
})
it('should stop a running node and offset when the log modal is visible', () => {
mockShowMessageLogModal = true
const { container } = renderWorkflowComponent(
<BasePanel id="node-1" data={createData({ _singleRunningStatus: NodeRunningStatus.Running }) as never}>
<div>panel-child</div>
</BasePanel>,
{
initialStoreState: {
nodePanelWidth: 480,
otherPanelWidth: 240,
},
},
)
const root = container.firstElementChild as HTMLElement
expect(root.style.right).toBe('240px')
expect(root.className).toContain('absolute')
const clickableItems = container.querySelectorAll('.cursor-pointer')
fireEvent.click(clickableItems[0] as HTMLElement)
expect(mockHandleStop).toHaveBeenCalledTimes(1)
})
it('should persist user resize changes and compress oversized panel widths', async () => {
const { container } = renderWorkflowComponent(
<BasePanel id="node-resize" data={createData() as never}>
<div>panel-child</div>
</BasePanel>,
{
initialStoreState: {
workflowCanvasWidth: 800,
nodePanelWidth: 600,
otherPanelWidth: 200,
},
},
)
await waitFor(() => {
const panel = container.querySelector('[style*="width"]') as HTMLElement
expect(panel.style.width).toBe('400px')
})
})
})

View File

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

View File

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

View File

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

View File

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

View File

@ -2,17 +2,14 @@ import type {
FC,
ReactElement,
} from 'react'
import type { IterationNodeType } from '@/app/components/workflow/nodes/iteration/types'
import type { NodeProps } from '@/app/components/workflow/types'
import {
cloneElement,
memo,
useEffect,
useMemo,
useRef,
} from 'react'
import { useTranslation } from 'react-i18next'
import Tooltip from '@/app/components/base/tooltip'
import BlockIcon from '@/app/components/workflow/block-icon'
import { ToolTypeEnum } from '@/app/components/workflow/block-selector/types'
import { useNodesReadOnly, useToolIcon } from '@/app/components/workflow/hooks'
@ -23,7 +20,6 @@ import { useNodeLoopInteractions } from '@/app/components/workflow/nodes/loop/us
import CopyID from '@/app/components/workflow/nodes/tool/components/copy-id'
import {
BlockEnum,
isTriggerNode,
NodeRunningStatus,
} from '@/app/components/workflow/types'
import { hasErrorHandleNode, hasRetryNode } from '@/app/components/workflow/utils'
@ -38,6 +34,18 @@ import {
} from './components/node-handle'
import NodeResizer from './components/node-resizer'
import RetryOnNode from './components/retry/retry-on-node'
import {
NodeBody,
NodeDescription,
NodeHeaderMeta,
} from './node-sections'
import {
getLoopIndexTextKey,
getNodeStatusBorders,
isContainerNode,
isEntryWorkflowNode,
} from './node.helpers'
import useNodeResizeObserver from './use-node-resize-observer'
type NodeChildProps = {
id: string
@ -65,59 +73,34 @@ const BaseNode: FC<BaseNodeProps> = ({
const { shouldDim: pluginDimmed, isChecking: pluginIsChecking, isMissing: pluginIsMissing, canInstall: pluginCanInstall, uniqueIdentifier: pluginUniqueIdentifier } = useNodePluginInstallation(data)
const pluginInstallLocked = !pluginIsChecking && pluginIsMissing && pluginCanInstall && Boolean(pluginUniqueIdentifier)
useEffect(() => {
if (nodeRef.current && data.selected && data.isInIteration) {
const resizeObserver = new ResizeObserver(() => {
handleNodeIterationChildSizeChange(id)
})
useNodeResizeObserver({
enabled: Boolean(data.selected && data.isInIteration),
nodeRef,
onResize: () => handleNodeIterationChildSizeChange(id),
})
resizeObserver.observe(nodeRef.current)
return () => {
resizeObserver.disconnect()
}
}
}, [data.isInIteration, data.selected, id, handleNodeIterationChildSizeChange])
useEffect(() => {
if (nodeRef.current && data.selected && data.isInLoop) {
const resizeObserver = new ResizeObserver(() => {
handleNodeLoopChildSizeChange(id)
})
resizeObserver.observe(nodeRef.current)
return () => {
resizeObserver.disconnect()
}
}
}, [data.isInLoop, data.selected, id, handleNodeLoopChildSizeChange])
useNodeResizeObserver({
enabled: Boolean(data.selected && data.isInLoop),
nodeRef,
onResize: () => handleNodeLoopChildSizeChange(id),
})
const { hasNodeInspectVars } = useInspectVarsCrud()
const isLoading = data._runningStatus === NodeRunningStatus.Running || data._singleRunningStatus === NodeRunningStatus.Running
const hasVarValue = hasNodeInspectVars(id)
const showSelectedBorder = data.selected || data._isBundled || data._isEntering
const showSelectedBorder = Boolean(data.selected || data._isBundled || data._isEntering)
const {
showRunningBorder,
showSuccessBorder,
showFailedBorder,
showExceptionBorder,
} = useMemo(() => {
return {
showRunningBorder: (data._runningStatus === NodeRunningStatus.Running || data._runningStatus === NodeRunningStatus.Paused) && !showSelectedBorder,
showSuccessBorder: (data._runningStatus === NodeRunningStatus.Succeeded || (hasVarValue && !data._runningStatus)) && !showSelectedBorder,
showFailedBorder: data._runningStatus === NodeRunningStatus.Failed && !showSelectedBorder,
showExceptionBorder: data._runningStatus === NodeRunningStatus.Exception && !showSelectedBorder,
}
}, [data._runningStatus, hasVarValue, showSelectedBorder])
} = useMemo(() => getNodeStatusBorders(data._runningStatus, hasVarValue, showSelectedBorder), [data._runningStatus, hasVarValue, showSelectedBorder])
const LoopIndex = useMemo(() => {
let text = ''
if (data._runningStatus === NodeRunningStatus.Running)
text = t('nodes.loop.currentLoopCount', { ns: 'workflow', count: data._loopIndex })
if (data._runningStatus === NodeRunningStatus.Succeeded || data._runningStatus === NodeRunningStatus.Failed)
text = t('nodes.loop.totalLoopCount', { ns: 'workflow', count: data._loopIndex })
const translationKey = getLoopIndexTextKey(data._runningStatus)
const text = translationKey
? t(translationKey, { ns: 'workflow', count: data._loopIndex })
: ''
if (text) {
return (
@ -145,8 +128,8 @@ const BaseNode: FC<BaseNodeProps> = ({
)}
ref={nodeRef}
style={{
width: (data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) ? data.width : 'auto',
height: (data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) ? data.height : 'auto',
width: isContainerNode(data.type) ? data.width : 'auto',
height: isContainerNode(data.type) ? data.height : 'auto',
}}
>
{(data._dimmed || pluginDimmed || pluginInstallLocked) && (
@ -174,8 +157,8 @@ const BaseNode: FC<BaseNodeProps> = ({
className={cn(
'group relative pb-1 shadow-xs',
'rounded-[15px] border border-transparent',
(data.type !== BlockEnum.Iteration && data.type !== BlockEnum.Loop) && 'w-[240px] bg-workflow-block-bg',
(data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) && 'flex h-full w-full flex-col border-workflow-block-border bg-workflow-block-bg-transparent',
!isContainerNode(data.type) && 'w-[240px] bg-workflow-block-bg',
isContainerNode(data.type) && 'flex h-full w-full flex-col border-workflow-block-border bg-workflow-block-bg-transparent',
!data._runningStatus && 'hover:shadow-lg',
showRunningBorder && '!border-state-accent-solid',
showSuccessBorder && '!border-state-success-solid',
@ -239,7 +222,7 @@ const BaseNode: FC<BaseNodeProps> = ({
}
<div className={cn(
'flex items-center rounded-t-2xl px-3 pb-2 pt-3',
(data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) && 'bg-transparent',
isContainerNode(data.type) && 'bg-transparent',
)}
>
<BlockIcon
@ -255,72 +238,19 @@ const BaseNode: FC<BaseNodeProps> = ({
<div>
{data.title}
</div>
{
data.type === BlockEnum.Iteration && (data as IterationNodeType).is_parallel && (
<Tooltip popupContent={(
<div className="w-[180px]">
<div className="font-extrabold">
{t('nodes.iteration.parallelModeEnableTitle', { ns: 'workflow' })}
</div>
{t('nodes.iteration.parallelModeEnableDesc', { ns: 'workflow' })}
</div>
)}
>
<div className="ml-1 flex items-center justify-center rounded-[5px] border-[1px] border-text-warning px-[5px] py-[3px] text-text-warning system-2xs-medium-uppercase">
{t('nodes.iteration.parallelModeUpper', { ns: 'workflow' })}
</div>
</Tooltip>
)
}
</div>
{
!!(data._iterationLength && data._iterationIndex && data._runningStatus === NodeRunningStatus.Running) && (
<div className="mr-1.5 text-xs font-medium text-text-accent">
{data._iterationIndex > data._iterationLength ? data._iterationLength : data._iterationIndex}
/
{data._iterationLength}
</div>
)
}
{
!!(data.type === BlockEnum.Loop && data._loopIndex) && LoopIndex
}
{
isLoading && <span className="i-ri-loader-2-line h-3.5 w-3.5 animate-spin text-text-accent" />
}
{
!isLoading && data._runningStatus === NodeRunningStatus.Failed && (
<span className="i-ri-error-warning-fill h-3.5 w-3.5 text-text-destructive" />
)
}
{
!isLoading && data._runningStatus === NodeRunningStatus.Exception && (
<span className="i-ri-alert-fill h-3.5 w-3.5 text-text-warning-secondary" />
)
}
{
!isLoading && (data._runningStatus === NodeRunningStatus.Succeeded || (hasVarValue && !data._runningStatus)) && (
<span className="i-ri-checkbox-circle-fill h-3.5 w-3.5 text-text-success" />
)
}
{
!isLoading && data._runningStatus === NodeRunningStatus.Paused && (
<span className="i-ri-pause-circle-fill h-3.5 w-3.5 text-text-warning-secondary" />
)
}
<NodeHeaderMeta
data={data}
hasVarValue={hasVarValue}
isLoading={isLoading}
loopIndex={LoopIndex}
t={t}
/>
</div>
{
data.type !== BlockEnum.Iteration && data.type !== BlockEnum.Loop && (
cloneElement(children, { id, data } as any)
)
}
{
(data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) && (
<div className="grow pb-1 pl-1 pr-1">
{cloneElement(children, { id, data } as any)}
</div>
)
}
<NodeBody
data={data}
child={cloneElement(children, { id, data } as any)}
/>
{
hasRetryNode(data.type) && (
<RetryOnNode
@ -337,13 +267,7 @@ const BaseNode: FC<BaseNodeProps> = ({
/>
)
}
{
!!(data.desc && data.type !== BlockEnum.Iteration && data.type !== BlockEnum.Loop) && (
<div className="whitespace-pre-line break-words px-3 pb-2 pt-1 text-text-tertiary system-xs-regular">
{data.desc}
</div>
)
}
<NodeDescription data={data} />
{data.type === BlockEnum.Tool && data.provider_type === ToolTypeEnum.MCP && (
<div className="px-3 pb-2">
<CopyID content={data.provider_id || ''} />
@ -354,7 +278,7 @@ const BaseNode: FC<BaseNodeProps> = ({
)
const isStartNode = data.type === BlockEnum.Start
const isEntryNode = isTriggerNode(data.type as any) || isStartNode
const isEntryNode = isEntryWorkflowNode(data.type)
return isEntryNode
? (

View File

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

View File

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

View File

@ -1,16 +1,11 @@
'use client'
import type { MouseEventHandler } from 'react'
import type {
CommonNodeType,
Node,
} from './types'
import {
RiAlertFill,
RiCloseLine,
RiFileDownloadLine,
} from '@remixicon/react'
import { load as yamlLoad } from 'js-yaml'
import {
memo,
useCallback,
@ -23,7 +18,6 @@ import Uploader from '@/app/components/app/create-from-dsl-modal/uploader'
import { useStore as useAppStore } from '@/app/components/app/store'
import Button from '@/app/components/base/button'
import Modal from '@/app/components/base/modal'
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
import { ToastContext } from '@/app/components/base/toast/context'
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
import { useEventEmitterContextContext } from '@/context/event-emitter'
@ -36,12 +30,13 @@ import {
importDSLConfirm,
} from '@/service/apps'
import { fetchWorkflowDraft } from '@/service/workflow'
import { AppModeEnum } from '@/types/app'
import { WORKFLOW_DATA_UPDATE } from './constants'
import {
BlockEnum,
SupportUploadFileTypes,
} from './types'
getImportNotificationPayload,
isImportCompleted,
normalizeWorkflowFeatures,
validateDSLContent,
} from './update-dsl-modal.helpers'
import {
initialEdges,
initialNodes,
@ -98,38 +93,13 @@ const UpdateDSLModal = ({
} = await fetchWorkflowDraft(`/apps/${app_id}/workflows/draft`)
const { nodes, edges, viewport } = graph
const newFeatures = {
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,
},
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 },
}
eventEmitter?.emit({
type: WORKFLOW_DATA_UPDATE,
payload: {
nodes: initialNodes(nodes, edges),
edges: initialEdges(edges, nodes),
viewport,
features: newFeatures,
features: normalizeWorkflowFeatures(features),
hash,
conversation_variables: conversation_variables || [],
environment_variables: environment_variables || [],
@ -137,34 +107,33 @@ const UpdateDSLModal = ({
} as any)
}, [eventEmitter])
const validateDSLContent = (content: string): boolean => {
try {
const data = yamlLoad(content) as any
const nodes = data?.workflow?.graph?.nodes ?? []
const invalidNodes = appDetail?.mode === AppModeEnum.ADVANCED_CHAT
? [
BlockEnum.End,
BlockEnum.TriggerWebhook,
BlockEnum.TriggerSchedule,
BlockEnum.TriggerPlugin,
]
: [BlockEnum.Answer]
const hasInvalidNode = nodes.some((node: Node<CommonNodeType>) => {
return invalidNodes.includes(node?.data?.type)
})
if (hasInvalidNode) {
notify({ type: 'error', message: t('common.importFailure', { ns: 'workflow' }) })
return false
}
return true
}
catch {
notify({ type: 'error', message: t('common.importFailure', { ns: 'workflow' }) })
return false
}
}
const isCreatingRef = useRef(false)
const handleCompletedImport = useCallback(async (status: DSLImportStatus, appId?: string) => {
if (!appId) {
notify({ type: 'error', message: t('common.importFailure', { ns: 'workflow' }) })
return
}
handleWorkflowUpdate(appId)
onImport?.()
notify(getImportNotificationPayload(status, t))
await handleCheckPluginDependencies(appId)
setLoading(false)
onCancel()
}, [handleCheckPluginDependencies, handleWorkflowUpdate, notify, onCancel, onImport, t])
const handlePendingImport = useCallback((id: string, importedVersion?: string | null, currentVersion?: string | null) => {
setShow(false)
setTimeout(() => {
setShowErrorModal(true)
}, 300)
setVersions({
importedVersion: importedVersion ?? '',
systemVersion: currentVersion ?? '',
})
setImportId(id)
}, [])
const handleImport: MouseEventHandler = useCallback(async () => {
if (isCreatingRef.current)
return
@ -172,44 +141,25 @@ const UpdateDSLModal = ({
if (!currentFile)
return
try {
if (appDetail && fileContent && validateDSLContent(fileContent)) {
if (appDetail && fileContent && validateDSLContent(fileContent, appDetail.mode)) {
setLoading(true)
const response = await importDSL({ mode: DSLImportMode.YAML_CONTENT, yaml_content: fileContent, app_id: appDetail.id })
const { id, status, app_id, imported_dsl_version, current_dsl_version } = response
if (status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS) {
if (!app_id) {
notify({ type: 'error', message: t('common.importFailure', { ns: 'workflow' }) })
return
}
handleWorkflowUpdate(app_id)
if (onImport)
onImport()
notify({
type: status === DSLImportStatus.COMPLETED ? 'success' : 'warning',
message: t(status === DSLImportStatus.COMPLETED ? 'common.importSuccess' : 'common.importWarning', { ns: 'workflow' }),
children: status === DSLImportStatus.COMPLETED_WITH_WARNINGS && t('common.importWarningDetails', { ns: 'workflow' }),
})
await handleCheckPluginDependencies(app_id)
setLoading(false)
onCancel()
if (isImportCompleted(status)) {
await handleCompletedImport(status, app_id)
}
else if (status === DSLImportStatus.PENDING) {
setShow(false)
setTimeout(() => {
setShowErrorModal(true)
}, 300)
setVersions({
importedVersion: imported_dsl_version ?? '',
systemVersion: current_dsl_version ?? '',
})
setImportId(id)
handlePendingImport(id, imported_dsl_version, current_dsl_version)
}
else {
setLoading(false)
notify({ type: 'error', message: t('common.importFailure', { ns: 'workflow' }) })
}
}
else if (fileContent) {
notify({ type: 'error', message: t('common.importFailure', { ns: 'workflow' }) })
}
}
// eslint-disable-next-line unused-imports/no-unused-vars
catch (e) {
@ -217,7 +167,7 @@ const UpdateDSLModal = ({
notify({ type: 'error', message: t('common.importFailure', { ns: 'workflow' }) })
}
isCreatingRef.current = false
}, [currentFile, fileContent, onCancel, notify, t, appDetail, onImport, handleWorkflowUpdate, handleCheckPluginDependencies])
}, [currentFile, fileContent, notify, t, appDetail, handleCompletedImport, handlePendingImport])
const onUpdateDSLConfirm: MouseEventHandler = async () => {
try {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,31 +2,23 @@ import type { VarInInspect } from '@/types/workflow'
import { useDebounceFn } from 'ahooks'
import * as React from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
import { getProcessedFiles, getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils'
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
import Textarea from '@/app/components/base/textarea'
import ErrorMessage from '@/app/components/workflow/nodes/llm/components/json-schema-config-modal/error-message'
import SchemaEditor from '@/app/components/workflow/nodes/llm/components/json-schema-config-modal/schema-editor'
import {
checkJsonSchemaDepth,
getValidationErrorMessage,
validateSchemaAgainstDraft7,
} from '@/app/components/workflow/nodes/llm/utils'
import { getProcessedFiles } from '@/app/components/base/file-uploader/utils'
import { useStore } from '@/app/components/workflow/store'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import {
validateJSONSchema,
} from '@/app/components/workflow/variable-inspect/utils'
import { JSON_SCHEMA_MAX_DEPTH } from '@/config'
import { TransferMethod } from '@/types/app'
import { VarInInspectType } from '@/types/workflow'
import { cn } from '@/utils/classnames'
import { PreviewMode } from '../../base/features/types'
import BoolValue from '../panel/chat-variable-panel/components/bool-value'
import DisplayContent from './display-content'
import LargeDataAlert from './large-data-alert'
import { CHUNK_SCHEMA_TYPES, PreviewType } from './types'
import {
BoolArraySection,
ErrorMessages,
FileEditorSection,
JsonEditorSection,
TextEditorSection,
} from './value-content-sections'
import {
formatInspectFileValue,
getValueEditorState,
isFileValueUploaded,
validateInspectJsonValue,
} from './value-content.helpers'
type Props = {
currentVar: VarInInspect
@ -42,35 +34,24 @@ const ValueContent = ({
const contentContainerRef = useRef<HTMLDivElement>(null)
const errorMessageRef = useRef<HTMLDivElement>(null)
const [editorHeight, setEditorHeight] = useState(0)
const showTextEditor = currentVar.value_type === 'secret' || currentVar.value_type === 'string' || currentVar.value_type === 'number'
const showBoolEditor = typeof currentVar.value === 'boolean'
const showBoolArrayEditor = Array.isArray(currentVar.value) && currentVar.value.every(v => typeof v === 'boolean')
const isSysFiles = currentVar.type === VarInInspectType.system && currentVar.name === 'files'
const showJSONEditor = !isSysFiles && (currentVar.value_type === 'object' || currentVar.value_type === 'array[string]' || currentVar.value_type === 'array[number]' || currentVar.value_type === 'array[object]' || currentVar.value_type === 'array[any]')
const showFileEditor = isSysFiles || currentVar.value_type === 'file' || currentVar.value_type === 'array[file]'
const textEditorDisabled = currentVar.type === VarInInspectType.environment || (currentVar.type === VarInInspectType.system && currentVar.name !== 'query' && currentVar.name !== 'files')
const JSONEditorDisabled = currentVar.value_type === 'array[any]'
const {
showTextEditor,
showBoolEditor,
showBoolArrayEditor,
isSysFiles,
showJSONEditor,
showFileEditor,
textEditorDisabled,
JSONEditorDisabled,
hasChunks,
} = useMemo(() => getValueEditorState(currentVar), [currentVar])
const fileUploadConfig = useStore(s => s.fileUploadConfig)
const hasChunks = useMemo(() => {
if (!currentVar.schemaType)
return false
return CHUNK_SCHEMA_TYPES.includes(currentVar.schemaType)
}, [currentVar.schemaType])
const formatFileValue = (value: VarInInspect) => {
if (value.value_type === 'file')
return value.value ? getProcessedFilesFromResponse([value.value]) : []
if (value.value_type === 'array[file]' || (value.type === VarInInspectType.system && currentVar.name === 'files'))
return value.value && value.value.length > 0 ? getProcessedFilesFromResponse(value.value) : []
return []
}
const [value, setValue] = useState<any>()
const [json, setJson] = useState('')
const [parseError, setParseError] = useState<Error | null>(null)
const [validationError, setValidationError] = useState<string>('')
const [fileValue, setFileValue] = useState<any>(() => formatFileValue(currentVar))
const [fileValue, setFileValue] = useState<any>(() => formatInspectFileValue(currentVar))
const { run: debounceValueChange } = useDebounceFn(handleValueChange, { wait: 500 })
@ -87,7 +68,7 @@ const ValueContent = ({
setJson(currentVar.value != null ? JSON.stringify(currentVar.value, null, 2) : '')
if (showFileEditor)
setFileValue(formatFileValue(currentVar))
setFileValue(formatInspectFileValue(currentVar))
}, [currentVar.id, currentVar.value])
const handleTextChange = (value: string) => {
@ -105,40 +86,10 @@ const ValueContent = ({
}
const jsonValueValidate = (value: string, type: string) => {
try {
const newJSONSchema = JSON.parse(value)
setParseError(null)
const result = validateJSONSchema(newJSONSchema, type)
if (!result.success) {
setValidationError(result.error.message)
return false
}
if (type === 'object' || type === 'array[object]') {
const schemaDepth = checkJsonSchemaDepth(newJSONSchema)
if (schemaDepth > JSON_SCHEMA_MAX_DEPTH) {
setValidationError(`Schema exceeds maximum depth of ${JSON_SCHEMA_MAX_DEPTH}.`)
return false
}
const validationErrors = validateSchemaAgainstDraft7(newJSONSchema)
if (validationErrors.length > 0) {
setValidationError(getValidationErrorMessage(validationErrors))
return false
}
}
setValidationError('')
return true
}
catch (error) {
setValidationError('')
if (error instanceof Error) {
setParseError(error)
return false
}
else {
setParseError(new Error('Invalid JSON'))
return false
}
}
const result = validateInspectJsonValue(value, type)
setParseError(result.parseError)
setValidationError(result.validationError)
return result.success
}
const handleEditorChange = (value: string) => {
@ -151,13 +102,11 @@ const ValueContent = ({
}
}
const fileValueValidate = (fileList: any[]) => fileList.every(file => file.upload_file_id)
const handleFileChange = (value: any[]) => {
setFileValue(value)
// check every file upload progress
// invoke update api after every file uploaded
if (!fileValueValidate(value))
if (!isFileValueUploaded(value))
return
if (currentVar.value_type === 'file')
debounceValueChange(currentVar.id, value[0])
@ -189,31 +138,13 @@ const ValueContent = ({
>
<div className={cn('relative grow')} style={{ height: `${editorHeight}px` }}>
{showTextEditor && (
<>
{isTruncated && <LargeDataAlert className="absolute left-3 right-3 top-1" />}
{
currentVar.value_type === 'string'
? (
<DisplayContent
previewType={PreviewType.Markdown}
varType={currentVar.value_type}
mdString={value as any}
readonly={textEditorDisabled}
handleTextChange={handleTextChange}
className={cn(isTruncated && 'pt-[36px]')}
/>
)
: (
<Textarea
readOnly={textEditorDisabled}
disabled={textEditorDisabled || isTruncated}
className={cn('h-full', isTruncated && 'pt-[48px]')}
value={value as any}
onChange={e => handleTextChange(e.target.value)}
/>
)
}
</>
<TextEditorSection
currentVar={currentVar}
value={value}
textEditorDisabled={textEditorDisabled}
isTruncated={isTruncated}
onTextChange={handleTextChange}
/>
)}
{showBoolEditor && (
<div className="w-[295px]">
@ -228,79 +159,41 @@ const ValueContent = ({
)}
{
showBoolArrayEditor && (
<div className="w-[295px] space-y-1">
{currentVar.value.map((v: boolean, i: number) => (
<BoolValue
key={i}
value={v}
onChange={(newValue) => {
const newArray = [...(currentVar.value as boolean[])]
newArray[i] = newValue
setValue(newArray)
debounceValueChange(currentVar.id, newArray)
}}
/>
))}
</div>
<BoolArraySection
values={currentVar.value as boolean[]}
onChange={(newArray) => {
setValue(newArray)
debounceValueChange(currentVar.id, newArray)
}}
/>
)
}
{showJSONEditor && (
hasChunks
? (
<DisplayContent
previewType={PreviewType.Chunks}
varType={currentVar.value_type}
schemaType={currentVar.schemaType ?? ''}
jsonString={json ?? '{}'}
readonly={JSONEditorDisabled}
handleEditorChange={handleEditorChange}
/>
)
: (
<SchemaEditor
readonly={JSONEditorDisabled || isTruncated}
className="overflow-y-auto"
hideTopMenu
schema={json}
onUpdate={handleEditorChange}
isTruncated={isTruncated}
/>
)
<JsonEditorSection
hasChunks={hasChunks}
schemaType={currentVar.schemaType}
valueType={currentVar.value_type}
json={json}
readonly={JSONEditorDisabled}
isTruncated={isTruncated}
onChange={handleEditorChange}
/>
)}
{showFileEditor && (
<div className="max-w-[460px]">
<FileUploaderInAttachmentWrapper
value={fileValue}
onChange={files => handleFileChange(getProcessedFiles(files))}
fileConfig={{
allowed_file_types: [
SupportUploadFileTypes.image,
SupportUploadFileTypes.document,
SupportUploadFileTypes.audio,
SupportUploadFileTypes.video,
],
allowed_file_extensions: [
...FILE_EXTS[SupportUploadFileTypes.image],
...FILE_EXTS[SupportUploadFileTypes.document],
...FILE_EXTS[SupportUploadFileTypes.audio],
...FILE_EXTS[SupportUploadFileTypes.video],
],
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
number_limits: currentVar.value_type === 'file' ? 1 : fileUploadConfig?.workflow_file_upload_limit || 5,
fileUploadConfig,
preview_config: {
mode: PreviewMode.NewPage,
file_type_list: ['application/pdf'],
},
}}
isDisabled={textEditorDisabled}
/>
</div>
<FileEditorSection
currentVar={currentVar}
fileValue={fileValue}
fileUploadConfig={fileUploadConfig}
textEditorDisabled={textEditorDisabled}
onChange={files => handleFileChange(getProcessedFiles(files))}
/>
)}
</div>
<div ref={errorMessageRef} className="shrink-0">
{parseError && <ErrorMessage className="mt-1" message={parseError.message} />}
{validationError && <ErrorMessage className="mt-1" message={validationError} />}
<ErrorMessages
parseError={parseError}
validationError={validationError}
/>
</div>
</div>
)