feat(api): introduce select, file and file list form input types to Human Input node (#36322)

Co-authored-by: JzoNg <jzongcode@gmail.com>
Co-authored-by: GPT 5.4 <codex@openai.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: -LAN- <laipz8200@outlook.com>
This commit is contained in:
QuantumGhost
2026-06-04 01:54:28 +00:00
committed by GitHub
co-authored by JzoNg GPT 5.4 autofix-ci[bot] -LAN-
parent 44725dde74
commit 3c98f96ae8
188 changed files with 11092 additions and 1156 deletions
+9 -3
View File
@@ -26,15 +26,21 @@ export const SelectTrigger = ({
children,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { children?: ReactNode }) => (
<button type="button" {...props}>
<button type="button" role="combobox" {...props}>
{children}
</button>
)
export const SelectValue = ({ placeholder }: { placeholder?: ReactNode }) => <>{placeholder}</>
export const SelectContent = ({ children }: { children?: ReactNode }) => (
<div data-testid="select-content">{children}</div>
export const SelectContent = ({
children,
popupClassName,
}: {
children?: ReactNode
popupClassName?: string
}) => (
<div data-side="bottom" data-testid="select-content" className={popupClassName}>{children}</div>
)
export const SelectItem = ({
@@ -11,6 +11,7 @@ const mockUploadRemoteFileInfo = vi.fn()
vi.mock('@/next/navigation', () => ({
useParams: () => ({}),
usePathname: () => '/',
}))
vi.mock('@/service/common', () => ({
@@ -0,0 +1,357 @@
import type { FormData } from '../form'
import type { HumanInputFieldValue } from '@/app/components/base/chat/chat/answer/human-input-content/field-renderer'
import { act, render, renderHook, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { UserActionButtonType } from '@/app/components/workflow/nodes/human-input/types'
import { InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
import FormContent from '../form'
import { useFormSubmit } from '../use-form-submit'
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' }),
}))
vi.mock('@/service/use-share', () => ({
useGetHumanInputForm: (...args: unknown[]) => mockUseGetHumanInputForm(...args),
useSubmitHumanInputForm: () => ({
mutate: mockSubmitForm,
isPending: false,
}),
}))
vi.mock('@/hooks/use-document-title', () => ({
__esModule: true,
default: vi.fn(),
}))
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 }) => {
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', () => ({
__esModule: true,
default: () => <div>expiration-time</div>,
}))
vi.mock('@/app/components/base/loading', () => ({
__esModule: true,
default: () => <div>loading</div>,
}))
vi.mock('@/app/components/base/logo/dify-logo', () => ({
__esModule: true,
default: () => <div>dify-logo</div>,
}))
vi.mock('@/app/components/base/app-icon', () => ({
__esModule: true,
default: () => <div>app-icon</div>,
}))
describe('Human input share form', () => {
const formData: FormData = {
site: {
site: {
title: 'Review App',
icon_type: 'emoji',
icon: 'R',
icon_background: '#fff',
icon_url: '',
default_language: 'en-US',
description: '',
copyright: '',
privacy_policy: '',
custom_disclaimer: '',
prompt_public: false,
use_icon_as_answer_icon: false,
},
},
form_content: '{{#$output.summary#}} {{#$output.attachments#}}',
inputs: [
{
type: InputVarType.paragraph,
output_variable_name: 'summary',
default: {
type: 'constant',
value: 'initial summary',
selector: [],
},
},
{
type: InputVarType.multiFiles,
output_variable_name: 'attachments',
allowed_file_extensions: ['.pdf'],
allowed_file_types: [SupportUploadFileTypes.document],
allowed_file_upload_methods: [TransferMethod.local_file],
number_limits: 3,
},
],
resolved_default_values: {},
user_actions: [
{
id: 'approve',
title: 'Approve',
button_style: UserActionButtonType.Primary,
},
],
expiration_time: 60,
}
beforeEach(() => {
vi.clearAllMocks()
mockContentItemState.staleAttachmentInputChange = undefined
mockUseGetHumanInputForm.mockReturnValue({
data: formData,
isLoading: false,
error: null,
})
})
it('should render the loading state while the form is being fetched', () => {
mockUseGetHumanInputForm.mockReturnValue({
data: undefined,
isLoading: true,
error: null,
})
render(<FormContent />)
expect(screen.getByText('loading')).toBeInTheDocument()
})
it('should render status cards for terminal fetch states', () => {
const cases = [
{
error: { code: 'human_input_form_expired' },
title: 'share.humanInput.sorry',
subtitle: 'share.humanInput.expired',
submissionID: true,
},
{
error: { code: 'human_input_form_submitted' },
title: 'share.humanInput.sorry',
subtitle: 'share.humanInput.completed',
submissionID: true,
},
{
error: { code: 'web_form_rate_limit_exceeded' },
title: 'share.humanInput.rateLimitExceeded',
subtitle: undefined,
submissionID: false,
},
{
error: null,
title: 'share.humanInput.formNotFound',
subtitle: undefined,
submissionID: false,
},
]
cases.forEach(({ error, title, subtitle, submissionID }) => {
mockUseGetHumanInputForm.mockReturnValue({
data: undefined,
isLoading: false,
error,
})
const { unmount } = render(<FormContent />)
expect(screen.getByText(title)).toBeInTheDocument()
if (subtitle)
expect(screen.getByText(subtitle)).toBeInTheDocument()
else
expect(screen.queryByText('share.humanInput.expired')).not.toBeInTheDocument()
if (submissionID)
expect(screen.getByText('share.humanInput.submissionID:{"id":"token-123"}')).toBeInTheDocument()
else
expect(screen.queryByText(/share\.humanInput\.submissionID/)).not.toBeInTheDocument()
expect(screen.getByText('share.chat.poweredBy')).toBeInTheDocument()
expect(screen.getByText('dify-logo')).toBeInTheDocument()
unmount()
})
})
it('submits typed human input values through the share form mutation', async () => {
const user = userEvent.setup()
render(<FormContent />)
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({
token: 'token-123',
data: {
action: 'approve',
inputs: {
summary: 'updated summary',
attachments: [{
type: 'document',
transfer_method: TransferMethod.local_file,
url: '',
upload_file_id: 'upload-file-1',
}],
},
},
}, expect.objectContaining({
onSuccess: expect.any(Function),
}))
})
it('should show the success status after the submit mutation succeeds', async () => {
const user = userEvent.setup()
render(<FormContent />)
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' }))
const options = mockSubmitForm.mock.calls[0]![1] as { onSuccess: () => void }
act(() => {
options.onSuccess()
})
expect(screen.getByText('share.humanInput.thanks')).toBeInTheDocument()
expect(screen.getByText('share.humanInput.recorded')).toBeInTheDocument()
expect(screen.getByText('share.humanInput.submissionID:{"id":"token-123"}')).toBeInTheDocument()
})
it('should submit empty inputs when there are no form values to process', () => {
const { result } = renderHook(() => useFormSubmit('token-empty'))
act(() => {
result.current.submit(
undefined as unknown as Record<string, HumanInputFieldValue>,
'reject',
[],
)
})
expect(mockSubmitForm).toHaveBeenCalledWith({
token: 'token-empty',
data: {
action: 'reject',
inputs: {},
},
}, 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: [{
type: 'document',
transfer_method: TransferMethod.local_file,
url: '',
upload_file_id: 'upload-file-1',
}],
},
},
}, 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()
})
})
@@ -0,0 +1,19 @@
import { render, screen } from '@testing-library/react'
import FormPage from '../page'
vi.mock('../form', () => ({
__esModule: true,
default: () => <div>form-content</div>,
}))
describe('Human input share form page', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render the form content inside the page shell', () => {
render(<FormPage />)
expect(screen.getByText('form-content')).toBeInTheDocument()
})
})
@@ -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 size-full flex-col items-center justify-center')}>
<div className="max-w-160 min-w-120">
<div className="flex h-80 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('size-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
+39 -223
View File
@@ -1,34 +1,23 @@
'use client'
import type { ButtonProps } from '@langgenius/dify-ui/button'
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 { 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 type { HumanInputResolvedValue } from '@/types/workflow'
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 } 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 }
form_content: string
inputs: FormInputItem[]
resolved_default_values: Record<string, string>
resolved_default_values: Record<string, HumanInputResolvedValue>
user_actions: UserAction[]
expiration_time: number
}
@@ -39,58 +28,13 @@ const FormContent = () => {
const { token } = useParams<{ token: string }>()
useDocumentTitle('')
const [inputs, setInputs] = useState<Record<string, string>>({})
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
const initialInputs: Record<string, string> = {}
formData.inputs.forEach((item) => {
initialInputs[item.output_variable_name] = item.default.type === 'variable' ? formData.resolved_default_values[item.output_variable_name] || '' : item.default.value
})
setInputs(initialInputs)
}, [formData?.inputs, formData?.resolved_default_values])
// 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" />
@@ -99,190 +43,62 @@ const FormContent = () => {
if (success) {
return (
<div className={cn('flex size-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="size-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 size-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 size-14 shrink-0 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
<RiInformation2Fill className="size-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 size-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 size-14 shrink-0 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
<RiInformation2Fill className="size-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 size-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 size-14 shrink-0 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
<RiErrorWarningFill className="size-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 size-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 size-14 shrink-0 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
<RiErrorWarningFill className="size-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,98 @@
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, getRenderedFormInputs, 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, formInputs: FormData['inputs']) => void
}
const LoadedFormContent = ({
formData,
isSubmitting,
onSubmit,
}: LoadedFormContentProps) => {
const { t } = useTranslation()
const renderedFormInputs = getRenderedFormInputs(formData.inputs, formData.form_content)
const [inputs, setInputs] = useState<Record<string, HumanInputFieldValue>>(() =>
initializeInputs(renderedFormInputs, 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, formData.inputs)
}
const isActionDisabled = isSubmitting || hasInvalidRequiredHumanInput(renderedFormInputs, inputs)
const site = formData.site.site
return (
<div className={cn('mx-auto flex size-full max-w-180 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
@@ -4,7 +4,7 @@ import FormContent from './form'
const FormPage = () => {
return (
<div className="h-full min-w-[300px] bg-chatbot-bg pb-[env(safe-area-inset-bottom)]">
<div className="h-full min-w-75 bg-chatbot-bg pb-[env(safe-area-inset-bottom)]">
<FormContent />
</div>
)
@@ -0,0 +1,33 @@
import type { HumanInputFieldValue } from '@/app/components/base/chat/chat/answer/human-input-content/field-renderer'
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import { useCallback, useState } from 'react'
import { getProcessedHumanInputFormInputs } from '@/app/components/base/chat/chat/answer/human-input-content/utils'
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, formInputs: FormInputItem[]) => {
submitForm(
{
token,
data: {
inputs: getProcessedHumanInputFormInputs(formInputs, inputs) || {},
action: actionID,
},
},
{
onSuccess: () => {
setSuccess(true)
},
},
)
}, [submitForm, token])
return {
isSubmitting,
submit,
success,
}
}
@@ -1,5 +1,6 @@
/* eslint-disable ts/no-explicit-any */
import { fireEvent, render, screen } from '@testing-library/react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import TypeSelector from '../type-select'
vi.mock('@langgenius/dify-ui/select', () => import('@/__mocks__/base-ui-select'))
@@ -9,8 +10,9 @@ vi.mock('@/app/components/workflow/nodes/_base/components/input-var-type-icon',
}))
describe('TypeSelector', () => {
it('should toggle open state and select a new variable type', () => {
it('should select a new variable type when an option is clicked', async () => {
const onSelect = vi.fn()
const user = userEvent.setup()
render(
<TypeSelector
@@ -23,9 +25,32 @@ describe('TypeSelector', () => {
/>,
)
fireEvent.click(screen.getByRole('button'))
fireEvent.click(screen.getByText('Number'))
await user.click(screen.getByRole('combobox'))
const [, numberOption] = await screen.findAllByRole('option')
await user.click(numberOption!)
expect(onSelect).toHaveBeenCalledWith({ value: 'number', name: 'Number' })
})
it('should size popup content to match the trigger width', async () => {
const user = userEvent.setup()
render(
<TypeSelector
value="text-input"
onSelect={vi.fn()}
items={[
{ value: 'text-input' as any, name: 'Text' },
{ value: 'number' as any, name: 'Number' },
]}
/>,
)
await user.click(screen.getByRole('combobox'))
const [, numberOption] = await screen.findAllByRole('option')
const popup = numberOption!.closest('[data-side]')
expect(popup).toHaveClass('w-(--anchor-width)')
})
})
@@ -59,9 +59,10 @@ const TypeSelector: FC<Props> = ({
<div className="flex items-center">
<InputVarTypeIcon type={selectedItem?.value as InputVarType} className="size-4 shrink-0 text-text-secondary" />
<span
className={`
ml-1.5 text-components-input-text-filled ${!selectedItem?.name && 'text-components-input-text-placeholder'}
`}
className={cn(
'ml-1.5 truncate text-components-input-text-filled',
!selectedItem?.name && 'text-components-input-text-placeholder',
)}
>
{selectedItem?.name}
</span>
@@ -73,7 +74,7 @@ const TypeSelector: FC<Props> = ({
</SelectTrigger>
<SelectContent
sideOffset={4}
popupClassName={cn('w-[432px] rounded-md px-1 py-1 text-base sm:text-sm', popupInnerClassName)}
popupClassName={cn('w-(--anchor-width) rounded-md px-1 py-1 text-base sm:text-sm', popupInnerClassName)}
listClassName="max-h-80 p-0"
>
{items.map((item: Item) => (
@@ -1,5 +1,6 @@
'use client'
import type { FC } from 'react'
import type { HumanInputFormSubmitData } from '@/app/components/base/chat/chat/answer/human-input-content/type'
import type { FeedbackType } from '@/app/components/base/chat/chat/type'
import type { WorkflowProcess } from '@/app/components/base/chat/types'
import type { SiteInfo } from '@/models/share'
@@ -178,7 +179,7 @@ const GenerationItem: FC<IGenerationItemProps> = ({
// eslint-disable-next-line react/set-state-in-effect
setCurrentTab(getDefaultGenerationTab(workflowProcessData))
}, [workflowProcessData])
const handleSubmitHumanInputForm = useCallback(async (formToken: string, formData: { inputs: Record<string, string>, action: string }) => {
const handleSubmitHumanInputForm = useCallback(async (formToken: string, formData: HumanInputFormSubmitData) => {
if (appSourceType === AppSourceType.installedApp)
await submitHumanInputFormService(formToken, formData)
else
@@ -1,5 +1,6 @@
'use client'
import type { FC } from 'react'
import type { HumanInputFormSubmitData } from '@/app/components/base/chat/chat/answer/human-input-content/type'
import type { WorkflowProcess } from '@/app/components/base/chat/types'
import type { SiteInfo } from '@/models/share'
import { cn } from '@langgenius/dify-ui/cn'
@@ -17,7 +18,7 @@ type WorkflowBodyProps = {
depth: number
hideProcessDetail?: boolean
isError: boolean
onSubmitHumanInputForm: (formToken: string, formData: { inputs: Record<string, string>, action: string }) => Promise<void>
onSubmitHumanInputForm: (formToken: string, formData: HumanInputFormSubmitData) => Promise<void>
onSwitchTab: (tab: string) => void
showResultTabs: boolean
siteInfo: SiteInfo | null
@@ -1076,6 +1076,10 @@ describe('useChatWithHistory', () => {
await waitFor(() => {
expect(result!.current.appPrevChatTree.length).toBeGreaterThan(0)
})
const answerNode = result!.current.appPrevChatTree[0]?.children?.[0]
expect(answerNode?.humanInputFormDataList).toHaveLength(1)
expect(answerNode?.workflow_run_id).toBe('wf-run-1')
})
it('should set workflow_run_id for normal messages with submitted human_input', async () => {
@@ -1114,6 +1118,75 @@ describe('useChatWithHistory', () => {
await waitFor(() => {
expect(result!.current.appPrevChatTree.length).toBeGreaterThan(0)
})
const answerNode = result!.current.appPrevChatTree[0]?.children?.[0]
expect(answerNode?.humanInputFilledFormDataList).toHaveLength(1)
})
it('should parse human input payloads regardless of message status', async () => {
const listData = createConversationData({
data: [createConversationItem({ id: 'conversation-1' })],
})
const chatListData = {
data: [
{
id: 'msg-status-agnostic',
query: 'Needs review',
answer: 'Pending follow-up',
message_files: [],
feedback: null,
retriever_resources: [],
agent_thoughts: null,
parent_message_id: null,
inputs: {},
status: 'error',
extra_contents: [
{
type: 'human_input',
submitted: true,
form_definition: {
form_id: 'form-1',
node_id: 'node-1',
node_title: 'Human Input',
form_content: '{{#$output.summary#}}',
inputs: [],
actions: [],
form_token: 'token-1',
resolved_default_values: {},
display_in_ui: true,
expiration_time: 0,
},
workflow_run_id: 'wf-run-status-agnostic',
form_submission_data: {
node_id: 'node-1',
node_title: 'Human Input',
rendered_content: 'Submitted summary',
action_id: 'submit',
action_text: 'Submit',
submitted_data: {
summary: 'approved',
},
},
},
],
},
],
}
mockFetchConversations.mockResolvedValue(listData)
mockFetchChatList.mockResolvedValue(chatListData)
const { result } = await renderWithClient(() => useChatWithHistory())
await waitFor(() => {
expect(result!.current.appPrevChatTree.length).toBeGreaterThan(0)
})
const answerNode = result!.current.appPrevChatTree[0]?.children?.[0]
expect(answerNode?.humanInputFormDataList).toHaveLength(0)
expect(answerNode?.humanInputFilledFormDataList).toHaveLength(1)
expect(answerNode?.humanInputFilledFormDataList?.[0]?.form_content).toBe('{{#$output.summary#}}')
expect(answerNode?.humanInputFilledFormDataList?.[0]?.inputs).toEqual([])
expect(answerNode?.workflow_run_id).toBe('wf-run-status-agnostic')
})
it('should return empty appPrevChatTree when there is no currentConversationId', async () => {
@@ -1835,6 +1908,15 @@ describe('useChatWithHistory', () => {
expect(messageWithFiles?.message_files).toHaveLength(1)
expect(messageWithFiles?.children?.[0]?.message_files).toHaveLength(1)
expect(messageWithFiles?.children?.[0]?.agent_thoughts?.[0]?.message_files).toHaveLength(1)
const normalAnswerNode = messageWithFiles?.children?.[0]
const pausedAnswerNode = result!.current.appPrevChatTree.find(item => item.id === 'question-msg-paused-branch')?.children?.[0]
expect(normalAnswerNode?.humanInputFilledFormDataList).toHaveLength(1)
expect(normalAnswerNode?.humanInputFormDataList).toHaveLength(0)
expect(pausedAnswerNode?.humanInputFormDataList).toHaveLength(1)
expect(pausedAnswerNode?.humanInputFilledFormDataList).toHaveLength(0)
expect(pausedAnswerNode?.workflow_run_id).toBe('wf-run-branch')
})
})
@@ -18,6 +18,7 @@ import { AppSourceType, delConversation, pinConversation, renameConversation, un
import { useInvalidateShareConversations, useShareChatList, useShareConversationName, useShareConversations } from '@/service/use-share'
import { TransferMethod } from '@/types/app'
import { addFileInfos, sortAgentSorts } from '../../../tools/utils'
import { enrichSubmittedHumanInputFormData } from '../chat/answer/human-input-content/submitted-utils'
import { CONVERSATION_ID_INFO } from '../constants'
import { buildChatItemTree, getProcessedSystemVariablesFromUrlParams, getRawInputsFromUrlParams, getRawUserVariablesFromUrlParams } from '../utils'
@@ -39,21 +40,30 @@ function getFormattedChatList(messages: any[]) {
const humanInputFormDataList: HumanInputFormData[] = []
const humanInputFilledFormDataList: HumanInputFilledFormData[] = []
let workflowRunId = ''
if (item.status === 'paused') {
item.extra_contents?.forEach((content: ExtraContent) => {
if (content.type === 'human_input' && !content.submitted) {
humanInputFormDataList.push(content.form_definition)
workflowRunId = content.workflow_run_id
}
})
}
else if (item.status === 'normal') {
item.extra_contents?.forEach((content: ExtraContent) => {
if (content.type === 'human_input' && content.submitted) {
humanInputFilledFormDataList.push(content.form_submission_data)
}
})
}
item.extra_contents?.forEach((content: ExtraContent) => {
if (content.type !== 'human_input')
return
const formDefinition = 'form_definition' in content ? content.form_definition : undefined
if (!content.submitted) {
if (!formDefinition)
return
humanInputFormDataList.push(formDefinition)
workflowRunId = content.workflow_run_id || workflowRunId
return
}
if (!('form_submission_data' in content) || !content.form_submission_data)
return
const currentFormIndex = humanInputFormDataList.findIndex(item => item.node_id === content.form_submission_data.node_id)
const requiredFormData = formDefinition || (currentFormIndex > -1
? humanInputFormDataList[currentFormIndex]
: undefined)
if (currentFormIndex > -1)
humanInputFormDataList.splice(currentFormIndex, 1)
workflowRunId = content.workflow_run_id || workflowRunId
humanInputFilledFormDataList.push(enrichSubmittedHumanInputFormData(content.form_submission_data, requiredFormData))
})
newChatList.push({
id: item.id,
content: item.answer,
@@ -411,7 +411,7 @@ describe('useChat', () => {
// Human input required
callbacks.onHumanInputRequired({ data: { node_id: 'n-human' } })
callbacks.onHumanInputRequired({ data: { node_id: 'n-human', updated: true } }) // update existing
callbacks.onHumanInputRequired({ data: { node_id: 'n-human', updated: true, form_content: '{{#$output.answer#}}', inputs: [] } }) // update existing
// setTimeout for timeout form
callbacks.onHumanInputFormTimeout({ data: { node_id: 'n-human', expiration_time: 123456 } })
@@ -437,6 +437,10 @@ describe('useChat', () => {
const lastResponse = result.current.chatList[1]
expect(lastResponse!.humanInputFormDataList).toHaveLength(0) // Removed when filled
expect(lastResponse!.humanInputFilledFormDataList).toHaveLength(2)
expect(lastResponse!.humanInputFilledFormDataList![0]).toEqual(expect.objectContaining({
form_content: '{{#$output.answer#}}',
inputs: [],
}))
expect(sseGet).toHaveBeenCalled() // from workflowPaused
expect(lastResponse!.annotation?.id).toBe('anno-1')
expect(lastResponse!.content).toBe('Replaced content')
@@ -1,5 +1,6 @@
import type { HumanInputFilledFormData } from '@/types/workflow'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it } from 'vitest'
import HumanInputFilledFormList from '../human-input-filled-form-list'
@@ -12,13 +13,15 @@ const createFormData = (
): HumanInputFilledFormData => ({
node_id: 'node-1',
node_title: 'Node Title',
rendered_content: 'fallback content',
action_id: 'approve',
action_text: 'Approve',
submitted_data: {
summary: 'Approved',
},
// 👇 IMPORTANT
// DO NOT guess properties like `inputs`
// Only include fields that actually exist in your project type.
// Leave everything else empty via spread.
...overrides,
} as HumanInputFilledFormData)
})
describe('HumanInputFilledFormList', () => {
it('renders nothing when list is empty', () => {
@@ -27,12 +30,15 @@ describe('HumanInputFilledFormList', () => {
expect(screen.queryByText('Node Title')).not.toBeInTheDocument()
})
it('renders one form item', () => {
it('renders one form item', async () => {
const user = userEvent.setup()
const data = [createFormData()]
render(<HumanInputFilledFormList humanInputFilledFormDataList={data} />)
expect(screen.getByText('Node Title')).toBeInTheDocument()
await user.click(screen.getByTestId('expand-icon'))
expect(screen.getByTestId('submitted-field-summary')).toHaveTextContent('Approved')
})
it('renders multiple form items', () => {
@@ -248,6 +248,13 @@ describe('Operation', () => {
expect(screen.getByTestId('log-btn'))!.toBeInTheDocument()
})
it('should keep hover-only controls visible when a descendant popup is open', () => {
renderOperation({ ...baseProps, showPromptLog: true })
expect(screen.getByTestId('operation-actions')).toHaveClass('group-has-[[data-popup-open]]:flex')
expect(screen.getByTestId('log-btn').parentElement).toHaveClass('group-has-[[data-popup-open]]:block')
})
it('should not show prompt log for opening statements', () => {
const item = { ...baseItem, isOpeningStatement: true }
renderOperation({ ...baseProps, item, showPromptLog: true })
@@ -1,13 +1,35 @@
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { TransferMethod } from '@/types/app'
import ContentItem from '../content-item'
vi.mock('@/app/components/base/markdown', () => ({
Markdown: ({ content }: { content: string }) => <div data-testid="mock-markdown">{content}</div>,
}))
vi.mock('../field-renderer', () => ({
__esModule: true,
default: ({
field,
onChange,
}: {
field: FormInputItem
onChange: (value: unknown) => void
}) => (
<button
type="button"
data-testid={`renderer-${field.type}`}
aria-label={field.output_variable_name}
onClick={() => onChange(field.type === 'paragraph' ? 'updated value' : field.type)}
>
{field.type}
</button>
),
}))
describe('ContentItem', () => {
const mockOnInputChange = vi.fn()
const mockFormInputFields: FormInputItem[] = [
@@ -49,9 +71,9 @@ describe('ContentItem', () => {
/>,
)
const textarea = screen.getByTestId('content-item-textarea')
const textarea = screen.getByTestId('renderer-paragraph')
expect(textarea).toBeInTheDocument()
expect(textarea).toHaveValue('Initial bio')
expect(screen.getByRole('button', { name: 'user_bio' })).toBeInTheDocument()
expect(screen.queryByTestId('mock-markdown')).not.toBeInTheDocument()
})
@@ -66,10 +88,9 @@ describe('ContentItem', () => {
/>,
)
const textarea = screen.getByTestId('content-item-textarea')
await user.type(textarea, 'x')
await user.click(screen.getByTestId('renderer-paragraph'))
expect(mockOnInputChange).toHaveBeenCalledWith('user_bio', 'Initial biox')
expect(mockOnInputChange).toHaveBeenCalledWith('user_bio', 'updated value')
})
it('should render nothing if field name is valid but not found in formInputFields', () => {
@@ -85,18 +106,20 @@ describe('ContentItem', () => {
expect(container.firstChild).toBeNull()
})
it('should render nothing if input type is not supported', () => {
const { container } = render(
it('should delegate select fields to the shared renderer', async () => {
const user = userEvent.setup()
render(
<ContentItem
content="{{#$output.user_bio#}}"
formInputFields={[
{
type: 'text-input',
type: 'select',
output_variable_name: 'user_bio',
default: {
option_source: {
type: 'constant',
value: '',
selector: [],
value: [],
},
} as FormInputItem,
]}
@@ -105,7 +128,68 @@ describe('ContentItem', () => {
/>,
)
expect(container.querySelector('[data-testid="content-item-textarea"]')).not.toBeInTheDocument()
expect(container.querySelector('.py-3')?.textContent).toBe('')
await user.click(screen.getByTestId('renderer-select'))
expect(mockOnInputChange).toHaveBeenCalledWith('user_bio', 'select')
})
it('should delegate single-file fields to the shared renderer', async () => {
const user = userEvent.setup()
render(
<ContentItem
content="{{#$output.attachment#}}"
formInputFields={[
{
type: 'file',
output_variable_name: 'attachment',
allowed_file_extensions: ['.pdf'],
allowed_file_types: ['document'],
allowed_file_upload_methods: ['local_file'],
} as FormInputItem,
]}
inputs={{ attachment: null }}
onInputChange={mockOnInputChange}
/>,
)
await user.click(screen.getByTestId('renderer-file'))
expect(mockOnInputChange).toHaveBeenCalledWith('attachment', 'file')
})
it('should delegate file-list fields to the shared renderer', async () => {
const user = userEvent.setup()
const existingFiles: FileEntity[] = [{
id: 'file-1',
name: 'brief.pdf',
size: 128,
type: 'document',
progress: 100,
transferMethod: TransferMethod.local_file,
supportFileType: 'document',
}]
render(
<ContentItem
content="{{#$output.attachments#}}"
formInputFields={[
{
type: 'file-list',
output_variable_name: 'attachments',
allowed_file_extensions: ['.pdf'],
allowed_file_types: ['document'],
allowed_file_upload_methods: ['local_file'],
number_limits: 4,
} as FormInputItem,
]}
inputs={{ attachments: existingFiles }}
onInputChange={mockOnInputChange}
/>,
)
await user.click(screen.getByTestId('renderer-file-list'))
expect(mockOnInputChange).toHaveBeenCalledWith('attachments', 'file-list')
})
})
@@ -0,0 +1,278 @@
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
import HumanInputFieldRenderer from '../field-renderer'
function MockTextarea({
value,
onChange,
onValueChange,
...props
}: {
value: string
onChange?: (event: { target: { value: string } }) => void
onValueChange?: (value: string) => void
} & React.TextareaHTMLAttributes<HTMLTextAreaElement>) {
return (
<textarea
data-testid="content-item-textarea"
value={value}
onChange={(event) => {
onChange?.({ target: { value: event.target.value } })
onValueChange?.(event.target.value)
}}
{...props}
/>
)
}
vi.mock('@langgenius/dify-ui/textarea', () => ({
Textarea: MockTextarea,
}))
vi.mock('@langgenius/dify-ui/select', () => ({
Select: ({ children, onValueChange }: { children: React.ReactNode, onValueChange: (value: string | null) => void }) => (
<div>
<button type="button" data-testid="content-item-select-root" onClick={() => onValueChange('alice')}>select alice</button>
<button type="button" data-testid="content-item-select-null" onClick={() => onValueChange(null)}>select null</button>
{children}
</div>
),
SelectTrigger: ({ children }: { children: React.ReactNode }) => <button type="button" data-testid="content-item-select">{children}</button>,
SelectContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SelectItem: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SelectItemText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
SelectItemIndicator: () => <span>selected</span>,
}))
vi.mock('@/app/components/base/file-uploader', () => ({
FileUploaderInAttachmentWrapper: ({ value, onChange, fileConfig }: {
value?: FileEntity[]
onChange: (files: FileEntity[]) => void
fileConfig: { number_limits?: number }
}) => (
<>
<button
type="button"
data-testid={`content-item-file-${fileConfig.number_limits ?? 0}`}
onClick={() => onChange([{ id: 'file-1', name: 'report.pdf', size: 1, type: 'document', progress: 100, transferMethod: TransferMethod.local_file, supportFileType: 'document' }])}
>
{(value || []).map(file => file.name).join(',')}
</button>
<button
type="button"
data-testid={`content-item-file-clear-${fileConfig.number_limits ?? 0}`}
onClick={() => onChange([])}
>
clear
</button>
</>
),
}))
describe('HumanInputFieldRenderer', () => {
it('renders paragraph input and emits string changes', async () => {
const onChange = vi.fn()
render(
<HumanInputFieldRenderer
field={{
type: InputVarType.paragraph,
output_variable_name: 'summary',
default: { type: 'constant', selector: [], value: '' },
}}
value="hello"
onChange={onChange}
/>,
)
fireEvent.change(screen.getByTestId('content-item-textarea'), {
target: { value: 'hello world' },
})
expect(onChange).toHaveBeenLastCalledWith('hello world')
})
it('renders paragraph input with an accessible name', () => {
render(
<HumanInputFieldRenderer
field={{
type: InputVarType.paragraph,
output_variable_name: 'summary',
default: { type: 'constant', selector: [], value: '' },
}}
value="hello"
onChange={vi.fn()}
/>,
)
expect(screen.getByLabelText('summary')).toHaveValue('hello')
})
it('renders paragraph input with an empty value when the current value is not a string', () => {
render(
<HumanInputFieldRenderer
field={{
type: InputVarType.paragraph,
output_variable_name: 'summary',
default: { type: 'constant', selector: [], value: '' },
}}
value={null}
onChange={vi.fn()}
/>,
)
expect(screen.getByTestId('content-item-textarea')).toHaveValue('')
})
it('renders select input and emits selected values', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<HumanInputFieldRenderer
field={{
type: InputVarType.select,
output_variable_name: 'reviewer',
option_source: { type: 'constant', selector: [], value: ['alice', 'bob'] },
}}
value=""
onChange={onChange}
/>,
)
await user.click(screen.getByTestId('content-item-select-root'))
expect(onChange).toHaveBeenCalledWith('alice')
})
it('ignores null select values', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<HumanInputFieldRenderer
field={{
type: InputVarType.select,
output_variable_name: 'reviewer',
option_source: { type: 'constant', selector: [], value: ['alice', 'bob'] },
}}
value={null}
onChange={onChange}
/>,
)
expect(screen.getByTestId('content-item-select')).toHaveTextContent('')
await user.click(screen.getByTestId('content-item-select-null'))
expect(onChange).not.toHaveBeenCalled()
})
it('renders single-file input and emits one file', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<HumanInputFieldRenderer
field={{
type: InputVarType.singleFile,
output_variable_name: 'attachment',
allowed_file_extensions: ['.pdf'],
allowed_file_types: [SupportUploadFileTypes.document],
allowed_file_upload_methods: [TransferMethod.local_file],
}}
value={null}
onChange={onChange}
/>,
)
await user.click(screen.getByTestId('content-item-file-1'))
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ name: 'report.pdf' }))
})
it('renders existing single-file values and emits null when cleared', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<HumanInputFieldRenderer
field={{
type: InputVarType.singleFile,
output_variable_name: 'attachment',
allowed_file_extensions: ['.pdf'],
allowed_file_types: [SupportUploadFileTypes.document],
allowed_file_upload_methods: [TransferMethod.local_file],
}}
value={{ id: 'file-2', name: 'existing.pdf', size: 1, type: 'document', progress: 100, transferMethod: TransferMethod.local_file, supportFileType: 'document' }}
onChange={onChange}
/>,
)
expect(screen.getByTestId('content-item-file-1')).toHaveTextContent('existing.pdf')
await user.click(screen.getByTestId('content-item-file-clear-1'))
expect(onChange).toHaveBeenCalledWith(null)
})
it('renders file-list input and emits file arrays with max count', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<HumanInputFieldRenderer
field={{
type: InputVarType.multiFiles,
output_variable_name: 'attachments',
allowed_file_extensions: ['.pdf'],
allowed_file_types: [SupportUploadFileTypes.document],
allowed_file_upload_methods: [TransferMethod.local_file],
number_limits: 3,
}}
value={[]}
onChange={onChange}
/>,
)
await user.click(screen.getByTestId('content-item-file-3'))
expect(onChange).toHaveBeenCalledWith([expect.objectContaining({ name: 'report.pdf' })])
})
it('uses the default max count for file-list inputs without an explicit limit', () => {
render(
<HumanInputFieldRenderer
field={{
type: InputVarType.multiFiles,
output_variable_name: 'attachments',
allowed_file_extensions: ['.pdf'],
allowed_file_types: [SupportUploadFileTypes.document],
allowed_file_upload_methods: [TransferMethod.local_file],
}}
value={null}
onChange={vi.fn()}
/>,
)
expect(screen.getByTestId('content-item-file-5')).toBeInTheDocument()
})
it('renders nothing for unsupported input types', () => {
const { container } = render(
<HumanInputFieldRenderer
field={{
type: 'unsupported',
output_variable_name: 'unknown',
} as unknown as Parameters<typeof HumanInputFieldRenderer>[0]['field']}
value=""
onChange={vi.fn()}
/>,
)
expect(container).toBeEmptyDOMElement()
})
})
@@ -4,13 +4,74 @@ import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { UserActionButtonType } from '@/app/components/workflow/nodes/human-input/types'
import { InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
import HumanInputForm from '../human-input-form'
vi.mock('../content-item', () => ({
default: ({ content, onInputChange }: { content: string, onInputChange: (name: string, value: string) => void }) => (
default: ({ content, onInputChange }: { content: string, onInputChange: (name: string, value: unknown) => void }) => (
<div data-testid="mock-content-item">
{content}
<button data-testid="update-input" onClick={() => onInputChange('field1', 'new value')}>Update</button>
<button data-testid="update-select" onClick={() => onInputChange('field2', 'approved')}>Update Select</button>
<button
data-testid="update-single-file"
onClick={() => onInputChange('field4', {
id: 'file-2',
name: 'main.png',
size: 256,
type: 'image/png',
progress: 100,
transferMethod: TransferMethod.local_file,
supportFileType: 'image',
uploadedId: 'upload-file-2',
})}
>
Update Single File
</button>
<button
data-testid="update-pending-single-file"
onClick={() => onInputChange('field4', {
id: 'file-2',
name: 'main.png',
size: 256,
type: 'image/png',
progress: 50,
transferMethod: TransferMethod.local_file,
supportFileType: 'image',
})}
>
Update Pending Single File
</button>
<button
data-testid="update-input-file"
onClick={() => onInputChange('field3', [{
id: 'file-1',
name: 'avatar.png',
size: 128,
type: 'image/png',
progress: 100,
transferMethod: TransferMethod.local_file,
supportFileType: 'image',
uploadedId: 'upload-file-1',
}])}
>
Update File
</button>
<button
data-testid="update-pending-input-file"
onClick={() => onInputChange('field3', [{
id: 'file-1',
name: 'avatar.png',
size: 128,
type: 'image/png',
progress: 50,
transferMethod: TransferMethod.local_file,
supportFileType: 'image',
}])}
>
Update Pending File
</button>
</div>
),
}))
@@ -50,12 +111,10 @@ describe('HumanInputForm', () => {
expect(contentItems[1])!.toHaveTextContent('{{#$output.field1#}}')
expect(contentItems[2])!.toHaveTextContent('Part 2')
const buttons = screen.getAllByRole('button').filter(button => button.textContent !== 'Update')
expect(buttons).toHaveLength(4)
expect(buttons[0])!.toHaveTextContent('Submit')
expect(buttons[1])!.toHaveTextContent('Cancel')
expect(buttons[2])!.toHaveTextContent('Accent')
expect(buttons[3])!.toHaveTextContent('Ghost')
expect(screen.getByRole('button', { name: 'Submit' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Accent' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Ghost' })).toBeInTheDocument()
})
it('should handle input changes and submit correctly', async () => {
@@ -76,6 +135,175 @@ describe('HumanInputForm', () => {
})
})
it('should submit file field values using the backend payload shape', async () => {
const user = userEvent.setup()
const mockOnSubmit = vi.fn().mockResolvedValue(undefined)
const formDataWithFileList: HumanInputFormData = {
...mockFormData,
form_content: '{{#$output.field1#}} {{#$output.field3#}}',
inputs: [
{
type: InputVarType.paragraph,
output_variable_name: 'field1',
default: { type: 'constant', value: 'initial', selector: [] },
},
{
type: InputVarType.multiFiles,
output_variable_name: 'field3',
allowed_file_extensions: ['.png'],
allowed_file_types: [SupportUploadFileTypes.image],
allowed_file_upload_methods: [TransferMethod.local_file],
number_limits: 5,
},
] as FormInputItem[],
}
render(<HumanInputForm formData={formDataWithFileList} onSubmit={mockOnSubmit} />)
await user.click(screen.getAllByTestId('update-input')[0]!)
await user.click(screen.getAllByTestId('update-input-file')[0]!)
await user.click(screen.getByRole('button', { name: 'Submit' }))
expect(mockOnSubmit).toHaveBeenCalledWith('token_123', {
action: 'action_1',
inputs: {
field1: 'new value',
field3: [{
type: 'image',
transfer_method: TransferMethod.local_file,
url: '',
upload_file_id: 'upload-file-1',
}],
},
})
})
it('should disable buttons until select, file, and file list inputs have uploaded values', async () => {
const user = userEvent.setup()
const mockOnSubmit = vi.fn().mockResolvedValue(undefined)
const formDataWithRequiredInteractiveFields: HumanInputFormData = {
...mockFormData,
form_content: '{{#$output.field2#}} {{#$output.field3#}} {{#$output.field4#}}',
inputs: [
{
type: InputVarType.select,
output_variable_name: 'field2',
option_source: {
type: 'constant',
value: ['approved'],
selector: [],
},
},
{
type: InputVarType.multiFiles,
output_variable_name: 'field3',
allowed_file_extensions: ['.png'],
allowed_file_types: [SupportUploadFileTypes.image],
allowed_file_upload_methods: [TransferMethod.local_file],
number_limits: 5,
},
{
type: InputVarType.singleFile,
output_variable_name: 'field4',
allowed_file_extensions: ['.png'],
allowed_file_types: [SupportUploadFileTypes.image],
allowed_file_upload_methods: [TransferMethod.local_file],
},
] as FormInputItem[],
}
render(<HumanInputForm formData={formDataWithRequiredInteractiveFields} onSubmit={mockOnSubmit} />)
const submitButton = screen.getByRole('button', { name: 'Submit' })
expect(submitButton).toBeDisabled()
await user.click(screen.getAllByTestId('update-select')[0]!)
await user.click(screen.getAllByTestId('update-pending-single-file')[0]!)
await user.click(screen.getAllByTestId('update-input-file')[0]!)
expect(submitButton).toBeDisabled()
await user.click(screen.getAllByTestId('update-single-file')[0]!)
await user.click(screen.getAllByTestId('update-pending-input-file')[0]!)
expect(submitButton).toBeDisabled()
await user.click(screen.getAllByTestId('update-input-file')[0]!)
expect(submitButton).toBeEnabled()
await user.click(submitButton)
expect(mockOnSubmit).toHaveBeenCalledWith('token_123', {
action: 'action_1',
inputs: {
field2: 'approved',
field3: [{
type: 'image',
transfer_method: TransferMethod.local_file,
url: '',
upload_file_id: 'upload-file-1',
}],
field4: {
type: 'image',
transfer_method: TransferMethod.local_file,
url: '',
upload_file_id: 'upload-file-2',
},
},
})
})
it('should ignore input fields that are not rendered in form content when checking actions', async () => {
const user = userEvent.setup()
const mockOnSubmit = vi.fn().mockResolvedValue(undefined)
const formDataWithStaleInput: HumanInputFormData = {
...mockFormData,
form_content: '{{#$output.field2#}}',
inputs: [
{
type: InputVarType.select,
output_variable_name: 'field2',
option_source: {
type: 'constant',
value: ['approved'],
selector: [],
},
},
{
type: InputVarType.select,
output_variable_name: 'stale_select',
option_source: {
type: 'constant',
value: ['unused'],
selector: [],
},
},
{
type: InputVarType.singleFile,
output_variable_name: 'stale_file',
allowed_file_extensions: ['.png'],
allowed_file_types: [SupportUploadFileTypes.image],
allowed_file_upload_methods: [TransferMethod.local_file],
},
] as FormInputItem[],
}
render(<HumanInputForm formData={formDataWithStaleInput} onSubmit={mockOnSubmit} />)
const submitButton = screen.getByRole('button', { name: 'Submit' })
expect(submitButton).toBeDisabled()
await user.click(screen.getByTestId('update-select'))
expect(submitButton).toBeEnabled()
await user.click(submitButton)
expect(mockOnSubmit).toHaveBeenCalledWith('token_123', {
action: 'action_1',
inputs: {
field2: 'approved',
},
})
})
it('should disable buttons during submission', async () => {
const user = userEvent.setup()
let resolveSubmit: (value: void | PromiseLike<void>) => void
@@ -109,19 +337,21 @@ describe('HumanInputForm', () => {
expect(screen.getAllByTestId('mock-content-item')).toHaveLength(3)
})
it('should handle unsupported input types in initializeInputs', () => {
it('should handle mixed supported input types in initializeInputs', () => {
const formDataWithUnsupported = {
...mockFormData,
inputs: [
{
type: 'text-input',
type: InputVarType.select,
output_variable_name: 'field2',
default: { type: 'variable', value: '', selector: [] },
option_source: { type: 'variable', value: [], selector: [] },
} as FormInputItem,
{
type: 'number',
type: InputVarType.singleFile,
output_variable_name: 'field3',
default: { type: 'constant', value: '0', selector: [] },
allowed_file_extensions: [],
allowed_file_types: [],
allowed_file_upload_methods: [],
} as FormInputItem,
],
resolved_default_values: { field2: 'default value' },
@@ -0,0 +1,181 @@
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import type { FileResponse } from '@/types/workflow'
import { render, screen } from '@testing-library/react'
import { InputVarType } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
import SubmittedContentItem from '../submitted-content-item'
const attachmentValue: FileResponse = {
related_id: 'file-1',
upload_file_id: 'upload-1',
filename: 'decision.pdf',
extension: 'pdf',
size: 128,
mime_type: 'application/pdf',
transfer_method: TransferMethod.local_file,
type: 'document',
url: 'https://example.com/decision.pdf',
remote_url: '',
}
const evidenceValues: FileResponse[] = [
{
related_id: 'file-2',
upload_file_id: 'upload-2',
filename: 'evidence.png',
extension: 'png',
size: 256,
mime_type: 'image/png',
transfer_method: TransferMethod.remote_url,
type: 'image',
url: 'https://example.com/evidence.png',
remote_url: 'https://example.com/evidence.png',
},
{
related_id: 'file-3',
upload_file_id: 'upload-3',
filename: 'evidence.pdf',
extension: 'pdf',
size: 512,
mime_type: 'application/pdf',
transfer_method: TransferMethod.local_file,
type: 'document',
url: 'https://example.com/evidence.pdf',
remote_url: '',
},
]
const fields: FormInputItem[] = [
{
type: InputVarType.paragraph,
output_variable_name: 'summary',
default: { type: 'constant', value: '', selector: [] },
},
{
type: InputVarType.select,
output_variable_name: 'decision',
option_source: { type: 'constant', value: ['approve', 'reject'], selector: [] },
},
{
type: InputVarType.singleFile,
output_variable_name: 'attachment',
allowed_file_extensions: [],
allowed_file_types: [],
allowed_file_upload_methods: [],
},
{
type: InputVarType.multiFiles,
output_variable_name: 'evidence',
allowed_file_extensions: [],
allowed_file_types: [],
allowed_file_upload_methods: [],
number_limits: 5,
},
]
describe('SubmittedContentItem', () => {
it('renders file-list output placeholders as readonly file lists', () => {
render(
<SubmittedContentItem
content="{{#$output.evidence#}}"
formInputFields={fields}
values={{ evidence: evidenceValues }}
/>,
)
expect(screen.getByTestId('submitted-field-evidence')).toBeInTheDocument()
expect(screen.getByRole('img', { name: 'Preview' })).toHaveAttribute('src', 'https://example.com/evidence.png')
expect(screen.getByText('evidence.pdf')).toBeInTheDocument()
})
it('renders empty text for paragraph and select placeholders with non-string values', () => {
const { rerender } = render(
<SubmittedContentItem
content="{{#$output.summary#}}"
formInputFields={fields}
values={{ summary: attachmentValue }}
/>,
)
expect(screen.getByTestId('submitted-field-summary')).toHaveTextContent('')
rerender(
<SubmittedContentItem
content="{{#$output.decision#}}"
formInputFields={fields}
values={{ decision: evidenceValues }}
/>,
)
expect(screen.getByRole('combobox', { name: 'decision' })).toHaveTextContent('')
})
it('renders nothing when output placeholders do not have a usable submitted value', () => {
const { container, rerender } = render(
<SubmittedContentItem
content="{{#$output.missing#}}"
formInputFields={fields}
values={{ missing: 'value' }}
/>,
)
expect(container).toBeEmptyDOMElement()
rerender(
<SubmittedContentItem
content="{{#$output.attachment#}}"
formInputFields={fields}
values={{}}
/>,
)
expect(container).toBeEmptyDOMElement()
})
it('skips file placeholders when submitted values have incompatible shapes', () => {
const { container, rerender } = render(
<SubmittedContentItem
content="{{#$output.attachment#}}"
formInputFields={fields}
values={{ attachment: 'not-a-file' }}
/>,
)
expect(container).toBeEmptyDOMElement()
rerender(
<SubmittedContentItem
content="{{#$output.attachment#}}"
formInputFields={fields}
values={{ attachment: evidenceValues }}
/>,
)
expect(container).toBeEmptyDOMElement()
rerender(
<SubmittedContentItem
content="{{#$output.evidence#}}"
formInputFields={fields}
values={{ evidence: attachmentValue }}
/>,
)
expect(container).toBeEmptyDOMElement()
})
it('renders nothing for unsupported output field types', () => {
const { container } = render(
<SubmittedContentItem
content="{{#$output.unknown#}}"
formInputFields={[{
type: 'unsupported',
output_variable_name: 'unknown',
} as unknown as FormInputItem]}
values={{ unknown: 'value' }}
/>,
)
expect(container).toBeEmptyDOMElement()
})
})
@@ -0,0 +1,140 @@
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import type { FileResponse, HumanInputFormValue } from '@/types/workflow'
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { InputVarType } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
import SubmittedFieldValues from '../submitted-field-values'
const fields: FormInputItem[] = [
{
type: InputVarType.paragraph,
output_variable_name: 'summary',
default: { type: 'constant', value: '', selector: [] },
},
{
type: InputVarType.select,
output_variable_name: 'decision',
option_source: { type: 'constant', value: ['approve', 'reject'], selector: [] },
},
{
type: InputVarType.singleFile,
output_variable_name: 'attachment',
allowed_file_extensions: [],
allowed_file_types: [],
allowed_file_upload_methods: [],
},
{
type: InputVarType.multiFiles,
output_variable_name: 'evidence',
allowed_file_extensions: [],
allowed_file_types: [],
allowed_file_upload_methods: [],
number_limits: 5,
},
]
const attachmentValue: FileResponse = {
related_id: 'file-1',
upload_file_id: 'upload-1',
filename: 'decision.pdf',
extension: 'pdf',
size: 128,
mime_type: 'application/pdf',
transfer_method: TransferMethod.local_file,
type: 'document',
url: 'https://example.com/decision.pdf',
remote_url: '',
}
const evidenceValues: FileResponse[] = [
{
related_id: 'file-2',
upload_file_id: 'upload-2',
filename: 'evidence-1.png',
extension: 'png',
size: 256,
mime_type: 'image/png',
transfer_method: TransferMethod.remote_url,
type: 'image',
url: 'https://example.com/evidence-1.png',
remote_url: 'https://example.com/evidence-1.png',
},
{
related_id: 'file-3',
upload_file_id: 'upload-3',
filename: 'evidence-2.pdf',
extension: 'pdf',
size: 512,
mime_type: 'application/pdf',
transfer_method: TransferMethod.local_file,
type: 'document',
url: 'https://example.com/evidence-2.pdf',
remote_url: '',
},
]
const values: Record<string, HumanInputFormValue> = {
summary: 'Need more context',
decision: 'approve',
attachment: attachmentValue,
evidence: evidenceValues,
}
describe('SubmittedFieldValues', () => {
it('renders text and select values as text', () => {
render(<SubmittedFieldValues fields={fields} values={values} />)
expect(screen.getByTestId('submitted-field-summary')).toHaveTextContent('Need more context')
expect(screen.getByTestId('submitted-field-decision')).toHaveTextContent('approve')
})
it('renders file and file-list values as file lists', () => {
render(<SubmittedFieldValues fields={fields} values={values} />)
expect(screen.getByTestId('submitted-field-attachment')).toHaveTextContent('decision.pdf')
expect(screen.getByRole('img', { name: 'Preview' })).toHaveAttribute('src', 'https://example.com/evidence-1.png')
expect(screen.getByText('evidence-2.pdf')).toBeInTheDocument()
expect(screen.getAllByTestId('file-list')).toHaveLength(2)
})
it('skips fields with missing values', () => {
render(<SubmittedFieldValues fields={fields} values={{ summary: 'Only one field' }} />)
expect(screen.getByTestId('submitted-field-summary')).toHaveTextContent('Only one field')
expect(screen.queryByTestId('submitted-field-decision')).not.toBeInTheDocument()
expect(screen.queryByTestId('submitted-field-attachment')).not.toBeInTheDocument()
})
it('infers value types when field definitions are unavailable', () => {
render(
<SubmittedFieldValues
values={{
summary: 'Unstructured summary',
attachment: attachmentValue,
evidence: evidenceValues,
}}
/>,
)
expect(screen.getByTestId('submitted-field-summary')).toHaveTextContent('Unstructured summary')
expect(screen.getByTestId('submitted-field-attachment')).toHaveTextContent('decision.pdf')
expect(screen.getByRole('img', { name: 'Preview' })).toHaveAttribute('src', 'https://example.com/evidence-1.png')
expect(screen.getByText('evidence-2.pdf')).toBeInTheDocument()
})
it('skips file fields when submitted values have incompatible shapes', () => {
render(
<SubmittedFieldValues
fields={fields}
values={{
attachment: 'not-a-file',
evidence: attachmentValue,
}}
/>,
)
expect(screen.queryByTestId('submitted-field-attachment')).not.toBeInTheDocument()
expect(screen.queryByTestId('submitted-field-evidence')).not.toBeInTheDocument()
})
})
@@ -1,6 +1,8 @@
import type { HumanInputFilledFormData } from '@/types/workflow'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { InputVarType } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
import { SubmittedHumanInputContent } from '../submitted'
vi.mock('@/app/components/base/markdown', () => ({
@@ -28,4 +30,99 @@ describe('SubmittedHumanInputContent Integration', () => {
// Trans component for triggered action. The mock usually renders the key.
expect(screen.getByText('nodes.humanInput.userActions.triggered')).toBeInTheDocument()
})
it('should prefer structured form data over rendered markdown when available', () => {
render(
<SubmittedHumanInputContent formData={{
...mockFormData,
form_content: 'Decision: {{#$output.answer#}}',
inputs: [{
type: InputVarType.paragraph,
output_variable_name: 'answer',
default: { type: 'constant', value: '', selector: [] },
}],
submitted_data: {
answer: 'approved',
},
}}
/>,
)
expect(screen.getByTestId('submitted-form-content')).toBeInTheDocument()
expect(screen.getByTestId('submitted-field-answer')).toHaveTextContent('approved')
expect(screen.queryByTestId('submitted-content')).not.toBeInTheDocument()
})
it('should render submitted select and file fields with the original form layout', () => {
render(
<SubmittedHumanInputContent formData={{
...mockFormData,
form_content: '{{#$output.decision#}} {{#$output.attachment#}}',
inputs: [
{
type: InputVarType.select,
output_variable_name: 'decision',
option_source: { type: 'constant', value: ['approve', 'reject'], selector: [] },
},
{
type: InputVarType.singleFile,
output_variable_name: 'attachment',
allowed_file_extensions: [],
allowed_file_types: [],
allowed_file_upload_methods: [],
},
],
submitted_data: {
decision: 'approve',
attachment: {
related_id: 'file-1',
upload_file_id: 'upload-1',
filename: 'decision.pdf',
extension: 'pdf',
size: 128,
mime_type: 'application/pdf',
transfer_method: TransferMethod.local_file,
type: 'document',
url: 'https://example.com/decision.pdf',
remote_url: '',
},
},
}}
/>,
)
expect(screen.getByRole('combobox', { name: 'decision' })).toBeDisabled()
expect(screen.getByRole('combobox', { name: 'decision' })).toHaveTextContent('approve')
expect(screen.getByTestId('submitted-field-attachment')).toHaveTextContent('decision.pdf')
})
it('should fallback to rendered markdown when structured form data is empty', () => {
render(
<SubmittedHumanInputContent formData={{
...mockFormData,
submitted_data: {},
}}
/>,
)
expect(screen.getByTestId('submitted-content')).toBeInTheDocument()
expect(screen.getByTestId('mock-markdown')).toHaveTextContent('Rendered **Markdown** content')
expect(screen.queryByTestId('submitted-field-values')).not.toBeInTheDocument()
})
it('should render submitted field values when original form layout is unavailable', () => {
render(
<SubmittedHumanInputContent formData={{
...mockFormData,
submitted_data: {
answer: 'approved',
},
}}
/>,
)
expect(screen.getByTestId('submitted-field-values')).toBeInTheDocument()
expect(screen.getByTestId('submitted-field-answer')).toHaveTextContent('approved')
expect(screen.queryByTestId('submitted-content')).not.toBeInTheDocument()
})
})
@@ -1,29 +1,78 @@
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import type { Locale } from '@/i18n-config/language'
import { UserActionButtonType } from '@/app/components/workflow/nodes/human-input/types'
import { InputVarType } from '@/app/components/workflow/types'
import { InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
import {
getButtonStyle,
getFormContentInputNames,
getProcessedHumanInputFormInputs,
getRelativeTime,
getRenderedFormInputs,
hasInvalidRequiredHumanInput,
hasInvalidSelectOrFileInput,
initializeInputs,
isRelativeTimeSameOrAfter,
splitByOutputVar,
} from '../utils'
const createInput = (overrides: Partial<FormInputItem>): FormInputItem => ({
label: 'field',
variable: 'field',
required: false,
max_length: 128,
type: InputVarType.textInput,
default: {
type: 'constant' as const,
value: '',
selector: [], // Dummy selector
},
const paragraphInput = (overrides: Partial<Extract<FormInputItem, { type: InputVarType.paragraph }>> = {}): FormInputItem => ({
type: InputVarType.paragraph,
output_variable_name: 'field',
default: {
type: 'constant',
value: '',
selector: [],
},
...overrides,
} as unknown as FormInputItem)
})
const selectInput = (overrides: Partial<Extract<FormInputItem, { type: InputVarType.select }>> = {}): FormInputItem => ({
type: InputVarType.select,
output_variable_name: 'field',
option_source: {
type: 'constant',
value: ['option-a', 'option-b'],
selector: [],
},
...overrides,
})
const fileInput = (overrides: Partial<Extract<FormInputItem, { type: InputVarType.singleFile }>> = {}): FormInputItem => ({
type: InputVarType.singleFile,
output_variable_name: 'field',
allowed_file_extensions: [],
allowed_file_types: [SupportUploadFileTypes.image],
allowed_file_upload_methods: [TransferMethod.local_file],
...overrides,
})
const fileListInput = (overrides: Partial<Extract<FormInputItem, { type: InputVarType.multiFiles }>> = {}): FormInputItem => ({
type: InputVarType.multiFiles,
output_variable_name: 'field',
allowed_file_extensions: [],
allowed_file_types: [SupportUploadFileTypes.image],
allowed_file_upload_methods: [TransferMethod.local_file],
number_limits: 5,
...overrides,
})
const uploadedFile = {
id: 'file-1',
name: 'avatar.png',
size: 128,
type: 'image',
progress: 100,
transferMethod: TransferMethod.local_file,
supportFileType: 'image',
uploadedId: 'upload-file-1',
}
const uploadingFile = {
...uploadedFile,
uploadedId: undefined,
progress: 50,
}
describe('human-input utils', () => {
describe('getButtonStyle', () => {
@@ -53,16 +102,31 @@ describe('human-input utils', () => {
})
})
describe('form content inputs', () => {
it('should extract and filter input fields rendered in form content', () => {
const formInputs: FormInputItem[] = [
selectInput({ output_variable_name: 'visible_select' }),
paragraphInput({ output_variable_name: 'visible_paragraph' }),
fileInput({ output_variable_name: 'stale_file' }),
]
const content = 'Select {{#$output.visible_select#}} and write {{#$output.visible_paragraph#}}'
expect(getFormContentInputNames(content)).toEqual(['visible_select', 'visible_paragraph'])
expect(getRenderedFormInputs(formInputs, content).map(input => input.output_variable_name)).toEqual([
'visible_select',
'visible_paragraph',
])
})
})
describe('initializeInputs', () => {
it('should initialize text fields with constants and variable defaults', () => {
const formInputs = [
createInput({
type: InputVarType.textInput,
it('should initialize paragraph fields with constants and variable defaults', () => {
const formInputs: FormInputItem[] = [
paragraphInput({
output_variable_name: 'name',
default: { type: 'constant', value: 'John', selector: [] },
}),
createInput({
type: InputVarType.paragraph,
paragraphInput({
output_variable_name: 'bio',
default: { type: 'variable', value: '', selector: [] },
}),
@@ -74,23 +138,45 @@ describe('human-input utils', () => {
})
})
it('should set non text-like inputs to undefined', () => {
const formInputs = [
createInput({
type: InputVarType.select,
it('should initialize select fields with empty strings', () => {
const formInputs: FormInputItem[] = [
selectInput({
output_variable_name: 'role',
}),
]
expect(initializeInputs(formInputs)).toEqual({
role: undefined,
role: '',
})
})
it('should initialize single file fields with null', () => {
const formInputs: FormInputItem[] = [
fileInput({
output_variable_name: 'avatar',
}),
]
expect(initializeInputs(formInputs)).toEqual({
avatar: null,
})
})
it('should initialize file list fields with empty arrays', () => {
const formInputs: FormInputItem[] = [
fileListInput({
output_variable_name: 'attachments',
}),
]
expect(initializeInputs(formInputs)).toEqual({
attachments: [],
})
})
it('should fallback to empty string when variable default is missing', () => {
const formInputs = [
createInput({
type: InputVarType.textInput,
const formInputs: FormInputItem[] = [
paragraphInput({
output_variable_name: 'summary',
default: { type: 'variable', value: '', selector: [] },
}),
@@ -100,6 +186,200 @@ describe('human-input utils', () => {
summary: '',
})
})
it('should fallback to default paragraph values when resolved defaults are not strings', () => {
const formInputs: FormInputItem[] = [
paragraphInput({
output_variable_name: 'summary',
default: { type: 'variable', value: '', selector: [] },
}),
]
expect(initializeInputs(formInputs, {
summary: {
related_id: 'file-1',
size: 128,
extension: '.pdf',
filename: 'brief.pdf',
mime_type: 'application/pdf',
transfer_method: TransferMethod.local_file,
type: 'document',
url: 'https://example.com/brief.pdf',
upload_file_id: 'upload-file-1',
remote_url: '',
},
})).toEqual({
summary: '',
})
})
it('should ignore resolved defaults for non-paragraph fields', () => {
const formInputs: FormInputItem[] = [
selectInput({
output_variable_name: 'role',
}),
fileInput({
output_variable_name: 'avatar',
}),
fileListInput({
output_variable_name: 'attachments',
}),
]
expect(initializeInputs(formInputs, {
role: 'maintainer',
avatar: {
related_id: 'file-2',
size: 64,
extension: '.png',
filename: 'avatar.png',
mime_type: 'image/png',
transfer_method: TransferMethod.local_file,
type: 'image',
url: 'https://example.com/avatar.png',
upload_file_id: 'upload-file-2',
remote_url: '',
},
attachments: [{
related_id: 'file-3',
size: 16,
extension: '.txt',
filename: 'notes.txt',
mime_type: 'text/plain',
transfer_method: TransferMethod.local_file,
type: 'document',
url: 'https://example.com/notes.txt',
upload_file_id: 'upload-file-3',
remote_url: '',
}],
})).toEqual({
role: '',
avatar: null,
attachments: [],
})
})
it('should ignore unsupported input types', () => {
expect(initializeInputs([
{
type: 'unsupported',
output_variable_name: 'unknown',
} as unknown as FormInputItem,
])).toEqual({})
})
})
describe('required input checks', () => {
it('should ignore fields that are not present in values', () => {
const formInputs: FormInputItem[] = [
selectInput({ output_variable_name: 'visible_select' }),
fileInput({ output_variable_name: 'stale_file' }),
paragraphInput({ output_variable_name: 'stale_paragraph' }),
]
const values = {
visible_select: 'approved',
}
expect(hasInvalidSelectOrFileInput(formInputs, values)).toBe(false)
expect(hasInvalidRequiredHumanInput(formInputs, values)).toBe(false)
})
it('should detect empty select values and unuploaded file values', () => {
expect(hasInvalidSelectOrFileInput([
selectInput({ output_variable_name: 'decision' }),
], {
decision: '',
})).toBe(true)
expect(hasInvalidRequiredHumanInput([
paragraphInput({ output_variable_name: 'summary' }),
], {
summary: ' ',
})).toBe(true)
expect(hasInvalidSelectOrFileInput([
fileInput({ output_variable_name: 'attachment' }),
], {
attachment: uploadingFile,
})).toBe(true)
expect(hasInvalidRequiredHumanInput([
fileListInput({ output_variable_name: 'attachments' }),
], {
attachments: [uploadedFile, uploadingFile],
})).toBe(true)
})
it('should accept uploaded single and multiple file values', () => {
expect(hasInvalidSelectOrFileInput([
fileInput({ output_variable_name: 'attachment' }),
fileListInput({ output_variable_name: 'attachments' }),
], {
attachment: [uploadedFile],
attachments: [uploadedFile],
})).toBe(false)
expect(hasInvalidRequiredHumanInput([
fileInput({ output_variable_name: 'attachment' }),
fileListInput({ output_variable_name: 'attachments' }),
], {
attachment: [uploadedFile],
attachments: [uploadedFile],
})).toBe(false)
})
it('should treat unsupported input types as valid by default', () => {
const unsupportedInput = {
type: 'unsupported',
output_variable_name: 'unknown',
} as unknown as FormInputItem
expect(hasInvalidSelectOrFileInput([unsupportedInput], {
unknown: 'value',
})).toBe(false)
expect(hasInvalidRequiredHumanInput([unsupportedInput], {
unknown: 'value',
})).toBe(false)
})
})
describe('getProcessedHumanInputFormInputs', () => {
it('should return undefined when no values are provided', () => {
expect(getProcessedHumanInputFormInputs([], undefined)).toBeUndefined()
})
it('should process file values and fallback invalid file values', () => {
expect(getProcessedHumanInputFormInputs([
fileInput({ output_variable_name: 'attachment' }),
fileInput({ output_variable_name: 'attachmentFromArray' }),
fileInput({ output_variable_name: 'emptyAttachment' }),
fileListInput({ output_variable_name: 'attachments' }),
fileListInput({ output_variable_name: 'emptyAttachments' }),
], {
attachment: uploadedFile,
attachmentFromArray: [uploadedFile],
emptyAttachment: '',
attachments: [uploadedFile],
emptyAttachments: null,
})).toEqual({
attachment: {
type: 'image',
transfer_method: TransferMethod.local_file,
url: '',
upload_file_id: 'upload-file-1',
},
attachmentFromArray: {
type: 'image',
transfer_method: TransferMethod.local_file,
url: '',
upload_file_id: 'upload-file-1',
},
emptyAttachment: undefined,
attachments: [{
type: 'image',
transfer_method: TransferMethod.local_file,
url: '',
upload_file_id: 'upload-file-1',
}],
emptyAttachments: [],
})
})
})
describe('time helpers', () => {
@@ -1,8 +1,8 @@
import type { ContentItemProps } from './type'
import { Textarea } from '@langgenius/dify-ui/textarea'
import * as React from 'react'
import { useMemo } from 'react'
import { Markdown } from '@/app/components/base/markdown'
import HumanInputFieldRenderer from './field-renderer'
const ContentItem = ({
content,
@@ -40,15 +40,11 @@ const ContentItem = ({
return (
<div className="py-3">
{formInputField.type === 'paragraph' && (
<Textarea
aria-label={fieldName}
className="h-[104px] sm:text-xs"
value={inputs[fieldName]!}
onValueChange={(value) => { onInputChange(fieldName, value) }}
data-testid="content-item-textarea"
/>
)}
<HumanInputFieldRenderer
field={formInputField}
value={inputs[fieldName]}
onChange={value => onInputChange(fieldName, value)}
/>
</div>
)
}
@@ -0,0 +1,113 @@
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import {
Select,
SelectContent,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectTrigger,
} from '@langgenius/dify-ui/select'
import { Textarea } from '@langgenius/dify-ui/textarea'
import * as React from 'react'
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
import {
isFileFormInput,
isFileListFormInput,
isParagraphFormInput,
isSelectFormInput,
} from '@/app/components/workflow/nodes/human-input/types'
export type HumanInputFieldValue = string | FileEntity | FileEntity[] | null
type Props = {
field: FormInputItem
value?: HumanInputFieldValue
onChange: (value: HumanInputFieldValue) => void
}
const HumanInputFieldRenderer = ({
field,
value,
onChange,
}: Props) => {
if (isParagraphFormInput(field)) {
return (
<Textarea
aria-label={field.output_variable_name}
className="h-[104px] sm:text-xs"
value={typeof value === 'string' ? value : ''}
onValueChange={nextValue => onChange(nextValue)}
data-testid="content-item-textarea"
/>
)
}
if (isSelectFormInput(field)) {
const options = field.option_source.value.map(option => ({
name: option,
value: option,
}))
return (
<Select
value={typeof value === 'string' ? value : ''}
onValueChange={(nextValue) => {
if (nextValue == null)
return
onChange(nextValue)
}}
>
<SelectTrigger size="large" className="w-full" aria-label={field.output_variable_name}>
{typeof value === 'string' ? value : ''}
</SelectTrigger>
<SelectContent listClassName="max-h-[140px] overflow-y-auto">
{options.map(option => (
<SelectItem key={option.value} value={option.value}>
<SelectItemText>{option.name}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
)
}
if (isFileFormInput(field)) {
const singleFileValue = value && !Array.isArray(value) && typeof value !== 'string'
? [value]
: []
return (
<FileUploaderInAttachmentWrapper
value={singleFileValue}
onChange={files => onChange(files[0] || null)}
fileConfig={{
allowed_file_types: field.allowed_file_types,
allowed_file_extensions: field.allowed_file_extensions,
allowed_file_upload_methods: field.allowed_file_upload_methods,
number_limits: 1,
}}
/>
)
}
if (isFileListFormInput(field)) {
return (
<FileUploaderInAttachmentWrapper
value={Array.isArray(value) ? value : []}
onChange={files => onChange(files)}
fileConfig={{
allowed_file_types: field.allowed_file_types,
allowed_file_extensions: field.allowed_file_extensions,
allowed_file_upload_methods: field.allowed_file_upload_methods,
number_limits: field.number_limits || 5,
}}
/>
)
}
return null
}
export default React.memo(HumanInputFieldRenderer)
@@ -1,36 +1,43 @@
'use client'
import type { ButtonProps } from '@langgenius/dify-ui/button'
import type { HumanInputFieldValue } from './field-renderer'
import type { HumanInputFormProps } from './type'
import type { UserAction } from '@/app/components/workflow/nodes/human-input/types'
import { Button } from '@langgenius/dify-ui/button'
import * as React from 'react'
import { useCallback, useState } from 'react'
import ContentItem from './content-item'
import { getButtonStyle, initializeInputs, splitByOutputVar } from './utils'
import { getButtonStyle, getProcessedHumanInputFormInputs, getRenderedFormInputs, hasInvalidSelectOrFileInput, initializeInputs, splitByOutputVar } from './utils'
const HumanInputForm = ({
formData,
onSubmit,
}: HumanInputFormProps) => {
const formToken = formData.form_token
const defaultInputs = initializeInputs(formData.inputs, formData.resolved_default_values || {})
const contentList = splitByOutputVar(formData.form_content)
const renderedFormInputs = getRenderedFormInputs(formData.inputs, formData.form_content)
const defaultInputs = initializeInputs(renderedFormInputs, formData.resolved_default_values || {})
const [inputs, setInputs] = useState(defaultInputs)
const [isSubmitting, setIsSubmitting] = useState(false)
const handleInputsChange = useCallback((name: string, value: string) => {
const handleInputsChange = useCallback((name: string, value: HumanInputFieldValue) => {
setInputs(prev => ({
...prev,
[name]: value,
}))
}, [])
const submit = async (formToken: string, actionID: string, inputs: Record<string, string>) => {
const submit = async (formToken: string, actionID: string, inputs: Record<string, HumanInputFieldValue>) => {
setIsSubmitting(true)
await onSubmit?.(formToken, { inputs, action: actionID })
await onSubmit?.(formToken, {
inputs: getProcessedHumanInputFormInputs(renderedFormInputs, inputs) || {},
action: actionID,
})
setIsSubmitting(false)
}
const isActionDisabled = isSubmitting || hasInvalidSelectOrFileInput(renderedFormInputs, inputs)
return (
<>
{contentList.map((content, index) => (
@@ -46,7 +53,7 @@ const HumanInputForm = ({
{formData.actions.map((action: UserAction) => (
<Button
key={action.id}
disabled={isSubmitting}
disabled={isActionDisabled}
variant={getButtonStyle(action.button_style) as ButtonProps['variant']}
onClick={() => submit(formToken, action.id, inputs)}
>
@@ -0,0 +1,121 @@
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import type { HumanInputFormValue } from '@/types/workflow'
import {
Select,
SelectContent,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectTrigger,
} from '@langgenius/dify-ui/select'
import * as React from 'react'
import { FileList } from '@/app/components/base/file-uploader'
import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils'
import { Markdown } from '@/app/components/base/markdown'
import {
isFileFormInput,
isFileListFormInput,
isParagraphFormInput,
isSelectFormInput,
} from '@/app/components/workflow/nodes/human-input/types'
type SubmittedContentItemProps = {
content: string
formInputFields: FormInputItem[]
values: Record<string, HumanInputFormValue>
}
const outputVarRegex = /\{\{#\$output\.([^#]+)#\}\}/
const isOutputField = (content: string) => outputVarRegex.test(content)
const extractFieldName = (content: string) => {
const match = outputVarRegex.exec(content)
return match ? match[1]! : ''
}
const SubmittedContentItem = ({
content,
formInputFields,
values,
}: SubmittedContentItemProps) => {
if (!isOutputField(content)) {
return (
<Markdown content={content} />
)
}
const fieldName = extractFieldName(content)
const field = formInputFields.find(field => field.output_variable_name === fieldName)
const value = values[fieldName]
if (!field || value == null)
return null
if (isParagraphFormInput(field)) {
return (
<span
className="body-md-regular break-words text-text-primary"
data-testid={`submitted-field-${fieldName}`}
>
{typeof value === 'string' ? value : ''}
</span>
)
}
if (isSelectFormInput(field)) {
const selectedValue = typeof value === 'string' ? value : ''
return (
<div className="py-3" data-testid={`submitted-field-${fieldName}`}>
<Select value={selectedValue} disabled>
<SelectTrigger size="large" className="w-full" aria-label={field.output_variable_name} disabled>
{selectedValue}
</SelectTrigger>
<SelectContent listClassName="max-h-[140px] overflow-y-auto">
{field.option_source.value.map(option => (
<SelectItem key={option} value={option}>
<SelectItemText>{option}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}
if (isFileFormInput(field)) {
if (typeof value === 'string' || Array.isArray(value))
return null
return (
<div className="py-3" data-testid={`submitted-field-${fieldName}`}>
<FileList
files={getProcessedFilesFromResponse([value])}
showDeleteAction={false}
showDownloadAction
/>
</div>
)
}
if (isFileListFormInput(field)) {
if (typeof value === 'string' || !Array.isArray(value))
return null
return (
<div className="py-3" data-testid={`submitted-field-${fieldName}`}>
<FileList
files={getProcessedFilesFromResponse(value)}
showDeleteAction={false}
showDownloadAction
/>
</div>
)
}
return null
}
export default React.memo(SubmittedContentItem)
@@ -0,0 +1,88 @@
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import type { HumanInputFormValue } from '@/types/workflow'
import * as React from 'react'
import { FileList } from '@/app/components/base/file-uploader'
import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils'
import {
isFileFormInput,
isFileListFormInput,
} from '@/app/components/workflow/nodes/human-input/types'
type SubmittedFieldValuesProps = {
fields?: FormInputItem[]
values: Record<string, HumanInputFormValue>
}
const SubmittedFieldValues = ({
fields,
values,
}: SubmittedFieldValuesProps) => {
const fieldNames = fields?.map(field => field.output_variable_name) ?? Object.keys(values)
const fieldMap = new Map(fields?.map(field => [field.output_variable_name, field]) ?? [])
return (
<div className="flex flex-col gap-3" data-testid="submitted-field-values">
{fieldNames.map((fieldName) => {
const field = fieldMap.get(fieldName)
const value = values[fieldName]
if (value == null)
return null
let valueKind: 'text' | 'file' | 'file-list' = 'text'
if (field && isFileFormInput(field))
valueKind = 'file'
else if (field && isFileListFormInput(field))
valueKind = 'file-list'
else if (typeof value === 'string')
valueKind = 'text'
else if (Array.isArray(value))
valueKind = 'file-list'
else
valueKind = 'file'
if (valueKind === 'file') {
if (typeof value === 'string' || Array.isArray(value))
return null
return (
<div key={fieldName} data-testid={`submitted-field-${fieldName}`}>
<FileList
files={getProcessedFilesFromResponse([value])}
showDeleteAction={false}
showDownloadAction
/>
</div>
)
}
if (valueKind === 'file-list') {
if (typeof value === 'string' || !Array.isArray(value))
return null
return (
<div key={fieldName} data-testid={`submitted-field-${fieldName}`}>
<FileList
files={getProcessedFilesFromResponse(value)}
showDeleteAction={false}
showDownloadAction
/>
</div>
)
}
return (
<div
key={fieldName}
className="body-md-regular break-words text-text-primary"
data-testid={`submitted-field-${fieldName}`}
>
{String(value)}
</div>
)
})}
</div>
)
}
export default React.memo(SubmittedFieldValues)
@@ -0,0 +1,34 @@
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import type { HumanInputFormValue } from '@/types/workflow'
import * as React from 'react'
import SubmittedContentItem from './submitted-content-item'
import { splitByOutputVar } from './utils'
type SubmittedFormContentProps = {
formContent: string
formInputFields: FormInputItem[]
values: Record<string, HumanInputFormValue>
}
const SubmittedFormContent = ({
formContent,
formInputFields,
values,
}: SubmittedFormContentProps) => {
const contentList = splitByOutputVar(formContent)
return (
<div data-testid="submitted-form-content">
{contentList.map((content, index) => (
<SubmittedContentItem
key={index}
content={content}
formInputFields={formInputFields}
values={values}
/>
))}
</div>
)
}
export default React.memo(SubmittedFormContent)
@@ -0,0 +1,15 @@
import type { HumanInputFilledFormData, HumanInputFormData } from '@/types/workflow'
export const enrichSubmittedHumanInputFormData = (
filledFormData: HumanInputFilledFormData,
requiredFormData?: Pick<HumanInputFormData, 'form_content' | 'inputs'>,
): HumanInputFilledFormData => {
if (!requiredFormData)
return filledFormData
return {
...filledFormData,
form_content: requiredFormData.form_content,
inputs: requiredFormData.inputs,
}
}
@@ -2,11 +2,13 @@ import type { SubmittedHumanInputContentProps } from './type'
import { useMemo } from 'react'
import ExecutedAction from './executed-action'
import SubmittedContent from './submitted-content'
import SubmittedFieldValues from './submitted-field-values'
import SubmittedFormContent from './submitted-form-content'
export const SubmittedHumanInputContent = ({
formData,
}: SubmittedHumanInputContentProps) => {
const { rendered_content, action_id, action_text } = formData
const { rendered_content, action_id, action_text, form_content, submitted_data, inputs } = formData
const executedAction = useMemo(() => {
return {
@@ -15,9 +17,21 @@ export const SubmittedHumanInputContent = ({
}
}, [action_id, action_text])
const content = form_content && inputs && submitted_data && Object.keys(submitted_data).length > 0
? (
<SubmittedFormContent
formContent={form_content}
formInputFields={inputs}
values={submitted_data}
/>
)
: submitted_data && Object.keys(submitted_data).length > 0
? <SubmittedFieldValues values={submitted_data} />
: <SubmittedContent content={rendered_content} />
return (
<>
<SubmittedContent content={rendered_content} />
{content}
{/* Executed Action */}
<ExecutedAction executedAction={executedAction} />
</>
@@ -1,3 +1,4 @@
import type { HumanInputFieldValue } from './field-renderer'
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import type { HumanInputFilledFormData, HumanInputFormData } from '@/types/workflow'
@@ -11,7 +12,7 @@ export type UnsubmittedHumanInputContentProps = {
showEmailTip?: boolean
isEmailDebugMode?: boolean
showDebugModeTip?: boolean
onSubmit?: (formToken: string, data: { inputs: Record<string, string>, action: string }) => Promise<void>
onSubmit?: (formToken: string, data: HumanInputFormSubmitData) => Promise<void>
}
export type SubmittedHumanInputContentProps = {
@@ -20,12 +21,17 @@ export type SubmittedHumanInputContentProps = {
export type HumanInputFormProps = {
formData: HumanInputFormData
onSubmit?: (formToken: string, data: { inputs: Record<string, string>, action: string }) => Promise<void>
onSubmit?: (formToken: string, data: HumanInputFormSubmitData) => Promise<void>
}
export type HumanInputFormSubmitData = {
inputs: Record<string, unknown>
action: string
}
export type ContentItemProps = {
content: string
formInputFields: FormInputItem[]
inputs: Record<string, string>
onInputChange: (name: string, value: string) => void
inputs: Record<string, HumanInputFieldValue>
onInputChange: (name: string, value: HumanInputFieldValue) => void
}
@@ -1,10 +1,20 @@
import type { HumanInputFieldValue } from './field-renderer'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import type { Locale } from '@/i18n-config'
import type { HumanInputResolvedValue } from '@/types/workflow'
import dayjs from 'dayjs'
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'
import relativeTime from 'dayjs/plugin/relativeTime'
import utc from 'dayjs/plugin/utc'
import { UserActionButtonType } from '@/app/components/workflow/nodes/human-input/types'
import { fileIsUploaded, getProcessedFiles } from '@/app/components/base/file-uploader/utils'
import {
isFileFormInput,
isFileListFormInput,
isParagraphFormInput,
isSelectFormInput,
UserActionButtonType,
} from '@/app/components/workflow/nodes/human-input/types'
import 'dayjs/locale/en'
import 'dayjs/locale/zh-cn'
import 'dayjs/locale/ja'
@@ -31,17 +41,140 @@ export const splitByOutputVar = (content: string): string[] => {
return parts.filter(part => part.length > 0)
}
export const initializeInputs = (formInputs: FormInputItem[], defaultValues: Record<string, string> = {}) => {
const initialInputs: Record<string, any> = {}
export const getFormContentInputNames = (content: string) => {
const outputVarRegex = /\{\{#\$output\.([^#]+)#\}\}/g
return [...content.matchAll(outputVarRegex)].map(match => match[1]!)
}
export const getRenderedFormInputs = (formInputs: FormInputItem[], content: string) => {
const inputNames = new Set(getFormContentInputNames(content))
return formInputs.filter(input => inputNames.has(input.output_variable_name))
}
export const initializeInputs = (formInputs: FormInputItem[], defaultValues: Record<string, HumanInputResolvedValue> = {}) => {
const initialInputs: Record<string, HumanInputFieldValue> = {}
formInputs.forEach((item) => {
if (item.type === 'text-input' || item.type === 'paragraph')
initialInputs[item.output_variable_name] = item.default.type === 'variable' ? defaultValues[item.output_variable_name] || '' : item.default.value
else
initialInputs[item.output_variable_name] = undefined
if (isParagraphFormInput(item)) {
const resolvedValue = defaultValues[item.output_variable_name]
initialInputs[item.output_variable_name] = item.default.type === 'variable' && typeof resolvedValue === 'string'
? resolvedValue
: item.default.value
return
}
if (isSelectFormInput(item)) {
initialInputs[item.output_variable_name] = ''
return
}
if (isFileFormInput(item)) {
initialInputs[item.output_variable_name] = null
return
}
if (isFileListFormInput(item)) {
initialInputs[item.output_variable_name] = []
}
})
return initialInputs
}
const isHumanInputFileUploaded = (value: HumanInputFieldValue | undefined) => {
return !!value
&& !Array.isArray(value)
&& typeof value !== 'string'
&& !!fileIsUploaded(value as FileEntity)
}
const hasUploadedHumanInputFiles = (value: HumanInputFieldValue | undefined) => {
return Array.isArray(value)
&& value.length > 0
&& value.every(file => !!fileIsUploaded(file))
}
export const hasInvalidSelectOrFileInput = (
formInputs: FormInputItem[],
values: Record<string, HumanInputFieldValue>,
) => {
return formInputs.some((input) => {
if (!(input.output_variable_name in values))
return false
const value = values[input.output_variable_name]
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 hasInvalidRequiredHumanInput = (
formInputs: FormInputItem[],
values: Record<string, HumanInputFieldValue>,
) => {
return formInputs.some((input) => {
if (!(input.output_variable_name in values))
return false
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,
) => {
if (!values)
return undefined
const processedInputs: Record<string, unknown> = { ...values }
formInputs.forEach((input) => {
const value = values[input.output_variable_name]
if (isFileListFormInput(input)) {
processedInputs[input.output_variable_name] = Array.isArray(value)
? getProcessedFiles(value)
: []
return
}
if (isFileFormInput(input)) {
if (Array.isArray(value)) {
processedInputs[input.output_variable_name] = getProcessedFiles(value)[0]
return
}
processedInputs[input.output_variable_name] = value && typeof value !== 'string'
? getProcessedFiles([value as FileEntity])[0]
: undefined
}
})
return processedInputs
}
const localeMap: Record<string, string> = {
'en-US': 'en',
'zh-Hans': 'zh-cn',
@@ -1,3 +1,4 @@
import type { HumanInputFormSubmitData } from './human-input-content/type'
import type { DeliveryMethod, HumanInputNodeType } from '@/app/components/workflow/nodes/human-input/types'
import type { Node } from '@/app/components/workflow/types'
import type { HumanInputFormData } from '@/types/workflow'
@@ -8,7 +9,7 @@ import { UnsubmittedHumanInputContent } from './human-input-content/unsubmitted'
type HumanInputFormListProps = {
humanInputFormDataList: HumanInputFormData[]
onHumanInputFormSubmit?: (formToken: string, formData: { inputs: Record<string, string>, action: string }) => Promise<void>
onHumanInputFormSubmit?: (formToken: string, formData: HumanInputFormSubmitData) => Promise<void>
getHumanInputNodeData?: (nodeID: string) => Node<HumanInputNodeType> | undefined
}
@@ -6,6 +6,7 @@ import type {
ChatConfig,
ChatItem,
} from '../../types'
import type { HumanInputFormSubmitData } from './human-input-content/type'
import type { AppData } from '@/models/share'
import { cn } from '@langgenius/dify-ui/cn'
import { memo, useCallback, useEffect, useRef, useState } from 'react'
@@ -40,7 +41,7 @@ type AnswerProps = {
noChatInput?: boolean
switchSibling?: (siblingMessageId: string) => void
hideAvatar?: boolean
onHumanInputFormSubmit?: (formToken: string, formData: any) => Promise<void>
onHumanInputFormSubmit?: (formToken: string, formData: HumanInputFormSubmitData) => Promise<void>
}
const Answer: FC<AnswerProps> = ({
item,
@@ -41,6 +41,8 @@ type FeedbackTooltipProps = {
}
const feedbackTooltipClassName = 'max-w-[260px]'
const answerActiveFlexClassName = 'group-hover:flex group-has-[[data-popup-open]]:flex'
const answerActiveBlockClassName = 'group-hover:block group-has-[[data-popup-open]]:block'
const FeedbackTooltip = ({ content, children }: FeedbackTooltipProps) => {
return (
@@ -199,7 +201,7 @@ function Operation({
{shouldShowUserFeedbackBar && !humanInputFormDataList?.length && (
<div className={cn(
'ml-1 items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-xs',
hasUserFeedback ? 'flex' : 'hidden group-hover:flex',
hasUserFeedback ? 'flex' : `hidden ${answerActiveFlexClassName}`,
)}
>
{hasUserFeedback
@@ -241,7 +243,7 @@ function Operation({
{shouldShowAdminFeedbackBar && !humanInputFormDataList?.length && (
<div className={cn(
'ml-1 items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-xs',
(hasAdminFeedback || hasUserFeedback) ? 'flex' : 'hidden group-hover:flex',
(hasAdminFeedback || hasUserFeedback) ? 'flex' : `hidden ${answerActiveFlexClassName}`,
)}
>
{displayUserFeedback?.rating && (
@@ -308,12 +310,12 @@ function Operation({
</div>
)}
{showPromptLog && !isOpeningStatement && (
<div className="hidden group-hover:block">
<div className={cn('hidden', answerActiveBlockClassName)}>
<Log logItem={item} />
</div>
)}
{!isOpeningStatement && (
<div className="ml-1 hidden items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-xs group-hover:flex" data-testid="operation-actions">
<div className={cn('ml-1 hidden items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-xs', answerActiveFlexClassName)} data-testid="operation-actions">
{(config?.text_to_speech?.enabled && !humanInputFormDataList?.length) && (
<NewAudioButton
id={id}
+16 -6
View File
@@ -26,6 +26,7 @@ import {
import { useTranslation } from 'react-i18next'
import { v4 as uuidV4 } from 'uuid'
import { AudioPlayerManager } from '@/app/components/base/audio-btn/audio.player.manager'
import { enrichSubmittedHumanInputFormData } from '@/app/components/base/chat/chat/answer/human-input-content/submitted-utils'
import {
getProcessedFiles,
getProcessedFilesFromResponse,
@@ -538,16 +539,20 @@ export const useChat = (
},
onHumanInputFormFilled: ({ data: humanInputFilledFormData }) => {
updateChatTreeNode(messageId, (responseItem) => {
let requiredFormData: NonNullable<ChatItem['humanInputFormDataList']>[number] | undefined
if (responseItem.humanInputFormDataList?.length) {
const currentFormIndex = responseItem.humanInputFormDataList.findIndex(item => item.node_id === humanInputFilledFormData.node_id)
if (currentFormIndex > -1)
if (currentFormIndex > -1) {
requiredFormData = responseItem.humanInputFormDataList[currentFormIndex]
responseItem.humanInputFormDataList.splice(currentFormIndex, 1)
}
}
const enrichedHumanInputFilledFormData = enrichSubmittedHumanInputFormData(humanInputFilledFormData, requiredFormData)
if (!responseItem.humanInputFilledFormDataList) {
responseItem.humanInputFilledFormDataList = [humanInputFilledFormData]
responseItem.humanInputFilledFormDataList = [enrichedHumanInputFilledFormData]
}
else {
responseItem.humanInputFilledFormDataList.push(humanInputFilledFormData)
responseItem.humanInputFilledFormDataList.push(enrichedHumanInputFilledFormData)
}
})
},
@@ -1108,15 +1113,20 @@ export const useChat = (
}
},
onHumanInputFormFilled: ({ data: humanInputFilledFormData }) => {
let requiredFormData: NonNullable<ChatItem['humanInputFormDataList']>[number] | undefined
if (responseItem.humanInputFormDataList?.length) {
const currentFormIndex = responseItem.humanInputFormDataList!.findIndex(item => item.node_id === humanInputFilledFormData.node_id)
responseItem.humanInputFormDataList.splice(currentFormIndex, 1)
if (currentFormIndex > -1) {
requiredFormData = responseItem.humanInputFormDataList[currentFormIndex]
responseItem.humanInputFormDataList.splice(currentFormIndex, 1)
}
}
const enrichedHumanInputFilledFormData = enrichSubmittedHumanInputFormData(humanInputFilledFormData, requiredFormData)
if (!responseItem.humanInputFilledFormDataList) {
responseItem.humanInputFilledFormDataList = [humanInputFilledFormData]
responseItem.humanInputFilledFormDataList = [enrichedHumanInputFilledFormData]
}
else {
responseItem.humanInputFilledFormDataList.push(humanInputFilledFormData)
responseItem.humanInputFilledFormDataList.push(enrichedHumanInputFilledFormData)
}
updateCurrentQAOnTree({
placeholderQuestionId,
+2 -1
View File
@@ -10,6 +10,7 @@ import type {
OnRegenerate,
OnSend,
} from '../types'
import type { HumanInputFormSubmitData } from './answer/human-input-content/type'
import type { InputForm } from './type'
import type { Emoji } from '@/app/components/tools/types'
import type { AppData } from '@/models/share'
@@ -69,7 +70,7 @@ export type ChatProps = {
sidebarCollapseState?: boolean
hideAvatar?: boolean
sendOnEnter?: boolean
onHumanInputFormSubmit?: (formToken: string, formData: any) => Promise<void>
onHumanInputFormSubmit?: (formToken: string, formData: HumanInputFormSubmitData) => Promise<void>
getHumanInputNodeData?: (nodeID: string) => any
}
+16 -12
View File
@@ -66,18 +66,22 @@ export type CitationItem = {
word_count: number
}
export type ExtraContent
= {
type: 'human_input'
submitted: false
form_definition: HumanInputFormData
workflow_run_id: string
}
| {
type: 'human_input'
submitted: true
form_submission_data: HumanInputFilledFormData
}
type PendingHumanInputExtraContent = {
type: 'human_input'
submitted: false
form_definition: HumanInputFormData
workflow_run_id: string
}
type SubmittedHumanInputExtraContent = {
type: 'human_input'
submitted: true
form_definition?: HumanInputFormData
form_submission_data: HumanInputFilledFormData
workflow_run_id?: string
}
export type ExtraContent = PendingHumanInputExtraContent | SubmittedHumanInputExtraContent
export type IChatItem = {
id: string
@@ -1,4 +1,5 @@
import type { RefObject } from 'react'
import type { HumanInputFieldValue } from '../../chat/answer/human-input-content/field-renderer'
import type { ChatConfig, ChatItem, ChatItemInTree } from '../../types'
import type { EmbeddedChatbotContextValue } from '../context'
import type { ConversationItem } from '@/models/share'
@@ -59,7 +60,7 @@ vi.mock('../../chat', () => ({
onSend: (message: string) => void
onRegenerate: (chatItem: ChatItem, editedQuestion?: { message: string, files?: never[] }) => void
switchSibling: (siblingMessageId: string) => void
onHumanInputFormSubmit: (formToken: string, formData: Record<string, string>) => Promise<void>
onHumanInputFormSubmit: (formToken: string, formData: { inputs: Record<string, HumanInputFieldValue>, action: string }) => Promise<void>
onStopResponding: () => void
}) => (
<div>
@@ -78,7 +79,7 @@ vi.mock('../../chat', () => ({
<button onClick={() => switchSibling('sibling-2')}>switch sibling</button>
<button disabled={inputDisabled}>send message</button>
<button onClick={onStopResponding}>stop responding</button>
<button onClick={() => onHumanInputFormSubmit('form-token', { answer: 'ok' })}>submit human input</button>
<button onClick={() => onHumanInputFormSubmit('form-token', { inputs: { answer: 'ok' }, action: 'approve' })}>submit human input</button>
</div>
),
}))
@@ -351,7 +352,7 @@ describe('EmbeddedChatbot chat-wrapper', () => {
fireEvent.click(screen.getByRole('button', { name: 'submit human input' }))
await waitFor(() => {
expect(submitHumanInputFormService).toHaveBeenCalledWith('form-token', { answer: 'ok' })
expect(submitHumanInputFormService).toHaveBeenCalledWith('form-token', { inputs: { answer: 'ok' }, action: 'approve' })
})
expect(submitHumanInputForm).not.toHaveBeenCalled()
})
@@ -391,7 +392,7 @@ describe('EmbeddedChatbot chat-wrapper', () => {
fireEvent.click(screen.getByRole('button', { name: 'submit human input' }))
await waitFor(() => {
expect(submitHumanInputForm).toHaveBeenCalledWith('form-token', { answer: 'ok' })
expect(submitHumanInputForm).toHaveBeenCalledWith('form-token', { inputs: { answer: 'ok' }, action: 'approve' })
})
expect(handleSend).toHaveBeenCalledTimes(2)
const sendOptions = handleSend.mock.calls[0]?.[2] as { onGetSuggestedQuestions: (responseItemId: string) => void }
@@ -1,4 +1,5 @@
import type { FileEntity } from '../../file-uploader/types'
import type { HumanInputFormSubmitData } from '../chat/answer/human-input-content/type'
import type {
ChatConfig,
ChatItem,
@@ -232,7 +233,7 @@ const ChatWrapper = () => {
}
}, [inputsForms.length, isMobile, currentConversationId, collapsed, allInputsHidden])
const handleSubmitHumanInputForm = useCallback(async (formToken: string, formData: any) => {
const handleSubmitHumanInputForm = useCallback(async (formToken: string, formData: HumanInputFormSubmitData) => {
if (isInstalledApp)
await submitHumanInputFormService(formToken, formData)
else
@@ -5,9 +5,14 @@ import { act, renderHook } from '@testing-library/react'
import { useFile, useFileSizeLimit } from '../hooks'
const mockNotify = vi.fn()
const mockNavigationState = vi.hoisted(() => ({
params: {} as { token?: string },
pathname: '/chat',
}))
vi.mock('@/next/navigation', () => ({
useParams: () => ({ token: undefined }),
useParams: () => mockNavigationState.params,
usePathname: () => mockNavigationState.pathname,
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
@@ -42,6 +47,13 @@ vi.mock('@/service/common', () => ({
uploadRemoteFileInfo: (...args: unknown[]) => mockUploadRemoteFileInfo(...args),
}))
const mockUploadHumanInputFormLocalFile = vi.fn()
const mockUploadHumanInputFormRemoteFileInfo = vi.fn()
vi.mock('@/service/share', () => ({
uploadHumanInputFormLocalFile: (...args: unknown[]) => mockUploadHumanInputFormLocalFile(...args),
uploadHumanInputFormRemoteFileInfo: (...args: unknown[]) => mockUploadHumanInputFormRemoteFileInfo(...args),
}))
vi.mock('uuid', () => ({
v4: () => 'mock-uuid',
}))
@@ -109,6 +121,8 @@ describe('useFile', () => {
beforeEach(() => {
vi.clearAllMocks()
mockStoreFiles = []
mockNavigationState.params = {}
mockNavigationState.pathname = '/chat'
mockIsAllowedFileExtension.mockReturnValue(true)
mockGetSupportFileType.mockReturnValue('document')
})
@@ -201,6 +215,31 @@ describe('useFile', () => {
expect(mockFileUpload).toHaveBeenCalled()
})
it('should use human input form local upload when re-uploading on form page', () => {
mockNavigationState.params = { token: 'form-token' }
mockNavigationState.pathname = '/form/form-token'
const originalFile = new File(['content'], 'test.txt', { type: 'text/plain' })
mockStoreFiles = [{
id: 'file-1',
name: 'test.txt',
type: 'text/plain',
size: 100,
progress: -1,
transferMethod: 'local_file',
supportFileType: 'document',
originalFile,
}] as FileEntity[]
const { result } = renderHook(() => useFile(defaultFileConfig))
result.current.handleReUploadFile('file-1')
expect(mockUploadHumanInputFormLocalFile).toHaveBeenCalledWith(expect.objectContaining({
formToken: 'form-token',
file: originalFile,
}))
expect(mockFileUpload).not.toHaveBeenCalled()
})
it('should not re-upload when file id is not found', () => {
mockStoreFiles = []
const { result } = renderHook(() => useFile(defaultFileConfig))
@@ -315,6 +354,24 @@ describe('useFile', () => {
expect(mockUploadRemoteFileInfo).toHaveBeenCalledWith('https://example.com/file.txt', false)
})
it('should use human input form remote upload on form page', () => {
mockNavigationState.params = { token: 'form-token' }
mockNavigationState.pathname = '/form/form-token'
mockUploadHumanInputFormRemoteFileInfo.mockResolvedValue({
id: 'remote-1',
mime_type: 'text/plain',
size: 100,
name: 'remote.txt',
url: 'https://example.com/remote.txt',
})
const { result } = renderHook(() => useFile(defaultFileConfig))
result.current.handleLoadFileFromLink('https://example.com/file.txt')
expect(mockUploadHumanInputFormRemoteFileInfo).toHaveBeenCalledWith('form-token', 'https://example.com/file.txt')
expect(mockUploadRemoteFileInfo).not.toHaveBeenCalled()
})
it('should remove file when extension is not allowed', async () => {
mockIsAllowedFileExtension.mockReturnValue(false)
mockUploadRemoteFileInfo.mockResolvedValue({
@@ -702,6 +759,21 @@ describe('useFile', () => {
expect(mockSetFiles).toHaveBeenCalled()
})
it('should use human input form local upload on form page', () => {
mockNavigationState.params = { token: 'form-token' }
mockNavigationState.pathname = '/form/form-token'
const file = new File(['content'], 'test.txt', { type: 'text/plain' })
const { result } = renderHook(() => useFile(defaultFileConfig))
result.current.handleLocalFileUpload(file)
expect(mockUploadHumanInputFormLocalFile).toHaveBeenCalledWith(expect.objectContaining({
formToken: 'form-token',
file,
}))
expect(mockFileUpload).not.toHaveBeenCalled()
})
it('should handle fileUpload error callback', () => {
const file = new File(['content'], 'test.txt', { type: 'text/plain' })
@@ -164,6 +164,29 @@ describe('FileUploaderInAttachmentWrapper', () => {
expect(linkButton.closest('button')).toBeInTheDocument()
})
it('should keep upload button layout stable when remote_url popup opens', () => {
render(
<FileUploaderInAttachmentWrapper
onChange={vi.fn()}
fileConfig={createFileConfig()}
/>,
)
const localButton = screen.getByText(/fileUploader\.uploadFromComputer/).closest('button')
const linkButton = screen.getByText(/fileUploader\.pasteFileLink/).closest('button')
const uploadGroup = localButton?.parentElement?.parentElement
expect(uploadGroup?.children).toHaveLength(2)
fireEvent.click(linkButton!)
expect(uploadGroup?.children).toHaveLength(2)
expect(linkButton).toHaveAttribute('data-popup-open')
expect(localButton?.parentElement).toHaveClass('min-w-0', 'flex-1')
expect(linkButton?.parentElement).toHaveClass('min-w-0', 'flex-1')
expect(linkButton).toHaveClass('w-full', 'min-w-0')
})
it('should disable upload buttons when file limit is reached', () => {
const files = [
createFile({ id: 'f1' }),
@@ -55,13 +55,12 @@ const FileUploaderInAttachment = ({
const renderButton = useCallback((option: Option, open?: boolean) => {
return (
<Button
key={option.value}
variant="tertiary"
className={cn('relative grow', open && 'bg-components-button-tertiary-bg-hover')}
className={cn('relative w-full min-w-0', open && 'bg-components-button-tertiary-bg-hover')}
disabled={!!(fileConfig.number_limits && files.length >= fileConfig.number_limits)}
>
{option.icon}
<span className="ml-1">{option.label}</span>
<span className="shrink-0">{option.icon}</span>
<span className="ml-1 min-w-0 truncate">{option.label}</span>
{
option.value === TransferMethod.local_file && (
<FileInput fileConfig={fileConfig} />
@@ -74,17 +73,23 @@ const FileUploaderInAttachment = ({
return (open: boolean) => renderButton(option, open)
}, [renderButton])
const renderOption = useCallback((option: Option) => {
if (option.value === TransferMethod.local_file && fileConfig?.allowed_file_upload_methods?.includes(TransferMethod.local_file))
return renderButton(option)
if (option.value === TransferMethod.local_file && fileConfig?.allowed_file_upload_methods?.includes(TransferMethod.local_file)) {
return (
<div key={option.value} className="min-w-0 flex-1">
{renderButton(option)}
</div>
)
}
if (option.value === TransferMethod.remote_url && fileConfig?.allowed_file_upload_methods?.includes(TransferMethod.remote_url)) {
return (
<FileFromLinkOrLocal
key={option.value}
showFromLocal={false}
trigger={renderTrigger(option)}
fileConfig={fileConfig}
/>
<div key={option.value} className="min-w-0 flex-1">
<FileFromLinkOrLocal
showFromLocal={false}
trigger={renderTrigger(option)}
fileConfig={fileConfig}
/>
</div>
)
}
}, [renderButton, renderTrigger, fileConfig])
@@ -92,7 +97,7 @@ const FileUploaderInAttachment = ({
return (
<div>
{!isDisabled && (
<div className="flex items-center space-x-1">
<div className="flex items-center gap-1">
{options.map(renderOption)}
</div>
)}
+43 -12
View File
@@ -19,8 +19,12 @@ import {
VIDEO_SIZE_LIMIT,
} from '@/app/components/base/file-uploader/constants'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import { useParams } from '@/next/navigation'
import { useParams, usePathname } from '@/next/navigation'
import { uploadRemoteFileInfo } from '@/service/common'
import {
uploadHumanInputFormLocalFile,
uploadHumanInputFormRemoteFileInfo,
} from '@/service/share'
import { TransferMethod } from '@/types/app'
import { formatFileSize } from '@/utils/format'
import { useFileStore } from './store'
@@ -51,7 +55,10 @@ export const useFile = (fileConfig: FileUpload, noNeedToCheckEnable = true) => {
const { t } = useTranslation()
const fileStore = useFileStore()
const params = useParams()
const pathname = usePathname()
const { imgSizeLimit, docSizeLimit, audioSizeLimit, videoSizeLimit } = useFileSizeLimit(fileConfig.fileUploadConfig)
const formToken = typeof params.token === 'string' ? params.token : undefined
const isHumanInputFormPage = !!formToken && /(?:^|\/)form\/[^/]+$/.test(pathname)
const checkSizeLimit = useCallback((fileType: string, fileSize: number) => {
switch (fileType) {
@@ -156,7 +163,7 @@ export const useFile = (fileConfig: FileUpload, noNeedToCheckEnable = true) => {
draft[index]!.progress = 0
})
setFiles(newFiles)
fileUpload({
const uploadParams: Parameters<typeof fileUpload>[0] = {
file: uploadingFile!.originalFile!,
onProgressCallback: (progress) => {
handleUpdateFile({ ...uploadingFile, progress })
@@ -164,14 +171,24 @@ export const useFile = (fileConfig: FileUpload, noNeedToCheckEnable = true) => {
onSuccessCallback: (res) => {
handleUpdateFile({ ...uploadingFile, uploadedId: res.id, progress: 100 })
},
onErrorCallback: (error?: any) => {
onErrorCallback: (error?: unknown) => {
const errorMessage = getFileUploadErrorMessage(error, t('fileUploader.uploadFromComputerUploadError', { ns: 'common' }), t)
toast.error(errorMessage)
handleUpdateFile({ ...uploadingFile, progress: -1 })
},
}, !!params.token)
}
if (isHumanInputFormPage) {
uploadHumanInputFormLocalFile({
formToken: formToken!,
...uploadParams,
})
}
else {
fileUpload(uploadParams, !!params.token)
}
}
}, [fileStore, t, handleUpdateFile, params])
}, [fileStore, t, handleUpdateFile, isHumanInputFormPage, formToken, params.token])
const startProgressTimer = useCallback((fileId: string) => {
const timer = setInterval(() => {
@@ -201,7 +218,11 @@ export const useFile = (fileConfig: FileUpload, noNeedToCheckEnable = true) => {
handleAddFile(uploadingFile)
startProgressTimer(uploadingFile.id)
uploadRemoteFileInfo(url, !!params.token).then((res) => {
const remoteUpload = isHumanInputFormPage
? uploadHumanInputFormRemoteFileInfo(formToken!, url)
: uploadRemoteFileInfo(url, !!params.token)
remoteUpload.then((res) => {
const newFile = {
...uploadingFile,
type: res.mime_type,
@@ -223,7 +244,7 @@ export const useFile = (fileConfig: FileUpload, noNeedToCheckEnable = true) => {
toast.error(t('fileUploader.pasteFileLinkInvalid', { ns: 'common' }))
handleRemoveFile(uploadingFile.id)
})
}, [checkSizeLimit, handleAddFile, handleUpdateFile, t, handleRemoveFile, fileConfig?.allowed_file_types, fileConfig.allowed_file_extensions, startProgressTimer, params.token])
}, [checkSizeLimit, handleAddFile, handleUpdateFile, t, handleRemoveFile, fileConfig?.allowed_file_types, fileConfig.allowed_file_extensions, startProgressTimer, isHumanInputFormPage, formToken, params.token])
const handleLoadFileFromLinkSuccess = useCallback(noop, [])
@@ -269,7 +290,7 @@ export const useFile = (fileConfig: FileUpload, noNeedToCheckEnable = true) => {
base64Url: isImage ? reader.result as string : '',
}
handleAddFile(uploadingFile)
fileUpload({
const uploadParams: Parameters<typeof fileUpload>[0] = {
file: uploadingFile.originalFile,
onProgressCallback: (progress) => {
handleUpdateFile({ ...uploadingFile, progress })
@@ -277,12 +298,22 @@ export const useFile = (fileConfig: FileUpload, noNeedToCheckEnable = true) => {
onSuccessCallback: (res) => {
handleUpdateFile({ ...uploadingFile, uploadedId: res.id, progress: 100 })
},
onErrorCallback: (error?: any) => {
const errorMessage = getFileUploadErrorMessage(error, t('fileUploader.uploadFromComputerUploadError', { ns: 'common' }), t as any)
onErrorCallback: (error?: unknown) => {
const errorMessage = getFileUploadErrorMessage(error, t('fileUploader.uploadFromComputerUploadError', { ns: 'common' }), t)
toast.error(errorMessage)
handleUpdateFile({ ...uploadingFile, progress: -1 })
},
}, !!params.token)
}
if (isHumanInputFormPage) {
uploadHumanInputFormLocalFile({
formToken: formToken!,
...uploadParams,
})
}
else {
fileUpload(uploadParams, !!params.token)
}
},
false,
)
@@ -294,7 +325,7 @@ export const useFile = (fileConfig: FileUpload, noNeedToCheckEnable = true) => {
false,
)
reader.readAsDataURL(file)
}, [noNeedToCheckEnable, checkSizeLimit, t, handleAddFile, handleUpdateFile, params.token, fileConfig?.allowed_file_types, fileConfig?.allowed_file_extensions, fileConfig?.enabled])
}, [noNeedToCheckEnable, checkSizeLimit, t, handleAddFile, handleUpdateFile, isHumanInputFormPage, formToken, params.token, fileConfig?.allowed_file_types, fileConfig?.allowed_file_extensions, fileConfig?.enabled])
const handleClipboardPasteFile = useCallback((e: ClipboardEvent<HTMLTextAreaElement>) => {
const file = e.clipboardData?.files[0]
@@ -29,6 +29,7 @@ vi.mock('../../..', () => ({
vi.mock('@/next/navigation', () => ({
useParams: () => ({ token: 'test-token' }),
usePathname: () => '/',
}))
describe('FileUploaderField', () => {
@@ -8,6 +8,7 @@ import { BaseFieldType } from '../types'
vi.mock('@/next/navigation', () => ({
useParams: () => ({}),
usePathname: () => '/',
}))
const createConfig = (overrides: Partial<BaseConfiguration> = {}): BaseConfiguration => ({
@@ -36,6 +36,7 @@ const mocks = vi.hoisted(() => {
})),
parseEditorState: vi.fn(() => ({ state: 'parsed' })),
setEditorState: vi.fn(),
setEditable: vi.fn(),
focus: vi.fn(),
update: vi.fn((fn: () => void) => fn()),
},
@@ -71,6 +72,7 @@ vi.mock('lexical', async (importOriginal) => {
})),
getAllTextNodes: () => [],
}),
$nodesOfType: () => [],
TextNode: class TextNode {
__text: string
constructor(text = '') {
@@ -92,9 +94,8 @@ vi.mock('@lexical/react/LexicalComposer', () => ({
try {
initialConfig.onError(new Error('test error'))
}
catch (e) {
// ignore error
console.error(e)
catch {
// Ignore the intentional throw from the mocked error boundary path.
}
}
if (initialConfig?.nodes) {
@@ -328,6 +329,20 @@ describe('PromptEditor', () => {
expect(screen.getByTestId('lexical-composer')).toBeInTheDocument()
})
it('should sync editable changes to the lexical editor instance', async () => {
const { rerender } = render(<PromptEditor editable={true} />)
await waitFor(() => {
expect(mocks.editor.setEditable).toHaveBeenCalledWith(true)
})
rerender(<PromptEditor editable={false} />)
await waitFor(() => {
expect(mocks.editor.setEditable).toHaveBeenLastCalledWith(false)
})
})
it('should render with isSupportFileVar=true', () => {
render(<PromptEditor isSupportFileVar={true} />)
expect(screen.getByTestId('lexical-composer')).toBeInTheDocument()
@@ -3,10 +3,9 @@
import type { InitialConfigType } from '@lexical/react/LexicalComposer'
import type {
EditorState,
LexicalCommand,
} from 'lexical'
import type { FC } from 'react'
import type { Hotkey } from './plugins/shortcuts-popup-plugin'
import type { Hotkey, ShortcutPopupInsertHandler } from './plugins/shortcuts-popup-plugin'
import type {
ContextBlockType,
CurrentBlockType,
@@ -97,6 +96,16 @@ const ValueSyncPlugin: FC<{ value?: string }> = ({ value }) => {
return null
}
const EditableSyncPlugin: FC<{ editable: boolean }> = ({ editable }) => {
const [editor] = useLexicalComposerContext()
useEffect(() => {
editor.setEditable(editable)
}, [editor, editable])
return null
}
export type PromptEditorProps = {
instanceId?: string
compact?: boolean
@@ -122,7 +131,7 @@ export type PromptEditorProps = {
errorMessageBlock?: ErrorMessageBlockType
lastRunBlock?: LastRunBlockType
isSupportFileVar?: boolean
shortcutPopups?: Array<{ hotkey: Hotkey, Popup: React.ComponentType<{ onClose: () => void, onInsert: (command: LexicalCommand<unknown>, params: any[]) => void }> }>
shortcutPopups?: Array<{ hotkey: Hotkey, Popup: React.ComponentType<{ onClose: () => void, onInsert: ShortcutPopupInsertHandler }> }>
}
const PromptEditor: FC<PromptEditorProps> = ({
@@ -194,13 +203,13 @@ const PromptEditor: FC<PromptEditorProps> = ({
eventEmitter?.emit({
type: UPDATE_DATASETS_EVENT_EMITTER,
payload: contextBlock?.datasets,
} as any)
})
}, [eventEmitter, contextBlock?.datasets])
useEffect(() => {
eventEmitter?.emit({
type: UPDATE_HISTORY_EVENT_EMITTER,
payload: historyBlock?.history,
} as any)
})
}, [eventEmitter, historyBlock?.history])
const [floatingAnchorElem, setFloatingAnchorElem] = useState<HTMLDivElement | null>(null)
@@ -243,6 +252,7 @@ const PromptEditor: FC<PromptEditorProps> = ({
onEditorChange={handleEditorChange}
/>
<ValueSyncPlugin value={value} />
<EditableSyncPlugin editable={editable} />
</div>
</LexicalComposer>
)
@@ -1,15 +1,17 @@
import type { ComponentProps } from 'react'
import type { WorkflowNodesMap } from '../../workflow-variable-block/node'
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import type { FormInputItem, ParagraphFormInput } from '@/app/components/workflow/nodes/human-input/types'
import type { ValueSelector } from '@/app/components/workflow/types'
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { useEffect, useState } from 'react'
import { BlockEnum, InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
import HITLInputComponentUI from '../component-ui'
import { HITLInputNode } from '../node'
const createFormInput = (overrides?: Partial<FormInputItem>): FormInputItem => ({
const createParagraphFormInput = (overrides?: Partial<ParagraphFormInput>): ParagraphFormInput => ({
type: InputVarType.paragraph,
output_variable_name: 'customer_name',
default: {
@@ -93,7 +95,7 @@ describe('HITLInputComponentUI', () => {
const selector = ['node-2', 'answer'] as ValueSelector
const { getByText } = renderComponent({
formInput: createFormInput({
formInput: createParagraphFormInput({
default: {
type: 'variable',
selector,
@@ -112,6 +114,124 @@ describe('HITLInputComponentUI', () => {
expect(screen.queryByRole('button', { name: 'common.operation.edit' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.operation.remove' })).not.toBeInTheDocument()
})
it('should close the edit modal when readonly becomes true', async () => {
let setReadonlyValue: ((readonly: boolean) => void) | undefined
const Harness = () => {
const [readonly, setReadonly] = useState(false)
const [namespace] = useState(() => `hitl-input-test-${crypto.randomUUID()}`)
useEffect(() => {
setReadonlyValue = setReadonly
return () => {
setReadonlyValue = undefined
}
}, [])
return (
<LexicalComposer
initialConfig={{
namespace,
onError: (error: Error) => {
throw error
},
nodes: [HITLInputNode],
}}
>
<HITLInputComponentUI
nodeId="node-1"
varName="customer_name"
workflowNodesMap={createWorkflowNodesMap()}
onChange={vi.fn()}
onRename={vi.fn()}
onRemove={vi.fn()}
readonly={readonly}
/>
</LexicalComposer>
)
}
render(<Harness />)
fireEvent.click(await screen.findByRole('button', { name: 'common.operation.edit' }))
expect(await screen.findByRole('textbox')).toBeInTheDocument()
act(() => {
setReadonlyValue?.(true)
})
await waitFor(() => {
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
})
it('should render select option summary for constant options', () => {
const { getByText } = renderComponent({
formInput: {
type: InputVarType.select,
output_variable_name: 'customer_name',
option_source: {
type: 'constant',
selector: [],
value: ['alpha', 'beta'],
},
} satisfies FormInputItem,
})
expect(getByText('alpha, beta')).toBeInTheDocument()
})
it('should render input type label after the summary content', () => {
const { getByText } = renderComponent({
formInput: {
type: InputVarType.select,
output_variable_name: 'customer_name',
option_source: {
type: 'constant',
selector: [],
value: ['alpha', 'beta'],
},
} satisfies FormInputItem,
})
const summary = getByText('alpha, beta')
const typeLabel = getByText('appDebug.variableConfig.select')
expect(summary.compareDocumentPosition(typeLabel) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
})
it('should render file-list placeholder instead of selected file details', () => {
const { getAllByText, queryByText } = renderComponent({
formInput: {
type: InputVarType.multiFiles,
output_variable_name: 'customer_name',
allowed_file_extensions: ['.pdf'],
allowed_file_types: [SupportUploadFileTypes.document],
allowed_file_upload_methods: [TransferMethod.local_file],
number_limits: 4,
} satisfies FormInputItem,
})
expect(getAllByText('appDebug.variableConfig.multi-files')).toHaveLength(2)
expect(queryByText(/document/)).not.toBeInTheDocument()
expect(queryByText(/4/)).not.toBeInTheDocument()
})
it('should render single-file placeholder instead of selected file details', () => {
const { getAllByText, queryByText } = renderComponent({
formInput: {
type: InputVarType.singleFile,
output_variable_name: 'customer_name',
allowed_file_extensions: ['.pdf'],
allowed_file_types: [SupportUploadFileTypes.document],
allowed_file_upload_methods: [TransferMethod.local_file],
} satisfies FormInputItem,
})
expect(getAllByText('appDebug.variableConfig.single-file')).toHaveLength(2)
expect(queryByText(/document/)).not.toBeInTheDocument()
})
})
describe('Remove action', () => {
@@ -145,10 +265,33 @@ describe('HITLInputComponentUI', () => {
expect(queryByRole('textbox')).not.toBeInTheDocument()
})
it('should prevent renaming to an existing variable name', async () => {
const {
findByRole,
onChange,
onRename,
} = renderComponent({
unavailableVariableNames: ['existing_name'],
})
fireEvent.click(await screen.findByRole('button', { name: 'common.operation.edit' }))
const textbox = await findByRole('textbox')
fireEvent.change(textbox, { target: { value: 'existing_name' } })
expect(screen.getByText('workflow.nodes.humanInput.insertInputField.variableNameDuplicated')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(onChange).not.toHaveBeenCalled()
expect(onRename).not.toHaveBeenCalled()
})
})
describe('Default formInput', () => {
it('should pass default payload to InputField when formInput is undefined', async () => {
it('should open an empty default editor when formInput is undefined', async () => {
const { findByRole } = renderComponent({
formInput: undefined,
})
@@ -156,16 +299,16 @@ describe('HITLInputComponentUI', () => {
fireEvent.click(await screen.findByRole('button', { name: 'common.operation.edit' }))
const textbox = await findByRole('textbox')
const saveButton = await screen.findByRole('button', { name: 'common.operation.save' })
fireEvent.click(await screen.findByRole('button', { name: 'common.operation.save' }))
expect(textbox).toHaveValue('customer_name')
expect(textbox).toHaveValue('')
expect(saveButton).toBeDisabled()
})
it('should render variable selector when workflowNodesMap fallback is used', () => {
const { getByText } = renderComponent({
workflowNodesMap: undefined as unknown as WorkflowNodesMap,
formInput: createFormInput({
formInput: createParagraphFormInput({
default: {
type: 'variable',
selector: ['node-2', 'answer'] as ValueSelector,
@@ -1,5 +1,5 @@
import type { RefObject } from 'react'
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import type { FormInputItem, ParagraphFormInput } from '@/app/components/workflow/nodes/human-input/types'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { InputVarType } from '@/app/components/workflow/types'
@@ -15,15 +15,17 @@ vi.mock('../../../hooks', () => ({
vi.mock('../component-ui', () => ({
default: ({ formInput, onChange }: { formInput?: FormInputItem, onChange: (payload: FormInputItem) => void }) => {
const basePayload: FormInputItem = formInput ?? {
type: InputVarType.paragraph,
output_variable_name: 'user_name',
default: {
type: 'constant',
selector: [],
value: 'hello',
},
}
const basePayload: ParagraphFormInput = (formInput && formInput.type === InputVarType.paragraph
? formInput
: {
type: InputVarType.paragraph,
output_variable_name: 'user_name',
default: {
type: 'constant',
selector: [],
value: 'hello',
},
}) satisfies ParagraphFormInput
return (
<div>
<button
@@ -63,7 +65,7 @@ const createHookReturn = (): [RefObject<HTMLDivElement | null>, boolean] => {
return [{ current: null }, false]
}
const createInput = (overrides?: Partial<FormInputItem>): FormInputItem => ({
const createInput = (overrides?: Partial<ParagraphFormInput>): ParagraphFormInput => ({
type: InputVarType.paragraph,
output_variable_name: 'user_name',
default: {
@@ -127,6 +129,31 @@ describe('HITLInputComponent', () => {
expect(onChange.mock.calls[0]![0][0].output_variable_name).toBe('renamed_name')
})
it('should ignore rename when the target variable name already exists', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<HITLInputComponent
nodeKey="node-key-duplicate"
nodeId="node-duplicate"
varName="user_name"
formInputs={[
createInput(),
createInput({ output_variable_name: 'renamed_name' }),
]}
onChange={onChange}
onRename={vi.fn()}
onRemove={vi.fn()}
workflowNodesMap={{}}
/>,
)
await user.click(screen.getByRole('button', { name: 'emit-rename' }))
expect(onChange).not.toHaveBeenCalled()
})
it('should update existing payload when variable name stays the same', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
@@ -1,11 +1,13 @@
import type { LexicalEditor } from 'lexical'
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { act, render, waitFor } from '@testing-library/react'
import {
$nodesOfType,
COMMAND_PRIORITY_EDITOR,
} from 'lexical'
import { useEffect } from 'react'
import { useEffect, useState } from 'react'
import {
BlockEnum,
InputVarType,
@@ -13,6 +15,7 @@ import {
import { CustomTextNode } from '../../custom-text/node'
import {
getNodeCount,
readEditorStateValue,
readRootTextContent,
renderLexicalEditor,
selectRootEnd,
@@ -76,6 +79,12 @@ const createInsertPayload = () => ({
onFormInputItemRemove: vi.fn(),
})
const readHITLReadonlyValues = (editor: LexicalEditor): boolean[] => {
return readEditorStateValue(editor, () => {
return $nodesOfType(HITLInputNode).map(node => node.getReadonly())
})
}
const renderHITLInputBlock = (props?: {
onInsert?: () => void
onDelete?: () => void
@@ -169,6 +178,65 @@ describe('HITLInputBlock', () => {
expect(getNodeCount(editor, HITLInputNode)).toBe(1)
})
it('should update existing and newly inserted nodes when readonly changes', async () => {
let setReadonlyValue: ((readonly: boolean) => void) | undefined
const ReadonlyHarness = () => {
const [readonly, setReadonly] = useState(false)
useEffect(() => {
setReadonlyValue = setReadonly
return () => {
setReadonlyValue = undefined
}
}, [])
return (
<HITLInputBlock
nodeId="node-1"
formInputs={[createFormInput()]}
onFormInputItemRename={vi.fn()}
onFormInputItemRemove={vi.fn()}
workflowNodesMap={createWorkflowNodesMap('First Node')}
readonly={readonly}
/>
)
}
const { getEditor } = renderLexicalEditor({
namespace: 'hitl-input-block-readonly-update-test',
nodes: [CustomTextNode, HITLInputNode],
children: <ReadonlyHarness />,
})
const editor = await waitForEditorReady(getEditor)
selectRootEnd(editor)
act(() => {
editor.dispatchCommand(INSERT_HITL_INPUT_BLOCK_COMMAND, createInsertPayload())
})
await waitFor(() => {
expect(readHITLReadonlyValues(editor)).toEqual([false])
})
act(() => {
setReadonlyValue?.(true)
})
await waitFor(() => {
expect(readHITLReadonlyValues(editor)).toEqual([true])
})
selectRootEnd(editor)
act(() => {
editor.dispatchCommand(INSERT_HITL_INPUT_BLOCK_COMMAND, createInsertPayload())
})
await waitFor(() => {
expect(readHITLReadonlyValues(editor)).toEqual([true, true])
})
})
it('should call onDelete when delete command is dispatched', async () => {
const onDelete = vi.fn()
const { getEditor } = renderHITLInputBlock({ onDelete })
@@ -1,15 +1,20 @@
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import type { FormInputItem, ParagraphFormInput } from '@/app/components/workflow/nodes/human-input/types'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { InputVarType } from '@/app/components/workflow/types'
import { InputVarType, SupportUploadFileTypes, VarType } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
import InputField from '../input-field'
type VarReferencePickerProps = {
onChange: (value: string[]) => void
filterVar?: (payload: { type: VarType }) => boolean
}
let lastVarReferencePickerProps: VarReferencePickerProps | undefined
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({
default: (props: VarReferencePickerProps) => {
lastVarReferencePickerProps = props
return (
<button type="button" onClick={() => props.onChange(['node-a', 'var-a'])}>
pick-variable
@@ -18,7 +23,58 @@ vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference
},
}))
const createPayload = (overrides?: Partial<FormInputItem>): FormInputItem => ({
vi.mock('@/app/components/app/configuration/config-var/config-modal/type-select', () => ({
__esModule: true,
default: ({ onSelect }: { onSelect: (item: { value: InputVarType }) => void }) => (
<div>
<button type="button" onClick={() => onSelect({ value: InputVarType.paragraph })}>
select-paragraph
</button>
<button type="button" onClick={() => onSelect({ value: InputVarType.select })}>
select-select
</button>
<button type="button" onClick={() => onSelect({ value: InputVarType.singleFile })}>
select-file
</button>
<button type="button" onClick={() => onSelect({ value: InputVarType.multiFiles })}>
select-file-list
</button>
</div>
),
}))
vi.mock('@/app/components/app/configuration/config-var/config-select', () => ({
__esModule: true,
default: ({ onChange }: { onChange: (options: string[]) => void }) => (
<button type="button" onClick={() => onChange(['alpha', 'beta'])}>
config-select
</button>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/file-upload-setting', () => ({
__esModule: true,
default: ({ onChange }: { onChange: (payload: {
allowed_file_extensions: string[]
allowed_file_types: SupportUploadFileTypes[]
allowed_file_upload_methods: TransferMethod[]
max_length?: number
}) => void }) => (
<button
type="button"
onClick={() => onChange({
allowed_file_extensions: ['.pdf'],
allowed_file_types: [SupportUploadFileTypes.document],
allowed_file_upload_methods: [TransferMethod.local_file],
max_length: 4,
})}
>
file-upload-setting
</button>
),
}))
const createPayload = (overrides?: Partial<ParagraphFormInput>): ParagraphFormInput => ({
type: InputVarType.paragraph,
output_variable_name: 'valid_name',
default: {
@@ -32,6 +88,7 @@ const createPayload = (overrides?: Partial<FormInputItem>): FormInputItem => ({
describe('InputField', () => {
beforeEach(() => {
vi.clearAllMocks()
lastVarReferencePickerProps = undefined
})
it('should disable save and show validation error when variable name is invalid', async () => {
@@ -59,6 +116,31 @@ describe('InputField', () => {
expect(onChange).not.toHaveBeenCalled()
})
it('should disable save and show validation error when variable name already exists', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<InputField
nodeId="node-duplicate-name"
isEdit={false}
payload={createPayload()}
unavailableVariableNames={['existing_name']}
onChange={onChange}
onCancel={vi.fn()}
/>,
)
const inputs = screen.getAllByRole('textbox')
await user.clear(inputs[0]!)
await user.type(inputs[0]!, 'existing_name')
expect(screen.getByText('workflow.nodes.humanInput.insertInputField.variableNameDuplicated')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i })).toBeDisabled()
await user.keyboard('{Control>}{Enter}{/Control}')
expect(onChange).not.toHaveBeenCalled()
})
it('should call onChange when saving a valid payload in edit mode', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
@@ -274,4 +356,278 @@ describe('InputField', () => {
value: '',
})
})
it('should switch to select payload when field type changes', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<InputField
nodeId="node-7"
isEdit={false}
payload={createPayload()}
onChange={onChange}
onCancel={vi.fn()}
/>,
)
await user.click(screen.getByRole('button', { name: 'select-select' }))
await user.click(screen.getByRole('button', { name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i }))
expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange.mock.calls[0]![0]).toEqual({
type: InputVarType.select,
output_variable_name: 'valid_name',
option_source: {
type: 'constant',
selector: [],
value: [],
},
})
expect(screen.queryByText(/workflow\.nodes\.humanInput\.insertInputField\.prePopulateField/i)).not.toBeInTheDocument()
})
it('should keep paragraph pre-populate editor available after switching back to paragraph', async () => {
const user = userEvent.setup()
render(
<InputField
nodeId="node-8"
isEdit={false}
payload={createPayload()}
onChange={vi.fn()}
onCancel={vi.fn()}
/>,
)
expect(screen.getByText(/workflow\.nodes\.humanInput\.insertInputField\.prePopulateField/i)).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'select-file' }))
expect(screen.queryByText(/workflow\.nodes\.humanInput\.insertInputField\.prePopulateField/i)).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'select-paragraph' }))
expect(screen.getByText(/workflow\.nodes\.humanInput\.insertInputField\.prePopulateField/i)).toBeInTheDocument()
})
it('should save constant select options', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<InputField
nodeId="node-9"
isEdit={false}
payload={createPayload()}
onChange={onChange}
onCancel={vi.fn()}
/>,
)
await user.click(screen.getByRole('button', { name: 'select-select' }))
await user.click(screen.getByRole('button', { name: 'config-select' }))
await user.click(screen.getByRole('button', { name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i }))
expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange.mock.calls[0]![0]).toEqual({
type: InputVarType.select,
output_variable_name: 'valid_name',
option_source: {
type: 'constant',
selector: [],
value: ['alpha', 'beta'],
},
})
})
it('should preserve constant and variable select sources when toggling', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<InputField
nodeId="node-10"
isEdit={false}
payload={createPayload()}
onChange={onChange}
onCancel={vi.fn()}
/>,
)
await user.click(screen.getByRole('button', { name: 'select-select' }))
await user.click(screen.getByRole('button', { name: 'config-select' }))
await user.click(screen.getByText(/workflow\.nodes\.humanInput\.insertInputField\.useVarInstead/i))
await user.click(screen.getByText('pick-variable'))
await user.click(screen.getByText(/workflow\.nodes\.humanInput\.insertInputField\.useConstantInstead/i))
await user.click(screen.getByRole('button', { name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i }))
expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange.mock.calls[0]![0]).toEqual({
type: InputVarType.select,
output_variable_name: 'valid_name',
option_source: {
type: 'constant',
selector: ['node-a', 'var-a'],
value: ['alpha', 'beta'],
},
})
})
it('should only allow array[string] variables for select option source', async () => {
const user = userEvent.setup()
render(
<InputField
nodeId="node-11"
isEdit={false}
payload={createPayload()}
onChange={vi.fn()}
onCancel={vi.fn()}
/>,
)
await user.click(screen.getByRole('button', { name: 'select-select' }))
await user.click(screen.getByText(/workflow\.nodes\.humanInput\.insertInputField\.useVarInstead/i))
expect(lastVarReferencePickerProps?.filterVar?.({ type: VarType.arrayString })).toBe(true)
expect(lastVarReferencePickerProps?.filterVar?.({ type: VarType.string })).toBe(false)
expect(lastVarReferencePickerProps?.filterVar?.({ type: VarType.arrayFile })).toBe(false)
})
it('should clear paragraph default state when switching to select', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<InputField
nodeId="node-12"
isEdit={false}
payload={createPayload({
default: {
type: 'constant',
selector: [],
value: 'paragraph-default',
},
})}
onChange={onChange}
onCancel={vi.fn()}
/>,
)
await user.click(screen.getByRole('button', { name: 'select-select' }))
await user.click(screen.getByRole('button', { name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i }))
expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange.mock.calls[0]![0]).not.toHaveProperty('default')
})
it('should save single file upload settings', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<InputField
nodeId="node-13"
isEdit={false}
payload={createPayload()}
onChange={onChange}
onCancel={vi.fn()}
/>,
)
await user.click(screen.getByRole('button', { name: 'select-file' }))
await user.click(screen.getByRole('button', { name: 'file-upload-setting' }))
await user.click(screen.getByRole('button', { name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i }))
expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange.mock.calls[0]![0]).toEqual({
type: InputVarType.singleFile,
output_variable_name: 'valid_name',
allowed_file_extensions: ['.pdf'],
allowed_file_types: ['document'],
allowed_file_upload_methods: ['local_file'],
})
})
it('should clear paragraph default state when switching to single file', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<InputField
nodeId="node-13-1"
isEdit={false}
payload={createPayload({
default: {
type: 'constant',
selector: [],
value: 'paragraph-default',
},
})}
onChange={onChange}
onCancel={vi.fn()}
/>,
)
await user.click(screen.getByRole('button', { name: 'select-file' }))
await user.click(screen.getByRole('button', { name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i }))
expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange.mock.calls[0]![0]).not.toHaveProperty('default')
})
it('should save file-list upload settings and max upload count', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<InputField
nodeId="node-14"
isEdit={false}
payload={createPayload()}
onChange={onChange}
onCancel={vi.fn()}
/>,
)
await user.click(screen.getByRole('button', { name: 'select-file-list' }))
await user.click(screen.getByRole('button', { name: 'file-upload-setting' }))
await user.click(screen.getByRole('button', { name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i }))
expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange.mock.calls[0]![0]).toEqual({
type: InputVarType.multiFiles,
output_variable_name: 'valid_name',
allowed_file_extensions: ['.pdf'],
allowed_file_types: ['document'],
allowed_file_upload_methods: ['local_file'],
number_limits: 4,
})
})
it('should clear paragraph default state when switching to file-list', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<InputField
nodeId="node-14-1"
isEdit={false}
payload={createPayload({
default: {
type: 'constant',
selector: [],
value: 'paragraph-default',
},
})}
onChange={onChange}
onCancel={vi.fn()}
/>,
)
await user.click(screen.getByRole('button', { name: 'select-file-list' }))
await user.click(screen.getByRole('button', { name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i }))
expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange.mock.calls[0]![0]).not.toHaveProperty('default')
})
})
@@ -98,6 +98,8 @@ describe('HITLInputNode', () => {
expect(node.getConversationVariables()).toEqual(props.conversationVariables)
expect(node.getRagVariables()).toEqual(props.ragVariables)
expect(node.getReadonly()).toBe(true)
node.setReadonly(false)
expect(node.getReadonly()).toBe(false)
expect(node.getTextContent()).toBe('{{#$output.user_name#}}')
})
})
@@ -9,16 +9,24 @@ import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { InputVarType } from '@/app/components/workflow/types'
import {
createDefaultParagraphFormInput,
isFileFormInput,
isParagraphFormInput,
isSelectFormInput,
} from '@/app/components/workflow/nodes/human-input/types'
import ActionButton from '../../../action-button'
import { VariableX } from '../../../icons/src/vender/workflow'
import InputField from './input-field'
import VariableBlock from './variable-block'
const i18nPrefix = 'nodes.humanInput.insertInputField'
type HITLInputComponentUIProps = {
nodeId: string
varName: string
formInput?: FormInputItem
unavailableVariableNames?: string[]
onChange: (input: FormInputItem) => void
onRename: (payload: FormInputItem, oldName: string) => void
onRemove: (varName: string) => void
@@ -36,15 +44,8 @@ type HITLInputComponentUIProps = {
const HITLInputComponentUI: FC<HITLInputComponentUIProps> = ({
nodeId,
varName,
formInput = {
type: InputVarType.paragraph,
output_variable_name: varName,
default: {
type: 'constant',
selector: [],
value: '',
},
},
formInput,
unavailableVariableNames = [],
onChange,
onRename,
onRemove,
@@ -56,11 +57,20 @@ const HITLInputComponentUI: FC<HITLInputComponentUIProps> = ({
readonly,
}) => {
const { t } = useTranslation()
const resolvedFormInput = formInput || createDefaultParagraphFormInput(varName)
const paragraphDefault = isParagraphFormInput(resolvedFormInput)
? resolvedFormInput.default
: null
const [isShowEditModal, {
setTrue: showEditModal,
setFalse: hideEditModal,
}] = useBoolean(false)
useEffect(() => {
if (readonly)
hideEditModal()
}, [hideEditModal, readonly])
// Lexical delegate the click make it unable to add click by the method of react
const editBtnRef = useRef<HTMLDivElement>(null)
useEffect(() => {
@@ -72,8 +82,7 @@ const HITLInputComponentUI: FC<HITLInputComponentUIProps> = ({
if (editBtn)
editBtn.removeEventListener('click', showEditModal)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
}, [showEditModal])
const removeBtnRef = useRef<HTMLDivElement>(null)
useEffect(() => {
@@ -89,16 +98,47 @@ const HITLInputComponentUI: FC<HITLInputComponentUIProps> = ({
}, [onRemove, varName])
const handleChange = useCallback((newPayload: FormInputItem) => {
if (newPayload.output_variable_name !== varName && unavailableVariableNames.includes(newPayload.output_variable_name))
return
if (varName === newPayload.output_variable_name)
onChange(newPayload)
else
onRename(newPayload, varName)
hideEditModal()
}, [hideEditModal, onChange, onRename, varName])
}, [hideEditModal, onChange, onRename, unavailableVariableNames, varName])
const isDefaultValueVariable = useMemo(() => {
return formInput.default?.type === 'variable'
}, [formInput.default?.type])
return paragraphDefault?.type === 'variable'
}, [paragraphDefault])
const inputTypeLabel = useMemo(() => {
if (isParagraphFormInput(resolvedFormInput))
return t('variableConfig.paragraph', { ns: 'appDebug' })
if (isSelectFormInput(resolvedFormInput))
return t('variableConfig.select', { ns: 'appDebug' })
if (isFileFormInput(resolvedFormInput))
return t('variableConfig.single-file', { ns: 'appDebug' })
return t('variableConfig.multi-files', { ns: 'appDebug' })
}, [resolvedFormInput, t])
const variableSelector = useMemo(() => {
if (isDefaultValueVariable)
return paragraphDefault?.selector || []
if (isSelectFormInput(resolvedFormInput) && resolvedFormInput.option_source.type === 'variable')
return resolvedFormInput.option_source.selector
return null
}, [isDefaultValueVariable, paragraphDefault?.selector, resolvedFormInput])
const summaryText = useMemo(() => {
if (isParagraphFormInput(resolvedFormInput))
return paragraphDefault?.value || inputTypeLabel
if (isSelectFormInput(resolvedFormInput)) {
if (resolvedFormInput.option_source.type === 'variable')
return t(`${i18nPrefix}.variable`, { ns: 'workflow' })
return resolvedFormInput.option_source.value.join(', ') || inputTypeLabel
}
return inputTypeLabel
}, [inputTypeLabel, paragraphDefault?.value, resolvedFormInput, t])
return (
<div
@@ -112,22 +152,27 @@ const HITLInputComponentUI: FC<HITLInputComponentUIProps> = ({
</div>
</div>
<div className="flex w-full items-center gap-x-0.5 pr-5">
<div className="flex w-full items-center gap-x-2 pr-5">
<div className="min-w-0 grow">
{/* Default Value Info */}
{isDefaultValueVariable && (
<VariableBlock
variables={formInput.default?.selector}
workflowNodesMap={workflowNodesMap}
getVarType={getVarType}
environmentVariables={environmentVariables}
conversationVariables={conversationVariables}
ragVariables={ragVariables}
/>
)}
{!isDefaultValueVariable && (
<div className="max-w-full truncate system-xs-medium text-components-input-text-filled">{formInput.default?.value}</div>
)}
{variableSelector
? (
<VariableBlock
variables={variableSelector}
workflowNodesMap={workflowNodesMap}
getVarType={getVarType}
environmentVariables={environmentVariables}
conversationVariables={conversationVariables}
ragVariables={ragVariables}
/>
)
: (
<div className="max-w-full truncate system-xs-medium text-components-input-text-filled">
{summaryText}
</div>
)}
</div>
<div className="shrink-0 system-2xs-medium text-text-tertiary uppercase">
{inputTypeLabel}
</div>
{/* Actions */}
@@ -168,6 +213,7 @@ const HITLInputComponentUI: FC<HITLInputComponentUIProps> = ({
nodeId={nodeId}
isEdit
payload={formInput}
unavailableVariableNames={unavailableVariableNames}
onChange={handleChange}
onCancel={hideEditModal}
/>
@@ -45,8 +45,14 @@ const HITLInputComponent: FC<HITLInputComponentProps> = ({
}) => {
const [ref] = useSelectOrDelete(nodeKey, DELETE_HITL_INPUT_BLOCK_COMMAND)
const payload = formInputs.find(item => item.output_variable_name === varName)
const unavailableVariableNames = formInputs
.map(item => item.output_variable_name)
.filter(name => name !== varName)
const handleChange = useCallback((newPayload: FormInputItem) => {
if (newPayload.output_variable_name !== varName && unavailableVariableNames.includes(newPayload.output_variable_name))
return
if (!payload) {
onChange([...formInputs, newPayload])
return
@@ -58,7 +64,7 @@ const HITLInputComponent: FC<HITLInputComponentProps> = ({
return
}
onChange(formInputs.map(item => item.output_variable_name === varName ? newPayload : item))
}, [formInputs, onChange, payload, varName])
}, [formInputs, onChange, payload, unavailableVariableNames, varName])
return (
<div
@@ -69,6 +75,7 @@ const HITLInputComponent: FC<HITLInputComponentProps> = ({
nodeId={nodeId}
varName={varName}
formInput={payload}
unavailableVariableNames={unavailableVariableNames}
onChange={handleChange}
onRename={onRename}
onRemove={onRemove}
@@ -1,5 +1,6 @@
import type { TextNode } from 'lexical'
import type { HITLInputBlockType } from '../../types'
import type { Var } from '@/app/components/workflow/types'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { mergeRegister } from '@lexical/utils'
import { $applyNodeReplacement } from 'lexical'
@@ -8,6 +9,7 @@ import {
useCallback,
useEffect,
useMemo,
useRef,
} from 'react'
import { HITL_INPUT_REG } from '@/config'
import { decoratorTransform } from '../../utils'
@@ -31,13 +33,39 @@ const HITLInputReplacementBlock = ({
const environmentVariables = useMemo(() => variables?.find(o => o.nodeId === 'env')?.vars || [], [variables])
const conversationVariables = useMemo(() => variables?.find(o => o.nodeId === 'conversation')?.vars || [], [variables])
const ragVariables = useMemo(() => variables?.reduce<any[]>((acc, curr) => {
const ragVariables = useMemo(() => variables?.reduce<Var[]>((acc, curr) => {
if (curr.nodeId === 'rag')
acc.push(...curr.vars)
else
acc.push(...curr.vars.filter(v => v.isRagVariable))
return acc
}, []), [variables])
const latestConfigRef = useRef({
nodeId,
formInputs,
onFormInputsChange,
onFormInputItemRename,
onFormInputItemRemove,
workflowNodesMap,
getVarType,
environmentVariables,
conversationVariables,
ragVariables,
readonly,
})
latestConfigRef.current = {
nodeId,
formInputs,
onFormInputsChange,
onFormInputItemRename,
onFormInputItemRemove,
workflowNodesMap,
getVarType,
environmentVariables,
conversationVariables,
ragVariables,
readonly,
}
useEffect(() => {
if (!editor.hasNodes([HITLInputNode]))
@@ -46,6 +74,20 @@ const HITLInputReplacementBlock = ({
const createHITLInputBlockNode = useCallback((textNode: TextNode): HITLInputNode => {
const varName = textNode.getTextContent().split('.')[1]!.replace(/#\}\}$/, '')
const {
nodeId,
formInputs,
onFormInputsChange,
onFormInputItemRename,
onFormInputItemRemove,
workflowNodesMap,
getVarType,
environmentVariables,
conversationVariables,
ragVariables,
readonly,
} = latestConfigRef.current
return $applyNodeReplacement($createHITLInputNode(
varName,
nodeId,
@@ -60,7 +102,7 @@ const HITLInputReplacementBlock = ({
ragVariables,
readonly,
))
}, [nodeId, formInputs, onFormInputsChange, onFormInputItemRename, onFormInputItemRemove, workflowNodesMap, getVarType, environmentVariables, conversationVariables, ragVariables, readonly])
}, [])
const getMatch = useCallback((text: string) => {
const matchArr = REGEX.exec(text)
@@ -81,7 +123,7 @@ const HITLInputReplacementBlock = ({
return mergeRegister(
editor.registerNodeTransform(CustomTextNode, textNode => decoratorTransform(textNode, getMatch, createHITLInputBlockNode)),
)
}, [])
}, [editor, getMatch, createHITLInputBlockNode])
return null
}
@@ -6,6 +6,7 @@ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext
import { mergeRegister } from '@lexical/utils'
import {
$insertNodes,
$nodesOfType,
COMMAND_PRIORITY_EDITOR,
createCommand,
} from 'lexical'
@@ -43,6 +44,14 @@ const HITLInputBlock = memo(({
})
}, [editor, workflowNodesMap, workflowAvailableVariables])
useEffect(() => {
editor.update(() => {
$nodesOfType(HITLInputNode).forEach((node) => {
node.setReadonly(readonly)
})
})
}, [editor, readonly])
useEffect(() => {
if (!editor.hasNodes([HITLInputNode]))
throw new Error('HITLInputBlockPlugin: HITLInputBlock not registered on editor')
@@ -95,7 +104,7 @@ const HITLInputBlock = memo(({
COMMAND_PRIORITY_EDITOR,
),
)
}, [editor, onInsert, onDelete])
}, [editor, onInsert, onDelete, workflowNodesMap, getVarType, readonly])
return null
})
@@ -1,15 +1,30 @@
import type { FormInputItem, FormInputItemDefault } from '@/app/components/workflow/nodes/human-input/types'
import type { ValueSelector } from '@/app/components/workflow/types'
import type { Item as TypeSelectItem } from '@/app/components/app/configuration/config-var/config-modal/type-select'
import type { FormInputItem, FormInputItemDefault, ParagraphFormInput } from '@/app/components/workflow/nodes/human-input/types'
import type { UploadFileSetting, ValueSelector } from '@/app/components/workflow/types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
import { formatForDisplay } from '@tanstack/react-hotkeys'
import { produce } from 'immer'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import TypeSelector from '@/app/components/app/configuration/config-var/config-modal/type-select'
import ConfigSelect from '@/app/components/app/configuration/config-var/config-select'
import Input from '@/app/components/base/input'
import { InputVarType } from '@/app/components/workflow/types'
import FileUploadSetting from '@/app/components/workflow/nodes/_base/components/file-upload-setting'
import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker'
import {
createDefaultFormInputByType,
createDefaultParagraphFormInput,
isFileFormInput,
isFileListFormInput,
isParagraphFormInput,
isSelectFormInput,
} from '@/app/components/workflow/nodes/human-input/types'
import { InputVarType, VarType } from '@/app/components/workflow/types'
import PrePopulate from './pre-populate'
import TypeSwitch from './type-switch'
const i18nPrefix = 'nodes.humanInput.insertInputField'
@@ -17,42 +32,79 @@ type InputFieldProps = {
nodeId: string
isEdit: boolean
payload?: FormInputItem
unavailableVariableNames?: string[]
onChange: (newPayload: FormInputItem) => void
onCancel: () => void
}
const defaultPayload: FormInputItem = {
type: InputVarType.paragraph,
output_variable_name: '',
default: { type: 'constant', selector: [], value: '' },
}
const InputField: React.FC<InputFieldProps> = ({
nodeId,
isEdit,
payload,
unavailableVariableNames = [],
onChange,
onCancel,
}) => {
const { t } = useTranslation()
const [tempPayload, setTempPayload] = useState(payload || defaultPayload)
const nameValid = useMemo(() => {
const [tempPayload, setTempPayload] = useState<FormInputItem>(() => payload || createDefaultParagraphFormInput())
const fieldTypeItems = useMemo<TypeSelectItem[]>(() => {
return [
{
name: t('variableConfig.paragraph', { ns: 'appDebug' }),
value: InputVarType.paragraph,
},
{
name: t('variableConfig.select', { ns: 'appDebug' }),
value: InputVarType.select,
},
{
name: t('variableConfig.single-file', { ns: 'appDebug' }),
value: InputVarType.singleFile,
},
{
name: t('variableConfig.multi-files', { ns: 'appDebug' }),
value: InputVarType.multiFiles,
},
]
}, [t])
const paragraphPayload = useMemo<ParagraphFormInput>(() => {
if (isParagraphFormInput(tempPayload)) {
return {
...tempPayload,
default: tempPayload.default || createDefaultParagraphFormInput().default,
}
}
return createDefaultParagraphFormInput(tempPayload.output_variable_name)
}, [tempPayload])
const unavailableVariableNameSet = useMemo(() => {
return new Set(unavailableVariableNames.map(name => name.trim()).filter(Boolean))
}, [unavailableVariableNames])
const variableNameError = useMemo(() => {
const name = tempPayload.output_variable_name.trim()
if (!name)
return false
return null
if (name.includes(' '))
return false
return /^[a-z_]\w{0,29}$/.test(name)
}, [tempPayload.output_variable_name])
return 'variableNameInvalid'
if (!/^[a-z_]\w{0,29}$/.test(name))
return 'variableNameInvalid'
if (unavailableVariableNameSet.has(name))
return 'variableNameDuplicated'
return null
}, [tempPayload.output_variable_name, unavailableVariableNameSet])
const nameValid = useMemo(() => {
return !!tempPayload.output_variable_name.trim() && !variableNameError
}, [tempPayload.output_variable_name, variableNameError])
const handleSave = useCallback(() => {
if (!nameValid)
return
onChange(tempPayload)
}, [nameValid, onChange, tempPayload])
const defaultValueConfig = tempPayload.default
const handleTypeChange = useCallback((item: TypeSelectItem) => {
setTempPayload(prev => createDefaultFormInputByType(item.value as FormInputItem['type'], prev.output_variable_name))
}, [])
const handleDefaultValueChange = useCallback((key: keyof FormInputItemDefault) => {
return (value: ValueSelector | string) => {
const nextValue = produce(tempPayload, (draft) => {
if (!draft.default)
draft.default = { type: 'constant', selector: [], value: '' }
const nextValue = produce(paragraphPayload, (draft) => {
if (key === 'selector') {
draft.default.type = 'variable'
draft.default.selector = value as ValueSelector
@@ -67,7 +119,78 @@ const InputField: React.FC<InputFieldProps> = ({
})
setTempPayload(nextValue)
}
}, [tempPayload])
}, [paragraphPayload])
const handleSelectOptionsChange = useCallback((options: string[]) => {
setTempPayload((prev) => {
if (!isSelectFormInput(prev))
return prev
return {
...prev,
option_source: {
...prev.option_source,
type: 'constant',
value: options,
},
}
})
}, [])
const handleSelectOptionSourceTypeChange = useCallback((isVariable: boolean) => {
setTempPayload((prev) => {
if (!isSelectFormInput(prev))
return prev
return {
...prev,
option_source: {
...prev.option_source,
type: isVariable ? 'variable' : 'constant',
},
}
})
}, [])
const handleSelectOptionSourceSelectorChange = useCallback((selector: ValueSelector | string) => {
setTempPayload((prev) => {
if (!isSelectFormInput(prev))
return prev
return {
...prev,
option_source: {
...prev.option_source,
type: 'variable',
selector: selector as ValueSelector,
},
}
})
}, [])
const handleFilePayloadChange = useCallback((payload: UploadFileSetting) => {
setTempPayload((prev) => {
if (!isFileFormInput(prev))
return prev
return {
...prev,
allowed_file_extensions: payload.allowed_file_extensions || [],
allowed_file_types: payload.allowed_file_types,
allowed_file_upload_methods: payload.allowed_file_upload_methods,
}
})
}, [])
const handleFileListPayloadChange = useCallback((payload: UploadFileSetting) => {
setTempPayload((prev) => {
if (!isFileListFormInput(prev))
return prev
return {
...prev,
allowed_file_extensions: payload.allowed_file_extensions || [],
allowed_file_types: payload.allowed_file_types,
allowed_file_upload_methods: payload.allowed_file_upload_methods,
number_limits: payload.max_length,
}
})
}, [])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -83,74 +206,151 @@ const InputField: React.FC<InputFieldProps> = ({
}, [handleSave])
return (
<div className="w-[372px]
rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-3 shadow-lg backdrop-blur-[5px]"
>
<div className="system-md-semibold text-text-primary">{t(`${i18nPrefix}.title`, { ns: 'workflow' })}</div>
<div className="mt-3">
<div className="system-xs-medium text-text-secondary">
{t(`${i18nPrefix}.saveResponseAs`, { ns: 'workflow' })}
<span className="relative system-xs-regular text-text-destructive-secondary">*</span>
<div className="flex max-h-[540px] w-[372px] flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]">
<div className="min-h-0 flex-1 overflow-y-auto p-3 pb-0">
<div className="system-md-semibold text-text-primary">{t(`${i18nPrefix}.title`, { ns: 'workflow' })}</div>
<div className="mt-3">
<div className="system-xs-medium text-text-secondary">
{t(`${i18nPrefix}.fieldType`, { ns: 'workflow' })}
</div>
<div className="mt-1.5">
<TypeSelector
value={tempPayload.type}
items={fieldTypeItems}
onSelect={handleTypeChange}
/>
</div>
</div>
<Input
className="mt-1.5"
placeholder={t(`${i18nPrefix}.saveResponseAsPlaceholder`, { ns: 'workflow' })}
value={tempPayload.output_variable_name}
onChange={(e) => {
setTempPayload(prev => ({ ...prev, output_variable_name: e.target.value }))
}}
autoFocus
/>
{tempPayload.output_variable_name && !nameValid && (
<div className="mt-1 px-1 system-xs-regular text-text-destructive-secondary">
{t(`${i18nPrefix}.variableNameInvalid`, { ns: 'workflow' })}
<div className="mt-3">
<div className="system-xs-medium text-text-secondary">
{t(`${i18nPrefix}.saveResponseAs`, { ns: 'workflow' })}
<span className="relative system-xs-regular text-text-destructive-secondary">*</span>
</div>
<Input
className="mt-1.5"
placeholder={t(`${i18nPrefix}.saveResponseAsPlaceholder`, { ns: 'workflow' })}
value={tempPayload.output_variable_name}
onChange={(e) => {
setTempPayload(prev => ({ ...prev, output_variable_name: e.target.value }))
}}
autoFocus
/>
{tempPayload.output_variable_name && variableNameError && (
<div className="mt-1 px-1 system-xs-regular text-text-destructive-secondary">
{t(`${i18nPrefix}.${variableNameError}`, { ns: 'workflow' })}
</div>
)}
</div>
{isParagraphFormInput(tempPayload) && (
<div className="mt-4">
<div className="mb-1.5 system-xs-medium text-text-secondary">
{t(`${i18nPrefix}.prePopulateField`, { ns: 'workflow' })}
</div>
<PrePopulate
isVariable={paragraphPayload.default.type === 'variable'}
onIsVariableChange={(isVariable) => {
handleDefaultValueChange('type')(isVariable ? 'variable' : 'constant')
}}
nodeId={nodeId}
valueSelector={paragraphPayload.default.selector}
onValueSelectorChange={handleDefaultValueChange('selector')}
value={paragraphPayload.default.value}
onValueChange={handleDefaultValueChange('value')}
/>
</div>
)}
{isSelectFormInput(tempPayload) && (
<div className="mt-4">
<div className="mb-1.5 system-xs-medium text-text-secondary">
{t(`${i18nPrefix}.options`, { ns: 'workflow' })}
</div>
{tempPayload.option_source.type === 'variable'
? (
<div className="relative min-h-[80px] rounded-lg border border-transparent bg-components-input-bg-normal px-3 pt-2 pb-8">
<VarReferencePicker
nodeId={nodeId}
value={tempPayload.option_source.selector}
onChange={handleSelectOptionSourceSelectorChange}
readonly={false}
isJustShowValue
filterVar={varPayload => varPayload.type === VarType.arrayString}
/>
<TypeSwitch
className="absolute bottom-1 left-1.5"
isVariable
onIsVariableChange={handleSelectOptionSourceTypeChange}
/>
</div>
)
: (
<div className={cn('rounded-lg border border-transparent bg-components-input-bg-normal p-2')}>
<ConfigSelect
options={tempPayload.option_source.value}
onChange={handleSelectOptionsChange}
/>
<TypeSwitch
className="mt-2"
isVariable={false}
onIsVariableChange={handleSelectOptionSourceTypeChange}
/>
</div>
)}
</div>
)}
{isFileFormInput(tempPayload) && (
<div className="mt-4">
<FileUploadSetting
payload={{
...tempPayload,
max_length: 1,
}}
isMultiple={false}
onChange={handleFilePayloadChange}
/>
</div>
)}
{isFileListFormInput(tempPayload) && (
<div className="mt-4">
<FileUploadSetting
payload={{
...tempPayload,
max_length: tempPayload.number_limits || 5,
}}
isMultiple
onChange={handleFileListPayloadChange}
/>
</div>
)}
</div>
<div className="mt-4">
<div className="mb-1.5 system-xs-medium text-text-secondary">
{t(`${i18nPrefix}.prePopulateField`, { ns: 'workflow' })}
<div className="shrink-0 p-3">
<div className="flex justify-end space-x-2">
<Button onClick={onCancel}>{t('operation.cancel', { ns: 'common' })}</Button>
{isEdit
? (
<Button
variant="primary"
onClick={handleSave}
disabled={!nameValid}
>
{t('operation.save', { ns: 'common' })}
</Button>
)
: (
<Button
className="flex"
variant="primary"
disabled={!nameValid}
onClick={handleSave}
>
<span className="mr-1">{t(`${i18nPrefix}.insert`, { ns: 'workflow' })}</span>
<KbdGroup>
{['Mod', 'Enter'].map(key => (
<Kbd key={key} color="white">{formatForDisplay(key)}</Kbd>
))}
</KbdGroup>
</Button>
)}
</div>
<PrePopulate
isVariable={defaultValueConfig?.type === 'variable'}
onIsVariableChange={(isVariable) => {
handleDefaultValueChange('type')(isVariable ? 'variable' : 'constant')
}}
nodeId={nodeId}
valueSelector={defaultValueConfig?.selector}
onValueSelectorChange={handleDefaultValueChange('selector')}
value={defaultValueConfig?.value}
onValueChange={handleDefaultValueChange('value')}
/>
</div>
<div className="mt-4 flex justify-end space-x-2">
<Button onClick={onCancel}>{t('operation.cancel', { ns: 'common' })}</Button>
{isEdit
? (
<Button
variant="primary"
onClick={handleSave}
disabled={!nameValid}
>
{t('operation.save', { ns: 'common' })}
</Button>
)
: (
<Button
className="flex"
variant="primary"
disabled={!nameValid}
onClick={handleSave}
>
<span className="mr-1">{t(`${i18nPrefix}.insert`, { ns: 'workflow' })}</span>
<KbdGroup>
{['Mod', 'Enter'].map(key => (
<Kbd key={key} color="white">{formatForDisplay(key)}</Kbd>
))}
</KbdGroup>
</Button>
)}
</div>
</div>
)
@@ -109,6 +109,11 @@ export class HITLInputNode extends DecoratorNode<React.JSX.Element> {
return self.__readonly || false
}
setReadonly(readonly?: boolean): void {
const self = this.getWritable()
self.__readonly = readonly
}
static override clone(node: HITLInputNode): HITLInputNode {
return new HITLInputNode(
node.__variableName,
@@ -29,7 +29,6 @@ type PlaceholderProps = {
value: ValueSelector
onChange: (valueSelector: ValueSelector | string) => void
readonly: boolean
zIndex: number
filterVar: (varPayload: Var) => boolean
isJustShowValue?: boolean
}
@@ -86,7 +85,6 @@ const PrePopulate: FC<Props> = ({
value: valueSelector || [],
onChange: onValueSelectorChange!,
readonly: false,
zIndex: 1000000, // bigger than shortcut plugin popup
filterVar: (varPayload: Var) => {
return [VarType.string, VarType.number, VarType.secret].includes(varPayload.type)
},
@@ -149,6 +149,30 @@ describe('ShortcutsPopupPlugin', () => {
})
})
it('does not close on mousedown inside a Base UI portal overlay', async () => {
render(<MinimalEditor />)
const ce = screen.getByTestId(CONTENT_EDITABLE_ID)
ce.focus()
fireEvent.keyDown(document, { key: '/', ctrlKey: true })
expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
const portal = document.createElement('div')
portal.setAttribute('data-base-ui-portal', '')
const portalChild = document.createElement('button')
portalChild.textContent = 'portal-child'
portal.appendChild(portalChild)
document.body.appendChild(portal)
fireEvent.mouseDown(portalChild)
await waitFor(() => {
expect(screen.getByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
})
portal.remove()
})
// ─── Container / portal ───
it('portals into provided container when container is set', async () => {
render(<MinimalEditor withContainer />)
@@ -162,7 +186,10 @@ describe('ShortcutsPopupPlugin', () => {
render(<MinimalEditor withContainer={false} />)
focusAndTriggerHotkey('/')
const portalContent = await screen.findByText(SHORTCUTS_EMPTY_CONTENT)
const floatingDiv = screen.getByTestId('shortcuts-popup')
expect(document.body).toContainElement(portalContent)
expect(floatingDiv).toHaveStyle({ zIndex: '50' })
expect(floatingDiv).toHaveStyle({ overflow: 'visible' })
})
// ─── matchHotkey: string hotkey ───
@@ -407,8 +434,8 @@ describe('ShortcutsPopupPlugin', () => {
it('applies custom className to floating popup', async () => {
render(<MinimalEditor className="custom-popup-class" />)
focusAndTriggerHotkey('/')
const content = await screen.findByText(SHORTCUTS_EMPTY_CONTENT)
const floatingDiv = content.closest('div')
await screen.findByText(SHORTCUTS_EMPTY_CONTENT)
const floatingDiv = screen.getByTestId('shortcuts-popup')
expect(floatingDiv).toHaveClass('custom-popup-class')
})
@@ -158,10 +158,9 @@ export default function ShortcutsPopupPlugin({
size({
apply({ availableWidth, availableHeight, elements }) {
Object.assign(elements.floating.style, {
maxWidth: `${Math.min(400, availableWidth)}px`,
maxHeight: `${Math.max(0, availableHeight)}px`,
overflowX: 'hidden',
overflowY: 'auto',
'--shortcut-popup-max-width': `${Math.min(400, availableWidth)}px`,
'--shortcut-popup-max-height': `${Math.max(0, availableHeight)}px`,
'overflow': 'visible',
})
},
padding: 8,
@@ -275,6 +274,9 @@ export default function ShortcutsPopupPlugin({
/* v8 ignore next 2 -- outside-click listener can race with ref cleanup during close/unmount; null-ref path is a safety guard. @preserve */
if (!portalRef.current)
return
const target = e.target as HTMLElement | null
if (target?.closest('[data-base-ui-portal]'))
return
if (!portalRef.current.contains(e.target as Node))
closePortal()
}
@@ -292,21 +294,25 @@ export default function ShortcutsPopupPlugin({
return createPortal(
<div
data-testid="shortcuts-popup"
ref={(node) => {
portalRef.current = node
refs.setFloating(node)
}}
className={cn(
useContainer ? '' : 'z-999999',
'absolute rounded-xl bg-components-panel-bg-blur shadow-lg',
className,
)}
style={{
...floatingStyles,
zIndex: useContainer ? undefined : 50,
overflow: 'visible',
visibility: isPositioned ? 'visible' : 'hidden',
}}
>
{typeof children === 'function' ? children(closePortal, handleInsert) : (children ?? SHORTCUTS_EMPTY_CONTENT)}
<div className="max-h-(--shortcut-popup-max-height) max-w-(--shortcut-popup-max-width) overflow-x-hidden overflow-y-auto rounded-xl">
{typeof children === 'function' ? children(closePortal, handleInsert) : (children ?? SHORTCUTS_EMPTY_CONTENT)}
</div>
</div>,
containerEl,
)
@@ -1,9 +1,6 @@
import type {
EditorState,
LexicalCommand,
} from 'lexical'
import type { EditorState } from 'lexical'
import type { FC } from 'react'
import type { Hotkey } from './plugins/shortcuts-popup-plugin'
import type { Hotkey, ShortcutPopupInsertHandler } from './plugins/shortcuts-popup-plugin'
import type {
ContextBlockType,
CurrentBlockType,
@@ -71,7 +68,7 @@ import {
type ShortcutPopup = {
hotkey: Hotkey
Popup: React.ComponentType<{ onClose: () => void, onInsert: (command: LexicalCommand<unknown>, params: unknown[]) => void }>
Popup: React.ComponentType<{ onClose: () => void, onInsert: ShortcutPopupInsertHandler }>
}
type PromptEditorContentProps = {
@@ -139,6 +139,8 @@ describe('workflow-stream-handlers helpers', () => {
expect(workflowProcessData.humanInputFilledFormDataList).toEqual([
expect.objectContaining({
action_text: 'Submit',
form_content: 'content',
inputs: [],
}),
])
expect(workflowProcessData.tracing[0]).toEqual(expect.objectContaining({
@@ -3,6 +3,7 @@ import type { WorkflowProcess } from '@/app/components/base/chat/types'
import type { IOtherOptions } from '@/service/base'
import type { HumanInputFormTimeoutData, NodeTracing, WorkflowFinishedResponse } from '@/types/workflow'
import { produce } from 'immer'
import { enrichSubmittedHumanInputFormData } from '@/app/components/base/chat/chat/answer/human-input-content/submitted-utils'
import { getFilesInLogs } from '@/app/components/base/file-uploader/utils'
import { NodeRunningStatus, WorkflowRunningStatus } from '@/app/components/workflow/types'
import { sseGet } from '@/service/base'
@@ -204,16 +205,20 @@ const updateHumanInputFilled = (
data: NonNullable<WorkflowProcess['humanInputFilledFormDataList']>[number],
) => {
return updateWorkflowProcess(current, (draft) => {
let requiredFormData: NonNullable<WorkflowProcess['humanInputFormDataList']>[number] | undefined
if (draft.humanInputFormDataList?.length) {
const currentFormIndex = draft.humanInputFormDataList.findIndex(item => item.node_id === data.node_id)
if (currentFormIndex > -1)
if (currentFormIndex > -1) {
requiredFormData = draft.humanInputFormDataList[currentFormIndex]
draft.humanInputFormDataList.splice(currentFormIndex, 1)
}
}
const enrichedData = enrichSubmittedHumanInputFormData(data, requiredFormData)
if (!draft.humanInputFilledFormDataList)
draft.humanInputFilledFormDataList = [data]
draft.humanInputFilledFormDataList = [enrichedData]
else
draft.humanInputFilledFormDataList.push(data)
draft.humanInputFilledFormDataList.push(enrichedData)
})
}
@@ -4,7 +4,7 @@ import type { LLMNodeType } from '@/app/components/workflow/nodes/llm/types'
import type { Node, PromptItem } from '@/app/components/workflow/types'
import { describe, expect, it } from 'vitest'
import { DeliveryMethodType } from '@/app/components/workflow/nodes/human-input/types'
import { BlockEnum, EditionType, PromptRole } from '@/app/components/workflow/types'
import { BlockEnum, EditionType, InputVarType, PromptRole } from '@/app/components/workflow/types'
import { AppModeEnum } from '@/types/app'
import { getNodeUsedVars, updateNodeVars } from '../utils'
@@ -62,7 +62,26 @@ describe('variable utils', () => {
title: 'Human Input',
desc: '',
form_content: '',
inputs: [],
inputs: [
{
type: InputVarType.paragraph,
output_variable_name: 'summary',
default: {
type: 'variable',
selector: ['conversation', 'memory'],
value: '',
},
},
{
type: InputVarType.select,
output_variable_name: 'decision',
option_source: {
type: 'variable',
selector: ['env', 'OPTIONS'],
value: [],
},
},
],
user_actions: [],
timeout: 1,
timeout_unit: 'day',
@@ -84,6 +103,8 @@ describe('variable utils', () => {
expect(getNodeUsedVars(node)).toEqual(
expect.arrayContaining([
['env', 'API_KEY'],
['conversation', 'memory'],
['env', 'OPTIONS'],
]),
)
})
@@ -129,7 +150,26 @@ describe('variable utils', () => {
title: 'Human Input',
desc: '',
form_content: '',
inputs: [],
inputs: [
{
type: InputVarType.paragraph,
output_variable_name: 'summary',
default: {
type: 'variable',
selector: ['env', 'API_KEY'],
value: '',
},
},
{
type: InputVarType.select,
output_variable_name: 'decision',
option_source: {
type: 'variable',
selector: ['env', 'API_KEY'],
value: [],
},
},
],
user_actions: [],
timeout: 1,
timeout_unit: 'day',
@@ -154,6 +194,16 @@ describe('variable utils', () => {
subject: 'Subject {{#conversation.memory#}}',
body: 'Body {{#env.RENAMED_KEY#}}',
})
expect((updatedNode.data as HumanInputNodeType).inputs[0]).toMatchObject({
default: {
selector: ['env', 'RENAMED_KEY'],
},
})
expect((updatedNode.data as HumanInputNodeType).inputs[1]).toMatchObject({
option_source: {
selector: ['env', 'RENAMED_KEY'],
},
})
})
})
})
@@ -1530,7 +1530,14 @@ export const getNodeUsedVars = (node: Node): ValueSelector[] => {
return []
return [method.config.body]
})
res = matchNotSystemVars([formContent, ...mailTemplates])
const inputSelectors = payload.inputs.flatMap((input) => {
if (input.type === InputVarType.paragraph && input.default.type === 'variable')
return [input.default.selector]
if (input.type === InputVarType.select && input.option_source.type === 'variable')
return [input.option_source.selector]
return []
})
res = [...matchNotSystemVars([formContent, ...mailTemplates]), ...inputSelectors]
break
}
}
@@ -2032,6 +2039,15 @@ export const updateNodeVars = (
},
}
})
payload.inputs = payload.inputs.map((input) => {
if (input.type === InputVarType.paragraph && input.default.type === 'variable' && input.default.selector.join('.') === oldVarSelector.join('.')) {
input.default.selector = newVarSelector
}
if (input.type === InputVarType.select && input.option_source.type === 'variable' && input.option_source.selector.join('.') === oldVarSelector.join('.')) {
input.option_source.selector = newVarSelector
}
return input
})
break
}
}
@@ -444,6 +444,88 @@ describe('DSL Import with Human Input Node', () => {
expect(result.isValid).toBe(false)
})
it('should validate that enabled email delivery methods have complete configuration', () => {
const t = (key: string) => key
const payload = {
...humanInputDefault.defaultValue,
delivery_methods: [
{
id: 'dm-email',
type: DeliveryMethodType.Email,
enabled: true,
},
],
user_actions: [
{ id: 'approve', title: 'Approve', button_style: UserActionButtonType.Primary },
],
} as HumanInputNodeType
const result = humanInputDefault.checkValid(payload, t)
expect(result.isValid).toBe(false)
expect(result.errorMessage).toBe('nodes.humanInput.errorMsg.emailConfigIncomplete')
})
it('should validate email delivery config fields before user actions', () => {
const t = (key: string) => key
const payload = {
...humanInputDefault.defaultValue,
delivery_methods: [
{
id: 'dm-email',
type: DeliveryMethodType.Email,
enabled: true,
config: {
recipients: { whole_workspace: false, items: [] },
subject: 'Review request',
body: 'Please review {{#url#}}',
debug_mode: false,
},
},
],
user_actions: [
{ id: 'approve', title: 'Approve', button_style: UserActionButtonType.Primary },
],
} as HumanInputNodeType
const result = humanInputDefault.checkValid(payload, t)
expect(result.isValid).toBe(false)
expect(result.errorMessage).toBe('nodes.humanInput.errorMsg.emailConfigIncomplete')
})
it('should validate enabled email subject and body content', () => {
const t = (key: string) => key
const createPayload = (body: string, subject = 'Review request') => ({
...humanInputDefault.defaultValue,
delivery_methods: [
{
id: 'dm-email',
type: DeliveryMethodType.Email,
enabled: true,
config: {
recipients: { whole_workspace: true, items: [] },
subject,
body,
debug_mode: false,
},
},
],
user_actions: [
{ id: 'approve', title: 'Approve', button_style: UserActionButtonType.Primary },
],
}) as HumanInputNodeType
expect(humanInputDefault.checkValid(createPayload('{{#url#}}', ' '), t)).toEqual({
isValid: false,
errorMessage: 'nodes.humanInput.errorMsg.emailConfigIncomplete',
})
expect(humanInputDefault.checkValid(createPayload('Please review'), t)).toEqual({
isValid: false,
errorMessage: 'nodes.humanInput.errorMsg.emailConfigIncomplete',
})
})
it('should validate that user actions are required', () => {
const t = (key: string) => key
const payload = {
@@ -477,6 +559,30 @@ describe('DSL Import with Human Input Node', () => {
expect(result.isValid).toBe(false)
})
it('should validate that user action ids and titles are not empty', () => {
const t = (key: string) => key
const createPayload = (userActions: HumanInputNodeType['user_actions']) => ({
...humanInputDefault.defaultValue,
delivery_methods: [
{ id: 'dm-1', type: DeliveryMethodType.WebApp, enabled: true },
],
user_actions: userActions,
}) as HumanInputNodeType
expect(humanInputDefault.checkValid(createPayload([
{ id: ' ', title: 'Approve', button_style: UserActionButtonType.Primary },
]), t)).toEqual({
isValid: false,
errorMessage: 'nodes.humanInput.errorMsg.emptyActionId',
})
expect(humanInputDefault.checkValid(createPayload([
{ id: 'approve', title: ' ', button_style: UserActionButtonType.Primary },
]), t)).toEqual({
isValid: false,
errorMessage: 'nodes.humanInput.errorMsg.emptyActionTitle',
})
})
it('should pass validation with correct configuration', () => {
const t = (key: string) => key
const payload = {
@@ -503,8 +609,10 @@ describe('DSL Import with Human Input Node', () => {
const payload = {
...humanInputDefault.defaultValue,
inputs: [
{ type: 'text-input', output_variable_name: 'review_result', default: { selector: [], type: 'constant' as const, value: '' } },
{ type: 'text-input', output_variable_name: 'comment', default: { selector: [], type: 'constant' as const, value: '' } },
{ type: 'paragraph', output_variable_name: 'review_result', default: { selector: [], type: 'constant' as const, value: '' } },
{ type: 'file', output_variable_name: 'attachment', allowed_file_extensions: [], allowed_file_types: [], allowed_file_upload_methods: [] },
{ type: 'file-list', output_variable_name: 'attachments', allowed_file_extensions: [], allowed_file_types: [], allowed_file_upload_methods: [], number_limits: 3 },
{ type: 'select', output_variable_name: 'comment', option_source: { type: 'constant', selector: [], value: ['A', 'B'] } },
],
} as HumanInputNodeType
@@ -512,6 +620,8 @@ describe('DSL Import with Human Input Node', () => {
expect(outputVars).toEqual([
{ variable: 'review_result', type: 'string' },
{ variable: 'attachment', type: 'file' },
{ variable: 'attachments', type: 'array[file]' },
{ variable: 'comment', type: 'string' },
])
})
@@ -23,7 +23,7 @@ const createData = (overrides: Partial<HumanInputNodeType> = {}): HumanInputNode
}],
form_content: 'Please review this request',
inputs: [{
type: InputVarType.textInput,
type: InputVarType.paragraph,
output_variable_name: 'review_result',
default: {
selector: [],
@@ -98,7 +98,7 @@ vi.mock('../components/form-content', () => ({
<button
type="button"
onClick={() => props.onFormInputsChange([{
type: InputVarType.textInput,
type: InputVarType.paragraph,
output_variable_name: 'email',
default: {
selector: [],
@@ -225,7 +225,7 @@ const createData = (overrides: Partial<HumanInputNodeType> = {}): HumanInputNode
}],
form_content: 'Please review this request',
inputs: [{
type: InputVarType.textInput,
type: InputVarType.paragraph,
output_variable_name: 'review_result',
default: {
selector: [],
@@ -286,6 +286,9 @@ describe('human-input/panel', () => {
availableVars: [{
variable: ['start', 'email'],
type: VarType.string,
}, {
variable: ['code', 'result'],
type: VarType.arrayString,
}, {
variable: ['start', 'files'],
type: VarType.file,
@@ -315,6 +318,12 @@ describe('human-input/panel', () => {
expect(screen.getByText('__action_id:string:Action ID user triggered')).toBeInTheDocument()
expect(screen.getByText('__action_value:string:Selected action value')).toBeInTheDocument()
expect(screen.getByText('__rendered_content:string:Rendered content')).toBeInTheDocument()
expect(mockDeliveryMethod).toHaveBeenCalledWith(expect.objectContaining({
nodesOutputVars: [
expect.objectContaining({ type: VarType.string }),
expect.objectContaining({ type: VarType.arrayString }),
],
}))
await user.click(screen.getByRole('button', { name: 'delivery-method:editable' }))
await user.click(screen.getByRole('button', { name: /workflow\.nodes\.humanInput\.formContent\.preview/ }))
@@ -378,4 +387,33 @@ describe('human-input/panel', () => {
expect(screen.getByRole('button', { name: 'timeout:readonly' })).toBeInTheDocument()
expect(screen.queryByText('form-preview')).not.toBeInTheDocument()
})
it('renders file outputs with file-aware types', () => {
mockUseConfig.mockReturnValue(createConfigResult({
inputs: createData({
inputs: [
{
type: InputVarType.singleFile,
output_variable_name: 'attachment',
allowed_file_extensions: [],
allowed_file_types: [],
allowed_file_upload_methods: [],
},
{
type: InputVarType.multiFiles,
output_variable_name: 'attachments',
allowed_file_extensions: [],
allowed_file_types: [],
allowed_file_upload_methods: [],
number_limits: 3,
},
],
}),
}))
renderPanel()
expect(screen.getByText('attachment:file:Form input value')).toBeInTheDocument()
expect(screen.getByText('attachments:array[file]:Form input value')).toBeInTheDocument()
})
})
@@ -0,0 +1,73 @@
import { InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
import {
createDefaultFormInputByType,
createDefaultParagraphFormInput,
isFileFormInput,
isFileListFormInput,
isParagraphFormInput,
isSelectFormInput,
} from '../types'
describe('human-input/types', () => {
describe('type guards', () => {
it('should identify each form input kind', () => {
const paragraph = createDefaultFormInputByType(InputVarType.paragraph, 'comment')
const select = createDefaultFormInputByType(InputVarType.select, 'decision')
const singleFile = createDefaultFormInputByType(InputVarType.singleFile, 'attachment')
const multiFiles = createDefaultFormInputByType(InputVarType.multiFiles, 'attachments')
expect(isParagraphFormInput(paragraph)).toBe(true)
expect(isParagraphFormInput(select)).toBe(false)
expect(isSelectFormInput(select)).toBe(true)
expect(isSelectFormInput(singleFile)).toBe(false)
expect(isFileFormInput(singleFile)).toBe(true)
expect(isFileFormInput(multiFiles)).toBe(false)
expect(isFileListFormInput(multiFiles)).toBe(true)
expect(isFileListFormInput(paragraph)).toBe(false)
})
})
describe('default factories', () => {
it('should create a paragraph input with an empty constant default', () => {
expect(createDefaultParagraphFormInput('review')).toEqual({
type: InputVarType.paragraph,
output_variable_name: 'review',
default: {
type: 'constant',
selector: [],
value: '',
},
})
})
it('should create defaults for every supported input type', () => {
expect(createDefaultFormInputByType(InputVarType.select, 'choice')).toEqual({
type: InputVarType.select,
output_variable_name: 'choice',
option_source: {
type: 'constant',
selector: [],
value: [],
},
})
expect(createDefaultFormInputByType(InputVarType.singleFile, 'file')).toEqual({
type: InputVarType.singleFile,
output_variable_name: 'file',
allowed_file_extensions: [],
allowed_file_types: [SupportUploadFileTypes.image],
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
})
expect(createDefaultFormInputByType(InputVarType.multiFiles, 'files')).toEqual({
type: InputVarType.multiFiles,
output_variable_name: 'files',
allowed_file_extensions: [],
allowed_file_types: [SupportUploadFileTypes.image],
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
number_limits: 5,
})
})
})
})
@@ -0,0 +1,75 @@
import type { FormInputItem } from '../../types'
import { fireEvent, render, screen } from '@testing-library/react'
import { InputVarType } from '@/app/components/workflow/types'
import AddInputField from '../add-input-field'
const mockInputField = vi.hoisted(() => vi.fn())
vi.mock('@/app/components/base/prompt-editor/plugins/hitl-input-block/input-field', () => ({
__esModule: true,
default: (props: {
nodeId: string
isEdit: boolean
unavailableVariableNames?: string[]
onChange: (newPayload: FormInputItem) => void
onCancel: () => void
}) => {
mockInputField(props)
return (
<div>
<button
type="button"
onClick={() => props.onChange({
type: InputVarType.paragraph,
output_variable_name: 'comment',
default: {
type: 'constant',
selector: [],
value: '',
},
})}
>
save
</button>
<button type="button" onClick={props.onCancel}>cancel</button>
</div>
)
},
}))
describe('human-input/components/add-input-field', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render InputField in create mode and forward callbacks', () => {
const handleSave = vi.fn()
const handleCancel = vi.fn()
render(
<AddInputField
nodeId="human-node"
unavailableVariableNames={['comment']}
onSave={handleSave}
onCancel={handleCancel}
/>,
)
expect(mockInputField).toHaveBeenCalledWith(expect.objectContaining({
nodeId: 'human-node',
isEdit: false,
unavailableVariableNames: ['comment'],
onChange: handleSave,
onCancel: handleCancel,
}))
fireEvent.click(screen.getByRole('button', { name: 'save' }))
expect(handleSave).toHaveBeenCalledWith(expect.objectContaining({
type: InputVarType.paragraph,
output_variable_name: 'comment',
}))
fireEvent.click(screen.getByRole('button', { name: 'cancel' }))
expect(handleCancel).toHaveBeenCalledTimes(1)
})
})
@@ -64,10 +64,14 @@ vi.mock('../variable-in-markdown', () => ({
rehypeNotes: vi.fn(),
rehypeVariable: vi.fn(),
Variable: ({ path }: { path: string }) => <div data-testid="variable-path">{path}</div>,
Note: ({ defaultInput, nodeName }: {
defaultInput: { selector: string[] }
Note: ({ input, nodeName }: {
input: { type: string, default?: { selector: string[] }, option_source?: { selector: string[] } }
nodeName: (nodeId: string) => string
}) => <div data-testid="note">{nodeName(defaultInput.selector[0]!)}</div>,
}) => (
<div data-testid="note">
{input.default?.selector?.length ? nodeName(input.default.selector[0]!) : input.option_source?.selector?.join('.') || input.type}
</div>
),
}))
describe('FormContentPreview', () => {
@@ -131,4 +135,25 @@ describe('FormContentPreview', () => {
expect(onClose).toHaveBeenCalledTimes(1)
})
it('should pass non-paragraph inputs through the preview note renderer', () => {
render(
<FormContentPreview
content="content"
formInputs={[{
type: 'select' as never,
output_variable_name: 'field_1',
option_source: {
type: 'variable',
selector: ['node-1', 'items'],
value: [],
},
}]}
userActions={[]}
onClose={onClose}
/>,
)
expect(screen.getByTestId('note')).toHaveTextContent('node-1.items')
})
})
@@ -1,5 +1,6 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import FormContent from '../form-content'
const mockUseTranslation = vi.hoisted(() => vi.fn())
@@ -54,12 +55,13 @@ vi.mock('@/app/components/base/prompt-editor', () => ({
}) => {
mockPromptEditor(props)
const popup = props.shortcutPopups?.[0]
const Popup = popup?.Popup
return (
<div>
<button type="button" onClick={props.onFocus}>focus-editor</button>
<button type="button" onClick={props.onBlur}>blur-editor</button>
<button type="button" onClick={() => props.onChange('updated value')}>change-editor</button>
{popup && popup.Popup({ onClose: vi.fn(), onInsert: mockOnInsert })}
{Popup && <Popup onClose={vi.fn()} onInsert={mockOnInsert} />}
</div>
)
},
@@ -67,7 +69,8 @@ vi.mock('@/app/components/base/prompt-editor', () => ({
vi.mock('../add-input-field', () => ({
__esModule: true,
default: (props: {
default: function MockAddInputField(props: {
unavailableVariableNames?: string[]
onSave: (payload: {
type: string
output_variable_name: string
@@ -78,10 +81,17 @@ vi.mock('../add-input-field', () => ({
}
}) => void
onCancel: () => void
}) => {
}) {
const [draftName, setDraftName] = React.useState('')
mockAddInputField(props)
return (
<div>
<input
aria-label="field-name"
value={draftName}
onChange={event => setDraftName(event.target.value)}
/>
<button
type="button"
onClick={() => props.onSave({
@@ -235,6 +245,41 @@ describe('FormContent', () => {
expect(container.firstChild).toHaveClass('pointer-events-none')
})
it('should not insert a new input when the variable name already exists', () => {
render(
<FormContent
nodeId="node-2"
value="Initial content"
onChange={onChange}
formInputs={[{
type: 'paragraph',
output_variable_name: 'approval',
default: {
type: 'constant',
selector: [],
value: '',
},
} as never]}
onFormInputsChange={onFormInputsChange}
onFormInputItemRename={onFormInputItemRename}
onFormInputItemRemove={onFormInputItemRemove}
editorKey={1}
isExpand={false}
availableVars={[]}
availableNodes={[]}
/>,
)
expect(mockAddInputField).toHaveBeenCalledWith(expect.objectContaining({
unavailableVariableNames: ['approval'],
}))
fireEvent.click(screen.getByText('save-input'))
expect(mockOnInsert).not.toHaveBeenCalled()
expect(onFormInputsChange).not.toHaveBeenCalled()
})
it('should render the mac hotkey hint when focused on macOS', () => {
mockFormatForDisplay.mockReturnValue('⌘')
@@ -259,4 +304,29 @@ describe('FormContent', () => {
expect(screen.getByText('⌘')).toBeInTheDocument()
expect(screen.getByText('/')).toBeInTheDocument()
})
it('should preserve add-input draft state across parent rerenders', () => {
const props = {
nodeId: 'node-2',
value: 'Initial content',
onChange,
formInputs: [],
onFormInputsChange,
onFormInputItemRename,
onFormInputItemRemove,
editorKey: 1,
isExpand: false,
availableVars: [],
availableNodes: [],
}
const { rerender } = render(<FormContent {...props} />)
fireEvent.change(screen.getByRole('textbox', { name: 'field-name' }), {
target: { value: 'approval_name' },
})
rerender(<FormContent {...props} />)
expect(screen.getByRole('textbox', { name: 'field-name' })).toHaveValue('approval_name')
})
})
@@ -1,4 +1,5 @@
import type { ReactNode } from 'react'
import type { HumanInputFieldValue } from '@/app/components/base/chat/chat/answer/human-input-content/field-renderer'
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import type { HumanInputFormData } from '@/types/workflow'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
@@ -12,25 +13,54 @@ vi.mock('react-i18next', () => ({
}),
}))
vi.mock('@langgenius/dify-ui/button', () => ({
Button: ({
children,
disabled,
onClick,
}: {
children?: ReactNode
disabled?: boolean
onClick?: () => void
}) => (
<button type="button" disabled={disabled} onClick={onClick}>
{children}
</button>
),
}))
vi.mock('@/app/components/base/chat/chat/answer/human-input-content/content-item', () => ({
__esModule: true,
default: ({ content }: { content: string }) => <div>{content}</div>,
default: ({
content,
formInputFields,
inputs,
onInputChange,
}: {
content: string
formInputFields: FormInputItem[]
inputs: Record<string, HumanInputFieldValue>
onInputChange: (name: string, value: HumanInputFieldValue) => void
}) => {
const fieldName = /\{\{#\$output\.([^#]+)#\}\}/.exec(content)?.[1]
if (!fieldName)
return <div>{content}</div>
const field = formInputFields.find(field => field.output_variable_name === fieldName)
if (!field)
return null
if (field.type === 'select') {
return (
<select
aria-label={fieldName}
value={typeof inputs[fieldName] === 'string' ? inputs[fieldName] : ''}
onChange={event => onInputChange(fieldName, event.target.value)}
>
<option value="">Select</option>
{field.option_source.value.map(option => (
<option key={option} value={option}>{option}</option>
))}
</select>
)
}
if (field.type === 'paragraph') {
return (
<textarea
aria-label={fieldName}
value={typeof inputs[fieldName] === 'string' ? inputs[fieldName] : ''}
onChange={event => onInputChange(fieldName, event.target.value)}
/>
)
}
return <div>{fieldName}</div>
},
}))
const createFormData = (overrides: Partial<HumanInputFormData> = {}): HumanInputFormData => ({
@@ -60,6 +90,10 @@ const createFormData = (overrides: Partial<HumanInputFormData> = {}): HumanInput
})
describe('SingleRunForm', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renders the back action as a named button and forwards clicks', async () => {
const user = userEvent.setup()
const handleBack = vi.fn()
@@ -99,4 +133,99 @@ describe('SingleRunForm', () => {
})
})
})
it('submits updated paragraph input values', async () => {
const user = userEvent.setup()
const onSubmit = vi.fn().mockResolvedValue(undefined)
render(
<SingleRunForm
nodeName="Review"
data={createFormData()}
onSubmit={onSubmit}
/>,
)
await user.clear(screen.getByRole('textbox', { name: 'review' }))
await user.type(screen.getByRole('textbox', { name: 'review' }), 'updated review')
await user.click(screen.getByRole('button', { name: 'Approve' }))
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({
inputs: { review: 'updated review' },
action: 'approve',
})
})
})
it('uses resolved default values for variable paragraph inputs', async () => {
const user = userEvent.setup()
const onSubmit = vi.fn().mockResolvedValue(undefined)
render(
<SingleRunForm
nodeName="Review"
data={createFormData({
inputs: [{
type: InputVarType.paragraph,
output_variable_name: 'review',
default: {
selector: ['source', 'answer'],
type: 'variable',
value: 'fallback review',
},
}],
resolved_default_values: {
review: 'resolved review',
},
})}
onSubmit={onSubmit}
/>,
)
await user.click(screen.getByRole('button', { name: 'Approve' }))
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({
inputs: { review: 'resolved review' },
action: 'approve',
})
})
})
it('disables submit actions until a select input has a value', async () => {
const user = userEvent.setup()
const onSubmit = vi.fn().mockResolvedValue(undefined)
render(
<SingleRunForm
nodeName="Review"
data={createFormData({
form_content: 'Choose {{#$output.choice#}}',
inputs: [{
type: InputVarType.select,
output_variable_name: 'choice',
option_source: {
selector: [],
type: 'constant',
value: ['approve', 'reject'],
},
}],
})}
onSubmit={onSubmit}
/>,
)
expect(screen.getByRole('button', { name: 'Approve' })).toBeDisabled()
await user.selectOptions(screen.getByRole('combobox', { name: 'choice' }), 'approve')
await user.click(screen.getByRole('button', { name: 'Approve' }))
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({
inputs: { choice: 'approve' },
action: 'approve',
})
})
})
})
@@ -1,4 +1,7 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { InputVarType } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
import { Note, rehypeNotes, rehypeVariable, Variable } from '../variable-in-markdown'
describe('variable-in-markdown', () => {
@@ -86,10 +89,14 @@ describe('variable-in-markdown', () => {
it('should render note values and replace node ids with labels for variable defaults', () => {
const { rerender } = render(
<Note
defaultInput={{
type: 'variable',
selector: ['node-1', 'output'],
value: '',
input={{
type: InputVarType.paragraph,
output_variable_name: 'approval',
default: {
type: 'variable',
selector: ['node-1', 'output'],
value: '',
},
}}
nodeName={nodeId => nodeId === 'node-1' ? 'Start Node' : nodeId}
/>,
@@ -99,10 +106,14 @@ describe('variable-in-markdown', () => {
rerender(
<Note
defaultInput={{
type: 'constant',
value: 'Plain value',
selector: [],
input={{
type: InputVarType.paragraph,
output_variable_name: 'approval',
default: {
type: 'constant',
value: 'Plain value',
selector: [],
},
}}
nodeName={nodeId => nodeId}
/>,
@@ -110,5 +121,88 @@ describe('variable-in-markdown', () => {
expect(screen.getByText('Plain value')).toBeInTheDocument()
})
it('should render a select preview control for select inputs', () => {
render(
<Note
input={{
type: InputVarType.select,
output_variable_name: 'approval',
option_source: {
type: 'constant',
selector: [],
value: ['Approved', 'Rejected'],
},
}}
nodeName={nodeId => nodeId}
/>,
)
expect(screen.getByTestId('human-input-note-select-preview')).toBeInTheDocument()
expect(screen.getByText('Approved')).toBeInTheDocument()
})
it('should render dynamic select inputs as variable information without a select control', () => {
render(
<Note
input={{
type: InputVarType.select,
output_variable_name: 'approval',
option_source: {
type: 'variable',
selector: ['node-1', 'options'],
value: [],
},
}}
nodeName={nodeId => nodeId === 'node-1' ? 'Start Node' : nodeId}
/>,
)
expect(screen.queryByTestId('human-input-note-select-preview')).not.toBeInTheDocument()
expect(screen.queryByRole('combobox', { name: 'human-input-note-select' })).not.toBeInTheDocument()
expect(screen.getByText('{{Start Node/options}}')).toBeInTheDocument()
})
it('should open the select preview and show option items', async () => {
const user = userEvent.setup()
render(
<Note
input={{
type: InputVarType.select,
output_variable_name: 'approval',
option_source: {
type: 'constant',
selector: [],
value: ['Approved', 'Rejected'],
},
}}
nodeName={nodeId => nodeId}
/>,
)
await user.click(screen.getByRole('combobox', { name: 'human-input-note-select' }))
expect(await screen.findByRole('option', { name: 'Rejected' })).toBeInTheDocument()
})
it('should render upload placeholders for file inputs', () => {
render(
<Note
input={{
type: InputVarType.singleFile,
output_variable_name: 'attachment',
allowed_file_extensions: ['.pdf'],
allowed_file_types: [],
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
}}
nodeName={nodeId => nodeId}
/>,
)
expect(screen.getByTestId('human-input-note-file-preview')).toBeInTheDocument()
expect(screen.getByText('common.fileUploader.uploadFromComputer')).toBeInTheDocument()
expect(screen.getByText('common.fileUploader.pasteFileLink')).toBeInTheDocument()
})
})
})
@@ -6,12 +6,14 @@ import InputField from '@/app/components/base/prompt-editor/plugins/hitl-input-b
type Props = {
nodeId: string
unavailableVariableNames?: string[]
onSave: (newPayload: FormInputItem) => void
onCancel: () => void
}
const AddInputField: FC<Props> = ({
nodeId,
unavailableVariableNames,
onSave,
onCancel,
}) => {
@@ -19,6 +21,7 @@ const AddInputField: FC<Props> = ({
<InputField
nodeId={nodeId}
isEdit={false}
unavailableVariableNames={unavailableVariableNames}
onChange={onSave}
onCancel={onCancel}
/>
@@ -77,7 +77,7 @@ const createEmailConfig = (overrides: Partial<EmailConfig> = {}): EmailConfig =>
})
const formInputs: FormInputItem[] = [{
type: InputVarType.textInput,
type: InputVarType.paragraph,
output_variable_name: 'name',
default: {
selector: ['start', 'name'],
@@ -1,16 +1,26 @@
/* eslint-disable react/no-context-provider -- use-context-selector contexts require .Provider in tests. */
import type { ReactNode } from 'react'
import type { EmailConfig, FormInputItem } from '../../../types'
import type { EmailConfig, FormInputItem, ParagraphFormInput, SelectFormInput } from '../../../types'
import type { CodeNodeType } from '@/app/components/workflow/nodes/code/types'
import type { App, AppSSO } from '@/types/app'
import { toast } from '@langgenius/dify-ui/toast'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen, waitFor } from '@testing-library/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useStore as useAppStore } from '@/app/components/app/store'
import { HooksStoreContext } from '@/app/components/workflow/hooks-store/provider'
import { createHooksStore } from '@/app/components/workflow/hooks-store/store'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import { BlockEnum, InputVarType, VarType } from '@/app/components/workflow/types'
import { AppContext, initialLangGeniusVersionInfo, initialWorkspaceInfo, userProfilePlaceholder } from '@/context/app-context'
import EmailSenderModal from '../test-email-sender'
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: {
error: vi.fn(),
},
}))
type RecordedRequest = {
url: string
method: string
@@ -145,8 +155,8 @@ const createConfig = (overrides: Partial<EmailConfig> = {}): EmailConfig => ({
...overrides,
})
const createFormInput = (overrides: Partial<FormInputItem> = {}): FormInputItem => ({
type: InputVarType.textInput,
const createFormInput = (overrides: Partial<ParagraphFormInput> = {}): FormInputItem => ({
type: InputVarType.paragraph,
output_variable_name: 'user_name',
default: {
type: 'variable',
@@ -156,6 +166,16 @@ const createFormInput = (overrides: Partial<FormInputItem> = {}): FormInputItem
...overrides,
})
const createDynamicSelectInput = (): SelectFormInput => ({
type: InputVarType.select,
output_variable_name: 'decision',
option_source: {
type: 'variable',
selector: ['code', 'result'],
value: [],
},
})
describe('human-input/delivery-method/test-email-sender', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -233,7 +253,7 @@ describe('human-input/delivery-method/test-email-sender', () => {
delivery_method_id: 'delivery-1',
inputs: {
'#start.user_name#': 'Ada',
'#start.score#': '42',
'#start.score#': 42,
},
},
}))
@@ -243,6 +263,81 @@ describe('human-input/delivery-method/test-email-sender', () => {
expect(handleOpenChange).toHaveBeenCalledWith(false)
})
it('should submit variables referenced by dynamic select option sources', async () => {
const user = userEvent.setup()
const { requests } = setupFetch()
renderWithProviders(
<EmailSenderModal
nodeId="human-node"
deliveryId="delivery-1"
open
onOpenChange={vi.fn()}
jumpToEmailConfigModal={vi.fn()}
config={createConfig({
body: '{{#url#}}',
})}
formInputs={[createDynamicSelectInput()]}
availableNodes={[
{
id: 'code',
type: 'custom',
position: { x: 0, y: 0 },
data: {
title: 'Code',
desc: '',
type: BlockEnum.Code,
variables: [],
code_language: CodeLanguage.python3,
code: '',
outputs: {
result: {
type: VarType.arrayString,
children: null,
},
},
} as CodeNodeType,
},
]}
nodesOutputVars={[
{
nodeId: 'code',
title: 'Code',
vars: [
{
variable: 'result',
type: VarType.arrayString,
},
],
},
]}
/>,
)
const sendButton = screen.getByRole('button', { name: 'workflow.nodes.humanInput.deliveryMethod.emailSender.send' })
expect(sendButton).toBeDisabled()
expect(screen.queryByPlaceholderText('result')).not.toBeInTheDocument()
fireEvent.change(screen.getByTestId('monaco-editor'), {
target: { value: '["approve","reject"]' },
})
expect(sendButton).toBeEnabled()
await user.click(sendButton)
await waitFor(() => expect(requests).toContainEqual(expect.objectContaining({
url: 'http://localhost:5001/console/api/apps/app-1/workflows/draft/human-input/nodes/human-node/delivery-test',
method: 'POST',
body: {
delivery_method_id: 'delivery-1',
inputs: {
'#code.result#': ['approve', 'reject'],
},
},
})))
})
it('should render fallback variable inputs and allow cancelling', async () => {
const user = userEvent.setup()
setupFetch()
@@ -295,4 +390,118 @@ describe('human-input/delivery-method/test-email-sender', () => {
expect(screen.getByText('external@example.com')).toBeInTheDocument()
expect(screen.getByText('nodes.humanInput.deliveryMethod.emailSender.tip')).toBeInTheDocument()
})
it('should show a validation toast when generated JSON input is invalid', async () => {
const user = userEvent.setup()
const { requests } = setupFetch()
renderWithProviders(
<EmailSenderModal
nodeId="human-node"
deliveryId="delivery-1"
open
onOpenChange={vi.fn()}
jumpToEmailConfigModal={vi.fn()}
config={createConfig({
body: 'Please review {{#code.payload#}}',
})}
availableNodes={[
{
id: 'code',
type: 'custom',
position: { x: 0, y: 0 },
data: {
title: 'Code',
desc: '',
type: BlockEnum.Code,
variables: [],
code_language: CodeLanguage.python3,
code: '',
outputs: {
payload: {
type: VarType.object,
children: null,
},
},
} as CodeNodeType,
},
]}
nodesOutputVars={[
{
nodeId: 'code',
title: 'Code',
vars: [
{
variable: 'payload',
type: VarType.object,
},
],
},
]}
/>,
)
fireEvent.change(screen.getByTestId('monaco-editor'), {
target: { value: '{invalid' },
})
await user.click(screen.getByRole('button', { name: 'workflow.nodes.humanInput.deliveryMethod.emailSender.send' }))
expect(toast.error).toHaveBeenCalledWith('workflow.errorMsg.invalidJson:{"field":"payload"}')
expect(requests).not.toContainEqual(expect.objectContaining({
url: 'http://localhost:5001/console/api/apps/app-1/workflows/draft/human-input/nodes/human-node/delivery-test',
method: 'POST',
}))
})
it('should show debug success copy after sending in debug mode', async () => {
const user = userEvent.setup()
setupFetch()
renderWithProviders(
<EmailSenderModal
nodeId="human-node"
deliveryId="delivery-1"
open
onOpenChange={vi.fn()}
jumpToEmailConfigModal={vi.fn()}
config={createConfig({
body: 'Plain body',
debug_mode: true,
})}
/>,
)
expect(screen.getByText('workflow.nodes.humanInput.deliveryMethod.emailSender.debugModeTip')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'workflow.nodes.humanInput.deliveryMethod.emailSender.send' }))
await waitFor(() => expect(screen.getByText('nodes.humanInput.deliveryMethod.emailSender.debugDone')).toBeInTheDocument())
})
it('should show specific-recipient success copy after sending', async () => {
const user = userEvent.setup()
setupFetch()
renderWithProviders(
<EmailSenderModal
nodeId="human-node"
deliveryId="delivery-1"
open
onOpenChange={vi.fn()}
jumpToEmailConfigModal={vi.fn()}
config={createConfig({
body: 'Plain body',
recipients: {
whole_workspace: false,
items: [{ type: 'external', email: 'external@example.com' }],
},
})}
/>,
)
await user.click(screen.getByRole('button', { name: 'workflow.nodes.humanInput.deliveryMethod.emailSender.send' }))
await waitFor(() => expect(screen.getByText('workflow.nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone3')).toBeInTheDocument())
expect(screen.getByText('external@example.com')).toBeInTheDocument()
})
})
@@ -136,6 +136,40 @@ describe('human-input/delivery-method/recipient/email-input', () => {
expect(handleSelect).toHaveBeenCalledTimes(1)
})
it('should keep typing focused and stop keyboard events from reaching workflow listeners', () => {
const handleParentKeyDown = vi.fn()
const handleWindowKeyDown = vi.fn()
window.addEventListener('keydown', handleWindowKeyDown)
try {
render(
<div onKeyDown={handleParentKeyDown}>
<EmailInput
email="owner@example.com"
value={[]}
list={members}
onDelete={vi.fn()}
onSelect={vi.fn()}
onAdd={vi.fn()}
/>
</div>,
)
const input = screen.getByRole('textbox')
input.focus()
fireEvent.change(input, { target: { value: 'a' } })
fireEvent.keyDown(input, { key: 'a', code: 'KeyA' })
expect(document.activeElement).toBe(input)
expect(handleParentKeyDown).not.toHaveBeenCalled()
expect(handleWindowKeyDown).not.toHaveBeenCalled()
}
finally {
window.removeEventListener('keydown', handleWindowKeyDown)
}
})
it('should delete the last recipient with backspace, flag missing members as errors, and stop focusing when disabled', () => {
const handleDelete = vi.fn()
const { container, rerender } = render(
@@ -11,9 +11,22 @@ vi.mock('../member-list', () => ({
searchValue: string
list: Member[]
email: string
onSearchChange: (value: string) => void
onSelect: (memberId: string) => void
}) => {
mockMemberList(props)
return <div data-testid="member-list" />
return (
<div data-testid="member-list">
<input
aria-label="member search"
value={props.searchValue}
onChange={e => props.onSearchChange(e.target.value)}
/>
<button type="button" onClick={() => props.onSelect('member-1')}>
select member
</button>
</div>
)
},
}))
@@ -66,4 +79,32 @@ describe('human-input/delivery-method/recipient/member-selector', () => {
await user.click(trigger)
expect(screen.queryByTestId('member-list')).not.toBeInTheDocument()
})
it('should update search value and close the list after selecting a member', async () => {
const user = userEvent.setup()
const handleSelect = vi.fn()
render(
<MemberSelector
value={[]}
email="owner@example.com"
onSelect={handleSelect}
list={members}
/>,
)
await user.click(screen.getByRole('button', {
name: 'workflow.nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.trigger',
}))
await user.type(screen.getByRole('textbox', { name: 'member search' }), 'member one')
expect(mockMemberList).toHaveBeenLastCalledWith(expect.objectContaining({
searchValue: 'member one',
}))
await user.click(screen.getByRole('button', { name: 'select member' }))
expect(handleSelect).toHaveBeenCalledWith('member-1')
expect(screen.queryByTestId('member-list')).not.toBeInTheDocument()
})
})
@@ -104,6 +104,8 @@ const EmailInput = ({
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
e.stopPropagation()
if (e.key === 'Enter' || e.key === 'Tab' || e.key === ' ' || e.key === ',') {
e.preventDefault()
handleEmailAdd()
@@ -155,6 +157,7 @@ const EmailInput = ({
sideOffset={4}
alignOffset={-40}
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
popupProps={{ initialFocus: false, finalFocus: false }}
positionerProps={{ anchor: inputRef }}
>
<MemberList
@@ -2,12 +2,12 @@ import type { EmailConfig, FormInputItem } from '../../types'
import type {
Node,
NodeOutPutVar,
ValueSelector,
Var,
} from '@/app/components/workflow/types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import { RiArrowRightSFill } from '@remixicon/react'
import { noop, unionBy } from 'es-toolkit/compat'
import { memo, useCallback, useMemo, useState } from 'react'
@@ -16,6 +16,7 @@ import { useStore as useAppStore } from '@/app/components/app/store'
import Divider from '@/app/components/base/divider'
import { getInputVars as doGetInputVars } from '@/app/components/base/prompt-editor/constants'
import FormItem from '@/app/components/workflow/nodes/_base/components/before-run-form/form-item'
import { formatValue } from '@/app/components/workflow/nodes/_base/components/before-run-form/helpers'
import {
getNodeInfoById,
isConversationVar,
@@ -26,7 +27,7 @@ import { InputVarType, VarType } from '@/app/components/workflow/types'
import { useAppContext } from '@/context/app-context'
import { useMembers } from '@/service/use-common'
import { useTestEmailSender } from '@/service/use-workflow'
import { isOutput } from '../../utils'
import { getHumanInputFormDependencySelectors, isOutput } from '../../utils'
import EmailInput from './recipient/email-input'
const i18nPrefix = 'nodes.humanInput'
@@ -70,6 +71,45 @@ const getOriginVar = (valueSelector: string[], list: NodeOutPutVar[]) => {
return undefined
}
const varTypeToInputVarType = (type: VarType) => {
if (type === VarType.number)
return InputVarType.number
if (type === VarType.boolean)
return InputVarType.checkbox
if ([VarType.object, VarType.array, VarType.arrayNumber, VarType.arrayString, VarType.arrayObject].includes(type))
return InputVarType.json
if (type === VarType.file)
return InputVarType.singleFile
if (type === VarType.arrayFile)
return InputVarType.multiFiles
return InputVarType.textInput
}
const formatEmailSenderInputs = (
variables: Array<{ variable: string, type: InputVarType, label: unknown }>,
values: Record<string, unknown>,
) => {
const formattedValues: Record<string, unknown> = {}
let parseErrorJsonField = ''
variables.forEach((variable) => {
try {
formattedValues[variable.variable] = formatValue(values[variable.variable], variable.type)
}
catch {
parseErrorJsonField = typeof variable.label === 'object' && variable.label !== null && 'variable' in variable.label
? String(variable.label.variable)
: variable.variable
}
})
return {
formattedValues,
parseErrorJsonField,
}
}
const EmailSenderModal = ({
nodeId,
deliveryId,
@@ -96,14 +136,9 @@ const EmailSenderModal = ({
const accounts = members?.accounts || []
const generatedInputs = useMemo(() => {
const defaultValueSelectors = (formInputs || []).reduce((acc, input) => {
if (input.default.type === 'variable') {
acc.push(input.default.selector)
}
return acc
}, [] as ValueSelector[])
const formInputDependencySelectors = getHumanInputFormDependencySelectors(formInputs || [])
const valueSelectors = doGetInputVars((formContent || '') + (config?.body || ''))
const variables = unionBy([...valueSelectors, ...defaultValueSelectors], item => item.join('.')).map((item) => {
const variables = unionBy([...valueSelectors, ...formInputDependencySelectors], item => item.join('.')).map((item) => {
const varInfo = getNodeInfoById(availableNodes, item[0]!)?.data
return {
@@ -132,7 +167,7 @@ const EmailSenderModal = ({
return {
label: item.label || item.variable,
variable: item.variable,
type: originalVar.type === VarType.number ? InputVarType.number : InputVarType.textInput,
type: varTypeToInputVarType(originalVar.type),
required: true,
}
})
@@ -166,20 +201,25 @@ const EmailSenderModal = ({
const handleConfirm = useCallback(async () => {
if (!confirmChecked)
return
const { formattedValues, parseErrorJsonField } = formatEmailSenderInputs(generatedInputs, inputs)
if (parseErrorJsonField) {
toast.error(t('errorMsg.invalidJson', { ns: 'workflow', field: parseErrorJsonField }))
return
}
setSendingEmail(true)
try {
await testEmailSender({
appID: appDetail?.id || '',
nodeID: nodeId,
deliveryID: deliveryId,
inputs,
inputs: formattedValues,
})
setDone(true)
}
finally {
setSendingEmail(false)
}
}, [confirmChecked, testEmailSender, appDetail?.id, nodeId, deliveryId, inputs])
}, [confirmChecked, generatedInputs, inputs, testEmailSender, appDetail?.id, nodeId, deliveryId, t])
if (done) {
return (
@@ -38,6 +38,21 @@ const FormContentPreview: FC<FormContentPreviewProps> = ({
return node?.data.title || nodeId
}, [nodes])
const renderInputPreview = React.useCallback(({ node }: { node?: { properties?: Record<string, unknown> } }) => {
const name = String(node?.properties?.dataName ?? '')
const input = formInputs.find(i => i.output_variable_name === name)
if (!input) {
return (
<div>
Can't find note:
{name}
</div>
)
}
return <Note input={input} nodeName={nodeName} />
}, [formInputs, nodeName])
return (
<div
className="fixed top-[112px] z-10 max-h-[calc(100vh-116px)] w-[600px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg py-3 shadow-xl"
@@ -64,22 +79,7 @@ const FormContentPreview: FC<FormContentPreviewProps> = ({
}
return <Variable path={newPath} />
},
section: ({ node }) => (() => {
const name = String(node?.properties?.dataName ?? '')
const input = formInputs.find(i => i.output_variable_name === name)
if (!input) {
return (
<div>
Can't find note:
{name}
</div>
)
}
const defaultInput = input.default
return (
<Note defaultInput={defaultInput!} nodeName={nodeName} />
)
})(),
section: renderInputPreview,
}}
/>
<div className="mt-3 flex flex-wrap gap-1 py-1">
@@ -1,14 +1,15 @@
'use client'
import type { LexicalCommand } from 'lexical'
import type { FC } from 'react'
import type { FormInputItem } from '../types'
import type { ShortcutPopupInsertHandler } from '@/app/components/base/prompt-editor/plugins/shortcuts-popup-plugin'
import type { WorkflowNodesMap } from '@/app/components/base/prompt-editor/types'
import type { Node, NodeOutPutVar } from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
import { Kbd } from '@langgenius/dify-ui/kbd'
import { formatForDisplay } from '@tanstack/react-hotkeys'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useEffect, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import PromptEditor from '@/app/components/base/prompt-editor'
import { INSERT_HITL_INPUT_BLOCK_COMMAND } from '@/app/components/base/prompt-editor/plugins/hitl-input-block'
@@ -49,11 +50,20 @@ const FormContent: FC<FormContentProps> = ({
const getVarType = useWorkflowVariableType()
const [needToAddFormInput, setNeedToAddFormInput] = useState(false)
const [newFormInputs, setNewFormInputs] = useState<FormInputItem[]>([])
const handleInsertHITLNode = (onInsert: (command: LexicalCommand<unknown>, params: any) => void) => {
const pendingFormInputsRef = useRef<{
value: string
formInputs: FormInputItem[]
} | null>(null)
const handleInsertHITLNode = useCallback((onInsert: ShortcutPopupInsertHandler) => {
return (payload: FormInputItem) => {
if (formInputs.some(input => input.output_variable_name === payload.output_variable_name))
return
const newFormInputs = [...(formInputs || []), payload]
pendingFormInputsRef.current = {
value,
formInputs: newFormInputs,
}
onInsert(INSERT_HITL_INPUT_BLOCK_COMMAND, {
variableName: payload.output_variable_name,
nodeId,
@@ -62,30 +72,37 @@ const FormContent: FC<FormContentProps> = ({
onFormInputItemRename,
onFormInputItemRemove,
})
setNewFormInputs(newFormInputs)
setNeedToAddFormInput(true)
}
}
}, [
formInputs,
nodeId,
onFormInputsChange,
onFormInputItemRemove,
onFormInputItemRename,
value,
])
// avoid update formInputs would overwrite the value just inserted
useEffect(() => {
if (needToAddFormInput) {
onFormInputsChange(newFormInputs)
setNeedToAddFormInput(false)
}
}, [value])
const pendingFormInputs = pendingFormInputsRef.current
if (!pendingFormInputs || pendingFormInputs.value === value)
return
onFormInputsChange(pendingFormInputs.formInputs)
pendingFormInputsRef.current = null
}, [onFormInputsChange, value])
const [isFocus, {
setTrue: setFocus,
setFalse: setBlur,
}] = useBoolean(false)
const workflowNodesMap = availableNodes.reduce((acc: any, node) => {
const workflowNodesMap = availableNodes.reduce<WorkflowNodesMap>((acc, node) => {
acc[node.id] = {
title: node.data.title,
type: node.data.type,
width: node.width,
height: node.height,
width: node.width ?? undefined,
height: node.height ?? undefined,
position: node.position,
}
if (node.data.type === BlockEnum.Start) {
@@ -96,6 +113,48 @@ const FormContent: FC<FormContentProps> = ({
}
return acc
}, {})
const unavailableVariableNames = useMemo(() => {
return formInputs.map(input => input.output_variable_name)
}, [formInputs])
const addInputFieldConfigRef = useRef({
nodeId,
unavailableVariableNames,
handleInsertHITLNode,
})
addInputFieldConfigRef.current = {
nodeId,
unavailableVariableNames,
handleInsertHITLNode,
}
const shortcutPopups = useMemo(() => {
if (readonly)
return []
return [{
hotkey: ['mod', '/'],
// Keep this component type stable while the popup is open; it reads fresh props from a ref.
// eslint-disable-next-line react/no-nested-component-definitions
Popup: ({ onClose, onInsert }: {
onClose: () => void
onInsert: ShortcutPopupInsertHandler
}) => {
const {
nodeId,
unavailableVariableNames,
handleInsertHITLNode,
} = addInputFieldConfigRef.current
return (
<AddInputField
nodeId={nodeId}
unavailableVariableNames={unavailableVariableNames}
onSave={handleInsertHITLNode(onInsert)}
onCancel={onClose}
/>
)
},
}]
}, [readonly])
return (
<div
@@ -130,22 +189,11 @@ const FormContent: FC<FormContentProps> = ({
workflowVariableBlock={{
show: true,
variables: availableVars || [],
getVarType: getVarType as any,
getVarType,
workflowNodesMap,
}}
editable={!readonly}
shortcutPopups={readonly
? []
: [{
hotkey: ['mod', '/'],
Popup: ({ onClose, onInsert }) => (
<AddInputField
nodeId={nodeId}
onSave={handleInsertHITLNode(onInsert!)}
onCancel={onClose}
/>
),
}]}
shortcutPopups={shortcutPopups}
/>
</div>
{isFocus && (
@@ -1,5 +1,6 @@
'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 { UserAction } from '@/app/components/workflow/nodes/human-input/types'
import type { HumanInputFormData } from '@/types/workflow'
import { Button } from '@langgenius/dify-ui/button'
@@ -9,14 +10,14 @@ import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import ContentItem from '@/app/components/base/chat/chat/answer/human-input-content/content-item'
import { getButtonStyle, initializeInputs, splitByOutputVar } from '@/app/components/base/chat/chat/answer/human-input-content/utils'
import { getButtonStyle, getRenderedFormInputs, hasInvalidSelectOrFileInput, initializeInputs, splitByOutputVar } from '@/app/components/base/chat/chat/answer/human-input-content/utils'
type Props = {
nodeName: string
data: HumanInputFormData
showBackButton?: boolean
handleBack?: () => void
onSubmit?: ({ inputs, action }: { inputs: Record<string, string>, action: string }) => Promise<void>
onSubmit?: ({ inputs, action }: { inputs: Record<string, HumanInputFieldValue>, action: string }) => Promise<void>
}
const FormContent = ({
@@ -27,18 +28,21 @@ const FormContent = ({
onSubmit,
}: Props) => {
const { t } = useTranslation()
const defaultInputs = initializeInputs(data.inputs, data.resolved_default_values || {})
const contentList = splitByOutputVar(data.form_content)
const renderedFormInputs = getRenderedFormInputs(data.inputs, data.form_content)
const defaultInputs = initializeInputs(renderedFormInputs, data.resolved_default_values || {})
const [inputs, setInputs] = useState(defaultInputs)
const [isSubmitting, setIsSubmitting] = useState(false)
const handleInputsChange = (name: string, value: string) => {
const handleInputsChange = (name: string, value: HumanInputFieldValue) => {
setInputs(prev => ({
...prev,
[name]: value,
}))
}
const hasEmptySelectOrFileInput = hasInvalidSelectOrFileInput(renderedFormInputs, inputs)
const submit = async (actionID: string) => {
setIsSubmitting(true)
await onSubmit?.({ inputs, action: actionID })
@@ -75,7 +79,7 @@ const FormContent = ({
{data.actions.map((action: UserAction) => (
<Button
key={action.id}
disabled={isSubmitting}
disabled={isSubmitting || hasEmptySelectOrFileInput}
variant={getButtonStyle(action.button_style) as ButtonProps['variant']}
onClick={() => submit(action.id)}
>
@@ -1,5 +1,11 @@
import type * as React from 'react'
import type { FormInputItemDefault } from '../types'
/* eslint-disable react-refresh/only-export-components */
import type { FormInputItem } from '../types'
import { cn } from '@langgenius/dify-ui/cn'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { TransferMethod } from '@/types/app'
import { isFileFormInput, isFileListFormInput, isSelectFormInput } from '../types'
const variableRegex = /\{\{#(.+?)#\}\}/g
const noteRegex = /\{\{#\$(.+?)#\}\}/g
@@ -87,6 +93,17 @@ const formatVariablePath = (path: string) => {
.replace('#}}', '}}')
}
const sourceToVariablePath = (
source: { selector: string[] },
nodeName: (nodeId: string) => string,
) => {
if (!source.selector.length)
return ''
const path = `{{#${source.selector.join('.')}#}}`
return replaceNodeIdsWithNames(path, nodeName)
}
export function rehypeVariable() {
return (tree: MarkdownNode) => {
visitTextNodes(tree, (value) => {
@@ -132,13 +149,98 @@ export const Variable: React.FC<{ path: string }> = ({ path }) => {
)
}
export const Note: React.FC<{ defaultInput: FormInputItemDefault, nodeName: (nodeId: string) => string }> = ({ defaultInput, nodeName }) => {
const isVariable = defaultInput.type === 'variable'
const path = `{{#${defaultInput.selector.join('.')}#}}`
const newPath = path ? replaceNodeIdsWithNames(path, nodeName) : path
const SelectPreview: React.FC<{ label: string, options: string[] }> = ({ label, options }) => {
const [value, setValue] = React.useState(options[0] || label)
return (
<div data-testid="human-input-note-select-preview" className="my-3">
<Select value={value} onValueChange={nextValue => nextValue && setValue(nextValue)}>
<SelectTrigger size="large" className="w-full rounded-[10px]" aria-label="human-input-note-select">
{value}
</SelectTrigger>
<SelectContent listClassName="max-h-[140px] overflow-y-auto">
{options.map(option => (
<SelectItem key={option} value={option}>
<SelectItemText>{option}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}
const FileUploadPreview: React.FC<{ methods: TransferMethod[], t: (key: string, options?: Record<string, unknown>) => string }> = ({ methods, t }) => {
const normalizedMethods = methods.length
? methods
: [TransferMethod.local_file, TransferMethod.remote_url]
const actions = [
normalizedMethods.includes(TransferMethod.local_file) && {
iconClassName: 'i-ri-upload-cloud-2-line',
label: t('fileUploader.uploadFromComputer', { ns: 'common' }),
},
normalizedMethods.includes(TransferMethod.remote_url) && {
iconClassName: 'i-ri-link',
label: t('fileUploader.pasteFileLink', { ns: 'common' }),
},
].filter(Boolean) as Array<{ iconClassName: string, label: string }>
return (
<div
data-testid="human-input-note-file-preview"
className={cn(
'my-3 grid gap-2',
actions.length > 1 ? 'grid-cols-2' : 'grid-cols-1',
)}
>
{actions.map(action => (
<div
key={action.label}
className="flex h-10 items-center justify-center rounded-xl bg-components-input-bg-normal px-3"
>
<span className={cn('mr-2 size-5 shrink-0 text-text-tertiary', action.iconClassName)} />
<span className="truncate system-sm-medium text-text-tertiary">
{action.label}
</span>
</div>
))}
</div>
)
}
export const Note: React.FC<{ input: FormInputItem, nodeName: (nodeId: string) => string }> = ({ input, nodeName }) => {
const { t } = useTranslation()
if (isSelectFormInput(input)) {
const isVariable = input.option_source.type === 'variable'
if (isVariable) {
const variablePath = sourceToVariablePath(input.option_source, nodeName)
return (
<div className="my-3 rounded-[10px] bg-components-input-bg-normal px-2.5 py-2">
{variablePath
? <Variable path={variablePath} />
: <span>{t('nodes.humanInput.insertInputField.variable', { ns: 'workflow' })}</span>}
</div>
)
}
const label = input.option_source.value[0] || t('variableConfig.select', { ns: 'appDebug' })
return <SelectPreview label={label} options={input.option_source.value} />
}
if (isFileFormInput(input)) {
return <FileUploadPreview methods={input.allowed_file_upload_methods} t={t} />
}
if (isFileListFormInput(input)) {
return <FileUploadPreview methods={input.allowed_file_upload_methods} t={t} />
}
const isVariable = input.default.type === 'variable'
const newPath = sourceToVariablePath(input.default, nodeName)
return (
<div className="my-3 rounded-[10px] bg-components-input-bg-normal px-2.5 py-2">
{isVariable ? <Variable path={newPath} /> : <span>{defaultInput.value}</span>}
{isVariable ? <Variable path={newPath} /> : <span>{input.default.value}</span>}
</div>
)
}
@@ -1,8 +1,9 @@
import type { NodeDefault, Var } from '../../types'
import type { HumanInputNodeType } from './types'
import type { DeliveryMethod, EmailConfig, FormInputItem, HumanInputNodeType } from './types'
import { BlockClassificationEnum } from '@/app/components/workflow/block-selector/types'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
import { genNodeMetaData } from '@/app/components/workflow/utils'
import { DeliveryMethodType } from './types'
const i18nPrefix = 'nodes.humanInput.errorMsg'
@@ -12,15 +13,46 @@ const metaData = genNodeMetaData({
type: BlockEnum.HumanInput,
})
const buildOutputVars = (variables: string[]): Var[] => {
return variables.map((variable) => {
const getFormInputVarType = (input: FormInputItem): VarType => {
if (input.type === 'file')
return VarType.file
if (input.type === 'file-list')
return VarType.arrayFile
return VarType.string
}
const buildOutputVars = (inputs: FormInputItem[]): Var[] => {
return inputs.map((input) => {
return {
variable,
type: VarType.string,
variable: input.output_variable_name,
type: getFormInputVarType(input),
}
})
}
const isEmailConfigComplete = (config?: EmailConfig): boolean => {
if (!config)
return false
if (!config.subject?.trim() || !config.body?.trim())
return false
if (!/\{\{#url#\}\}/.test(config.body.trim()))
return false
return !!config.recipients?.whole_workspace || !!config.recipients?.items?.length
}
const hasIncompleteEnabledEmailConfig = (deliveryMethods: DeliveryMethod[]): boolean => {
return deliveryMethods.some((method) => {
return method.enabled
&& method.type === DeliveryMethodType.Email
&& !isEmailConfigComplete(method.config)
})
}
const nodeDefault: NodeDefault<HumanInputNodeType> = {
metaData,
defaultValue: {
@@ -39,6 +71,9 @@ const nodeDefault: NodeDefault<HumanInputNodeType> = {
if (!errorMessages && payload.delivery_methods.length > 0 && !payload.delivery_methods.some(method => method.enabled))
errorMessages = t(`${i18nPrefix}.noDeliveryMethodEnabled`, { ns: 'workflow' })
if (!errorMessages && hasIncompleteEnabledEmailConfig(payload.delivery_methods))
errorMessages = t(`${i18nPrefix}.emailConfigIncomplete`, { ns: 'workflow' })
if (!errorMessages && !payload.user_actions.length)
errorMessages = t(`${i18nPrefix}.noUserActions`, { ns: 'workflow' })
@@ -67,8 +102,7 @@ const nodeDefault: NodeDefault<HumanInputNodeType> = {
}
},
getOutputVars(payload, _allPluginInfoList, _ragVars) {
const variables = payload.inputs.map(input => input.output_variable_name)
return buildOutputVars(variables)
return buildOutputVars(payload.inputs)
},
}
@@ -1,4 +1,4 @@
import type { FormInputItem, HumanInputNodeType } from '../../types'
import type { HumanInputNodeType, ParagraphFormInput } from '../../types'
import { act, renderHook } from '@testing-library/react'
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
import useFormContent from '../use-form-content'
@@ -15,8 +15,8 @@ vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({
default: (...args: unknown[]) => mockUseNodeCrud(...args),
}))
const createFormInput = (overrides: Partial<FormInputItem> = {}): FormInputItem => ({
type: InputVarType.textInput,
const createFormInput = (overrides: Partial<ParagraphFormInput> = {}): ParagraphFormInput => ({
type: InputVarType.paragraph,
output_variable_name: 'old_name',
default: {
selector: [],
@@ -96,6 +96,26 @@ describe('human-input/use-form-content', () => {
expect(result.current.editorKey).toBe(1)
})
it('should not rename an input to an existing variable name', () => {
currentInputs = createPayload({
inputs: [
createFormInput(),
createFormInput({ output_variable_name: 'existing_name' }),
],
})
const { result } = renderHook(() => useFormContent('human-input-node', currentInputs))
act(() => {
result.current.handleFormInputItemRename(createFormInput({
output_variable_name: 'existing_name',
}), 'old_name')
})
expect(mockSetInputs).not.toHaveBeenCalled()
expect(mockHandleOutVarRenameChange).not.toHaveBeenCalled()
expect(result.current.editorKey).toBe(0)
})
it('should remove an input placeholder and its form input metadata', () => {
const { result } = renderHook(() => useFormContent('human-input-node', currentInputs))
@@ -1,9 +1,10 @@
import type { HumanInputNodeType } from '../../types'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { InputVar } from '@/app/components/workflow/types'
import type { HumanInputFormData } from '@/types/workflow'
import { act, renderHook } from '@testing-library/react'
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
import { AppModeEnum } from '@/types/app'
import { BlockEnum, InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
import { AppModeEnum, TransferMethod } from '@/types/app'
import useSingleRunFormParams from '../use-single-run-form-params'
const mockUseTranslation = vi.hoisted(() => vi.fn())
@@ -37,7 +38,7 @@ const createPayload = (overrides: Partial<HumanInputNodeType> = {}): HumanInputN
delivery_methods: [],
form_content: 'Summary: {{#start.topic#}}',
inputs: [{
type: InputVarType.textInput,
type: InputVarType.paragraph,
output_variable_name: 'summary',
default: {
type: 'variable',
@@ -114,6 +115,28 @@ describe('human-input/hooks/use-single-run-form-params', () => {
mockSubmitHumanInputNodeStepRunForm.mockResolvedValue({})
})
const uploadedFile: FileEntity = {
id: 'file-1',
name: 'decision.pdf',
size: 128,
type: 'application/pdf',
progress: 100,
transferMethod: TransferMethod.local_file,
supportFileType: 'document',
uploadedId: 'upload-file-1',
}
const remoteFile: FileEntity = {
id: 'file-2',
name: 'reference.pdf',
size: 256,
type: 'application/pdf',
progress: 100,
transferMethod: TransferMethod.remote_url,
supportFileType: 'document',
url: 'https://example.com/reference.pdf',
}
it('should build a single before-run form, filter output vars, and expose dependent vars', () => {
const { result } = renderHook(() => useSingleRunFormParams({
id: 'node-1',
@@ -147,7 +170,93 @@ describe('human-input/hooks/use-single-run-form-params', () => {
])
})
it('should include variables referenced by dynamic select option sources', () => {
currentInputs = createPayload({
inputs: [
{
type: InputVarType.paragraph,
output_variable_name: 'summary',
default: {
type: 'variable',
selector: ['start', 'topic'],
value: '',
},
},
{
type: InputVarType.select,
output_variable_name: 'choice',
option_source: {
type: 'variable',
selector: ['start', 'choices'],
value: [],
},
},
],
})
getInputVars.mockReturnValue([
createInputVar(),
createInputVar({
label: 'Choices',
variable: '#start.choices#',
value_selector: ['start', 'choices'],
}),
])
const { result } = renderHook(() => useSingleRunFormParams({
id: 'node-1',
payload: currentInputs,
runInputData: {},
getInputVars,
setRunInputData: mockSetRunInputData,
}))
expect(getInputVars).toHaveBeenCalledWith([
'{{#start.topic#}}',
'{{#start.choices#}}',
'Summary: {{#start.topic#}}',
])
expect(result.current.forms[0]!.inputs).toEqual([
expect.objectContaining({ variable: '#start.topic#' }),
expect.objectContaining({ variable: '#start.choices#' }),
])
expect(result.current.getDependentVars()).toEqual([
['start', 'topic'],
['start', 'choices'],
])
})
it('should fetch and submit generated forms in workflow mode while keeping required inputs', async () => {
const formDataWithFiles = {
...mockFormData,
inputs: [
{
type: InputVarType.paragraph,
output_variable_name: 'answer',
default: {
type: 'constant',
selector: [],
value: '',
},
},
{
type: InputVarType.singleFile,
output_variable_name: 'attachment',
allowed_file_extensions: ['.pdf'],
allowed_file_types: [SupportUploadFileTypes.document],
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
},
{
type: InputVarType.multiFiles,
output_variable_name: 'references',
allowed_file_extensions: ['.pdf'],
allowed_file_types: [SupportUploadFileTypes.document],
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
number_limits: 3,
},
],
} satisfies HumanInputFormData
mockFetchHumanInputNodeStepRunForm.mockResolvedValue(formDataWithFiles)
const { result } = renderHook(() => useSingleRunFormParams({
id: 'node-1',
payload: currentInputs,
@@ -170,11 +279,11 @@ describe('human-input/hooks/use-single-run-form-params', () => {
inputs: { topic: 'AI' },
},
)
expect(result.current.formData).toEqual(mockFormData)
expect(result.current.formData).toEqual(formDataWithFiles)
await act(async () => {
await result.current.handleSubmitHumanInputForm({
inputs: { answer: 'approved' },
inputs: { answer: 'approved', attachment: uploadedFile, references: [uploadedFile, remoteFile] },
form_inputs: { ignored: 'value' },
action: 'approve',
})
@@ -184,7 +293,29 @@ describe('human-input/hooks/use-single-run-form-params', () => {
'/apps/app-1/workflows/draft/human-input/nodes/node-1/form',
{
inputs: { topic: 'AI' },
form_inputs: { answer: 'approved' },
form_inputs: {
answer: 'approved',
attachment: {
type: 'document',
transfer_method: TransferMethod.local_file,
url: '',
upload_file_id: 'upload-file-1',
},
references: [
{
type: 'document',
transfer_method: TransferMethod.local_file,
url: '',
upload_file_id: 'upload-file-1',
},
{
type: 'document',
transfer_method: TransferMethod.remote_url,
url: 'https://example.com/reference.pdf',
upload_file_id: '',
},
],
},
action: 'approve',
},
)
@@ -29,6 +29,13 @@ const useFormContent = (id: string, payload: HumanInputNodeType) => {
const handleFormInputItemRename = useCallback((payload: FormInputItem, oldName: string) => {
const inputs = inputsRef.current
if (
oldName !== payload.output_variable_name
&& inputs.inputs.some(item => item.output_variable_name === payload.output_variable_name)
) {
return
}
const newInputs = produce(inputs, (draft) => {
draft.form_content = draft.form_content.replaceAll(`{{#$output.${oldName}#}}`, `{{#$output.${payload.output_variable_name}#}}`)
draft.inputs = draft.inputs.map(item => item.output_variable_name === oldName ? payload : item)
@@ -1,14 +1,16 @@
import type { HumanInputNodeType } from '../types'
import type { HumanInputFieldValue } from '@/app/components/base/chat/chat/answer/human-input-content/field-renderer'
import type { Props as FormProps } from '@/app/components/workflow/nodes/_base/components/before-run-form/form'
import type { InputVar } from '@/app/components/workflow/types'
import type { HumanInputFormData } from '@/types/workflow'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import { getProcessedHumanInputFormInputs } from '@/app/components/base/chat/chat/answer/human-input-content/utils'
import { fetchHumanInputNodeStepRunForm, submitHumanInputNodeStepRunForm } from '@/service/workflow'
import { AppModeEnum } from '@/types/app'
import useNodeCrud from '../../_base/hooks/use-node-crud'
import { isOutput } from '../utils'
import { getHumanInputFormDependencySelectors, isOutput } from '../utils'
const i18nPrefix = 'nodes.humanInput'
@@ -19,6 +21,7 @@ type Params = {
getInputVars: (textList: string[]) => InputVar[]
setRunInputData: (data: Record<string, string>) => void
}
const useSingleRunFormParams = ({
id,
payload,
@@ -32,13 +35,9 @@ const useSingleRunFormParams = ({
const [formData, setFormData] = useState<HumanInputFormData | null>(null)
const [requiredInputs, setRequiredInputs] = useState<Record<string, string>>({})
const generatedInputs = useMemo(() => {
const defaultInputs = inputs.inputs.reduce((acc, input) => {
if (input.default.type === 'variable') {
acc.push(`{{#${input.default.selector.join('.')}#}}`)
}
return acc
}, [] as string[])
const allInputs = getInputVars([...defaultInputs, inputs.form_content || '']).filter(item => !isOutput(item.value_selector || []))
const formInputDependencyInputs = getHumanInputFormDependencySelectors(inputs.inputs)
.map(selector => `{{#${selector.join('.')}#}}`)
const allInputs = getInputVars([...formInputDependencyInputs, inputs.form_content || '']).filter(item => !isOutput(item.value_selector || []))
return allInputs
}, [getInputVars, inputs.form_content, inputs.inputs])
@@ -92,17 +91,19 @@ const useSingleRunFormParams = ({
return data
}, [fetchURL])
const handleSubmitHumanInputForm = useCallback(async (formData: {
inputs: Record<string, string> | undefined
form_inputs: Record<string, string> | undefined
const handleSubmitHumanInputForm = useCallback(async (submission: {
inputs: Record<string, HumanInputFieldValue> | undefined
form_inputs: Record<string, HumanInputFieldValue> | undefined
action: string
}) => {
const formInputs = formData?.inputs?.length ? formData.inputs : inputs.inputs
await submitHumanInputNodeStepRunForm(fetchURL, {
inputs: requiredInputs,
form_inputs: formData.inputs,
action: formData.action,
form_inputs: getProcessedHumanInputFormInputs(formInputs, submission.inputs),
action: submission.action,
})
}, [fetchURL, requiredInputs])
}, [fetchURL, formData?.inputs, inputs.inputs, requiredInputs])
const handleShowGeneratedForm = async (formValue: Record<string, string>) => {
setShowGeneratedForm(true)
@@ -1,5 +1,5 @@
import type { FC } from 'react'
import type { HumanInputNodeType } from './types'
import type { FormInputItem, HumanInputNodeType } from './types'
import type { NodePanelProps, Var } from '@/app/components/workflow/types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
@@ -34,6 +34,16 @@ import { UserActionButtonType } from './types'
const i18nPrefix = 'nodes.humanInput'
const getOutputVarType = (input: FormInputItem): VarType => {
if (input.type === 'file')
return VarType.file
if (input.type === 'file-list')
return VarType.arrayFile
return VarType.string
}
const Panel: FC<NodePanelProps<HumanInputNodeType>> = ({
id,
data,
@@ -59,7 +69,7 @@ const Panel: FC<NodePanelProps<HumanInputNodeType>> = ({
const { availableVars, availableNodesWithParent } = useAvailableVarList(id, {
onlyLeafNodeVar: false,
filterVar: (varPayload: Var) => {
return [VarType.string, VarType.number, VarType.secret].includes(varPayload.type)
return [VarType.string, VarType.number, VarType.secret, VarType.arrayString].includes(varPayload.type)
},
})
@@ -226,7 +236,7 @@ const Panel: FC<NodePanelProps<HumanInputNodeType>> = ({
<VarItem
key={input.output_variable_name}
name={input.output_variable_name}
type={VarType.string}
type={getOutputVarType(input)}
description="Form input value"
/>
))
@@ -1,8 +1,13 @@
import type {
CommonNodeType,
InputVarType,
UploadFileSetting,
ValueSelector,
} from '@/app/components/workflow/types'
import {
InputVarType,
SupportUploadFileTypes,
} from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
export type HumanInputNodeType = CommonNodeType & {
delivery_methods: DeliveryMethod[]
@@ -59,14 +64,137 @@ export type UserAction = {
button_style: UserActionButtonType
}
export type FormInputItemDefault = {
type StringDefault = {
selector: ValueSelector
type: 'variable' | 'constant'
value: string
}
export type FormInputItem = {
type: InputVarType
type StringListSource = {
selector: ValueSelector
type: 'variable' | 'constant'
value: string[]
}
// Preserve the old export during the transition to the new schema names.
export type FormInputItemDefault = StringDefault
type BaseFormInputItem = {
output_variable_name: string
default: FormInputItemDefault
}
export type ParagraphFormInput = BaseFormInputItem & {
type: InputVarType.paragraph
default: StringDefault
}
export type SelectFormInput = BaseFormInputItem & {
type: InputVarType.select
option_source: StringListSource
}
type SharedFileFormInput = Pick<
UploadFileSetting,
'allowed_file_extensions' | 'allowed_file_types' | 'allowed_file_upload_methods'
>
export type FileFormInput = BaseFormInputItem & SharedFileFormInput & {
type: InputVarType.singleFile
}
export type FileListFormInput = BaseFormInputItem & SharedFileFormInput & {
type: InputVarType.multiFiles
number_limits?: UploadFileSetting['number_limits']
}
export type FormInputItem
= | ParagraphFormInput
| SelectFormInput
| FileFormInput
| FileListFormInput
export const isParagraphFormInput = (
input: FormInputItem,
): input is ParagraphFormInput => {
return input.type === InputVarType.paragraph
}
export const isSelectFormInput = (
input: FormInputItem,
): input is SelectFormInput => {
return input.type === InputVarType.select
}
export const isFileFormInput = (
input: FormInputItem,
): input is FileFormInput => {
return input.type === InputVarType.singleFile
}
export const isFileListFormInput = (
input: FormInputItem,
): input is FileListFormInput => {
return input.type === InputVarType.multiFiles
}
export const createDefaultParagraphFormInput = (
output_variable_name = '',
): ParagraphFormInput => ({
type: InputVarType.paragraph,
output_variable_name,
default: {
type: 'constant',
selector: [],
value: '',
},
})
const createDefaultSelectFormInput = (
output_variable_name = '',
): SelectFormInput => ({
type: InputVarType.select,
output_variable_name,
option_source: {
type: 'constant',
selector: [],
value: [],
},
})
const createDefaultFileFormInput = (
output_variable_name = '',
): FileFormInput => ({
type: InputVarType.singleFile,
output_variable_name,
allowed_file_extensions: [],
allowed_file_types: [SupportUploadFileTypes.image],
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
})
const createDefaultFileListFormInput = (
output_variable_name = '',
): FileListFormInput => ({
type: InputVarType.multiFiles,
output_variable_name,
allowed_file_extensions: [],
allowed_file_types: [SupportUploadFileTypes.image],
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
number_limits: 5,
})
export const createDefaultFormInputByType = (
type: FormInputItem['type'],
output_variable_name = '',
): FormInputItem => {
switch (type) {
case InputVarType.select:
return createDefaultSelectFormInput(output_variable_name)
case InputVarType.singleFile:
return createDefaultFileFormInput(output_variable_name)
case InputVarType.multiFiles:
return createDefaultFileListFormInput(output_variable_name)
case InputVarType.paragraph:
default:
return createDefaultParagraphFormInput(output_variable_name)
}
}
@@ -1,3 +1,19 @@
import type { FormInputItem } from './types'
import type { ValueSelector } from '@/app/components/workflow/types'
import { isParagraphFormInput, isSelectFormInput } from './types'
export const isOutput = (valueSelector: string[]) => {
return valueSelector[0] === '$output'
}
export const getHumanInputFormDependencySelectors = (inputs: FormInputItem[]): ValueSelector[] => {
return inputs.flatMap((input) => {
if (isParagraphFormInput(input) && input.default.type === 'variable' && input.default.selector.length > 0)
return [input.default.selector]
if (isSelectFormInput(input) && input.option_source.type === 'variable' && input.option_source.selector.length > 0)
return [input.option_source.selector]
return []
})
}
@@ -9,6 +9,9 @@ const createFilledForm = (overrides: Partial<HumanInputFilledFormData> = {}): Hu
rendered_content: 'Approved by Alice',
action_id: 'approve',
action_text: 'Approve',
submitted_data: {
summary: 'Approved by Alice',
},
...overrides,
})
@@ -33,11 +36,13 @@ describe('HumanInputFilledFormList', () => {
expect(screen.getByText('Approval'))!.toBeInTheDocument()
expect(screen.getByText('Review'))!.toBeInTheDocument()
expect(screen.getAllByTestId('submitted-content')).toHaveLength(2)
expect(screen.getAllByTestId('submitted-field-values')).toHaveLength(2)
expect(screen.getAllByTestId('executed-action')).toHaveLength(2)
expect(screen.getAllByTestId('submitted-field-summary')).toHaveLength(2)
expect(screen.getAllByTestId('submitted-field-summary')[0]).toHaveTextContent('Approved by Alice')
await user.click(screen.getAllByTestId('expand-icon')[0]!)
expect(screen.getAllByTestId('submitted-content')).toHaveLength(1)
expect(screen.getAllByTestId('submitted-field-values')).toHaveLength(1)
})
})
@@ -1,4 +1,5 @@
import type { Shape } from '../../store/workflow'
import type { HumanInputFieldValue } from '@/app/components/base/chat/chat/answer/human-input-content/field-renderer'
import type { HumanInputFilledFormData, HumanInputFormData } from '@/types/workflow'
import { toast } from '@langgenius/dify-ui/toast'
import { fireEvent, screen, waitFor } from '@testing-library/react'
@@ -84,11 +85,11 @@ vi.mock('@/app/components/workflow/panel/human-input-form-list', () => ({
onHumanInputFormSubmit,
}: {
humanInputFormDataList: unknown[]
onHumanInputFormSubmit?: (token: string, formData: Record<string, string>) => Promise<void>
onHumanInputFormSubmit?: (token: string, formData: { inputs: Record<string, HumanInputFieldValue>, action: string }) => Promise<void>
}) => (
<div>
<div data-testid="human-form-list">{humanInputFormDataList.length}</div>
<button type="button" onClick={() => onHumanInputFormSubmit?.('form-token', { answer: 'ok' })}>
<button type="button" onClick={() => onHumanInputFormSubmit?.('form-token', { inputs: { answer: 'ok' }, action: 'approve' })}>
submit-human-form
</button>
</div>
@@ -236,7 +237,7 @@ describe('WorkflowPreview', () => {
expect(screen.getByTestId('filled-form-list')).toHaveTextContent('1')
await user.click(screen.getByRole('button', { name: 'submit-human-form' }))
expect(mockSubmitHumanInputForm).toHaveBeenCalledWith('form-token', { answer: 'ok' })
expect(mockSubmitHumanInputForm).toHaveBeenCalledWith('form-token', { inputs: { answer: 'ok' }, action: 'approve' })
})
it('should copy successful string output and show a success toast', async () => {

Some files were not shown because too many files have changed in this diff Show More