feat: add CustomSelectField component and integrate with input field form

This commit is contained in:
twwu 2025-04-23 22:16:19 +08:00
parent 8d9c252811
commit 93f83086c1
6 changed files with 307 additions and 5 deletions

View File

@ -0,0 +1,57 @@
import cn from '@/utils/classnames'
import { useFieldContext } from '../..'
import type { CustomSelectProps, Option } from '../../../select/custom'
import CustomSelect from '../../../select/custom'
import Label from '../label'
import { useCallback } from 'react'
type CustomSelectFieldProps<T extends Option> = {
label: string
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,
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}
/>
<CustomSelect<T>
value={field.state.value}
options={options}
onChange={handleChange}
{...selectProps}
/>
</div>
)
}
export default CustomSelectField

View File

@ -2,13 +2,42 @@ import { useTranslation } from 'react-i18next'
import { InputType } from '../types'
import { InputVarType } from '@/app/components/workflow/types'
import { useMemo } from 'react'
import {
RiAlignLeft,
RiCheckboxLine,
RiFileCopy2Line,
RiFileTextLine,
RiHashtag,
RiListCheck3,
RiTextSnippet,
} from '@remixicon/react'
const i18nFileTypeMap: Record<string, string> = {
'file': 'single-file',
'file-list': 'multi-files',
}
export const useInputTypes = (supportFile: boolean) => {
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
@ -16,6 +45,8 @@ export const useInputTypes = (supportFile: boolean) => {
return {
value,
label: t(`appDebug.variableConfig.${i18nFileTypeMap[value] || value}`),
Icon: INPUT_TYPE_ICON[value],
type: DATA_TYPE[value],
}
})
}

View File

