From 6633f5aef8bfcc83802166a42a8575f99782bb26 Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Tue, 24 Mar 2026 16:16:54 +0800 Subject: [PATCH] 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. --- .../update-dsl-modal.helpers.spec.ts | 79 ++ .../__tests__/update-dsl-modal.spec.tsx | 373 ++++++++++ .../_base/__tests__/node-sections.spec.tsx | 41 ++ .../_base/__tests__/node.helpers.spec.ts | 34 + .../nodes/_base/__tests__/node.spec.tsx | 187 +++++ .../use-node-resize-observer.spec.tsx | 55 ++ .../before-run-form/__tests__/helpers.spec.ts | 115 +++ .../before-run-form/__tests__/index.spec.tsx | 226 ++++++ .../components/before-run-form/helpers.ts | 105 +++ .../components/before-run-form/index.tsx | 95 +-- .../var-reference-vars.helpers.spec.ts | 84 +++ .../__tests__/var-reference-vars.spec.tsx | 226 ++++++ .../variable/var-reference-vars.helpers.ts | 100 +++ .../variable/var-reference-vars.tsx | 90 +-- .../workflow-panel/__tests__/helpers.spec.tsx | 90 +++ .../workflow-panel/__tests__/index.spec.tsx | 692 +++++++++++++++--- .../components/workflow-panel/helpers.tsx | 80 ++ .../_base/components/workflow-panel/index.tsx | 75 +- .../workflow/nodes/_base/node-sections.tsx | 94 +++ .../workflow/nodes/_base/node.helpers.tsx | 32 + .../components/workflow/nodes/_base/node.tsx | 168 ++--- .../nodes/_base/use-node-resize-observer.ts | 30 + .../workflow/update-dsl-modal.helpers.ts | 110 +++ .../components/workflow/update-dsl-modal.tsx | 130 +--- .../__tests__/value-content-sections.spec.tsx | 143 ++++ .../value-content.helpers.branches.spec.ts | 48 ++ .../__tests__/value-content.helpers.spec.ts | 80 ++ .../__tests__/value-content.spec.tsx | 410 +++++++++++ .../value-content-sections.tsx | 190 +++++ .../variable-inspect/value-content.helpers.ts | 77 ++ .../variable-inspect/value-content.tsx | 239 ++---- 31 files changed, 3811 insertions(+), 687 deletions(-) create mode 100644 web/app/components/workflow/__tests__/update-dsl-modal.helpers.spec.ts create mode 100644 web/app/components/workflow/__tests__/update-dsl-modal.spec.tsx create mode 100644 web/app/components/workflow/nodes/_base/__tests__/node-sections.spec.tsx create mode 100644 web/app/components/workflow/nodes/_base/__tests__/node.helpers.spec.ts create mode 100644 web/app/components/workflow/nodes/_base/__tests__/node.spec.tsx create mode 100644 web/app/components/workflow/nodes/_base/__tests__/use-node-resize-observer.spec.tsx create mode 100644 web/app/components/workflow/nodes/_base/components/before-run-form/__tests__/helpers.spec.ts create mode 100644 web/app/components/workflow/nodes/_base/components/before-run-form/__tests__/index.spec.tsx create mode 100644 web/app/components/workflow/nodes/_base/components/before-run-form/helpers.ts create mode 100644 web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-vars.helpers.spec.ts create mode 100644 web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-vars.spec.tsx create mode 100644 web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.helpers.ts create mode 100644 web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/helpers.spec.tsx create mode 100644 web/app/components/workflow/nodes/_base/components/workflow-panel/helpers.tsx create mode 100644 web/app/components/workflow/nodes/_base/node-sections.tsx create mode 100644 web/app/components/workflow/nodes/_base/node.helpers.tsx create mode 100644 web/app/components/workflow/nodes/_base/use-node-resize-observer.ts create mode 100644 web/app/components/workflow/update-dsl-modal.helpers.ts create mode 100644 web/app/components/workflow/variable-inspect/__tests__/value-content-sections.spec.tsx create mode 100644 web/app/components/workflow/variable-inspect/__tests__/value-content.helpers.branches.spec.ts create mode 100644 web/app/components/workflow/variable-inspect/__tests__/value-content.helpers.spec.ts create mode 100644 web/app/components/workflow/variable-inspect/__tests__/value-content.spec.tsx create mode 100644 web/app/components/workflow/variable-inspect/value-content-sections.tsx create mode 100644 web/app/components/workflow/variable-inspect/value-content.helpers.ts diff --git a/web/app/components/workflow/__tests__/update-dsl-modal.helpers.spec.ts b/web/app/components/workflow/__tests__/update-dsl-modal.helpers.spec.ts new file mode 100644 index 0000000000..ac1cf67970 --- /dev/null +++ b/web/app/components/workflow/__tests__/update-dsl-modal.helpers.spec.ts @@ -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 }) + }) + }) +}) diff --git a/web/app/components/workflow/__tests__/update-dsl-modal.spec.tsx b/web/app/components/workflow/__tests__/update-dsl-modal.spec.tsx new file mode 100644 index 0000000000..a85291128b --- /dev/null +++ b/web/app/components/workflow/__tests__/update-dsl-modal.spec.tsx @@ -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) => 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 + 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 }) => ( + 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 + + return render( + + + + + , + ) + } + + 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 + 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', + })) + }) + }) +}) diff --git a/web/app/components/workflow/nodes/_base/__tests__/node-sections.spec.tsx b/web/app/components/workflow/nodes/_base/__tests__/node-sections.spec.tsx new file mode 100644 index 0000000000..efa54ab150 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/__tests__/node-sections.spec.tsx @@ -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( + loop-index} + 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( + body-content} + />, + ) + + expect(screen.getByText('body-content').parentElement).toHaveClass('grow') + + rerender() + expect(screen.getByText('node description')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/workflow/nodes/_base/__tests__/node.helpers.spec.ts b/web/app/components/workflow/nodes/_base/__tests__/node.helpers.spec.ts new file mode 100644 index 0000000000..78e1f938c5 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/__tests__/node.helpers.spec.ts @@ -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) + }) +}) diff --git a/web/app/components/workflow/nodes/_base/__tests__/node.spec.tsx b/web/app/components/workflow/nodes/_base/__tests__/node.spec.tsx new file mode 100644 index 0000000000..0298a05848 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/__tests__/node.spec.tsx @@ -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: () =>
, +})) +vi.mock('../components/entry-node-container', () => ({ + __esModule: true, + StartNodeTypeEnum: { Start: 'start', Trigger: 'trigger' }, + default: ({ children }: PropsWithChildren) =>
{children}
, +})) +vi.mock('../components/error-handle/error-handle-on-node', () => ({ + default: () =>
, +})) +vi.mock('../components/node-control', () => ({ + default: () =>
, +})) +vi.mock('../components/node-handle', () => ({ + NodeSourceHandle: () =>
, + NodeTargetHandle: () =>
, +})) +vi.mock('../components/node-resizer', () => ({ + default: () =>
, +})) +vi.mock('../components/retry/retry-on-node', () => ({ + default: () =>
, +})) +vi.mock('@/app/components/workflow/block-icon', () => ({ + default: () =>
, +})) +vi.mock('@/app/components/workflow/nodes/tool/components/copy-id', () => ({ + default: ({ content }: { content: string }) =>
{content}
, +})) + +const createData = (overrides: Record = {}) => ({ + 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) => 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( + +
Body
+
, + ) + + 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( + +
Body
+
, + ) + + 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( + +
Body
+
, + ) + + const overlay = screen.getByTestId('workflow-node-install-overlay') + expect(overlay).toBeInTheDocument() + fireEvent.click(overlay) + }) + + it('should render running status indicators for loop nodes', () => { + renderWorkflowComponent( + +
Loop body
+
, + ) + + 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( + +
Iteration body
+
, + ) + + expect(screen.getByTestId('node-resizer')).toBeInTheDocument() + expect(screen.getByTestId('workflow-node-install-overlay')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/workflow/nodes/_base/__tests__/use-node-resize-observer.spec.tsx b/web/app/components/workflow/nodes/_base/__tests__/use-node-resize-observer.spec.tsx new file mode 100644 index 0000000000..9ee377be4d --- /dev/null +++ b/web/app/components/workflow/nodes/_base/__tests__/use-node-resize-observer.spec.tsx @@ -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() + }) +}) diff --git a/web/app/components/workflow/nodes/_base/components/before-run-form/__tests__/helpers.spec.ts b/web/app/components/workflow/nodes/_base/components/before-run-form/__tests__/helpers.spec.ts new file mode 100644 index 0000000000..f4d456b6f6 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/before-run-form/__tests__/helpers.spec.ts @@ -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[0][number] + +describe('before-run-form helpers', () => { + const createValues = (values: Record) => values as unknown as Record + const createInput = (input: Partial): InputVar => ({ + variable: 'field', + label: 'Field', + type: InputVarType.textInput, + required: false, + ...input, + }) + const createForm = (form: Partial): 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) => `${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) + }) +}) diff --git a/web/app/components/workflow/nodes/_base/components/before-run-form/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/_base/components/before-run-form/__tests__/index.spec.tsx new file mode 100644 index 0000000000..6ed8210721 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/before-run-form/__tests__/index.spec.tsx @@ -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 }) =>
{Object.keys(values).join(',')}
, +})) + +vi.mock('../panel-wrap', () => ({ + default: ({ children, nodeName }: { children: React.ReactNode, nodeName: string }) => ( +
+
{nodeName}
+ {children} +
+ ), +})) + +vi.mock('@/app/components/workflow/nodes/human-input/components/single-run-form', () => ({ + default: ({ onSubmit, handleBack }: { onSubmit: (data: Record) => void, handleBack?: () => void }) => ( +
+
single-run-form
+ + +
+ ), +})) + +describe('BeforeRunForm', () => { + const createForm = (form: Partial): FormProps => ({ + inputs: [], + values: {}, + onChange: vi.fn(), + ...form, + }) + const createProps = (props: Partial): 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'workflow.singleRun.startRun' })) + + expect(notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + type: 'error', + })) + }) +}) diff --git a/web/app/components/workflow/nodes/_base/components/before-run-form/helpers.ts b/web/app/components/workflow/nodes/_base/components/before-run-form/helpers.ts new file mode 100644 index 0000000000..3e5cdf9a74 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/before-run-form/helpers.ts @@ -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[], + t: (key: string, options?: Record) => 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 = {} + 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 +} diff --git a/web/app/components/workflow/nodes/_base/components/before-run-form/index.tsx b/web/app/components/workflow/nodes/_base/components/before-run-form/index.tsx index 4b7f65bcc1..0e414f70a5 100644 --- a/web/app/components/workflow/nodes/_base/components/before-run-form/index.tsx +++ b/web/app/components/workflow/nodes/_base/components/before-run-form/index.tsx @@ -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 -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 = ({ nodeName, nodeType, @@ -88,43 +66,10 @@ const BeforeRunForm: FC = ({ 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 = ({ return } - const submitData: Record = {} - 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 = ({ 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 ( diff --git a/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-vars.helpers.spec.ts b/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-vars.helpers.spec.ts new file mode 100644 index 0000000000..5b00464be1 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-vars.helpers.spec.ts @@ -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' })]) + }) +}) diff --git a/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-vars.spec.tsx b/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-vars.spec.tsx new file mode 100644 index 0000000000..eca09b88f6 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-vars.spec.tsx @@ -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 + }) => ( +
+ + +
+ ), +})) + +vi.mock('../manage-input-field', () => ({ + default: ({ onManage }: { onManage: () => void }) => , +})) + +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( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + fireEvent.blur(screen.getByPlaceholderText('workflow.common.searchVar')) + expect(onBlur).toHaveBeenCalledTimes(1) + + fireEvent.click(screen.getByText('asset')) + expect(onChange).not.toHaveBeenCalled() + }) +}) diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.helpers.ts b/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.helpers.ts new file mode 100644 index 0000000000..36418a298d --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.helpers.ts @@ -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)), + } + }) +} diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx b/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx index 34d2b5e4f9..c85ffc3f33 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx @@ -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 = ({ } }, [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 = ({ 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 ( = ({ } } - 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 ( <> diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/helpers.spec.tsx b/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/helpers.spec.tsx new file mode 100644 index 0000000000..5eef8d3fa4 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/helpers.spec.tsx @@ -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>) => tools as ToolWithProvider[] + const asTriggerList = (triggers: Array>) => triggers as TriggerWithProvider[] + const asNodeData = (data: Partial) => data as Node['data'] + const createCustomRunFormProps = (payload: Partial): 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']), + }, + }) + }) + }) +}) diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/index.spec.tsx index 5f718153b5..ab276fa919 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/index.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/index.spec.tsx @@ -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> = [] -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 => ({ + 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() + 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) =>
{children}
, + AuthorizedInNode: ({ onAuthorizationItemClick }: { onAuthorizationItemClick?: (credentialId: string) => void }) => ( + + ), + PluginAuthInDataSourceNode: ({ children, onJumpToDataSourcePage }: PropsWithChildren<{ onJumpToDataSourcePage?: () => void }>) => ( +
+ + {children} +
+ ), + AuthorizedInDataSourceNode: ({ onJumpToDataSourcePage }: { onJumpToDataSourcePage?: () => void }) => ( + + ), + AuthCategory: { tool: 'tool' }, +})) + +vi.mock('@/app/components/plugins/readme-panel/entrance', () => ({ + ReadmeEntrance: () =>
readme-entrance
, +})) + +vi.mock('@/app/components/workflow/block-icon', () => ({ + default: () =>
block-icon
, +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/split', () => ({ + default: () =>
split
, +})) + +vi.mock('@/app/components/workflow/nodes/data-source/before-run-form', () => ({ + default: () =>
data-source-before-run-form
, +})) + +vi.mock('@/app/components/workflow/run/special-result-panel', () => ({ + default: () =>
special-result-panel
, +})) + +vi.mock('../before-run-form', () => ({ + default: () =>
before-run-form
, +})) + +vi.mock('../before-run-form/panel-wrap', () => ({ + default: ({ children }: PropsWithChildren<{ nodeName: string, onHide: () => void }>) =>
{children}
, +})) + +vi.mock('../error-handle/error-handle-on-panel', () => ({ + default: () =>
error-handle-panel
, +})) + +vi.mock('../help-link', () => ({ + default: () =>
help-link
, +})) + +vi.mock('../next-step', () => ({ + default: () =>
next-step
, +})) + +vi.mock('../panel-operator', () => ({ + default: () =>
panel-operator
, +})) + +vi.mock('../retry/retry-on-panel', () => ({ + default: () =>
retry-panel
, +})) + +vi.mock('../title-description-input', () => ({ + TitleInput: ({ value, onBlur }: { value: string, onBlur: (value: string) => void }) => ( + onBlur(event.target.value)} /> + ), + DescriptionInput: ({ value, onChange }: { value: string, onChange: (value: string) => void }) => ( +