fix(web): form content action button disable state

This commit is contained in:
JzoNg 2026-05-11 17:36:03 +08:00
parent 998201f6e3
commit 94ad8674d6
6 changed files with 119 additions and 10 deletions

View File

@ -10,7 +10,7 @@ import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import ContentItem from '@/app/components/base/chat/chat/answer/human-input-content/content-item'
import ExpirationTime from '@/app/components/base/chat/chat/answer/human-input-content/expiration-time'
import { getButtonStyle, hasInvalidRequiredHumanInput, initializeInputs, splitByOutputVar } from '@/app/components/base/chat/chat/answer/human-input-content/utils'
import { getButtonStyle, getRenderedFormInputs, hasInvalidRequiredHumanInput, initializeInputs, splitByOutputVar } from '@/app/components/base/chat/chat/answer/human-input-content/utils'
import DifyLogo from '@/app/components/base/logo/dify-logo'
type LoadedFormContentProps = {
@ -25,8 +25,9 @@ const LoadedFormContent = ({
onSubmit,
}: LoadedFormContentProps) => {
const { t } = useTranslation()
const renderedFormInputs = getRenderedFormInputs(formData.inputs, formData.form_content)
const [inputs, setInputs] = useState<Record<string, HumanInputFieldValue>>(() =>
initializeInputs(formData.inputs, formData.resolved_default_values),
initializeInputs(renderedFormInputs, formData.resolved_default_values),
)
const contentList = useMemo(() => {
@ -43,7 +44,7 @@ const LoadedFormContent = ({
onSubmit(inputs, actionID, formData.inputs)
}
const isActionDisabled = isSubmitting || hasInvalidRequiredHumanInput(formData.inputs, inputs)
const isActionDisabled = isSubmitting || hasInvalidRequiredHumanInput(renderedFormInputs, inputs)
const site = formData.site.site
return (

View File

@ -253,6 +253,59 @@ describe('HumanInputForm', () => {
})
})
it('should ignore input fields that are not rendered in form content when checking actions', async () => {
const user = userEvent.setup()
const mockOnSubmit = vi.fn().mockResolvedValue(undefined)
const formDataWithStaleInput: HumanInputFormData = {
...mockFormData,
form_content: '{{#$output.field2#}}',
inputs: [
{
type: InputVarType.select,
output_variable_name: 'field2',
option_source: {
type: 'constant',
value: ['approved'],
selector: [],
},
},
{
type: InputVarType.select,
output_variable_name: 'stale_select',
option_source: {
type: 'constant',
value: ['unused'],
selector: [],
},
},
{
type: InputVarType.singleFile,
output_variable_name: 'stale_file',
allowed_file_extensions: ['.png'],
allowed_file_types: [SupportUploadFileTypes.image],
allowed_file_upload_methods: [TransferMethod.local_file],
},
] as FormInputItem[],
}
render(<HumanInputForm formData={formDataWithStaleInput} onSubmit={mockOnSubmit} />)
const submitButton = screen.getByRole('button', { name: 'Submit' })
expect(submitButton).toBeDisabled()
await user.click(screen.getByTestId('update-select'))
expect(submitButton).toBeEnabled()
await user.click(submitButton)
expect(mockOnSubmit).toHaveBeenCalledWith('token_123', {
action: 'action_1',
inputs: {
field2: 'approved',
},
})
})
it('should disable buttons during submission', async () => {
const user = userEvent.setup()
let resolveSubmit: (value: void | PromiseLike<void>) => void

View File

@ -5,7 +5,11 @@ import { InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/
import { TransferMethod } from '@/types/app'
import {
getButtonStyle,
getFormContentInputNames,
getRelativeTime,
getRenderedFormInputs,
hasInvalidRequiredHumanInput,
hasInvalidSelectOrFileInput,
initializeInputs,
isRelativeTimeSameOrAfter,
splitByOutputVar,
@ -80,6 +84,23 @@ describe('human-input utils', () => {
})
})
describe('form content inputs', () => {
it('should extract and filter input fields rendered in form content', () => {
const formInputs: FormInputItem[] = [
selectInput({ output_variable_name: 'visible_select' }),
paragraphInput({ output_variable_name: 'visible_paragraph' }),
fileInput({ output_variable_name: 'stale_file' }),
]
const content = 'Select {{#$output.visible_select#}} and write {{#$output.visible_paragraph#}}'
expect(getFormContentInputNames(content)).toEqual(['visible_select', 'visible_paragraph'])
expect(getRenderedFormInputs(formInputs, content).map(input => input.output_variable_name)).toEqual([
'visible_select',
'visible_paragraph',
])
})
})
describe('initializeInputs', () => {
it('should initialize paragraph fields with constants and variable defaults', () => {
const formInputs: FormInputItem[] = [
@ -221,6 +242,22 @@ describe('human-input utils', () => {
})
})
describe('required input checks', () => {
it('should ignore fields that are not present in values', () => {
const formInputs: FormInputItem[] = [
selectInput({ output_variable_name: 'visible_select' }),
fileInput({ output_variable_name: 'stale_file' }),
paragraphInput({ output_variable_name: 'stale_paragraph' }),
]
const values = {
visible_select: 'approved',
}
expect(hasInvalidSelectOrFileInput(formInputs, values)).toBe(false)
expect(hasInvalidRequiredHumanInput(formInputs, values)).toBe(false)
})
})
describe('time helpers', () => {
it('should format relative time for supported and fallback locales', () => {
const now = Date.now()

View File

@ -7,15 +7,16 @@ import { Button } from '@langgenius/dify-ui/button'
import * as React from 'react'
import { useCallback, useState } from 'react'
import ContentItem from './content-item'
import { getButtonStyle, getProcessedHumanInputFormInputs, hasInvalidSelectOrFileInput, initializeInputs, splitByOutputVar } from './utils'
import { getButtonStyle, getProcessedHumanInputFormInputs, getRenderedFormInputs, hasInvalidSelectOrFileInput, initializeInputs, splitByOutputVar } from './utils'
const HumanInputForm = ({
formData,
onSubmit,
}: HumanInputFormProps) => {
const formToken = formData.form_token
const defaultInputs = initializeInputs(formData.inputs, formData.resolved_default_values || {})
const contentList = splitByOutputVar(formData.form_content)
const renderedFormInputs = getRenderedFormInputs(formData.inputs, formData.form_content)
const defaultInputs = initializeInputs(renderedFormInputs, formData.resolved_default_values || {})
const [inputs, setInputs] = useState(defaultInputs)
const [isSubmitting, setIsSubmitting] = useState(false)
@ -29,13 +30,13 @@ const HumanInputForm = ({
const submit = async (formToken: string, actionID: string, inputs: Record<string, HumanInputFieldValue>) => {
setIsSubmitting(true)
await onSubmit?.(formToken, {
inputs: getProcessedHumanInputFormInputs(formData.inputs, inputs) || {},
inputs: getProcessedHumanInputFormInputs(renderedFormInputs, inputs) || {},
action: actionID,
})
setIsSubmitting(false)
}
const isActionDisabled = isSubmitting || hasInvalidSelectOrFileInput(formData.inputs, inputs)
const isActionDisabled = isSubmitting || hasInvalidSelectOrFileInput(renderedFormInputs, inputs)
return (
<>

View File

@ -41,6 +41,16 @@ export const splitByOutputVar = (content: string): string[] => {
return parts.filter(part => part.length > 0)
}
export const getFormContentInputNames = (content: string) => {
const outputVarRegex = /\{\{#\$output\.([^#]+)#\}\}/g
return [...content.matchAll(outputVarRegex)].map(match => match[1]!)
}
export const getRenderedFormInputs = (formInputs: FormInputItem[], content: string) => {
const inputNames = new Set(getFormContentInputNames(content))
return formInputs.filter(input => inputNames.has(input.output_variable_name))
}
export const initializeInputs = (formInputs: FormInputItem[], defaultValues: Record<string, HumanInputResolvedValue> = {}) => {
const initialInputs: Record<string, HumanInputFieldValue> = {}
formInputs.forEach((item) => {
@ -87,6 +97,9 @@ export const hasInvalidSelectOrFileInput = (
values: Record<string, HumanInputFieldValue>,
) => {
return formInputs.some((input) => {
if (!(input.output_variable_name in values))
return false
const value = values[input.output_variable_name]
if (isSelectFormInput(input))
@ -107,6 +120,9 @@ export const hasInvalidRequiredHumanInput = (
values: Record<string, HumanInputFieldValue>,
) => {
return formInputs.some((input) => {
if (!(input.output_variable_name in values))
return false
const value = values[input.output_variable_name]
if (isParagraphFormInput(input))

View File

@ -10,7 +10,7 @@ import * as React from 'react'
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, hasInvalidSelectOrFileInput, initializeInputs, splitByOutputVar } from '@/app/components/base/chat/chat/answer/human-input-content/utils'
import { getButtonStyle, getRenderedFormInputs, hasInvalidSelectOrFileInput, initializeInputs, splitByOutputVar } from '@/app/components/base/chat/chat/answer/human-input-content/utils'
type Props = {
nodeName: string
@ -28,8 +28,9 @@ const FormContent = ({
onSubmit,
}: Props) => {
const { t } = useTranslation()
const defaultInputs = initializeInputs(data.inputs, data.resolved_default_values || {})
const contentList = splitByOutputVar(data.form_content)
const renderedFormInputs = getRenderedFormInputs(data.inputs, data.form_content)
const defaultInputs = initializeInputs(renderedFormInputs, data.resolved_default_values || {})
const [inputs, setInputs] = useState(defaultInputs)
const [isSubmitting, setIsSubmitting] = useState(false)
@ -40,7 +41,7 @@ const FormContent = ({
}))
}
const hasEmptySelectOrFileInput = hasInvalidSelectOrFileInput(data.inputs, inputs)
const hasEmptySelectOrFileInput = hasInvalidSelectOrFileInput(renderedFormInputs, inputs)
const submit = async (actionID: string) => {
setIsSubmitting(true)