mirror of https://github.com/langgenius/dify.git
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:
parent
508350ec6a
commit
6633f5aef8
|
|
@ -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 })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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',
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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',
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,84 @@
|
|||
import type { NodeOutPutVar, Var } from '@/app/components/workflow/types'
|
||||
import { VarType } from '@/app/components/workflow/types'
|
||||
import {
|
||||
filterReferenceVars,
|
||||
getValueSelector,
|
||||
getVariableCategory,
|
||||
getVariableDisplayName,
|
||||
} from '../var-reference-vars.helpers'
|
||||
|
||||
describe('var-reference-vars helpers', () => {
|
||||
it('should derive display names for flat and mapped variables', () => {
|
||||
expect(getVariableDisplayName('sys.files', false)).toBe('files')
|
||||
expect(getVariableDisplayName('current', true, true)).toBe('current_code')
|
||||
expect(getVariableDisplayName('foo', true, false)).toBe('foo')
|
||||
})
|
||||
|
||||
it('should resolve variable categories', () => {
|
||||
expect(getVariableCategory({ isEnv: true, isChatVar: false })).toBe('environment')
|
||||
expect(getVariableCategory({ isEnv: false, isChatVar: true })).toBe('conversation')
|
||||
expect(getVariableCategory({ isEnv: false, isChatVar: false, isLoopVar: true })).toBe('loop')
|
||||
expect(getVariableCategory({ isEnv: false, isChatVar: false, isRagVariable: true })).toBe('rag')
|
||||
})
|
||||
|
||||
it('should build selectors by variable scope and file support', () => {
|
||||
const itemData: Var = { variable: 'output', type: VarType.string }
|
||||
expect(getValueSelector({
|
||||
itemData,
|
||||
isFlat: true,
|
||||
isSupportFileVar: true,
|
||||
isFile: false,
|
||||
isSys: false,
|
||||
isEnv: false,
|
||||
isChatVar: false,
|
||||
nodeId: 'node-1',
|
||||
objPath: [],
|
||||
})).toEqual(['output'])
|
||||
|
||||
expect(getValueSelector({
|
||||
itemData: { variable: 'env.apiKey', type: VarType.string },
|
||||
isFlat: false,
|
||||
isSupportFileVar: true,
|
||||
isFile: false,
|
||||
isSys: false,
|
||||
isEnv: true,
|
||||
isChatVar: false,
|
||||
nodeId: 'node-1',
|
||||
objPath: ['parent'],
|
||||
})).toEqual(['parent', 'env', 'apiKey'])
|
||||
|
||||
expect(getValueSelector({
|
||||
itemData: { variable: 'file', type: VarType.file },
|
||||
isFlat: false,
|
||||
isSupportFileVar: false,
|
||||
isFile: true,
|
||||
isSys: false,
|
||||
isEnv: false,
|
||||
isChatVar: false,
|
||||
nodeId: 'node-1',
|
||||
objPath: [],
|
||||
})).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should filter out invalid vars and apply search text', () => {
|
||||
const vars = filterReferenceVars([
|
||||
{
|
||||
title: 'Node A',
|
||||
nodeId: 'node-a',
|
||||
vars: [
|
||||
{ variable: 'valid_name', type: VarType.string },
|
||||
{ variable: 'invalid-key', type: VarType.string },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Node B',
|
||||
nodeId: 'node-b',
|
||||
vars: [{ variable: 'another_value', type: VarType.string }],
|
||||
},
|
||||
] as NodeOutPutVar[], 'another')
|
||||
|
||||
expect(vars).toHaveLength(1)
|
||||
expect(vars[0].title).toBe('Node B')
|
||||
expect(vars[0].vars).toEqual([expect.objectContaining({ variable: 'another_value' })])
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,226 @@
|
|||
import type { NodeOutPutVar } from '@/app/components/workflow/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { VarType } from '@/app/components/workflow/types'
|
||||
import VarReferenceVars from '../var-reference-vars'
|
||||
|
||||
vi.mock('../object-child-tree-panel/picker', () => ({
|
||||
default: ({
|
||||
onHovering,
|
||||
onSelect,
|
||||
}: {
|
||||
onHovering?: (value: boolean) => void
|
||||
onSelect?: (value: string[]) => void
|
||||
}) => (
|
||||
<div>
|
||||
<button onMouseEnter={() => onHovering?.(true)} onMouseLeave={() => onHovering?.(false)}>
|
||||
picker-panel
|
||||
</button>
|
||||
<button onClick={() => onSelect?.(['node-obj', 'payload', 'child'])}>pick-child</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../manage-input-field', () => ({
|
||||
default: ({ onManage }: { onManage: () => void }) => <button onClick={onManage}>manage-input</button>,
|
||||
}))
|
||||
|
||||
describe('VarReferenceVars', () => {
|
||||
const createVars = (vars: NodeOutPutVar[]) => vars
|
||||
|
||||
const baseVars = createVars([{
|
||||
title: 'Node A',
|
||||
nodeId: 'node-a',
|
||||
vars: [{ variable: 'valid_name', type: VarType.string }],
|
||||
}])
|
||||
|
||||
it('should filter vars through the search box and call onClose on escape', () => {
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<VarReferenceVars
|
||||
vars={baseVars}
|
||||
onChange={vi.fn()}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('workflow.common.searchVar'), {
|
||||
target: { value: 'valid' },
|
||||
})
|
||||
expect(screen.getByText('valid_name')).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(screen.getByPlaceholderText('workflow.common.searchVar'), { key: 'Escape' })
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onChange when a variable item is chosen', () => {
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<VarReferenceVars
|
||||
vars={baseVars}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('valid_name'))
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(['node-a', 'valid_name'], expect.objectContaining({
|
||||
variable: 'valid_name',
|
||||
}))
|
||||
})
|
||||
|
||||
it('should render empty state and manage input action', () => {
|
||||
const onManageInputField = vi.fn()
|
||||
|
||||
render(
|
||||
<VarReferenceVars
|
||||
vars={[]}
|
||||
onChange={vi.fn()}
|
||||
showManageInputField
|
||||
onManageInputField={onManageInputField}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('workflow.common.noVar')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('manage-input'))
|
||||
expect(onManageInputField).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should render special variable labels and schema types', () => {
|
||||
render(
|
||||
<VarReferenceVars
|
||||
hideSearch
|
||||
preferSchemaType
|
||||
vars={createVars([
|
||||
{
|
||||
title: 'Specials',
|
||||
nodeId: 'node-special',
|
||||
vars: [
|
||||
{ variable: 'env.API_KEY', type: VarType.string, schemaType: 'secret' },
|
||||
{ variable: 'conversation.user_name', type: VarType.string, des: 'User name' },
|
||||
{ variable: 'retrieval.source.title', type: VarType.string, isRagVariable: true },
|
||||
],
|
||||
},
|
||||
])}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByPlaceholderText('workflow.common.searchVar')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('API_KEY')).toBeInTheDocument()
|
||||
expect(screen.getByText('user_name')).toBeInTheDocument()
|
||||
expect(screen.getByText('secret')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render flat vars and the last output separator', () => {
|
||||
render(
|
||||
<VarReferenceVars
|
||||
hideSearch
|
||||
vars={createVars([
|
||||
{
|
||||
title: 'Flat',
|
||||
nodeId: 'node-flat',
|
||||
isFlat: true,
|
||||
vars: [{ variable: 'current', type: VarType.string }],
|
||||
},
|
||||
{
|
||||
title: 'Node B',
|
||||
nodeId: 'node-b',
|
||||
vars: [{ variable: 'payload', type: VarType.string }],
|
||||
},
|
||||
])}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('workflow.debug.lastOutput')).toBeInTheDocument()
|
||||
expect(screen.getByText('current_prompt')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should resolve selectors for special variables and file support', () => {
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<VarReferenceVars
|
||||
hideSearch
|
||||
isSupportFileVar
|
||||
vars={createVars([
|
||||
{
|
||||
title: 'Specials',
|
||||
nodeId: 'node-special',
|
||||
vars: [
|
||||
{ variable: 'env.API_KEY', type: VarType.string },
|
||||
{ variable: 'conversation.user_name', type: VarType.string, des: 'User name' },
|
||||
{ variable: 'current', type: VarType.string },
|
||||
{ variable: 'asset', type: VarType.file },
|
||||
],
|
||||
},
|
||||
])}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('API_KEY'))
|
||||
fireEvent.click(screen.getByText('user_name'))
|
||||
fireEvent.click(screen.getByText('current'))
|
||||
fireEvent.click(screen.getByText('asset'))
|
||||
|
||||
expect(onChange).toHaveBeenNthCalledWith(1, ['env', 'API_KEY'], expect.objectContaining({ variable: 'env.API_KEY' }))
|
||||
expect(onChange).toHaveBeenNthCalledWith(2, ['conversation', 'user_name'], expect.objectContaining({ variable: 'conversation.user_name' }))
|
||||
expect(onChange).toHaveBeenNthCalledWith(3, ['node-special', 'current'], expect.objectContaining({ variable: 'current' }))
|
||||
expect(onChange).toHaveBeenNthCalledWith(4, ['node-special', 'asset'], expect.objectContaining({ variable: 'asset' }))
|
||||
})
|
||||
|
||||
it('should render object vars and select them by node path', () => {
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<VarReferenceVars
|
||||
hideSearch
|
||||
vars={createVars([
|
||||
{
|
||||
title: 'Object vars',
|
||||
nodeId: 'node-obj',
|
||||
vars: [{
|
||||
variable: 'payload',
|
||||
type: VarType.object,
|
||||
children: [{ variable: 'child', type: VarType.string }],
|
||||
}],
|
||||
},
|
||||
])}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('payload'))
|
||||
expect(onChange).toHaveBeenCalledWith(['node-obj', 'payload'], expect.objectContaining({
|
||||
variable: 'payload',
|
||||
}))
|
||||
})
|
||||
|
||||
it('should ignore file vars when file support is disabled and forward blur events', () => {
|
||||
const onChange = vi.fn()
|
||||
const onBlur = vi.fn()
|
||||
|
||||
render(
|
||||
<VarReferenceVars
|
||||
vars={createVars([
|
||||
{
|
||||
title: 'Files',
|
||||
nodeId: 'node-files',
|
||||
vars: [{ variable: 'asset', type: VarType.file }],
|
||||
},
|
||||
])}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.blur(screen.getByPlaceholderText('workflow.common.searchVar'))
|
||||
expect(onBlur).toHaveBeenCalledTimes(1)
|
||||
|
||||
fireEvent.click(screen.getByText('asset'))
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
import type { NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types'
|
||||
import { VAR_SHOW_NAME_MAP } from '@/app/components/workflow/constants'
|
||||
import { checkKeys } from '@/utils/var'
|
||||
import { isSpecialVar } from './utils'
|
||||
|
||||
export const getVariableDisplayName = (
|
||||
variable: string,
|
||||
isFlat: boolean,
|
||||
isInCodeGeneratorInstructionEditor?: boolean,
|
||||
) => {
|
||||
if (VAR_SHOW_NAME_MAP[variable])
|
||||
return VAR_SHOW_NAME_MAP[variable]
|
||||
if (!isFlat)
|
||||
return variable
|
||||
if (variable === 'current')
|
||||
return isInCodeGeneratorInstructionEditor ? 'current_code' : 'current_prompt'
|
||||
return variable
|
||||
}
|
||||
|
||||
export const getVariableCategory = ({
|
||||
isEnv,
|
||||
isChatVar,
|
||||
isLoopVar,
|
||||
isRagVariable,
|
||||
}: {
|
||||
isEnv: boolean
|
||||
isChatVar: boolean
|
||||
isLoopVar?: boolean
|
||||
isRagVariable?: boolean
|
||||
}) => {
|
||||
if (isEnv)
|
||||
return 'environment'
|
||||
if (isChatVar)
|
||||
return 'conversation'
|
||||
if (isLoopVar)
|
||||
return 'loop'
|
||||
if (isRagVariable)
|
||||
return 'rag'
|
||||
return 'system'
|
||||
}
|
||||
|
||||
export const getValueSelector = ({
|
||||
itemData,
|
||||
isFlat,
|
||||
isSupportFileVar,
|
||||
isFile,
|
||||
isSys,
|
||||
isEnv,
|
||||
isChatVar,
|
||||
isRagVariable,
|
||||
nodeId,
|
||||
objPath,
|
||||
}: {
|
||||
itemData: Var
|
||||
isFlat?: boolean
|
||||
isSupportFileVar?: boolean
|
||||
isFile: boolean
|
||||
isSys: boolean
|
||||
isEnv: boolean
|
||||
isChatVar: boolean
|
||||
isRagVariable?: boolean
|
||||
nodeId: string
|
||||
objPath: string[]
|
||||
}): ValueSelector | undefined => {
|
||||
if (!isSupportFileVar && isFile)
|
||||
return undefined
|
||||
|
||||
if (isFlat)
|
||||
return [itemData.variable]
|
||||
if (isSys || isEnv || isChatVar || isRagVariable)
|
||||
return [...objPath, ...itemData.variable.split('.')]
|
||||
return [nodeId, ...objPath, itemData.variable]
|
||||
}
|
||||
|
||||
const getVisibleChildren = (vars: Var[]) => {
|
||||
return vars.filter(variable => checkKeys([variable.variable], false).isValid || isSpecialVar(variable.variable.split('.')[0]))
|
||||
}
|
||||
|
||||
export const filterReferenceVars = (vars: NodeOutPutVar[], searchText: string) => {
|
||||
const searchTextLower = searchText.toLowerCase()
|
||||
|
||||
return vars
|
||||
.map(node => ({ ...node, vars: getVisibleChildren(node.vars) }))
|
||||
.filter(node => node.vars.length > 0)
|
||||
.filter((node) => {
|
||||
if (!searchText)
|
||||
return true
|
||||
return node.vars.some(variable => variable.variable.toLowerCase().includes(searchTextLower))
|
||||
|| node.title.toLowerCase().includes(searchTextLower)
|
||||
})
|
||||
.map((node) => {
|
||||
if (!searchText || node.title.toLowerCase().includes(searchTextLower))
|
||||
return node
|
||||
|
||||
return {
|
||||
...node,
|
||||
vars: node.vars.filter(variable => variable.variable.toLowerCase().includes(searchTextLower)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -17,15 +17,19 @@ import {
|
|||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { VAR_SHOW_NAME_MAP } from '@/app/components/workflow/constants'
|
||||
import PickerStructurePanel from '@/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/picker'
|
||||
import { VariableIconWithColor } from '@/app/components/workflow/nodes/_base/components/variable/variable-label'
|
||||
import { VarType } from '@/app/components/workflow/types'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { checkKeys } from '@/utils/var'
|
||||
import { Type } from '../../../llm/types'
|
||||
import ManageInputField from './manage-input-field'
|
||||
import { isSpecialVar, varTypeToStructType } from './utils'
|
||||
import { varTypeToStructType } from './utils'
|
||||
import {
|
||||
filterReferenceVars,
|
||||
getValueSelector,
|
||||
getVariableCategory,
|
||||
getVariableDisplayName,
|
||||
} from './var-reference-vars.helpers'
|
||||
|
||||
type ItemProps = {
|
||||
nodeId: string
|
||||
|
|
@ -84,17 +88,10 @@ const Item: FC<ItemProps> = ({
|
|||
}
|
||||
}, [isFlat, isInCodeGeneratorInstructionEditor, itemData.variable])
|
||||
|
||||
const varName = useMemo(() => {
|
||||
if (VAR_SHOW_NAME_MAP[itemData.variable])
|
||||
return VAR_SHOW_NAME_MAP[itemData.variable]
|
||||
|
||||
if (!isFlat)
|
||||
return itemData.variable
|
||||
if (itemData.variable === 'current')
|
||||
return isInCodeGeneratorInstructionEditor ? 'current_code' : 'current_prompt'
|
||||
|
||||
return itemData.variable
|
||||
}, [isFlat, isInCodeGeneratorInstructionEditor, itemData.variable])
|
||||
const varName = useMemo(
|
||||
() => getVariableDisplayName(itemData.variable, !!isFlat, isInCodeGeneratorInstructionEditor),
|
||||
[isFlat, isInCodeGeneratorInstructionEditor, itemData.variable],
|
||||
)
|
||||
|
||||
const objStructuredOutput: StructuredOutput | null = useMemo(() => {
|
||||
if (!isObj)
|
||||
|
|
@ -150,30 +147,26 @@ const Item: FC<ItemProps> = ({
|
|||
const handleChosen = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
e.nativeEvent.stopImmediatePropagation()
|
||||
if (!isSupportFileVar && isFile)
|
||||
return
|
||||
const valueSelector = getValueSelector({
|
||||
itemData,
|
||||
isFlat,
|
||||
isSupportFileVar,
|
||||
isFile,
|
||||
isSys,
|
||||
isEnv,
|
||||
isChatVar,
|
||||
isRagVariable,
|
||||
nodeId,
|
||||
objPath,
|
||||
})
|
||||
|
||||
if (isFlat) {
|
||||
onChange([itemData.variable], itemData)
|
||||
}
|
||||
else if (isSys || isEnv || isChatVar || isRagVariable) { // system variable | environment variable | conversation variable
|
||||
onChange([...objPath, ...itemData.variable.split('.')], itemData)
|
||||
}
|
||||
else {
|
||||
onChange([nodeId, ...objPath, itemData.variable], itemData)
|
||||
}
|
||||
if (valueSelector)
|
||||
onChange(valueSelector, itemData)
|
||||
}
|
||||
const variableCategory = useMemo(() => {
|
||||
if (isEnv)
|
||||
return 'environment'
|
||||
if (isChatVar)
|
||||
return 'conversation'
|
||||
if (isLoopVar)
|
||||
return 'loop'
|
||||
if (isRagVariable)
|
||||
return 'rag'
|
||||
return 'system'
|
||||
}, [isEnv, isChatVar, isSys, isLoopVar, isRagVariable])
|
||||
const variableCategory = useMemo(
|
||||
() => getVariableCategory({ isEnv, isChatVar, isLoopVar, isRagVariable }),
|
||||
[isEnv, isChatVar, isLoopVar, isRagVariable],
|
||||
)
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
|
|
@ -290,30 +283,7 @@ const VarReferenceVars: FC<Props> = ({
|
|||
}
|
||||
}
|
||||
|
||||
const filteredVars = vars.filter((v) => {
|
||||
const children = v.vars.filter(v => checkKeys([v.variable], false).isValid || isSpecialVar(v.variable.split('.')[0]))
|
||||
return children.length > 0
|
||||
}).filter((node) => {
|
||||
if (!searchText)
|
||||
return node
|
||||
const children = node.vars.filter((v) => {
|
||||
const searchTextLower = searchText.toLowerCase()
|
||||
return v.variable.toLowerCase().includes(searchTextLower) || node.title.toLowerCase().includes(searchTextLower)
|
||||
})
|
||||
return children.length > 0
|
||||
}).map((node) => {
|
||||
let vars = node.vars.filter(v => checkKeys([v.variable], false).isValid || isSpecialVar(v.variable.split('.')[0]))
|
||||
if (searchText) {
|
||||
const searchTextLower = searchText.toLowerCase()
|
||||
if (!node.title.toLowerCase().includes(searchTextLower))
|
||||
vars = vars.filter(v => v.variable.toLowerCase().includes(searchText.toLowerCase()))
|
||||
}
|
||||
|
||||
return {
|
||||
...node,
|
||||
vars,
|
||||
}
|
||||
})
|
||||
const filteredVars = useMemo(() => filterReferenceVars(vars, searchText), [vars, searchText])
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,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']),
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
import type { FC, ReactNode } from 'react'
|
||||
import type { SimpleSubscription } from '@/app/components/plugins/plugin-detail-panel/subscription-list'
|
||||
import type { CustomRunFormProps } from '@/app/components/workflow/nodes/data-source/types'
|
||||
import type { Node } from '@/app/components/workflow/types'
|
||||
import {
|
||||
RiCloseLine,
|
||||
|
|
@ -47,8 +46,6 @@ import {
|
|||
import { useHooksStore } from '@/app/components/workflow/hooks-store'
|
||||
import useInspectVarsCrud from '@/app/components/workflow/hooks/use-inspect-vars-crud'
|
||||
import Split from '@/app/components/workflow/nodes/_base/components/split'
|
||||
import DataSourceBeforeRunForm from '@/app/components/workflow/nodes/data-source/before-run-form'
|
||||
import { DataSourceClassification } from '@/app/components/workflow/nodes/data-source/types'
|
||||
import { useLogs } from '@/app/components/workflow/run/hooks'
|
||||
import SpecialResultPanel from '@/app/components/workflow/run/special-result-panel'
|
||||
import { useStore } from '@/app/components/workflow/store'
|
||||
|
|
@ -63,7 +60,6 @@ import { useModalContext } from '@/context/modal-context'
|
|||
import { useAllBuiltInTools } from '@/service/use-tools'
|
||||
import { useAllTriggerPlugins } from '@/service/use-triggers'
|
||||
import { FlowType } from '@/types/common'
|
||||
import { canFindTool } from '@/utils'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useResizePanel } from '../../hooks/use-resize-panel'
|
||||
import BeforeRunForm from '../before-run-form'
|
||||
|
|
@ -74,28 +70,20 @@ import NextStep from '../next-step'
|
|||
import PanelOperator from '../panel-operator'
|
||||
import RetryOnPanel from '../retry/retry-on-panel'
|
||||
import { DescriptionInput, TitleInput } from '../title-description-input'
|
||||
import {
|
||||
clampNodePanelWidth,
|
||||
getCompressedNodePanelWidth,
|
||||
getCurrentDataSource,
|
||||
getCurrentToolCollection,
|
||||
getCurrentTriggerPlugin,
|
||||
getCustomRunForm,
|
||||
getMaxNodePanelWidth,
|
||||
} from './helpers'
|
||||
import LastRun from './last-run'
|
||||
import useLastRun from './last-run/use-last-run'
|
||||
import Tab, { TabType } from './tab'
|
||||
import { TriggerSubscription } from './trigger-subscription'
|
||||
|
||||
const getCustomRunForm = (params: CustomRunFormProps): React.JSX.Element => {
|
||||
const nodeType = params.payload.type
|
||||
switch (nodeType) {
|
||||
case BlockEnum.DataSource:
|
||||
return <DataSourceBeforeRunForm {...params} />
|
||||
default:
|
||||
return (
|
||||
<div>
|
||||
Custom Run Form:
|
||||
{nodeType}
|
||||
{' '}
|
||||
not found
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type BasePanelProps = {
|
||||
children: ReactNode
|
||||
id: Node['id']
|
||||
|
|
@ -124,17 +112,13 @@ const BasePanel: FC<BasePanelProps> = ({
|
|||
|
||||
const reservedCanvasWidth = 400 // Reserve the minimum visible width for the canvas
|
||||
|
||||
const maxNodePanelWidth = useMemo(() => {
|
||||
if (!workflowCanvasWidth)
|
||||
return 720
|
||||
|
||||
const available = workflowCanvasWidth - (otherPanelWidth || 0) - reservedCanvasWidth
|
||||
return Math.max(available, 400)
|
||||
}, [workflowCanvasWidth, otherPanelWidth])
|
||||
const maxNodePanelWidth = useMemo(
|
||||
() => getMaxNodePanelWidth(workflowCanvasWidth, otherPanelWidth, reservedCanvasWidth),
|
||||
[workflowCanvasWidth, otherPanelWidth],
|
||||
)
|
||||
|
||||
const updateNodePanelWidth = useCallback((width: number, source: 'user' | 'system' = 'user') => {
|
||||
// Ensure the width is within the min and max range
|
||||
const newValue = Math.max(400, Math.min(width, maxNodePanelWidth))
|
||||
const newValue = clampNodePanelWidth(width, maxNodePanelWidth)
|
||||
|
||||
if (source === 'user')
|
||||
localStorage.setItem('workflow-node-panel-width', `${newValue}`)
|
||||
|
|
@ -162,15 +146,9 @@ const BasePanel: FC<BasePanelProps> = ({
|
|||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!workflowCanvasWidth)
|
||||
return
|
||||
|
||||
// If the total width of the three exceeds the canvas, shrink the node panel to the available range (at least 400px)
|
||||
const total = nodePanelWidth + otherPanelWidth + reservedCanvasWidth
|
||||
if (total > workflowCanvasWidth) {
|
||||
const target = Math.max(workflowCanvasWidth - otherPanelWidth - reservedCanvasWidth, 400)
|
||||
debounceUpdate(target)
|
||||
}
|
||||
const compressedWidth = getCompressedNodePanelWidth(nodePanelWidth, workflowCanvasWidth, otherPanelWidth, reservedCanvasWidth)
|
||||
if (compressedWidth !== undefined)
|
||||
debounceUpdate(compressedWidth)
|
||||
}, [nodePanelWidth, otherPanelWidth, workflowCanvasWidth, debounceUpdate])
|
||||
|
||||
const { handleNodeSelect } = useNodesInteractions()
|
||||
|
|
@ -284,21 +262,17 @@ const BasePanel: FC<BasePanelProps> = ({
|
|||
|
||||
const storeBuildInTools = useStore(s => s.buildInTools)
|
||||
const { data: buildInTools } = useAllBuiltInTools()
|
||||
const currToolCollection = useMemo(() => {
|
||||
const candidates = buildInTools ?? storeBuildInTools
|
||||
return candidates?.find(item => canFindTool(item.id, data.provider_id))
|
||||
}, [buildInTools, storeBuildInTools, data.provider_id])
|
||||
const currToolCollection = useMemo(
|
||||
() => getCurrentToolCollection(buildInTools, storeBuildInTools, data.provider_id),
|
||||
[buildInTools, storeBuildInTools, data.provider_id],
|
||||
)
|
||||
const needsToolAuth = useMemo(() => {
|
||||
return data.type === BlockEnum.Tool && currToolCollection?.allow_delete
|
||||
}, [data.type, currToolCollection?.allow_delete])
|
||||
|
||||
// only fetch trigger plugins when the node is a trigger plugin
|
||||
const { data: triggerPlugins = [] } = useAllTriggerPlugins(data.type === BlockEnum.TriggerPlugin)
|
||||
const currentTriggerPlugin = useMemo(() => {
|
||||
if (data.type !== BlockEnum.TriggerPlugin || !data.plugin_id || !triggerPlugins?.length)
|
||||
return undefined
|
||||
return triggerPlugins?.find(p => p.plugin_id === data.plugin_id)
|
||||
}, [data.type, data.plugin_id, triggerPlugins])
|
||||
const currentTriggerPlugin = useMemo(() => getCurrentTriggerPlugin(data, triggerPlugins), [data, triggerPlugins])
|
||||
const { setDetail } = usePluginStore()
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -321,10 +295,7 @@ const BasePanel: FC<BasePanelProps> = ({
|
|||
|
||||
const dataSourceList = useStore(s => s.dataSourceList)
|
||||
|
||||
const currentDataSource = useMemo(() => {
|
||||
if (data.type === BlockEnum.DataSource && data.provider_type !== DataSourceClassification.localFile)
|
||||
return dataSourceList?.find(item => item.plugin_id === data.plugin_id)
|
||||
}, [data.type, data.provider_type, data.plugin_id, dataSourceList])
|
||||
const currentDataSource = useMemo(() => getCurrentDataSource(data, dataSourceList), [data, dataSourceList])
|
||||
|
||||
const handleAuthorizationItemClick = useCallback((credential_id: string) => {
|
||||
handleNodeDataUpdateWithSyncDraft({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,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>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import type { NodeProps } from '@/app/components/workflow/types'
|
||||
import { BlockEnum, isTriggerNode, NodeRunningStatus } from '@/app/components/workflow/types'
|
||||
|
||||
export const getNodeStatusBorders = (
|
||||
runningStatus: NodeRunningStatus | undefined,
|
||||
hasVarValue: boolean,
|
||||
showSelectedBorder: boolean,
|
||||
) => {
|
||||
return {
|
||||
showRunningBorder: (runningStatus === NodeRunningStatus.Running || runningStatus === NodeRunningStatus.Paused) && !showSelectedBorder,
|
||||
showSuccessBorder: (runningStatus === NodeRunningStatus.Succeeded || (hasVarValue && !runningStatus)) && !showSelectedBorder,
|
||||
showFailedBorder: runningStatus === NodeRunningStatus.Failed && !showSelectedBorder,
|
||||
showExceptionBorder: runningStatus === NodeRunningStatus.Exception && !showSelectedBorder,
|
||||
}
|
||||
}
|
||||
|
||||
export const getLoopIndexTextKey = (runningStatus: NodeRunningStatus | undefined) => {
|
||||
if (runningStatus === NodeRunningStatus.Running)
|
||||
return 'nodes.loop.currentLoopCount'
|
||||
if (runningStatus === NodeRunningStatus.Succeeded || runningStatus === NodeRunningStatus.Failed)
|
||||
return 'nodes.loop.totalLoopCount'
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const isEntryWorkflowNode = (type: NodeProps['data']['type']) => {
|
||||
return isTriggerNode(type) || type === BlockEnum.Start
|
||||
}
|
||||
|
||||
export const isContainerNode = (type: NodeProps['data']['type']) => {
|
||||
return type === BlockEnum.Iteration || type === BlockEnum.Loop
|
||||
}
|
||||
|
|
@ -2,17 +2,14 @@ import type {
|
|||
FC,
|
||||
ReactElement,
|
||||
} from 'react'
|
||||
import type { IterationNodeType } from '@/app/components/workflow/nodes/iteration/types'
|
||||
import type { NodeProps } from '@/app/components/workflow/types'
|
||||
import {
|
||||
cloneElement,
|
||||
memo,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import BlockIcon from '@/app/components/workflow/block-icon'
|
||||
import { ToolTypeEnum } from '@/app/components/workflow/block-selector/types'
|
||||
import { useNodesReadOnly, useToolIcon } from '@/app/components/workflow/hooks'
|
||||
|
|
@ -23,7 +20,6 @@ import { useNodeLoopInteractions } from '@/app/components/workflow/nodes/loop/us
|
|||
import CopyID from '@/app/components/workflow/nodes/tool/components/copy-id'
|
||||
import {
|
||||
BlockEnum,
|
||||
isTriggerNode,
|
||||
NodeRunningStatus,
|
||||
} from '@/app/components/workflow/types'
|
||||
import { hasErrorHandleNode, hasRetryNode } from '@/app/components/workflow/utils'
|
||||
|
|
@ -38,6 +34,18 @@ import {
|
|||
} from './components/node-handle'
|
||||
import NodeResizer from './components/node-resizer'
|
||||
import RetryOnNode from './components/retry/retry-on-node'
|
||||
import {
|
||||
NodeBody,
|
||||
NodeDescription,
|
||||
NodeHeaderMeta,
|
||||
} from './node-sections'
|
||||
import {
|
||||
getLoopIndexTextKey,
|
||||
getNodeStatusBorders,
|
||||
isContainerNode,
|
||||
isEntryWorkflowNode,
|
||||
} from './node.helpers'
|
||||
import useNodeResizeObserver from './use-node-resize-observer'
|
||||
|
||||
type NodeChildProps = {
|
||||
id: string
|
||||
|
|
@ -65,59 +73,34 @@ const BaseNode: FC<BaseNodeProps> = ({
|
|||
const { shouldDim: pluginDimmed, isChecking: pluginIsChecking, isMissing: pluginIsMissing, canInstall: pluginCanInstall, uniqueIdentifier: pluginUniqueIdentifier } = useNodePluginInstallation(data)
|
||||
const pluginInstallLocked = !pluginIsChecking && pluginIsMissing && pluginCanInstall && Boolean(pluginUniqueIdentifier)
|
||||
|
||||
useEffect(() => {
|
||||
if (nodeRef.current && data.selected && data.isInIteration) {
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
handleNodeIterationChildSizeChange(id)
|
||||
})
|
||||
useNodeResizeObserver({
|
||||
enabled: Boolean(data.selected && data.isInIteration),
|
||||
nodeRef,
|
||||
onResize: () => handleNodeIterationChildSizeChange(id),
|
||||
})
|
||||
|
||||
resizeObserver.observe(nodeRef.current)
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
}
|
||||
}, [data.isInIteration, data.selected, id, handleNodeIterationChildSizeChange])
|
||||
|
||||
useEffect(() => {
|
||||
if (nodeRef.current && data.selected && data.isInLoop) {
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
handleNodeLoopChildSizeChange(id)
|
||||
})
|
||||
|
||||
resizeObserver.observe(nodeRef.current)
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
}
|
||||
}, [data.isInLoop, data.selected, id, handleNodeLoopChildSizeChange])
|
||||
useNodeResizeObserver({
|
||||
enabled: Boolean(data.selected && data.isInLoop),
|
||||
nodeRef,
|
||||
onResize: () => handleNodeLoopChildSizeChange(id),
|
||||
})
|
||||
|
||||
const { hasNodeInspectVars } = useInspectVarsCrud()
|
||||
const isLoading = data._runningStatus === NodeRunningStatus.Running || data._singleRunningStatus === NodeRunningStatus.Running
|
||||
const hasVarValue = hasNodeInspectVars(id)
|
||||
const showSelectedBorder = data.selected || data._isBundled || data._isEntering
|
||||
const showSelectedBorder = Boolean(data.selected || data._isBundled || data._isEntering)
|
||||
const {
|
||||
showRunningBorder,
|
||||
showSuccessBorder,
|
||||
showFailedBorder,
|
||||
showExceptionBorder,
|
||||
} = useMemo(() => {
|
||||
return {
|
||||
showRunningBorder: (data._runningStatus === NodeRunningStatus.Running || data._runningStatus === NodeRunningStatus.Paused) && !showSelectedBorder,
|
||||
showSuccessBorder: (data._runningStatus === NodeRunningStatus.Succeeded || (hasVarValue && !data._runningStatus)) && !showSelectedBorder,
|
||||
showFailedBorder: data._runningStatus === NodeRunningStatus.Failed && !showSelectedBorder,
|
||||
showExceptionBorder: data._runningStatus === NodeRunningStatus.Exception && !showSelectedBorder,
|
||||
}
|
||||
}, [data._runningStatus, hasVarValue, showSelectedBorder])
|
||||
} = useMemo(() => getNodeStatusBorders(data._runningStatus, hasVarValue, showSelectedBorder), [data._runningStatus, hasVarValue, showSelectedBorder])
|
||||
|
||||
const LoopIndex = useMemo(() => {
|
||||
let text = ''
|
||||
|
||||
if (data._runningStatus === NodeRunningStatus.Running)
|
||||
text = t('nodes.loop.currentLoopCount', { ns: 'workflow', count: data._loopIndex })
|
||||
if (data._runningStatus === NodeRunningStatus.Succeeded || data._runningStatus === NodeRunningStatus.Failed)
|
||||
text = t('nodes.loop.totalLoopCount', { ns: 'workflow', count: data._loopIndex })
|
||||
const translationKey = getLoopIndexTextKey(data._runningStatus)
|
||||
const text = translationKey
|
||||
? t(translationKey, { ns: 'workflow', count: data._loopIndex })
|
||||
: ''
|
||||
|
||||
if (text) {
|
||||
return (
|
||||
|
|
@ -145,8 +128,8 @@ const BaseNode: FC<BaseNodeProps> = ({
|
|||
)}
|
||||
ref={nodeRef}
|
||||
style={{
|
||||
width: (data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) ? data.width : 'auto',
|
||||
height: (data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) ? data.height : 'auto',
|
||||
width: isContainerNode(data.type) ? data.width : 'auto',
|
||||
height: isContainerNode(data.type) ? data.height : 'auto',
|
||||
}}
|
||||
>
|
||||
{(data._dimmed || pluginDimmed || pluginInstallLocked) && (
|
||||
|
|
@ -174,8 +157,8 @@ const BaseNode: FC<BaseNodeProps> = ({
|
|||
className={cn(
|
||||
'group relative pb-1 shadow-xs',
|
||||
'rounded-[15px] border border-transparent',
|
||||
(data.type !== BlockEnum.Iteration && data.type !== BlockEnum.Loop) && 'w-[240px] bg-workflow-block-bg',
|
||||
(data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) && 'flex h-full w-full flex-col border-workflow-block-border bg-workflow-block-bg-transparent',
|
||||
!isContainerNode(data.type) && 'w-[240px] bg-workflow-block-bg',
|
||||
isContainerNode(data.type) && 'flex h-full w-full flex-col border-workflow-block-border bg-workflow-block-bg-transparent',
|
||||
!data._runningStatus && 'hover:shadow-lg',
|
||||
showRunningBorder && '!border-state-accent-solid',
|
||||
showSuccessBorder && '!border-state-success-solid',
|
||||
|
|
@ -239,7 +222,7 @@ const BaseNode: FC<BaseNodeProps> = ({
|
|||
}
|
||||
<div className={cn(
|
||||
'flex items-center rounded-t-2xl px-3 pb-2 pt-3',
|
||||
(data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) && 'bg-transparent',
|
||||
isContainerNode(data.type) && 'bg-transparent',
|
||||
)}
|
||||
>
|
||||
<BlockIcon
|
||||
|
|
@ -255,72 +238,19 @@ const BaseNode: FC<BaseNodeProps> = ({
|
|||
<div>
|
||||
{data.title}
|
||||
</div>
|
||||
{
|
||||
data.type === BlockEnum.Iteration && (data as IterationNodeType).is_parallel && (
|
||||
<Tooltip popupContent={(
|
||||
<div className="w-[180px]">
|
||||
<div className="font-extrabold">
|
||||
{t('nodes.iteration.parallelModeEnableTitle', { ns: 'workflow' })}
|
||||
</div>
|
||||
{t('nodes.iteration.parallelModeEnableDesc', { ns: 'workflow' })}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="ml-1 flex items-center justify-center rounded-[5px] border-[1px] border-text-warning px-[5px] py-[3px] text-text-warning system-2xs-medium-uppercase">
|
||||
{t('nodes.iteration.parallelModeUpper', { ns: 'workflow' })}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{
|
||||
!!(data._iterationLength && data._iterationIndex && data._runningStatus === NodeRunningStatus.Running) && (
|
||||
<div className="mr-1.5 text-xs font-medium text-text-accent">
|
||||
{data._iterationIndex > data._iterationLength ? data._iterationLength : data._iterationIndex}
|
||||
/
|
||||
{data._iterationLength}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
!!(data.type === BlockEnum.Loop && data._loopIndex) && LoopIndex
|
||||
}
|
||||
{
|
||||
isLoading && <span className="i-ri-loader-2-line h-3.5 w-3.5 animate-spin text-text-accent" />
|
||||
}
|
||||
{
|
||||
!isLoading && data._runningStatus === NodeRunningStatus.Failed && (
|
||||
<span className="i-ri-error-warning-fill h-3.5 w-3.5 text-text-destructive" />
|
||||
)
|
||||
}
|
||||
{
|
||||
!isLoading && data._runningStatus === NodeRunningStatus.Exception && (
|
||||
<span className="i-ri-alert-fill h-3.5 w-3.5 text-text-warning-secondary" />
|
||||
)
|
||||
}
|
||||
{
|
||||
!isLoading && (data._runningStatus === NodeRunningStatus.Succeeded || (hasVarValue && !data._runningStatus)) && (
|
||||
<span className="i-ri-checkbox-circle-fill h-3.5 w-3.5 text-text-success" />
|
||||
)
|
||||
}
|
||||
{
|
||||
!isLoading && data._runningStatus === NodeRunningStatus.Paused && (
|
||||
<span className="i-ri-pause-circle-fill h-3.5 w-3.5 text-text-warning-secondary" />
|
||||
)
|
||||
}
|
||||
<NodeHeaderMeta
|
||||
data={data}
|
||||
hasVarValue={hasVarValue}
|
||||
isLoading={isLoading}
|
||||
loopIndex={LoopIndex}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
{
|
||||
data.type !== BlockEnum.Iteration && data.type !== BlockEnum.Loop && (
|
||||
cloneElement(children, { id, data } as any)
|
||||
)
|
||||
}
|
||||
{
|
||||
(data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) && (
|
||||
<div className="grow pb-1 pl-1 pr-1">
|
||||
{cloneElement(children, { id, data } as any)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<NodeBody
|
||||
data={data}
|
||||
child={cloneElement(children, { id, data } as any)}
|
||||
/>
|
||||
{
|
||||
hasRetryNode(data.type) && (
|
||||
<RetryOnNode
|
||||
|
|
@ -337,13 +267,7 @@ const BaseNode: FC<BaseNodeProps> = ({
|
|||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!!(data.desc && data.type !== BlockEnum.Iteration && data.type !== BlockEnum.Loop) && (
|
||||
<div className="whitespace-pre-line break-words px-3 pb-2 pt-1 text-text-tertiary system-xs-regular">
|
||||
{data.desc}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<NodeDescription data={data} />
|
||||
{data.type === BlockEnum.Tool && data.provider_type === ToolTypeEnum.MCP && (
|
||||
<div className="px-3 pb-2">
|
||||
<CopyID content={data.provider_id || ''} />
|
||||
|
|
@ -354,7 +278,7 @@ const BaseNode: FC<BaseNodeProps> = ({
|
|||
)
|
||||
|
||||
const isStartNode = data.type === BlockEnum.Start
|
||||
const isEntryNode = isTriggerNode(data.type as any) || isStartNode
|
||||
const isEntryNode = isEntryWorkflowNode(data.type)
|
||||
|
||||
return isEntryNode
|
||||
? (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
import { useEffect } from 'react'
|
||||
|
||||
type ResizeObserverParams = {
|
||||
enabled: boolean
|
||||
nodeRef: React.RefObject<HTMLDivElement | null>
|
||||
onResize: () => void
|
||||
}
|
||||
|
||||
const useNodeResizeObserver = ({
|
||||
enabled,
|
||||
nodeRef,
|
||||
onResize,
|
||||
}: ResizeObserverParams) => {
|
||||
useEffect(() => {
|
||||
if (!enabled || !nodeRef.current)
|
||||
return
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
onResize()
|
||||
})
|
||||
|
||||
resizeObserver.observe(nodeRef.current)
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
}, [enabled, nodeRef, onResize])
|
||||
}
|
||||
|
||||
export default useNodeResizeObserver
|
||||
|
|
@ -0,0 +1,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 },
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in New Issue