Extract shared human input field renderer

This commit is contained in:
JzoNg 2026-04-22 08:05:24 +08:00
parent c2fd595a82
commit 8d3ddee7d3
2 changed files with 248 additions and 0 deletions

View File

@ -0,0 +1,136 @@
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'
vi.mock('@/app/components/base/textarea', () => ({
__esModule: true,
default: ({ value, onChange }: { value: string, onChange: (event: { target: { value: string } }) => void }) => (
<textarea
data-testid="content-item-textarea"
value={value}
onChange={event => onChange({ target: { value: event.target.value } })}
/>
),
}))
vi.mock('@langgenius/dify-ui/select', () => ({
Select: ({ children, onValueChange }: { children: React.ReactNode, onValueChange: (value: string) => void }) => (
<div data-testid="content-item-select-root" onClick={() => onValueChange('alice')}>{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>
),
}))
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 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('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 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],
max_upload_count: 3,
}}
value={[]}
onChange={onChange}
/>,
)
await user.click(screen.getByTestId('content-item-file-3'))
expect(onChange).toHaveBeenCalledWith([expect.objectContaining({ name: 'report.pdf' })])
})
})

View File

@ -0,0 +1,112 @@
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 * as React from 'react'
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
import Textarea from '@/app/components/base/textarea'
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
className="h-[104px] sm:text-xs"
value={typeof value === 'string' ? value : ''}
onChange={e => onChange(e.target.value)}
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.max_upload_count || 5,
}}
/>
)
}
return null
}
export default React.memo(HumanInputFieldRenderer)