diff --git a/web/app/(humanInputLayout)/form/[token]/__tests__/form.spec.tsx b/web/app/(humanInputLayout)/form/[token]/__tests__/form.spec.tsx
index 0106fd564d..f6d545c548 100644
--- a/web/app/(humanInputLayout)/form/[token]/__tests__/form.spec.tsx
+++ b/web/app/(humanInputLayout)/form/[token]/__tests__/form.spec.tsx
@@ -1,5 +1,4 @@
import type { FormData } from '../form'
-import type { FileEntity } from '@/app/components/base/file-uploader/types'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { UserActionButtonType } from '@/app/components/workflow/nodes/human-input/types'
@@ -9,6 +8,29 @@ import FormContent from '../form'
const mockSubmitForm = vi.hoisted(() => vi.fn())
const mockUseGetHumanInputForm = vi.hoisted(() => vi.fn())
+const mockContentItemState = vi.hoisted(() => ({
+ staleAttachmentInputChange: undefined as ((name: string, value: unknown) => void) | undefined,
+ uploadedFile: {
+ id: 'file-1',
+ name: 'review.pdf',
+ size: 128,
+ type: 'document',
+ progress: 100,
+ transferMethod: 'local_file',
+ supportFileType: 'document',
+ uploadedId: 'upload-file-1',
+ },
+ uploadingFile: {
+ id: 'file-1',
+ name: 'review.pdf',
+ size: 128,
+ type: 'document',
+ progress: 50,
+ transferMethod: 'local_file',
+ supportFileType: 'document',
+ uploadedId: undefined,
+ },
+}))
vi.mock('@/next/navigation', () => ({
useParams: () => ({ token: 'token-123' }),
@@ -29,28 +51,45 @@ vi.mock('@/hooks/use-document-title', () => ({
vi.mock('@/app/components/base/chat/chat/answer/human-input-content/content-item', () => ({
__esModule: true,
- default: ({ content, onInputChange }: { content: string, onInputChange: (name: string, value: unknown) => void }) => (
-
- {content}
-
-
-
- ),
+ default: ({ content, onInputChange }: { content: string, onInputChange: (name: string, value: unknown) => void }) => {
+ const isSummaryField = content.includes('summary')
+ const isAttachmentField = content.includes('attachments')
+
+ if (isAttachmentField && !mockContentItemState.staleAttachmentInputChange)
+ mockContentItemState.staleAttachmentInputChange = onInputChange
+
+ return (
+
+ {content}
+ {isSummaryField && (
+ <>
+
+
+ >
+ )}
+ {isAttachmentField && (
+ <>
+
+
+ >
+ )}
+
+ )
+ },
}))
vi.mock('@/app/components/base/chat/chat/answer/human-input-content/expiration-time', () => ({
@@ -124,6 +163,7 @@ describe('Human input share form', () => {
beforeEach(() => {
vi.clearAllMocks()
+ mockContentItemState.staleAttachmentInputChange = undefined
mockUseGetHumanInputForm.mockReturnValue({
data: formData,
isLoading: false,
@@ -136,8 +176,8 @@ describe('Human input share form', () => {
render()
- await user.click(screen.getAllByRole('button', { name: 'share-update-summary' })[0]!)
- await user.click(screen.getAllByRole('button', { name: 'share-update-attachments' })[0]!)
+ await user.click(screen.getByRole('button', { name: 'share-update-summary' }))
+ await user.click(screen.getByRole('button', { name: 'share-update-attachments' }))
await user.click(screen.getByRole('button', { name: 'Approve' }))
expect(mockSubmitForm).toHaveBeenCalledWith({
@@ -146,19 +186,54 @@ describe('Human input share form', () => {
action: 'approve',
inputs: {
summary: 'updated summary',
- attachments: [{
- id: 'file-1',
- name: 'review.pdf',
- size: 128,
- type: 'document',
- progress: 100,
- transferMethod: TransferMethod.local_file,
- supportFileType: 'document',
- } satisfies FileEntity],
+ attachments: [mockContentItemState.uploadedFile],
},
},
}, expect.objectContaining({
onSuccess: expect.any(Function),
}))
})
+
+ it('should keep initialized defaults when file upload uses the initial change callback', async () => {
+ const user = userEvent.setup()
+
+ render()
+
+ await user.click(screen.getByRole('button', { name: 'share-update-attachments' }))
+ await user.click(screen.getByRole('button', { name: 'Approve' }))
+
+ expect(mockSubmitForm).toHaveBeenCalledWith({
+ token: 'token-123',
+ data: {
+ action: 'approve',
+ inputs: {
+ summary: 'initial summary',
+ attachments: [mockContentItemState.uploadedFile],
+ },
+ },
+ }, expect.objectContaining({
+ onSuccess: expect.any(Function),
+ }))
+ })
+
+ it('should disable action buttons until every required field is filled and files are uploaded', async () => {
+ const user = userEvent.setup()
+
+ render()
+
+ const approveButton = screen.getByRole('button', { name: 'Approve' })
+ expect(approveButton).toBeDisabled()
+
+ await user.click(screen.getByRole('button', { name: 'share-uploading-attachments' }))
+ expect(approveButton).toBeDisabled()
+
+ await user.click(screen.getByRole('button', { name: 'share-update-attachments' }))
+ expect(approveButton).toBeEnabled()
+
+ await user.click(screen.getByRole('button', { name: 'share-clear-summary' }))
+ expect(approveButton).toBeDisabled()
+
+ await user.click(screen.getByRole('button', { name: 'share-update-summary' }))
+ expect(approveButton).toBeEnabled()
+ })
})
diff --git a/web/app/(humanInputLayout)/form/[token]/form-status-card.tsx b/web/app/(humanInputLayout)/form/[token]/form-status-card.tsx
new file mode 100644
index 0000000000..9ec7b900bc
--- /dev/null
+++ b/web/app/(humanInputLayout)/form/[token]/form-status-card.tsx
@@ -0,0 +1,51 @@
+import type { ReactNode } from 'react'
+import { cn } from '@langgenius/dify-ui/cn'
+import { useTranslation } from 'react-i18next'
+import DifyLogo from '@/app/components/base/logo/dify-logo'
+
+type FormStatusCardProps = {
+ iconClassName: string
+ title: ReactNode
+ subtitle?: ReactNode
+ submissionID?: string
+}
+
+const FormStatusCard = ({
+ iconClassName,
+ title,
+ subtitle,
+ submissionID,
+}: FormStatusCardProps) => {
+ const { t } = useTranslation()
+
+ return (
+
+
+
+
+
+
+
+
{title}
+ {subtitle && (
+
{subtitle}
+ )}
+
+ {submissionID && (
+
+ {t('humanInput.submissionID', { id: submissionID, ns: 'share' })}
+
+ )}
+
+
+
+
{t('chat.poweredBy', { ns: 'share' })}
+
+
+
+
+
+ )
+}
+
+export default FormStatusCard
diff --git a/web/app/(humanInputLayout)/form/[token]/form.tsx b/web/app/(humanInputLayout)/form/[token]/form.tsx
index 07ea9e9c9a..55491462ce 100644
--- a/web/app/(humanInputLayout)/form/[token]/form.tsx
+++ b/web/app/(humanInputLayout)/form/[token]/form.tsx
@@ -1,30 +1,17 @@
'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 { FormInputItem, UserAction } from '@/app/components/workflow/nodes/human-input/types'
import type { SiteInfo } from '@/models/share'
import type { HumanInputFormError } from '@/service/use-share'
import type { HumanInputResolvedValue } from '@/types/workflow'
-import { Button } from '@langgenius/dify-ui/button'
-import { cn } from '@langgenius/dify-ui/cn'
-import {
- RiCheckboxCircleFill,
- RiErrorWarningFill,
- RiInformation2Fill,
-} from '@remixicon/react'
-import { produce } from 'immer'
import * as React from 'react'
-import { useEffect, useMemo, useState } from 'react'
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, initializeInputs } from '@/app/components/base/chat/chat/answer/human-input-content/utils'
import Loading from '@/app/components/base/loading'
-import DifyLogo from '@/app/components/base/logo/dify-logo'
import useDocumentTitle from '@/hooks/use-document-title'
import { useParams } from '@/next/navigation'
-import { useGetHumanInputForm, useSubmitHumanInputForm } from '@/service/use-share'
+import { useGetHumanInputForm } from '@/service/use-share'
+import FormStatusCard from './form-status-card'
+import LoadedFormContent from './loaded-form-content'
+import { useFormSubmit } from './use-form-submit'
export type FormData = {
site: { site: SiteInfo }
@@ -41,53 +28,13 @@ const FormContent = () => {
const { token } = useParams<{ token: string }>()
useDocumentTitle('')
- const [inputs, setInputs] = useState>({})
- const [success, setSuccess] = useState(false)
-
- const { mutate: submitForm, isPending: isSubmitting } = useSubmitHumanInputForm()
-
const { data: formData, isLoading, error } = useGetHumanInputForm(token)
+ const { isSubmitting, submit, success } = useFormSubmit(token)
const expired = (error as HumanInputFormError | null)?.code === 'human_input_form_expired'
const submitted = (error as HumanInputFormError | null)?.code === 'human_input_form_submitted'
const rateLimitExceeded = (error as HumanInputFormError | null)?.code === 'web_form_rate_limit_exceeded'
- const splitByOutputVar = (content: string): string[] => {
- const outputVarRegex = /(\{\{#\$output\.[^#]+#\}\})/g
- const parts = content.split(outputVarRegex)
- return parts.filter(part => part.length > 0)
- }
-
- const contentList = useMemo(() => {
- if (!formData?.form_content)
- return []
- return splitByOutputVar(formData.form_content)
- }, [formData?.form_content])
-
- useEffect(() => {
- if (!formData?.inputs)
- return
- setInputs(initializeInputs(formData.inputs, formData.resolved_default_values))
- }, [formData?.inputs, formData?.resolved_default_values])
-
- const handleInputsChange = (name: string, value: HumanInputFieldValue) => {
- const newInputs = produce(inputs, (draft) => {
- draft[name] = value
- })
- setInputs(newInputs)
- }
-
- const submit = (actionID: string) => {
- submitForm(
- { token, data: { inputs, action: actionID } },
- {
- onSuccess: () => {
- setSuccess(true)
- },
- },
- )
- }
-
if (isLoading) {
return (
@@ -96,190 +43,62 @@ const FormContent = () => {
if (success) {
return (
-
-
-
-
-
-
-
-
{t('humanInput.thanks', { ns: 'share' })}
-
{t('humanInput.recorded', { ns: 'share' })}
-
-
{t('humanInput.submissionID', { id: token, ns: 'share' })}
-
-
-
-
{t('chat.poweredBy', { ns: 'share' })}
-
-
-
-
-
+
)
}
if (expired) {
return (
-
-
-
-
-
-
-
-
{t('humanInput.sorry', { ns: 'share' })}
-
{t('humanInput.expired', { ns: 'share' })}
-
-
{t('humanInput.submissionID', { id: token, ns: 'share' })}
-
-
-
-
{t('chat.poweredBy', { ns: 'share' })}
-
-
-
-
-
+
)
}
if (submitted) {
return (
-
-
-
-
-
-
-
-
{t('humanInput.sorry', { ns: 'share' })}
-
{t('humanInput.completed', { ns: 'share' })}
-
-
{t('humanInput.submissionID', { id: token, ns: 'share' })}
-
-
-
-
{t('chat.poweredBy', { ns: 'share' })}
-
-
-
-
-
+
)
}
if (rateLimitExceeded) {
return (
-
-
-
-
-
-
-
-
{t('humanInput.rateLimitExceeded', { ns: 'share' })}
-
-
-
-
-
{t('chat.poweredBy', { ns: 'share' })}
-
-
-
-
-
+
)
}
if (!formData) {
return (
-
-
-
-
-
-
-
-
{t('humanInput.formNotFound', { ns: 'share' })}
-
-
-
-
-
{t('chat.poweredBy', { ns: 'share' })}
-
-
-
-
-
+
)
}
- const site = formData.site.site
-
return (
-
-
-
-
- {contentList.map((content, index) => (
-
- ))}
-
- {formData.user_actions.map((action: UserAction) => (
-
- ))}
-
-
-
-
-
-
{t('chat.poweredBy', { ns: 'share' })}
-
-
-
-
-
+
)
}
diff --git a/web/app/(humanInputLayout)/form/[token]/loaded-form-content.tsx b/web/app/(humanInputLayout)/form/[token]/loaded-form-content.tsx
new file mode 100644
index 0000000000..65257f5956
--- /dev/null
+++ b/web/app/(humanInputLayout)/form/[token]/loaded-form-content.tsx
@@ -0,0 +1,97 @@
+import type { ButtonProps } from '@langgenius/dify-ui/button'
+import type { FormData } from './form'
+import type { HumanInputFieldValue } from '@/app/components/base/chat/chat/answer/human-input-content/field-renderer'
+import type { UserAction } from '@/app/components/workflow/nodes/human-input/types'
+import { Button } from '@langgenius/dify-ui/button'
+import { cn } from '@langgenius/dify-ui/cn'
+import { produce } from 'immer'
+import { useMemo, useState } from 'react'
+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 DifyLogo from '@/app/components/base/logo/dify-logo'
+
+type LoadedFormContentProps = {
+ formData: FormData
+ isSubmitting: boolean
+ onSubmit: (inputs: Record, actionID: string) => void
+}
+
+const LoadedFormContent = ({
+ formData,
+ isSubmitting,
+ onSubmit,
+}: LoadedFormContentProps) => {
+ const { t } = useTranslation()
+ const [inputs, setInputs] = useState>(() =>
+ initializeInputs(formData.inputs, formData.resolved_default_values),
+ )
+
+ const contentList = useMemo(() => {
+ return splitByOutputVar(formData.form_content)
+ }, [formData.form_content])
+
+ const handleInputsChange = (name: string, value: HumanInputFieldValue) => {
+ setInputs(prevInputs => produce(prevInputs, (draft) => {
+ draft[name] = value
+ }))
+ }
+
+ const submit = (actionID: string) => {
+ onSubmit(inputs, actionID)
+ }
+
+ const isActionDisabled = isSubmitting || hasInvalidRequiredHumanInput(formData.inputs, inputs)
+ const site = formData.site.site
+
+ return (
+
+
+
+
+ {contentList.map((content, index) => (
+
+ ))}
+
+ {formData.user_actions.map((action: UserAction) => (
+
+ ))}
+
+
+
+
+
+
{t('chat.poweredBy', { ns: 'share' })}
+
+
+
+
+
+ )
+}
+
+export default LoadedFormContent
diff --git a/web/app/(humanInputLayout)/form/[token]/use-form-submit.ts b/web/app/(humanInputLayout)/form/[token]/use-form-submit.ts
new file mode 100644
index 0000000000..24879c3653
--- /dev/null
+++ b/web/app/(humanInputLayout)/form/[token]/use-form-submit.ts
@@ -0,0 +1,25 @@
+import type { HumanInputFieldValue } from '@/app/components/base/chat/chat/answer/human-input-content/field-renderer'
+import { useCallback, useState } from 'react'
+import { useSubmitHumanInputForm } from '@/service/use-share'
+
+export const useFormSubmit = (token: string) => {
+ const [success, setSuccess] = useState(false)
+ const { mutate: submitForm, isPending: isSubmitting } = useSubmitHumanInputForm()
+
+ const submit = useCallback((inputs: Record, actionID: string) => {
+ submitForm(
+ { token, data: { inputs, action: actionID } },
+ {
+ onSuccess: () => {
+ setSuccess(true)
+ },
+ },
+ )
+ }, [submitForm, token])
+
+ return {
+ isSubmitting,
+ submit,
+ success,
+ }
+}
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 a967c2e701..cd1396b1f3 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
@@ -102,6 +102,29 @@ export const hasInvalidSelectOrFileInput = (
})
}
+export const hasInvalidRequiredHumanInput = (
+ formInputs: FormInputItem[],
+ values: Record,
+) => {
+ return formInputs.some((input) => {
+ const value = values[input.output_variable_name]
+
+ if (isParagraphFormInput(input))
+ return typeof value !== 'string' || value.trim().length === 0
+
+ if (isSelectFormInput(input))
+ return typeof value !== 'string' || value.length === 0
+
+ if (isFileFormInput(input))
+ return Array.isArray(value) ? !hasUploadedHumanInputFiles(value) : !isHumanInputFileUploaded(value)
+
+ if (isFileListFormInput(input))
+ return !hasUploadedHumanInputFiles(value)
+
+ return false
+ })
+}
+
export const getProcessedHumanInputFormInputs = (
formInputs: FormInputItem[],
values: Record | undefined,