refactor: update human input form handling with new hooks and improve error management

This commit is contained in:
twwu 2026-01-22 14:52:06 +08:00
parent 8f780cad4c
commit ac46cf499f
5 changed files with 142 additions and 74 deletions

View File

@ -1,12 +1,15 @@
'use client'
import type { FormInputItem, UserAction } from '@/app/components/workflow/nodes/human-input/types'
import type { HumanInputFormError } from '@/service/use-share'
import {
RiCheckboxCircleFill,
RiErrorWarningFill,
RiInformation2Fill,
} from '@remixicon/react'
import { produce } from 'immer'
import { useParams } from 'next/navigation'
import * as React from 'react'
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import Button from '@/app/components/base/button'
@ -16,8 +19,7 @@ import { getButtonStyle } from '@/app/components/base/chat/chat/answer/human-inp
import Loading from '@/app/components/base/loading'
import DifyLogo from '@/app/components/base/logo/dify-logo'
import useDocumentTitle from '@/hooks/use-document-title'
import { getHumanInputForm, submitHumanInputForm } from '@/service/share'
import { asyncRunSafe } from '@/utils'
import { useGetHumanInputForm, useSubmitHumanInputForm } from '@/service/use-share'
import { cn } from '@/utils/classnames'
export type FormData = {
@ -35,14 +37,15 @@ const FormContent = () => {
const { token } = useParams<{ token: string }>()
useDocumentTitle('')
const [isLoading, setIsLoading] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [formData, setFormData] = useState<FormData>()
const [contentList, setContentList] = useState<string[]>([])
const [inputs, setInputs] = useState({})
const [inputs, setInputs] = useState<Record<string, string>>({})
const [success, setSuccess] = useState(false)
const [expired, setExpired] = useState(false)
const [submitted, setSubmitted] = useState(false)
const { mutate: submitForm, isPending: isSubmitting } = useSubmitHumanInputForm()
const { data: formData, isLoading, error } = useGetHumanInputForm(token)
const expired = (error as HumanInputFormError | null)?.code === 'human_input_form_expired'
const submitted = (error as HumanInputFormError | null)?.code === 'human_input_form_submitted'
const site = formData?.site.site
@ -52,69 +55,42 @@ const FormContent = () => {
return parts.filter(part => part.length > 0)
}
const initializeInputs = (formInputs: FormInputItem[]) => {
const initialInputs: Record<string, any> = {}
formInputs.forEach((item) => {
if (item.type === 'text-input' || item.type === 'paragraph')
initialInputs[item.output_variable_name] = ''
else
initialInputs[item.output_variable_name] = undefined
})
setInputs(initialInputs)
}
const initializeContentList = (formContent: string) => {
const parts = splitByOutputVar(formContent)
setContentList(parts)
}
// use immer
const handleInputsChange = (name: string, value: any) => {
setInputs(prev => ({
...prev,
[name]: value,
}))
}
const getForm = async (token: string) => {
try {
const data = await getHumanInputForm(token)
setFormData(data)
initializeInputs(data.inputs)
initializeContentList(data.form_content)
setIsLoading(false)
}
catch (error) {
console.error(error)
}
}
const submit = async (actionID: string) => {
setIsSubmitting(true)
try {
await submitHumanInputForm(token, { inputs, action: actionID })
setSuccess(true)
}
catch (e: any) {
if (e.status === 400) {
const [, errRespData] = await asyncRunSafe<{ error_code: string }>(e.json())
const { error_code } = errRespData || {}
if (error_code === 'human_input_form_expired')
setExpired(true)
if (error_code === 'human_input_form_submitted')
setSubmitted(true)
}
}
finally {
setIsSubmitting(false)
}
}
const contentList = useMemo(() => {
if (!formData?.form_content)
return []
return splitByOutputVar(formData.form_content)
}, [formData?.form_content])
useEffect(() => {
getForm(token)
}, [token])
if (!formData?.inputs)
return
const initialInputs: Record<string, string> = {}
formData.inputs.forEach((item) => {
initialInputs[item.output_variable_name] = ''
})
setInputs(initialInputs)
}, [formData?.inputs])
if (isLoading || !formData) {
// use immer
const handleInputsChange = (name: string, value: string) => {
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" />
)
@ -153,7 +129,7 @@ const FormContent = () => {
<div className={cn('flex h-full w-full flex-col items-center justify-center')}>
<div className="min-w-[480px] max-w-[640px]">
<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-sm">
<div className="h-[56px] w-[56px] shrink-0 rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
<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">
@ -181,7 +157,7 @@ const FormContent = () => {
<div className={cn('flex h-full w-full flex-col items-center justify-center')}>
<div className="min-w-[480px] max-w-[640px]">
<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-sm">
<div className="h-[56px] w-[56px] shrink-0 rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
<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">
@ -204,6 +180,32 @@ const FormContent = () => {
)
}
if (!formData) {
return (
<div className={cn('flex h-full w-full flex-col items-center justify-center')}>
<div className="min-w-[480px] max-w-[640px]">
<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-sm">
<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>
)
}
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">

View File

@ -100,8 +100,11 @@
}
},
"app/(humanInputLayout)/form/[token]/form.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
},
"ts/no-explicit-any": {
"count": 7
"count": 4
}
},
"app/(shareLayout)/components/splash.tsx": {

View File

@ -61,6 +61,7 @@
"humanInput.completed": "Seems like this request was dealt with elsewhere.",
"humanInput.expirationTime": "This action will expire {{relativeTime}}.",
"humanInput.expired": "Seems like this request has expired.",
"humanInput.formNotFound": "Form not found.",
"humanInput.recorded": "Your input has been recorded.",
"humanInput.sorry": "Sorry!",
"humanInput.submissionID": "submission_id: {{id}}",

View File

@ -61,6 +61,7 @@
"humanInput.completed": "此请求似乎在其他地方得到了处理。",
"humanInput.expirationTime": "此操作将在 {{relativeTime}}过期。",
"humanInput.expired": "此请求似乎已过期。",
"humanInput.formNotFound": "表单不存在。",
"humanInput.recorded": "您的输入已被记录。",
"humanInput.sorry": "抱歉!",
"humanInput.thanks": "谢谢!",

View File

@ -1,5 +1,6 @@
import type { FormData as HumanInputFormData } from '@/app/(humanInputLayout)/form/[token]/form'
import type { AppConversationData, ConversationItem } from '@/models/share'
import { useQuery } from '@tanstack/react-query'
import { useMutation, useQuery } from '@tanstack/react-query'
import {
fetchAppInfo,
fetchAppMeta,
@ -8,6 +9,8 @@ import {
fetchConversations,
generationConversationName,
getAppAccessModeByAppCode,
getHumanInputForm,
submitHumanInputForm,
} from './share'
import { useInvalid } from './use-base'
@ -48,6 +51,7 @@ export const shareQueryKeys = {
conversationList: (params: ShareConversationsParams) => [NAME_SPACE, 'conversations', params] as const,
chatList: (params: ShareChatListParams) => [NAME_SPACE, 'chatList', params] as const,
conversationName: (params: ShareConversationNameParams) => [NAME_SPACE, 'conversationName', params] as const,
humanInputForm: (token: string) => [NAME_SPACE, 'humanInputForm', token] as const,
}
export const useGetWebAppAccessModeByCode = (code: string | null) => {
@ -148,3 +152,60 @@ export const useShareConversationName = (params: ShareConversationNameParams, op
export const useInvalidateShareConversations = () => {
return useInvalid(shareQueryKeys.conversations)
}
export class HumanInputFormError extends Error {
code: string
status: number
constructor(code: string, message: string, status: number) {
super(message)
this.name = 'HumanInputFormError'
this.code = code
this.status = status
}
}
export const useGetHumanInputForm = (token: string, options: ShareQueryOptions = {}) => {
const {
enabled = true,
refetchOnReconnect,
refetchOnWindowFocus,
} = options
return useQuery<HumanInputFormData, HumanInputFormError>({
queryKey: shareQueryKeys.humanInputForm(token),
queryFn: async () => {
try {
return await getHumanInputForm(token)
}
catch (error) {
const response = error as Response
if (response.status && response.json) {
const errorData = await response.json() as { code: string, message: string }
throw new HumanInputFormError(errorData.code, errorData.message, response.status)
}
throw error
}
},
enabled: enabled && !!token,
refetchOnReconnect,
refetchOnWindowFocus,
retry: false,
})
}
export type SubmitHumanInputFormParams = {
token: string
data: {
inputs: Record<string, unknown>
action: string
}
}
export const useSubmitHumanInputForm = () => {
return useMutation({
mutationKey: [NAME_SPACE, 'submit-human-input-form'],
mutationFn: ({ token, data }: SubmitHumanInputFormParams) => {
return submitHumanInputForm(token, data)
},
})
}