mirror of
https://github.com/langgenius/dify.git
synced 2026-05-10 05:56:31 +08:00
refactor(web): human input form page
This commit is contained in:
parent
cec437b35b
commit
7a12d46a45
@ -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 }) => (
|
||||
<div data-testid="share-form-content-item">
|
||||
{content}
|
||||
<button type="button" onClick={() => onInputChange('summary', 'updated summary')}>
|
||||
share-update-summary
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onInputChange('attachments', [{
|
||||
id: 'file-1',
|
||||
name: 'review.pdf',
|
||||
size: 128,
|
||||
type: 'document',
|
||||
progress: 100,
|
||||
transferMethod: TransferMethod.local_file,
|
||||
supportFileType: 'document',
|
||||
}])}
|
||||
>
|
||||
share-update-attachments
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
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 (
|
||||
<div data-testid="share-form-content-item">
|
||||
{content}
|
||||
{isSummaryField && (
|
||||
<>
|
||||
<button type="button" onClick={() => onInputChange('summary', '')}>
|
||||
share-clear-summary
|
||||
</button>
|
||||
<button type="button" onClick={() => onInputChange('summary', 'updated summary')}>
|
||||
share-update-summary
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{isAttachmentField && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => mockContentItemState.staleAttachmentInputChange?.('attachments', [mockContentItemState.uploadingFile])}
|
||||
>
|
||||
share-uploading-attachments
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => mockContentItemState.staleAttachmentInputChange?.('attachments', [mockContentItemState.uploadedFile])}
|
||||
>
|
||||
share-update-attachments
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
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(<FormContent />)
|
||||
|
||||
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(<FormContent />)
|
||||
|
||||
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(<FormContent />)
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
51
web/app/(humanInputLayout)/form/[token]/form-status-card.tsx
Normal file
51
web/app/(humanInputLayout)/form/[token]/form-status-card.tsx
Normal file
@ -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 (
|
||||
<div className={cn('flex h-full w-full flex-col items-center justify-center')}>
|
||||
<div className="max-w-[640px] min-w-[480px]">
|
||||
<div className="flex h-[320px] flex-col gap-4 rounded-[20px] border border-divider-subtle bg-chat-bubble-bg p-10 pb-9 shadow-lg backdrop-blur-xs">
|
||||
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
|
||||
<span className={cn('h-8 w-8', iconClassName)} />
|
||||
</div>
|
||||
<div className="grow">
|
||||
<div className="title-4xl-semi-bold text-text-primary">{title}</div>
|
||||
{subtitle && (
|
||||
<div className="title-4xl-semi-bold text-text-primary">{subtitle}</div>
|
||||
)}
|
||||
</div>
|
||||
{submissionID && (
|
||||
<div className="shrink-0 system-2xs-regular-uppercase text-text-tertiary">
|
||||
{t('humanInput.submissionID', { id: submissionID, ns: 'share' })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-row-reverse px-2 py-3">
|
||||
<div className="flex shrink-0 items-center gap-1.5 px-1">
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
|
||||
<DifyLogo size="small" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FormStatusCard
|
||||
@ -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<Record<string, HumanInputFieldValue>>({})
|
||||
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 (
|
||||
<Loading type="app" />
|
||||
@ -96,190 +43,62 @@ const FormContent = () => {
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className={cn('flex h-full w-full flex-col items-center justify-center')}>
|
||||
<div className="max-w-[640px] min-w-[480px]">
|
||||
<div className="border-components-divider-subtle flex h-[320px] flex-col gap-4 rounded-[20px] border bg-chat-bubble-bg p-10 pb-9 shadow-lg backdrop-blur-xs">
|
||||
<div className="h-[56px] w-[56px] shrink-0 rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
|
||||
<RiCheckboxCircleFill className="h-8 w-8 text-text-success" />
|
||||
</div>
|
||||
<div className="grow">
|
||||
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.thanks', { ns: 'share' })}</div>
|
||||
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.recorded', { ns: 'share' })}</div>
|
||||
</div>
|
||||
<div className="shrink-0 system-2xs-regular-uppercase text-text-tertiary">{t('humanInput.submissionID', { id: token, ns: 'share' })}</div>
|
||||
</div>
|
||||
<div className="flex flex-row-reverse px-2 py-3">
|
||||
<div className={cn(
|
||||
'flex shrink-0 items-center gap-1.5 px-1',
|
||||
)}
|
||||
>
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
|
||||
<DifyLogo size="small" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<FormStatusCard
|
||||
iconClassName="i-ri-checkbox-circle-fill text-text-success"
|
||||
title={t('humanInput.thanks', { ns: 'share' })}
|
||||
subtitle={t('humanInput.recorded', { ns: 'share' })}
|
||||
submissionID={token}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (expired) {
|
||||
return (
|
||||
<div className={cn('flex h-full w-full flex-col items-center justify-center')}>
|
||||
<div className="max-w-[640px] min-w-[480px]">
|
||||
<div className="border-components-divider-subtle flex h-[320px] flex-col gap-4 rounded-[20px] border bg-chat-bubble-bg p-10 pb-9 shadow-lg backdrop-blur-xs">
|
||||
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
|
||||
<RiInformation2Fill className="h-8 w-8 text-text-accent" />
|
||||
</div>
|
||||
<div className="grow">
|
||||
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.sorry', { ns: 'share' })}</div>
|
||||
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.expired', { ns: 'share' })}</div>
|
||||
</div>
|
||||
<div className="shrink-0 system-2xs-regular-uppercase text-text-tertiary">{t('humanInput.submissionID', { id: token, ns: 'share' })}</div>
|
||||
</div>
|
||||
<div className="flex flex-row-reverse px-2 py-3">
|
||||
<div className={cn(
|
||||
'flex shrink-0 items-center gap-1.5 px-1',
|
||||
)}
|
||||
>
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
|
||||
<DifyLogo size="small" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<FormStatusCard
|
||||
iconClassName="i-ri-information-2-fill text-text-accent"
|
||||
title={t('humanInput.sorry', { ns: 'share' })}
|
||||
subtitle={t('humanInput.expired', { ns: 'share' })}
|
||||
submissionID={token}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (submitted) {
|
||||
return (
|
||||
<div className={cn('flex h-full w-full flex-col items-center justify-center')}>
|
||||
<div className="max-w-[640px] min-w-[480px]">
|
||||
<div className="border-components-divider-subtle flex h-[320px] flex-col gap-4 rounded-[20px] border bg-chat-bubble-bg p-10 pb-9 shadow-lg backdrop-blur-xs">
|
||||
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
|
||||
<RiInformation2Fill className="h-8 w-8 text-text-accent" />
|
||||
</div>
|
||||
<div className="grow">
|
||||
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.sorry', { ns: 'share' })}</div>
|
||||
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.completed', { ns: 'share' })}</div>
|
||||
</div>
|
||||
<div className="shrink-0 system-2xs-regular-uppercase text-text-tertiary">{t('humanInput.submissionID', { id: token, ns: 'share' })}</div>
|
||||
</div>
|
||||
<div className="flex flex-row-reverse px-2 py-3">
|
||||
<div className={cn(
|
||||
'flex shrink-0 items-center gap-1.5 px-1',
|
||||
)}
|
||||
>
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
|
||||
<DifyLogo size="small" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<FormStatusCard
|
||||
iconClassName="i-ri-information-2-fill text-text-accent"
|
||||
title={t('humanInput.sorry', { ns: 'share' })}
|
||||
subtitle={t('humanInput.completed', { ns: 'share' })}
|
||||
submissionID={token}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (rateLimitExceeded) {
|
||||
return (
|
||||
<div className={cn('flex h-full w-full flex-col items-center justify-center')}>
|
||||
<div className="max-w-[640px] min-w-[480px]">
|
||||
<div className="border-components-divider-subtle flex h-[320px] flex-col gap-4 rounded-[20px] border bg-chat-bubble-bg p-10 pb-9 shadow-lg backdrop-blur-xs">
|
||||
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
|
||||
<RiErrorWarningFill className="h-8 w-8 text-text-destructive" />
|
||||
</div>
|
||||
<div className="grow">
|
||||
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.rateLimitExceeded', { ns: 'share' })}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row-reverse px-2 py-3">
|
||||
<div className={cn(
|
||||
'flex shrink-0 items-center gap-1.5 px-1',
|
||||
)}
|
||||
>
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
|
||||
<DifyLogo size="small" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<FormStatusCard
|
||||
iconClassName="i-ri-error-warning-fill text-text-destructive"
|
||||
title={t('humanInput.rateLimitExceeded', { ns: 'share' })}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (!formData) {
|
||||
return (
|
||||
<div className={cn('flex h-full w-full flex-col items-center justify-center')}>
|
||||
<div className="max-w-[640px] min-w-[480px]">
|
||||
<div className="border-components-divider-subtle flex h-[320px] flex-col gap-4 rounded-[20px] border bg-chat-bubble-bg p-10 pb-9 shadow-lg backdrop-blur-xs">
|
||||
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
|
||||
<RiErrorWarningFill className="h-8 w-8 text-text-destructive" />
|
||||
</div>
|
||||
<div className="grow">
|
||||
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.formNotFound', { ns: 'share' })}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row-reverse px-2 py-3">
|
||||
<div className={cn(
|
||||
'flex shrink-0 items-center gap-1.5 px-1',
|
||||
)}
|
||||
>
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
|
||||
<DifyLogo size="small" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<FormStatusCard
|
||||
iconClassName="i-ri-error-warning-fill text-text-destructive"
|
||||
title={t('humanInput.formNotFound', { ns: 'share' })}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const site = formData.site.site
|
||||
|
||||
return (
|
||||
<div className={cn('mx-auto flex h-full w-full max-w-[720px] flex-col items-center')}>
|
||||
<div className="mt-4 flex w-full shrink-0 items-center gap-3 py-3">
|
||||
<AppIcon
|
||||
size="large"
|
||||
iconType={site.icon_type}
|
||||
icon={site.icon}
|
||||
background={site.icon_background}
|
||||
imageUrl={site.icon_url}
|
||||
/>
|
||||
<div className="grow system-xl-semibold text-text-primary">{site.title}</div>
|
||||
</div>
|
||||
<div className="h-0 w-full grow overflow-y-auto">
|
||||
<div className="border-components-divider-subtle rounded-[20px] border bg-chat-bubble-bg p-4 shadow-lg backdrop-blur-xs">
|
||||
{contentList.map((content, index) => (
|
||||
<ContentItem
|
||||
key={index}
|
||||
content={content}
|
||||
formInputFields={formData.inputs}
|
||||
inputs={inputs}
|
||||
onInputChange={handleInputsChange}
|
||||
/>
|
||||
))}
|
||||
<div className="flex flex-wrap gap-1 py-1">
|
||||
{formData.user_actions.map((action: UserAction) => (
|
||||
<Button
|
||||
key={action.id}
|
||||
disabled={isSubmitting}
|
||||
variant={getButtonStyle(action.button_style) as ButtonProps['variant']}
|
||||
onClick={() => submit(action.id)}
|
||||
>
|
||||
{action.title}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<ExpirationTime expirationTime={formData.expiration_time * 1000} />
|
||||
</div>
|
||||
<div className="flex flex-row-reverse px-2 py-3">
|
||||
<div className={cn(
|
||||
'flex shrink-0 items-center gap-1.5 px-1',
|
||||
)}
|
||||
>
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
|
||||
<DifyLogo size="small" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<LoadedFormContent
|
||||
key={token}
|
||||
formData={formData}
|
||||
isSubmitting={isSubmitting}
|
||||
onSubmit={submit}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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<string, HumanInputFieldValue>, actionID: string) => void
|
||||
}
|
||||
|
||||
const LoadedFormContent = ({
|
||||
formData,
|
||||
isSubmitting,
|
||||
onSubmit,
|
||||
}: LoadedFormContentProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [inputs, setInputs] = useState<Record<string, HumanInputFieldValue>>(() =>
|
||||
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 (
|
||||
<div className={cn('mx-auto flex h-full w-full max-w-[720px] flex-col items-center')}>
|
||||
<div className="mt-4 flex w-full shrink-0 items-center gap-3 py-3">
|
||||
<AppIcon
|
||||
size="large"
|
||||
iconType={site.icon_type}
|
||||
icon={site.icon}
|
||||
background={site.icon_background}
|
||||
imageUrl={site.icon_url}
|
||||
/>
|
||||
<div className="grow system-xl-semibold text-text-primary">{site.title}</div>
|
||||
</div>
|
||||
<div className="h-0 w-full grow overflow-y-auto">
|
||||
<div className="rounded-[20px] border border-divider-subtle bg-chat-bubble-bg p-4 shadow-lg backdrop-blur-xs">
|
||||
{contentList.map((content, index) => (
|
||||
<ContentItem
|
||||
key={index}
|
||||
content={content}
|
||||
formInputFields={formData.inputs}
|
||||
inputs={inputs}
|
||||
onInputChange={handleInputsChange}
|
||||
/>
|
||||
))}
|
||||
<div className="flex flex-wrap gap-1 py-1">
|
||||
{formData.user_actions.map((action: UserAction) => (
|
||||
<Button
|
||||
key={action.id}
|
||||
disabled={isActionDisabled}
|
||||
variant={getButtonStyle(action.button_style) as ButtonProps['variant']}
|
||||
onClick={() => submit(action.id)}
|
||||
>
|
||||
{action.title}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<ExpirationTime expirationTime={formData.expiration_time * 1000} />
|
||||
</div>
|
||||
<div className="flex flex-row-reverse px-2 py-3">
|
||||
<div className="flex shrink-0 items-center gap-1.5 px-1">
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
|
||||
<DifyLogo size="small" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LoadedFormContent
|
||||
25
web/app/(humanInputLayout)/form/[token]/use-form-submit.ts
Normal file
25
web/app/(humanInputLayout)/form/[token]/use-form-submit.ts
Normal file
@ -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<string, HumanInputFieldValue>, actionID: string) => {
|
||||
submitForm(
|
||||
{ token, data: { inputs, action: actionID } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setSuccess(true)
|
||||
},
|
||||
},
|
||||
)
|
||||
}, [submitForm, token])
|
||||
|
||||
return {
|
||||
isSubmitting,
|
||||
submit,
|
||||
success,
|
||||
}
|
||||
}
|
||||
@ -102,6 +102,29 @@ export const hasInvalidSelectOrFileInput = (
|
||||
})
|
||||
}
|
||||
|
||||
export const hasInvalidRequiredHumanInput = (
|
||||
formInputs: FormInputItem[],
|
||||
values: Record<string, HumanInputFieldValue>,
|
||||
) => {
|
||||
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<string, HumanInputFieldValue> | undefined,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user