This commit is contained in:
2025-12-01 17:21:38 +08:00
parent 32fee2b8ab
commit fab8c13cb3
7511 changed files with 996300 additions and 0 deletions

View File

@@ -0,0 +1,347 @@
import CheckboxList from '@/app/components/base/checkbox-list'
import type { FieldState, FormSchema, TypeWithI18N } from '@/app/components/base/form/types'
import { FormItemValidateStatusEnum, FormTypeEnum } from '@/app/components/base/form/types'
import Input from '@/app/components/base/input'
import Radio from '@/app/components/base/radio'
import RadioE from '@/app/components/base/radio/ui'
import PureSelect from '@/app/components/base/select/pure'
import Tooltip from '@/app/components/base/tooltip'
import { useRenderI18nObject } from '@/hooks/use-i18n'
import { useTriggerPluginDynamicOptions } from '@/service/use-triggers'
import cn from '@/utils/classnames'
import { RiExternalLinkLine } from '@remixicon/react'
import type { AnyFieldApi } from '@tanstack/react-form'
import { useStore } from '@tanstack/react-form'
import {
isValidElement,
memo,
useCallback,
useMemo,
} from 'react'
import { useTranslation } from 'react-i18next'
const getExtraProps = (type: FormTypeEnum) => {
switch (type) {
case FormTypeEnum.secretInput:
return { type: 'password', autoComplete: 'new-password' }
case FormTypeEnum.textNumber:
return { type: 'number' }
default:
return { type: 'text' }
}
}
const getTranslatedContent = ({ content, render }: {
content: React.ReactNode | string | null | undefined | TypeWithI18N<string> | Record<string, string>
render: (content: TypeWithI18N<string> | Record<string, string>) => string
}): string => {
if (isValidElement(content) || typeof content === 'string')
return content as string
if (typeof content === 'object' && content !== null)
return render(content as TypeWithI18N<string>)
return ''
}
const VALIDATE_STATUS_STYLE_MAP: Record<FormItemValidateStatusEnum, { componentClassName: string, textClassName: string, infoFieldName: string }> = {
[FormItemValidateStatusEnum.Error]: {
componentClassName: 'border-components-input-border-destructive focus:border-components-input-border-destructive',
textClassName: 'text-text-destructive',
infoFieldName: 'errors',
},
[FormItemValidateStatusEnum.Warning]: {
componentClassName: 'border-components-input-border-warning focus:border-components-input-border-warning',
textClassName: 'text-text-warning',
infoFieldName: 'warnings',
},
[FormItemValidateStatusEnum.Success]: {
componentClassName: '',
textClassName: '',
infoFieldName: '',
},
[FormItemValidateStatusEnum.Validating]: {
componentClassName: '',
textClassName: '',
infoFieldName: '',
},
}
export type BaseFieldProps = {
fieldClassName?: string
labelClassName?: string
inputContainerClassName?: string
inputClassName?: string
formSchema: FormSchema
field: AnyFieldApi
disabled?: boolean
onChange?: (field: string, value: any) => void
fieldState?: FieldState
}
const BaseField = ({
fieldClassName,
labelClassName,
inputContainerClassName,
inputClassName,
formSchema,
field,
disabled: propsDisabled,
onChange,
fieldState,
}: BaseFieldProps) => {
const renderI18nObject = useRenderI18nObject()
const { t } = useTranslation()
const {
name,
label,
required,
placeholder,
options,
labelClassName: formLabelClassName,
disabled: formSchemaDisabled,
type: formItemType,
dynamicSelectParams,
multiple = false,
tooltip,
showCopy,
description,
url,
help,
} = formSchema
const disabled = propsDisabled || formSchemaDisabled
const [translatedLabel, translatedPlaceholder, translatedTooltip, translatedDescription, translatedHelp] = useMemo(() => {
const results = [
label,
placeholder,
tooltip,
description,
help,
].map(v => getTranslatedContent({ content: v, render: renderI18nObject }))
if (!results[1]) results[1] = t('common.placeholder.input')
return results
}, [label, placeholder, tooltip, description, help, renderI18nObject])
const watchedVariables = useMemo(() => {
const variables = new Set<string>()
for (const option of options || []) {
for (const condition of option.show_on || [])
variables.add(condition.variable)
}
return Array.from(variables)
}, [options])
const watchedValues = useStore(field.form.store, (s) => {
const result: Record<string, any> = {}
for (const variable of watchedVariables)
result[variable] = s.values[variable]
return result
})
const memorizedOptions = useMemo(() => {
return options?.filter((option) => {
if (!option.show_on?.length)
return true
return option.show_on.every((condition) => {
return watchedValues[condition.variable] === condition.value
})
}).map((option) => {
return {
label: getTranslatedContent({ content: option.label, render: renderI18nObject }),
value: option.value,
}
}) || []
}, [options, renderI18nObject, watchedValues])
const value = useStore(field.form.store, s => s.values[field.name])
const { data: dynamicOptionsData, isLoading: isDynamicOptionsLoading, error: dynamicOptionsError } = useTriggerPluginDynamicOptions(
dynamicSelectParams || {
plugin_id: '',
provider: '',
action: '',
parameter: '',
credential_id: '',
},
formItemType === FormTypeEnum.dynamicSelect,
)
const dynamicOptions = useMemo(() => {
if (!dynamicOptionsData?.options)
return []
return dynamicOptionsData.options.map(option => ({
label: getTranslatedContent({ content: option.label, render: renderI18nObject }),
value: option.value,
}))
}, [dynamicOptionsData, renderI18nObject])
const handleChange = useCallback((value: any) => {
field.handleChange(value)
onChange?.(field.name, value)
}, [field, onChange])
return (
<>
<div className={cn(fieldClassName)}>
<div className={cn(labelClassName, formLabelClassName)}>
{translatedLabel}
{
required && !isValidElement(label) && (
<span className='ml-1 text-text-destructive-secondary'>*</span>
)
}
{tooltip && (
<Tooltip
popupContent={<div className='w-[200px]'>{translatedTooltip}</div>}
triggerClassName='ml-0.5 w-4 h-4'
/>
)}
</div>
<div className={cn(inputContainerClassName)}>
{
[FormTypeEnum.textInput, FormTypeEnum.secretInput, FormTypeEnum.textNumber].includes(formItemType) && (
<Input
id={field.name}
name={field.name}
className={cn(inputClassName, VALIDATE_STATUS_STYLE_MAP[fieldState?.validateStatus as FormItemValidateStatusEnum]?.componentClassName)}
value={value || ''}
onChange={(e) => {
handleChange(e.target.value)
}}
onBlur={field.handleBlur}
disabled={disabled}
placeholder={translatedPlaceholder}
{...getExtraProps(formItemType)}
showCopyIcon={showCopy}
/>
)
}
{
formItemType === FormTypeEnum.select && !multiple && (
<PureSelect
value={value}
onChange={v => handleChange(v)}
disabled={disabled}
placeholder={translatedPlaceholder}
options={memorizedOptions}
triggerPopupSameWidth
popupProps={{
className: 'max-h-[320px] overflow-y-auto',
}}
/>
)
}
{
formItemType === FormTypeEnum.checkbox /* && multiple */ && (
<CheckboxList
title={name}
value={value}
onChange={v => field.handleChange(v)}
options={memorizedOptions}
maxHeight='200px'
/>
)
}
{
formItemType === FormTypeEnum.dynamicSelect && (
<PureSelect
options={dynamicOptions}
value={value}
onChange={field.handleChange}
disabled={disabled || isDynamicOptionsLoading}
placeholder={
isDynamicOptionsLoading
? t('common.dynamicSelect.loading')
: translatedPlaceholder
}
{...(dynamicOptionsError ? { popupProps: { title: t('common.dynamicSelect.error'), titleClassName: 'text-text-destructive-secondary' } }
: (!dynamicOptions.length ? { popupProps: { title: t('common.dynamicSelect.noData') } } : {}))}
triggerPopupSameWidth
multiple={multiple}
/>
)
}
{
formItemType === FormTypeEnum.radio && (
<div className={cn(
memorizedOptions.length < 3 ? 'flex items-center space-x-2' : 'space-y-2',
)}>
{
memorizedOptions.map(option => (
<div
key={option.value}
className={cn(
'system-sm-regular hover:bg-components-option-card-option-hover-bg hover:border-components-option-card-option-hover-border flex h-8 flex-[1] grow cursor-pointer items-center justify-center gap-2 rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg p-2 text-text-secondary',
value === option.value && 'border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg text-text-primary shadow-xs',
disabled && 'cursor-not-allowed opacity-50',
inputClassName,
)}
onClick={() => !disabled && handleChange(option.value)}
>
{
formSchema.showRadioUI && (
<RadioE
className='mr-2'
isChecked={value === option.value}
/>
)
}
{option.label}
</div>
))
}
</div>
)
}
{
formItemType === FormTypeEnum.boolean && (
<Radio.Group
className='flex w-fit items-center'
value={value}
onChange={v => field.handleChange(v)}
>
<Radio value={true} className='!mr-1'>True</Radio>
<Radio value={false}>False</Radio>
</Radio.Group>
)
}
{fieldState?.validateStatus && [FormItemValidateStatusEnum.Error, FormItemValidateStatusEnum.Warning].includes(fieldState?.validateStatus) && (
<div className={cn(
'system-xs-regular mt-1 px-0 py-[2px]',
VALIDATE_STATUS_STYLE_MAP[fieldState?.validateStatus].textClassName,
)}>
{fieldState?.[VALIDATE_STATUS_STYLE_MAP[fieldState?.validateStatus].infoFieldName as keyof FieldState]}
</div>
)}
</div>
</div>
{description && (
<div className='system-xs-regular mt-4 text-text-tertiary'>
{translatedDescription}
</div>
)}
{
url && (
<a
className='system-xs-regular mt-4 flex items-center text-text-accent'
href={url}
target='_blank'
>
<span className='break-all'>
{translatedHelp}
</span>
<RiExternalLinkLine className='ml-1 h-3 w-3 shrink-0' />
</a>
)
}
</>
)
}
export default memo(BaseField)

View File

