From 94ad8674d61ecddeff9cf7ae2a1e323ef30980fd Mon Sep 17 00:00:00 2001 From: JzoNg Date: Mon, 11 May 2026 17:36:03 +0800 Subject: [PATCH] fix(web): form content action button disable state --- .../form/[token]/loaded-form-content.tsx | 7 +-- .../__tests__/human-input-form.spec.tsx | 53 +++++++++++++++++++ .../__tests__/utils.spec.ts | 37 +++++++++++++ .../human-input-content/human-input-form.tsx | 9 ++-- .../chat/answer/human-input-content/utils.ts | 16 ++++++ .../components/single-run-form.tsx | 7 +-- 6 files changed, 119 insertions(+), 10 deletions(-) diff --git a/web/app/(humanInputLayout)/form/[token]/loaded-form-content.tsx b/web/app/(humanInputLayout)/form/[token]/loaded-form-content.tsx index 2c8177db14..a0e1db23c2 100644 --- a/web/app/(humanInputLayout)/form/[token]/loaded-form-content.tsx +++ b/web/app/(humanInputLayout)/form/[token]/loaded-form-content.tsx @@ -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>(() => - 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 ( diff --git a/web/app/components/base/chat/chat/answer/human-input-content/__tests__/human-input-form.spec.tsx b/web/app/components/base/chat/chat/answer/human-input-content/__tests__/human-input-form.spec.tsx index b18bb5bcb3..15eb11d7bf 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/__tests__/human-input-form.spec.tsx +++ b/web/app/components/base/chat/chat/answer/human-input-content/__tests__/human-input-form.spec.tsx @@ -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() + + 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 diff --git a/web/app/components/base/chat/chat/answer/human-input-content/__tests__/utils.spec.ts b/web/app/components/base/chat/chat/answer/human-input-content/__tests__/utils.spec.ts index b669ab7549..6e5dd98f13 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/__tests__/utils.spec.ts +++ b/web/app/components/base/chat/chat/answer/human-input-content/__tests__/utils.spec.ts @@ -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() diff --git a/web/app/components/base/chat/chat/answer/human-input-content/human-input-form.tsx b/web/app/components/base/chat/chat/answer/human-input-content/human-input-form.tsx index 6dae3634eb..31b0328a9e 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/human-input-form.tsx +++ b/web/app/components/base/chat/chat/answer/human-input-content/human-input-form.tsx @@ -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) => { 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 ( <> diff --git a/web/app/components/base/chat/chat/answer/human-input-content/utils.ts b/web/app/components/base/chat/chat/answer/human-input-content/utils.ts index cd1396b1f3..69622f402e 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/utils.ts +++ b/web/app/components/base/chat/chat/answer/human-input-content/utils.ts @@ -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 = {}) => { const initialInputs: Record = {} formInputs.forEach((item) => { @@ -87,6 +97,9 @@ export const hasInvalidSelectOrFileInput = ( values: Record, ) => { 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, ) => { return formInputs.some((input) => { + if (!(input.output_variable_name in values)) + return false + const value = values[input.output_variable_name] if (isParagraphFormInput(input)) 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 93ac6ec235..bb644e52f5 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 @@ -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)