From 51165408edc59651dae7081a529363049ed15640 Mon Sep 17 00:00:00 2001 From: twwu Date: Mon, 21 Apr 2025 09:53:35 +0800 Subject: [PATCH] feat: implement input field form with file upload settings and validation --- .../base/form/components/field/select.tsx | 10 +- .../form-scenarios/input-field/hooks/index.ts | 72 +++++ .../hooks/use-file-types-fields.tsx | 83 ++++++ .../hooks/use-max-number-of-uploads-filed.tsx | 76 +++++ .../hooks/use-upload-method-field.tsx | 64 +++++ .../form/form-scenarios/input-field/index.tsx | 268 ++++++++++++++++++ .../input-field/show-all-settings.tsx | 30 ++ .../form/form-scenarios/input-field/types.ts | 53 ++++ web/app/components/workflow/types.ts | 5 +- web/app/dev-preview/page.tsx | 13 +- web/i18n/en-US/app-debug.ts | 13 + web/i18n/en-US/common.ts | 3 + web/i18n/zh-Hans/app-debug.ts | 13 + web/i18n/zh-Hans/common.ts | 3 + web/utils/var.ts | 3 + 15 files changed, 705 insertions(+), 4 deletions(-) create mode 100644 web/app/components/base/form/form-scenarios/input-field/hooks/index.ts create mode 100644 web/app/components/base/form/form-scenarios/input-field/hooks/use-file-types-fields.tsx create mode 100644 web/app/components/base/form/form-scenarios/input-field/hooks/use-max-number-of-uploads-filed.tsx create mode 100644 web/app/components/base/form/form-scenarios/input-field/hooks/use-upload-method-field.tsx create mode 100644 web/app/components/base/form/form-scenarios/input-field/index.tsx create mode 100644 web/app/components/base/form/form-scenarios/input-field/show-all-settings.tsx create mode 100644 web/app/components/base/form/form-scenarios/input-field/types.ts diff --git a/web/app/components/base/form/components/field/select.tsx b/web/app/components/base/form/components/field/select.tsx index 95af3c0116..d13babf825 100644 --- a/web/app/components/base/form/components/field/select.tsx +++ b/web/app/components/base/form/components/field/select.tsx @@ -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() + const handleChange = useCallback((value: string) => { + field.handleChange(value) + onChange?.(value) + }, [field, onChange]) + return (
) diff --git a/web/app/components/base/form/form-scenarios/input-field/hooks/index.ts b/web/app/components/base/form/form-scenarios/input-field/hooks/index.ts new file mode 100644 index 0000000000..b833709fa6 --- /dev/null +++ b/web/app/components/base/form/form-scenarios/input-field/hooks/index.ts @@ -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 = { + '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 +} diff --git a/web/app/components/base/form/form-scenarios/input-field/hooks/use-file-types-fields.tsx b/web/app/components/base/form/form-scenarios/input-field/hooks/use-file-types-fields.tsx new file mode 100644 index 0000000000..dd816de3a1 --- /dev/null +++ b/web/app/components/base/form/form-scenarios/input-field/hooks/use-file-types-fields.tsx @@ -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 ( +
+
+ ) + }, + }) + }, [initialData]) + + return FileTypesFields +} + +export default UseFileTypesFields diff --git a/web/app/components/base/form/form-scenarios/input-field/hooks/use-max-number-of-uploads-filed.tsx b/web/app/components/base/form/form-scenarios/input-field/hooks/use-max-number-of-uploads-filed.tsx new file mode 100644 index 0000000000..7842d7a3b2 --- /dev/null +++ b/web/app/components/base/form/form-scenarios/input-field/hooks/use-max-number-of-uploads-filed.tsx @@ -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 ( +
+
+ ) + }, + }) + }, [initialData]) + + return MaxNumberOfUploadsField +} + +export default UseMaxNumberOfUploadsField diff --git a/web/app/components/base/form/form-scenarios/input-field/hooks/use-upload-method-field.tsx b/web/app/components/base/form/form-scenarios/input-field/hooks/use-upload-method-field.tsx new file mode 100644 index 0000000000..9f3111166a --- /dev/null +++ b/web/app/components/base/form/form-scenarios/input-field/hooks/use-upload-method-field.tsx @@ -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 ( +
+
+ ) + }, + }) + }, [initialData]) + + return UploadMethodField +} + +export default UseUploadMethodField diff --git a/web/app/components/base/form/form-scenarios/input-field/index.tsx b/web/app/components/base/form/form-scenarios/input-field/index.tsx new file mode 100644 index 0000000000..2468238044 --- /dev/null +++ b/web/app/components/base/form/form-scenarios/input-field/index.tsx @@ -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 ( +
{ + e.preventDefault() + e.stopPropagation() + form.handleSubmit() + }} + > +
+ ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + {isTextInput && ( + ( + + )} + /> + )} + {isSelectInput && ( + { + form.setFieldValue('default', '') + }, + }} + children={field => ( + + )} + /> + )} + {(isSingleFile || isMultipleFile) && ( + + )} + ( + + )} + /> + + {!showAllSettings && ( + + )} + {showAllSettings && ( + <> + {isTextInput && ( + ( + + )} + /> + )} + {isNumberInput && ( + ( + + )} + /> + )} + {isSelectInput && ( + ( + + )} + /> + )} + {(isTextInput || isNumberInput) && ( + ( + + )} + /> + )} + {isNumberInput && ( + ( + + )} + /> + )} + {(isSingleFile || isMultipleFile) && ( + + )} + {isMultipleFile && ( + + )} + { + return ( + + ) + } + } + /> + + )} +
+
+ + + + {t('common.operation.save')} + + +
+
+ ) +} + +export default InputFieldForm diff --git a/web/app/components/base/form/form-scenarios/input-field/show-all-settings.tsx b/web/app/components/base/form/form-scenarios/input-field/show-all-settings.tsx new file mode 100644 index 0000000000..0a55c70ef1 --- /dev/null +++ b/web/app/components/base/form/form-scenarios/input-field/show-all-settings.tsx @@ -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 ( +
+
+ + {t('appDebug.variableConfig.showAllSettings')} + + + {description} + +
+ +
+ ) +} + +export default ShowAllSettings diff --git a/web/app/components/base/form/form-scenarios/input-field/types.ts b/web/app/components/base/form/form-scenarios/input-field/types.ts new file mode 100644 index 0000000000..36ef19f8f5 --- /dev/null +++ b/web/app/components/base/form/form-scenarios/input-field/types.ts @@ -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 +} diff --git a/web/app/components/workflow/types.ts b/web/app/components/workflow/types.ts index 884bdfbd10..0cc859fcb5 100644 --- a/web/app/components/workflow/types.ts +++ b/web/app/components/workflow/types.ts @@ -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 export type ModelConfig = { diff --git a/web/app/dev-preview/page.tsx b/web/app/dev-preview/page.tsx index 69464d612a..c8d7f8c241 100644 --- a/web/app/dev-preview/page.tsx +++ b/web/app/dev-preview/page.tsx @@ -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 (
- +
+ { console.log('cancel') }} + onSubmit={value => console.log('submit', value)} + /> + {/* */} +
) } diff --git a/web/i18n/en-US/app-debug.ts b/web/i18n/en-US/app-debug.ts index 3ee5fd3e1d..d4076fc9bb 100644 --- a/web/i18n/en-US/app-debug.ts +++ b/web/i18n/en-US/app-debug.ts @@ -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', diff --git a/web/i18n/en-US/common.ts b/web/i18n/en-US/common.ts index 0811ba73ff..89ebef3cce 100644 --- a/web/i18n/en-US/common.ts +++ b/web/i18n/en-US/common.ts @@ -65,6 +65,9 @@ const translation = { input: 'Please enter', select: 'Please select', }, + label: { + optional: '(optional)', + }, voice: { language: { zhHans: 'Chinese', diff --git a/web/i18n/zh-Hans/app-debug.ts b/web/i18n/zh-Hans/app-debug.ts index c2c659b41f..198cd9cf0c 100644 --- a/web/i18n/zh-Hans/app-debug.ts +++ b/web/i18n/zh-Hans/app-debug.ts @@ -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': '最大上传数', diff --git a/web/i18n/zh-Hans/common.ts b/web/i18n/zh-Hans/common.ts index 9c822885e0..1c7ef93c59 100644 --- a/web/i18n/zh-Hans/common.ts +++ b/web/i18n/zh-Hans/common.ts @@ -65,6 +65,9 @@ const translation = { input: '请输入', select: '请选择', }, + label: { + optional: '(可选)', + }, voice: { language: { zhHans: '中文', diff --git a/web/utils/var.ts b/web/utils/var.ts index 06cb43c268..6bffaee6e1 100644 --- a/web/utils/var.ts +++ b/web/utils/var.ts @@ -42,6 +42,9 @@ export const getNewVarInWorkflow = (key: string, type = InputVarType.textInput) type, variable: key, label: key.slice(0, getMaxVarNameLength(key)), + placeholder: '', + default: '', + hint: '', } }