From 93f83086c1ca7fc9f594befc084f8d70dcc5cda1 Mon Sep 17 00:00:00 2001 From: twwu Date: Wed, 23 Apr 2025 22:16:19 +0800 Subject: [PATCH] feat: add CustomSelectField component and integrate with input field form --- .../form/components/field/custom-select.tsx | 57 ++++++ .../form-scenarios/input-field/hooks/index.ts | 33 +++- .../form/form-scenarios/input-field/index.tsx | 48 ++++- .../form/form-scenarios/input-field/types.ts | 8 + web/app/components/base/form/index.tsx | 2 + web/app/components/base/select/custom.tsx | 164 ++++++++++++++++++ 6 files changed, 307 insertions(+), 5 deletions(-) create mode 100644 web/app/components/base/form/components/field/custom-select.tsx create mode 100644 web/app/components/base/select/custom.tsx diff --git a/web/app/components/base/form/components/field/custom-select.tsx b/web/app/components/base/form/components/field/custom-select.tsx new file mode 100644 index 0000000000..82c531a800 --- /dev/null +++ b/web/app/components/base/form/components/field/custom-select.tsx @@ -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 = { + label: string + options: T[] + onChange?: (value: string) => void + isRequired?: boolean + showOptional?: boolean + tooltip?: string + className?: string + labelClassName?: string +} & Omit, 'options' | 'value' | 'onChange'> + +const CustomSelectField = ({ + label, + options, + onChange, + isRequired, + showOptional, + tooltip, + className, + labelClassName, + ...selectProps +}: CustomSelectFieldProps) => { + const field = useFieldContext() + + const handleChange = useCallback((value: string) => { + field.handleChange(value) + onChange?.(value) + }, [field, onChange]) + + return ( +
+
+ ) +} + +export default CustomSelectField diff --git a/web/app/components/base/form/form-scenarios/input-field/hooks/index.ts b/web/app/components/base/form/form-scenarios/input-field/hooks/index.ts index b833709fa6..24f989dc93 100644 --- a/web/app/components/base/form/form-scenarios/input-field/hooks/index.ts +++ b/web/app/components/base/form/form-scenarios/input-field/hooks/index.ts @@ -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 = { '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], } }) } diff --git a/web/app/components/base/form/form-scenarios/input-field/index.tsx b/web/app/components/base/form/form-scenarios/input-field/index.tsx index eb21eb0ec6..b36dd21927 100644 --- a/web/app/components/base/form/form-scenarios/input-field/index.tsx +++ b/web/app/components/base/form/form-scenarios/input-field/index.tsx @@ -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 = ({ ( - 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.label} +
+ +
+ + ) : ( + {t('common.placeholder.select')} + )} + + + ) + }} + CustomOption={(option) => { + return ( + <> + + {option.label} + + + ) }} /> )} diff --git a/web/app/components/base/form/form-scenarios/input-field/types.ts b/web/app/components/base/form/form-scenarios/input-field/types.ts index 36ef19f8f5..e2c7b0afb3 100644 --- a/web/app/components/base/form/form-scenarios/input-field/types.ts +++ b/web/app/components/base/form/form-scenarios/input-field/types.ts @@ -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 +} diff --git a/web/app/components/base/form/index.tsx b/web/app/components/base/form/index.tsx index aeb482ad02..2ce8014cc0 100644 --- a/web/app/components/base/form/index.tsx +++ b/web/app/components/base/form/index.tsx @@ -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: { diff --git a/web/app/components/base/select/custom.tsx b/web/app/components/base/select/custom.tsx new file mode 100644 index 0000000000..54bb7b1387 --- /dev/null +++ b/web/app/components/base/select/custom.tsx @@ -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 = { + 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 = ({ + options, + value, + onChange, + containerProps, + triggerProps, + popupProps, + CustomTrigger, + CustomOption, +}: CustomSelectProps) => { + 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 ( + + handleOpenChange(!mergedOpen)} + asChild + > +
+ {CustomTrigger ? CustomTrigger(selectedOption, mergedOpen) : ( + <> +
+ {triggerText} +
+ + + )} +
+
+ +
+ { + options.map((option) => { + const selected = value === option.value + return ( +
{ + onChange?.(option.value) + handleOpenChange(false) + }} + > + {CustomOption ? CustomOption(option, selected) : ( + <> +
+ {option.label} +
+ { + selected && + } + + )} +
+ ) + }) + } +
+
+
+ ) +} + +export default CustomSelect