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 ( -
-
- -
{site.title}
-
-
-
- {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 ( +
+
+ +
{site.title}
+
+
+
+ {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,