mirror of
https://github.com/langgenius/dify.git
synced 2026-09-05 16:55:14 +08:00
refactor(dify-ui): adopt ID-backed combobox items (#41821)
This commit is contained in:
parent
067a60e0ac
commit
dcb7b36ad4
@ -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.
|
||||
|
||||
|
||||
@ -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 })
|
||||
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user