@@ -0,0 +1,203 @@
import {
memo,
useCallback,
useImperativeHandle,
useMemo,
useState,
} from 'react'
import type {
AnyFieldApi,
AnyFormApi,
} from '@tanstack/react-form'
import {
useForm,
useStore,
} from '@tanstack/react-form'
import {
type FieldState,
FormItemValidateStatusEnum,
type FormRef,
type FormSchema,
type SetFieldsParam,
} from '@/app/components/base/form/types'
import {
BaseField,
} from '.'
import type {
BaseFieldProps,
} from '.'
import cn from '@/utils/classnames'
import {
useGetFormValues,
useGetValidators,
} from '@/app/components/base/form/hooks'
export type BaseFormProps = {
formSchemas?: FormSchema[]
defaultValues?: Record<string, any>
formClassName?: string
ref?: FormRef
disabled?: boolean
formFromProps?: AnyFormApi
onChange?: (field: string, value: any) => void
onSubmit?: (e: React.FormEvent<HTMLFormElement>) => void
preventDefaultSubmit?: boolean
} & Pick<BaseFieldProps, 'fieldClassName' | 'labelClassName' | 'inputContainerClassName' | 'inputClassName'>
const BaseForm = ({
formSchemas = [],
defaultValues,
formClassName,
fieldClassName,
labelClassName,
inputContainerClassName,
inputClassName,
ref,
disabled,
formFromProps,
onChange,
onSubmit,
preventDefaultSubmit = false,
}: BaseFormProps) => {
const initialDefaultValues = useMemo(() => {
if (defaultValues)
return defaultValues
return formSchemas.reduce((acc, schema) => {
if (schema.default)
acc[schema.name] = schema.default
return acc
}, {} as Record<string, any>)
}, [defaultValues])
const formFromHook = useForm({
defaultValues: initialDefaultValues,
})
const form: any = formFromProps || formFromHook
const { getFormValues } = useGetFormValues(form, formSchemas)
const { getValidators } = useGetValidators()
const [fieldStates, setFieldStates] = useState<Record<string, FieldState>>({})
const showOnValues = useStore(form.store, (s: any) => {
const result: Record<string, any> = {}
formSchemas.forEach((schema) => {
const { show_on } = schema
if (show_on?.length) {
show_on.forEach((condition) => {
result[condition.variable] = s.values[condition.variable]
})
}
})
return result
})
const setFields = useCallback((fields: SetFieldsParam[]) => {
const newFieldStates: Record<string, FieldState> = { ...fieldStates }
for (const field of fields) {
const { name, value, errors, warnings, validateStatus, help } = field
if (value !== undefined)
form.setFieldValue(name, value)
let finalValidateStatus = validateStatus
if (!finalValidateStatus) {
if (errors && errors.length > 0)
finalValidateStatus = FormItemValidateStatusEnum.Error
else if (warnings && warnings.length > 0)
finalValidateStatus = FormItemValidateStatusEnum.Warning
}
newFieldStates[name] = {
validateStatus: finalValidateStatus,
help,
errors,
warnings,
}
}
setFieldStates(newFieldStates)
}, [form, fieldStates])
useImperativeHandle(ref, () => {
return {
getForm() {
return form
},
getFormValues: (option) => {
return getFormValues(option)
},
setFields,
}
}, [form, getFormValues, setFields])
const renderField = useCallback((field: AnyFieldApi) => {
const formSchema = formSchemas?.find(schema => schema.name === field.name)
if (formSchema) {
return (
<BaseField
field={field}
formSchema={formSchema}
fieldClassName={fieldClassName ?? formSchema.fieldClassName}
labelClassName={labelClassName ?? formSchema.labelClassName}
inputContainerClassName={inputContainerClassName}
inputClassName={inputClassName}
disabled={disabled}
onChange={onChange}
fieldState={fieldStates[field.name]}
/>
)
}
return null
}, [formSchemas, fieldClassName, labelClassName, inputContainerClassName, inputClassName, disabled, onChange, fieldStates])
const renderFieldWrapper = useCallback((formSchema: FormSchema) => {
const validators = getValidators(formSchema)
const {
name,
show_on = [],
} = formSchema
const show = show_on?.every((condition) => {
const conditionValue = showOnValues[condition.variable]
return conditionValue === condition.value
})
if (!show)
return null
return (
<form.Field
key={name}
name={name}
validators={validators}
>
{renderField}
</form.Field>
)
}, [renderField, form, getValidators, showOnValues])
if (!formSchemas?.length)
return null
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
if (preventDefaultSubmit) {
e.preventDefault()
e.stopPropagation()
}
onSubmit?.(e)
}
return (
<form
className={cn(formClassName)}
onSubmit={handleSubmit}
>
{formSchemas.map(renderFieldWrapper)}
</form>
)
}
export default memo(BaseForm)

View File

@@ -0,0 +1,2 @@
export { default as BaseForm, type BaseFormProps } from './base-form'
export { default as BaseField, type BaseFieldProps } from './base-field'

View File

@@ -0,0 +1,43 @@
import cn from '@/utils/classnames'
import { useFieldContext } from '../..'
import Checkbox from '../../../checkbox'
type CheckboxFieldProps = {
label: string;
labelClassName?: string;
}
const CheckboxField = ({
label,
labelClassName,
}: CheckboxFieldProps) => {
const field = useFieldContext<boolean>()
return (
<div className='flex gap-2'>
<div className='flex h-6 shrink-0 items-center'>
<Checkbox
id={field.name}
checked={field.state.value}
onCheck={() => {
field.handleChange(!field.state.value)
}}
/>
</div>
<label
htmlFor={field.name}
className={cn(
'system-sm-medium grow cursor-pointer pt-1 text-text-secondary',
labelClassName,
)}
onClick={() => {
field.handleChange(!field.state.value)
}}
>
{label}
</label>
</div>
)
}
export default CheckboxField

View File

@@ -0,0 +1,41 @@
import cn from '@/utils/classnames'
import { useFieldContext } from '../..'
import type { CustomSelectProps, Option } from '../../../select/custom'
import CustomSelect from '../../../select/custom'
import type { LabelProps } from '../label'
import Label from '../label'
type CustomSelectFieldProps<T extends Option> = {
label: string
labelOptions?: Omit<LabelProps, 'htmlFor' | 'label'>
options: T[]
className?: string
} & Omit<CustomSelectProps<T>, 'options' | 'value' | 'onChange'>
const CustomSelectField = <T extends Option>({
label,
labelOptions,
options,
className,
...selectProps
}: CustomSelectFieldProps<T>) => {
const field = useFieldContext<string>()
return (
<div className={cn('flex flex-col gap-y-0.5', className)}>
<Label
htmlFor={field.name}
label={label}
{...(labelOptions ?? {})}
/>
<CustomSelect<T>
value={field.state.value}
options={options}
onChange={value => field.handleChange(value)}
{...selectProps}
/>
</div>
)
}
export default CustomSelectField

View File

@@ -0,0 +1,83 @@
import cn from '@/utils/classnames'
import type { LabelProps } from '../label'
import { useFieldContext } from '../..'
import Label from '../label'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import FileTypeItem from '@/app/components/workflow/nodes/_base/components/file-type-item'
import { useCallback } from 'react'
type FieldValue = {
allowedFileTypes: string[],
allowedFileExtensions: string[]
}
type FileTypesFieldProps = {
label: string
labelOptions?: Omit<LabelProps, 'htmlFor' | 'label'>
className?: string
}
const FileTypesField = ({
label,
labelOptions,
className,
}: FileTypesFieldProps) => {
const field = useFieldContext<FieldValue>()
const handleSupportFileTypeChange = useCallback((type: SupportUploadFileTypes) => {
let newAllowFileTypes = [...field.state.value.allowedFileTypes]
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)
}
field.handleChange({
...field.state.value,
allowedFileTypes: newAllowFileTypes,
})
}, [field])
const handleCustomFileTypesChange = useCallback((customFileTypes: string[]) => {
field.handleChange({
...field.state.value,
allowedFileExtensions: customFileTypes,
})
}, [field])
return (
<div className={cn('flex flex-col gap-y-0.5', className)}>
<Label
htmlFor={field.name}
label={label}
{...(labelOptions ?? {})}
/>
{
[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={field.state.value.allowedFileTypes.includes(type)}
onToggle={handleSupportFileTypeChange}
/>
))
}
<FileTypeItem
type={SupportUploadFileTypes.custom}
selected={field.state.value.allowedFileTypes.includes(SupportUploadFileTypes.custom)}
onToggle={handleSupportFileTypeChange}
customFileTypes={field.state.value.allowedFileExtensions}
onCustomFileTypesChange={handleCustomFileTypesChange}
/>
</div>
)
}
export default FileTypesField

View File

@@ -0,0 +1,40 @@
import React from 'react'
import { useFieldContext } from '../..'
import type { LabelProps } from '../label'
import Label from '../label'
import cn from '@/utils/classnames'
import type { FileUploaderInAttachmentWrapperProps } from '../../../file-uploader/file-uploader-in-attachment'
import FileUploaderInAttachmentWrapper from '../../../file-uploader/file-uploader-in-attachment'
import type { FileEntity } from '../../../file-uploader/types'
type FileUploaderFieldProps = {
label: string
labelOptions?: Omit<LabelProps, 'htmlFor' | 'label'>
className?: string
} & Omit<FileUploaderInAttachmentWrapperProps, 'value' | 'onChange'>
const FileUploaderField = ({
label,
labelOptions,
className,
...inputProps
}: FileUploaderFieldProps) => {
const field = useFieldContext<FileEntity[]>()
return (
<div className={cn('flex flex-col gap-y-0.5', className)}>
<Label
htmlFor={field.name}
label={label}
{...(labelOptions ?? {})}
/>
<FileUploaderInAttachmentWrapper
value={field.state.value}
onChange={value => field.handleChange(value)}
{...inputProps}
/>
</div>
)
}
export default FileUploaderField

View File

@@ -0,0 +1,52 @@
import { InputTypeEnum } from './types'
import { PipelineInputVarType } from '@/models/pipeline'
import { useTranslation } from 'react-i18next'
import {
RiAlignLeft,
RiCheckboxLine,
RiFileCopy2Line,
RiFileTextLine,
RiHashtag,
RiListCheck3,
RiTextSnippet,
} from '@remixicon/react'
const i18nFileTypeMap: Record<string, string> = {
'number': 'number',
'file': 'single-file',
'file-list': 'multi-files',
}
const INPUT_TYPE_ICON = {
[PipelineInputVarType.textInput]: RiTextSnippet,
[PipelineInputVarType.paragraph]: RiAlignLeft,
[PipelineInputVarType.number]: RiHashtag,
[PipelineInputVarType.select]: RiListCheck3,
[PipelineInputVarType.checkbox]: RiCheckboxLine,
[PipelineInputVarType.singleFile]: RiFileTextLine,
[PipelineInputVarType.multiFiles]: RiFileCopy2Line,
}
const DATA_TYPE = {
[PipelineInputVarType.textInput]: 'string',
[PipelineInputVarType.paragraph]: 'string',
[PipelineInputVarType.number]: 'number',
[PipelineInputVarType.select]: 'string',
[PipelineInputVarType.checkbox]: 'boolean',
[PipelineInputVarType.singleFile]: 'file',
[PipelineInputVarType.multiFiles]: 'array[file]',
}
export const useInputTypeOptions = (supportFile: boolean) => {
const { t } = useTranslation()
const options = supportFile ? InputTypeEnum.options : InputTypeEnum.exclude(['file', 'file-list']).options
return options.map((value) => {
return {
value,
label: t(`appDebug.variableConfig.${i18nFileTypeMap[value] || value}`),
Icon: INPUT_TYPE_ICON[value],
type: DATA_TYPE[value],
}
})
}

View File

