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) => (