feat: implement input field form with file upload settings and validation

This commit is contained in:
twwu 2025-04-21 09:53:35 +08:00
parent a2dc38f90a
commit 51165408ed
15 changed files with 705 additions and 4 deletions

View File

@ -2,6 +2,7 @@ import cn from '@/utils/classnames'
import { useFieldContext } from '../..'
import PureSelect from '../../../select/pure'
import Label from '../label'
import { useCallback } from 'react'
type SelectOption = {
value: string
@ -11,6 +12,7 @@ type SelectOption = {
type SelectFieldProps = {
label: string
options: SelectOption[]
onChange?: (value: string) => void
isRequired?: boolean
showOptional?: boolean
tooltip?: string
@ -21,6 +23,7 @@ type SelectFieldProps = {
const SelectField = ({
label,
options,
onChange,
isRequired,
showOptional,
tooltip,
@ -29,6 +32,11 @@ const SelectField = ({
}: SelectFieldProps) => {
const field = useFieldContext<string>()
const handleChange = useCallback((value: string) => {
field.handleChange(value)
onChange?.(value)
}, [field, onChange])
return (
<div className={cn('flex flex-col gap-y-0.5', className)}>
<Label
@ -42,7 +50,7 @@ const SelectField = ({
<PureSelect
value={field.state.value}
options={options}
onChange={value => field.handleChange(value)}
onChange={handleChange}
/>
</div>
)

View File

@ -0,0 +1,72 @@
import { useTranslation } from 'react-i18next'
import { InputType } from '../types'
import { InputVarType } from '@/app/components/workflow/types'
import { useMemo } from 'react'
const i18nFileTypeMap: Record<string, string> = {
'file': 'single-file',
'file-list': 'multi-files',
}
export const useInputTypes = (supportFile: boolean) => {
const { t } = useTranslation()
const options = supportFile ? InputType.options : InputType.exclude(['file', 'file-list']).options
return options.map((value) => {
return {
value,
label: t(`appDebug.variableConfig.${i18nFileTypeMap[value] || value}`),
}
})
}
export const useHiddenFieldNames = (type: InputVarType) => {
const { t } = useTranslation()
const hiddenFieldNames = useMemo(() => {
let fieldNames = []
switch (type) {
case InputVarType.textInput:
case InputVarType.paragraph:
fieldNames = [
t('appDebug.variableConfig.defaultValue'),
t('appDebug.variableConfig.placeholder'),
t('appDebug.variableConfig.tooltips'),
]
break
case InputVarType.number:
fieldNames = [
t('appDebug.variableConfig.defaultValue'),
t('appDebug.variableConfig.unit'),
t('appDebug.variableConfig.placeholder'),
t('appDebug.variableConfig.tooltips'),
]
break
case InputVarType.select:
fieldNames = [
t('appDebug.variableConfig.defaultValue'),
t('appDebug.variableConfig.tooltips'),
]
break
case InputVarType.singleFile:
fieldNames = [
t('appDebug.variableConfig.uploadMethod'),
t('appDebug.variableConfig.tooltips'),
]
break
case InputVarType.multiFiles:
fieldNames = [
t('appDebug.variableConfig.uploadMethod'),
t('appDebug.variableConfig.maxNumberOfUploads'),
t('appDebug.variableConfig.tooltips'),
]
break
default:
fieldNames = [
t('appDebug.variableConfig.tooltips'),
]
}
return fieldNames.map(name => name.toLowerCase()).join(', ')
}, [type, t])
return hiddenFieldNames
}

View File

@ -0,0 +1,83 @@
import { useTranslation } from 'react-i18next'
import { withForm } from '../../..'
import { type InputVar, SupportUploadFileTypes } from '@/app/components/workflow/types'
import { getNewVarInWorkflow } from '@/utils/var'
import { useField } from '@tanstack/react-form'
import Label from '../../../components/label'
import FileTypeItem from '@/app/components/workflow/nodes/_base/components/file-type-item'
import { useCallback, useMemo } from 'react'
type FileTypesFieldsProps = {
initialData?: InputVar
}
const UseFileTypesFields = ({
initialData,
}: FileTypesFieldsProps) => {
const FileTypesFields = useMemo(() => {
return withForm({
defaultValues: initialData || getNewVarInWorkflow(''),
render: function Render({
form,
}) {
const { t } = useTranslation()
const allowFileTypesField = useField({ form, name: 'allowed_file_types' })
const allowFileExtensionsField = useField({ form, name: 'allowed_file_extensions' })
const { value: allowed_file_types = [] } = allowFileTypesField.state
const { value: allowed_file_extensions = [] } = allowFileExtensionsField.state
const handleSupportFileTypeChange = useCallback((type: SupportUploadFileTypes) => {
let newAllowFileTypes = [...allowed_file_types]
if (type === SupportUploadFileTypes.custom) {
if (!newAllowFileTypes.includes(SupportUploadFileTypes.custom))
newAllowFileTypes = [SupportUploadFileTypes.custom]
else
newAllowFileTypes = newAllowFileTypes.filter(v => v !== type)
}
else {
newAllowFileTypes = newAllowFileTypes.filter(v => v !== SupportUploadFileTypes.custom)
if (newAllowFileTypes.includes(type))
newAllowFileTypes = newAllowFileTypes.filter(v => v !== type)
else
newAllowFileTypes.push(type)
}
allowFileTypesField.handleChange(newAllowFileTypes)
}, [allowFileTypesField, allowed_file_types])
const handleCustomFileTypesChange = useCallback((customFileTypes: string[]) => {
allowFileExtensionsField.handleChange(customFileTypes)
}, [allowFileExtensionsField])
return (
<div className='flex flex-col gap-y-0.5'>
<Label
htmlFor='allowed_file_types'
label={t('appDebug.variableConfig.file.supportFileTypes')}
/>
{
[SupportUploadFileTypes.document, SupportUploadFileTypes.image, SupportUploadFileTypes.audio, SupportUploadFileTypes.video].map((type: SupportUploadFileTypes) => (
<FileTypeItem
key={type}
type={type as SupportUploadFileTypes.image | SupportUploadFileTypes.document | SupportUploadFileTypes.audio | SupportUploadFileTypes.video}
selected={allowed_file_types.includes(type)}
onToggle={handleSupportFileTypeChange}
/>
))
}
<FileTypeItem
type={SupportUploadFileTypes.custom}
selected={allowed_file_types.includes(SupportUploadFileTypes.custom)}
onToggle={handleSupportFileTypeChange}
customFileTypes={allowed_file_extensions}
onCustomFileTypesChange={handleCustomFileTypesChange}
/>
</div>
)
},
})
}, [initialData])
return FileTypesFields
}
export default UseFileTypesFields

View File

@ -0,0 +1,76 @@
import { useTranslation } from 'react-i18next'
import { withForm } from '../../..'
import type { InputVar } from '@/app/components/workflow/types'
import { getNewVarInWorkflow } from '@/utils/var'
import { useField } from '@tanstack/react-form'
import Label from '../../../components/label'
import { useCallback, useMemo } from 'react'
import useSWR from 'swr'
import { fetchFileUploadConfig } from '@/service/common'
import { useFileSizeLimit } from '@/app/components/base/file-uploader/hooks'
import { formatFileSize } from '@/utils/format'
import InputNumberWithSlider from '@/app/components/workflow/nodes/_base/components/input-number-with-slider'
type MaxNumberOfUploadsFieldProps = {
initialData?: InputVar
}
const UseMaxNumberOfUploadsField = ({
initialData,
}: MaxNumberOfUploadsFieldProps) => {
const MaxNumberOfUploadsField = useMemo(() => {
return withForm({
defaultValues: initialData || getNewVarInWorkflow(''),
render: function Render({
form,
}) {
const { t } = useTranslation()
const maxNumberOfUploadsField = useField({ form, name: 'max_length' })
const { value: max_length = 0 } = maxNumberOfUploadsField.state
const { data: fileUploadConfigResponse } = useSWR({ url: '/files/upload' }, fetchFileUploadConfig)
const {
imgSizeLimit,
docSizeLimit,
audioSizeLimit,
videoSizeLimit,
maxFileUploadLimit,
} = useFileSizeLimit(fileUploadConfigResponse)
const handleMaxUploadNumLimitChange = useCallback((value: number) => {
maxNumberOfUploadsField.handleChange(value)
}, [maxNumberOfUploadsField])
return (
<div className='flex flex-col gap-y-0.5'>
<Label
htmlFor='allowed_file_types'
label={t('appDebug.variableConfig.maxNumberOfUploads')}
/>
<div>
<div className='body-xs-regular mb-1.5 text-text-tertiary'>
{t('appDebug.variableConfig.maxNumberTip', {
imgLimit: formatFileSize(imgSizeLimit),
docLimit: formatFileSize(docSizeLimit),
audioLimit: formatFileSize(audioSizeLimit),
videoLimit: formatFileSize(videoSizeLimit),
})}
</div>
<InputNumberWithSlider
value={max_length}
min={1}
max={maxFileUploadLimit}
onChange={handleMaxUploadNumLimitChange}
/>
</div>
</div>
)
},
})
}, [initialData])
return MaxNumberOfUploadsField
}
export default UseMaxNumberOfUploadsField

View File

@ -0,0 +1,64 @@
import { useTranslation } from 'react-i18next'
import { withForm } from '../../..'
import type { InputVar } from '@/app/components/workflow/types'
import { getNewVarInWorkflow } from '@/utils/var'
import { useField } from '@tanstack/react-form'
import Label from '../../../components/label'
import { useCallback, useMemo } from 'react'
import OptionCard from '@/app/components/workflow/nodes/_base/components/option-card'
import { TransferMethod } from '@/types/app'
type UploadMethodFieldProps = {
initialData?: InputVar
}
const UseUploadMethodField = ({
initialData,
}: UploadMethodFieldProps) => {
const UploadMethodField = useMemo(() => {
return withForm({
defaultValues: initialData || getNewVarInWorkflow(''),
render: function Render({
form,
}) {
const { t } = useTranslation()
const allowFileUploadMethodField = useField({ form, name: 'allowed_file_upload_methods' })
const { value: allowed_file_upload_methods = [] } = allowFileUploadMethodField.state
const handleUploadMethodChange = useCallback((method: TransferMethod) => {
allowFileUploadMethodField.handleChange(method === TransferMethod.all ? [TransferMethod.local_file, TransferMethod.remote_url] : [method])
}, [allowFileUploadMethodField])
return (
<div className='flex flex-col gap-y-0.5'>
<Label
htmlFor='allowed_file_types'
label={t('appDebug.variableConfig.uploadFileTypes')}
/>
<div className='grid grid-cols-3 gap-2'>
<OptionCard
title={t('appDebug.variableConfig.localUpload')}
selected={allowed_file_upload_methods.length === 1 && allowed_file_upload_methods.includes(TransferMethod.local_file)}
onSelect={handleUploadMethodChange.bind(null, TransferMethod.local_file)}
/>
<OptionCard
title="URL"
selected={allowed_file_upload_methods.length === 1 && allowed_file_upload_methods.includes(TransferMethod.remote_url)}
onSelect={handleUploadMethodChange.bind(null, TransferMethod.remote_url)}
/>
<OptionCard
title={t('appDebug.variableConfig.both')}
selected={allowed_file_upload_methods.includes(TransferMethod.local_file) && allowed_file_upload_methods.includes(TransferMethod.remote_url)}
onSelect={handleUploadMethodChange.bind(null, TransferMethod.all)}
/>
</div>
</div>
)
},
})
}, [initialData])
return UploadMethodField
}
export default UseUploadMethodField

View File

@ -0,0 +1,268 @@
import { useTranslation } from 'react-i18next'
import { useAppForm } from '../..'
import type { InputFieldFormProps } from './types'
import { getNewVarInWorkflow } from '@/utils/var'
import { useHiddenFieldNames, useInputTypes } from './hooks'
import Divider from '../../../divider'
import { useCallback, useMemo, useState } from 'react'
import { useStore } from '@tanstack/react-form'
import { InputVarType } from '@/app/components/workflow/types'
import ShowAllSettings from './show-all-settings'
import Button from '../../../button'
import UseFileTypesFields from './hooks/use-file-types-fields'
import UseUploadMethodField from './hooks/use-upload-method-field'
import UseMaxNumberOfUploadsField from './hooks/use-max-number-of-uploads-filed'
import { DEFAULT_FILE_UPLOAD_SETTING } from '@/app/components/workflow/constants'
import { DEFAULT_VALUE_MAX_LEN } from '@/config'
const InputFieldForm = ({
initialData,
supportFile = false,
onCancel,
onSubmit,
}: InputFieldFormProps) => {
const { t } = useTranslation()
const form = useAppForm({
defaultValues: initialData || getNewVarInWorkflow(''),
validators: {
onSubmit: ({ value }) => {
// TODO: Add validation logic here
console.log('Validator form on submit:', value)
},
},
onSubmit: ({ value }) => {
// TODO: Add submit logic here
onSubmit(value)
},
})
const [showAllSettings, setShowAllSettings] = useState(false)
const type = useStore(form.store, state => state.values.type)
const options = useStore(form.store, state => state.values.options)
const hiddenFieldNames = useHiddenFieldNames(type)
const inputTypes = useInputTypes(supportFile)
const FileTypesFields = UseFileTypesFields({ initialData })
const UploadMethodField = UseUploadMethodField({ initialData })
const MaxNumberOfUploads = UseMaxNumberOfUploadsField({ initialData })
const isTextInput = [InputVarType.textInput, InputVarType.paragraph].includes(type)
const isNumberInput = type === InputVarType.number
const isSelectInput = type === InputVarType.select
const isSingleFile = type === InputVarType.singleFile
const isMultipleFile = type === InputVarType.multiFiles
const defaultSelectOptions = useMemo(() => {
if (isSelectInput && options) {
const defaultOptions = [
{
value: '',
label: t('appDebug.variableConfig.noDefaultSelected'),
},
]
const otherOptions = options.map((option: string) => ({
value: option,
label: option,
}))
return [...defaultOptions, ...otherOptions]
}
return []
}, [isSelectInput, options, t])
const handleTypeChange = useCallback((type: string) => {
if ([InputVarType.singleFile, InputVarType.multiFiles].includes(type as InputVarType)) {
(Object.keys(DEFAULT_FILE_UPLOAD_SETTING)).forEach((key) => {
if (key !== 'max_length')
form.setFieldValue(key as keyof typeof form.options.defaultValues, (DEFAULT_FILE_UPLOAD_SETTING as any)[key])
})
if (type === InputVarType.multiFiles)
form.setFieldValue('max_length', DEFAULT_FILE_UPLOAD_SETTING.max_length)
}
if (type === InputVarType.paragraph)
form.setFieldValue('max_length', DEFAULT_VALUE_MAX_LEN)
}, [form])
const handleShowAllSettings = useCallback(() => {
setShowAllSettings(true)
}, [])
return (
<form
className='w-full'
onSubmit={(e) => {
e.preventDefault()
e.stopPropagation()
form.handleSubmit()
}}
>
<div className='flex flex-col gap-4 px-4 py-2'>
<form.AppField
name='type'
children={field => (
<field.SelectField
label={t('appDebug.variableConfig.fieldType')}
options={inputTypes}
onChange={handleTypeChange}
/>
)}
/>
<form.AppField
name='variable'
children={field => (
<field.TextField
label={t('appDebug.variableConfig.varName')}
placeholder={t('appDebug.variableConfig.inputPlaceholder')!}
/>
)}
/>
<form.AppField
name='label'
children={field => (
<field.TextField
label={t('appDebug.variableConfig.labelName')}
placeholder={t('appDebug.variableConfig.inputPlaceholder')!}
/>
)}
/>
{isTextInput && (
<form.AppField
name='max_length'
children={field => (
<field.NumberInputField
label={t('appDebug.variableConfig.maxLength')}
/>
)}
/>
)}
{isSelectInput && (
<form.AppField
name='options'
listeners={{
onChange: () => {
form.setFieldValue('default', '')
},
}}
children={field => (
<field.OptionsField
label={t('appDebug.variableConfig.options')}
/>
)}
/>
)}
{(isSingleFile || isMultipleFile) && (
<FileTypesFields form={form} />
)}
<form.AppField
name='required'
children={field => (
<field.CheckboxField
label={t('appDebug.variableConfig.required')}
/>
)}
/>
<Divider type='horizontal' />
{!showAllSettings && (
<ShowAllSettings
handleShowAllSettings={handleShowAllSettings}
description={hiddenFieldNames}
/>
)}
{showAllSettings && (
<>
{isTextInput && (
<form.AppField
name='default'
children={field => (
<field.TextField
label={t('appDebug.variableConfig.defaultValue')}
placeholder={t('appDebug.variableConfig.defaultValuePlaceholder')!}
showOptional
/>
)}
/>
)}
{isNumberInput && (
<form.AppField
name='default'
children={field => (
<field.NumberInputField
label={t('appDebug.variableConfig.defaultValue')}
placeholder={t('appDebug.variableConfig.defaultValuePlaceholder')!}
showOptional
/>
)}
/>
)}
{isSelectInput && (
<form.AppField
name='default'
children={field => (
<field.SelectField
label={t('appDebug.variableConfig.startSelectedOption')}
options={defaultSelectOptions}
showOptional
/>
)}
/>
)}
{(isTextInput || isNumberInput) && (
<form.AppField
name='placeholder'
children={field => (
<field.TextField
label={t('appDebug.variableConfig.placeholder')}
placeholder={t('appDebug.variableConfig.placeholderPlaceholder')!}
showOptional
/>
)}
/>
)}
{isNumberInput && (
<form.AppField
name='unit'
children={field => (
<field.TextField
label={t('appDebug.variableConfig.unit')}
placeholder={t('appDebug.variableConfig.unitPlaceholder')!}
showOptional
/>
)}
/>
)}
{(isSingleFile || isMultipleFile) && (
<UploadMethodField form={form} />
)}
{isMultipleFile && (
<MaxNumberOfUploads form={form} />
)}
<form.AppField
name='hint'
children={(field) => {
return (
<field.TextField
label={t('appDebug.variableConfig.tooltips')}
placeholder={t('appDebug.variableConfig.tooltipsPlaceholder')!}
showOptional
/>
)
}
}
/>
</>
)}
</div>
<div className='flex items-center justify-end gap-x-2 p-4 pt-2'>
<Button variant='secondary' onClick={onCancel}>
{t('common.operation.cancel')}
</Button>
<form.AppForm>
<form.SubmitButton variant='primary'>
{t('common.operation.save')}
</form.SubmitButton>
</form.AppForm>
</div>
</form>
)
}
export default InputFieldForm

View File

@ -0,0 +1,30 @@
import { useTranslation } from 'react-i18next'
import { RiArrowRightSLine } from '@remixicon/react'
type ShowAllSettingsProps = {
description: string
handleShowAllSettings: () => void
}
const ShowAllSettings = ({
description,
handleShowAllSettings,
}: ShowAllSettingsProps) => {
const { t } = useTranslation()
return (
<div className='flex cursor-pointer items-center gap-x-4' onClick={handleShowAllSettings}>
<div className='flex grow flex-col'>
<span className='system-sm-medium flex min-h-6 items-center text-text-secondary'>
{t('appDebug.variableConfig.showAllSettings')}
</span>
<span className='body-xs-regular pb-0.5 text-text-tertiary first-letter:capitalize'>
{description}
</span>
</div>
<RiArrowRightSLine className='h-4 w-4 shrink-0 text-text-secondary' />
</div>
)
}
export default ShowAllSettings

View File

@ -0,0 +1,53 @@
import type { InputVar } from '@/app/components/workflow/types'
import type { TFunction } from 'i18next'
import { z } from 'zod'
export const InputType = z.enum([
'text-input',
'paragraph',
'number',
'select',
'checkbox',
'file',
'file-list',
])
const TransferMethod = z.enum([
'all',
'local_file',
'remote_url',
])
const SupportedFileTypes = z.enum([
'image',
'document',
'video',
'audio',
'custom',
])
// TODO: Add validation rules
export const createInputFieldSchema = (t: TFunction) => z.object({
type: InputType,
label: z.string(),
variable: z.string(),
max_length: z.number().optional(),
default: z.string().optional(),
required: z.boolean(),
hint: z.string().optional(),
options: z.array(z.string()).optional(),
allowed_file_upload_methods: z.array(TransferMethod),
allowed_file_types: z.array(SupportedFileTypes),
allowed_file_extensions: z.string().optional(),
})
export type InputFieldFormProps = {
initialData?: InputVar
supportFile?: boolean
onCancel: () => void
onSubmit: (value: InputVar) => void
}
export type TextFieldsProps = {
initialData?: InputVar
}

View File

@ -179,6 +179,7 @@ export enum InputVarType {
singleFile = 'file',
multiFiles = 'file-list',
loop = 'loop', // loop input
checkbox = 'checkbox',
}
export type InputVar = {
@ -191,11 +192,13 @@ export type InputVar = {
}
variable: string
max_length?: number
default?: string
default?: string | number
required: boolean
hint?: string
options?: string[]
value_selector?: ValueSelector
placeholder?: string
unit?: string
} & Partial<UploadFileSetting>
export type ModelConfig = {

View File

@ -1,11 +1,20 @@
'use client'
import DemoForm from '../components/base/form/form-scenarios/demo'
import InputFieldForm from '../components/base/form/form-scenarios/input-field'
// import DemoForm from '../components/base/form/form-scenarios/demo'
export default function Page() {
return (
<div className='flex h-screen w-full items-center justify-center p-20'>
<DemoForm />
<div className='w-[400px] rounded-lg border border-gray-800 bg-components-panel-bg'>
<InputFieldForm
initialData={undefined}
supportFile
onCancel={() => { console.log('cancel') }}
onSubmit={value => console.log('submit', value)}
/>
{/* <DemoForm /> */}
</div>
</div>
)
}

View File

@ -368,6 +368,18 @@ const translation = {
'inputPlaceholder': 'Please input',
'content': 'Content',
'required': 'Required',
'placeholder': 'Placeholder',
'placeholderPlaceholder': 'Enter text to display when the field is empty',
'defaultValue': 'Default Value',
'defaultValuePlaceholder': 'Enter default value to pre-populate the field',
'unit': 'Unit',
'unitPlaceholder': 'Display units after numbers, e.g. tokens',
'tooltips': 'Tooltips',
'tooltipsPlaceholder': 'Enter helpful text shown when hovering over the label',
'showAllSettings': 'Show All Settings',
'checkbox': 'Checkbox',
'startSelectedOption': 'Start selected option',
'noDefaultSelected': 'Don\'t select',
'file': {
supportFileTypes: 'Support File Types',
image: {
@ -389,6 +401,7 @@ const translation = {
},
},
'uploadFileTypes': 'Upload File Types',
'uploadMethod': 'Upload Method',
'localUpload': 'Local Upload',
'both': 'Both',
'maxNumberOfUploads': 'Max number of uploads',

View File

@ -65,6 +65,9 @@ const translation = {
input: 'Please enter',
select: 'Please select',
},
label: {
optional: '(optional)',
},
voice: {
language: {
zhHans: 'Chinese',

View File

@ -361,6 +361,18 @@ const translation = {
'inputPlaceholder': '请输入',
'labelName': '显示名称',
'required': '必填',
'placeholder': '占位符',
'placeholderPlaceholder': '输入字段为空时显示的文本',
'defaultValue': '默认值',
'defaultValuePlaceholder': '输入默认值以预先填充字段',
'unit': '单位',
'unitPlaceholder': '在数字后显示的单位,如 token',
'tooltips': '提示',
'tooltipsPlaceholder': '输入悬停在标签上时显示的提示文本',
'showAllSettings': '显示所有设置',
'checkbox': '复选框',
'startSelectedOption': '默认选中项',
'noDefaultSelected': '不默认选中',
'file': {
supportFileTypes: '支持的文件类型',
image: {
@ -382,6 +394,7 @@ const translation = {
},
},
'uploadFileTypes': '上传文件类型',
'uploadMethod': '上传方式',
'localUpload': '本地上传',
'both': '两者',
'maxNumberOfUploads': '最大上传数',

View File

@ -65,6 +65,9 @@ const translation = {
input: '请输入',
select: '请选择',
},
label: {
optional: '(可选)',
},
voice: {
language: {
zhHans: '中文',

View File

@ -42,6 +42,9 @@ export const getNewVarInWorkflow = (key: string, type = InputVarType.textInput)
type,
variable: key,
label: key.slice(0, getMaxVarNameLength(key)),
placeholder: '',
default: '',
hint: '',
}
}