@@ -0,0 +1,64 @@
import cn from '@/utils/classnames'
import { useFieldContext } from '../../..'
import type { CustomSelectProps } from '../../../../select/custom'
import CustomSelect from '../../../../select/custom'
import type { LabelProps } from '../../label'
import Label from '../../label'
import { useCallback } from 'react'
import Trigger from './trigger'
import type { FileTypeSelectOption, InputType } from './types'
import { useInputTypeOptions } from './hooks'
import Option from './option'
type InputTypeSelectFieldProps = {
label: string
labelOptions?: Omit<LabelProps, 'htmlFor' | 'label'>
supportFile: boolean
className?: string
} & Omit<CustomSelectProps<FileTypeSelectOption>, 'options' | 'value' | 'onChange' | 'CustomTrigger' | 'CustomOption'>
const InputTypeSelectField = ({
label,
labelOptions,
supportFile,
className,
...customSelectProps
}: InputTypeSelectFieldProps) => {
const field = useFieldContext<InputType>()
const inputTypeOptions = useInputTypeOptions(supportFile)
const renderTrigger = useCallback((option: FileTypeSelectOption | undefined, open: boolean) => {
return <Trigger option={option} open={open} />
}, [])
const renderOption = useCallback((option: FileTypeSelectOption) => {
return <Option option={option} />
}, [])
return (
<div className={cn('flex flex-col gap-y-0.5', className)}>
<Label
htmlFor={field.name}
label={label}
{...(labelOptions ?? {})}
/>
<CustomSelect<FileTypeSelectOption>
value={field.state.value}
options={inputTypeOptions}
onChange={value => field.handleChange(value as InputType)}
triggerProps={{
className: 'gap-x-0.5',
}}
popupProps={{
className: 'w-[368px]',
wrapperClassName: 'z-[9999999]',
itemClassName: 'gap-x-1',
}}
CustomTrigger={renderTrigger}
CustomOption={renderOption}
{...customSelectProps}
/>
</div>
)
}
export default InputTypeSelectField

View File

@@ -0,0 +1,21 @@
import React from 'react'
import type { FileTypeSelectOption } from './types'
import Badge from '@/app/components/base/badge'
type OptionProps = {
option: FileTypeSelectOption
}
const Option = ({
option,
}: OptionProps) => {
return (
<>
<option.Icon className='h-4 w-4 shrink-0 text-text-tertiary' />
<span className='grow px-1'>{option.label}</span>
<Badge text={option.type} uppercase={false} />
</>
)
}
export default React.memo(Option)

View File

