fix(web): file_list type

This commit is contained in:
JzoNg 2026-04-24 10:27:55 +08:00
parent b8481f6d6f
commit 46747993d4
5 changed files with 127 additions and 7 deletions

View File

@ -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)')
})

View File

@ -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: {

View File

@ -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) => {

View File

@ -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

View File

@ -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<Record<string, 'enabled' | 'disabled'>>((acc, trigger) => {
acc[trigger.node_id] = trigger.status === 'enabled' ? 'enabled' : 'disabled'
@ -71,6 +81,49 @@ export const coerceReplayUserInputs = (rawInputs: unknown): Record<string, strin
return userInputs
}
const normalizeHumanInputFieldType = (
type: unknown,
direction: 'frontend' | 'backend',
) => {
if (direction === 'frontend')
return type === 'file_list' ? 'file-list' : type
return type === 'file-list' ? 'file_list' : type
}
const isHumanInputNode = (node: Node): node is Node<HumanInputNodeExtra> => {
return node.data.type === BlockEnum.HumanInput && Array.isArray((node.data as Partial<HumanInputNodeExtra>).inputs)
}
const normalizeHumanInputNode = (
node: Node,
direction: 'frontend' | 'backend',
): Node => {
if (!isHumanInputNode(node))
return node
const normalizedNode: Node<HumanInputNodeExtra> = {
...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,