@ -1,8 +1,8 @@
import { useTranslation } from 'react-i18next'
import { useAppForm } from '../..'
import type { InputFieldFormProps } from './types'
import type { FileTypeSelectOption, InputFieldFormProps } from './types'
import { getNewVarInWorkflow } from '@/utils/var'
import { useHiddenFieldNames, useInputTypes } from './hooks'
import { useHiddenFieldNames, useInputTypeOptions } from './hooks'
import Divider from '../../../divider'
import { useCallback, useMemo, useState } from 'react'
import { useStore } from '@tanstack/react-form'
@ -14,6 +14,9 @@ import UseUploadMethodField from './hooks/use-upload-method-field'
import UseMaxNumberOfUploadsField from './hooks/use-max-number-of-uploads-filed'
import { DEFAULT_FILE_UPLOAD_SETTING } from '@/app/components/workflow/constants'
import { DEFAULT_VALUE_MAX_LEN } from '@/config'
import { RiArrowDownSLine } from '@remixicon/react'
import cn from '@/utils/classnames'
import Badge from '../../../badge'
const InputFieldForm = ({
initialData,
@ -40,7 +43,7 @@ const InputFieldForm = ({
const type = useStore(form.store, state => state.values.type)
const options = useStore(form.store, state => state.values.options)
const hiddenFieldNames = useHiddenFieldNames(type)
const inputTypes = useInputTypes(supportFile)
const inputTypes = useInputTypeOptions(supportFile)
const FileTypesFields = UseFileTypesFields({ initialData })
const UploadMethodField = UseUploadMethodField({ initialData })
@ -99,12 +102,49 @@ const InputFieldForm = ({
<form.AppField
name='type'
children={field => (
<field.SelectField
<field.CustomSelectField<FileTypeSelectOption>
label={t('appDebug.variableConfig.fieldType')}
options={inputTypes}
onChange={handleTypeChange}
triggerProps={{
className: 'gap-x-0.5',
}}
popupProps={{
className: 'w-[368px]',
wrapperClassName: 'z-40',
itemClassName: 'gap-x-1',
}}
CustomTrigger={(option, open) => {
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',
)}
/>
</>
)
}}
CustomOption={(option) => {
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} />
</>
)
}}
/>
)}

View File

@ -1,4 +1,5 @@
import type { InputVar } from '@/app/components/workflow/types'
import type { RemixiconComponentType } from '@remixicon/react'
import type { TFunction } from 'i18next'
import { z } from 'zod'
@ -51,3 +52,10 @@ export type InputFieldFormProps = {
export type TextFieldsProps = {
initialData?: InputVar
}
export type FileTypeSelectOption = {
value: string
label: string
Icon: RemixiconComponentType
type: string
}

View File

@ -3,6 +3,7 @@ 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 SubmitButton from './components/form/submit-button'
@ -15,6 +16,7 @@ export const { useAppForm, withForm } = createFormHook({
NumberInputField,
CheckboxField,
SelectField,
CustomSelectField,
OptionsField,
},
formComponents: {

View File

@ -0,0 +1,164 @@
import {
useCallback,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import {
RiArrowDownSLine,
RiCheckLine,
} from '@remixicon/react'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import type {
PortalToFollowElemOptions,
} from '@/app/components/base/portal-to-follow-elem'
import cn from '@/utils/classnames'
export type Option = {
label: string
value: string
}
export type CustomSelectProps<T extends Option> = {
options: T[]
value?: string
onChange?: (value: string) => void
containerProps?: PortalToFollowElemOptions & {
open?: boolean
onOpenChange?: (open: boolean) => void
}
triggerProps?: {
className?: string
},
popupProps?: {
wrapperClassName?: string
className?: string
itemClassName?: string
title?: string
},
CustomTrigger?: (option: T | undefined, open: boolean) => React.ReactNode
CustomOption?: (option: T, selected: boolean) => React.ReactNode
}
const CustomSelect = <T extends Option>({
options,
value,
onChange,
containerProps,
triggerProps,
popupProps,
CustomTrigger,
CustomOption,
}: CustomSelectProps<T>) => {
const { t } = useTranslation()
const {
open,
onOpenChange,
placement,
offset,
} = containerProps || {}
const {
className: triggerClassName,
} = triggerProps || {}
const {
wrapperClassName: popupWrapperClassName,
className: popupClassName,
itemClassName: popupItemClassName,
} = popupProps || {}
const [localOpen, setLocalOpen] = useState(false)
const mergedOpen = open ?? localOpen
const handleOpenChange = useCallback((openValue: boolean) => {
onOpenChange?.(openValue)
setLocalOpen(openValue)
}, [onOpenChange])
const selectedOption = options.find(option => option.value === value)
const triggerText = selectedOption?.label || t('common.placeholder.select')
return (
<PortalToFollowElem
placement={placement || 'bottom-start'}
offset={offset || 4}
open={mergedOpen}
onOpenChange={handleOpenChange}
>
<PortalToFollowElemTrigger
onClick={() => handleOpenChange(!mergedOpen)}
asChild
>
<div
className={cn(
'system-sm-regular group flex h-8 cursor-pointer items-center rounded-lg bg-components-input-bg-normal px-2 text-components-input-text-filled hover:bg-state-base-hover-alt',
mergedOpen && 'bg-state-base-hover-alt',
triggerClassName,
)}
>
{CustomTrigger ? CustomTrigger(selectedOption, mergedOpen) : (
<>
<div
className='grow'
title={triggerText}
>
{triggerText}
</div>
<RiArrowDownSLine
className={cn(
'h-4 w-4 shrink-0 text-text-quaternary group-hover:text-text-secondary',
mergedOpen && 'text-text-secondary',
)}
/>
</>
)}
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className={cn(
'z-10',
popupWrapperClassName,
)}>
<div
className={cn(
'rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg shadow-shadow-shadow-5',
popupClassName,
)}
>
{
options.map((option) => {
const selected = value === option.value
return (
<div
key={option.value}
className={cn(
'system-sm-medium flex h-8 cursor-pointer items-center rounded-lg px-2 text-text-secondary hover:bg-state-base-hover',
popupItemClassName,
)}
title={option.label}
onClick={() => {
onChange?.(option.value)
handleOpenChange(false)
}}
>
{CustomOption ? CustomOption(option, selected) : (
<>
<div className='mr-1 grow truncate px-1'>
{option.label}
</div>
{
selected && <RiCheckLine className='h-4 w-4 shrink-0 text-text-accent' />
}
</>
)}
</div>
)
})
}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default CustomSelect