feat: Introduce new form field components and enhance existing ones with label options
parent
b1fbaaed95
commit
d12e9b81e3
@ -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
|
||||||
@ -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],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -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
|
||||||
@ -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)
|
||||||
@ -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)
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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
|
||||||
@ -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
|
||||||
Loading…
Reference in New Issue