From 11c52e90f6bbfdf279824e4bea28215402e29714 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Thu, 30 Apr 2026 14:15:08 +0800 Subject: [PATCH] refactor(web/select): base selects to dify-ui (#35720) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- eslint-suppressions.json | 31 +- .../src/select/__tests__/index.spec.tsx | 6 + packages/dify-ui/src/select/index.tsx | 2 +- .../__tests__/dropdown-callbacks.spec.tsx | 8 +- .../app-sidebar/dataset-info/dropdown.tsx | 39 +- .../base/__tests__/base-field.spec.tsx | 60 +- .../base/form/components/base/base-field.tsx | 185 +++- .../field/__tests__/custom-select.spec.tsx | 49 - .../field/__tests__/select.spec.tsx | 28 +- .../form/components/field/custom-select.tsx | 41 - .../__tests__/index.spec.tsx | 14 +- .../__tests__/trigger.spec.tsx | 18 +- .../field/input-type-select/index.tsx | 61 +- .../field/input-type-select/trigger.tsx | 36 +- .../base/form/components/field/select.tsx | 84 +- .../base/form/form-scenarios/base/types.ts | 2 +- web/app/components/base/form/index.tsx | 2 - web/app/components/base/infotip/index.tsx | 11 +- .../base/portal-to-follow-elem/index.tsx | 2 +- .../base/select/__tests__/custom.spec.tsx | 124 --- .../base/select/__tests__/index.spec.tsx | 957 ------------------ .../select/__tests__/locale-signin.spec.tsx | 116 --- .../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 -------- .../components/base/select/locale-signin.tsx | 64 -- web/app/components/base/select/locale.tsx | 64 -- web/app/components/base/select/pure.tsx | 207 ---- .../__tests__/usage-priority-section.spec.tsx | 22 +- .../usage-priority-section.tsx | 39 +- .../workflow/block-selector/main.tsx | 31 +- .../variable/var-reference-picker.trigger.tsx | 2 + .../components/parameter-table.tsx | 2 +- web/app/signin/__tests__/_header.spec.tsx | 57 ++ .../__tests__/_locale-menu.spec.tsx} | 34 +- web/app/signin/_header.tsx | 7 +- web/app/signin/_locale-menu.tsx | 73 ++ web/docs/overlay-migration.md | 1 - web/eslint.constants.mjs | 12 - 40 files changed, 626 insertions(+), 3246 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-signin.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-signin.tsx delete mode 100644 web/app/components/base/select/locale.tsx delete mode 100644 web/app/components/base/select/pure.tsx create mode 100644 web/app/signin/__tests__/_header.spec.tsx rename web/app/{components/base/select/__tests__/locale.spec.tsx => signin/__tests__/_locale-menu.spec.tsx} (74%) create mode 100644 web/app/signin/_locale-menu.tsx diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 183560f81b..7b24f216aa 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -202,11 +202,6 @@ "count": 1 } }, - "web/app/components/app-sidebar/dataset-info/dropdown.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, "web/app/components/app-sidebar/index.tsx": { "ts/no-explicit-any": { "count": 1 @@ -1158,7 +1153,7 @@ }, "web/app/components/base/form/components/base/base-field.tsx": { "no-restricted-imports": { - "count": 2 + "count": 1 }, "ts/no-explicit-any": { "count": 3 @@ -1909,25 +1904,6 @@ "count": 1 } }, - "web/app/components/base/select/index.stories.tsx": { - "no-console": { - "count": 4 - }, - "ts/no-explicit-any": { - "count": 1 - } - }, - "web/app/components/base/select/index.tsx": { - "react/set-state-in-effect": { - "count": 2 - }, - "style/multiline-ternary": { - "count": 2 - }, - "ts/no-explicit-any": { - "count": 1 - } - }, "web/app/components/base/sort/index.tsx": { "ts/no-explicit-any": { "count": 2 @@ -5432,11 +5408,6 @@ "count": 1 } }, - "web/app/signin/_header.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/signin/components/mail-and-password-auth.tsx": { "ts/no-explicit-any": { "count": 1 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/app-sidebar/dataset-info/__tests__/dropdown-callbacks.spec.tsx b/web/app/components/app-sidebar/dataset-info/__tests__/dropdown-callbacks.spec.tsx index ceb8302ee6..05a06f2f77 100644 --- a/web/app/components/app-sidebar/dataset-info/__tests__/dropdown-callbacks.spec.tsx +++ b/web/app/components/app-sidebar/dataset-info/__tests__/dropdown-callbacks.spec.tsx @@ -113,7 +113,9 @@ vi.mock('@/service/datasets', () => ({ })) vi.mock('@langgenius/dify-ui/toast', () => ({ - toast: (...args: unknown[]) => mockToast(...args), + toast: { + error: (...args: unknown[]) => mockToast(...args), + }, })) vi.mock('@/app/components/datasets/rename-modal', () => ({ @@ -220,7 +222,7 @@ describe('Dropdown callback coverage', () => { await user.click(screen.getByText('datasetPipeline.operations.exportPipeline')) await waitFor(() => { - expect(mockToast).toHaveBeenCalledWith('app.exportFailed', { type: 'error' }) + expect(mockToast).toHaveBeenCalledWith('app.exportFailed') }) }) @@ -257,7 +259,7 @@ describe('Dropdown callback coverage', () => { await user.click(screen.getByText('common.operation.delete')) await waitFor(() => { - expect(mockToast).toHaveBeenCalledWith('check failed', { type: 'error' }) + expect(mockToast).toHaveBeenCalledWith('check failed') }) expect(screen.queryByText('dataset.deleteDatasetConfirmTitle')).not.toBeInTheDocument() }) diff --git a/web/app/components/app-sidebar/dataset-info/dropdown.tsx b/web/app/components/app-sidebar/dataset-info/dropdown.tsx index 8f3a25738a..fa5a40f8a4 100644 --- a/web/app/components/app-sidebar/dataset-info/dropdown.tsx +++ b/web/app/components/app-sidebar/dataset-info/dropdown.tsx @@ -34,6 +34,25 @@ type DropDownProps = { expand: boolean } +type JsonErrorResponse = { + json: () => Promise<{ message?: string }> +} + +const isJsonErrorResponse = (error: unknown): error is JsonErrorResponse => { + return typeof error === 'object' + && error !== null + && 'json' in error + && typeof error.json === 'function' +} + +const getErrorMessage = async (error: unknown) => { + if (!isJsonErrorResponse(error)) + return 'Unknown error' + + const res = await error.json() + return res?.message || 'Unknown error' +} + const DropDown = ({ expand, }: DropDownProps) => { @@ -78,7 +97,7 @@ const DropDown = ({ downloadBlob({ data: file, fileName: `${name}.pipeline` }) } catch { - toast(t('exportFailed', { ns: 'app' }), { type: 'error' }) + toast.error(t('exportFailed', { ns: 'app' })) } }, [dataset, exportPipelineConfig, t]) @@ -89,9 +108,8 @@ const DropDown = ({ setConfirmMessage(isUsedByApp ? t('datasetUsedByApp', { ns: 'dataset' })! : t('deleteDatasetConfirmContent', { ns: 'dataset' })!) setShowConfirmDelete(true) } - catch (e: any) { - const res = await e.json() - toast(res?.message || 'Unknown error', { type: 'error' }) + catch (e: unknown) { + toast.error(await getErrorMessage(e)) } }, [dataset.id, t]) @@ -112,10 +130,15 @@ const DropDown = ({ open={open} onOpenChange={setOpen} > - }> - - - + + )} + > + { expect(screen.queryByText('Beta')).not.toBeInTheDocument() }) + it('should not render current select value when it is filtered out by show_on conditions', () => { + renderBaseField({ + formSchema: { + type: FormTypeEnum.select, + name: 'mode', + label: 'Mode', + required: false, + options: [ + { label: 'Alpha', value: 'alpha' }, + { label: 'Beta', value: 'beta', show_on: [{ variable: 'enabled', value: 'yes' }] }, + ], + }, + defaultValues: { mode: 'beta', enabled: 'no' }, + }) + + expect(screen.getByRole('combobox', { name: 'Mode' })).not.toHaveTextContent('beta') + expect(screen.getByRole('combobox', { name: 'Mode' })).toHaveTextContent('common.placeholder.input') + }) + it('should render dynamic select loading state', () => { mockDynamicOptions.mockReturnValue({ data: undefined, @@ -238,6 +258,7 @@ describe('BaseField', () => { }) it('should render dynamic options and allow selecting one', async () => { + const user = userEvent.setup() mockDynamicOptions.mockReturnValue({ data: { options: [ @@ -258,13 +279,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..a256bccd8b 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' @@ -43,6 +51,19 @@ const getTranslatedContent = ({ content, render }: { return '' } +type SelectOption = { + label: string + value: string +} + +const getSingleSelectValue = (value: unknown, options: SelectOption[]) => { + return options.find(option => option.value === value)?.value ?? null +} + +const getSingleSelectLabel = (value: unknown, options: SelectOption[], placeholder: string | undefined) => { + return options.find(option => option.value === value)?.label ?? placeholder +} + const VALIDATE_STATUS_STYLE_MAP: Record = { [FormItemValidateStatusEnum.Error]: { componentClassName: 'border-components-input-border-destructive focus:border-components-input-border-destructive', @@ -121,7 +142,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 +205,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 +251,58 @@ 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 + ? ( + + + + {(selectedValue: string[]) => selectedValue.length + ? t('dynamicSelect.selected', { ns: 'common', count: selectedValue.length }) + : translatedPlaceholder} + + + + {memorizedOptions.map(option => ( + + {option.label} + + + ))} + + + ) + : ( + { + if (next == null) + return + handleChange(next) + }} + > + + + {nextValue => getSingleSelectLabel(nextValue, memorizedOptions, translatedPlaceholder)} + + + + {memorizedOptions.map(option => ( + + {option.label} + + + ))} + + + )) } { formItemType === FormTypeEnum.checkbox /* && multiple */ && ( @@ -249,24 +316,76 @@ const BaseField = ({ ) } { - formItemType === FormTypeEnum.dynamicSelect && ( - - ) + formItemType === FormTypeEnum.dynamicSelect && (multiple + ? ( + + + + {(selectedValue: string[]) => selectedValue.length + ? t('dynamicSelect.selected', { ns: 'common', count: selectedValue.length }) + : dynamicPlaceholder} + + + + {dynamicNoticeTitle && ( + + {dynamicNoticeTitle} + + )} + {dynamicOptions.map(option => ( + + {option.label} + + + ))} + + + ) + : ( + { + if (next == null) + return + field.handleChange(next) + }} + > + + + {nextValue => getSingleSelectLabel(nextValue, dynamicOptions, dynamicPlaceholder)} + + + + {dynamicNoticeTitle && ( + + {dynamicNoticeTitle} + + )} + {dynamicOptions.map(option => ( + + {option.label} + + + ))} + + + )) } { 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..45cc87d157 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,27 @@ 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 render the option label when selected value is an empty string', () => { + mockField.state.value = '' + + render( + , + ) + + expect(screen.getByRole('combobox', { name: 'Mode' })).toHaveTextContent('No default selected') + }) + + 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 ( - - - - value={field.state.value} - options={options} - onChange={value => field.handleChange(value)} - {...selectProps} - /> - - ) -} - -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..f666315ddf 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 = { @@ -20,17 +21,20 @@ describe('InputTypeSelectField', () => { }) it('should render label and selected option', () => { - render() + const { container } = render() expect(screen.getByText('Input type')).toBeInTheDocument() expect(screen.getByText('appDebug.variableConfig.text-input')).toBeInTheDocument() + expect(container.querySelector('[role="combobox"] span > div')).not.toBeInTheDocument() + expect(container.querySelector('[role="combobox"] > span > span')).toHaveClass('flex', 'min-w-0', 'items-center', 'gap-x-0.5') }) - 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..a7a1f2a294 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,11 +18,25 @@ describe('InputTypeSelect Trigger', () => { Icon: MockIcon, type: 'string', }} - open={false} />, ) expect(screen.getByText('Text Input')).toBeInTheDocument() expect(screen.getByText('string')).toBeInTheDocument() }) + + it('should keep selected option parts in one inline flex row', () => { + render( + , + ) + + expect(screen.getByText('Text Input').parentElement).toHaveClass('flex', 'min-w-0', 'items-center', 'gap-x-0.5') + }) }) 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 - }, []) + const selected = inputTypeOptions.find(option => option.value === field.state.value) return ( @@ -41,22 +39,31 @@ const InputTypeSelectField = ({ label={label} {...(labelOptions ?? {})} /> - - value={field.state.value} - options={inputTypeOptions} - onChange={value => field.handleChange(value as InputType)} - triggerProps={{ - className: 'gap-x-0.5', + { + if (next == null) + return + field.handleChange(next as InputType) }} - popupProps={{ - className: 'w-[368px]', - wrapperClassName: 'z-9999999', - itemClassName: 'gap-x-1', - }} - CustomTrigger={renderTrigger} - CustomOption={renderOption} - {...customSelectProps} - /> + > + + + + + {inputTypeOptions.map((option: FileTypeSelectOption) => ( + + + + ))} + + ) } diff --git a/web/app/components/base/form/components/field/input-type-select/trigger.tsx b/web/app/components/base/form/components/field/input-type-select/trigger.tsx index c2b61ca547..fab37c81f7 100644 --- a/web/app/components/base/form/components/field/input-type-select/trigger.tsx +++ b/web/app/components/base/form/components/field/input-type-select/trigger.tsx @@ -1,43 +1,27 @@ import type { FileTypeSelectOption } from './types' -import { cn } from '@langgenius/dify-ui/cn' -import { RiArrowDownSLine } from '@remixicon/react' import * as React from 'react' import { useTranslation } from 'react-i18next' -import Badge from '@/app/components/base/badge' type TriggerProps = { option: FileTypeSelectOption | undefined - open: boolean } const Trigger = ({ option, - open, }: TriggerProps) => { const { t } = useTranslation() + if (!option) + return {t('placeholder.select', { ns: 'common' })} + return ( - <> - {option - ? ( - <> - - {option.label} - - - - > - ) - : ( - {t('placeholder.select', { ns: 'common' })} - )} - - > + + + {option.label} + + {option.type} + + ) } diff --git a/web/app/components/base/form/components/field/select.tsx b/web/app/components/base/form/components/field/select.tsx index 77405fdca6..a8b49a2691 100644 --- a/web/app/components/base/form/components/field/select.tsx +++ b/web/app/components/base/form/components/field/select.tsx @@ -1,18 +1,46 @@ -import type { Option, PureSelectProps } from '../../../select/pure' import type { LabelProps } from '../label' import { cn } from '@langgenius/dify-ui/cn' +import { + Select, + SelectContent, + SelectItem, + SelectItemIndicator, + SelectItemText, + SelectTrigger, + SelectValue, +} from '@langgenius/dify-ui/select' +import { useTranslation } from 'react-i18next' import { useFieldContext } from '../..' -import PureSelect from '../../../select/pure' import Label from '../label' +export type Option = { + label: string + value: string +} + +const getSelectedValue = (value: string | undefined, options: Option[]) => { + return options.some(option => option.value === value) ? value : null +} + +const getDisplayLabel = (value: string | null, options: Option[], placeholder: string) => { + return options.find(option => option.value === value)?.label ?? placeholder +} + +type SelectFieldPopupProps = { + className?: string + title?: string + titleClassName?: string +} + type SelectFieldProps = { label: string labelOptions?: Omit options: Option[] onChange?: (value: string) => void className?: string -} & Omit & { - multiple?: false + placeholder?: string + disabled?: boolean + popupProps?: SelectFieldPopupProps } const SelectField = ({ @@ -21,9 +49,13 @@ const SelectField = ({ options, onChange, className, - ...selectProps + placeholder, + disabled, + popupProps, }: SelectFieldProps) => { + const { t } = useTranslation() const field = useFieldContext() + const placeholderText = placeholder || t('placeholder.select', { ns: 'common' }) return ( @@ -32,15 +64,41 @@ const SelectField = ({ label={label} {...(labelOptions ?? {})} /> - { - field.handleChange(value) - onChange?.(value) + { + if (next == null) + return + field.handleChange(next) + onChange?.(next) }} - {...selectProps} - /> + > + + + {(nextValue: string | null) => getDisplayLabel(nextValue, options, placeholderText)} + + + + {popupProps?.title && ( + + {popupProps.title} + + )} + {options.map(option => ( + + {option.label} + + + ))} + + ) } diff --git a/web/app/components/base/form/form-scenarios/base/types.ts b/web/app/components/base/form/form-scenarios/base/types.ts index 5c778054b5..b9c3dd1b31 100644 --- a/web/app/components/base/form/form-scenarios/base/types.ts +++ b/web/app/components/base/form/form-scenarios/base/types.ts @@ -1,4 +1,4 @@ -import type { Option } from '../../../select/pure' +import type { Option } from '../../components/field/select' import type { CustomActionsProps } from '../../components/form/actions' import type { TransferMethod } from '@/types/app' diff --git a/web/app/components/base/form/index.tsx b/web/app/components/base/form/index.tsx index 663b7f1fe8..46dbf06b7c 100644 --- a/web/app/components/base/form/index.tsx +++ b/web/app/components/base/form/index.tsx @@ -1,6 +1,5 @@ import { createFormHook, createFormHookContexts } from '@tanstack/react-form' import CheckboxField from './components/field/checkbox' -import CustomSelectField from './components/field/custom-select' import FileTypesField from './components/field/file-types' import FileUploaderField from './components/field/file-uploader' import InputTypeSelectField from './components/field/input-type-select' @@ -26,7 +25,6 @@ export const { useAppForm, withForm } = createFormHook({ NumberInputField, CheckboxField, SelectField, - CustomSelectField, OptionsField, InputTypeSelectField, FileTypesField, diff --git a/web/app/components/base/infotip/index.tsx b/web/app/components/base/infotip/index.tsx index ce818fe030..0927e6ac27 100644 --- a/web/app/components/base/infotip/index.tsx +++ b/web/app/components/base/infotip/index.tsx @@ -65,12 +65,13 @@ export function Infotip({ delay={delay} closeDelay={closeDelay} aria-label={ariaLabel} - render={( - - - + className={cn( + 'inline-flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center border-0 bg-transparent p-0 focus-visible:ring-1 focus-visible:ring-components-input-border-hover focus-visible:outline-hidden', + className, )} - /> + > + + { - beforeEach(() => { - vi.clearAllMocks() - }) - - // Rendering behavior and value fallback. - describe('Rendering', () => { - it('should show the placeholder when value is undefined or not found', () => { - const { rerender } = render( - , - ) - - expect(screen.getByTitle(/select/i)).toBeInTheDocument() - - rerender( - , - ) - - expect(screen.getByTitle(/select/i)).toBeInTheDocument() - }) - }) - - // User interactions for opening and selecting options. - describe('User Interactions', () => { - it('should call onChange and close the popup when an option is selected', async () => { - const user = userEvent.setup() - const onChange = vi.fn() - - render( - , - ) - - await user.click(screen.getByTitle(/select/i)) - expect(screen.getByTitle('Second option')).toBeInTheDocument() - - await user.click(screen.getByTitle('Second option')) - expect(onChange).toHaveBeenCalledWith('second') - expect(screen.queryByTitle('Second option')).not.toBeInTheDocument() - }) - }) - - // Controlled container props behavior. - describe('Container Props', () => { - it('should delegate open-state changes through containerProps.onOpenChange', async () => { - const user = userEvent.setup() - const onOpenChange = vi.fn() - - render( - , - ) - - expect(screen.getByTitle('First option')).toBeInTheDocument() - - await user.click(screen.getByTitle(/select/i)) - expect(onOpenChange).toHaveBeenCalledWith(false) - }) - }) - - // Custom rendering hooks for trigger and options. - describe('Custom Renderers', () => { - it('should render CustomTrigger and CustomOption with selected state', async () => { - const user = userEvent.setup() - - render( - {`${option?.label ?? 'none'}-${open ? 'open' : 'closed'}`}} - CustomOption={(option, selected) => {`${option.label}-${selected ? 'selected' : 'idle'}`}} - />, - ) - - expect(screen.getByText('First option-closed')).toBeInTheDocument() - - await user.click(screen.getByText('First option-closed')) - - expect(screen.getByText('First option-open')).toBeInTheDocument() - expect(screen.getByText('First option-selected')).toBeInTheDocument() - expect(screen.getByText('Second option-idle')).toBeInTheDocument() - }) - }) - - // Class-based customization props. - describe('Style Props', () => { - it('should apply trigger and popup class names from props', async () => { - const user = userEvent.setup() - - render( - , - ) - - const triggerLabel = screen.getByTitle(/select/i) - const trigger = triggerLabel.parentElement - expect(trigger).toHaveClass('trigger-class') - - await user.click(triggerLabel) - - expect(document.querySelector('.wrapper-class')).toBeInTheDocument() - expect(document.querySelector('.popup-class')).toBeInTheDocument() - expect(document.querySelectorAll('.item-class')).toHaveLength(options.length) - }) - }) -}) diff --git a/web/app/components/base/select/__tests__/index.spec.tsx b/web/app/components/base/select/__tests__/index.spec.tsx deleted file mode 100644 index 6ddc51c177..0000000000 --- a/web/app/components/base/select/__tests__/index.spec.tsx +++ /dev/null @@ -1,957 +0,0 @@ -import type { Item } from '../index' -import { fireEvent, render, screen } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import Select, { PortalSelect, SimpleSelect } from '../index' - -const items: Item[] = [ - { value: 'apple', name: 'Apple' }, - { value: 'banana', name: 'Banana' }, - { value: 'citrus', name: 'Citrus' }, -] - -describe('Select', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('Rendering', () => { - it('should show the default selected item when defaultValue matches an item', () => { - render( - , - ) - - expect(screen.getByTitle('Banana'))!.toBeInTheDocument() - }) - - it('should render null selectedItem when defaultValue does not match any item', () => { - render( - , - ) - - // No item title should appear for a non-matching default - // No item title should appear for a non-matching default - // No item title should appear for a non-matching default - // No item title should appear for a non-matching default - // No item title should appear for a non-matching default - // No item title should appear for a non-matching default - // No item title should appear for a non-matching default - // No item title should appear for a non-matching default - // No item title should appear for a non-matching default - // No item title should appear for a non-matching default - // No item title should appear for a non-matching default - // No item title should appear for a non-matching default - // No item title should appear for a non-matching default - // No item title should appear for a non-matching default - // No item title should appear for a non-matching default - // No item title should appear for a non-matching default - // No item title should appear for a non-matching default - // No item title should appear for a non-matching default - // No item title should appear for a non-matching default - // No item title should appear for a non-matching default - // No item title should appear for a non-matching default - // No item title should appear for a non-matching default - // No item title should appear for a non-matching default - // No item title should appear for a non-matching default - // No item title should appear for a non-matching default - // No item title should appear for a non-matching default - // No item title should appear for a non-matching default - // No item title should appear for a non-matching default - // No item title should appear for a non-matching default - // No item title should appear for a non-matching default - // No item title should appear for a non-matching default - // No item title should appear for a non-matching default - expect(screen.queryByTitle('Apple')).not.toBeInTheDocument() - expect(screen.queryByTitle('Banana')).not.toBeInTheDocument() - }) - - it('should render with allowSearch=true (input mode)', () => { - render( - , - ) - - expect(screen.getByRole('combobox'))!.toBeInTheDocument() - }) - - it('should apply custom bgClassName', () => { - render( - , - ) - - expect(screen.getByTitle('Apple'))!.toBeInTheDocument() - }) - }) - - describe('User Interactions', () => { - it('should call onSelect when choosing an option from default select', async () => { - const user = userEvent.setup() - const onSelect = vi.fn() - - render( - , - ) - - await user.click(screen.getByTitle('Banana')) - await user.click(screen.getByText('Citrus')) - - expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ - value: 'citrus', - name: 'Citrus', - })) - }) - - it('should not open or select when default select is disabled', async () => { - const user = userEvent.setup() - const onSelect = vi.fn() - - render( - , - ) - - await user.click(screen.getByTitle('Banana')) - - expect(screen.queryByText('Citrus')).not.toBeInTheDocument() - expect(onSelect).not.toHaveBeenCalled() - }) - - it('should filter items when searching with allowSearch=true', async () => { - const user = userEvent.setup() - - render( - , - ) - - // First, click the chevron button to open the dropdown - const buttons = screen.getAllByRole('button') - await user.click(buttons[0]!) - - // Now type in the search input to filter - const input = screen.getByRole('combobox') - await user.clear(input) - await user.type(input, 'ban') - - // Citrus should be filtered away - // Citrus should be filtered away - // Citrus should be filtered away - // Citrus should be filtered away - // Citrus should be filtered away - // Citrus should be filtered away - // Citrus should be filtered away - // Citrus should be filtered away - // Citrus should be filtered away - // Citrus should be filtered away - // Citrus should be filtered away - // Citrus should be filtered away - // Citrus should be filtered away - // Citrus should be filtered away - // Citrus should be filtered away - // Citrus should be filtered away - // Citrus should be filtered away - // Citrus should be filtered away - // Citrus should be filtered away - // Citrus should be filtered away - // Citrus should be filtered away - // Citrus should be filtered away - // Citrus should be filtered away - // Citrus should be filtered away - // Citrus should be filtered away - // Citrus should be filtered away - // Citrus should be filtered away - // Citrus should be filtered away - // Citrus should be filtered away - // Citrus should be filtered away - // Citrus should be filtered away - // Citrus should be filtered away - expect(screen.queryByText('Citrus')).not.toBeInTheDocument() - }) - - it('should not filter or update query when disabled and allowSearch=true', async () => { - render( - , - ) - - const input = screen.getByRole('combobox') as HTMLInputElement - - // we must use fireEvent because userEvent throws on disabled inputs - fireEvent.change(input, { target: { value: 'ban' } }) - - // We just want to ensure it doesn't throw and covers the !disabled branch in onChange. - // Since it's disabled, no search dropdown should appear. - // We just want to ensure it doesn't throw and covers the !disabled branch in onChange. - // Since it's disabled, no search dropdown should appear. - // We just want to ensure it doesn't throw and covers the !disabled branch in onChange. - // Since it's disabled, no search dropdown should appear. - // We just want to ensure it doesn't throw and covers the !disabled branch in onChange. - // Since it's disabled, no search dropdown should appear. - // We just want to ensure it doesn't throw and covers the !disabled branch in onChange. - // Since it's disabled, no search dropdown should appear. - // We just want to ensure it doesn't throw and covers the !disabled branch in onChange. - // Since it's disabled, no search dropdown should appear. - // We just want to ensure it doesn't throw and covers the !disabled branch in onChange. - // Since it's disabled, no search dropdown should appear. - // We just want to ensure it doesn't throw and covers the !disabled branch in onChange. - // Since it's disabled, no search dropdown should appear. - // We just want to ensure it doesn't throw and covers the !disabled branch in onChange. - // Since it's disabled, no search dropdown should appear. - // We just want to ensure it doesn't throw and covers the !disabled branch in onChange. - // Since it's disabled, no search dropdown should appear. - // We just want to ensure it doesn't throw and covers the !disabled branch in onChange. - // Since it's disabled, no search dropdown should appear. - // We just want to ensure it doesn't throw and covers the !disabled branch in onChange. - // Since it's disabled, no search dropdown should appear. - // We just want to ensure it doesn't throw and covers the !disabled branch in onChange. - // Since it's disabled, no search dropdown should appear. - // We just want to ensure it doesn't throw and covers the !disabled branch in onChange. - // Since it's disabled, no search dropdown should appear. - // We just want to ensure it doesn't throw and covers the !disabled branch in onChange. - // Since it's disabled, no search dropdown should appear. - // We just want to ensure it doesn't throw and covers the !disabled branch in onChange. - // Since it's disabled, no search dropdown should appear. - // We just want to ensure it doesn't throw and covers the !disabled branch in onChange. - // Since it's disabled, no search dropdown should appear. - // We just want to ensure it doesn't throw and covers the !disabled branch in onChange. - // Since it's disabled, no search dropdown should appear. - // We just want to ensure it doesn't throw and covers the !disabled branch in onChange. - // Since it's disabled, no search dropdown should appear. - // We just want to ensure it doesn't throw and covers the !disabled branch in onChange. - // Since it's disabled, no search dropdown should appear. - // We just want to ensure it doesn't throw and covers the !disabled branch in onChange. - // Since it's disabled, no search dropdown should appear. - // We just want to ensure it doesn't throw and covers the !disabled branch in onChange. - // Since it's disabled, no search dropdown should appear. - // We just want to ensure it doesn't throw and covers the !disabled branch in onChange. - // Since it's disabled, no search dropdown should appear. - // We just want to ensure it doesn't throw and covers the !disabled branch in onChange. - // Since it's disabled, no search dropdown should appear. - // We just want to ensure it doesn't throw and covers the !disabled branch in onChange. - // Since it's disabled, no search dropdown should appear. - // We just want to ensure it doesn't throw and covers the !disabled branch in onChange. - // Since it's disabled, no search dropdown should appear. - // We just want to ensure it doesn't throw and covers the !disabled branch in onChange. - // Since it's disabled, no search dropdown should appear. - // We just want to ensure it doesn't throw and covers the !disabled branch in onChange. - // Since it's disabled, no search dropdown should appear. - // We just want to ensure it doesn't throw and covers the !disabled branch in onChange. - // Since it's disabled, no search dropdown should appear. - // We just want to ensure it doesn't throw and covers the !disabled branch in onChange. - // Since it's disabled, no search dropdown should appear. - // We just want to ensure it doesn't throw and covers the !disabled branch in onChange. - // Since it's disabled, no search dropdown should appear. - // We just want to ensure it doesn't throw and covers the !disabled branch in onChange. - // Since it's disabled, no search dropdown should appear. - expect(screen.queryByRole('listbox')).not.toBeInTheDocument() - }) - - it('should not call onSelect when a disabled Combobox value changes externally', () => { - // In Headless UI, disabled elements do not fire events via React. - // To cover the defensive `if (!disabled)` branches inside the callbacks, - // we temporarily remove the disabled attribute from the DOM to force the event through. - const onSelect = vi.fn() - - render( - , - ) - - const button = screen.getAllByRole('button')[0] as HTMLButtonElement - button.removeAttribute('disabled') - button.removeAttribute('aria-disabled') - fireEvent.click(button) - - expect(onSelect).not.toHaveBeenCalled() - }) - - it('should not open dropdown when clicking ComboboxButton while disabled and allowSearch=false', () => { - // Covers line 128-141 where disabled check prevents open state toggle - render( - , - ) - - // The main trigger button should be disabled - const button = screen.getAllByRole('button')[0] as HTMLButtonElement - button.removeAttribute('disabled') - - const chevron = screen.getAllByRole('button')[1] as HTMLButtonElement - chevron.removeAttribute('disabled') - - fireEvent.click(button) - fireEvent.click(chevron) - - // Dropdown options should not appear because the internal `if (!disabled)` guards it - // Dropdown options should not appear because the internal `if (!disabled)` guards it - // Dropdown options should not appear because the internal `if (!disabled)` guards it - // Dropdown options should not appear because the internal `if (!disabled)` guards it - // Dropdown options should not appear because the internal `if (!disabled)` guards it - // Dropdown options should not appear because the internal `if (!disabled)` guards it - // Dropdown options should not appear because the internal `if (!disabled)` guards it - // Dropdown options should not appear because the internal `if (!disabled)` guards it - // Dropdown options should not appear because the internal `if (!disabled)` guards it - // Dropdown options should not appear because the internal `if (!disabled)` guards it - // Dropdown options should not appear because the internal `if (!disabled)` guards it - // Dropdown options should not appear because the internal `if (!disabled)` guards it - // Dropdown options should not appear because the internal `if (!disabled)` guards it - // Dropdown options should not appear because the internal `if (!disabled)` guards it - // Dropdown options should not appear because the internal `if (!disabled)` guards it - // Dropdown options should not appear because the internal `if (!disabled)` guards it - // Dropdown options should not appear because the internal `if (!disabled)` guards it - // Dropdown options should not appear because the internal `if (!disabled)` guards it - // Dropdown options should not appear because the internal `if (!disabled)` guards it - // Dropdown options should not appear because the internal `if (!disabled)` guards it - // Dropdown options should not appear because the internal `if (!disabled)` guards it - // Dropdown options should not appear because the internal `if (!disabled)` guards it - // Dropdown options should not appear because the internal `if (!disabled)` guards it - // Dropdown options should not appear because the internal `if (!disabled)` guards it - // Dropdown options should not appear because the internal `if (!disabled)` guards it - // Dropdown options should not appear because the internal `if (!disabled)` guards it - // Dropdown options should not appear because the internal `if (!disabled)` guards it - // Dropdown options should not appear because the internal `if (!disabled)` guards it - // Dropdown options should not appear because the internal `if (!disabled)` guards it - // Dropdown options should not appear because the internal `if (!disabled)` guards it - // Dropdown options should not appear because the internal `if (!disabled)` guards it - // Dropdown options should not appear because the internal `if (!disabled)` guards it - expect(screen.queryByText('Banana')).not.toBeInTheDocument() - }) - - it('should handle missing item nicely in renderTrigger', () => { - render( - { - return ( - - {/* eslint-disable-next-line style/jsx-one-expression-per-line */} - Custom: {selected?.name ?? 'Fallback'} - - ) - }} - />, - ) - expect(screen.getByText('Custom: Fallback'))!.toBeInTheDocument() - }) - - it('should render with custom renderOption', async () => { - const user = userEvent.setup() - - render( - ( - - {item.name} - {selected ? ' ✓' : ''} - - )} - />, - ) - - await user.click(screen.getByTitle('Apple')) - - expect(screen.getByTestId('custom-opt-apple'))!.toBeInTheDocument() - expect(screen.getByTestId('custom-opt-banana'))!.toBeInTheDocument() - }) - - it('should show ChevronUpIcon when open and ChevronDownIcon when closed', async () => { - const user = userEvent.setup() - - render( - , - ) - - // Initially closed — should have a chevron button - await user.click(screen.getByTitle('Apple')) - // Dropdown is now open - // Dropdown is now open - expect(screen.getByText('Banana'))!.toBeInTheDocument() - }) - }) -}) - -// ────────────────────────────────────────────────────────────── -// SimpleSelect (Listbox-based) -// ────────────────────────────────────────────────────────────── -describe('SimpleSelect', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('Rendering', () => { - it('should render i18n placeholder when no selection exists', () => { - render( - , - ) - - expect(screen.getByText(/select/i))!.toBeInTheDocument() - }) - - it('should render custom placeholder when provided', () => { - render( - , - ) - - expect(screen.getByText('Pick one'))!.toBeInTheDocument() - }) - - it('should render selected item name when defaultValue matches', () => { - render( - , - ) - - expect(screen.getByText('Banana'))!.toBeInTheDocument() - }) - - it('should render with isLoading=true showing spinner', () => { - render( - , - ) - - // Loader icon should be rendered (RiLoader4Line has aria hidden) - // Loader icon should be rendered (RiLoader4Line has aria hidden) - expect(screen.getByText('Apple'))!.toBeInTheDocument() - }) - - it('should render group items as non-selectable headers', async () => { - const user = userEvent.setup() - const groupItems: Item[] = [ - { value: 'fruits-group', name: 'Fruits', isGroup: true }, - { value: 'apple', name: 'Apple' }, - { value: 'banana', name: 'Banana' }, - ] - - render( - , - ) - - await user.click(screen.getByRole('button')) - expect(screen.getByText('Fruits'))!.toBeInTheDocument() - }) - - it('should not render ListboxOptions when disabled', () => { - render( - , - ) - - expect(screen.getByText('Apple'))!.toBeInTheDocument() - }) - - it('should not open SimpleSelect when disabled', async () => { - const user = userEvent.setup() - - render( - , - ) - - const button = screen.getByRole('button') - await user.click(button) - - // Banana should not be visible as it won't open - // Banana should not be visible as it won't open - // Banana should not be visible as it won't open - // Banana should not be visible as it won't open - // Banana should not be visible as it won't open - // Banana should not be visible as it won't open - // Banana should not be visible as it won't open - // Banana should not be visible as it won't open - // Banana should not be visible as it won't open - // Banana should not be visible as it won't open - // Banana should not be visible as it won't open - // Banana should not be visible as it won't open - // Banana should not be visible as it won't open - // Banana should not be visible as it won't open - // Banana should not be visible as it won't open - // Banana should not be visible as it won't open - // Banana should not be visible as it won't open - // Banana should not be visible as it won't open - // Banana should not be visible as it won't open - // Banana should not be visible as it won't open - // Banana should not be visible as it won't open - // Banana should not be visible as it won't open - // Banana should not be visible as it won't open - // Banana should not be visible as it won't open - // Banana should not be visible as it won't open - // Banana should not be visible as it won't open - // Banana should not be visible as it won't open - // Banana should not be visible as it won't open - // Banana should not be visible as it won't open - // Banana should not be visible as it won't open - // Banana should not be visible as it won't open - // Banana should not be visible as it won't open - expect(screen.queryByText('Banana')).not.toBeInTheDocument() - }) - - it('should not trigger onSelect via onChange when Listbox is disabled', () => { - // Covers line 228 (!disabled check) inside Listbox onChange - const onSelect = vi.fn() - render( - , - ) - - const button = screen.getByRole('button') as HTMLButtonElement - button.removeAttribute('disabled') - button.removeAttribute('aria-disabled') - fireEvent.click(button) - - expect(onSelect).not.toHaveBeenCalled() - }) - }) - - describe('User Interactions', () => { - it('should call onSelect and update display when an option is chosen', async () => { - const user = userEvent.setup() - const onSelect = vi.fn() - - render( - , - ) - - await user.click(screen.getByRole('button')) - await user.click(screen.getByText('Apple')) - - expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ - value: 'apple', - name: 'Apple', - })) - expect(screen.getByText('Apple'))!.toBeInTheDocument() - }) - - it('should pass open state into renderTrigger', async () => { - const user = userEvent.setup() - - render( - ( - {`${selected?.name ?? 'none'}-${open ? 'open' : 'closed'}`} - )} - />, - ) - - expect(screen.getByText('none-closed'))!.toBeInTheDocument() - await user.click(screen.getByText('none-closed')) - expect(screen.getByText('none-open'))!.toBeInTheDocument() - }) - - it('should clear selection when XMark is clicked (notClearable=false)', async () => { - const user = userEvent.setup() - const onSelect = vi.fn() - - render( - , - ) - - // The clear button (XMarkIcon) should be visible when an item is selected - const clearBtn = screen.getByRole('button').querySelector('[aria-hidden="false"]') - expect(clearBtn)!.toBeInTheDocument() - - await user.click(clearBtn!) - - expect(onSelect).toHaveBeenCalledWith({ name: '', value: '' }) - }) - - it('should not show clear button when notClearable is true', () => { - render( - , - ) - - const clearBtn = screen.getByRole('button').querySelector('[aria-hidden="false"]') - expect(clearBtn).not.toBeInTheDocument() - }) - - it('should hide check marks when hideChecked is true', async () => { - const user = userEvent.setup() - - render( - , - ) - - await user.click(screen.getByRole('button')) - // The selected item should be visible but without a check icon - expect(screen.getAllByText('Apple').length).toBeGreaterThanOrEqual(1) - }) - - it('should render with custom renderOption in SimpleSelect', async () => { - const user = userEvent.setup() - - render( - ( - - {item.name} - {selected ? ' (selected)' : ''} - - )} - />, - ) - - await user.click(screen.getByRole('button')) - expect(screen.getByTestId('simple-opt-apple'))!.toBeInTheDocument() - expect(screen.getByTestId('simple-opt-banana'))!.toBeInTheDocument() - // Verify the custom render shows selected state - // Verify the custom render shows selected state - expect(screen.getByTestId('simple-opt-apple'))!.toHaveTextContent('Apple (selected)') - }) - - it('should call onOpenChange when the button is clicked', async () => { - const user = userEvent.setup() - const onOpenChange = vi.fn() - - render( - , - ) - - await user.click(screen.getByRole('button')) - expect(onOpenChange).toHaveBeenCalled() - }) - - it('should handle disabled items that cannot be selected', async () => { - const user = userEvent.setup() - const onSelect = vi.fn() - const disabledItems: Item[] = [ - { value: 'apple', name: 'Apple' }, - { value: 'banana', name: 'Banana', disabled: true }, - { value: 'citrus', name: 'Citrus' }, - ] - - render( - , - ) - - await user.click(screen.getByRole('button')) - // Banana should be rendered but not selectable - // Banana should be rendered but not selectable - expect(screen.getByText('Banana'))!.toBeInTheDocument() - }) - }) -}) - -// ────────────────────────────────────────────────────────────── -// PortalSelect -// ────────────────────────────────────────────────────────────── -describe('PortalSelect', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('Rendering', () => { - it('should show placeholder when value is empty', () => { - render( - , - ) - - expect(screen.getByText(/select/i))!.toBeInTheDocument() - }) - - it('should show selected item name when value matches', () => { - render( - , - ) - - expect(screen.getByTitle('Banana'))!.toBeInTheDocument() - }) - - it('should render with custom placeholder', () => { - render( - , - ) - - expect(screen.getByText('Choose fruit'))!.toBeInTheDocument() - }) - - it('should render with renderTrigger', () => { - render( - ( - {item?.name ?? 'None'} - )} - />, - ) - - expect(screen.getByTestId('custom-trigger'))!.toHaveTextContent('Apple') - }) - - it('should show INSTALLED badge when installedValue differs from selected value', () => { - render( - , - ) - - expect(screen.getByTitle('Banana'))!.toBeInTheDocument() - }) - - it('should apply triggerClassNameFn', () => { - const triggerClassNameFn = vi.fn((open: boolean) => open ? 'trigger-open' : 'trigger-closed') - - render( - , - ) - - expect(triggerClassNameFn).toHaveBeenCalledWith(false) - }) - }) - - describe('User Interactions', () => { - it('should call onSelect when choosing an option from portal dropdown', async () => { - const user = userEvent.setup() - const onSelect = vi.fn() - - render( - , - ) - - await user.click(screen.getByText(/select/i)) - await user.click(screen.getByText('Citrus')) - - expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ - value: 'citrus', - name: 'Citrus', - })) - }) - - it('should not open the portal dropdown when readonly is true', async () => { - const user = userEvent.setup() - - render( - , - ) - - await user.click(screen.getByText(/select/i)) - expect(screen.queryByTitle('Citrus')).not.toBeInTheDocument() - }) - - it('should show check mark for selected item when hideChecked is false', async () => { - const user = userEvent.setup() - - render( - , - ) - - await user.click(screen.getByTitle('Banana')) - // Banana option in the dropdown should be displayed - const allBananas = screen.getAllByText('Banana') - expect(allBananas.length).toBeGreaterThanOrEqual(1) - }) - - it('should hide check marks when hideChecked is true', async () => { - const user = userEvent.setup() - - render( - , - ) - - await user.click(screen.getByTitle('Banana')) - expect(screen.getAllByText('Banana').length).toBeGreaterThanOrEqual(1) - }) - - it('should display INSTALLED badge in dropdown for installed items', async () => { - const user = userEvent.setup() - - render( - , - ) - - await user.click(screen.getByTitle('Banana')) - // The installed badge should appear in the dropdown - // The installed badge should appear in the dropdown - expect(screen.getByText('INSTALLED'))!.toBeInTheDocument() - }) - - it('should render item.extra content in dropdown', async () => { - const user = userEvent.setup() - const extraItems: Item[] = [ - { value: 'apple', name: 'Apple', extra: Extra }, - { value: 'banana', name: 'Banana' }, - ] - - render( - , - ) - - await user.click(screen.getByText(/select/i)) - expect(screen.getByTestId('extra-apple'))!.toBeInTheDocument() - }) - }) -}) diff --git a/web/app/components/base/select/__tests__/locale-signin.spec.tsx b/web/app/components/base/select/__tests__/locale-signin.spec.tsx deleted file mode 100644 index 1eca2b1aed..0000000000 --- a/web/app/components/base/select/__tests__/locale-signin.spec.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { render, screen } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import LocaleSigninSelect from '../locale-signin' - -const localeItems = [ - { value: 'en-US', name: 'English (US)' }, - { value: 'zh-Hans', name: '简体中文' }, - { value: 'ja-JP', name: '日本語' }, -] - -describe('LocaleSigninSelect', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - // Rendering behavior for selected value and fallback state. - describe('Rendering', () => { - it('should render selected locale name when value matches an item', () => { - render( - , - ) - - expect(screen.getByRole('button', { name: /english \(us\)/i })).toBeInTheDocument() - }) - - it('should render trigger without selected label when value is not found', () => { - render( - , - ) - - const trigger = screen.getByRole('button') - expect(trigger).toBeInTheDocument() - expect(trigger).not.toHaveTextContent('English (US)') - }) - }) - - // Menu interactions and callback behavior. - describe('User Interactions', () => { - it('should call onChange with selected locale value when clicking an option', async () => { - const user = userEvent.setup() - const onChange = vi.fn() - - render( - , - ) - - await user.click(screen.getByRole('button', { name: /english \(us\)/i })) - await user.click(screen.getByRole('menuitem', { name: '日本語' })) - - expect(onChange).toHaveBeenCalledWith('ja-JP') - }) - - it('should render all locale options when menu is opened', async () => { - const user = userEvent.setup() - - render( - , - ) - - await user.click(screen.getByRole('button', { name: /english \(us\)/i })) - - expect(screen.getByRole('menuitem', { name: 'English (US)' })).toBeInTheDocument() - expect(screen.getByRole('menuitem', { name: '简体中文' })).toBeInTheDocument() - expect(screen.getByRole('menuitem', { name: '日本語' })).toBeInTheDocument() - }) - }) - - // Edge behavior for missing callback and empty data. - describe('Edge Cases', () => { - it('should not throw when onChange is undefined and option is selected', async () => { - const user = userEvent.setup() - - render( - , - ) - - await user.click(screen.getByRole('button', { name: /english \(us\)/i })) - await user.click(screen.getByRole('menuitem', { name: '简体中文' })) - // No assertion needed — test verifies no exception is thrown during selection without onChange. - }) - - it('should render no options when items are empty', async () => { - const user = userEvent.setup() - - render( - , - ) - - await user.click(screen.getByRole('button')) - expect(screen.queryAllByRole('menuitem')).toHaveLength(0) - }) - }) -}) diff --git a/web/app/components/base/select/__tests__/pure.spec.tsx b/web/app/components/base/select/__tests__/pure.spec.tsx deleted file mode 100644 index 7b62754ec4..0000000000 --- a/web/app/components/base/select/__tests__/pure.spec.tsx +++ /dev/null @@ -1,197 +0,0 @@ -import type { Option } from '../pure' -import { render, screen } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import PureSelect from '../pure' - -const options: Option[] = [ - { label: 'Apple', value: 'apple' }, - { label: 'Banana', value: 'banana' }, - { label: 'Citrus', value: 'citrus' }, -] - -describe('PureSelect', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - // Rendering and placeholder behavior in single/multiple modes. - describe('Rendering', () => { - it('should render i18n placeholder when single value is empty', () => { - render() - expect(screen.getByTitle(/select/i))!.toBeInTheDocument() - }) - - it('should render custom placeholder when provided', () => { - render() - expect(screen.getByTitle('Choose value'))!.toBeInTheDocument() - }) - - it('should render selected option label in single mode', () => { - render() - expect(screen.getByTitle('Banana'))!.toBeInTheDocument() - }) - - it('should render selected count text in multiple mode', () => { - render() - expect(screen.getByText(/selected/i))!.toBeInTheDocument() - }) - - it('should render placeholder in multiple mode when selected values are empty', () => { - render() - expect(screen.getByTitle('Pick fruits'))!.toBeInTheDocument() - }) - }) - - // Interaction behavior in single and multiple selection modes. - describe('User Interactions', () => { - it('should call onChange and close popup when selecting an option in single mode', async () => { - const user = userEvent.setup() - const onChange = vi.fn() - - render() - - await user.click(screen.getByTitle(/select/i)) - expect(screen.getByTitle('Banana'))!.toBeInTheDocument() - - await user.click(screen.getByTitle('Banana')) - - expect(onChange).toHaveBeenCalledWith('banana') - expect(screen.queryByTitle('Citrus')).not.toBeInTheDocument() - }) - - it('should append a new value in multiple mode when clicking an unselected option', async () => { - const user = userEvent.setup() - const onChange = vi.fn() - - render( - , - ) - - await user.click(screen.getByText(/common\.dynamicSelect\.selected/i)) - await user.click(screen.getAllByTitle('Banana')[0]!) - - expect(onChange).toHaveBeenCalledWith(['apple', 'banana']) - }) - - it('should remove an existing value in multiple mode when clicking a selected option', async () => { - const user = userEvent.setup() - const onChange = vi.fn() - - render( - , - ) - - await user.click(screen.getByText(/common\.dynamicSelect\.selected/i)) - await user.click(screen.getAllByTitle('Apple')[0]!) - - expect(onChange).toHaveBeenCalledWith(['banana']) - }) - - it('should start with empty array when multiple value is undefined', async () => { - const user = userEvent.setup() - const onChange = vi.fn() - - render( - , - ) - - await user.click(screen.getAllByTitle('Apple')[0]!) - expect(onChange).toHaveBeenCalledWith(['apple']) - }) - }) - - // Controlled open state and disabled behavior. - describe('Container And Disabled Props', () => { - it('should call containerProps.onOpenChange when trigger is clicked in controlled mode', async () => { - const user = userEvent.setup() - const onOpenChange = vi.fn() - - render( - , - ) - - expect(screen.getByTitle('Apple'))!.toBeInTheDocument() - await user.click(screen.getByTitle(/select/i)) - - expect(onOpenChange).toHaveBeenCalledWith(false) - }) - - it('should not open popup when disabled', async () => { - const user = userEvent.setup() - - render( - , - ) - - await user.click(screen.getByTitle(/select/i)) - expect(screen.queryByTitle('Apple')).not.toBeInTheDocument() - }) - - it('should ignore option clicks when disabled even if popup is open', async () => { - const user = userEvent.setup() - const onChange = vi.fn() - - render( - , - ) - - await user.click(screen.getAllByTitle('Apple')[0]!) - expect(onChange).not.toHaveBeenCalled() - }) - }) - - // Style and popup customization props. - describe('Style Props', () => { - it('should apply trigger and popup class names and render popup title', () => { - render( - , - ) - - const triggerLabel = screen.getByTitle(/select/i) - const trigger = triggerLabel.parentElement - - expect(trigger)!.toHaveClass('trigger-class') - expect(document.querySelector('.wrapper-class'))!.toBeInTheDocument() - expect(document.querySelector('.popup-class'))!.toBeInTheDocument() - expect(document.querySelectorAll('.item-class')).toHaveLength(options.length) - expect(screen.getByText('Available options'))!.toHaveClass('title-class') - }) - }) -}) diff --git a/web/app/components/base/select/custom.tsx b/web/app/components/base/select/custom.tsx deleted file mode 100644 index dbf4995c22..0000000000 --- a/web/app/components/base/select/custom.tsx +++ /dev/null @@ -1,171 +0,0 @@ -import type { - PortalToFollowElemOptions, -} from '@/app/components/base/portal-to-follow-elem' -import { cn } from '@langgenius/dify-ui/cn' -import { - RiArrowDownSLine, - RiCheckLine, -} from '@remixicon/react' -import { - useCallback, - useState, -} from 'react' -import { useTranslation } from 'react-i18next' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' - -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.JSX.Element - CustomOption?: (option: T, selected: boolean) => React.JSX.Element -} -const CustomSelect = ({ - options, - value, - onChange, - containerProps, - triggerProps, - popupProps, - CustomTrigger, - CustomOption, -}: CustomSelectProps) => { - const { t } = useTranslation() - const { - open, - onOpenChange, - placement, - offset, - triggerPopupSameWidth = true, - } = 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('placeholder.select', { ns: 'common' }) - - 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 diff --git a/web/app/components/base/select/index.stories.tsx b/web/app/components/base/select/index.stories.tsx deleted file mode 100644 index 629fd7f64d..0000000000 --- a/web/app/components/base/select/index.stories.tsx +++ /dev/null @@ -1,572 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/nextjs-vite' -import type { Item } from '.' -import { useState } from 'react' -import Select, { PortalSelect, SimpleSelect } from '.' - -const meta = { - title: 'Base/Data Entry/Select', - component: SimpleSelect, - parameters: { - layout: 'centered', - docs: { - description: { - component: 'Select component with three variants: Select (with search), SimpleSelect (basic dropdown), and PortalSelect (portal-based positioning). Built on Headless UI.', - }, - }, - }, - tags: ['autodocs'], - argTypes: { - placeholder: { - control: 'text', - description: 'Placeholder text', - }, - disabled: { - control: 'boolean', - description: 'Disabled state', - }, - notClearable: { - control: 'boolean', - description: 'Hide clear button', - }, - hideChecked: { - control: 'boolean', - description: 'Hide check icon on selected item', - }, - }, - args: { - onSelect: (item) => { - console.log('Selected:', item) - }, - }, -} satisfies Meta - -export default meta -type Story = StoryObj - -const fruits: Item[] = [ - { value: 'apple', name: 'Apple' }, - { value: 'banana', name: 'Banana' }, - { value: 'cherry', name: 'Cherry' }, - { value: 'date', name: 'Date' }, - { value: 'elderberry', name: 'Elderberry' }, -] - -const countries: Item[] = [ - { value: 'us', name: 'United States' }, - { value: 'uk', name: 'United Kingdom' }, - { value: 'ca', name: 'Canada' }, - { value: 'au', name: 'Australia' }, - { value: 'de', name: 'Germany' }, - { value: 'fr', name: 'France' }, - { value: 'jp', name: 'Japan' }, - { value: 'cn', name: 'China' }, -] - -// SimpleSelect Demo -const SimpleSelectDemo = (args: any) => { - const [selected, setSelected] = useState(args.defaultValue || '') - - return ( - - { - setSelected(item.value) - console.log('Selected:', item) - }} - /> - {selected && ( - - Selected: - {' '} - {selected} - - )} - - ) -} - -// Default SimpleSelect -export const Default: Story = { - render: args => , - args: { - placeholder: 'Select a fruit...', - defaultValue: 'apple', - items: [], - }, -} - -// With placeholder (no selection) -export const WithPlaceholder: Story = { - render: args => , - args: { - placeholder: 'Choose an option...', - defaultValue: '', - items: [], - }, -} - -// Disabled state -export const Disabled: Story = { - render: args => , - args: { - placeholder: 'Select a fruit...', - defaultValue: 'banana', - disabled: true, - items: [], - }, -} - -// Not clearable -export const NotClearable: Story = { - render: args => , - args: { - placeholder: 'Select a fruit...', - defaultValue: 'cherry', - notClearable: true, - items: [], - }, -} - -// Hide checked icon -export const HideChecked: Story = { - render: args => , - args: { - placeholder: 'Select a fruit...', - defaultValue: 'apple', - hideChecked: true, - items: [], - }, -} - -// Select with search -const WithSearchDemo = () => { - const [selected, setSelected] = useState('us') - - return ( - - { - setSelected(item.value as string) - console.log('Selected:', item) - }} - allowSearch={true} - /> - - Selected: - {' '} - {selected} - - - ) -} - -export const WithSearch: Story = { - render: () => , - parameters: { controls: { disable: true } }, -} as unknown as Story - -// PortalSelect -const PortalSelectVariantDemo = () => { - const [selected, setSelected] = useState('apple') - - return ( - - { - setSelected(item.value as string) - console.log('Selected:', item) - }} - placeholder="Select a fruit..." - /> - - Selected: - {' '} - {selected} - - - ) -} - -export const PortalSelectVariant: Story = { - render: () => , - parameters: { controls: { disable: true } }, -} as unknown as Story - -// Custom render option -const CustomRenderOptionDemo = () => { - const [selected, setSelected] = useState('us') - - const countriesWithFlags = [ - { value: 'us', name: 'United States', flag: '🇺🇸' }, - { value: 'uk', name: 'United Kingdom', flag: '🇬🇧' }, - { value: 'ca', name: 'Canada', flag: '🇨🇦' }, - { value: 'au', name: 'Australia', flag: '🇦🇺' }, - { value: 'de', name: 'Germany', flag: '🇩🇪' }, - ] - - return ( - - setSelected(item.value as string)} - renderOption={({ item, selected }) => ( - - - {item.flag} - {item.name} - - {selected && ✓} - - )} - /> - - ) -} - -export const CustomRenderOption: Story = { - render: () => , - parameters: { controls: { disable: true } }, -} as unknown as Story - -// Loading state -export const LoadingState: Story = { - render: () => { - return ( - - undefined} - placeholder="Loading options..." - isLoading={true} - /> - - ) - }, - parameters: { controls: { disable: true } }, -} as unknown as Story - -// Real-world example - Form field -const FormFieldDemo = () => { - const [formData, setFormData] = useState({ - country: 'us', - language: 'en', - timezone: 'pst', - }) - - const languages = [ - { value: 'en', name: 'English' }, - { value: 'es', name: 'Spanish' }, - { value: 'fr', name: 'French' }, - { value: 'de', name: 'German' }, - { value: 'zh', name: 'Chinese' }, - ] - - const timezones = [ - { value: 'pst', name: 'Pacific Time (PST)' }, - { value: 'mst', name: 'Mountain Time (MST)' }, - { value: 'cst', name: 'Central Time (CST)' }, - { value: 'est', name: 'Eastern Time (EST)' }, - ] - - return ( - - User Preferences - - - Country - setFormData({ ...formData, country: item.value as string })} - /> - - - Language - setFormData({ ...formData, language: item.value as string })} - /> - - - Timezone - setFormData({ ...formData, timezone: item.value as string })} - /> - - - - - Country: - {' '} - {formData.country} - - - Language: - {' '} - {formData.language} - - - Timezone: - {' '} - {formData.timezone} - - - - ) -} - -export const FormField: Story = { - render: () => , - parameters: { controls: { disable: true } }, -} as unknown as Story - -// Real-world example - Filter selector -const FilterSelectorDemo = () => { - const [status, setStatus] = useState('all') - const [priority, setPriority] = useState('all') - - const statusOptions = [ - { value: 'all', name: 'All Status' }, - { value: 'active', name: 'Active' }, - { value: 'pending', name: 'Pending' }, - { value: 'completed', name: 'Completed' }, - { value: 'cancelled', name: 'Cancelled' }, - ] - - const priorityOptions = [ - { value: 'all', name: 'All Priorities' }, - { value: 'high', name: 'High Priority' }, - { value: 'medium', name: 'Medium Priority' }, - { value: 'low', name: 'Low Priority' }, - ] - - return ( - - Task Filters - - - Status - setStatus(item.value as string)} - notClearable - /> - - - Priority - setPriority(item.value as string)} - notClearable - /> - - - - Active Filters: - - - Status: - {' '} - {status} - - - Priority: - {' '} - {priority} - - - - - ) -} - -export const FilterSelector: Story = { - render: () => , - parameters: { controls: { disable: true } }, -} as unknown as Story - -// Real-world example - Version selector with badge -const VersionSelectorDemo = () => { - const [selectedVersion, setSelectedVersion] = useState('2.1.0') - - const versions = [ - { value: '3.0.0', name: 'v3.0.0 (Beta)' }, - { value: '2.1.0', name: 'v2.1.0 (Latest)' }, - { value: '2.0.5', name: 'v2.0.5' }, - { value: '2.0.4', name: 'v2.0.4' }, - { value: '1.9.8', name: 'v1.9.8' }, - ] - - return ( - - Select Version - setSelectedVersion(item.value as string)} - installedValue="2.0.5" - placeholder="Choose version..." - /> - - {selectedVersion !== '2.0.5' && ( - - ⚠️ Version change detected - - )} - - Current: - {selectedVersion} - - Installed: 2.0.5 - - - ) -} - -export const VersionSelector: Story = { - render: () => , - parameters: { controls: { disable: true } }, -} as unknown as Story - -// Real-world example - Settings dropdown -const SettingsDropdownDemo = () => { - const [theme, setTheme] = useState('light') - const [fontSize, setFontSize] = useState('medium') - - const themeOptions = [ - { value: 'light', name: '☀️ Light Mode' }, - { value: 'dark', name: '🌙 Dark Mode' }, - { value: 'auto', name: '🔄 Auto (System)' }, - ] - - const fontSizeOptions = [ - { value: 'small', name: 'Small (12px)' }, - { value: 'medium', name: 'Medium (14px)' }, - { value: 'large', name: 'Large (16px)' }, - { value: 'xlarge', name: 'Extra Large (18px)' }, - ] - - return ( - - Display Settings - - - Theme - setTheme(item.value as string)} - notClearable - /> - - - Font Size - setFontSize(item.value as string)} - notClearable - /> - - - - ) -} - -export const SettingsDropdown: Story = { - render: () => , - parameters: { controls: { disable: true } }, -} as unknown as Story - -// Comparison of variants -const VariantComparisonDemo = () => { - const [simple, setSimple] = useState('apple') - const [withSearch, setWithSearch] = useState('us') - const [portal, setPortal] = useState('banana') - - return ( - - Select Variants Comparison - - - SimpleSelect (Basic) - - setSimple(item.value as string)} - placeholder="Choose a fruit..." - /> - - Standard dropdown without search - - - - Select (With Search) - - setWithSearch(item.value as string)} - allowSearch={true} - /> - - Dropdown with search/filter capability - - - - PortalSelect (Portal-based) - - setPortal(item.value as string)} - placeholder="Choose a fruit..." - /> - - Portal-based positioning for better overflow handling - - - - ) -} - -export const VariantComparison: Story = { - render: () => , - parameters: { controls: { disable: true } }, -} as unknown as Story - -// Interactive playground -const PlaygroundDemo = () => { - const [selected, setSelected] = useState('apple') - - return ( - - setSelected(item.value as string)} - placeholder="Select an option..." - /> - - ) -} - -export const Playground: Story = { - render: () => , - parameters: { controls: { disable: true } }, -} as unknown as Story diff --git a/web/app/components/base/select/index.tsx b/web/app/components/base/select/index.tsx deleted file mode 100644 index 5033f381c2..0000000000 --- a/web/app/components/base/select/index.tsx +++ /dev/null @@ -1,441 +0,0 @@ -'use client' -/** - * @deprecated Use `@langgenius/dify-ui/select` instead. - * This component will be removed after migration is complete. - * See: https://github.com/langgenius/dify/issues/32767 - */ -import type { FC } from 'react' -import { Combobox, ComboboxButton, ComboboxInput, ComboboxOption, ComboboxOptions, Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react' -import { ChevronDownIcon, ChevronUpIcon, XMarkIcon } from '@heroicons/react/20/solid' -import { cn } from '@langgenius/dify-ui/cn' -import { RiCheckLine, RiLoader4Line } from '@remixicon/react' -import * as React from 'react' -import { useEffect, useRef, useState } from 'react' -import { useTranslation } from 'react-i18next' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' -import Badge from '../badge/index' - -const defaultItems = [ - { value: 1, name: 'option1' }, - { value: 2, name: 'option2' }, - { value: 3, name: 'option3' }, - { value: 4, name: 'option4' }, - { value: 5, name: 'option5' }, - { value: 6, name: 'option6' }, - { value: 7, name: 'option7' }, -] - -export type Item = { - value: number | string - name: string - isGroup?: boolean - disabled?: boolean - extra?: React.ReactNode -} & Record - -type ISelectProps = { - className?: string - wrapperClassName?: string - renderTrigger?: (value: Item | null, isOpen: boolean) => React.JSX.Element | null - items?: Item[] - defaultValue?: number | string - disabled?: boolean - onSelect: (value: Item) => void - allowSearch?: boolean - bgClassName?: string - placeholder?: string - overlayClassName?: string - optionWrapClassName?: string - optionClassName?: string - hideChecked?: boolean - notClearable?: boolean - renderOption?: ({ - item, - selected, - }: { - item: Item - selected: boolean - }) => React.ReactNode - isLoading?: boolean - onOpenChange?: (open: boolean) => void -} -const Select: FC = ({ - className, - items = defaultItems, - defaultValue = 1, - disabled = false, - onSelect, - allowSearch = true, - bgClassName = 'bg-components-input-bg-normal', - overlayClassName, - optionClassName, - renderOption, -}) => { - const [query, setQuery] = useState('') - const [open, setOpen] = useState(false) - - const [selectedItem, setSelectedItem] = useState(null) - // Ensure selectedItem is properly set when defaultValue or items change - useEffect(() => { - let defaultSelect = null - // Handle cases where defaultValue might be undefined, null, or empty string - defaultSelect = (defaultValue && items.find((item: Item) => item.value === defaultValue)) || null - setSelectedItem(defaultSelect) - }, [defaultValue, items]) - - const filteredItems: Item[] - = query === '' - ? items - : items.filter((item) => { - return item.name.toLowerCase().includes(query.toLowerCase()) - }) - - return ( - { - if (!disabled) { - setSelectedItem(value) - setOpen(false) - onSelect(value as Item) - } - }} - > - - - {allowSearch - ? ( - { - if (!disabled) - setQuery(event.target.value) - }} - displayValue={(item: Item) => item?.name} - /> - ) - : ( - { - if (!disabled) - setOpen(!open) - } - } - className={cn(`flex h-9 w-full items-center rounded-lg border-0 ${bgClassName} py-1.5 pr-10 pl-3 shadow-sm group-hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:outline-hidden sm:text-sm sm:leading-6`, optionClassName)} - > - {selectedItem?.name} - - )} - { - if (!disabled) - setOpen(!open) - } - } - > - {open ? : } - - - - {(filteredItems.length > 0 && open) && ( - - {filteredItems.map((item: Item) => ( - - cn('relative cursor-default rounded-lg py-2 pr-9 pl-3 text-text-secondary select-none hover:bg-state-base-hover', active ? 'bg-state-base-hover' : '', optionClassName)} - > - {({ /* active, */ selected }) => ( - <> - {renderOption - ? renderOption({ item, selected }) - : ( - <> - {item.name} - {selected && ( - - - - )} - > - )} - > - )} - - ))} - - )} - - - ) -} - -const SimpleSelect: FC = ({ - className, - wrapperClassName = '', - renderTrigger, - items = defaultItems, - defaultValue = 1, - disabled = false, - onSelect, - onOpenChange, - placeholder, - optionWrapClassName, - optionClassName, - hideChecked, - notClearable, - renderOption, - isLoading = false, -}) => { - const { t } = useTranslation() - const localPlaceholder = placeholder || t('placeholder.select', { ns: 'common' }) - - const [selectedItem, setSelectedItem] = useState(null) - - // Enhanced: Preserve user selection, only reset when necessary - useEffect(() => { - // Only reset if no current selection or current selection is invalid - const isCurrentSelectionValid = selectedItem && items.some(item => item.value === selectedItem.value) - - if (!isCurrentSelectionValid) { - let defaultSelect = null - // Handle cases where defaultValue might be undefined, null, or empty string - defaultSelect = items.find((item: Item) => item.value === defaultValue) ?? null - setSelectedItem(defaultSelect) - } - }, [defaultValue, items, selectedItem]) - - const listboxRef = useRef(null) - - return ( - { - if (!disabled) { - setSelectedItem(value) - onSelect(value as Item) - } - }} - > - {({ open }) => ( - - {renderTrigger && {renderTrigger(selectedItem, open)}} - {!renderTrigger && ( - { - onOpenChange?.(open) - }} - className={cn(`flex h-full w-full items-center rounded-lg border-0 bg-components-input-bg-normal pr-10 pl-3 group-hover/simple-select:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt focus-visible:outline-hidden sm:text-sm sm:leading-6 ${disabled ? 'cursor-not-allowed' : 'cursor-pointer'}`, className)} - > - {selectedItem?.name ?? localPlaceholder} - - {isLoading - ? - : (selectedItem && !notClearable) - ? ( - { - e.stopPropagation() - setSelectedItem(null) - onSelect({ name: '', value: '' }) - }} - className="h-4 w-4 cursor-pointer text-text-quaternary" - aria-hidden="false" - /> - ) - : ( - open - ? ( - - ) - : ( - - ) - )} - - - )} - - {(!disabled) && ( - - {items.map((item: Item) => - item.isGroup ? ( - - {item.name} - - ) : ( - - {({ /* active, */ selected }) => ( - <> - {renderOption - ? renderOption({ item, selected }) - : ( - <> - {item.name} - {selected && !hideChecked && ( - - - - )} - > - )} - > - )} - - ), - )} - - )} - - )} - - ) -} - -type PortalSelectProps = { - value: string | number - onSelect: (value: Item) => void - items: Item[] - placeholder?: string - installedValue?: string | number - renderTrigger?: (value?: Item) => React.JSX.Element | null - triggerClassName?: string - triggerClassNameFn?: (open: boolean) => string - popupClassName?: string - popupInnerClassName?: string - readonly?: boolean - hideChecked?: boolean -} -const PortalSelect: FC = ({ - value, - onSelect, - items, - placeholder, - installedValue, - renderTrigger, - triggerClassName, - triggerClassNameFn, - popupClassName, - popupInnerClassName, - readonly, - hideChecked, -}) => { - const { t } = useTranslation() - const [open, setOpen] = useState(false) - const localPlaceholder = placeholder || t('placeholder.select', { ns: 'common' }) - const selectedItem = value ? items.find(item => item.value === value) : undefined - - return ( - - !readonly && setOpen(v => !v)} className="w-full"> - {renderTrigger - ? renderTrigger(selectedItem) - : ( - - - {selectedItem?.name ?? localPlaceholder} - - - {!!(installedValue && selectedItem && selectedItem.value !== installedValue) && ( - - {installedValue} - {' '} - {'->'} - {' '} - {selectedItem.value} - {' '} - - )} - - - - )} - - - - - {items.map((item: Item) => ( - { - onSelect(item) - setOpen(false) - }} - > - - {item.name} - {item.value === installedValue && ( - INSTALLED - )} - - {!hideChecked && item.value === value && ( - - )} - {item.extra} - - ))} - - - - ) -} -export { PortalSelect, SimpleSelect } -export default React.memo(Select) diff --git a/web/app/components/base/select/locale-signin.tsx b/web/app/components/base/select/locale-signin.tsx deleted file mode 100644 index 046a76a5d4..0000000000 --- a/web/app/components/base/select/locale-signin.tsx +++ /dev/null @@ -1,64 +0,0 @@ -'use client' -import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react' -import { GlobeAltIcon } from '@heroicons/react/24/outline' -import { Fragment } from 'react' - -type ISelectProps = { - items: Array<{ value: string, name: string }> - value?: string - className?: string - onChange?: (value: string) => void -} - -export default function LocaleSigninSelect({ - items, - value, - onChange, -}: ISelectProps) { - const item = items.filter(item => item.value === value)[0] - - return ( - - - - - - {item?.name} - - - - - - {items.map((item) => { - return ( - - { - evt.preventDefault() - onChange?.(item.value) - }} - > - {item.name} - - - ) - })} - - - - - - - - ) -} diff --git a/web/app/components/base/select/locale.tsx b/web/app/components/base/select/locale.tsx deleted file mode 100644 index ec4331c11f..0000000000 --- a/web/app/components/base/select/locale.tsx +++ /dev/null @@ -1,64 +0,0 @@ -'use client' -import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react' -import { GlobeAltIcon } from '@heroicons/react/24/outline' -import { Fragment } from 'react' - -type ISelectProps = { - items: Array<{ value: string, name: string }> - value?: string - className?: string - onChange?: (value: string) => void -} - -export default function Select({ - items, - value, - onChange, -}: ISelectProps) { - const item = items.filter(item => item.value === value)[0] - - return ( - - - - - - {item?.name} - - - - - - {items.map((item) => { - return ( - - { - evt.preventDefault() - onChange?.(item.value) - }} - > - {item.name} - - - ) - })} - - - - - - - - ) -} diff --git a/web/app/components/base/select/pure.tsx b/web/app/components/base/select/pure.tsx deleted file mode 100644 index aba2ae8da5..0000000000 --- a/web/app/components/base/select/pure.tsx +++ /dev/null @@ -1,207 +0,0 @@ -import type { - PortalToFollowElemOptions, -} from '@/app/components/base/portal-to-follow-elem' -import { cn } from '@langgenius/dify-ui/cn' -import { - RiArrowDownSLine, - RiCheckLine, -} from '@remixicon/react' -import { - useCallback, - useMemo, - useState, -} from 'react' -import { useTranslation } from 'react-i18next' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' - -export type Option = { - label: string - value: string -} - -type SharedPureSelectProps = { - options: Option[] - containerProps?: PortalToFollowElemOptions & { - open?: boolean - onOpenChange?: (open: boolean) => void - } - triggerProps?: { - className?: string - } - popupProps?: { - wrapperClassName?: string - className?: string - itemClassName?: string - title?: string - titleClassName?: string - } - placeholder?: string - disabled?: boolean - triggerPopupSameWidth?: boolean -} - -type SingleSelectProps = { - multiple?: false - value?: string - onChange?: (value: string) => void -} - -type MultiSelectProps = { - multiple: true - value?: string[] - onChange?: (value: string[]) => void -} - -export type PureSelectProps = SharedPureSelectProps & (SingleSelectProps | MultiSelectProps) -const PureSelect = (props: PureSelectProps) => { - const { - options, - containerProps, - triggerProps, - popupProps, - placeholder, - disabled, - triggerPopupSameWidth, - multiple, - value, - onChange, - } = props - const { t } = useTranslation() - const { - open, - onOpenChange, - placement, - offset, - } = containerProps || {} - const { - className: triggerClassName, - } = triggerProps || {} - const { - wrapperClassName: popupWrapperClassName, - className: popupClassName, - itemClassName: popupItemClassName, - title: popupTitle, - titleClassName: popupTitleClassName, - } = popupProps || {} - - const [localOpen, setLocalOpen] = useState(false) - const mergedOpen = open ?? localOpen - - const handleOpenChange = useCallback((openValue: boolean) => { - onOpenChange?.(openValue) - setLocalOpen(openValue) - }, [onOpenChange]) - - const triggerText = useMemo(() => { - const placeholderText = placeholder || t('placeholder.select', { ns: 'common' }) - if (multiple) - return value?.length ? t('dynamicSelect.selected', { ns: 'common', count: value.length }) : placeholderText - - return options.find(option => option.value === value)?.label || placeholderText - }, [multiple, value, options, placeholder]) - - return ( - - !disabled && handleOpenChange(!mergedOpen)} - asChild - > - - - {triggerText} - - - - - - - { - popupTitle && ( - - {popupTitle} - - ) - } - { - options.map(option => ( - { - if (disabled) - return - if (multiple) { - const currentValues = value ?? [] - const nextValues = currentValues.includes(option.value) - ? currentValues.filter(valueItem => valueItem !== option.value) - : [...currentValues, option.value] - onChange?.(nextValues) - return - } - onChange?.(option.value) - handleOpenChange(false) - }} - > - - {option.label} - - { - ( - multiple - ? (value ?? []).includes(option.value) - : value === option.value - ) && - } - - )) - } - - - - ) -} - -export default PureSelect diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/usage-priority-section.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/usage-priority-section.spec.tsx index c400d05f22..2df673726a 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/usage-priority-section.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/usage-priority-section.spec.tsx @@ -4,6 +4,8 @@ import UsagePrioritySection from '../usage-priority-section' describe('UsagePrioritySection', () => { const onSelect = vi.fn() + const getAiCreditsButton = () => screen.getByRole('button', { name: /aiCreditsOption/ }) + const getApiKeyButton = () => screen.getByRole('button', { name: /apiKeyOption/ }) beforeEach(() => { vi.clearAllMocks() @@ -15,7 +17,8 @@ describe('UsagePrioritySection', () => { render() expect(screen.getByText(/usagePriority/))!.toBeInTheDocument() - expect(screen.getAllByRole('button')).toHaveLength(2) + expect(getAiCreditsButton()).toBeInTheDocument() + expect(getApiKeyButton()).toBeInTheDocument() }) }) @@ -24,24 +27,21 @@ describe('UsagePrioritySection', () => { it('should highlight AI credits option when value is credits', () => { render() - const buttons = screen.getAllByRole('button') - expect(buttons[0]!.className).toContain('border-components-option-card-option-selected-border') - expect(buttons[1]!.className).not.toContain('border-components-option-card-option-selected-border') + expect(getAiCreditsButton()).toHaveAttribute('aria-pressed', 'true') + expect(getApiKeyButton()).toHaveAttribute('aria-pressed', 'false') }) it('should highlight API key option when value is apiKey', () => { render() - const buttons = screen.getAllByRole('button') - expect(buttons[0]!.className).not.toContain('border-components-option-card-option-selected-border') - expect(buttons[1]!.className).toContain('border-components-option-card-option-selected-border') + expect(getAiCreditsButton()).toHaveAttribute('aria-pressed', 'false') + expect(getApiKeyButton()).toHaveAttribute('aria-pressed', 'true') }) it('should highlight API key option when value is apiKeyOnly', () => { render() - const buttons = screen.getAllByRole('button') - expect(buttons[1]!.className).toContain('border-components-option-card-option-selected-border') + expect(getApiKeyButton()).toHaveAttribute('aria-pressed', 'true') }) }) @@ -50,7 +50,7 @@ describe('UsagePrioritySection', () => { it('should call onSelect with system when clicking AI credits option', () => { render() - fireEvent.click(screen.getAllByRole('button')[0]!) + fireEvent.click(getAiCreditsButton()) expect(onSelect).toHaveBeenCalledWith(PreferredProviderTypeEnum.system) }) @@ -58,7 +58,7 @@ describe('UsagePrioritySection', () => { it('should call onSelect with custom when clicking API key option', () => { render() - fireEvent.click(screen.getAllByRole('button')[1]!) + fireEvent.click(getApiKeyButton()) expect(onSelect).toHaveBeenCalledWith(PreferredProviderTypeEnum.custom) }) diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/usage-priority-section.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/usage-priority-section.tsx index 19b2ba20a3..b491f07889 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/usage-priority-section.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/usage-priority-section.tsx @@ -26,7 +26,7 @@ export default function UsagePrioritySection({ value, disabled, onSelect }: Usag - + @@ -37,22 +37,27 @@ export default function UsagePrioritySection({ value, disabled, onSelect }: Usag - {options.map(option => ( - onSelect(option.key)} - > - {t(option.labelKey, { ns: 'common' })} - - ))} + {options.map((option) => { + const selected = selectedKey === option.key + + return ( + onSelect(option.key)} + > + {t(option.labelKey, { ns: 'common' })} + + ) + })} diff --git a/web/app/components/workflow/block-selector/main.tsx b/web/app/components/workflow/block-selector/main.tsx index b583170ae7..a588fb660e 100644 --- a/web/app/components/workflow/block-selector/main.tsx +++ b/web/app/components/workflow/block-selector/main.tsx @@ -13,6 +13,7 @@ import type { OnSelectBlock, ToolWithProvider, } from '../types' +import { cn } from '@langgenius/dify-ui/cn' import { Popover, PopoverContent, @@ -177,24 +178,27 @@ const NodeSelector: FC = ({ }, [activeTab, t]) const defaultTriggerElement = ( - - - + + ) - const triggerElement = trigger ? trigger(open) : defaultTriggerElement + const triggerElement = trigger?.(open) + const shouldRenderTriggerElementAsRoot = React.isValidElement(triggerElement) + && (asChild || triggerElement.type === 'button') const triggerElementProps = React.isValidElement(triggerElement) ? (triggerElement.props as { onClick?: MouseEventHandler }) : null - const resolvedTriggerElement = asChild && React.isValidElement(triggerElement) + const resolvedTriggerElement = shouldRenderTriggerElementAsRoot ? React.cloneElement( triggerElement as React.ReactElement<{ onClick?: MouseEventHandler @@ -215,8 +219,7 @@ const NodeSelector: FC = ({ const resolvedOffset = typeof offset === 'number' || typeof offset === 'function' ? undefined : offset const sideOffset = typeof offset === 'number' ? offset : (resolvedOffset?.mainAxis ?? 0) const alignOffset = typeof offset === 'number' ? 0 : (resolvedOffset?.crossAxis ?? 0) - const nativeButton = asChild - && React.isValidElement(triggerElement) + const nativeButton = shouldRenderTriggerElementAsRoot && (typeof triggerElement.type !== 'string' || triggerElement.type === 'button') return ( @@ -224,7 +227,9 @@ const NodeSelector: FC = ({ open={open} onOpenChange={handleOpenChange} > - + {trigger + ? + : defaultTriggerElement} = ({ ? variablePicker : ( @@ -344,6 +345,7 @@ const VarReferencePickerTrigger: FC = ({ return ( diff --git a/web/app/components/workflow/nodes/trigger-webhook/components/parameter-table.tsx b/web/app/components/workflow/nodes/trigger-webhook/components/parameter-table.tsx index 11e9265a4b..64556fe461 100644 --- a/web/app/components/workflow/nodes/trigger-webhook/components/parameter-table.tsx +++ b/web/app/components/workflow/nodes/trigger-webhook/components/parameter-table.tsx @@ -28,7 +28,7 @@ const ParameterTable: FC = ({ }) => { const { t } = useTranslation() - // Memoize typeOptions to prevent unnecessary re-renders that cause SimpleSelect state resets + // Memoize typeOptions to prevent unnecessary re-renders that cause Select state resets const typeOptions = useMemo(() => createParameterTypeOptions(contentType), [contentType]) diff --git a/web/app/signin/__tests__/_header.spec.tsx b/web/app/signin/__tests__/_header.spec.tsx new file mode 100644 index 0000000000..06a54e0b36 --- /dev/null +++ b/web/app/signin/__tests__/_header.spec.tsx @@ -0,0 +1,57 @@ +import { useSuspenseQuery } from '@tanstack/react-query' +import { fireEvent, render, screen } from '@testing-library/react' +import { setLocaleOnClient } from '@/i18n-config' +import Header from '../_header' + +vi.mock('@tanstack/react-query', () => ({ + useSuspenseQuery: vi.fn(), +})) + +vi.mock('@/context/i18n', () => ({ + useLocale: () => 'en-US', +})) + +vi.mock('@/i18n-config', () => ({ + setLocaleOnClient: vi.fn(), +})) + +vi.mock('@/next/dynamic', () => ({ + default: () => () => null, +})) + +vi.mock('@/service/system-features', () => ({ + systemFeaturesQueryOptions: () => ({}), +})) + +vi.mock('../_locale-menu', () => ({ + default: ({ onChange }: { onChange?: (value: string) => void }) => ( + onChange?.('ja-JP')}> + Switch Language + + ), +})) + +const mockUseSuspenseQuery = vi.mocked(useSuspenseQuery) +const mockSetLocaleOnClient = vi.mocked(setLocaleOnClient) + +describe('Signin Header', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseSuspenseQuery.mockReturnValue({ + data: { + branding: { + enabled: false, + login_page_logo: '', + }, + }, + } as ReturnType) + }) + + it('should switch locale without forcing a full page reload', () => { + render() + + fireEvent.click(screen.getByRole('button', { name: 'Switch Language' })) + + expect(mockSetLocaleOnClient).toHaveBeenCalledWith('ja-JP', false) + }) +}) diff --git a/web/app/components/base/select/__tests__/locale.spec.tsx b/web/app/signin/__tests__/_locale-menu.spec.tsx similarity index 74% rename from web/app/components/base/select/__tests__/locale.spec.tsx rename to web/app/signin/__tests__/_locale-menu.spec.tsx index 2c1c83bf95..86edd69cf8 100644 --- a/web/app/components/base/select/__tests__/locale.spec.tsx +++ b/web/app/signin/__tests__/_locale-menu.spec.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import LocaleSelect from '../locale' +import LocaleMenu from '../_locale-menu' const localeItems = [ { value: 'en-US', name: 'English (US)' }, @@ -8,16 +8,15 @@ const localeItems = [ { value: 'ja-JP', name: '日本語' }, ] -describe('LocaleSelect', () => { +describe('LocaleMenu', () => { beforeEach(() => { vi.clearAllMocks() }) - // Rendering behavior for selected value and fallback state. describe('Rendering', () => { it('should render selected locale name when value matches an item', () => { render( - { it('should render trigger without selected label when value is not found', () => { render( - { }) }) - // Menu interactions and callback behavior. describe('User Interactions', () => { it('should call onChange with selected locale value when clicking an option', async () => { const user = userEvent.setup() const onChange = vi.fn() render( - { ) await user.click(screen.getByRole('button', { name: /english \(us\)/i })) - await user.click(screen.getByRole('menuitem', { name: '日本語' })) + await user.click(screen.getByRole('menuitemradio', { name: '日本語' })) expect(onChange).toHaveBeenCalledWith('ja-JP') }) @@ -66,7 +64,7 @@ describe('LocaleSelect', () => { const user = userEvent.setup() render( - { await user.click(screen.getByRole('button', { name: /english \(us\)/i })) - expect(screen.getByRole('menuitem', { name: 'English (US)' })).toBeInTheDocument() - expect(screen.getByRole('menuitem', { name: '简体中文' })).toBeInTheDocument() - expect(screen.getByRole('menuitem', { name: '日本語' })).toBeInTheDocument() + expect(screen.getByRole('menuitemradio', { name: 'English (US)' })).toBeInTheDocument() + expect(screen.getByRole('menuitemradio', { name: '简体中文' })).toBeInTheDocument() + expect(screen.getByRole('menuitemradio', { name: '日本語' })).toBeInTheDocument() }) }) - // Edge behavior for missing callback and empty data. describe('Edge Cases', () => { it('should not throw when onChange is undefined and option is selected', async () => { const user = userEvent.setup() render( - , ) await user.click(screen.getByRole('button', { name: /english \(us\)/i })) - await user.click(screen.getByRole('menuitem', { name: '简体中文' })) + await user.click(screen.getByRole('menuitemradio', { name: '简体中文' })) + + expect(screen.queryByRole('menuitemradio', { name: '简体中文' })).not.toBeInTheDocument() }) it('should render no options when items are empty', async () => { const user = userEvent.setup() render( - { ) await user.click(screen.getByRole('button')) - expect(screen.queryAllByRole('menuitem')).toHaveLength(0) + + expect(screen.queryAllByRole('menuitemradio')).toHaveLength(0) }) }) }) diff --git a/web/app/signin/_header.tsx b/web/app/signin/_header.tsx index 0b424d45c2..22a05c8d2d 100644 --- a/web/app/signin/_header.tsx +++ b/web/app/signin/_header.tsx @@ -1,13 +1,12 @@ 'use client' -import type { Locale } from '@/i18n-config' import { useSuspenseQuery } from '@tanstack/react-query' import Divider from '@/app/components/base/divider' -import LocaleSigninSelect from '@/app/components/base/select/locale-signin' import { useLocale } from '@/context/i18n' import { setLocaleOnClient } from '@/i18n-config' import { languages } from '@/i18n-config/language' import dynamic from '@/next/dynamic' import { systemFeaturesQueryOptions } from '@/service/system-features' +import LocaleMenu from './_locale-menu' // Avoid rendering the logo and theme selector on the server const DifyLogo = dynamic(() => import('@/app/components/base/logo/dify-logo'), { @@ -35,11 +34,11 @@ const Header = () => { ) : } - item.supported)} onChange={(value) => { - setLocaleOnClient(value as Locale) + setLocaleOnClient(value, false) }} /> diff --git a/web/app/signin/_locale-menu.tsx b/web/app/signin/_locale-menu.tsx new file mode 100644 index 0000000000..5f72a51eb9 --- /dev/null +++ b/web/app/signin/_locale-menu.tsx @@ -0,0 +1,73 @@ +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuRadioItemIndicator, + DropdownMenuTrigger, +} from '@langgenius/dify-ui/dropdown-menu' + +type LocaleMenuItem = { + value: T + name: string +} + +type LocaleMenuProps = { + items: Array> + value?: T + onChange?: (value: T) => void +} + +export default function LocaleMenu({ + items, + value, + onChange, +}: LocaleMenuProps) { + const selectedItem = items.find(item => item.value === value) + const handleValueChange = (nextValue: string) => { + const nextItem = items.find(item => item.value === nextValue) + if (nextItem) + onChange?.(nextItem.value) + } + + return ( + + + + + + )} + > + + {selectedItem?.name} + + + + + + {items.map(item => ( + + {item.name} + + + ))} + + + + + ) +} diff --git a/web/docs/overlay-migration.md b/web/docs/overlay-migration.md index cb020f9ab6..1ab79511f2 100644 --- a/web/docs/overlay-migration.md +++ b/web/docs/overlay-migration.md @@ -10,7 +10,6 @@ This document tracks the Dify-web migration away from legacy overlay APIs. - `@/app/components/base/portal-to-follow-elem` - `@/app/components/base/tooltip` - `@/app/components/base/modal` - - `@/app/components/base/select` (including `custom` / `pure`) - `@/app/components/base/dialog` - Replacement primitives: - `@langgenius/dify-ui/tooltip` diff --git a/web/eslint.constants.mjs b/web/eslint.constants.mjs index 1039a300ec..fb9c67c454 100644 --- a/web/eslint.constants.mjs +++ b/web/eslint.constants.mjs @@ -58,15 +58,6 @@ export const OVERLAY_RESTRICTED_IMPORT_PATTERNS = [ ], message: 'Deprecated: use @langgenius/dify-ui/dialog instead. See issue #32767.', }, - { - group: [ - '**/base/select', - '**/base/select/index', - '**/base/select/custom', - '**/base/select/pure', - ], - message: 'Deprecated: use @langgenius/dify-ui/select instead. See issue #32767.', - }, { group: [ '**/base/dialog', @@ -89,9 +80,6 @@ export const OVERLAY_MIGRATION_LEGACY_BASE_FILES = [ 'app/components/base/modal/modal.tsx', 'app/components/base/prompt-editor/plugins/context-block/component.tsx', 'app/components/base/prompt-editor/plugins/history-block/component.tsx', - 'app/components/base/select/custom.tsx', - 'app/components/base/select/index.tsx', - 'app/components/base/select/pure.tsx', 'app/components/base/sort/index.tsx', 'app/components/base/theme-selector.tsx', 'app/components/base/tooltip/index.tsx',
Standard dropdown without search
Dropdown with search/filter capability
Portal-based positioning for better overflow handling