From dcb7b36ad4bac25cc7182be45eda360af7290b28 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Fri, 4 Sep 2026 09:54:44 +0000 Subject: [PATCH] refactor(dify-ui): adopt ID-backed combobox items (#41821) --- packages/dify-ui/docs/selection.md | 61 ++++++++++ .../src/combobox/__tests__/index.spec.tsx | 105 ++++++++++++++++++ .../dify-ui/src/combobox/index.stories.tsx | 48 +++++++- packages/dify-ui/src/combobox/index.tsx | 34 +++--- web/app/components/apps/creators-filter.tsx | 42 +++---- .../logs/__tests__/source-picker.spec.tsx | 22 ++++ .../logs/components/source-picker.tsx | 42 ++++--- web/features/skills/detail/skill-metadata.tsx | 42 +++++-- .../__tests__/tag-filter.spec.tsx | 17 ++- .../__tests__/tag-search-content.spec.tsx | 52 ++++----- .../tag-management/components/tag-filter.tsx | 52 +++++---- .../components/tag-search-content.tsx | 6 +- .../components/tag-selector.tsx | 58 ++++++---- 13 files changed, 429 insertions(+), 152 deletions(-) diff --git a/packages/dify-ui/docs/selection.md b/packages/dify-ui/docs/selection.md index b0ad5a45e60..acdae46dcd6 100644 --- a/packages/dify-ui/docs/selection.md +++ b/packages/dify-ui/docs/selection.md @@ -80,6 +80,67 @@ single-or-multiple union. Prefer the Base UI `items` collection pattern so the root, value display, and item list share one runtime source of truth. Convert values to strings only at real serialization boundaries. +### Combobox source items and selected values + +Combobox has separate types for the selected business value and the source record rendered by the +list: + +```tsx +import { + Combobox, + ComboboxItem, + ComboboxList, + createComboboxItems, +} from '@langgenius/dify-ui/combobox' +import { useMemo } from 'react' + +const userItems = useMemo( + () => + createComboboxItems(users, { + getValue: user => user.id, + getLabel: user => user.name, + }), + [users], +) + + + multiple + items={userItems} + value={selectedUserIds} + onValueChange={setSelectedUserIds} +> + > + {user => value={user.id}>{user.name}} + + +``` + +The first generic is `Value`, the second is the literal multiple-selection mode, and the third is +the source `Item`. `ComboboxValue` and `ComboboxItem` use `Value`; `filter`, `ComboboxList`, +`ComboboxGroup`, `ComboboxCollection`, and `useComboboxFilteredItems` use source items. Grouped +roots use the leaf record as `Item`, while the list callback receives the group object. + +Use `createComboboxItems` when the business contract stores a stable primitive ID but list rows +need complete records. Its `getValue` result must be unique and stable, and `getLabel` owns default +filtering, typeahead, and selected-value display. Create static collections at module scope and +memoize collections derived from changing data. Treat the returned collection as opaque and pass +it directly to `items`. + +For server-side search, keep the complete set of records needed to resolve selected labels in the +collection passed to `items`, and pass the current result window to `filteredItems`. Filtered items +are source `Item` records, not derived IDs, and grouped results must retain the collection's group +shape. + +Keep object values when the selected record itself is the business state or the selection callback +immediately needs the full record. When async refreshes may replace object references, provide +`isItemEqualToValue` using the stable domain identity. + +`itemToStringValue` only serializes a selected `Value` for forms and autofill; it does not change +`onValueChange` into an ID callback. Do not add it, `itemToStringLabel`, or a comparator as a +mechanical trio. Primitive IDs normally use the default equality. For async or paged data, keep +selected records in the collection when their labels must remain available after they leave the +current result window, or provide an ID-only label fallback. + `CheckboxGroup` follows Base UI and uses `string[]`. Model stronger business ID distinctions at the domain boundary unless the upstream primitive contract changes. diff --git a/packages/dify-ui/src/combobox/__tests__/index.spec.tsx b/packages/dify-ui/src/combobox/__tests__/index.spec.tsx index 0b3ab2673ee..893ff6c9001 100644 --- a/packages/dify-ui/src/combobox/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/combobox/__tests__/index.spec.tsx @@ -7,6 +7,7 @@ import { ComboboxChipRemove, ComboboxChips, ComboboxClear, + ComboboxCollection, ComboboxEmpty, ComboboxGroup, ComboboxGroupLabel, @@ -25,8 +26,56 @@ import { ComboboxStatus, ComboboxTrigger, ComboboxValue, + createComboboxItems, } from '../index' +type ResourceOption = { + id: string + label: string +} + +const resourceOptions: ResourceOption[] = [ + { id: 'workflow', label: 'Workflow' }, + { id: 'dataset', label: 'Dataset' }, +] +const resourceItems = createComboboxItems(resourceOptions, { + getValue: (item) => item.id, + getLabel: (item) => item.label, +}) + +function ComboboxTypeExamples() { + return ( + + + multiple + items={resourceItems} + value={['workflow']} + filter={(item, query) => item.label.includes(query)} + onValueChange={(value) => { + const selectedIds: string[] = value + void selectedIds + }} + > + >{(value) => value?.join(', ') ?? ''} + > + {(item) => value={item.id}>{item.label}} + + items={resourceOptions}> + > + {(item) => value={item.id}>{item.label}} + + + {/* @ts-expect-error item anatomy accepts the derived string value, not the source object */} + value={resourceOptions[0]} /> + + {/* @ts-expect-error root value uses the derived string domain, not the source object */} + items={resourceItems} value={resourceOptions[0]} /> + + ) +} + +void ComboboxTypeExamples + const renderWithSafeViewport = (ui: React.ReactNode) => render(
{ui}
) @@ -113,6 +162,33 @@ describe('Combobox wrappers', () => { .element(screen.getByRole('combobox', { name: 'Resource type' })) .toBeInTheDocument() }) + + it('should expose readonly styling state while allowing options to be inspected', async () => { + const screen = await render( + + + + + + + + + Workflow + Dataset + + + + + , + ) + const trigger = screen.getByRole('combobox', { name: 'Resource type' }) + + await expect.element(trigger).toHaveAttribute('data-readonly') + await trigger.click() + await expect.element(screen.getByRole('option', { name: 'Dataset' })).toBeVisible() + await screen.getByRole('option', { name: 'Dataset' }).click() + await expect.element(trigger).toHaveTextContent('workflow') + }) }) describe('Input group and controls', () => { @@ -206,6 +282,35 @@ describe('Combobox wrappers', () => { }) describe('Popup anatomy and options', () => { + it('should render source objects while exposing primitive selected values', async () => { + const onValueChange = vi.fn() + const screen = await render( + + defaultOpen + items={resourceItems} + defaultValue="workflow" + filter={(item, query) => item.label.toLowerCase().includes(query.toLowerCase())} + onValueChange={(nextValue) => onValueChange(nextValue)} + > + + > + {(item) => ( + key={item.id} value={item.id}> + {item.label} + + )} + + , + ) + + await expect + .element(screen.getByRole('option', { name: 'Workflow' })) + .toHaveAttribute('aria-selected', 'true') + await userEvent.click(screen.getByRole('option', { name: 'Dataset' })) + + expect(onValueChange).toHaveBeenCalledWith('dataset') + }) + it('should use default overlay placement', async () => { const screen = await renderSelectLikeCombobox({ open: true }) diff --git a/packages/dify-ui/src/combobox/index.stories.tsx b/packages/dify-ui/src/combobox/index.stories.tsx index 194042a1fe9..406270cf695 100644 --- a/packages/dify-ui/src/combobox/index.stories.tsx +++ b/packages/dify-ui/src/combobox/index.stories.tsx @@ -28,6 +28,7 @@ import { ComboboxStatus, ComboboxTrigger, ComboboxValue, + createComboboxItems, useComboboxFilter, useComboboxFilteredItems, } from '.' @@ -191,6 +192,10 @@ const tagOptions: Option[] = [ { value: 'finance', label: 'Finance' }, { value: 'support', label: 'Support' }, ] +const tagItems = createComboboxItems(tagOptions, { + getValue: (option) => option.value, + getLabel: (option) => option.label, +}) const directoryOptions: Option[] = [ { @@ -981,15 +986,31 @@ export const ReadOnly: Story = { ), + play: async ({ canvas, canvasElement, userEvent }) => { + const input = canvas.getByRole('combobox', { name: 'Read-only source' }) + const body = within(canvasElement.ownerDocument.body) + + await expect(input).toHaveValue('Website crawler') + await userEvent.click(input) + await waitFor(async () => { + await expect(body.getByRole('option', { name: /Notion/ })).toBeVisible() + }) + await userEvent.keyboard('{ArrowDown}') + await expect(body.getByRole('option', { name: /S3 bucket/ })).toHaveAttribute( + 'data-highlighted', + ) + await userEvent.keyboard('{Enter}') + await expect(input).toHaveValue('Website crawler') + }, } const ControlledDemo = () => { - const [value, setValue] = React.useState