refactor(dify-ui): adopt ID-backed combobox items (#41821)

This commit is contained in:
yyh 2026-09-04 09:54:44 +00:00 committed by GitHub
parent 067a60e0ac
commit dcb7b36ad4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 429 additions and 152 deletions

View File

@ -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],
)
<Combobox<string, true, User>
multiple
items={userItems}
value={selectedUserIds}
onValueChange={setSelectedUserIds}
>
<ComboboxList<User>>
{user => <ComboboxItem<string> value={user.id}>{user.name}</ComboboxItem>}
</ComboboxList>
</Combobox>
```
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.

View File

@ -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 (
<React.Fragment>
<Combobox<string, true, ResourceOption>
multiple
items={resourceItems}
value={['workflow']}
filter={(item, query) => item.label.includes(query)}
onValueChange={(value) => {
const selectedIds: string[] = value
void selectedIds
}}
>
<ComboboxValue<string, true>>{(value) => value?.join(', ') ?? ''}</ComboboxValue>
<ComboboxList<ResourceOption>>
{(item) => <ComboboxItem<string> value={item.id}>{item.label}</ComboboxItem>}
</ComboboxList>
<ComboboxGroup<ResourceOption> items={resourceOptions}>
<ComboboxCollection<ResourceOption>>
{(item) => <ComboboxItem<string> value={item.id}>{item.label}</ComboboxItem>}
</ComboboxCollection>
</ComboboxGroup>
{/* @ts-expect-error item anatomy accepts the derived string value, not the source object */}
<ComboboxItem<string> value={resourceOptions[0]} />
</Combobox>
{/* @ts-expect-error root value uses the derived string domain, not the source object */}
<Combobox<string, false, ResourceOption> items={resourceItems} value={resourceOptions[0]} />
</React.Fragment>
)
}
void ComboboxTypeExamples
const renderWithSafeViewport = (ui: React.ReactNode) =>
render(<div style={{ minHeight: '100vh', minWidth: '100vw', padding: '240px' }}>{ui}</div>)
@ -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(
<Combobox readOnly defaultValue="workflow" items={['workflow', 'dataset']}>
<ComboboxTrigger aria-label="Resource type">
<ComboboxValue />
</ComboboxTrigger>
<ComboboxPortal>
<ComboboxPositioner>
<ComboboxPopup aria-label="Resource type">
<ComboboxList>
<ComboboxItem value="workflow">Workflow</ComboboxItem>
<ComboboxItem value="dataset">Dataset</ComboboxItem>
</ComboboxList>
</ComboboxPopup>
</ComboboxPositioner>
</ComboboxPortal>
</Combobox>,
)
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(
<Combobox<string, false, ResourceOption>
defaultOpen
items={resourceItems}
defaultValue="workflow"
filter={(item, query) => item.label.toLowerCase().includes(query.toLowerCase())}
onValueChange={(nextValue) => onValueChange(nextValue)}
>
<ComboboxInput aria-label="Filter resources" />
<ComboboxList<ResourceOption>>
{(item) => (
<ComboboxItem<string> key={item.id} value={item.id}>
{item.label}
</ComboboxItem>
)}
</ComboboxList>
</Combobox>,
)
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 })

View File

@ -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 = {
</Combobox>
</Field>
),
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<Option | null>(defaultTag)
const [value, setValue] = React.useState<string | null>(defaultTag.value)
return (
<div className="flex w-80 flex-col items-start gap-3">
<div className="w-full">
<Combobox items={tagOptions} value={value} onValueChange={setValue}>
<Combobox<string, false, Option> items={tagItems} value={value} onValueChange={setValue}>
<ComboboxLabel>Default app tag</ComboboxLabel>
<ComboboxTrigger>
<ComboboxValue placeholder="Select tag" />
@ -998,14 +1019,21 @@ const ControlledDemo = () => {
<ComboboxPositioner>
<ComboboxPopup aria-label="Default app tag">
<PopupSearchInput label="Search app tags" placeholder="Search tags" />
<ComboboxList<Option>>{renderSimpleOptionItem}</ComboboxList>
<ComboboxList<Option>>
{(option) => (
<ComboboxItem<string> key={option.value} value={option.value}>
<ComboboxItemText>{option.label}</ComboboxItemText>
<ComboboxItemIndicator />
</ComboboxItem>
)}
</ComboboxList>
</ComboboxPopup>
</ComboboxPositioner>
</ComboboxPortal>
</Combobox>
</div>
<span className="rounded-md border border-divider-subtle bg-components-panel-bg px-2 py-1 system-xs-regular text-text-tertiary">
Selected: {value?.label ?? 'None'}
Selected ID: {value ?? 'None'}
</span>
</div>
)
@ -1013,16 +1041,24 @@ const ControlledDemo = () => {
export const Controlled: Story = {
render: () => <ControlledDemo />,
parameters: {
docs: {
description: {
story:
'Uses `createComboboxItems` so the controlled value is a primitive ID while the list renders complete option records.',
},
},
},
play: async ({ canvas, canvasElement, userEvent }) => {
const trigger = canvas.getByRole('combobox', { name: 'Default app tag' })
const body = within(canvasElement.ownerDocument.body)
await expect(canvas.getByText('Selected: Production')).toBeVisible()
await expect(canvas.getByText('Selected ID: production')).toBeVisible()
await userEvent.click(trigger)
await userEvent.click(await body.findByRole('option', { name: 'Finance' }))
await expect(trigger).toHaveTextContent('Finance')
await expect(canvas.getByText('Selected: Finance')).toBeVisible()
await expect(canvas.getByText('Selected ID: finance')).toBeVisible()
await waitFor(async () => {
await expect(body.queryByRole('dialog', { name: 'Default app tag' })).not.toBeInTheDocument()
})

View File

@ -15,19 +15,21 @@ import {
} from '../overlay-shared'
import { parsePlacement } from '../placement'
type ComboboxProps<Value, Multiple extends boolean | undefined = false> = BaseCombobox.Root.Props<
type ComboboxProps<
Value,
Multiple
> &
Multiple extends boolean | undefined = false,
Item = Value,
> = BaseCombobox.Root.Props<Value, Multiple, Item> &
([Multiple] extends [true] ? { multiple: true } : unknown)
type ComboboxChangeEventDetails = BaseCombobox.Root.ChangeEventDetails
function Combobox<Value, Multiple extends boolean | undefined = false>(
props: ComboboxProps<Value, Multiple>,
function Combobox<Value, Multiple extends boolean | undefined = false, Item = Value>(
props: ComboboxProps<Value, Multiple, Item>,
): React.JSX.Element {
return <BaseCombobox.Root {...props} />
}
const createComboboxItems = BaseCombobox.createItems
const ComboboxRow = BaseCombobox.Row
const useComboboxFilter = BaseCombobox.useFilter
const useComboboxFilteredItems = BaseCombobox.useFilteredItems
@ -51,19 +53,19 @@ function ComboboxValue(props: BaseCombobox.Value.Props): React.JSX.Element {
return <BaseCombobox.Value {...props} />
}
type ComboboxGroupProps<Value = unknown> = Omit<BaseCombobox.Group.Props, 'items'> & {
items?: readonly Value[]
type ComboboxGroupProps<Item = unknown> = Omit<BaseCombobox.Group.Props, 'items'> & {
items?: readonly Item[]
}
function ComboboxGroup<Value = unknown>(props: ComboboxGroupProps<Value>) {
function ComboboxGroup<Item = unknown>(props: ComboboxGroupProps<Item>) {
return <BaseCombobox.Group {...props} />
}
type ComboboxCollectionProps<Value = unknown> = Omit<BaseCombobox.Collection.Props, 'children'> & {
children: (item: Value, index: number) => React.ReactNode
type ComboboxCollectionProps<Item = unknown> = Omit<BaseCombobox.Collection.Props, 'children'> & {
children: (item: Item, index: number) => React.ReactNode
}
function ComboboxCollection<Value = unknown>(props: ComboboxCollectionProps<Value>) {
function ComboboxCollection<Item = unknown>(props: ComboboxCollectionProps<Item>) {
return <BaseCombobox.Collection {...props} />
}
@ -350,15 +352,12 @@ function ComboboxPopup({ className, ...props }: ComboboxPopupProps) {
)
}
type ComboboxListProps<Value = unknown> = Omit<
BaseCombobox.List.Props,
'children' | 'className'
> & {
type ComboboxListProps<Item = unknown> = Omit<BaseCombobox.List.Props, 'children' | 'className'> & {
className?: string
children?: React.ReactNode | ((item: Value, index: number) => React.ReactNode)
children?: React.ReactNode | ((item: Item, index: number) => React.ReactNode)
}
function ComboboxList<Value = unknown>({ className, ...props }: ComboboxListProps<Value>) {
function ComboboxList<Item = unknown>({ className, ...props }: ComboboxListProps<Item>) {
return <BaseCombobox.List className={cn(comboboxListClassName, className)} {...props} />
}
@ -536,6 +535,7 @@ export {
ComboboxStatus,
ComboboxTrigger,
ComboboxValue,
createComboboxItems,
useComboboxFilter,
useComboboxFilteredItems,
}

View File

@ -15,6 +15,7 @@ import {
ComboboxPositioner,
ComboboxTrigger,
ComboboxValue,
createComboboxItems,
} from '@langgenius/dify-ui/combobox'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import { useSuspenseQuery } from '@tanstack/react-query'
@ -71,28 +72,20 @@ const CreatorsFilter = ({ value, onChange }: CreatorsFilterProps) => {
() => new Map(creatorOptions.map((creator) => [creator.id, creator])),
[creatorOptions],
)
const selectedCreatorValues = useMemo(() => {
return value.map(
(id) =>
creatorMap.get(id) ?? {
id,
name: id,
avatarUrl: null,
isYou: false,
},
)
}, [creatorMap, value])
const creatorItems = useMemo(
() =>
createComboboxItems(creatorOptions, {
getValue: (creator) => creator.id,
getLabel: (creator) => creator.name,
}),
[creatorOptions],
)
const selectedCreators = useMemo(() => {
return value
.map((id) => creatorMap.get(id))
.filter((creator): creator is CreatorOption => Boolean(creator))
}, [creatorMap, value])
const handleValueChange = useCallback(
(creators: CreatorOption[]) => onChange(creators.map((creator) => creator.id)),
[onChange],
)
const clearCreatorQuery = useCallback(() => {
setKeywords('')
searchInputRef.current?.focus()
@ -117,17 +110,14 @@ const CreatorsFilter = ({ value, onChange }: CreatorsFilterProps) => {
: ''
return (
<Combobox<CreatorOption, true>
<Combobox<string, true, CreatorOption>
multiple
autoHighlight
items={creatorOptions}
value={selectedCreatorValues}
items={creatorItems}
value={value}
inputValue={keywords}
isItemEqualToValue={(creator, selectedCreator) => creator.id === selectedCreator.id}
itemToStringLabel={(creator) => creator.name}
itemToStringValue={(creator) => creator.id}
onInputValueChange={setKeywords}
onValueChange={handleValueChange}
onValueChange={(nextValue) => onChange(nextValue)}
>
<div className="relative inline-flex h-8 items-stretch">
<ComboboxTrigger
@ -139,7 +129,7 @@ const CreatorsFilter = ({ value, onChange }: CreatorsFilterProps) => {
'peer/creators-trigger w-auto min-w-0 border-components-button-secondary-border bg-components-button-secondary-bg pr-8 shadow-xs hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt data-placeholder:border-transparent data-placeholder:bg-components-input-bg-normal data-placeholder:pr-2 data-placeholder:text-text-tertiary data-placeholder:shadow-none data-placeholder:hover:bg-components-input-bg-hover data-popup-open:bg-state-base-hover-alt',
)}
>
<ComboboxValue<CreatorOption, true>>
<ComboboxValue<string, true>>
<span aria-hidden className="flex min-w-0 items-center">
<span className="px-1 text-text-tertiary group-data-popup-open/combobox-trigger:text-text-secondary">
{creatorFilterLabel}
@ -212,9 +202,9 @@ const CreatorsFilter = ({ value, onChange }: CreatorsFilterProps) => {
</div>
<ComboboxList<CreatorOption> className="max-h-60 px-1 pt-0 pb-1">
{(creator) => (
<ComboboxItem
<ComboboxItem<string>
key={creator.id}
value={creator}
value={creator.id}
className="group/creator-option grid-cols-[auto_1fr] gap-1 rounded-md"
>
<span

View File

@ -111,6 +111,28 @@ describe('AgentLogSourcePicker', () => {
expect(workflowOption.querySelector('.i-ri-check-line')).toBeInTheDocument()
})
it('should preserve selected source ids that are outside the current result set', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<AgentLogSourcePicker {...commonProps} value={['missing-source']} onChange={onChange} />)
expect(
screen.getByRole('combobox', {
name: 'agentV2.agentDetail.logs.filters.source.label',
}),
).toHaveTextContent('common.dynamicSelect.selected:{"count":1}')
await user.click(
screen.getByRole('combobox', {
name: 'agentV2.agentDetail.logs.filters.source.label',
}),
)
await user.click(screen.getByRole('option', { name: /Book Translation/ }))
expect(onChange).toHaveBeenCalledWith(['missing-source', sources.webapp.id])
})
it('should show one named popup state and keep retry outside the listbox', async () => {
const user = userEvent.setup()
const onRetry = vi.fn()

View File

@ -22,6 +22,7 @@ import {
ComboboxStatus,
ComboboxTrigger,
ComboboxValue,
createComboboxItems,
} from '@langgenius/dify-ui/combobox'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -63,18 +64,28 @@ export function AgentLogSourcePicker({
() => groups.map(({ sources, ...group }) => ({ ...group, items: sources ?? [] })),
[groups],
)
const sources = sourceGroups.flatMap((group) => group.items)
const selectedSources = sources.filter((source) => value.includes(source.id))
const sourceItems = useMemo(
() =>
createComboboxItems(sourceGroups, {
getValue: (source) => source.id,
getLabel: getSourceLabel,
}),
[sourceGroups],
)
const sourceById = useMemo(
() =>
new Map(sourceGroups.flatMap((group) => group.items).map((source) => [source.id, source])),
[sourceGroups],
)
return (
<Combobox<AgentLogSourceResponse, true>
<Combobox<AgentLogSourceResponse['id'], true, AgentLogSourceResponse>
multiple
items={sourceGroups}
value={selectedSources}
itemToStringLabel={getSourceLabel}
onValueChange={(nextSources) => {
items={sourceItems}
value={value}
onValueChange={(nextValue) => {
setInputValue('')
onChange(nextSources.map((source) => source.id))
onChange(nextValue)
}}
inputValue={inputValue}
onInputValueChange={setInputValue}
@ -83,12 +94,17 @@ export function AgentLogSourcePicker({
aria-label={t(($) => $['agentDetail.logs.filters.source.label'])}
className="mt-0 w-fit max-w-full min-w-22"
>
<ComboboxValue<AgentLogSourceResponse, true>
<ComboboxValue<AgentLogSourceResponse['id'], true>
placeholder={t(($) => $['agentDetail.logs.filters.source.all'])}
>
{(selectedValue) => {
if (!selectedValue?.length) return t(($) => $['agentDetail.logs.filters.source.all'])
if (selectedValue.length === 1) return selectedValue[0]!.app_name
if (selectedValue.length === 1) {
return (
sourceById.get(selectedValue[0]!)?.app_name ??
tCommon(($) => $['dynamicSelect.selected'], { count: 1 })
)
}
return tCommon(($) => $['dynamicSelect.selected'], { count: selectedValue.length })
}}
</ComboboxValue>
@ -135,15 +151,15 @@ export function AgentLogSourcePicker({
{!isLoading && !isError && (
<ComboboxList<AgentLogSourceComboboxGroup> className="max-h-69 p-2 pt-1">
{(group) => (
<ComboboxGroup key={group.type} items={group.items}>
<ComboboxGroup<AgentLogSourceResponse> key={group.type} items={group.items}>
<ComboboxGroupLabel className="px-1 pt-2 pb-1">
{getSourceGroupLabel(group, t)}
</ComboboxGroupLabel>
<ComboboxCollection<AgentLogSourceResponse>>
{(source) => (
<ComboboxItem
<ComboboxItem<AgentLogSourceResponse['id']>
key={source.id}
value={source}
value={source.id}
className="min-h-7 grid-cols-[1fr] gap-0 px-1 py-1"
render={(props, state) => (
<div {...props} className={props.className}>

View File

@ -15,6 +15,7 @@ import {
ComboboxPortal,
ComboboxPositioner,
ComboboxTrigger,
createComboboxItems,
} from '@langgenius/dify-ui/combobox'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
@ -34,6 +35,8 @@ import {
SKILL_TAG_CREATE_OPTION_PREFIX,
} from './shared'
const getSkillTagOptionId = (tag: string) => `skill-tag:${tag.trim().toLocaleLowerCase()}`
export function SkillTagsEditor({
detail,
fileMutationCoordinator,
@ -69,7 +72,7 @@ export function SkillTagsEditor({
seenTags.add(tagKey)
options.push({
id: `skill-tag:${tagKey}`,
id: getSkillTagOptionId(normalizedTag),
name: normalizedTag,
type: 'skill',
binding_count: '0',
@ -94,6 +97,16 @@ export function SkillTagsEditor({
return options
}, [draftTags, normalizedTagSearch, tags, tagsQuery.data?.data])
const tagOptionById = useMemo(() => new Map(tagOptions.map((tag) => [tag.id, tag])), [tagOptions])
const tagItems = useMemo(
() =>
createComboboxItems(tagOptions, {
getValue: (tag) => tag.id,
getLabel: (tag) => tag.name,
}),
[tagOptions],
)
const draftTagIds = useMemo(() => draftTags.map(getSkillTagOptionId), [draftTags])
const saveTags = (nextTags: string[]) => {
if (!detail || metadataMutation.isPending) return
@ -170,31 +183,36 @@ export function SkillTagsEditor({
<div className="mt-3 flex flex-wrap items-center gap-1">
{tags.map(renderTagBadge)}
{!readonly && (
<Combobox<TagComboboxItem, true>
items={tagOptions}
<Combobox<string, true, TagComboboxItem>
items={tagItems}
multiple
open={addOpen}
onOpenChange={handleOpenChange}
value={tagOptions.filter(
(tag) => !isCreateTagOption(tag) && draftTags.includes(tag.name),
)}
onValueChange={(nextTags) => {
const createOption = nextTags.find(isCreateTagOption)
if (createOption) {
value={draftTagIds}
onValueChange={(nextTagIds) => {
const createOptionId = nextTagIds.find((tagId) => {
const tag = tagOptionById.get(tagId)
return tag ? isCreateTagOption(tag) : false
})
const createOption = createOptionId ? tagOptionById.get(createOptionId) : undefined
if (createOption && isCreateTagOption(createOption)) {
setDraftTags((currentTags) => [...currentTags, createOption.name])
setTagSearch('')
return
}
setDraftTags(nextTags.filter((tag) => !isCreateTagOption(tag)).map((tag) => tag.name))
setDraftTags(
nextTagIds.flatMap((tagId) => {
const tag = tagOptionById.get(tagId)
return tag && !isCreateTagOption(tag) ? [tag.name] : []
}),
)
}}
inputValue={tagSearch}
onInputValueChange={setTagSearch}
filter={(tag, query) =>
tag.name.toLocaleLowerCase().includes(query.toLocaleLowerCase())
}
itemToStringLabel={(tag) => tag.name}
isItemEqualToValue={(item, value) => item.id === value.id}
>
<ComboboxTrigger
icon={false}

View File

@ -337,9 +337,20 @@ describe('TagFilter', () => {
expect(screen.getByText(i18n.noTag)).toBeInTheDocument()
})
it('should handle value with non-existent tag ids gracefully', () => {
render(<TagFilter {...defaultProps} value={['non-existent-id']} />)
expect(screen.queryByText(i18n.placeholder)).not.toBeInTheDocument()
it('should name and preserve selected tag ids outside the current result set', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const selectedCountLabel = 'common.dynamicSelect.selected:{"count":1}'
render(<TagFilter {...defaultProps} value={['non-existent-id']} onChange={onChange} />)
const trigger = screen.getByRole('combobox', { name: selectedCountLabel })
expect(trigger).toHaveTextContent(selectedCountLabel)
await user.click(trigger)
await user.click(screen.getByRole('option', { name: /Frontend/i }))
expect(onChange).toHaveBeenCalledWith(['non-existent-id', 'tag-1'])
})
it('should not show count badge when only one tag is selected', () => {

View File

@ -1,6 +1,6 @@
import type { TagResponse as Tag, TagType } from '@dify/contracts/api/console/tags/types.gen'
import type { TagComboboxItem } from '../components/tag-combobox-item'
import { Combobox } from '@langgenius/dify-ui/combobox'
import { Combobox, createComboboxItems } from '@langgenius/dify-ui/combobox'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useMemo, useState } from 'react'
@ -52,8 +52,6 @@ type PanelHarnessProps = {
onOpenTagManagement?: () => void
}
const tagToString = (tag: TagComboboxItem) => tag.name
const isSameTag = (item: TagComboboxItem, value: TagComboboxItem) => item.id === value.id
const tagFilter = (tag: TagComboboxItem, query: string) => tag.name.includes(query)
const PanelHarness = ({
@ -63,7 +61,7 @@ const PanelHarness = ({
canBindOrUnbindTags,
onOpenTagManagement,
}: PanelHarnessProps) => {
const [selectedTags, setSelectedTags] = useState<Tag[]>(value)
const [selectedTagIds, setSelectedTagIds] = useState(() => value.map((tag) => tag.id))
const [inputValue, setInputValue] = useState('')
const items = useMemo<TagComboboxItem[]>(() => {
const tags = tagList.filter((tag) => tag.type === type)
@ -81,22 +79,33 @@ const PanelHarness = ({
...tags,
]
}, [inputValue, tagList, type])
const itemById = useMemo(() => new Map(items.map((tag) => [tag.id, tag])), [items])
const comboboxItems = useMemo(
() =>
createComboboxItems(items, {
getValue: (tag) => tag.id,
getLabel: (tag) => tag.name,
}),
[items],
)
return (
<Combobox
items={items}
<Combobox<string, true, TagComboboxItem>
items={comboboxItems}
multiple
value={selectedTags}
onValueChange={(nextTags) => {
onValueChangeSpy(nextTags)
if (nextTags.some(isCreateTagOption)) return
setSelectedTags(nextTags)
value={selectedTagIds}
onValueChange={(nextTagIds) => {
onValueChangeSpy(nextTagIds)
const hasCreateOption = nextTagIds.some((tagId) => {
const tag = itemById.get(tagId)
return tag ? isCreateTagOption(tag) : false
})
if (hasCreateOption) return
setSelectedTagIds(nextTagIds)
}}
inputValue={inputValue}
onInputValueChange={setInputValue}
filter={tagFilter}
itemToStringLabel={tagToString}
isItemEqualToValue={isSameTag}
>
<TagSearchContent
type={type}
@ -183,12 +192,10 @@ describe('TagSearchContent', () => {
render(<PanelHarness />)
await user.click(screen.getByRole('option', { name: /Backend/i }))
expect(onValueChangeSpy).toHaveBeenLastCalledWith(
expect.arrayContaining([expect.objectContaining({ id: 'tag-2' })]),
)
expect(onValueChangeSpy).toHaveBeenLastCalledWith(expect.arrayContaining(['tag-2']))
await user.click(screen.getByRole('option', { name: /Backend/i }))
expect(onValueChangeSpy).toHaveBeenLastCalledWith([expect.objectContaining({ id: 'tag-1' })])
expect(onValueChangeSpy).toHaveBeenLastCalledWith(['tag-1'])
})
it('routes create option activation through the combobox value change API', async () => {
@ -200,12 +207,7 @@ describe('TagSearchContent', () => {
await user.click(screen.getByRole('option', { name: /BrandNewTag/i }))
expect(onValueChangeSpy).toHaveBeenLastCalledWith(
expect.arrayContaining([
expect.objectContaining({
isCreateOption: true,
name: 'BrandNewTag',
}),
]),
expect.arrayContaining(['__create_tag__:BrandNewTag']),
)
})
@ -252,9 +254,7 @@ describe('TagSearchContent', () => {
await user.click(screen.getByRole('option', { name: /Backend/i }))
expect(onValueChangeSpy).toHaveBeenLastCalledWith(
expect.arrayContaining([expect.objectContaining({ id: 'tag-2' })]),
)
expect(onValueChangeSpy).toHaveBeenLastCalledWith(expect.arrayContaining(['tag-2']))
expect(screen.queryByRole('button', { name: i18n.manageTags })).not.toBeInTheDocument()
})

View File

@ -7,19 +7,20 @@ import {
ComboboxPortal,
ComboboxPositioner,
ComboboxTrigger,
createComboboxItems,
} from '@langgenius/dify-ui/combobox'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import { useQuery } from '@tanstack/react-query'
import { useCallback, useMemo, useState } from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import XCircleIcon from '@/app/components/base/icons/src/vender/solid/general/XCircle'
import { consoleQuery } from '@/service/client'
import { TagSearchContent } from './tag-search-content'
const tagFilterComboboxFilter: NonNullable<ComboboxProps<Tag, true>['filter']> = (tag, query) =>
tag.name.toLocaleLowerCase().includes(query.toLocaleLowerCase())
const tagToString = (tag: Tag) => tag.name
const isSameTag = (item: Tag, value: Tag) => item.id === value.id
const tagFilterComboboxFilter: NonNullable<ComboboxProps<Tag['id'], true, Tag>['filter']> = (
tag,
query,
) => tag.name.toLocaleLowerCase().includes(query.toLocaleLowerCase())
type TagFilterProps = {
iconOnly?: boolean
@ -58,7 +59,15 @@ export const TagFilter = ({
)
const tagById = useMemo(() => new Map(tagList.map((tag) => [tag.id, tag])), [tagList])
const items = useMemo(() => tagList.filter((tag) => tag.type === type), [tagList, type])
const tagOptions = useMemo(() => tagList.filter((tag) => tag.type === type), [tagList, type])
const tagItems = useMemo(
() =>
createComboboxItems(tagOptions, {
getValue: (tag) => tag.id,
getLabel: (tag) => tag.name,
}),
[tagOptions],
)
const selectedTags = useMemo(() => {
return value.flatMap((tagId) => {
const tag = tagById.get(tagId)
@ -69,30 +78,27 @@ export const TagFilter = ({
const firstTagId = value[0]
const currentTagName = firstTagId ? tagById.get(firstTagId)?.name : undefined
const placeholderLabel = t(($) => $['tag.placeholder'], { ns: 'common' })
const triggerLabel = selectedTags.length
? selectedTags.map((tag) => tag.name).join(', ')
const selectedCountLabel = t(($) => $['dynamicSelect.selected'], {
ns: 'common',
count: value.length,
})
const triggerLabel = value.length
? selectedTags.length === value.length
? selectedTags.map((tag) => tag.name).join(', ')
: selectedCountLabel
: placeholderLabel
const handleValueChange = useCallback(
(nextTags: Tag[]) => {
const unknownTagIds = value.filter((tagId) => !tagById.has(tagId))
onChange([...unknownTagIds, ...nextTags.map((tag) => tag.id)])
},
[onChange, tagById, value],
)
return (
<Combobox
<Combobox<Tag['id'], true, Tag>
open={open}
onOpenChange={setOpen}
items={items}
items={tagItems}
multiple
value={selectedTags}
onValueChange={handleValueChange}
value={value}
onValueChange={(nextValue) => onChange(nextValue)}
inputValue={inputValue}
onInputValueChange={setInputValue}
filter={tagFilterComboboxFilter}
itemToStringLabel={tagToString}
isItemEqualToValue={isSameTag}
>
<div className="relative">
<ComboboxTrigger
@ -127,9 +133,9 @@ export const TagFilter = ({
)}
<span className="min-w-0 grow truncate text-[13px] leading-4.5 text-text-tertiary">
{!value.length && placeholderLabel}
{!!value.length && currentTagName}
{!!value.length && (currentTagName ?? selectedCountLabel)}
</span>
{value.length > 1 && (
{currentTagName && value.length > 1 && (
<span className="shrink-0 text-xs/4.5 font-medium text-text-tertiary">{`+${value.length - 1}`}</span>
)}
{!value.length && (

View File

@ -89,7 +89,7 @@ export const TagSearchContentView = ({
if (isCreateTagOption(tag) && canManageTags) {
return (
<Fragment key={tag.id}>
<ComboboxItem value={tag}>
<ComboboxItem<string> value={tag.id}>
<ComboboxItemText className="flex items-center gap-x-1 px-0">
<span
aria-hidden="true"
@ -107,9 +107,9 @@ export const TagSearchContentView = ({
}
return (
<ComboboxItem
<ComboboxItem<string>
key={tag.id}
value={tag}
value={tag.id}
disabled={!canBindOrUnbindTags && !canManageTags}
>
<ComboboxItemText title={tag.name}>{tag.name}</ComboboxItemText>

View File

@ -8,6 +8,7 @@ import {
ComboboxPortal,
ComboboxPositioner,
ComboboxTrigger,
createComboboxItems,
} from '@langgenius/dify-ui/combobox'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation, useQuery } from '@tanstack/react-query'
@ -24,15 +25,12 @@ import { TagSearchContent } from './tag-search-content'
import { TagTriggerContent } from './tag-trigger-content'
const normalizeTagName = (name: string) => name.trim().toLocaleLowerCase()
const TAG_COMBOBOX_FILTER: NonNullable<ComboboxProps<TagComboboxItem, true>['filter']> = (
tag,
query,
) => normalizeTagName(tag.name).includes(normalizeTagName(query))
const tagToString = (tag: TagComboboxItem) => tag.name
const isSameTag = (item: TagComboboxItem, value: TagComboboxItem) => item.id === value.id
const TAG_COMBOBOX_FILTER: NonNullable<
ComboboxProps<TagComboboxItem['id'], true, TagComboboxItem>['filter']
> = (tag, query) => normalizeTagName(tag.name).includes(normalizeTagName(query))
type TagSelectorRootProps = Omit<
ComboboxProps<TagComboboxItem, true>,
ComboboxProps<TagComboboxItem['id'], true, TagComboboxItem>,
| 'items'
| 'multiple'
| 'value'
@ -43,6 +41,7 @@ type TagSelectorRootProps = Omit<
| 'onInputValueChange'
| 'filter'
| 'itemToStringLabel'
| 'itemToStringValue'
| 'isItemEqualToValue'
| 'open'
| 'defaultOpen'
@ -75,7 +74,7 @@ export const TagSelector = ({
}: TagSelectorProps) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [draftTags, setDraftTags] = useState<Tag[]>(value)
const [draftTagIds, setDraftTagIds] = useState(() => value.map((tag) => tag.id))
const [inputValue, setInputValue] = useState('')
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const canManageTags = hasPermission(workspacePermissionKeys, getTagManagePermissionKey(type))
@ -123,7 +122,10 @@ export const TagSelector = ({
}
for (const tag of value) {
if (tag.type === type && !tagIds.has(tag.id)) nextItems.push(tag)
if (tag.type === type && !tagIds.has(tag.id)) {
tagIds.add(tag.id)
nextItems.push(tag)
}
}
if (
@ -143,9 +145,17 @@ export const TagSelector = ({
return nextItems
}, [canManageTags, inputValue, tagList, type, value])
const tagItemById = useMemo(() => new Map(items.map((tag) => [tag.id, tag])), [items])
const tagItems = useMemo(
() =>
createComboboxItems(items, {
getValue: (tag) => tag.id,
getLabel: (tag) => tag.name,
}),
[items],
)
const applyTagBindings = useCallback(() => {
const draftTagIds = draftTags.map((tag) => tag.id)
const draftTagIdSet = new Set(draftTagIds)
const tagSelectionChanged =
selectedTagIds.length !== draftTagIds.length ||
@ -184,19 +194,19 @@ export const TagSelector = ({
},
},
)
}, [applyTagBindingsMutation, draftTags, onTagsChange, selectedTagIds, t, targetId, type])
}, [applyTagBindingsMutation, draftTagIds, onTagsChange, selectedTagIds, t, targetId, type])
const handleOpenChange = useCallback(
(nextOpen: boolean) => {
if (nextOpen) {
setDraftTags(value)
setDraftTagIds(selectedTagIds)
} else {
applyTagBindings()
}
setOpen(nextOpen)
},
[applyTagBindings, value],
[applyTagBindings, selectedTagIds],
)
const createNewTag = useCallback(
@ -225,32 +235,34 @@ export const TagSelector = ({
)
const handleValueChange = useCallback(
(nextTags: TagComboboxItem[]) => {
const createOption = nextTags.find(isCreateTagOption)
if (createOption) {
(nextTagIds: string[]) => {
const createOptionId = nextTagIds.find((tagId) => {
const tag = tagItemById.get(tagId)
return tag ? isCreateTagOption(tag) : false
})
const createOption = createOptionId ? tagItemById.get(createOptionId) : undefined
if (createOption && isCreateTagOption(createOption)) {
createNewTag(createOption.name)
return
}
setDraftTags(nextTags.filter((tag) => !isCreateTagOption(tag)))
setDraftTagIds(nextTagIds)
},
[createNewTag],
[createNewTag, tagItemById],
)
return (
<Combobox
<Combobox<TagComboboxItem['id'], true, TagComboboxItem>
{...rootProps}
open={open}
onOpenChange={handleOpenChange}
items={items}
items={tagItems}
multiple
value={draftTags}
value={draftTagIds}
onValueChange={handleValueChange}
inputValue={inputValue}
onInputValueChange={setInputValue}
filter={TAG_COMBOBOX_FILTER}
itemToStringLabel={tagToString}
isItemEqualToValue={isSameTag}
>
<ComboboxTrigger
disabled={!canManageTags && !canBindOrUnbindTags}