Pārlūkot izejas kodu

feat: Introduce new form field components and enhance existing ones with label options

tags/2.0.0-beta.1
twwu pirms 6 mēnešiem
vecāks
revīzija
d12e9b81e3

+ 5
- 21
web/app/components/base/form/components/field/custom-select.tsx Parādīt failu

@@ -2,52 +2,36 @@ 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'
import { useCallback } from 'react'

type CustomSelectFieldProps<T extends Option> = {
label: string
labelOptions?: Omit<LabelProps, 'htmlFor' | 'label'>
options: T[]
onChange?: (value: string) => void
isRequired?: boolean
showOptional?: boolean
tooltip?: string
className?: string
labelClassName?: string
} & Omit<CustomSelectProps<T>, 'options' | 'value' | 'onChange'>

const CustomSelectField = <T extends Option>({
label,
labelOptions,
options,
onChange,
isRequired,
showOptional,
tooltip,
className,
labelClassName,
...selectProps
}: CustomSelectFieldProps<T>) => {
const field = useFieldContext<string>()

const handleChange = useCallback((value: string) => {
field.handleChange(value)
onChange?.(value)
}, [field, onChange])

return (
<div className={cn('flex flex-col gap-y-0.5', className)}>
<Label
htmlFor={field.name}
label={label}
isRequired={isRequired}
showOptional={showOptional}
tooltip={tooltip}
className={labelClassName}
{...(labelOptions ?? {})}
/>
<CustomSelect<T>
value={field.state.value}
options={options}
onChange={handleChange}
onChange={value => field.handleChange(value)}
{...selectProps}
/>
</div>

+ 83
- 0
web/app/components/base/form/components/field/file-types.tsx Parādīt failu

@@ -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

+ 51
- 0
web/app/components/base/form/components/field/input-type-select/hooks.tsx Parādīt failu

@@ -0,0 +1,51 @@
import { InputVarType } from '@/app/components/workflow/types'
import { InputType } from './types'
import { useTranslation } from 'react-i18next'
import {
RiAlignLeft,
RiCheckboxLine,
RiFileCopy2Line,
RiFileTextLine,
RiHashtag,
RiListCheck3,
RiTextSnippet,
} from '@remixicon/react'

const i18nFileTypeMap: Record<string, string> = {
'file': 'single-file',
'file-list': 'multi-files',
}

const INPUT_TYPE_ICON = {
[InputVarType.textInput]: RiTextSnippet,
[InputVarType.paragraph]: RiAlignLeft,
[InputVarType.number]: RiHashtag,
[InputVarType.select]: RiListCheck3,
[InputVarType.checkbox]: RiCheckboxLine,
[InputVarType.singleFile]: RiFileTextLine,
[InputVarType.multiFiles]: RiFileCopy2Line,
}

const DATA_TYPE = {
[InputVarType.textInput]: 'string',
[InputVarType.paragraph]: 'string',
[InputVarType.number]: 'number',
[InputVarType.select]: 'string',
[InputVarType.checkbox]: 'boolean',
[InputVarType.singleFile]: 'file',
[InputVarType.multiFiles]: 'array[file]',
}

export const useInputTypeOptions = (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}`),
Icon: INPUT_TYPE_ICON[value],
type: DATA_TYPE[value],
}
})
}

+ 64
- 0
web/app/components/base/form/components/field/input-type-select/index.tsx Parādīt failu

@@ -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 } from './types'
import { useInputTypeOptions } from './hooks'
import Option from './option'

type InputTypeSelectFieldProps = {
label: string
labeOptions?: Omit<LabelProps, 'htmlFor' | 'label'>
supportFile: boolean
className?: string
} & Omit<CustomSelectProps<FileTypeSelectOption>, 'options' | 'value' | 'onChange' | 'CustomTrigger' | 'CustomOption'>

const InputTypeSelectField = ({
label,
labeOptions,
supportFile,
className,
...customSelectProps
}: InputTypeSelectFieldProps) => {
const field = useFieldContext<string>()
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}
{...(labeOptions ?? {})}
/>
<CustomSelect<FileTypeSelectOption>
value={field.state.value}
options={inputTypeOptions}
onChange={value => field.handleChange(value)}
triggerProps={{
className: 'gap-x-0.5',
}}
popupProps={{
className: 'w-[368px]',
wrapperClassName: 'z-40',
itemClassName: 'gap-x-1',
}}
CustomTrigger={renderTrigger}
CustomOption={renderOption}
{...customSelectProps}
/>
</div>
)
}

export default InputTypeSelectField

+ 21
- 0
web/app/components/base/form/components/field/input-type-select/option.tsx Parādīt failu

@@ -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)

+ 42
- 0
web/app/components/base/form/components/field/input-type-select/trigger.tsx Parādīt failu

@@ -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)

+ 19
- 0
web/app/components/base/form/components/field/input-type-select/types.tsx Parādīt failu

@@ -0,0 +1,19 @@
import type { RemixiconComponentType } from '@remixicon/react'
import { z } from 'zod'

export const InputType = z.enum([
'text-input',
'paragraph',
'number',
'select',
'checkbox',
'file',
'file-list',
])

export type FileTypeSelectOption = {
value: string
label: string
Icon: RemixiconComponentType
type: string
}

+ 4
- 12
web/app/components/base/form/components/field/number-input.tsx Parādīt failu

@@ -1,5 +1,6 @@
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'
@@ -7,20 +8,14 @@ import { InputNumber } from '../../../input-number'

type TextFieldProps = {
label: string
isRequired?: boolean
showOptional?: boolean
tooltip?: string
labelOptions?: Omit<LabelProps, 'htmlFor' | 'label'>
className?: string
labelClassName?: string
} & Omit<InputNumberProps, 'id' | 'value' | 'onChange' | 'onBlur'>

const NumberInputField = ({
label,
isRequired,
showOptional,
tooltip,
labelOptions,
className,
labelClassName,
...inputProps
}: TextFieldProps) => {
const field = useFieldContext<number | undefined>()
@@ -30,10 +25,7 @@ const NumberInputField = ({
<Label
htmlFor={field.name}
label={label}
isRequired={isRequired}
showOptional={showOptional}
tooltip={tooltip}
className={labelClassName}
{...(labelOptions ?? {})}
/>
<InputNumber
id={field.name}

+ 47
- 0
web/app/components/base/form/components/field/number-slider.tsx Parādīt failu

@@ -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

+ 4
- 3
web/app/components/base/form/components/field/options.tsx Parādīt failu

@@ -1,18 +1,19 @@
import cn from '@/utils/classnames'
import { useFieldContext } from '../..'
import type { LabelProps } from '../label'
import Label from '../label'
import ConfigSelect from '@/app/components/app/configuration/config-var/config-select'

type OptionsFieldProps = {
label: string;
labelOptions?: Omit<LabelProps, 'htmlFor' | 'label'>
className?: string;
labelClassName?: string;
}

const OptionsField = ({
label,
className,
labelClassName,
labelOptions,
}: OptionsFieldProps) => {
const field = useFieldContext<string[]>()

@@ -21,7 +22,7 @@ const OptionsField = ({
<Label
htmlFor={field.name}
label={label}
className={labelClassName}
{...(labelOptions ?? {})}
/>
<ConfigSelect
options={field.state.value}

+ 5
- 19
web/app/components/base/form/components/field/select.tsx Parādīt failu

@@ -2,52 +2,38 @@ 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'
import { useCallback } from 'react'

type SelectFieldProps = {
label: string
labelOptions?: Omit<LabelProps, 'htmlFor' | 'label'>
options: Option[]
onChange?: (value: string) => void
isRequired?: boolean
showOptional?: boolean
tooltip?: string
className?: string
labelClassName?: string
} & Omit<PureSelectProps, 'options' | 'value' | 'onChange'>

const SelectField = ({
label,
labelOptions,
options,
onChange,
isRequired,
showOptional,
tooltip,
className,
labelClassName,
...selectProps
}: SelectFieldProps) => {
const field = useFieldContext<string>()

const handleChange = useCallback((value: string) => {
field.handleChange(value)
onChange?.(value)
}, [field, onChange])

return (
<div className={cn('flex flex-col gap-y-0.5', className)}>
<Label
htmlFor={field.name}
label={label}
isRequired={isRequired}
showOptional={showOptional}
tooltip={tooltip}
className={labelClassName}
{...(labelOptions ?? {})}
/>
<PureSelect
value={field.state.value}
options={options}
onChange={handleChange}
onChange={value => field.handleChange(value)}
{...selectProps}
/>
</div>

+ 4
- 12
web/app/components/base/form/components/field/text.tsx Parādīt failu

@@ -1,25 +1,20 @@
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
isRequired?: boolean
showOptional?: boolean
tooltip?: string
labelOptions?: Omit<LabelProps, 'htmlFor' | 'label'>
className?: string
labelClassName?: string
} & Omit<InputProps, 'className' | 'onChange' | 'onBlur' | 'value' | 'id'>

const TextField = ({
label,
isRequired,
showOptional,
tooltip,
labelOptions,
className,
labelClassName,
...inputProps
}: TextFieldProps) => {
const field = useFieldContext<string>()
@@ -29,10 +24,7 @@ const TextField = ({
<Label
htmlFor={field.name}
label={label}
isRequired={isRequired}
showOptional={showOptional}
tooltip={tooltip}
className={labelClassName}
{...(labelOptions ?? {})}
/>
<Input
id={field.name}

+ 58
- 0
web/app/components/base/form/components/field/upload-method.tsx Parādīt failu

@@ -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

+ 21
- 8
web/app/components/base/form/form-scenarios/base/field.tsx Parādīt failu

@@ -3,15 +3,15 @@ import { type BaseConfiguration, BaseVarType } from './types'
import { withForm } from '../..'
import { useStore } from '@tanstack/react-form'

type FieldProps<T> = {
type BaseFieldProps<T> = {
initialData?: T
config: BaseConfiguration<T>
}

const Field = <T,>({
const BaseField = <T,>({
initialData,
config,
}: FieldProps<T>) => withForm({
}: BaseFieldProps<T>) => withForm({
defaultValues: initialData,
props: {
config,
@@ -20,7 +20,7 @@ const Field = <T,>({
form,
config,
}) {
const { type, label, placeholder, variable, tooltip, showConditions, max, min, options } = config
const { type, label, placeholder, variable, tooltip, showConditions, max, min, options, required, showOptional } = config

const fieldValues = useStore(form.store, state => state.values)

@@ -43,7 +43,11 @@ const Field = <T,>({
children={field => (
<field.TextField
label={label}
tooltip={tooltip}
labelOptions={{
tooltip,
isRequired: required,
showOptional,
}}
placeholder={placeholder}
/>
)}
@@ -58,7 +62,11 @@ const Field = <T,>({
children={field => (
<field.NumberInputField
label={label}
tooltip={tooltip}
labelOptions={{
tooltip,
isRequired: required,
showOptional,
}}
placeholder={placeholder}
max={max}
min={min}
@@ -87,8 +95,13 @@ const Field = <T,>({
name={variable}
children={field => (
<field.SelectField
options={options!}
label={label}
labelOptions={{
tooltip,
isRequired: required,
showOptional,
}}
options={options!}
/>
)}
/>
@@ -99,4 +112,4 @@ const Field = <T,>({
},
})

export default Field
export default BaseField

+ 2
- 2
web/app/components/base/form/form-scenarios/base/index.tsx Parādīt failu

@@ -1,6 +1,6 @@
import React from 'react'
import { useAppForm } from '../..'
import Field from './field'
import BaseField from './field'
import type { BaseFormProps } from './types'

const BaseForm = <T,>({
@@ -32,7 +32,7 @@ const BaseForm = <T,>({
>
<div className='flex flex-col gap-4 px-4 py-2'>
{configurations.map((config, index) => {
const FieldComponent = Field<T>({
const FieldComponent = BaseField<T>({
initialData,
config,
})

+ 1
- 0
web/app/components/base/form/form-scenarios/base/types.ts Parādīt failu

@@ -29,6 +29,7 @@ export type BaseConfiguration<T> = {
maxLength?: number // Max length for text input
placeholder?: string
required: boolean
showOptional?: boolean // show optional label
showConditions: ShowCondition<T>[] // Show this field only when all conditions are met
type: BaseVarType
tooltip?: string // Tooltip for this field

+ 2
- 2
web/app/components/base/select/custom.tsx Parādīt failu

@@ -39,8 +39,8 @@ export type CustomSelectProps<T extends Option> = {
itemClassName?: string
title?: string
},
CustomTrigger?: (option: T | undefined, open: boolean) => React.ReactNode
CustomOption?: (option: T, selected: boolean) => React.ReactNode
CustomTrigger?: (option: T | undefined, open: boolean) => React.JSX.Element
CustomOption?: (option: T, selected: boolean) => React.JSX.Element
}
const CustomSelect = <T extends Option>({
options,

+ 2
- 2
web/app/components/workflow/nodes/_base/components/input-number-with-slider.tsx Parādīt failu

@@ -3,7 +3,7 @@ import type { FC } from 'react'
import React, { useCallback } from 'react'
import Slider from '@/app/components/base/slider'

type Props = {
export type InputNumberWithSliderProps = {
value: number
defaultValue?: number
min?: number
@@ -12,7 +12,7 @@ type Props = {
onChange: (value: number) => void
}

const InputNumberWithSlider: FC<Props> = ({
const InputNumberWithSlider: FC<InputNumberWithSliderProps> = ({
value,
defaultValue = 0,
min,

Notiek ielāde…
Atcelt
Saglabāt