mirror of
https://github.com/langgenius/dify.git
synced 2026-05-12 15:58:19 +08:00
fix(web): form content action button disable state
This commit is contained in:
parent
998201f6e3
commit
94ad8674d6
@ -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 (
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 (
|
||||
<>
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user