refactor(web): human input form page

This commit is contained in:
JzoNg 2026-04-24 16:21:32 +08:00
parent cec437b35b
commit 7a12d46a45
6 changed files with 342 additions and 252 deletions

View File

@ -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()
})
})

View 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

View File

@ -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}
/>
)
}

View File

@ -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

View 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,
}
}

View File

@ -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,