From 46747993d49c693613a232fa95a791f038c1bce2 Mon Sep 17 00:00:00 2001 From: JzoNg Date: Fri, 24 Apr 2026 10:27:55 +0800 Subject: [PATCH] fix(web): file_list type --- .../__tests__/type-select.spec.tsx | 6 +- .../workflow-app/__tests__/utils.spec.ts | 59 ++++++++++++++++++- .../hooks/use-nodes-sync-draft.ts | 5 +- .../workflow-app/hooks/use-workflow-init.ts | 9 ++- web/app/components/workflow-app/utils.ts | 55 ++++++++++++++++- 5 files changed, 127 insertions(+), 7 deletions(-) diff --git a/web/app/components/app/configuration/config-var/config-modal/__tests__/type-select.spec.tsx b/web/app/components/app/configuration/config-var/config-modal/__tests__/type-select.spec.tsx index 1d50350229..c4f0b5b6d6 100644 --- a/web/app/components/app/configuration/config-var/config-modal/__tests__/type-select.spec.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/__tests__/type-select.spec.tsx @@ -24,7 +24,7 @@ describe('TypeSelector', () => { ) await user.click(screen.getByRole('combobox')) - const [, numberOption] = await screen.findAllByRole('option') + const numberOption = await screen.findByRole('option', { name: 'Number' }) await user.click(numberOption) expect(onSelect).toHaveBeenCalledWith({ value: 'number', name: 'Number' }) @@ -46,8 +46,10 @@ describe('TypeSelector', () => { await user.click(screen.getByRole('combobox')) - const [, numberOption] = await screen.findAllByRole('option') + const numberOption = await screen.findByRole('option', { name: 'Number' }) const popup = numberOption.closest('[data-side]') + if (!popup) + throw new Error('Expected popup container to exist') expect(popup).toHaveClass('w-(--anchor-width)') }) diff --git a/web/app/components/workflow-app/__tests__/utils.spec.ts b/web/app/components/workflow-app/__tests__/utils.spec.ts index c8a9fffeec..1b47ec9f3c 100644 --- a/web/app/components/workflow-app/__tests__/utils.spec.ts +++ b/web/app/components/workflow-app/__tests__/utils.spec.ts @@ -1,11 +1,23 @@ -import { SupportUploadFileTypes } from '@/app/components/workflow/types' +import type { Node } from '@/app/components/workflow/types' +import { BlockEnum, SupportUploadFileTypes } from '@/app/components/workflow/types' import { TransferMethod } from '@/types/app' import { buildInitialFeatures, buildTriggerStatusMap, coerceReplayUserInputs, + normalizeWorkflowNodesForBackend, + normalizeWorkflowNodesForFrontend, } from '../utils' +type HumanInputTestField = { + type: string + output_variable_name: string +} + +type HumanInputTestNode = Node<{ + inputs: HumanInputTestField[] +}> + describe('workflow-app utils', () => { it('should map trigger statuses to enabled and disabled states', () => { expect(buildTriggerStatusMap([ @@ -38,6 +50,51 @@ describe('workflow-app utils', () => { expect(coerceReplayUserInputs(null)).toBeNull() }) + it('should normalize human-input multi-file types between frontend and backend payloads', () => { + const nodes: HumanInputTestNode[] = [ + { + id: 'node-1', + type: 'custom', + position: { x: 0, y: 0 }, + data: { + title: 'Human Input', + desc: '', + type: BlockEnum.HumanInput, + inputs: [ + { type: 'paragraph', output_variable_name: 'summary' }, + { type: 'file-list', output_variable_name: 'attachments' }, + ], + }, + }, + ] + + const backendNodes = normalizeWorkflowNodesForBackend(nodes) as HumanInputTestNode[] + expect(backendNodes[0]!.data.inputs).toEqual([ + { type: 'paragraph', output_variable_name: 'summary' }, + { type: 'file_list', output_variable_name: 'attachments' }, + ]) + + const frontendPayloadNodes: HumanInputTestNode[] = [ + { + ...nodes[0]!, + data: { + ...nodes[0]!.data, + inputs: [ + { type: 'paragraph', output_variable_name: 'summary' }, + { type: 'file_list', output_variable_name: 'attachments' }, + ], + }, + }, + ] + + const frontendNodes = normalizeWorkflowNodesForFrontend(frontendPayloadNodes) as HumanInputTestNode[] + + expect(frontendNodes[0]!.data.inputs).toEqual([ + { type: 'paragraph', output_variable_name: 'summary' }, + { type: 'file-list', output_variable_name: 'attachments' }, + ]) + }) + it('should build initial features with file-upload and feature fallbacks', () => { const result = buildInitialFeatures({ file_upload: { diff --git a/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts b/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts index 0ac528c303..76bceb37bb 100644 --- a/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts +++ b/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts @@ -14,6 +14,7 @@ import { postWithKeepalive } from '@/service/fetch' import { systemFeaturesQueryOptions } from '@/service/system-features' import { syncWorkflowDraft } from '@/service/workflow' import { useWorkflowRefreshDraft } from '.' +import { normalizeWorkflowNodesForBackend } from '../utils' export const useNodesSyncDraft = () => { const store = useStoreApi() @@ -46,14 +47,14 @@ export const useNodesSyncDraft = () => { return null const features = featuresStore!.getState().features - const producedNodes = produce(nodes, (draft) => { + const producedNodes = normalizeWorkflowNodesForBackend(produce(nodes, (draft) => { draft.forEach((node) => { Object.keys(node.data).forEach((key) => { if (key.startsWith('_')) delete node.data[key] }) }) - }) + })) const producedEdges = produce(edges.filter(edge => !edge.data?._isTemp), (draft) => { draft.forEach((edge) => { Object.keys(edge.data).forEach((key) => { diff --git a/web/app/components/workflow-app/hooks/use-workflow-init.ts b/web/app/components/workflow-app/hooks/use-workflow-init.ts index 00bff2919f..a4018dccce 100644 --- a/web/app/components/workflow-app/hooks/use-workflow-init.ts +++ b/web/app/components/workflow-app/hooks/use-workflow-init.ts @@ -20,6 +20,7 @@ import { syncWorkflowDraft, } from '@/service/workflow' import { AppModeEnum } from '@/types/app' +import { normalizeWorkflowNodesForFrontend } from '../utils' import { useWorkflowTemplate } from './use-workflow-template' const hasConnectedUserInput = (nodes: Node[] = [], edges: Edge[] = []): boolean => { @@ -58,7 +59,13 @@ export const useWorkflowInit = () => { const handleGetInitialWorkflowData = useCallback(async () => { try { const res = await fetchWorkflowDraft(`/apps/${appDetail.id}/workflows/draft`) - setData(res) + setData({ + ...res, + graph: { + ...res.graph, + nodes: normalizeWorkflowNodesForFrontend(res.graph.nodes), + }, + }) workflowStore.setState({ envSecrets: (res.environment_variables || []).filter(env => env.value_type === 'secret').reduce((acc, env) => { acc[env.id] = env.value diff --git a/web/app/components/workflow-app/utils.ts b/web/app/components/workflow-app/utils.ts index df344e333b..64f0b5fc17 100644 --- a/web/app/components/workflow-app/utils.ts +++ b/web/app/components/workflow-app/utils.ts @@ -1,7 +1,8 @@ import type { Features as FeaturesData } from '@/app/components/base/features/types' +import type { Node } from '@/app/components/workflow/types' import type { FileUploadConfigResponse } from '@/models/common' import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants' -import { SupportUploadFileTypes } from '@/app/components/workflow/types' +import { BlockEnum, SupportUploadFileTypes } from '@/app/components/workflow/types' import { TransferMethod } from '@/types/app' type TriggerStatusLike = { @@ -33,6 +34,15 @@ type WorkflowFeaturesLike = { sensitive_word_avoidance?: { enabled?: boolean } } +type HumanInputFieldLike = { + type: unknown + [key: string]: unknown +} + +type HumanInputNodeExtra = { + inputs: HumanInputFieldLike[] +} + export const buildTriggerStatusMap = (triggers: TriggerStatusLike[]) => { return triggers.reduce>((acc, trigger) => { acc[trigger.node_id] = trigger.status === 'enabled' ? 'enabled' : 'disabled' @@ -71,6 +81,49 @@ export const coerceReplayUserInputs = (rawInputs: unknown): Record { + if (direction === 'frontend') + return type === 'file_list' ? 'file-list' : type + + return type === 'file-list' ? 'file_list' : type +} + +const isHumanInputNode = (node: Node): node is Node => { + return node.data.type === BlockEnum.HumanInput && Array.isArray((node.data as Partial).inputs) +} + +const normalizeHumanInputNode = ( + node: Node, + direction: 'frontend' | 'backend', +): Node => { + if (!isHumanInputNode(node)) + return node + + const normalizedNode: Node = { + ...node, + data: { + ...node.data, + inputs: node.data.inputs.map(input => ({ + ...input, + type: normalizeHumanInputFieldType(input.type, direction), + })), + }, + } + + return normalizedNode +} + +export const normalizeWorkflowNodesForFrontend = (nodes: Node[]) => { + return nodes.map(node => normalizeHumanInputNode(node, 'frontend')) +} + +export const normalizeWorkflowNodesForBackend = (nodes: Node[]) => { + return nodes.map(node => normalizeHumanInputNode(node, 'backend')) +} + export const buildInitialFeatures = ( featuresSource: WorkflowFeaturesLike | null | undefined, fileUploadConfigResponse: FileUploadConfigResponse | undefined,