fix(web): human input step run preview restriction

This commit is contained in:
JzoNg 2026-04-24 11:06:58 +08:00
parent 60f577fd11
commit a8e663863d
2 changed files with 166 additions and 2 deletions

View File

@ -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 }) => (
<div data-testid="single-run-content-item">
{content}
<button type="button" onClick={() => onInputChange('decision', 'approve')}>
update-decision
</button>
<button type="button" onClick={() => onInputChange('summary', 'updated summary')}>
update-summary
</button>
<button
type="button"
onClick={() => onInputChange('attachment', {
id: 'file-0',
name: 'main.pdf',
size: 64,
type: 'document',
progress: 100,
transferMethod: TransferMethod.local_file,
supportFileType: 'document',
uploadedId: 'upload-file-0',
})}
>
update-attachment
</button>
<button
type="button"
onClick={() => onInputChange('attachment', {
id: 'file-0',
name: 'main.pdf',
size: 64,
type: 'document',
progress: 50,
transferMethod: TransferMethod.local_file,
supportFileType: 'document',
})}
>
update-pending-attachment
</button>
<button
type="button"
onClick={() => onInputChange('attachments', [{
@ -25,10 +57,25 @@ vi.mock('@/app/components/base/chat/chat/answer/human-input-content/content-item
progress: 100,
transferMethod: TransferMethod.local_file,
supportFileType: 'document',
uploadedId: 'upload-file-1',
}])}
>
update-attachments
</button>
<button
type="button"
onClick={() => onInputChange('attachments', [{
id: 'file-1',
name: 'review.pdf',
size: 128,
type: 'document',
progress: 50,
transferMethod: TransferMethod.local_file,
supportFileType: 'document',
}])}
>
update-pending-attachments
</button>
</div>
),
}))
@ -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(
<SingleRunForm
nodeName="Human Input"
data={formData}
onSubmit={onSubmit}
/>,
)
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(
<SingleRunForm
nodeName="Human Input"
data={formData}
onSubmit={onSubmit}
/>,
)
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()
})
})

View File

@ -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<string, HumanInputFieldValue>, action: string }) => Promise<void>
}
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) => (
<Button
key={action.id}
disabled={isSubmitting}
disabled={isSubmitting || hasEmptySelectOrFileInput}
variant={getButtonStyle(action.button_style) as ButtonProps['variant']}
onClick={() => submit(action.id)}
>