From b68a631948feb085673d57aa04c9a8cfaef33301 Mon Sep 17 00:00:00 2001 From: yyh Date: Thu, 30 Apr 2026 12:09:41 +0800 Subject: [PATCH 01/15] refactor: migrate form selects to dify-ui --- .../src/select/__tests__/index.spec.tsx | 6 + packages/dify-ui/src/select/index.tsx | 2 +- .../base/__tests__/base-field.spec.tsx | 41 +- .../base/form/components/base/base-field.tsx | 168 ++- .../field/__tests__/custom-select.spec.tsx | 49 - .../field/__tests__/select.spec.tsx | 12 +- .../form/components/field/custom-select.tsx | 41 - .../__tests__/index.spec.tsx | 10 +- .../__tests__/trigger.spec.tsx | 3 +- .../field/input-type-select/index.tsx | 61 +- .../field/input-type-select/trigger.tsx | 35 +- .../base/form/components/field/select.tsx | 74 +- .../base/form/form-scenarios/base/types.ts | 2 +- web/app/components/base/form/index.tsx | 2 - .../base/select/__tests__/custom.spec.tsx | 124 --- .../base/select/__tests__/index.spec.tsx | 957 ------------------ .../base/select/__tests__/locale.spec.tsx | 115 --- .../base/select/__tests__/pure.spec.tsx | 197 ---- web/app/components/base/select/custom.tsx | 171 ---- .../components/base/select/index.stories.tsx | 572 ----------- web/app/components/base/select/index.tsx | 441 -------- web/app/components/base/select/locale.tsx | 64 -- web/app/components/base/select/pure.tsx | 207 ---- .../components/parameter-table.tsx | 2 +- web/docs/overlay-migration.md | 1 - web/eslint.constants.mjs | 12 - 26 files changed, 299 insertions(+), 3070 deletions(-) delete mode 100644 web/app/components/base/form/components/field/__tests__/custom-select.spec.tsx delete mode 100644 web/app/components/base/form/components/field/custom-select.tsx delete mode 100644 web/app/components/base/select/__tests__/custom.spec.tsx delete mode 100644 web/app/components/base/select/__tests__/index.spec.tsx delete mode 100644 web/app/components/base/select/__tests__/locale.spec.tsx delete mode 100644 web/app/components/base/select/__tests__/pure.spec.tsx delete mode 100644 web/app/components/base/select/custom.tsx delete mode 100644 web/app/components/base/select/index.stories.tsx delete mode 100644 web/app/components/base/select/index.tsx delete mode 100644 web/app/components/base/select/locale.tsx delete mode 100644 web/app/components/base/select/pure.tsx diff --git a/packages/dify-ui/src/select/__tests__/index.spec.tsx b/packages/dify-ui/src/select/__tests__/index.spec.tsx index f2f3221eda..ac8be5917e 100644 --- a/packages/dify-ui/src/select/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/select/__tests__/index.spec.tsx @@ -170,6 +170,12 @@ describe('Select wrappers', () => { expect(screen.getByRole('combobox', { name: 'city select' }).element().querySelector('.i-ri-arrow-down-s-line')).toBeInTheDocument() }) + + it('should include open state feedback classes', async () => { + const screen = await renderOpenSelect() + + expect(screen.getByRole('combobox', { name: 'city select' }).element().className).toContain('data-open:bg-state-base-hover-alt') + }) }) describe('SelectContent', () => { diff --git a/packages/dify-ui/src/select/index.tsx b/packages/dify-ui/src/select/index.tsx index 017093c584..2f2f91d9c6 100644 --- a/packages/dify-ui/src/select/index.tsx +++ b/packages/dify-ui/src/select/index.tsx @@ -21,7 +21,7 @@ export const SelectGroup = BaseSelect.Group const selectTriggerVariants = cva( [ 'group flex w-full items-center border-0 bg-components-input-bg-normal text-left text-components-input-text-filled outline-hidden', - 'hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt', + 'hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt data-open:bg-state-base-hover-alt', 'data-placeholder:text-components-input-text-placeholder', 'data-readonly:cursor-default data-readonly:bg-transparent data-readonly:hover:bg-transparent', 'data-disabled:cursor-not-allowed data-disabled:bg-components-input-bg-disabled data-disabled:text-components-input-text-filled-disabled data-disabled:hover:bg-components-input-bg-disabled', diff --git a/web/app/components/base/form/components/base/__tests__/base-field.spec.tsx b/web/app/components/base/form/components/base/__tests__/base-field.spec.tsx index 54d7accad4..a5a84b8738 100644 --- a/web/app/components/base/form/components/base/__tests__/base-field.spec.tsx +++ b/web/app/components/base/form/components/base/__tests__/base-field.spec.tsx @@ -2,6 +2,7 @@ import type { AnyFieldApi } from '@tanstack/react-form' import type { FormSchema } from '@/app/components/base/form/types' import { useForm } from '@tanstack/react-form' import { act, fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { FormItemValidateStatusEnum, FormTypeEnum } from '@/app/components/base/form/types' import BaseField from '../base-field' @@ -238,6 +239,7 @@ describe('BaseField', () => { }) it('should render dynamic options and allow selecting one', async () => { + const user = userEvent.setup() mockDynamicOptions.mockReturnValue({ data: { options: [ @@ -258,13 +260,42 @@ describe('BaseField', () => { defaultValues: { plugin_option: '' }, }) - await act(async () => { - fireEvent.click(screen.getByText('common.placeholder.input')) + await user.click(screen.getByRole('combobox', { name: 'Plugin option' })) + await user.click(screen.getByRole('option', { name: 'Option A' })) + expect(screen.getByRole('combobox', { name: 'Plugin option' })).toHaveTextContent('Option A') + }) + + it('should preserve multiple dynamic select values', async () => { + const user = userEvent.setup() + mockDynamicOptions.mockReturnValue({ + data: { + options: [ + { label: { en_US: 'Option A', zh_Hans: '选项A' }, value: 'a' }, + { label: { en_US: 'Option B', zh_Hans: '选项B' }, value: 'b' }, + ], + }, + isLoading: false, + error: null, }) - await act(async () => { - fireEvent.click(screen.getByText('Option A')) + + renderBaseField({ + formSchema: { + type: FormTypeEnum.dynamicSelect, + name: 'plugin_options', + label: 'Plugin options', + required: false, + multiple: true, + }, + defaultValues: { plugin_options: ['a'] }, + showCurrentValue: true, }) - expect(screen.getByText('Option A')).toBeInTheDocument() + + expect(screen.getByRole('combobox', { name: 'Plugin options' })).toHaveTextContent('common.dynamicSelect.selected') + + await user.click(screen.getByRole('combobox', { name: 'Plugin options' })) + await user.click(screen.getByRole('option', { name: 'Option B' })) + + expect(screen.getByTestId('field-value')).toHaveTextContent('a,b') }) it('should update boolean field when users choose false', async () => { diff --git a/web/app/components/base/form/components/base/base-field.tsx b/web/app/components/base/form/components/base/base-field.tsx index b1e17bdefc..a4159cbc6f 100644 --- a/web/app/components/base/form/components/base/base-field.tsx +++ b/web/app/components/base/form/components/base/base-field.tsx @@ -1,6 +1,15 @@ import type { AnyFieldApi } from '@tanstack/react-form' import type { FieldState, FormSchema, TypeWithI18N } from '@/app/components/base/form/types' import { cn } from '@langgenius/dify-ui/cn' +import { + Select, + SelectContent, + SelectItem, + SelectItemIndicator, + SelectItemText, + SelectTrigger, + SelectValue, +} from '@langgenius/dify-ui/select' import { useStore } from '@tanstack/react-form' import { isValidElement, @@ -14,7 +23,6 @@ import { FormItemValidateStatusEnum, FormTypeEnum } from '@/app/components/base/ 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' @@ -121,7 +129,7 @@ const BaseField = ({ if (!results[1]) results[1] = t('placeholder.input', { ns: 'common' }) return results - }, [label, placeholder, tooltip, description, help, renderI18nObject]) + }, [label, placeholder, tooltip, description, help, renderI18nObject, t]) const watchedVariables = useMemo(() => { const variables = new Set() @@ -184,6 +192,13 @@ const BaseField = ({ field.handleChange(value) onChange?.(field.name, value) }, [field, onChange]) + const dynamicPlaceholder = isDynamicOptionsLoading + ? t('dynamicSelect.loading', { ns: 'common' }) + : translatedPlaceholder + const dynamicNoticeTitle = dynamicOptionsError + ? t('dynamicSelect.error', { ns: 'common' }) + : (!dynamicOptions.length ? t('dynamicSelect.noData', { ns: 'common' }) : null) + const dynamicNoticeClassName = dynamicOptionsError ? 'text-text-destructive-secondary' : undefined return ( <> @@ -223,19 +238,56 @@ const BaseField = ({ ) } { - formItemType === FormTypeEnum.select && !multiple && ( - handleChange(v)} - disabled={disabled} - placeholder={translatedPlaceholder} - options={memorizedOptions} - triggerPopupSameWidth - popupProps={{ - className: 'max-h-[320px] overflow-y-auto', - }} - /> - ) + formItemType === FormTypeEnum.select && (multiple + ? ( + + ) + : ( + + )) } { formItemType === FormTypeEnum.checkbox /* && multiple */ && ( @@ -249,24 +301,74 @@ const BaseField = ({ ) } { - formItemType === FormTypeEnum.dynamicSelect && ( - - ) + formItemType === FormTypeEnum.dynamicSelect && (multiple + ? ( + + ) + : ( + + )) } { formItemType === FormTypeEnum.radio && ( diff --git a/web/app/components/base/form/components/field/__tests__/custom-select.spec.tsx b/web/app/components/base/form/components/field/__tests__/custom-select.spec.tsx deleted file mode 100644 index 5470df58a3..0000000000 --- a/web/app/components/base/form/components/field/__tests__/custom-select.spec.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { fireEvent, render, screen } from '@testing-library/react' -import CustomSelectField from '../custom-select' - -const mockField = { - name: 'custom-select-field', - state: { - value: 'small', - }, - handleChange: vi.fn(), -} - -vi.mock('../../..', () => ({ - useFieldContext: () => mockField, -})) - -describe('CustomSelectField', () => { - beforeEach(() => { - vi.clearAllMocks() - mockField.state.value = 'small' - }) - - it('should render select placeholder or selected value', () => { - render( - , - ) - expect(screen.getByText('Small')).toBeInTheDocument() - }) - - it('should update value when users select another option', () => { - render( - , - ) - fireEvent.click(screen.getByText('Small')) - fireEvent.click(screen.getByText('Large')) - expect(mockField.handleChange).toHaveBeenCalledWith('large') - }) -}) diff --git a/web/app/components/base/form/components/field/__tests__/select.spec.tsx b/web/app/components/base/form/components/field/__tests__/select.spec.tsx index 0bf6b4e022..aec6c6fd56 100644 --- a/web/app/components/base/form/components/field/__tests__/select.spec.tsx +++ b/web/app/components/base/form/components/field/__tests__/select.spec.tsx @@ -1,4 +1,5 @@ -import { fireEvent, render, screen } from '@testing-library/react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import SelectField from '../select' const mockField = { @@ -29,10 +30,11 @@ describe('SelectField', () => { ]} />, ) - expect(screen.getByText('Alpha')).toBeInTheDocument() + expect(screen.getByRole('combobox', { name: 'Mode' })).toHaveTextContent('Alpha') }) - it('should update value when users select another option', () => { + it('should update value when users select another option', async () => { + const user = userEvent.setup() render( { ]} />, ) - fireEvent.click(screen.getByText('Alpha')) - fireEvent.click(screen.getByText('Beta')) + await user.click(screen.getByRole('combobox', { name: 'Mode' })) + await user.click(screen.getByRole('option', { name: 'Beta' })) expect(mockField.handleChange).toHaveBeenCalledWith('beta') }) }) diff --git a/web/app/components/base/form/components/field/custom-select.tsx b/web/app/components/base/form/components/field/custom-select.tsx deleted file mode 100644 index 5808d4004d..0000000000 --- a/web/app/components/base/form/components/field/custom-select.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import type { CustomSelectProps, Option } from '../../../select/custom' -import type { LabelProps } from '../label' -import { cn } from '@langgenius/dify-ui/cn' -import { useFieldContext } from '../..' -import CustomSelect from '../../../select/custom' -import Label from '../label' - -type CustomSelectFieldProps = { - label: string - labelOptions?: Omit - options: T[] - className?: string -} & Omit, 'options' | 'value' | 'onChange'> - -const CustomSelectField = ({ - label, - labelOptions, - options, - className, - ...selectProps -}: CustomSelectFieldProps) => { - const field = useFieldContext() - - return ( -
-
- ) -} - -export default CustomSelectField diff --git a/web/app/components/base/form/components/field/input-type-select/__tests__/index.spec.tsx b/web/app/components/base/form/components/field/input-type-select/__tests__/index.spec.tsx index bb7ae80a34..e5e4e9dc9a 100644 --- a/web/app/components/base/form/components/field/input-type-select/__tests__/index.spec.tsx +++ b/web/app/components/base/form/components/field/input-type-select/__tests__/index.spec.tsx @@ -1,4 +1,5 @@ -import { fireEvent, render, screen } from '@testing-library/react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import InputTypeSelectField from '../index' const mockField = { @@ -26,11 +27,12 @@ describe('InputTypeSelectField', () => { expect(screen.getByText('appDebug.variableConfig.text-input')).toBeInTheDocument() }) - it('should update value when users choose another input type', () => { + it('should update value when users choose another input type', async () => { + const user = userEvent.setup() render() - fireEvent.click(screen.getByText('appDebug.variableConfig.text-input')) - fireEvent.click(screen.getByText('appDebug.variableConfig.number')) + await user.click(screen.getByRole('combobox', { name: 'Input type' })) + await user.click(screen.getByRole('option', { name: /appDebug.variableConfig.number/ })) expect(mockField.handleChange).toHaveBeenCalledWith('number') }) diff --git a/web/app/components/base/form/components/field/input-type-select/__tests__/trigger.spec.tsx b/web/app/components/base/form/components/field/input-type-select/__tests__/trigger.spec.tsx index 0957ac41c1..24c22e1f1b 100644 --- a/web/app/components/base/form/components/field/input-type-select/__tests__/trigger.spec.tsx +++ b/web/app/components/base/form/components/field/input-type-select/__tests__/trigger.spec.tsx @@ -5,7 +5,7 @@ const MockIcon = () => describe('InputTypeSelect Trigger', () => { it('should show placeholder text when no option is selected', () => { - render() + render() expect(screen.getByText('common.placeholder.select')).toBeInTheDocument() }) @@ -18,7 +18,6 @@ describe('InputTypeSelect Trigger', () => { Icon: MockIcon, type: 'string', }} - open={false} />, ) diff --git a/web/app/components/base/form/components/field/input-type-select/index.tsx b/web/app/components/base/form/components/field/input-type-select/index.tsx index 5e150240f6..37f9a510d4 100644 --- a/web/app/components/base/form/components/field/input-type-select/index.tsx +++ b/web/app/components/base/form/components/field/input-type-select/index.tsx @@ -1,10 +1,13 @@ -import type { CustomSelectProps } from '../../../../select/custom' import type { LabelProps } from '../../label' import type { FileTypeSelectOption, InputType } from './types' import { cn } from '@langgenius/dify-ui/cn' -import { useCallback } from 'react' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, +} from '@langgenius/dify-ui/select' import { useFieldContext } from '../../..' -import CustomSelect from '../../../../select/custom' import Label from '../../label' import { useInputTypeOptions } from './hooks' import Option from './option' @@ -15,24 +18,19 @@ type InputTypeSelectFieldProps = { labelOptions?: Omit supportFile: boolean className?: string -} & Omit, 'options' | 'value' | 'onChange' | 'CustomTrigger' | 'CustomOption'> + disabled?: boolean +} const InputTypeSelectField = ({ label, labelOptions, supportFile, className, - ...customSelectProps + disabled, }: InputTypeSelectFieldProps) => { const field = useFieldContext() const inputTypeOptions = useInputTypeOptions(supportFile) - - const renderTrigger = useCallback((option: FileTypeSelectOption | undefined, open: boolean) => { - return - }, []) - const renderOption = useCallback((option: FileTypeSelectOption) => { - return