From a8e663863d34705088566923eb456307ab28e275 Mon Sep 17 00:00:00 2001 From: JzoNg Date: Fri, 24 Apr 2026 11:06:58 +0800 Subject: [PATCH] fix(web): human input step run preview restriction --- .../__tests__/single-run-form.spec.tsx | 135 +++++++++++++++++- .../components/single-run-form.tsx | 33 ++++- 2 files changed, 166 insertions(+), 2 deletions(-) diff --git a/web/app/components/workflow/nodes/human-input/components/__tests__/single-run-form.spec.tsx b/web/app/components/workflow/nodes/human-input/components/__tests__/single-run-form.spec.tsx index 55edfc5e58..9df1719f80 100644 --- a/web/app/components/workflow/nodes/human-input/components/__tests__/single-run-form.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/components/__tests__/single-run-form.spec.tsx @@ -12,9 +12,41 @@ vi.mock('@/app/components/base/chat/chat/answer/human-input-content/content-item default: ({ content, onInputChange }: { content: string, onInputChange: (name: string, value: unknown) => void }) => (
{content} + + + +
), })) @@ -38,8 +85,17 @@ describe('SingleRunForm', () => { form_id: 'form-1', node_id: 'node-1', node_title: 'Human Input', - form_content: '{{#$output.summary#}} {{#$output.attachments#}}', + form_content: '{{#$output.decision#}} {{#$output.summary#}} {{#$output.attachment#}} {{#$output.attachments#}}', inputs: [ + { + type: InputVarType.select, + output_variable_name: 'decision', + option_source: { + type: 'constant', + value: ['approve', 'reject'], + selector: [], + }, + }, { type: InputVarType.paragraph, output_variable_name: 'summary', @@ -49,6 +105,13 @@ describe('SingleRunForm', () => { selector: [], }, }, + { + type: InputVarType.singleFile, + output_variable_name: 'attachment', + allowed_file_extensions: ['.pdf'], + allowed_file_types: [SupportUploadFileTypes.document], + allowed_file_upload_methods: [TransferMethod.local_file], + }, { type: InputVarType.multiFiles, output_variable_name: 'attachments', @@ -84,13 +147,26 @@ describe('SingleRunForm', () => { ) await user.click(screen.getAllByRole('button', { name: 'update-summary' })[0]!) + await user.click(screen.getAllByRole('button', { name: 'update-decision' })[0]!) + await user.click(screen.getAllByRole('button', { name: 'update-attachment' })[0]!) await user.click(screen.getAllByRole('button', { name: 'update-attachments' })[0]!) await user.click(screen.getByRole('button', { name: 'Approve' })) expect(onSubmit).toHaveBeenCalledWith({ action: 'approve', inputs: { + decision: 'approve', summary: 'updated summary', + attachment: { + id: 'file-0', + name: 'main.pdf', + size: 64, + type: 'document', + progress: 100, + transferMethod: TransferMethod.local_file, + supportFileType: 'document', + uploadedId: 'upload-file-0', + } satisfies FileEntity, attachments: [{ id: 'file-1', name: 'review.pdf', @@ -99,8 +175,65 @@ describe('SingleRunForm', () => { progress: 100, transferMethod: TransferMethod.local_file, supportFileType: 'document', + uploadedId: 'upload-file-1', } satisfies FileEntity], }, }) }) + + it('disables action buttons until select, file, and file list fields have values', async () => { + const user = userEvent.setup() + const onSubmit = vi.fn().mockResolvedValue(undefined) + + render( + , + ) + + const actionButton = screen.getByRole('button', { name: 'Approve' }) + expect(actionButton).toBeDisabled() + + await user.click(screen.getAllByRole('button', { name: 'update-decision' })[0]!) + expect(actionButton).toBeDisabled() + + await user.click(screen.getAllByRole('button', { name: 'update-attachment' })[0]!) + expect(actionButton).toBeDisabled() + + await user.click(screen.getAllByRole('button', { name: 'update-attachments' })[0]!) + expect(actionButton).toBeEnabled() + + await user.click(actionButton) + + expect(onSubmit).toHaveBeenCalledTimes(1) + }) + + it('keeps action buttons disabled while selected files are still uploading', async () => { + const user = userEvent.setup() + const onSubmit = vi.fn().mockResolvedValue(undefined) + + render( + , + ) + + const actionButton = screen.getByRole('button', { name: 'Approve' }) + + await user.click(screen.getAllByRole('button', { name: 'update-decision' })[0]!) + await user.click(screen.getAllByRole('button', { name: 'update-pending-attachment' })[0]!) + await user.click(screen.getAllByRole('button', { name: 'update-attachments' })[0]!) + expect(actionButton).toBeDisabled() + + await user.click(screen.getAllByRole('button', { name: 'update-attachment' })[0]!) + await user.click(screen.getAllByRole('button', { name: 'update-pending-attachments' })[0]!) + expect(actionButton).toBeDisabled() + + await user.click(screen.getAllByRole('button', { name: 'update-attachments' })[0]!) + expect(actionButton).toBeEnabled() + }) }) diff --git a/web/app/components/workflow/nodes/human-input/components/single-run-form.tsx b/web/app/components/workflow/nodes/human-input/components/single-run-form.tsx index 78846601a8..0285735604 100644 --- a/web/app/components/workflow/nodes/human-input/components/single-run-form.tsx +++ b/web/app/components/workflow/nodes/human-input/components/single-run-form.tsx @@ -1,6 +1,7 @@ 'use client' import type { ButtonProps } from '@langgenius/dify-ui/button' import type { HumanInputFieldValue } from '@/app/components/base/chat/chat/answer/human-input-content/field-renderer' +import type { FileEntity } from '@/app/components/base/file-uploader/types' import type { UserAction } from '@/app/components/workflow/nodes/human-input/types' import type { HumanInputFormData } from '@/types/workflow' import { Button } from '@langgenius/dify-ui/button' @@ -11,6 +12,8 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' import ContentItem from '@/app/components/base/chat/chat/answer/human-input-content/content-item' import { getButtonStyle, initializeInputs, splitByOutputVar } from '@/app/components/base/chat/chat/answer/human-input-content/utils' +import { fileIsUploaded } from '@/app/components/base/file-uploader/utils' +import { isFileFormInput, isFileListFormInput, isSelectFormInput } from '@/app/components/workflow/nodes/human-input/types' type Props = { nodeName: string @@ -20,6 +23,19 @@ type Props = { onSubmit?: ({ inputs, action }: { inputs: Record, action: string }) => Promise } +const isUploadedFile = (value: HumanInputFieldValue | undefined) => { + return !!value + && !Array.isArray(value) + && typeof value !== 'string' + && !!fileIsUploaded(value as FileEntity) +} + +const hasUploadedFiles = (value: HumanInputFieldValue | undefined) => { + return Array.isArray(value) + && value.length > 0 + && value.every(file => !!fileIsUploaded(file)) +} + const FormContent = ({ nodeName, data, @@ -40,6 +56,21 @@ const FormContent = ({ })) } + const hasEmptySelectOrFileInput = data.inputs.some((input) => { + const value = inputs[input.output_variable_name] + + if (isSelectFormInput(input)) + return typeof value !== 'string' || value.length === 0 + + if (isFileFormInput(input)) + return Array.isArray(value) ? !hasUploadedFiles(value) : !isUploadedFile(value) + + if (isFileListFormInput(input)) + return !hasUploadedFiles(value) + + return false + }) + const submit = async (actionID: string) => { setIsSubmitting(true) await onSubmit?.({ inputs, action: actionID }) @@ -72,7 +103,7 @@ const FormContent = ({ {data.actions.map((action: UserAction) => (