@@ -0,0 +1,42 @@
import React from 'react'
import Badge from '@/app/components/base/badge'
import cn from '@/utils/classnames'
import { RiArrowDownSLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import type { FileTypeSelectOption } from './types'
type TriggerProps = {
option: FileTypeSelectOption | undefined
open: boolean
}
const Trigger = ({
option,
open,
}: TriggerProps) => {
const { t } = useTranslation()
return (
<>
{option ? (
<>
<option.Icon className='h-4 w-4 shrink-0 text-text-tertiary' />
<span className='grow p-1'>{option.label}</span>
<div className='pr-0.5'>
<Badge text={option.type} uppercase={false} />
</div>
</>
) : (
<span className='grow p-1'>{t('common.placeholder.select')}</span>
)}
<RiArrowDownSLine
className={cn(
'h-4 w-4 shrink-0 text-text-quaternary group-hover:text-text-secondary',
open && 'text-text-secondary',
)}
/>
</>
)
}
export default React.memo(Trigger)

View File

@@ -0,0 +1,21 @@
import type { RemixiconComponentType } from '@remixicon/react'
import { z } from 'zod'
export const InputTypeEnum = z.enum([
'text-input',
'paragraph',
'number',
'select',
'checkbox',
'file',
'file-list',
])
export type InputType = z.infer<typeof InputTypeEnum>
export type FileTypeSelectOption = {
value: InputType
label: string
Icon: RemixiconComponentType
type: string
}

View File

@@ -0,0 +1,39 @@
import {
memo,
} from 'react'
import PromptEditor from '@/app/components/base/prompt-editor'
import cn from '@/utils/classnames'
import Placeholder from './placeholder'
type MixedVariableTextInputProps = {
editable?: boolean
value?: string
onChange?: (text: string) => void
}
const MixedVariableTextInput = ({
editable = true,
value = '',
onChange,
}: MixedVariableTextInputProps) => {
return (
<PromptEditor
wrapperClassName={cn(
'rounded-lg border border-transparent bg-components-input-bg-normal px-2 py-1',
'hover:border-components-input-border-hover hover:bg-components-input-bg-hover',
'focus-within:border-components-input-border-active focus-within:bg-components-input-bg-active focus-within:shadow-xs',
)}
className='caret:text-text-accent'
editable={editable}
value={value}
workflowVariableBlock={{
show: true,
variables: [],
workflowNodesMap: {},
}}
placeholder={<Placeholder />}
onChange={onChange}
/>
)
}
export default memo(MixedVariableTextInput)

View File

@@ -0,0 +1,49 @@
import { useCallback } from 'react'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { FOCUS_COMMAND } from 'lexical'
import { $insertNodes } from 'lexical'
import { CustomTextNode } from '@/app/components/base/prompt-editor/plugins/custom-text/node'
import Badge from '@/app/components/base/badge'
const Placeholder = () => {
const [editor] = useLexicalComposerContext()
const handleInsert = useCallback((text: string) => {
editor.update(() => {
const textNode = new CustomTextNode(text)
$insertNodes([textNode])
})
editor.dispatchCommand(FOCUS_COMMAND, undefined as any)
}, [editor])
return (
<div
className='pointer-events-auto flex h-full w-full cursor-text items-center px-2'
onClick={(e) => {
e.stopPropagation()
handleInsert('')
}}
>
<div className='flex grow items-center'>
Type or press
<div className='system-kbd mx-0.5 flex h-4 w-4 items-center justify-center rounded bg-components-kbd-bg-gray text-text-placeholder'>/</div>
<div
className='system-sm-regular cursor-pointer text-components-input-text-placeholder underline decoration-dotted decoration-auto underline-offset-auto hover:text-text-tertiary'
onClick={((e) => {
e.stopPropagation()
handleInsert('/')
})}
>
insert variable
</div>
</div>
<Badge
className='shrink-0'
text='String'
uppercase={false}
/>
</div>
)
}
export default Placeholder

View File

@@ -0,0 +1,41 @@
import React from 'react'
import { useFieldContext } from '../..'
import type { LabelProps } from '../label'
import Label from '../label'
import cn from '@/utils/classnames'
import type { InputNumberProps } from '../../../input-number'
import { InputNumber } from '../../../input-number'
type TextFieldProps = {
label: string
labelOptions?: Omit<LabelProps, 'htmlFor' | 'label'>
className?: string
} & Omit<InputNumberProps, 'id' | 'value' | 'onChange' | 'onBlur'>
const NumberInputField = ({
label,
labelOptions,
className,
...inputProps
}: TextFieldProps) => {
const field = useFieldContext<number>()
return (
<div className={cn('flex flex-col gap-y-0.5', className)}>
<Label
htmlFor={field.name}
label={label}
{...(labelOptions ?? {})}
/>
<InputNumber
id={field.name}
value={field.state.value}
onChange={value => field.handleChange(value)}
onBlur={field.handleBlur}
{...inputProps}
/>
</div>
)
}
export default NumberInputField

View File

@@ -0,0 +1,47 @@
import cn from '@/utils/classnames'
import type { LabelProps } from '../label'
import { useFieldContext } from '../..'
import Label from '../label'
import type { InputNumberWithSliderProps } from '@/app/components/workflow/nodes/_base/components/input-number-with-slider'
import InputNumberWithSlider from '@/app/components/workflow/nodes/_base/components/input-number-with-slider'
type NumberSliderFieldProps = {
label: string
labelOptions?: Omit<LabelProps, 'htmlFor' | 'label'>
description?: string
className?: string
} & Omit<InputNumberWithSliderProps, 'value' | 'onChange'>
const NumberSliderField = ({
label,
labelOptions,
description,
className,
...InputNumberWithSliderProps
}: NumberSliderFieldProps) => {
const field = useFieldContext<number>()
return (
<div className={cn('flex flex-col gap-y-0.5', className)}>
<div>
<Label
htmlFor={field.name}
label={label}
{...(labelOptions ?? {})}
/>
{description && (
<div className='body-xs-regular pb-0.5 text-text-tertiary'>
{description}
</div>
)}
</div>
<InputNumberWithSlider
value={field.state.value}
onChange={value => field.handleChange(value)}
{...InputNumberWithSliderProps}
/>
</div>
)
}
export default NumberSliderField

View File

@@ -0,0 +1,36 @@
import cn from '@/utils/classnames'
import { useFieldContext } from '../..'
import type { LabelProps } from '../label'
import Label from '../label'
import type { Options } from '@/app/components/app/configuration/config-var/config-select'
import ConfigSelect from '@/app/components/app/configuration/config-var/config-select'
type OptionsFieldProps = {
label: string;
labelOptions?: Omit<LabelProps, 'htmlFor' | 'label'>
className?: string;
}
const OptionsField = ({
label,
className,
labelOptions,
}: OptionsFieldProps) => {
const field = useFieldContext<Options>()
return (
<div className={cn('flex flex-col gap-y-0.5', className)}>
<Label
htmlFor={field.name}
label={label}
{...(labelOptions ?? {})}
/>
<ConfigSelect
options={field.state.value}
onChange={value => field.handleChange(value)}
/>
</div>
)
}
export default OptionsField

View File

@@ -0,0 +1,48 @@
import cn from '@/utils/classnames'
import { useFieldContext } from '../..'
import type { Option, PureSelectProps } from '../../../select/pure'
import PureSelect from '../../../select/pure'
import type { LabelProps } from '../label'
import Label from '../label'
type SelectFieldProps = {
label: string
labelOptions?: Omit<LabelProps, 'htmlFor' | 'label'>
options: Option[]
onChange?: (value: string) => void
className?: string
} & Omit<PureSelectProps, 'options' | 'value' | 'onChange' | 'multiple'> & {
multiple?: false
}
const SelectField = ({
label,
labelOptions,
options,
onChange,
className,
...selectProps
}: SelectFieldProps) => {
const field = useFieldContext<string>()
return (
<div className={cn('flex flex-col gap-y-0.5', className)}>
<Label
htmlFor={field.name}
label={label}
{...(labelOptions ?? {})}
/>
<PureSelect
value={field.state.value}
options={options}
onChange={(value) => {
field.handleChange(value)
onChange?.(value)
}}
{...selectProps}
/>
</div>
)
}
export default SelectField

View File

@@ -0,0 +1,41 @@
import React from 'react'
import { useFieldContext } from '../..'
import type { LabelProps } from '../label'
import Label from '../label'
import cn from '@/utils/classnames'
import type { TextareaProps } from '../../../textarea'
import Textarea from '../../../textarea'
type TextAreaFieldProps = {
label: string
labelOptions?: Omit<LabelProps, 'htmlFor' | 'label'>
className?: string
} & Omit<TextareaProps, 'className' | 'onChange' | 'onBlur' | 'value' | 'id'>
const TextAreaField = ({
label,
labelOptions,
className,
...inputProps
}: TextAreaFieldProps) => {
const field = useFieldContext<string>()
return (
<div className={cn('flex flex-col gap-y-0.5', className)}>
<Label
htmlFor={field.name}
label={label}
{...(labelOptions ?? {})}
/>
<Textarea
id={field.name}
value={field.state.value}
onChange={e => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
{...inputProps}
/>
</div>
)
}
export default TextAreaField

View File

@@ -0,0 +1,40 @@
import React from 'react'
import { useFieldContext } from '../..'
import Input, { type InputProps } from '../../../input'
import type { LabelProps } from '../label'
import Label from '../label'
import cn from '@/utils/classnames'
type TextFieldProps = {
label: string
labelOptions?: Omit<LabelProps, 'htmlFor' | 'label'>
className?: string
} & Omit<InputProps, 'className' | 'onChange' | 'onBlur' | 'value' | 'id'>
const TextField = ({
label,
labelOptions,
className,
...inputProps
}: TextFieldProps) => {
const field = useFieldContext<string>()
return (
<div className={cn('flex flex-col gap-y-0.5', className)}>
<Label
htmlFor={field.name}
label={label}
{...(labelOptions ?? {})}
/>
<Input
id={field.name}
value={field.state.value}
onChange={e => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
{...inputProps}
/>
</div>
)
}
export default TextField

View File

@@ -0,0 +1,58 @@
import cn from '@/utils/classnames'
import type { LabelProps } from '../label'
import { useFieldContext } from '../..'
import Label from '../label'
import OptionCard from '@/app/components/workflow/nodes/_base/components/option-card'
import { useTranslation } from 'react-i18next'
import { TransferMethod } from '@/types/app'
import { useCallback } from 'react'
type UploadMethodFieldProps = {
label: string
labelOptions?: Omit<LabelProps, 'htmlFor' | 'label'>
className?: string
}
const UploadMethodField = ({
label,
labelOptions,
className,
}: UploadMethodFieldProps) => {
const { t } = useTranslation()
const field = useFieldContext<TransferMethod[]>()
const { value } = field.state
const handleUploadMethodChange = useCallback((method: TransferMethod) => {
field.handleChange(method === TransferMethod.all ? [TransferMethod.local_file, TransferMethod.remote_url] : [method])
}, [field])
return (
<div className={cn('flex flex-col gap-y-0.5', className)}>
<Label
htmlFor={field.name}
label={label}
{...(labelOptions ?? {})}
/>
<div className='grid grid-cols-3 gap-2'>
<OptionCard
title={t('appDebug.variableConfig.localUpload')}
selected={value.length === 1 && value.includes(TransferMethod.local_file)}
onSelect={handleUploadMethodChange.bind(null, TransferMethod.local_file)}
/>
<OptionCard
title="URL"
selected={value.length === 1 && value.includes(TransferMethod.remote_url)}
onSelect={handleUploadMethodChange.bind(null, TransferMethod.remote_url)}
/>
<OptionCard
title={t('appDebug.variableConfig.both')}
selected={value.includes(TransferMethod.local_file) && value.includes(TransferMethod.remote_url)}
onSelect={handleUploadMethodChange.bind(null, TransferMethod.all)}
/>
</div>
</div>
)
}
export default UploadMethodField

View File

@@ -0,0 +1,86 @@
import type { ChangeEvent } from 'react'
import { useCallback, useState } from 'react'
import { RiEditLine } from '@remixicon/react'
import cn from '@/utils/classnames'
import SegmentedControl from '@/app/components/base/segmented-control'
import { VariableX } from '@/app/components/base/icons/src/vender/workflow'
import Input from '@/app/components/base/input'
import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker'
import type { LabelProps } from '../label'
import Label from '../label'
type VariableOrConstantInputFieldProps = {
label: string
labelOptions?: Omit<LabelProps, 'htmlFor' | 'label'>
className?: string
}
const VariableOrConstantInputField = ({
className,
label,
labelOptions,
}: VariableOrConstantInputFieldProps) => {
const [variableType, setVariableType] = useState('variable')
const options = [
{
Icon: VariableX,
value: 'variable',
},
{
Icon: RiEditLine,
value: 'constant',
},
]
const handleVariableOrConstantChange = useCallback((value: string) => {
setVariableType(value)
}, [setVariableType])
const handleVariableValueChange = () => {
console.log('Variable value changed')
}
const handleConstantValueChange = (e: ChangeEvent<HTMLInputElement>) => {
console.log('Constant value changed:', e.target.value)
}
return (
<div className={cn('flex flex-col gap-y-0.5', className)}>
<Label
htmlFor={'variable-or-constant'}
label={label}
{...(labelOptions ?? {})}
/>
<div className='flex items-center'>
<SegmentedControl
className='mr-1 shrink-0'
value={variableType}
onChange={handleVariableOrConstantChange as any}
options={options as any}
/>
{
variableType === 'variable' && (
<VarReferencePicker
className='grow'
nodeId=''
readonly={false}
value={[]}
onChange={handleVariableValueChange}
/>
)
}
{
variableType === 'constant' && (
<Input
className='ml-1'
onChange={handleConstantValueChange}
/>
)
}
</div>
</div>
)
}
export default VariableOrConstantInputField

View File

@@ -0,0 +1,41 @@
import cn from '@/utils/classnames'
import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker'
import type { LabelProps } from '../label'
import Label from '../label'
type VariableOrConstantInputFieldProps = {
label: string
labelOptions?: Omit<LabelProps, 'htmlFor' | 'label'>
className?: string
}
const VariableOrConstantInputField = ({
className,
label,
labelOptions,
}: VariableOrConstantInputFieldProps) => {
const handleVariableValueChange = () => {
console.log('Variable value changed')
}
return (
<div className={cn('flex flex-col gap-y-0.5', className)}>
<Label
htmlFor={'variable-or-constant'}
label={label}
{...(labelOptions ?? {})}
/>
<div className='flex items-center'>
<VarReferencePicker
className='grow'
nodeId=''
readonly={false}
value={[]}
onChange={handleVariableValueChange}
/>
</div>
</div>
)
}
export default VariableOrConstantInputField

View File

@@ -0,0 +1,43 @@
import { useStore } from '@tanstack/react-form'
import type { FormType } from '../..'
import { useFormContext } from '../..'
import Button from '../../../button'
import { useTranslation } from 'react-i18next'
export type CustomActionsProps = {
form: FormType
isSubmitting: boolean
canSubmit: boolean
}
type ActionsProps = {
CustomActions?: (props: CustomActionsProps) => React.ReactNode | React.JSX.Element
}
const Actions = ({
CustomActions,
}: ActionsProps) => {
const { t } = useTranslation()
const form = useFormContext()
const [isSubmitting, canSubmit] = useStore(form.store, state => [
state.isSubmitting,
state.canSubmit,
])
if (CustomActions)
return CustomActions({ form, isSubmitting, canSubmit })
return (
<Button
variant='primary'
disabled={isSubmitting || !canSubmit}
loading={isSubmitting}
onClick={() => form.handleSubmit()}
>
{t('common.operation.submit')}
</Button>
)
}
export default Actions

View File

@@ -0,0 +1,53 @@
import { fireEvent, render, screen } from '@testing-library/react'
import Label from './label'
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
describe('Label Component', () => {
const defaultProps = {
htmlFor: 'test-input',
label: 'Test Label',
}
it('renders basic label correctly', () => {
render(<Label {...defaultProps} />)
const label = screen.getByTestId('label')
expect(label).toBeInTheDocument()
expect(label).toHaveAttribute('for', 'test-input')
})
it('shows optional text when showOptional is true', () => {
render(<Label {...defaultProps} showOptional />)
expect(screen.getByText('common.label.optional')).toBeInTheDocument()
})
it('shows required asterisk when isRequired is true', () => {
render(<Label {...defaultProps} isRequired />)
expect(screen.getByText('*')).toBeInTheDocument()
})
it('renders tooltip when tooltip prop is provided', () => {
const tooltipText = 'Test Tooltip'
render(<Label {...defaultProps} tooltip={tooltipText} />)
const trigger = screen.getByTestId('test-input-tooltip')
fireEvent.mouseEnter(trigger)
expect(screen.getByText(tooltipText)).toBeInTheDocument()
})
it('applies custom className when provided', () => {
const customClass = 'custom-label'
render(<Label {...defaultProps} className={customClass} />)
const label = screen.getByTestId('label')
expect(label).toHaveClass(customClass)
})
it('does not show optional text and required asterisk simultaneously', () => {
render(<Label {...defaultProps} isRequired showOptional />)
expect(screen.queryByText('common.label.optional')).not.toBeInTheDocument()
expect(screen.getByText('*')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,48 @@
import cn from '@/utils/classnames'
import Tooltip from '../../tooltip'
import { useTranslation } from 'react-i18next'
export type LabelProps = {
htmlFor: string
label: string
isRequired?: boolean
showOptional?: boolean
tooltip?: string
className?: string
}
const Label = ({
htmlFor,
label,
isRequired,
showOptional,
tooltip,
className,
}: LabelProps) => {
const { t } = useTranslation()
return (
<div className='flex h-6 items-center'>
<label
data-testid='label'
htmlFor={htmlFor}
className={cn('system-sm-medium text-text-secondary', className)}
>
{label}
</label>
{!isRequired && showOptional && <div className='system-xs-regular ml-1 text-text-tertiary'>{t('common.label.optional')}</div>}
{isRequired && <div className='system-xs-regular ml-1 text-text-destructive-secondary'>*</div>}
{tooltip && (
<Tooltip
popupContent={
<div className='w-[200px]'>{tooltip}</div>
}
triggerClassName='ml-0.5 w-4 h-4'
triggerTestId={`${htmlFor}-tooltip`}
/>
)}
</div>
)
}
export default Label

View File

@@ -0,0 +1,25 @@
import { memo } from 'react'
import { BaseForm } from '../../components/base'
import type { BaseFormProps } from '../../components/base'
const AuthForm = ({
formSchemas = [],
defaultValues,
ref,
formFromProps,
...rest
}: BaseFormProps) => {
return (
<BaseForm
ref={ref}
formSchemas={formSchemas}
defaultValues={defaultValues}
formClassName='space-y-4'
labelClassName='h-6 flex items-center mb-1 system-sm-medium text-text-secondary'
formFromProps={formFromProps}
{...rest}
/>
)
}
export default memo(AuthForm)

View File

@@ -0,0 +1,197 @@
import React from 'react'
import { type BaseConfiguration, BaseFieldType } from './types'
import { withForm } from '../..'
import { useStore } from '@tanstack/react-form'
type BaseFieldProps = {
initialData?: Record<string, any>
config: BaseConfiguration
}
const BaseField = ({
initialData,
config,
}: BaseFieldProps) => withForm({
defaultValues: initialData,
render: function Render({
form,
}) {
const {
type,
label,
placeholder,
variable,
tooltip,
showConditions,
max,
min,
options,
required,
showOptional,
popupProps,
allowedFileExtensions,
allowedFileTypes,
allowedFileUploadMethods,
maxLength,
unit,
} = config
const isAllConditionsMet = useStore(form.store, (state) => {
const fieldValues = state.values
if (!showConditions.length) return true
return showConditions.every((condition) => {
const { variable, value } = condition
const fieldValue = fieldValues[variable as keyof typeof fieldValues]
return fieldValue === value
})
})
if (!isAllConditionsMet)
return <></>
if (type === BaseFieldType.textInput) {
return (
<form.AppField
name={variable}
children={field => (
<field.TextField
label={label}
labelOptions={{
tooltip,
isRequired: required,
showOptional,
}}
placeholder={placeholder}
/>
)}
/>
)
}
if (type === BaseFieldType.paragraph) {
return (
<form.AppField
name={variable}
children={field => (
<field.TextAreaField
label={label}
labelOptions={{
tooltip,
isRequired: required,
showOptional,
}}
placeholder={placeholder}
/>
)}
/>
)
}
if (type === BaseFieldType.numberInput) {
return (
<form.AppField
name={variable}
children={field => (
<field.NumberInputField
label={label}
labelOptions={{
tooltip,
isRequired: required,
showOptional,
}}
placeholder={placeholder}
max={max}
min={min}
unit={unit}
/>
)}
/>
)
}
if (type === BaseFieldType.checkbox) {
return (
<form.AppField
name={variable}
children={field => (
<field.CheckboxField
label={label}
/>
)}
/>
)
}
if (type === BaseFieldType.select) {
return (
<form.AppField
name={variable}
children={field => (
<field.SelectField
label={label}
labelOptions={{
tooltip,
isRequired: required,
showOptional,
}}
options={options!}
popupProps={popupProps}
/>
)}
/>
)
}
if (type === BaseFieldType.file) {
return (
<form.AppField
name={variable}
children={field => (
<field.FileUploaderField
label={label}
labelOptions={{
tooltip,
isRequired: required,
showOptional,
}}
fileConfig={{
allowed_file_extensions: allowedFileExtensions,
allowed_file_types: allowedFileTypes,
allowed_file_upload_methods: allowedFileUploadMethods,
number_limits: 1,
}}
/>
)}
/>
)
}
if (type === BaseFieldType.fileList) {
return (
<form.AppField
name={variable}
children={field => (
<field.FileUploaderField
label={label}
labelOptions={{
tooltip,
isRequired: required,
showOptional,
}}
fileConfig={{
allowed_file_extensions: allowedFileExtensions,
allowed_file_types: allowedFileTypes,
allowed_file_upload_methods: allowedFileUploadMethods,
number_limits: maxLength,
}}
/>
)}
/>
)
}
return <></>
},
})
export default BaseField

View File

@@ -0,0 +1,63 @@
import React, { useMemo } from 'react'
import { useAppForm } from '../..'
import BaseField from './field'
import type { BaseFormProps } from './types'
import { generateZodSchema } from './utils'
const BaseForm = ({
initialData,
configurations,
onSubmit,
CustomActions,
}: BaseFormProps) => {
const schema = useMemo(() => {
const schema = generateZodSchema(configurations)
return schema
}, [configurations])
const baseForm = useAppForm({
defaultValues: initialData,
validators: {
onChange: ({ value }) => {
const result = schema.safeParse(value)
if (!result.success) {
const issues = result.error.issues
const firstIssue = issues[0].message
return firstIssue
}
return undefined
},
},
onSubmit: ({ value }) => {
onSubmit(value)
},
})
return (
<form
className='w-full'
onSubmit={(e) => {
e.preventDefault()
e.stopPropagation()
baseForm.handleSubmit()
}}
>
<div className='flex flex-col gap-4 px-4 py-2'>
{configurations.map((config, index) => {
const FieldComponent = BaseField({
initialData,
config,
})
return <FieldComponent key={index} form={baseForm} />
})}
</div>
<baseForm.AppForm>
<baseForm.Actions
CustomActions={CustomActions}
/>
</baseForm.AppForm>
</form>
)
}
export default React.memo(BaseForm)

View File

@@ -0,0 +1,61 @@
import type { TransferMethod } from '@/types/app'
import type { Option } from '../../../select/pure'
import type { CustomActionsProps } from '../../components/form/actions'
export enum BaseFieldType {
textInput = 'text-input',
paragraph = 'paragraph',
numberInput = 'number-input',
checkbox = 'checkbox',
select = 'select',
file = 'file',
fileList = 'file-list',
}
export type ShowCondition = {
variable: string
value: any
}
export type NumberConfiguration = {
max?: number
min?: number
unit?: string
}
export type SelectConfiguration = {
options: Option[] // Options for select field
popupProps?: {
wrapperClassName?: string
className?: string
itemClassName?: string
title?: string
}
}
export type FileConfiguration = {
allowedFileTypes: string[]
allowedFileExtensions: string[]
allowedFileUploadMethods: TransferMethod[]
}
export type BaseConfiguration = {
label: string
variable: string // Variable name
maxLength?: number // Max length for text input
placeholder?: string
required: boolean
showOptional?: boolean // show optional label
showConditions: ShowCondition[] // Show this field only when all conditions are met
type: BaseFieldType
tooltip?: string // Tooltip for this field
} & NumberConfiguration
& Partial<SelectConfiguration>
& Partial<FileConfiguration>
export type BaseFormProps = {
initialData?: Record<string, any>
configurations: BaseConfiguration[]
CustomActions?: (props: CustomActionsProps) => React.ReactNode
onSubmit: (value: Record<string, any>) => void
}

View File

@@ -0,0 +1,57 @@
import type { ZodNumber, ZodSchema, ZodString } from 'zod'
import { z } from 'zod'
import { type BaseConfiguration, BaseFieldType } from './types'
export const generateZodSchema = (fields: BaseConfiguration[]) => {
const shape: Record<string, ZodSchema> = {}
fields.forEach((field) => {
let zodType
switch (field.type) {
case BaseFieldType.textInput:
case BaseFieldType.paragraph:
zodType = z.string()
break
case BaseFieldType.numberInput:
zodType = z.number()
break
case BaseFieldType.checkbox:
zodType = z.boolean()
break
case BaseFieldType.select:
zodType = z.string()
break
default:
zodType = z.any()
break
}
if (field.maxLength) {
if ([BaseFieldType.textInput, BaseFieldType.paragraph].includes(field.type))
zodType = (zodType as ZodString).max(field.maxLength, `${field.label} exceeds max length of ${field.maxLength}`)
}
if (field.min) {
if ([BaseFieldType.numberInput].includes(field.type))
zodType = (zodType as ZodNumber).min(field.min, `${field.label} must be at least ${field.min}`)
}
if (field.max) {
if ([BaseFieldType.numberInput].includes(field.type))
zodType = (zodType as ZodNumber).max(field.max, `${field.label} exceeds max value of ${field.max}`)
}
if (field.required) {
if ([BaseFieldType.textInput, BaseFieldType.paragraph].includes(field.type))
zodType = (zodType as ZodString).nonempty(`${field.label} is required`)
}
else {
zodType = zodType.optional().nullable()
}
shape[field.variable] = zodType
})
return z.object(shape)
}

View File

@@ -0,0 +1,35 @@
import { withForm } from '../..'
import { demoFormOpts } from './shared-options'
import { ContactMethods } from './types'
const ContactFields = withForm({
...demoFormOpts,
render: ({ form }) => {
return (
<div className='my-2'>
<h3 className='title-lg-bold text-text-primary'>Contacts</h3>
<div className='flex flex-col gap-4'>
<form.AppField
name='contact.email'
children={field => <field.TextField label='Email' />}
/>
<form.AppField
name='contact.phone'
children={field => <field.TextField label='Phone' />}
/>
<form.AppField
name='contact.preferredContactMethod'
children={field => (
<field.SelectField
label='Preferred Contact Method'
options={ContactMethods}
/>
)}
/>
</div>
</div>
)
},
})
export default ContactFields

View File

@@ -0,0 +1,68 @@
import { useStore } from '@tanstack/react-form'
import { useAppForm } from '../..'
import ContactFields from './contact-fields'
import { demoFormOpts } from './shared-options'
import { UserSchema } from './types'
const DemoForm = () => {
const form = useAppForm({
...demoFormOpts,
validators: {
onSubmit: ({ value }) => {
// Validate the entire form
const result = UserSchema.safeParse(value)
if (!result.success) {
const issues = result.error.issues
console.log('Validation errors:', issues)
return issues[0].message
}
return undefined
},
},
onSubmit: ({ value }) => {
console.log('Form submitted:', value)
},
})
const name = useStore(form.store, state => state.values.name)
return (
<form
className='flex w-[400px] flex-col gap-4'
onSubmit={(e) => {
e.preventDefault()
e.stopPropagation()
form.handleSubmit()
}}
>
<form.AppField
name='name'
children={field => (
<field.TextField label='Name' />
)}
/>
<form.AppField
name='surname'
children={field => (
<field.TextField label='Surname' />
)}
/>
<form.AppField
name='isAcceptingTerms'
children={field => (
<field.CheckboxField label='I accept the terms and conditions.' />
)}
/>
{
!!name && (
<ContactFields form={form} />
)
}
<form.AppForm>
<form.Actions />
</form.AppForm>
</form>
)
}
export default DemoForm

View File

@@ -0,0 +1,14 @@
import { formOptions } from '@tanstack/react-form'
export const demoFormOpts = formOptions({
defaultValues: {
name: '',
surname: '',
isAcceptingTerms: false,
contact: {
email: '',
phone: '',
preferredContactMethod: 'email',
},
},
})

View File

@@ -0,0 +1,34 @@
import { z } from 'zod'
const ContactMethod = z.union([
z.literal('email'),
z.literal('phone'),
z.literal('whatsapp'),
z.literal('sms'),
])
export const ContactMethods = ContactMethod.options.map(({ value }) => ({
value,
label: value.charAt(0).toUpperCase() + value.slice(1),
}))
export const UserSchema = z.object({
name: z
.string()
.regex(/^[A-Z]/, 'Name must start with a capital letter')
.min(3, 'Name must be at least 3 characters long'),
surname: z
.string()
.min(3, 'Surname must be at least 3 characters long')
.regex(/^[A-Z]/, 'Surname must start with a capital letter'),
isAcceptingTerms: z.boolean().refine(val => val, {
message: 'You must accept the terms and conditions',
}),
contact: z.object({
email: z.string().email('Invalid email address'),
phone: z.string().optional(),
preferredContactMethod: ContactMethod,
}),
})
export type User = z.infer<typeof UserSchema>

View File

@@ -0,0 +1,222 @@
import React from 'react'
import { type InputFieldConfiguration, InputFieldType } from './types'
import { withForm } from '../..'
import { useStore } from '@tanstack/react-form'
type InputFieldProps = {
initialData?: Record<string, any>
config: InputFieldConfiguration
}
const InputField = ({
initialData,
config,
}: InputFieldProps) => withForm({
defaultValues: initialData,
render: function Render({
form,
}) {
const {
type,
label,
placeholder,
variable,
tooltip,
showConditions,
max,
min,
required,
showOptional,
supportFile,
description,
options,
listeners,
popupProps,
} = config
const isAllConditionsMet = useStore(form.store, (state) => {
const fieldValues = state.values
return showConditions.every((condition) => {
const { variable, value } = condition
const fieldValue = fieldValues[variable as keyof typeof fieldValues]
return fieldValue === value
})
})
if (!isAllConditionsMet)
return <></>
if (type === InputFieldType.textInput) {
return (
<form.AppField
name={variable}
listeners={listeners}
children={field => (
<field.TextField
label={label}
labelOptions={{
tooltip,
isRequired: required,
showOptional,
}}
placeholder={placeholder}
/>
)}
/>
)
}
if (type === InputFieldType.numberInput) {
return (
<form.AppField
name={variable}
children={field => (
<field.NumberInputField
label={label}
labelOptions={{
tooltip,
isRequired: required,
showOptional,
}}
placeholder={placeholder}
max={max}
min={min}
/>
)}
/>
)
}
if (type === InputFieldType.numberSlider) {
return (
<form.AppField
name={variable}
children={field => (
<field.NumberSliderField
label={label}
labelOptions={{
tooltip,
isRequired: required,
showOptional,
}}
description={description}
max={max}
min={min}
/>
)}
/>
)
}
if (type === InputFieldType.checkbox) {
return (
<form.AppField
name={variable}
children={field => (
<field.CheckboxField
label={label}
/>
)}
/>
)
}
if (type === InputFieldType.select) {
return (
<form.AppField
name={variable}
children={field => (
<field.SelectField
label={label}
labelOptions={{
tooltip,
isRequired: required,
showOptional,
}}
options={options!}
popupProps={popupProps}
/>
)}
/>
)
}
if (type === InputFieldType.inputTypeSelect) {
return (
<form.AppField
name={variable}
listeners={listeners}
children={field => (
<field.InputTypeSelectField
label={label}
labelOptions={{
tooltip,
isRequired: required,
showOptional,
}}
supportFile={!!supportFile}
/>
)}
/>
)
}
if (type === InputFieldType.uploadMethod) {
return (
<form.AppField
name={variable}
children={field => (
<field.UploadMethodField
label={label}
labelOptions={{
tooltip,
isRequired: required,
showOptional,
}}
/>
)}
/>
)
}
if (type === InputFieldType.fileTypes) {
return (
<form.AppField
name={variable}
children={field => (
<field.FileTypesField
label={label}
labelOptions={{
tooltip,
isRequired: required,
showOptional,
}}
/>
)}
/>
)
}
if (type === InputFieldType.options) {
return (
<form.AppField
name={variable}
children={field => (
<field.OptionsField
label={label}
labelOptions={{
tooltip,
isRequired: required,
showOptional,
}}
/>
)}
/>
)
}
return <></>
},
})
export default InputField

View File

@@ -0,0 +1,39 @@
import type { DeepKeys, FieldListeners } from '@tanstack/react-form'
import type { NumberConfiguration, SelectConfiguration, ShowCondition } from '../base/types'
export enum InputFieldType {
textInput = 'textInput',
numberInput = 'numberInput',
numberSlider = 'numberSlider',
checkbox = 'checkbox',
options = 'options',
select = 'select',
inputTypeSelect = 'inputTypeSelect',
uploadMethod = 'uploadMethod',
fileTypes = 'fileTypes',
}
export type InputTypeSelectConfiguration = {
supportFile: boolean
}
export type NumberSliderConfiguration = {
description: string
max?: number
min?: number
}
export type InputFieldConfiguration = {
label: string
variable: string // Variable name
maxLength?: number // Max length for text input
placeholder?: string
required: boolean
showOptional?: boolean // show optional label
showConditions: ShowCondition[] // Show this field only when all conditions are met
type: InputFieldType
tooltip?: string // Tooltip for this field
listeners?: FieldListeners<Record<string, any>, DeepKeys<Record<string, any>>> // Listener for this field
} & NumberConfiguration & Partial<InputTypeSelectConfiguration>
& Partial<NumberSliderConfiguration>
& Partial<SelectConfiguration>

View File

@@ -0,0 +1,75 @@
import type { ZodSchema, ZodString } from 'zod'
import { z } from 'zod'
import { type InputFieldConfiguration, InputFieldType } from './types'
import { SupportedFileTypes, TransferMethod } from '@/app/components/rag-pipeline/components/panel/input-field/editor/form/schema'
export const generateZodSchema = (fields: InputFieldConfiguration[]) => {
const shape: Record<string, ZodSchema> = {}
fields.forEach((field) => {
let zodType
switch (field.type) {
case InputFieldType.textInput:
zodType = z.string()
break
case InputFieldType.numberInput:
zodType = z.number()
break
case InputFieldType.numberSlider:
zodType = z.number()
break
case InputFieldType.checkbox:
zodType = z.boolean()
break
case InputFieldType.options:
zodType = z.array(z.string())
break
case InputFieldType.select:
zodType = z.string()
break
case InputFieldType.fileTypes:
zodType = z.object({
allowedFileExtensions: z.string().optional(),
allowedFileTypes: z.array(SupportedFileTypes),
})
break
case InputFieldType.inputTypeSelect:
zodType = z.string()
break
case InputFieldType.uploadMethod:
zodType = z.array(TransferMethod)
break
default:
zodType = z.any()
break
}
if (field.maxLength) {
if ([InputFieldType.textInput].includes(field.type))
zodType = (zodType as ZodString).max(field.maxLength, `${field.label} exceeds max length of ${field.maxLength}`)
}
if (field.min) {
if ([InputFieldType.numberInput].includes(field.type))
zodType = (zodType as ZodString).min(field.min, `${field.label} must be at least ${field.min}`)
}
if (field.max) {
if ([InputFieldType.numberInput].includes(field.type))
zodType = (zodType as ZodString).max(field.max, `${field.label} exceeds max value of ${field.max}`)
}
if (field.required) {
if ([InputFieldType.textInput].includes(field.type))
zodType = (zodType as ZodString).nonempty(`${field.label} is required`)
}
else {
zodType = zodType.optional()
}
shape[field.variable] = zodType
})
return z.object(shape)
}

View File

@@ -0,0 +1,239 @@
import React from 'react'
import { type InputFieldConfiguration, InputFieldType } from './types'
import { withForm } from '../..'
import { useStore } from '@tanstack/react-form'
type InputFieldProps = {
initialData?: Record<string, any>
config: InputFieldConfiguration
}
const NodePanelField = ({
initialData,
config,
}: InputFieldProps) => withForm({
defaultValues: initialData,
render: function Render({
form,
}) {
const {
type,
label,
placeholder,
variable,
tooltip,
showConditions,
max,
min,
required,
showOptional,
supportFile,
description,
options,
listeners,
popupProps,
} = config
const isAllConditionsMet = useStore(form.store, (state) => {
const fieldValues = state.values
return showConditions.every((condition) => {
const { variable, value } = condition
const fieldValue = fieldValues[variable as keyof typeof fieldValues]
return fieldValue === value
})
})
if (!isAllConditionsMet)
return <></>
if (type === InputFieldType.textInput) {
return (
<form.AppField
name={variable}
children={field => (
<field.TextField
label={label}
labelOptions={{
tooltip,
isRequired: required,
showOptional,
}}
placeholder={placeholder}
/>
)}
/>
)
}
if (type === InputFieldType.numberInput) {
return (
<form.AppField
name={variable}
children={field => (
<field.NumberInputField
label={label}
labelOptions={{
tooltip,
isRequired: required,
showOptional,
}}
placeholder={placeholder}
max={max}
min={min}
/>
)}
/>
)
}
if (type === InputFieldType.numberSlider) {
return (
<form.AppField
name={variable}
children={field => (
<field.NumberSliderField
label={label}
labelOptions={{
tooltip,
isRequired: required,
showOptional,
}}
description={description}
max={max}
min={min}
/>
)}
/>
)
}
if (type === InputFieldType.checkbox) {
return (
<form.AppField
name={variable}
children={field => (
<field.CheckboxField
label={label}
/>
)}
/>
)
}
if (type === InputFieldType.select) {
return (
<form.AppField
name={variable}
children={field => (
<field.SelectField
label={label}
labelOptions={{
tooltip,
isRequired: required,
showOptional,
}}
options={options!}
popupProps={popupProps}
/>
)}
/>
)
}
if (type === InputFieldType.inputTypeSelect) {
return (
<form.AppField
name={variable}
listeners={listeners}
children={field => (
<field.InputTypeSelectField
label={label}
labelOptions={{
tooltip,
isRequired: required,
showOptional,
}}
supportFile={!!supportFile}
/>
)}
/>
)
}
if (type === InputFieldType.uploadMethod) {
return (
<form.AppField
name={variable}
children={field => (
<field.UploadMethodField
label={label}
labelOptions={{
tooltip,
isRequired: required,
showOptional,
}}
/>
)}
/>
)
}
if (type === InputFieldType.fileTypes) {
return (
<form.AppField
name={variable}
children={field => (
<field.FileTypesField
label={label}
labelOptions={{
tooltip,
isRequired: required,
showOptional,
}}
/>
)}
/>
)
}
if (type === InputFieldType.options) {
return (
<form.AppField
name={variable}
children={field => (
<field.OptionsField
label={label}
labelOptions={{
tooltip,
isRequired: required,
showOptional,
}}
/>
)}
/>
)
}
if (type === InputFieldType.variableOrConstant) {
return (
<form.AppField
name={variable}
children={field => (
<field.VariableOrConstantInputField
label={label}
labelOptions={{
tooltip,
isRequired: required,
showOptional,
}}
/>
)}
/>
)
}
return <></>
},
})
export default NodePanelField

View File

@@ -0,0 +1,40 @@
import type { DeepKeys, FieldListeners } from '@tanstack/react-form'
import type { NumberConfiguration, SelectConfiguration, ShowCondition } from '../base/types'
export enum InputFieldType {
textInput = 'textInput',
numberInput = 'numberInput',
numberSlider = 'numberSlider',
checkbox = 'checkbox',
options = 'options',
select = 'select',
inputTypeSelect = 'inputTypeSelect',
uploadMethod = 'uploadMethod',
fileTypes = 'fileTypes',
variableOrConstant = 'variableOrConstant',
}
export type InputTypeSelectConfiguration = {
supportFile: boolean
}
export type NumberSliderConfiguration = {
description: string
max?: number
min?: number
}
export type InputFieldConfiguration = {
label: string
variable: string // Variable name
maxLength?: number // Max length for text input
placeholder?: string
required: boolean
showOptional?: boolean // show optional label
showConditions: ShowCondition[] // Show this field only when all conditions are met
type: InputFieldType
tooltip?: string // Tooltip for this field
listeners?: FieldListeners<Record<string, any>, DeepKeys<Record<string, any>>> // Listener for this field
} & NumberConfiguration & Partial<InputTypeSelectConfiguration>
& Partial<NumberSliderConfiguration>
& Partial<SelectConfiguration>

View File

@@ -0,0 +1,3 @@
export * from './use-check-validated'
export * from './use-get-form-values'
export * from './use-get-validators'

View File

@@ -0,0 +1,48 @@
import { useCallback } from 'react'
import type { AnyFormApi } from '@tanstack/react-form'
import { useToastContext } from '@/app/components/base/toast'
import type { FormSchema } from '@/app/components/base/form/types'
export const useCheckValidated = (form: AnyFormApi, FormSchemas: FormSchema[]) => {
const { notify } = useToastContext()
const checkValidated = useCallback(() => {
const allError = form?.getAllErrors()
const values = form.state.values
if (allError) {
const fields = allError.fields
const errorArray = Object.keys(fields).reduce((acc: string[], key: string) => {
const currentSchema = FormSchemas.find(schema => schema.name === key)
const { show_on = [] } = currentSchema || {}
const showOnValues = show_on.reduce((acc, condition) => {
acc[condition.variable] = values[condition.variable]
return acc
}, {} as Record<string, any>)
const show = show_on?.every((condition) => {
const conditionValue = showOnValues[condition.variable]
return conditionValue === condition.value
})
const errors: any[] = show ? fields[key].errors : []
return [...acc, ...errors]
}, [] as string[])
if (errorArray.length) {
notify({
type: 'error',
message: errorArray[0],
})
return false
}
return true
}
return true
}, [form, notify, FormSchemas])
return {
checkValidated,
}
}

View File

@@ -0,0 +1,44 @@
import { useCallback } from 'react'
import type { AnyFormApi } from '@tanstack/react-form'
import { useCheckValidated } from './use-check-validated'
import type {
FormSchema,
GetValuesOptions,
} from '../types'
import { getTransformedValuesWhenSecretInputPristine } from '../utils'
export const useGetFormValues = (form: AnyFormApi, formSchemas: FormSchema[]) => {
const { checkValidated } = useCheckValidated(form, formSchemas)
const getFormValues = useCallback((
{
needCheckValidatedValues = true,
needTransformWhenSecretFieldIsPristine,
}: GetValuesOptions,
) => {
const values = form?.store.state.values || {}
if (!needCheckValidatedValues) {
return {
values,
isCheckValidated: true,
}
}
if (checkValidated()) {
return {
values: needTransformWhenSecretFieldIsPristine ? getTransformedValuesWhenSecretInputPristine(formSchemas, form) : values,
isCheckValidated: true,
}
}
else {
return {
values: {},
isCheckValidated: false,
}
}
}, [form, checkValidated, formSchemas])
return {
getFormValues,
}
}

View File

@@ -0,0 +1,54 @@
import {
isValidElement,
useCallback,
} from 'react'
import type { ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import type { FormSchema } from '../types'
import { useRenderI18nObject } from '@/hooks/use-i18n'
export const useGetValidators = () => {
const { t } = useTranslation()
const renderI18nObject = useRenderI18nObject()
const getLabel = useCallback((label: string | Record<string, string> | ReactNode) => {
if (isValidElement(label))
return ''
if (typeof label === 'string')
return label
if (typeof label === 'object' && label !== null)
return renderI18nObject(label as Record<string, string>)
}, [])
const getValidators = useCallback((formSchema: FormSchema) => {
const {
name,
validators,
required,
label,
} = formSchema
let mergedValidators = validators
const memorizedLabel = getLabel(label)
if (required && !validators) {
mergedValidators = {
onMount: ({ value }: any) => {
if (!value)
return t('common.errorMsg.fieldRequired', { field: memorizedLabel || name })
},
onChange: ({ value }: any) => {
if (!value)
return t('common.errorMsg.fieldRequired', { field: memorizedLabel || name })
},
onBlur: ({ value }: any) => {
if (!value)
return t('common.errorMsg.fieldRequired', { field: memorizedLabel })
},
}
}
return mergedValidators
}, [t, getLabel])
return {
getValidators,
}
}

View File

@@ -0,0 +1,559 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useMemo, useState } from 'react'
import { useStore } from '@tanstack/react-form'
import ContactFields from './form-scenarios/demo/contact-fields'
import { demoFormOpts } from './form-scenarios/demo/shared-options'
import { ContactMethods, UserSchema } from './form-scenarios/demo/types'
import BaseForm from './components/base/base-form'
import type { FormSchema } from './types'
import { FormTypeEnum } from './types'
import { type FormStoryRender, FormStoryWrapper } from '../../../../.storybook/utils/form-story-wrapper'
import Button from '../button'
import { TransferMethod } from '@/types/app'
import { PreviewMode } from '@/app/components/base/features/types'
const FormStoryHost = () => null
const meta = {
title: 'Base/Data Entry/AppForm',
component: FormStoryHost,
parameters: {
layout: 'fullscreen',
docs: {
description: {
component: 'Helper utilities built on top of `@tanstack/react-form` that power form rendering across Dify. These stories demonstrate the `useAppForm` hook, field primitives, conditional visibility, and custom actions.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof FormStoryHost>
export default meta
type Story = StoryObj<typeof meta>
type AppFormInstance = Parameters<FormStoryRender>[0]
type ContactFieldsProps = React.ComponentProps<typeof ContactFields>
type ContactFieldsFormApi = ContactFieldsProps['form']
type PlaygroundFormFieldsProps = {
form: AppFormInstance
status: string
}
const PlaygroundFormFields = ({ form, status }: PlaygroundFormFieldsProps) => {
type PlaygroundFormValues = typeof demoFormOpts.defaultValues
const name = useStore(form.store, state => (state.values as PlaygroundFormValues).name)
const contactFormApi = form as ContactFieldsFormApi
return (
<form
className="flex w-full max-w-xl flex-col gap-4"
onSubmit={(event) => {
event.preventDefault()
event.stopPropagation()
form.handleSubmit()
}}
>
<form.AppField
name="name"
children={field => (
<field.TextField
label="Name"
placeholder="Start with a capital letter"
/>
)}
/>
<form.AppField
name="surname"
children={field => (
<field.TextField
label="Surname"
placeholder="Surname must be at least 3 characters"
/>
)}
/>
<form.AppField
name="isAcceptingTerms"
children={field => (
<field.CheckboxField
label="I accept the terms and conditions"
/>
)}
/>
{!!name && <ContactFields form={contactFormApi} />}
<form.AppForm>
<form.Actions />
</form.AppForm>
<p className="text-xs text-text-tertiary">{status}</p>
</form>
)
}
const FormPlayground = () => {
const [status, setStatus] = useState('Fill in the form and submit to see results.')
return (
<FormStoryWrapper
title="Customer onboarding form"
subtitle="Validates with zod and conditionally reveals contact preferences."
options={{
...demoFormOpts,
validators: {
onSubmit: ({ value: formValue }) => {
const result = UserSchema.safeParse(formValue as typeof demoFormOpts.defaultValues)
if (!result.success)
return result.error.issues[0].message
return undefined
},
},
onSubmit: () => {
setStatus('Successfully saved profile.')
},
}}
>
{form => <PlaygroundFormFields form={form} status={status} />}
</FormStoryWrapper>
)
}
const mockFileUploadConfig = {
enabled: true,
allowed_file_extensions: ['pdf', 'png'],
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
number_limits: 3,
preview_config: {
mode: PreviewMode.CurrentPage,
file_type_list: ['pdf', 'png'],
},
}
const mockFieldDefaults = {
headline: 'Dify App',
description: 'Streamline your AI workflows with configurable building blocks.',
category: 'workbench',
allowNotifications: true,
dailyLimit: 40,
attachment: [],
}
const FieldGallery = () => {
const selectOptions = useMemo(() => [
{ value: 'workbench', label: 'Workbench' },
{ value: 'playground', label: 'Playground' },
{ value: 'production', label: 'Production' },
], [])
return (
<FormStoryWrapper
title="Field gallery"
subtitle="Preview the most common field primitives exposed through `form.AppField` helpers."
options={{
defaultValues: mockFieldDefaults,
}}
>
{form => (
<form
className="grid w-full max-w-4xl grid-cols-1 gap-4 lg:grid-cols-2"
onSubmit={(event) => {
event.preventDefault()
event.stopPropagation()
form.handleSubmit()
}}
>
<form.AppField
name="headline"
children={field => (
<field.TextField
label="Headline"
placeholder="Name your experience"
/>
)}
/>
<form.AppField
name="description"
children={field => (
<field.TextAreaField
label="Description"
placeholder="Describe what this configuration does"
/>
)}
/>
<form.AppField
name="category"
children={field => (
<field.SelectField
label="Category"
options={selectOptions}
/>
)}
/>
<form.AppField
name="allowNotifications"
children={field => (
<field.CheckboxField label="Enable usage notifications" />
)}
/>
<form.AppField
name="dailyLimit"
children={field => (
<field.NumberSliderField
label="Daily session limit"
description="Control the maximum number of runs per user each day."
min={10}
max={100}
/>
)}
/>
<form.AppField
name="attachment"
children={field => (
<field.FileUploaderField
label="Reference materials"
fileConfig={mockFileUploadConfig}
/>
)}
/>
<div className="lg:col-span-2">
<form.AppForm>
<form.Actions />
</form.AppForm>
</div>
</form>
)}
</FormStoryWrapper>
)
}
const conditionalSchemas: FormSchema[] = [
{
type: FormTypeEnum.select,
name: 'channel',
label: 'Preferred channel',
required: true,
default: 'email',
options: ContactMethods,
},
{
type: FormTypeEnum.textInput,
name: 'contactEmail',
label: 'Email address',
required: true,
placeholder: 'user@example.com',
show_on: [{ variable: 'channel', value: 'email' }],
},
{
type: FormTypeEnum.textInput,
name: 'contactPhone',
label: 'Phone number',
required: true,
placeholder: '+1 555 123 4567',
show_on: [{ variable: 'channel', value: 'phone' }],
},
{
type: FormTypeEnum.boolean,
name: 'optIn',
label: 'Opt in to marketing messages',
required: false,
},
]
const ConditionalFieldsStory = () => {
const [values, setValues] = useState<Record<string, unknown>>({
channel: 'email',
optIn: false,
})
return (
<div className="flex flex-col gap-6 px-6 md:flex-row md:px-10">
<div className="flex-1 rounded-xl border border-divider-subtle bg-components-panel-bg p-5 shadow-sm">
<BaseForm
formSchemas={conditionalSchemas}
defaultValues={values}
formClassName="flex flex-col gap-4"
onChange={(field, value) => {
setValues(prev => ({
...prev,
[field]: value,
}))
}}
/>
</div>
<aside className="w-full max-w-sm rounded-xl border border-divider-subtle bg-components-panel-bg p-4 text-xs text-text-secondary shadow-sm">
<h3 className="text-sm font-semibold text-text-primary">Live values</h3>
<p className="mb-2 text-[11px] text-text-tertiary">`show_on` rules hide or reveal inputs without losing track of the form state.</p>
<pre className="max-h-48 overflow-auto rounded-md bg-background-default-subtle p-3 font-mono text-[11px] leading-tight text-text-primary">
{JSON.stringify(values, null, 2)}
</pre>
</aside>
</div>
)
}
const CustomActionsStory = () => {
return (
<FormStoryWrapper
title="Custom footer actions"
subtitle="Override the default submit button to add reset or secondary operations."
options={{
defaultValues: {
datasetName: 'Support FAQ',
datasetDescription: 'Knowledge base snippets sourced from Zendesk exports.',
},
validators: {
onChange: ({ value }) => {
const nextValues = value as { datasetName?: string }
if (!nextValues.datasetName || nextValues.datasetName.length < 3)
return 'Dataset name must contain at least 3 characters.'
return undefined
},
},
}}
>
{form => (
<form
className="flex w-full max-w-xl flex-col gap-4"
onSubmit={(event) => {
event.preventDefault()
event.stopPropagation()
form.handleSubmit()
}}
>
<form.AppField
name="datasetName"
children={field => (
<field.TextField
label="Dataset name"
placeholder="Support knowledge base"
/>
)}
/>
<form.AppField
name="datasetDescription"
children={field => (
<field.TextAreaField
label="Description"
placeholder="Add a helpful summary for collaborators"
/>
)}
/>
<form.AppForm>
<form.Actions
CustomActions={({ form: appForm, isSubmitting, canSubmit }) => (
<div className="flex items-center gap-2">
<Button
variant="ghost"
onClick={() => appForm.reset()}
disabled={isSubmitting}
>
Reset
</Button>
<Button
variant="tertiary"
onClick={() => {
appForm.handleSubmit()
}}
disabled={!canSubmit}
loading={isSubmitting}
>
Save draft
</Button>
<Button
variant="primary"
onClick={() => appForm.handleSubmit()}
disabled={!canSubmit}
loading={isSubmitting}
>
Publish
</Button>
</div>
)}
/>
</form.AppForm>
</form>
)}
</FormStoryWrapper>
)
}
export const Playground: Story = {
render: () => <FormPlayground />,
parameters: {
docs: {
source: {
language: 'tsx',
code: `
const form = useAppForm({
...demoFormOpts,
validators: {
onSubmit: ({ value }) => UserSchema.safeParse(value).success ? undefined : 'Validation failed',
},
onSubmit: ({ value }) => {
setStatus(\`Successfully saved profile for \${value.name}\`)
},
})
return (
<form onSubmit={handleSubmit}>
<form.AppField name="name">
{field => <field.TextField label="Name" placeholder="Start with a capital letter" />}
</form.AppField>
<form.AppField name="surname">
{field => <field.TextField label="Surname" />}
</form.AppField>
<form.AppField name="isAcceptingTerms">
{field => <field.CheckboxField label="I accept the terms and conditions" />}
</form.AppField>
{!!form.store.state.values.name && <ContactFields form={form} />}
<form.AppForm>
<form.Actions />
</form.AppForm>
</form>
)
`.trim(),
},
},
},
}
export const FieldExplorer: Story = {
render: () => <FieldGallery />,
parameters: {
nextjs: {
appDirectory: true,
navigation: {
pathname: '/apps/demo-app/form',
params: { appId: 'demo-app' },
},
},
docs: {
source: {
language: 'tsx',
code: `
const form = useAppForm({
defaultValues: {
headline: 'Dify App',
description: 'Streamline your AI workflows',
category: 'workbench',
allowNotifications: true,
dailyLimit: 40,
attachment: [],
},
})
return (
<form className="grid grid-cols-1 gap-4 lg:grid-cols-2" onSubmit={handleSubmit}>
<form.AppField name="headline">
{field => <field.TextField label="Headline" />}
</form.AppField>
<form.AppField name="description">
{field => <field.TextAreaField label="Description" />}
</form.AppField>
<form.AppField name="category">
{field => <field.SelectField label="Category" options={selectOptions} />}
</form.AppField>
<form.AppField name="allowNotifications">
{field => <field.CheckboxField label="Enable usage notifications" />}
</form.AppField>
<form.AppField name="dailyLimit">
{field => <field.NumberSliderField label="Daily session limit" min={10} max={100} step={10} />}
</form.AppField>
<form.AppField name="attachment">
{field => <field.FileUploaderField label="Reference materials" fileConfig={mockFileUploadConfig} />}
</form.AppField>
<form.AppForm>
<form.Actions />
</form.AppForm>
</form>
)
`.trim(),
},
},
},
}
export const ConditionalVisibility: Story = {
render: () => <ConditionalFieldsStory />,
parameters: {
docs: {
description: {
story: 'Demonstrates schema-driven visibility using `show_on` conditions rendered through the reusable `BaseForm` component.',
},
source: {
language: 'tsx',
code: `
const conditionalSchemas: FormSchema[] = [
{ type: FormTypeEnum.select, name: 'channel', label: 'Preferred channel', options: ContactMethods },
{ type: FormTypeEnum.textInput, name: 'contactEmail', label: 'Email', show_on: [{ variable: 'channel', value: 'email' }] },
{ type: FormTypeEnum.textInput, name: 'contactPhone', label: 'Phone', show_on: [{ variable: 'channel', value: 'phone' }] },
{ type: FormTypeEnum.boolean, name: 'optIn', label: 'Opt in to marketing messages' },
]
return (
<BaseForm
formSchemas={conditionalSchemas}
defaultValues={{ channel: 'email', optIn: false }}
formClassName="flex flex-col gap-4"
onChange={(field, value) => setValues(prev => ({ ...prev, [field]: value }))}
/>
)
`.trim(),
},
},
},
}
export const CustomActions: Story = {
render: () => <CustomActionsStory />,
parameters: {
docs: {
description: {
story: 'Shows how to replace the default submit button with a fully custom footer leveraging contextual form state.',
},
source: {
language: 'tsx',
code: `
const form = useAppForm({
defaultValues: {
datasetName: 'Support FAQ',
datasetDescription: 'Knowledge base snippets sourced from Zendesk exports.',
},
validators: {
onChange: ({ value }) => value.datasetName?.length >= 3 ? undefined : 'Dataset name must contain at least 3 characters.',
},
})
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<form.AppField name="datasetName">
{field => <field.TextField label="Dataset name" />}
</form.AppField>
<form.AppField name="datasetDescription">
{field => <field.TextAreaField label="Description" />}
</form.AppField>
<form.AppForm>
<form.Actions
CustomActions={({ form: appForm, isSubmitting, canSubmit }) => (
<div className="flex items-center gap-2">
<Button variant="ghost" onClick={() => appForm.reset()} disabled={isSubmitting}>
Reset
</Button>
<Button variant="tertiary" onClick={() => appForm.handleSubmit()} disabled={!canSubmit} loading={isSubmitting}>
Save draft
</Button>
<Button variant="primary" onClick={() => appForm.handleSubmit()} disabled={!canSubmit} loading={isSubmitting}>
Publish
</Button>
</div>
)}
/>
</form.AppForm>
</form>
)
`.trim(),
},
},
},
}

View File

@@ -0,0 +1,43 @@
import { createFormHook, createFormHookContexts } from '@tanstack/react-form'
import TextField from './components/field/text'
import NumberInputField from './components/field/number-input'
import CheckboxField from './components/field/checkbox'
import SelectField from './components/field/select'
import CustomSelectField from './components/field/custom-select'
import OptionsField from './components/field/options'
import Actions from './components/form/actions'
import InputTypeSelectField from './components/field/input-type-select'
import FileTypesField from './components/field/file-types'
import UploadMethodField from './components/field/upload-method'
import NumberSliderField from './components/field/number-slider'
import VariableOrConstantInputField from './components/field/variable-selector'
import TextAreaField from './components/field/text-area'
import FileUploaderField from './components/field/file-uploader'
export const { fieldContext, useFieldContext, formContext, useFormContext }
= createFormHookContexts()
export const { useAppForm, withForm } = createFormHook({
fieldComponents: {
TextField,
TextAreaField,
NumberInputField,
CheckboxField,
SelectField,
CustomSelectField,
OptionsField,
InputTypeSelectField,
FileTypesField,
UploadMethodField,
NumberSliderField,
VariableOrConstantInputField,
FileUploaderField,
},
formComponents: {
Actions,
},
fieldContext,
formContext,
})
export type FormType = ReturnType<typeof useFormContext>

View File

@@ -0,0 +1,112 @@
import type {
ForwardedRef,
ReactNode,
} from 'react'
import type {
AnyFormApi,
FieldValidators,
} from '@tanstack/react-form'
import type { Locale } from '@/i18n-config'
export type TypeWithI18N<T = string> = {
en_US: T
zh_Hans: T
[key: string]: T
}
export type FormShowOnObject = {
variable: string
value: string
}
export enum FormTypeEnum {
textInput = 'text-input',
textNumber = 'number-input',
secretInput = 'secret-input',
select = 'select',
radio = 'radio',
checkbox = 'checkbox',
files = 'files',
file = 'file',
modelSelector = 'model-selector',
toolSelector = 'tool-selector',
multiToolSelector = 'array[tools]',
appSelector = 'app-selector',
dynamicSelect = 'dynamic-select',
boolean = 'boolean',
}
export type FormOption = {
label: string | TypeWithI18N | Record<Locale, string>
value: string
show_on?: FormShowOnObject[]
icon?: string
}
export type AnyValidators = FieldValidators<any, any, any, any, any, any, any, any, any, any, any, any>
export enum FormItemValidateStatusEnum {
Success = 'success',
Warning = 'warning',
Error = 'error',
Validating = 'validating',
}
export type FormSchema = {
type: FormTypeEnum
name: string
label: string | ReactNode | TypeWithI18N | Record<Locale, string>
required: boolean
multiple?: boolean
default?: any
description?: string | TypeWithI18N | Record<Locale, string>
tooltip?: string | TypeWithI18N | Record<Locale, string>
show_on?: FormShowOnObject[]
url?: string
scope?: string
help?: string | TypeWithI18N | Record<Locale, string>
placeholder?: string | TypeWithI18N | Record<Locale, string>
options?: FormOption[]
labelClassName?: string
fieldClassName?: string
validators?: AnyValidators
showRadioUI?: boolean
disabled?: boolean
showCopy?: boolean
dynamicSelectParams?: {
plugin_id: string
provider: string
action: string
parameter: string
credential_id: string
}
}
export type FormValues = Record<string, any>
export type GetValuesOptions = {
needTransformWhenSecretFieldIsPristine?: boolean
needCheckValidatedValues?: boolean
}
export type FieldState = {
validateStatus?: FormItemValidateStatusEnum
help?: string | ReactNode
errors?: string[]
warnings?: string[]
}
export type SetFieldsParam = {
name: string
value?: any
} & FieldState
export type FormRefObject = {
getForm: () => AnyFormApi
getFormValues: (obj: GetValuesOptions) => {
values: Record<string, any>
isCheckValidated: boolean
}
setFields: (fields: SetFieldsParam[]) => void
}
export type FormRef = ForwardedRef<FormRefObject>

View File

@@ -0,0 +1 @@
export * from './secret-input'

View File

@@ -0,0 +1,29 @@
import type { AnyFormApi } from '@tanstack/react-form'
import type { FormSchema } from '@/app/components/base/form/types'
import { FormTypeEnum } from '@/app/components/base/form/types'
export const transformFormSchemasSecretInput = (isPristineSecretInputNames: string[], values: Record<string, any>) => {
const transformedValues: Record<string, any> = { ...values }
isPristineSecretInputNames.forEach((name) => {
if (transformedValues[name])
transformedValues[name] = '[__HIDDEN__]'
})
return transformedValues
}
export const getTransformedValuesWhenSecretInputPristine = (formSchemas: FormSchema[], form: AnyFormApi) => {
const values = form?.store.state.values || {}
const isPristineSecretInputNames: string[] = []
for (let i = 0; i < formSchemas.length; i++) {
const schema = formSchemas[i]
if (schema.type === FormTypeEnum.secretInput) {
const fieldMeta = form?.getFieldMeta(schema.name)
if (fieldMeta?.isPristine)
isPristineSecretInputNames.push(schema.name)
}
}
return transformFormSchemasSecretInput(isPristineSecretInputNames, values)
}