mirror of
https://github.com/langgenius/dify.git
synced 2026-09-04 16:07:08 +08:00
fix(web): align combobox interaction semantics (#40826)
This commit is contained in:
parent
141cbf9832
commit
facb60abfe
@ -6,7 +6,6 @@ import {
|
||||
ComboboxChipRemove,
|
||||
ComboboxChips,
|
||||
ComboboxClear,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxGroup,
|
||||
ComboboxGroupLabel,
|
||||
@ -18,6 +17,9 @@ import {
|
||||
ComboboxItemText,
|
||||
ComboboxLabel,
|
||||
ComboboxList,
|
||||
ComboboxPopup,
|
||||
ComboboxPortal,
|
||||
ComboboxPositioner,
|
||||
ComboboxSeparator,
|
||||
ComboboxStatus,
|
||||
ComboboxTrigger,
|
||||
@ -41,31 +43,27 @@ const renderSelectLikeCombobox = ({
|
||||
{children ?? (
|
||||
<React.Fragment>
|
||||
<ComboboxLabel data-testid="label">Resource type</ComboboxLabel>
|
||||
<ComboboxTrigger aria-label="Resource type" data-testid="trigger">
|
||||
<ComboboxTrigger data-testid="trigger">
|
||||
<ComboboxValue placeholder="Select resource" />
|
||||
</ComboboxTrigger>
|
||||
<ComboboxContent
|
||||
positionerProps={{
|
||||
role: 'group',
|
||||
'aria-label': 'combobox positioner',
|
||||
}}
|
||||
popupProps={{
|
||||
role: 'dialog',
|
||||
'aria-label': 'combobox popup',
|
||||
}}
|
||||
>
|
||||
<ComboboxStatus data-testid="status">2 options</ComboboxStatus>
|
||||
<ComboboxList role="listbox" aria-label="combobox list" data-testid="list">
|
||||
<ComboboxItem value="workflow">
|
||||
<ComboboxItemText>Workflow</ComboboxItemText>
|
||||
<ComboboxItemIndicator />
|
||||
</ComboboxItem>
|
||||
<ComboboxItem value="dataset">
|
||||
<ComboboxItemText>Dataset</ComboboxItemText>
|
||||
</ComboboxItem>
|
||||
</ComboboxList>
|
||||
<ComboboxEmpty data-testid="empty">No options</ComboboxEmpty>
|
||||
</ComboboxContent>
|
||||
<ComboboxPortal>
|
||||
<ComboboxPositioner data-testid="combobox-positioner">
|
||||
<ComboboxPopup aria-label="Choose a resource" data-testid="combobox-popup">
|
||||
<ComboboxInput aria-label="Filter resources" />
|
||||
<ComboboxStatus data-testid="status">2 options</ComboboxStatus>
|
||||
<ComboboxList data-testid="list">
|
||||
<ComboboxItem value="workflow">
|
||||
<ComboboxItemText>Workflow</ComboboxItemText>
|
||||
<ComboboxItemIndicator />
|
||||
</ComboboxItem>
|
||||
<ComboboxItem value="dataset">
|
||||
<ComboboxItemText>Dataset</ComboboxItemText>
|
||||
</ComboboxItem>
|
||||
</ComboboxList>
|
||||
<ComboboxEmpty data-testid="empty">No options</ComboboxEmpty>
|
||||
</ComboboxPopup>
|
||||
</ComboboxPositioner>
|
||||
</ComboboxPortal>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</Combobox>,
|
||||
@ -87,14 +85,18 @@ const renderInputCombobox = ({
|
||||
<ComboboxClear data-testid="clear" />
|
||||
<ComboboxInputTrigger data-testid="input-trigger" />
|
||||
</ComboboxInputGroup>
|
||||
<ComboboxContent popupProps={{ role: 'dialog', 'aria-label': 'combobox popup' }}>
|
||||
<ComboboxList role="listbox" aria-label="combobox list">
|
||||
<ComboboxItem value="workflow">
|
||||
<ComboboxItemText>Workflow</ComboboxItemText>
|
||||
<ComboboxItemIndicator />
|
||||
</ComboboxItem>
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
<ComboboxPortal>
|
||||
<ComboboxPositioner>
|
||||
<ComboboxPopup data-testid="combobox-popup">
|
||||
<ComboboxList>
|
||||
<ComboboxItem value="workflow">
|
||||
<ComboboxItemText>Workflow</ComboboxItemText>
|
||||
<ComboboxItemIndicator />
|
||||
</ComboboxItem>
|
||||
</ComboboxList>
|
||||
</ComboboxPopup>
|
||||
</ComboboxPositioner>
|
||||
</ComboboxPortal>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</Combobox>,
|
||||
@ -188,16 +190,19 @@ describe('Combobox wrappers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Content and options', () => {
|
||||
describe('Popup anatomy and options', () => {
|
||||
it('should use default overlay placement', async () => {
|
||||
const screen = await renderSelectLikeCombobox({ open: true })
|
||||
|
||||
await expect
|
||||
.element(screen.getByRole('group', { name: 'combobox positioner' }))
|
||||
.element(screen.getByTestId('combobox-positioner'))
|
||||
.toHaveAttribute('data-side', 'bottom')
|
||||
await expect
|
||||
.element(screen.getByRole('group', { name: 'combobox positioner' }))
|
||||
.element(screen.getByTestId('combobox-positioner'))
|
||||
.toHaveAttribute('data-align', 'start')
|
||||
await expect
|
||||
.element(screen.getByRole('dialog', { name: 'Choose a resource' }))
|
||||
.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply custom placement side and passthrough popup props', async () => {
|
||||
@ -207,52 +212,100 @@ describe('Combobox wrappers', () => {
|
||||
<ComboboxTrigger aria-label="Resource type">
|
||||
<ComboboxValue />
|
||||
</ComboboxTrigger>
|
||||
<ComboboxContent
|
||||
placement="top-end"
|
||||
sideOffset={12}
|
||||
alignOffset={6}
|
||||
positionerProps={{ role: 'group', 'aria-label': 'combobox positioner' }}
|
||||
popupProps={{
|
||||
role: 'dialog',
|
||||
'aria-label': 'combobox popup',
|
||||
onClick: onPopupClick,
|
||||
}}
|
||||
>
|
||||
<ComboboxList role="listbox" aria-label="combobox list">
|
||||
<ComboboxItem value="workflow">
|
||||
<ComboboxItemText>Workflow</ComboboxItemText>
|
||||
</ComboboxItem>
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
<ComboboxPortal>
|
||||
<ComboboxPositioner
|
||||
placement="top-end"
|
||||
sideOffset={12}
|
||||
alignOffset={6}
|
||||
data-testid="combobox-positioner"
|
||||
>
|
||||
<ComboboxPopup
|
||||
aria-label="Choose a resource"
|
||||
data-testid="combobox-popup"
|
||||
onClick={onPopupClick}
|
||||
>
|
||||
<ComboboxInput aria-label="Filter resources" />
|
||||
<ComboboxList>
|
||||
<ComboboxItem value="workflow">
|
||||
<ComboboxItemText>Workflow</ComboboxItemText>
|
||||
</ComboboxItem>
|
||||
</ComboboxList>
|
||||
</ComboboxPopup>
|
||||
</ComboboxPositioner>
|
||||
</ComboboxPortal>
|
||||
</Combobox>,
|
||||
)
|
||||
|
||||
await screen.getByRole('dialog', { name: 'combobox popup' }).click()
|
||||
await screen.getByTestId('combobox-popup').click()
|
||||
|
||||
await expect
|
||||
.element(screen.getByRole('group', { name: 'combobox positioner' }))
|
||||
.element(screen.getByTestId('combobox-positioner'))
|
||||
.toHaveAttribute('data-side', 'top')
|
||||
expect(onPopupClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('names the dialog popup when the input is composed inside it', async () => {
|
||||
const screen = await renderWithSafeViewport(
|
||||
<Combobox open items={['workflow']}>
|
||||
<ComboboxLabel>Resource type</ComboboxLabel>
|
||||
<ComboboxTrigger>Choose resource</ComboboxTrigger>
|
||||
<ComboboxPortal>
|
||||
<ComboboxPositioner>
|
||||
<ComboboxPopup aria-label="Choose a resource">
|
||||
<ComboboxInput aria-label="Filter resources" />
|
||||
<ComboboxList>
|
||||
<ComboboxItem value="workflow">Workflow</ComboboxItem>
|
||||
</ComboboxList>
|
||||
</ComboboxPopup>
|
||||
</ComboboxPositioner>
|
||||
</ComboboxPortal>
|
||||
</Combobox>,
|
||||
)
|
||||
|
||||
await expect
|
||||
.element(screen.getByRole('dialog', { name: 'Choose a resource' }))
|
||||
.toBeInTheDocument()
|
||||
await expect
|
||||
.element(screen.getByRole('combobox', { name: 'Filter resources' }))
|
||||
.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps an empty live region mounted without visible spacing', async () => {
|
||||
const screen = await renderWithSafeViewport(
|
||||
<Combobox items={[]}>
|
||||
<ComboboxInput aria-label="Search resources" />
|
||||
<ComboboxStatus>{null}</ComboboxStatus>
|
||||
</Combobox>,
|
||||
)
|
||||
|
||||
const status = screen.getByRole('status')
|
||||
|
||||
expect(status.element().getBoundingClientRect().height).toBe(0)
|
||||
})
|
||||
|
||||
it('should forward custom classes to group label separator item text and indicator', async () => {
|
||||
const screen = await renderWithSafeViewport(
|
||||
<Combobox open defaultValue="workflow" items={['workflow']}>
|
||||
<ComboboxTrigger aria-label="Resource type">
|
||||
<ComboboxValue />
|
||||
</ComboboxTrigger>
|
||||
<ComboboxContent popupProps={{ role: 'dialog', 'aria-label': 'combobox popup' }}>
|
||||
<ComboboxList role="listbox" aria-label="combobox list" data-testid="custom-list">
|
||||
<ComboboxGroup items={['workflow']}>
|
||||
<ComboboxGroupLabel className="custom-label">Resources</ComboboxGroupLabel>
|
||||
<ComboboxSeparator className="custom-separator" data-testid="separator" />
|
||||
<ComboboxItem value="workflow" className="custom-item">
|
||||
<ComboboxItemText className="custom-text">Workflow</ComboboxItemText>
|
||||
<ComboboxItemIndicator className="custom-indicator" data-testid="indicator" />
|
||||
</ComboboxItem>
|
||||
</ComboboxGroup>
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
<ComboboxPortal>
|
||||
<ComboboxPositioner>
|
||||
<ComboboxPopup aria-label="Choose a resource">
|
||||
<ComboboxInput aria-label="Filter resources" />
|
||||
<ComboboxList data-testid="custom-list">
|
||||
<ComboboxGroup items={['workflow']}>
|
||||
<ComboboxGroupLabel className="custom-label">Resources</ComboboxGroupLabel>
|
||||
<ComboboxSeparator className="custom-separator" data-testid="separator" />
|
||||
<ComboboxItem value="workflow" className="custom-item">
|
||||
<ComboboxItemText className="custom-text">Workflow</ComboboxItemText>
|
||||
<ComboboxItemIndicator className="custom-indicator" data-testid="indicator" />
|
||||
</ComboboxItem>
|
||||
</ComboboxGroup>
|
||||
</ComboboxList>
|
||||
</ComboboxPopup>
|
||||
</ComboboxPositioner>
|
||||
</ComboboxPortal>
|
||||
</Combobox>,
|
||||
)
|
||||
|
||||
@ -273,16 +326,20 @@ describe('Combobox wrappers', () => {
|
||||
<ComboboxInputGroup>
|
||||
<ComboboxInput aria-label="Search resources" />
|
||||
</ComboboxInputGroup>
|
||||
<ComboboxContent>
|
||||
<ComboboxList<string>>
|
||||
{(item) => (
|
||||
<ComboboxItem key={item} value={item}>
|
||||
<ComboboxItemText>{item}</ComboboxItemText>
|
||||
<ComboboxItemIndicator />
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
<ComboboxPortal>
|
||||
<ComboboxPositioner>
|
||||
<ComboboxPopup>
|
||||
<ComboboxList<string>>
|
||||
{(item) => (
|
||||
<ComboboxItem key={item} value={item}>
|
||||
<ComboboxItemText>{item}</ComboboxItemText>
|
||||
<ComboboxItemIndicator />
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxPopup>
|
||||
</ComboboxPositioner>
|
||||
</ComboboxPortal>
|
||||
</Combobox>,
|
||||
)
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import type { Virtualizer } from '@tanstack/react-virtual'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import * as React from 'react'
|
||||
import { expect } from 'storybook/test'
|
||||
import { expect, waitFor, within } from 'storybook/test'
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxChip,
|
||||
@ -10,7 +10,6 @@ import {
|
||||
ComboboxChips,
|
||||
ComboboxClear,
|
||||
ComboboxCollection,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxGroup,
|
||||
ComboboxGroupLabel,
|
||||
@ -22,6 +21,9 @@ import {
|
||||
ComboboxItemText,
|
||||
ComboboxLabel,
|
||||
ComboboxList,
|
||||
ComboboxPopup,
|
||||
ComboboxPortal,
|
||||
ComboboxPositioner,
|
||||
ComboboxSeparator,
|
||||
ComboboxStatus,
|
||||
ComboboxTrigger,
|
||||
@ -29,8 +31,17 @@ import {
|
||||
useComboboxFilter,
|
||||
useComboboxFilteredItems,
|
||||
} from '.'
|
||||
import { Button } from '../button'
|
||||
import { cn } from '../cn'
|
||||
import { Field, FieldDescription, FieldLabel } from '../field'
|
||||
import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from '../popover'
|
||||
import {
|
||||
ScrollArea,
|
||||
ScrollAreaContent,
|
||||
ScrollAreaScrollbar,
|
||||
ScrollAreaThumb,
|
||||
ScrollAreaViewport,
|
||||
} from '../scroll-area'
|
||||
|
||||
type Option = {
|
||||
value: string
|
||||
@ -256,7 +267,6 @@ const defaultPopupDataSource = dataSourceOptions[1]!
|
||||
const readOnlyDataSource = dataSourceOptions[2]!
|
||||
const defaultTool = toolGroups[0]!.items[0]!
|
||||
const defaultReviewers = [reviewerOptions[0]!, reviewerOptions[1]!]
|
||||
const defaultAsyncReviewers = [reviewerOptions[1]!]
|
||||
const defaultTag = tagOptions[2]!
|
||||
|
||||
const getOptionLabel = (option: Option) => option.label
|
||||
@ -313,11 +323,13 @@ const renderSimpleOptionItem = (option: Option) => (
|
||||
)
|
||||
|
||||
// Only virtualized items receive an explicit index; ordinary lists must let Base UI register items by DOM order for keyboard navigation.
|
||||
const renderVirtualizedOptionItem = (option: Option, index: number) => (
|
||||
const renderVirtualizedOptionItem = (option: Option, index: number, itemCount: number) => (
|
||||
<ComboboxItem
|
||||
key={option.value}
|
||||
value={option}
|
||||
index={index}
|
||||
aria-posinset={index + 1}
|
||||
aria-setsize={itemCount}
|
||||
disabled={option.disabled}
|
||||
className="h-auto min-h-8 py-1.5"
|
||||
>
|
||||
@ -348,7 +360,6 @@ const PopupSearchInput = ({ label, placeholder }: { label: string; placeholder:
|
||||
placeholder={`${placeholder}…`}
|
||||
className="block h-4.5 grow px-1 py-0 system-sm-regular text-components-input-text-filled"
|
||||
/>
|
||||
<ComboboxClear className="mr-0" />
|
||||
</ComboboxInputGroup>
|
||||
</div>
|
||||
)
|
||||
@ -392,36 +403,46 @@ const VirtualizedModelList = ({
|
||||
}, [virtualizer, virtualizerRef])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="max-h-[min(22rem,var(--available-height))] overflow-x-hidden overflow-y-auto overscroll-contain outline-hidden"
|
||||
>
|
||||
<ComboboxList
|
||||
className="relative max-h-none overflow-visible p-0"
|
||||
style={{
|
||||
height: virtualizer.getTotalSize(),
|
||||
}}
|
||||
<ScrollArea className="relative overflow-hidden">
|
||||
<ScrollAreaViewport
|
||||
ref={scrollRef}
|
||||
role="region"
|
||||
aria-label="Model catalog"
|
||||
className="max-h-[min(22rem,var(--available-height))] overscroll-contain"
|
||||
style={{ overflowX: 'hidden' }}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||
const option = filteredItems[virtualItem.index]
|
||||
<ScrollAreaContent style={{ minWidth: 0 }}>
|
||||
<ComboboxList
|
||||
className="relative max-h-none overflow-visible p-0"
|
||||
style={{
|
||||
height: virtualizer.getTotalSize(),
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||
const option = filteredItems[virtualItem.index]
|
||||
|
||||
if (!option) return null
|
||||
if (!option) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
key={virtualItem.key}
|
||||
className="absolute top-0 left-0 w-full"
|
||||
style={{
|
||||
height: virtualItem.size,
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
}}
|
||||
>
|
||||
{renderVirtualizedOptionItem(option, virtualItem.index)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</ComboboxList>
|
||||
</div>
|
||||
return (
|
||||
<div
|
||||
key={virtualItem.key}
|
||||
className="absolute top-0 left-0 w-full"
|
||||
style={{
|
||||
height: virtualItem.size,
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
}}
|
||||
>
|
||||
{renderVirtualizedOptionItem(option, virtualItem.index, filteredItems.length)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</ComboboxList>
|
||||
</ScrollAreaContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar>
|
||||
<ScrollAreaThumb />
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
|
||||
@ -451,15 +472,19 @@ const VirtualizedLongListDemo = () => {
|
||||
}}
|
||||
>
|
||||
<ComboboxLabel>Model catalog</ComboboxLabel>
|
||||
<ComboboxTrigger aria-label="Model catalog">
|
||||
<ComboboxTrigger>
|
||||
<ComboboxValue placeholder="Select model" />
|
||||
</ComboboxTrigger>
|
||||
<ComboboxContent popupClassName="w-[440px]">
|
||||
<PopupSearchInput label="Filter model catalog" placeholder="Filter 1,000 models" />
|
||||
<FilteredModelStatus />
|
||||
<VirtualizedModelList virtualizerRef={virtualizerRef} />
|
||||
<ComboboxEmpty>No model matches this filter</ComboboxEmpty>
|
||||
</ComboboxContent>
|
||||
<ComboboxPortal>
|
||||
<ComboboxPositioner>
|
||||
<ComboboxPopup className="w-110" aria-label="Model catalog">
|
||||
<PopupSearchInput label="Filter model catalog" placeholder="Filter 1,000 models" />
|
||||
<FilteredModelStatus />
|
||||
<VirtualizedModelList virtualizerRef={virtualizerRef} />
|
||||
<ComboboxEmpty>No model matches this filter</ComboboxEmpty>
|
||||
</ComboboxPopup>
|
||||
</ComboboxPositioner>
|
||||
</ComboboxPortal>
|
||||
</Combobox>
|
||||
</div>
|
||||
)
|
||||
@ -555,153 +580,70 @@ const AsyncDirectoryDemo = () => {
|
||||
<ComboboxClear className="mr-0.5" />
|
||||
<ComboboxInputTrigger className="mr-0" />
|
||||
</ComboboxInputGroup>
|
||||
<ComboboxContent
|
||||
popupClassName="w-[420px]"
|
||||
popupProps={{ 'aria-busy': isPending || undefined }}
|
||||
>
|
||||
<ComboboxStatus className="border-b border-divider-subtle">{status}</ComboboxStatus>
|
||||
<ComboboxList<Option>>{renderOptionItem}</ComboboxList>
|
||||
<ComboboxEmpty>{emptyMessage}</ComboboxEmpty>
|
||||
</ComboboxContent>
|
||||
<ComboboxPortal>
|
||||
<ComboboxPositioner>
|
||||
<ComboboxPopup className="w-105" aria-busy={isPending || undefined}>
|
||||
<ComboboxStatus className="border-b border-divider-subtle">{status}</ComboboxStatus>
|
||||
<ComboboxList<Option>>{renderOptionItem}</ComboboxList>
|
||||
<ComboboxEmpty>{emptyMessage}</ComboboxEmpty>
|
||||
</ComboboxPopup>
|
||||
</ComboboxPositioner>
|
||||
</ComboboxPortal>
|
||||
</Combobox>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
const AsyncReviewerDemo = () => {
|
||||
const [searchResults, setSearchResults] = React.useState<Option[]>([])
|
||||
const [selectedValues, setSelectedValues] = React.useState<Option[]>(defaultAsyncReviewers)
|
||||
const [searchValue, setSearchValue] = React.useState('')
|
||||
const [error, setError] = React.useState<string | null>(null)
|
||||
const [blockStartStatus, setBlockStartStatus] = React.useState(false)
|
||||
const [isPending, startTransition] = React.useTransition()
|
||||
const { contains } = useComboboxFilter()
|
||||
const abortControllerRef = React.useRef<AbortController | null>(null)
|
||||
const selectedValuesRef = React.useRef<Option[]>(defaultAsyncReviewers)
|
||||
const trimmedSearchValue = searchValue.trim()
|
||||
|
||||
const items = React.useMemo(() => {
|
||||
if (selectedValues.length === 0) return searchResults
|
||||
|
||||
const merged = [...searchResults]
|
||||
|
||||
selectedValues.forEach((selected) => {
|
||||
if (!searchResults.some((result) => result.value === selected.value)) merged.push(selected)
|
||||
})
|
||||
|
||||
return merged
|
||||
}, [searchResults, selectedValues])
|
||||
|
||||
const status = (() => {
|
||||
if (isPending) return 'Searching reviewer matches…'
|
||||
|
||||
if (error) return error
|
||||
|
||||
if (trimmedSearchValue === '' && !blockStartStatus)
|
||||
return selectedValues.length > 0 ? null : 'Start typing to search reviewers…'
|
||||
|
||||
if (searchResults.length === 0 && !blockStartStatus)
|
||||
return `No matches for "${trimmedSearchValue}".`
|
||||
|
||||
return `${searchResults.length} reviewer${searchResults.length === 1 ? '' : 's'} found`
|
||||
})()
|
||||
|
||||
const emptyMessage =
|
||||
trimmedSearchValue === '' || isPending || searchResults.length > 0 || error
|
||||
? null
|
||||
: 'Try a different reviewer search.'
|
||||
const InlinePopoverDemo = () => {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [value, setValue] = React.useState<Option | null>(null)
|
||||
const [inputValue, setInputValue] = React.useState('')
|
||||
|
||||
return (
|
||||
<Field name="asyncReviewers" className={fieldWidth}>
|
||||
<FieldLabel>Async reviewers</FieldLabel>
|
||||
<Combobox
|
||||
items={items}
|
||||
itemToStringLabel={getOptionLabel}
|
||||
multiple
|
||||
filter={null}
|
||||
value={selectedValues}
|
||||
onOpenChangeComplete={(open) => {
|
||||
if (!open) {
|
||||
setSearchResults(selectedValuesRef.current)
|
||||
setBlockStartStatus(false)
|
||||
}
|
||||
}}
|
||||
onValueChange={(nextSelectedValues) => {
|
||||
selectedValuesRef.current = nextSelectedValues
|
||||
setSelectedValues(nextSelectedValues)
|
||||
setSearchValue('')
|
||||
setError(null)
|
||||
|
||||
if (nextSelectedValues.length === 0) {
|
||||
setSearchResults([])
|
||||
setBlockStartStatus(false)
|
||||
} else {
|
||||
setBlockStartStatus(true)
|
||||
}
|
||||
}}
|
||||
onInputValueChange={(nextSearchValue, { reason }) => {
|
||||
setSearchValue(nextSearchValue)
|
||||
|
||||
const controller = new AbortController()
|
||||
abortControllerRef.current?.abort()
|
||||
abortControllerRef.current = controller
|
||||
|
||||
if (nextSearchValue === '') {
|
||||
setSearchResults(selectedValuesRef.current)
|
||||
setError(null)
|
||||
setBlockStartStatus(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (reason === 'item-press') return
|
||||
|
||||
startTransition(async () => {
|
||||
setError(null)
|
||||
|
||||
const result = await searchOptions(reviewerOptions, nextSearchValue, contains)
|
||||
|
||||
if (controller.signal.aborted) return
|
||||
|
||||
startTransition(() => {
|
||||
setSearchResults(result.items)
|
||||
setError(result.error)
|
||||
})
|
||||
})
|
||||
<div className="flex w-80 flex-col items-start gap-3">
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
onOpenChangeComplete={(nextOpen) => {
|
||||
if (!nextOpen) setInputValue('')
|
||||
}}
|
||||
>
|
||||
<ComboboxInputGroup className="h-auto min-h-8 items-start py-1">
|
||||
<ComboboxChips>
|
||||
<ComboboxValue<Option, true>>
|
||||
{(selectedValue) => (
|
||||
<React.Fragment>
|
||||
{selectedValue?.map((item) => (
|
||||
<ComboboxChip key={item.value} aria-label={item.label}>
|
||||
<span className="max-w-32 truncate">{item.label}</span>
|
||||
<ComboboxChipRemove aria-label={`Remove ${item.label}`} />
|
||||
</ComboboxChip>
|
||||
))}
|
||||
<ComboboxInput
|
||||
placeholder={selectedValue?.length ? '' : 'Search reviewers…'}
|
||||
className="min-w-24 px-1 py-0.5"
|
||||
/>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</ComboboxValue>
|
||||
</ComboboxChips>
|
||||
</ComboboxInputGroup>
|
||||
<ComboboxContent
|
||||
popupClassName="w-[420px]"
|
||||
popupProps={{ 'aria-busy': isPending || undefined }}
|
||||
>
|
||||
<ComboboxStatus className="border-b border-divider-subtle">{status}</ComboboxStatus>
|
||||
<ComboboxList<Option>>{renderOptionItem}</ComboboxList>
|
||||
<ComboboxEmpty>{emptyMessage}</ComboboxEmpty>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
<FieldDescription>
|
||||
Selected reviewers stay available while async matches change.
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
<PopoverTrigger render={<Button variant="secondary" />}>Choose reviewer</PopoverTrigger>
|
||||
<PopoverContent placement="bottom-start" sideOffset={4} popupClassName="w-80 p-0">
|
||||
<PopoverTitle className="sr-only">Choose reviewer</PopoverTitle>
|
||||
<Combobox
|
||||
inline
|
||||
open={open}
|
||||
items={reviewerOptions}
|
||||
value={value}
|
||||
inputValue={inputValue}
|
||||
itemToStringLabel={getOptionLabel}
|
||||
onOpenChange={setOpen}
|
||||
onValueChange={setValue}
|
||||
onInputValueChange={setInputValue}
|
||||
>
|
||||
<div className="p-2 pb-1">
|
||||
<ComboboxInputGroup className="h-8 min-h-8 px-2">
|
||||
<span
|
||||
aria-hidden
|
||||
className="mr-0.5 i-ri-search-line size-4 shrink-0 text-components-input-text-placeholder"
|
||||
/>
|
||||
<ComboboxInput
|
||||
aria-label="Search reviewers"
|
||||
placeholder="Search reviewers…"
|
||||
className="block h-4.5 grow px-1 py-0 system-sm-regular text-components-input-text-filled"
|
||||
/>
|
||||
</ComboboxInputGroup>
|
||||
</div>
|
||||
<ComboboxList<Option>>{renderOptionItem}</ComboboxList>
|
||||
<ComboboxEmpty>No reviewers found</ComboboxEmpty>
|
||||
</Combobox>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<span className="system-xs-regular text-text-tertiary">
|
||||
Selected reviewer: {value?.label ?? 'None'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -713,7 +655,7 @@ const meta = {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Compound combobox built on Base UI Combobox for searchable predefined selections. Compose triggers, inputs, lists, groups, status, empty states, and chips without importing Base UI primitives directly.',
|
||||
'Compound combobox built on Base UI Combobox for searchable predefined selections. Use an input as the trigger, place an input inside a named popup, or set `inline` when an external Popover owns the surface. Keep independent actions outside the listbox, keep Status mounted while changing its children, and use Clear only for selection clearing.',
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -740,65 +682,97 @@ export const Default: Story = {
|
||||
<ComboboxClear className="mr-0.5" />
|
||||
<ComboboxInputTrigger className="mr-0" />
|
||||
</ComboboxInputGroup>
|
||||
<ComboboxContent>
|
||||
<ComboboxList<Option>>{renderSimpleOptionItem}</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</Field>
|
||||
),
|
||||
}
|
||||
|
||||
export const FormField: Story = {
|
||||
render: () => (
|
||||
<Field name="sourceConnector" className={fieldWidth}>
|
||||
<FieldLabel>Connect source</FieldLabel>
|
||||
<Combobox items={dataSourceOptions} defaultValue={defaultDataSource}>
|
||||
<ComboboxInputGroup className="h-8 min-h-8 px-2">
|
||||
<span
|
||||
aria-hidden
|
||||
className="mr-0.5 i-ri-search-line size-4 shrink-0 text-components-input-text-placeholder"
|
||||
/>
|
||||
<ComboboxInput
|
||||
placeholder="Search data sources…"
|
||||
className="block h-4.5 grow px-1 py-0 system-sm-regular text-components-input-text-filled"
|
||||
/>
|
||||
<ComboboxClear className="mr-0.5" />
|
||||
<ComboboxInputTrigger className="mr-0" />
|
||||
</ComboboxInputGroup>
|
||||
<ComboboxContent>
|
||||
<ComboboxList<Option>>{renderSimpleOptionItem}</ComboboxList>
|
||||
</ComboboxContent>
|
||||
<ComboboxPortal>
|
||||
<ComboboxPositioner>
|
||||
<ComboboxPopup>
|
||||
<ComboboxList<Option>>{renderSimpleOptionItem}</ComboboxList>
|
||||
</ComboboxPopup>
|
||||
</ComboboxPositioner>
|
||||
</ComboboxPortal>
|
||||
</Combobox>
|
||||
<FieldDescription>Type to filter, then choose a remembered data source.</FieldDescription>
|
||||
</Field>
|
||||
),
|
||||
play: async ({ canvas, canvasElement, userEvent }) => {
|
||||
const input = canvas.getByRole('combobox', { name: 'Connect source' })
|
||||
const body = within(canvasElement.ownerDocument.body)
|
||||
|
||||
await expect(input).toHaveValue('Knowledge Base')
|
||||
await userEvent.clear(input)
|
||||
await userEvent.type(input, 'Notion')
|
||||
await waitFor(async () => {
|
||||
await expect(body.getByRole('option', { name: 'Notion' })).toBeVisible()
|
||||
})
|
||||
|
||||
await userEvent.keyboard('{ArrowDown}{Enter}')
|
||||
await expect(input).toHaveValue('Notion')
|
||||
},
|
||||
}
|
||||
|
||||
export const CompactTriggerWithPopupSearch: Story = {
|
||||
export const TriggerWithPopupInput: Story = {
|
||||
render: () => (
|
||||
<div className={fieldWidth}>
|
||||
<Combobox items={dataSourceOptions} defaultValue={defaultPopupDataSource}>
|
||||
<ComboboxLabel>Data source</ComboboxLabel>
|
||||
<ComboboxTrigger aria-label="Data source">
|
||||
<ComboboxTrigger>
|
||||
<ComboboxValue placeholder="Choose source" />
|
||||
</ComboboxTrigger>
|
||||
<ComboboxContent>
|
||||
<PopupSearchInput label="Search data sources" placeholder="Search sources" />
|
||||
<ComboboxList<Option>>{renderOptionItem}</ComboboxList>
|
||||
</ComboboxContent>
|
||||
<ComboboxPortal>
|
||||
<ComboboxPositioner>
|
||||
<ComboboxPopup aria-label="Data source">
|
||||
<PopupSearchInput label="Search data sources" placeholder="Search sources" />
|
||||
<ComboboxList<Option>>{renderOptionItem}</ComboboxList>
|
||||
</ComboboxPopup>
|
||||
</ComboboxPositioner>
|
||||
</ComboboxPortal>
|
||||
</Combobox>
|
||||
</div>
|
||||
),
|
||||
play: async ({ canvas, canvasElement, userEvent }) => {
|
||||
const trigger = canvas.getByRole('combobox', { name: 'Data source' })
|
||||
const body = within(canvasElement.ownerDocument.body)
|
||||
|
||||
await userEvent.click(trigger)
|
||||
const popup = await body.findByRole('dialog', { name: 'Data source' })
|
||||
const input = within(popup).getByRole('combobox', { name: 'Search data sources' })
|
||||
await userEvent.type(input, 'Website')
|
||||
await userEvent.click(within(popup).getByRole('option', { name: /Website crawler/ }))
|
||||
|
||||
await expect(trigger).toHaveTextContent('Website crawler')
|
||||
await waitFor(async () => {
|
||||
await expect(body.queryByRole('dialog', { name: 'Data source' })).not.toBeInTheDocument()
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export const AsyncSearchSingle: Story = {
|
||||
export const InlineInPopover: Story = {
|
||||
render: () => <InlinePopoverDemo />,
|
||||
play: async ({ canvas, canvasElement, userEvent }) => {
|
||||
const body = within(canvasElement.ownerDocument.body)
|
||||
|
||||
await userEvent.click(canvas.getByRole('button', { name: 'Choose reviewer' }))
|
||||
const popover = await body.findByRole('dialog', { name: 'Choose reviewer' })
|
||||
const input = within(popover).getByRole('combobox', { name: 'Search reviewers' })
|
||||
await userEvent.type(input, 'Nora')
|
||||
await userEvent.click(within(popover).getByRole('option', { name: /Nora Park/ }))
|
||||
|
||||
await expect(canvas.getByText('Selected reviewer: Nora Park')).toBeVisible()
|
||||
await waitFor(async () => {
|
||||
await expect(body.queryByRole('dialog', { name: 'Choose reviewer' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
await userEvent.click(canvas.getByRole('button', { name: 'Choose reviewer' }))
|
||||
const reopenedPopover = await body.findByRole('dialog', { name: 'Choose reviewer' })
|
||||
await expect(
|
||||
within(reopenedPopover).getByRole('combobox', { name: 'Search reviewers' }),
|
||||
).toHaveValue('')
|
||||
},
|
||||
}
|
||||
|
||||
export const AsyncSearch: Story = {
|
||||
render: () => <AsyncDirectoryDemo />,
|
||||
}
|
||||
|
||||
export const AsyncSearchMultiple: Story = {
|
||||
render: () => <AsyncReviewerDemo />,
|
||||
}
|
||||
|
||||
export const Sizes: Story = {
|
||||
render: () => (
|
||||
<div className="flex w-80 flex-col gap-3">
|
||||
@ -815,9 +789,13 @@ export const Sizes: Story = {
|
||||
<ComboboxClear size={size} className="mr-0.5" />
|
||||
<ComboboxInputTrigger size={size} className="mr-0" />
|
||||
</ComboboxInputGroup>
|
||||
<ComboboxContent>
|
||||
<ComboboxList<Option>>{renderOptionItem}</ComboboxList>
|
||||
</ComboboxContent>
|
||||
<ComboboxPortal>
|
||||
<ComboboxPositioner>
|
||||
<ComboboxPopup>
|
||||
<ComboboxList<Option>>{renderOptionItem}</ComboboxList>
|
||||
</ComboboxPopup>
|
||||
</ComboboxPositioner>
|
||||
</ComboboxPortal>
|
||||
</Combobox>
|
||||
</Field>
|
||||
))}
|
||||
@ -830,13 +808,17 @@ export const Grouped: Story = {
|
||||
<div className={fieldWidth}>
|
||||
<Combobox items={toolGroups} defaultValue={defaultTool}>
|
||||
<ComboboxLabel>Workflow tool</ComboboxLabel>
|
||||
<ComboboxTrigger aria-label="Workflow tool">
|
||||
<ComboboxTrigger>
|
||||
<ComboboxValue placeholder="Select tool" />
|
||||
</ComboboxTrigger>
|
||||
<ComboboxContent>
|
||||
<PopupSearchInput label="Search workflow tools" placeholder="Search workflow tools" />
|
||||
<GroupedToolList />
|
||||
</ComboboxContent>
|
||||
<ComboboxPortal>
|
||||
<ComboboxPositioner>
|
||||
<ComboboxPopup aria-label="Workflow tool">
|
||||
<PopupSearchInput label="Search workflow tools" placeholder="Search workflow tools" />
|
||||
<GroupedToolList />
|
||||
</ComboboxPopup>
|
||||
</ComboboxPositioner>
|
||||
</ComboboxPortal>
|
||||
</Combobox>
|
||||
</div>
|
||||
),
|
||||
@ -869,9 +851,13 @@ const MultipleChipsDemo = () => {
|
||||
</ComboboxValue>
|
||||
</ComboboxChips>
|
||||
</ComboboxInputGroup>
|
||||
<ComboboxContent>
|
||||
<ComboboxList<Option>>{renderOptionItem}</ComboboxList>
|
||||
</ComboboxContent>
|
||||
<ComboboxPortal>
|
||||
<ComboboxPositioner>
|
||||
<ComboboxPopup>
|
||||
<ComboboxList<Option>>{renderOptionItem}</ComboboxList>
|
||||
</ComboboxPopup>
|
||||
</ComboboxPositioner>
|
||||
</ComboboxPortal>
|
||||
</Combobox>
|
||||
<FieldDescription>
|
||||
Selected reviewers wrap inside the input instead of scrolling horizontally.
|
||||
@ -897,7 +883,7 @@ export const VirtualizedLongList: Story = {
|
||||
render: () => <VirtualizedLongListDemo />,
|
||||
}
|
||||
|
||||
export const EmptyAndStatus: Story = {
|
||||
export const Empty: Story = {
|
||||
render: () => (
|
||||
<Field name="connector" className={fieldWidth}>
|
||||
<FieldLabel>Connector</FieldLabel>
|
||||
@ -911,54 +897,66 @@ export const EmptyAndStatus: Story = {
|
||||
placeholder="Search connectors…"
|
||||
className="block h-4.5 grow px-1 py-0 system-sm-regular text-components-input-text-filled"
|
||||
/>
|
||||
<ComboboxClear className="mr-0.5" />
|
||||
<ComboboxInputTrigger className="mr-0" />
|
||||
</ComboboxInputGroup>
|
||||
<ComboboxContent>
|
||||
<ComboboxStatus>Search workspace connectors</ComboboxStatus>
|
||||
<ComboboxEmpty>No connectors found</ComboboxEmpty>
|
||||
<ComboboxList<Option>>{renderSimpleOptionItem}</ComboboxList>
|
||||
</ComboboxContent>
|
||||
<ComboboxPortal>
|
||||
<ComboboxPositioner>
|
||||
<ComboboxPopup>
|
||||
<ComboboxEmpty>No connectors found</ComboboxEmpty>
|
||||
<ComboboxList<Option>>{renderSimpleOptionItem}</ComboboxList>
|
||||
</ComboboxPopup>
|
||||
</ComboboxPositioner>
|
||||
</ComboboxPortal>
|
||||
</Combobox>
|
||||
</Field>
|
||||
),
|
||||
}
|
||||
|
||||
export const DisabledAndReadOnly: Story = {
|
||||
export const Disabled: Story = {
|
||||
render: () => (
|
||||
<div className="flex w-80 flex-col gap-3">
|
||||
<Field name="disabledProvider" disabled>
|
||||
<Combobox items={providerOptions} defaultValue={disabledProvider} disabled>
|
||||
<ComboboxLabel>Disabled provider</ComboboxLabel>
|
||||
<ComboboxTrigger aria-label="Disabled model provider">
|
||||
<ComboboxValue />
|
||||
</ComboboxTrigger>
|
||||
<ComboboxContent>
|
||||
<PopupSearchInput label="Search disabled providers" placeholder="Search providers" />
|
||||
<ComboboxList<Option>>{renderOptionItem}</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</Field>
|
||||
<Field name="readOnlySource">
|
||||
<FieldLabel>Read-only source</FieldLabel>
|
||||
<Combobox items={dataSourceOptions} defaultValue={readOnlyDataSource} readOnly>
|
||||
<ComboboxInputGroup className="h-8 min-h-8 px-2">
|
||||
<ComboboxInput
|
||||
placeholder="Read-only data source…"
|
||||
className="block h-4.5 grow px-1 py-0 system-sm-regular text-components-input-text-filled"
|
||||
/>
|
||||
<ComboboxClear className="mr-0.5" />
|
||||
<ComboboxInputTrigger className="mr-0" />
|
||||
</ComboboxInputGroup>
|
||||
<ComboboxContent>
|
||||
<ComboboxList<Option>>{renderOptionItem}</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</Field>
|
||||
<div className={fieldWidth}>
|
||||
<Combobox items={providerOptions} defaultValue={disabledProvider} disabled>
|
||||
<ComboboxLabel>Disabled provider</ComboboxLabel>
|
||||
<ComboboxTrigger>
|
||||
<ComboboxValue />
|
||||
</ComboboxTrigger>
|
||||
<ComboboxPortal>
|
||||
<ComboboxPositioner>
|
||||
<ComboboxPopup aria-label="Disabled provider">
|
||||
<PopupSearchInput label="Search disabled providers" placeholder="Search providers" />
|
||||
<ComboboxList<Option>>{renderOptionItem}</ComboboxList>
|
||||
</ComboboxPopup>
|
||||
</ComboboxPositioner>
|
||||
</ComboboxPortal>
|
||||
</Combobox>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const ReadOnly: Story = {
|
||||
render: () => (
|
||||
<Field name="readOnlySource" className={fieldWidth}>
|
||||
<FieldLabel>Read-only source</FieldLabel>
|
||||
<Combobox items={dataSourceOptions} defaultValue={readOnlyDataSource} readOnly>
|
||||
<ComboboxInputGroup className="h-8 min-h-8 px-2">
|
||||
<ComboboxInput
|
||||
placeholder="Read-only data source…"
|
||||
className="block h-4.5 grow px-1 py-0 system-sm-regular text-components-input-text-filled"
|
||||
/>
|
||||
<ComboboxInputTrigger className="mr-0" />
|
||||
</ComboboxInputGroup>
|
||||
<ComboboxPortal>
|
||||
<ComboboxPositioner>
|
||||
<ComboboxPopup>
|
||||
<ComboboxList<Option>>{renderOptionItem}</ComboboxList>
|
||||
</ComboboxPopup>
|
||||
</ComboboxPositioner>
|
||||
</ComboboxPortal>
|
||||
</Combobox>
|
||||
</Field>
|
||||
),
|
||||
}
|
||||
|
||||
const ControlledDemo = () => {
|
||||
const [value, setValue] = React.useState<Option | null>(defaultTag)
|
||||
|
||||
@ -967,13 +965,17 @@ const ControlledDemo = () => {
|
||||
<div className="w-full">
|
||||
<Combobox items={tagOptions} value={value} onValueChange={setValue}>
|
||||
<ComboboxLabel>Default app tag</ComboboxLabel>
|
||||
<ComboboxTrigger aria-label="Default app tag">
|
||||
<ComboboxTrigger>
|
||||
<ComboboxValue placeholder="Select tag" />
|
||||
</ComboboxTrigger>
|
||||
<ComboboxContent>
|
||||
<PopupSearchInput label="Search app tags" placeholder="Search tags" />
|
||||
<ComboboxList<Option>>{renderSimpleOptionItem}</ComboboxList>
|
||||
</ComboboxContent>
|
||||
<ComboboxPortal>
|
||||
<ComboboxPositioner>
|
||||
<ComboboxPopup aria-label="Default app tag">
|
||||
<PopupSearchInput label="Search app tags" placeholder="Search tags" />
|
||||
<ComboboxList<Option>>{renderSimpleOptionItem}</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">
|
||||
@ -985,4 +987,18 @@ const ControlledDemo = () => {
|
||||
|
||||
export const Controlled: Story = {
|
||||
render: () => <ControlledDemo />,
|
||||
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 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 waitFor(async () => {
|
||||
await expect(body.queryByRole('dialog', { name: 'Default app tag' })).not.toBeInTheDocument()
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
@ -307,52 +307,46 @@ function ComboboxIcon({ className, children, ...props }: ComboboxIconProps) {
|
||||
)
|
||||
}
|
||||
|
||||
type ComboboxContentProps = {
|
||||
children: React.ReactNode
|
||||
placement?: Placement
|
||||
sideOffset?: number
|
||||
alignOffset?: number
|
||||
const ComboboxPortal = BaseCombobox.Portal
|
||||
type ComboboxPortalProps = BaseCombobox.Portal.Props
|
||||
|
||||
type ComboboxPositionerProps = Omit<
|
||||
BaseCombobox.Positioner.Props,
|
||||
'className' | 'side' | 'align'
|
||||
> & {
|
||||
className?: string
|
||||
popupClassName?: string
|
||||
portalProps?: Omit<BaseCombobox.Portal.Props, 'children'>
|
||||
positionerProps?: Omit<
|
||||
BaseCombobox.Positioner.Props,
|
||||
'children' | 'className' | 'side' | 'align' | 'sideOffset' | 'alignOffset'
|
||||
>
|
||||
popupProps?: Omit<BaseCombobox.Popup.Props, 'children' | 'className'>
|
||||
placement?: Placement
|
||||
}
|
||||
|
||||
function ComboboxContent({
|
||||
children,
|
||||
function ComboboxPositioner({
|
||||
className,
|
||||
placement = 'bottom-start',
|
||||
sideOffset = 4,
|
||||
alignOffset = 0,
|
||||
className,
|
||||
popupClassName,
|
||||
portalProps,
|
||||
positionerProps,
|
||||
popupProps,
|
||||
}: ComboboxContentProps) {
|
||||
...props
|
||||
}: ComboboxPositionerProps) {
|
||||
const { side, align } = parsePlacement(placement)
|
||||
|
||||
return (
|
||||
<BaseCombobox.Portal {...portalProps}>
|
||||
<BaseCombobox.Positioner
|
||||
side={side}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
alignOffset={alignOffset}
|
||||
className={cn('z-50 outline-hidden', className)}
|
||||
{...positionerProps}
|
||||
>
|
||||
<BaseCombobox.Popup
|
||||
className={cn(comboboxPopupClassName, floatingPopupAnimationClassName, popupClassName)}
|
||||
{...popupProps}
|
||||
>
|
||||
{children}
|
||||
</BaseCombobox.Popup>
|
||||
</BaseCombobox.Positioner>
|
||||
</BaseCombobox.Portal>
|
||||
<BaseCombobox.Positioner
|
||||
side={side}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn('z-50 outline-hidden', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type ComboboxPopupProps = Omit<BaseCombobox.Popup.Props, 'className'> & {
|
||||
className?: string
|
||||
}
|
||||
|
||||
function ComboboxPopup({ className, ...props }: ComboboxPopupProps) {
|
||||
return (
|
||||
<BaseCombobox.Popup
|
||||
className={cn(comboboxPopupClassName, floatingPopupAnimationClassName, className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -453,7 +447,7 @@ type ComboboxStatusProps = Omit<BaseCombobox.Status.Props, 'className'> & {
|
||||
function ComboboxStatus({ className, ...props }: ComboboxStatusProps) {
|
||||
return (
|
||||
<BaseCombobox.Status
|
||||
className={cn('px-3 py-2 system-sm-regular text-text-tertiary', className)}
|
||||
className={cn('px-3 py-2 system-sm-regular text-text-tertiary empty:p-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@ -522,7 +516,6 @@ export {
|
||||
ComboboxChips,
|
||||
ComboboxClear,
|
||||
ComboboxCollection,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxGroup,
|
||||
ComboboxGroupLabel,
|
||||
@ -535,6 +528,9 @@ export {
|
||||
ComboboxItemText,
|
||||
ComboboxLabel,
|
||||
ComboboxList,
|
||||
ComboboxPopup,
|
||||
ComboboxPortal,
|
||||
ComboboxPositioner,
|
||||
ComboboxRow,
|
||||
ComboboxSeparator,
|
||||
ComboboxStatus,
|
||||
@ -551,7 +547,6 @@ export type {
|
||||
ComboboxChipsProps,
|
||||
ComboboxClearProps,
|
||||
ComboboxCollectionProps,
|
||||
ComboboxContentProps,
|
||||
ComboboxEmptyProps,
|
||||
ComboboxGroupLabelProps,
|
||||
ComboboxGroupProps,
|
||||
@ -564,6 +559,9 @@ export type {
|
||||
ComboboxItemTextProps,
|
||||
ComboboxLabelProps,
|
||||
ComboboxListProps,
|
||||
ComboboxPopupProps,
|
||||
ComboboxPortalProps,
|
||||
ComboboxPositionerProps,
|
||||
ComboboxProps,
|
||||
ComboboxRowProps,
|
||||
ComboboxSeparatorProps,
|
||||
|
||||
@ -97,15 +97,30 @@ describe('AddMemberOrGroupDialog', () => {
|
||||
|
||||
await user.click(screen.getByText('common.operation.add'))
|
||||
|
||||
expect(
|
||||
screen.getByPlaceholderText(
|
||||
'app.accessControlDialog.operateGroupAndMember.searchPlaceholder',
|
||||
),
|
||||
).toBeInTheDocument()
|
||||
const searchLabel = 'app.accessControlDialog.operateGroupAndMember.searchPlaceholder'
|
||||
expect(screen.getByRole('dialog', { name: searchLabel })).toBeInTheDocument()
|
||||
expect(screen.getByRole('searchbox', { name: searchLabel })).toHaveFocus()
|
||||
expect(screen.getByText(baseGroup.name)).toBeInTheDocument()
|
||||
expect(screen.getByText(baseMember.name)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep group selection and expansion as separate keyboard actions', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ControlledDialog />)
|
||||
|
||||
await user.click(screen.getByText('common.operation.add'))
|
||||
|
||||
const groupToggle = screen.getByRole('button', { name: /Group One/ })
|
||||
const expandButton = screen.getByRole('button', {
|
||||
name: 'app.accessControlDialog.operateGroupAndMember.expand',
|
||||
})
|
||||
|
||||
groupToggle.focus()
|
||||
await user.tab()
|
||||
|
||||
expect(expandButton).toHaveFocus()
|
||||
})
|
||||
|
||||
it('should allow expanding groups and report selected members', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
@ -119,8 +134,12 @@ describe('AddMemberOrGroupDialog', () => {
|
||||
true,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('option', { name: /Member One/ }))
|
||||
const memberToggle = screen.getByRole('button', { name: /Member One/ })
|
||||
|
||||
expect(memberToggle).toHaveAttribute('aria-pressed', 'false')
|
||||
await user.click(memberToggle)
|
||||
expect(onChange).toHaveBeenCalledWith({ groups: [], members: [baseMember] })
|
||||
expect(memberToggle).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
|
||||
it('should show the empty state when no candidates are returned', async () => {
|
||||
|
||||
@ -1,28 +1,26 @@
|
||||
'use client'
|
||||
|
||||
import type { ComboboxChangeEventDetails } from '@langgenius/dify-ui/combobox'
|
||||
import type { AccessControlSubjects } from '../specific-groups-or-members'
|
||||
import type {
|
||||
AccessControlAccount,
|
||||
AccessControlGroup,
|
||||
Subject,
|
||||
SubjectAccount,
|
||||
SubjectGroup,
|
||||
} from '@/models/access-control'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxInputGroup,
|
||||
ComboboxList,
|
||||
ComboboxStatus,
|
||||
ComboboxTrigger,
|
||||
} from '@langgenius/dify-ui/combobox'
|
||||
ScrollArea,
|
||||
ScrollAreaContent,
|
||||
ScrollAreaScrollbar,
|
||||
ScrollAreaThumb,
|
||||
ScrollAreaViewport,
|
||||
} from '@langgenius/dify-ui/scroll-area'
|
||||
import { useDebounce } from 'ahooks'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { SearchInput } from '@/app/components/base/search-input'
|
||||
import { SubjectType } from '@/models/access-control'
|
||||
import { useSearchForWhiteListCandidates } from '@/service/access-control'
|
||||
import { SelectedGroupsBreadcrumb } from './breadcrumb'
|
||||
@ -45,23 +43,23 @@ export default function AddMemberOrGroupDialog({
|
||||
>([])
|
||||
const scrollRootRef = useRef<HTMLDivElement>(null)
|
||||
const anchorRef = useRef<HTMLDivElement>(null)
|
||||
const { groups: specificGroups, members: specificMembers } = selectedAccessSubjects
|
||||
const debouncedKeyword = useDebounce(keyword, { wait: 500 })
|
||||
|
||||
const lastAvailableGroup = selectedGroupsForBreadcrumb[selectedGroupsForBreadcrumb.length - 1]
|
||||
const { isLoading, isFetchingNextPage, fetchNextPage, data } = useSearchForWhiteListCandidates(
|
||||
{ keyword: debouncedKeyword, groupId: lastAvailableGroup?.id, resultsPerPage: 10 },
|
||||
open,
|
||||
)
|
||||
const pages = data?.pages ?? []
|
||||
const subjects = pages.flatMap((page) => page.subjects ?? [])
|
||||
const selectedSubjects = [
|
||||
...specificGroups.map(groupToSubject),
|
||||
...specificMembers.map(memberToSubject),
|
||||
]
|
||||
const hasResults = pages.length > 0 && subjects.length > 0
|
||||
const candidates = pages.flatMap((page) => page.subjects ?? [])
|
||||
const hasResults = pages.length > 0 && candidates.length > 0
|
||||
const shouldShowBreadcrumb = hasResults || selectedGroupsForBreadcrumb.length > 0
|
||||
const hasMore = pages[pages.length - 1]?.hasMore ?? false
|
||||
const searchLabel = t(($) => $['accessControlDialog.operateGroupAndMember.searchPlaceholder'], {
|
||||
ns: 'app',
|
||||
})
|
||||
const noResultLabel = t(($) => $['accessControlDialog.operateGroupAndMember.noResult'], {
|
||||
ns: 'app',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
let observer: IntersectionObserver | undefined
|
||||
@ -78,85 +76,86 @@ export default function AddMemberOrGroupDialog({
|
||||
}, [isLoading, fetchNextPage, hasMore])
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (!nextOpen) setKeyword('')
|
||||
|
||||
if (!nextOpen) {
|
||||
setKeyword('')
|
||||
setSelectedGroupsForBreadcrumb([])
|
||||
}
|
||||
setOpen(nextOpen)
|
||||
}
|
||||
|
||||
const handleInputValueChange = (inputValue: string, details: ComboboxChangeEventDetails) => {
|
||||
if (details.reason !== 'item-press') setKeyword(inputValue)
|
||||
}
|
||||
const handleSubjectToggle = (subject: Subject) => {
|
||||
const { groups, members } = selectedAccessSubjects
|
||||
|
||||
const handleValueChange = (nextSubjects: Subject[]) => {
|
||||
const nextGroups: AccessControlGroup[] = []
|
||||
const nextMembers: AccessControlAccount[] = []
|
||||
|
||||
for (const subject of nextSubjects) {
|
||||
if (subject.subjectType === SubjectType.GROUP)
|
||||
nextGroups.push((subject as SubjectGroup).groupData)
|
||||
else nextMembers.push((subject as SubjectAccount).accountData)
|
||||
if (subject.subjectType === SubjectType.GROUP) {
|
||||
const group = (subject as SubjectGroup).groupData
|
||||
const selected = groups.some((candidate) => candidate.id === group.id)
|
||||
onChange({
|
||||
groups: selected
|
||||
? groups.filter((candidate) => candidate.id !== group.id)
|
||||
: [...groups, group],
|
||||
members,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
onChange({ groups: nextGroups, members: nextMembers })
|
||||
const member = (subject as SubjectAccount).accountData
|
||||
const selected = members.some((candidate) => candidate.id === member.id)
|
||||
onChange({
|
||||
groups,
|
||||
members: selected
|
||||
? members.filter((candidate) => candidate.id !== member.id)
|
||||
: [...members, member],
|
||||
})
|
||||
}
|
||||
|
||||
const isSubjectSelected = (subject: Subject) =>
|
||||
subject.subjectType === SubjectType.GROUP
|
||||
? selectedAccessSubjects.groups.some((group) => group.id === subject.subjectId)
|
||||
: selectedAccessSubjects.members.some((member) => member.id === subject.subjectId)
|
||||
|
||||
const statusText = isLoading
|
||||
? t(($) => $.loading, { ns: 'common' })
|
||||
: hasResults
|
||||
? null
|
||||
: noResultLabel
|
||||
|
||||
return (
|
||||
<Combobox<Subject, true>
|
||||
multiple
|
||||
open={open}
|
||||
value={selectedSubjects}
|
||||
inputValue={keyword}
|
||||
items={subjects}
|
||||
itemToStringLabel={getSubjectLabel}
|
||||
itemToStringValue={getSubjectValue}
|
||||
isItemEqualToValue={isSameSubject}
|
||||
filter={null}
|
||||
onOpenChange={handleOpenChange}
|
||||
onInputValueChange={handleInputValueChange}
|
||||
onValueChange={handleValueChange}
|
||||
>
|
||||
<ComboboxTrigger
|
||||
aria-label={t(($) => $['operation.add'], { ns: 'common' })}
|
||||
icon={false}
|
||||
size="small"
|
||||
className="h-6 w-auto min-w-13 shrink-0 rounded-md border-0 bg-transparent px-2 py-0 text-xs font-medium text-components-button-secondary-accent-text hover:bg-state-accent-hover focus-visible:bg-state-accent-hover data-popup-open:bg-state-accent-hover"
|
||||
>
|
||||
<span className="inline-flex min-w-0 items-center justify-center gap-x-0.5 whitespace-nowrap">
|
||||
<span className="i-ri-add-circle-fill size-4 shrink-0" aria-hidden="true" />
|
||||
<span className="shrink-0">{t(($) => $['operation.add'], { ns: 'common' })}</span>
|
||||
</span>
|
||||
</ComboboxTrigger>
|
||||
<ComboboxContent
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost-accent"
|
||||
size="small"
|
||||
aria-label={t(($) => $['operation.add'], { ns: 'common' })}
|
||||
className="min-w-13 shrink-0 gap-x-0.5 px-2 data-popup-open:bg-state-accent-hover"
|
||||
>
|
||||
<span className="i-ri-add-circle-fill size-4 shrink-0" aria-hidden="true" />
|
||||
<span className="shrink-0">{t(($) => $['operation.add'], { ns: 'common' })}</span>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-end"
|
||||
alignOffset={300}
|
||||
popupClassName="relative flex max-h-[400px] w-[400px] flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-0 shadow-lg backdrop-blur-[5px]"
|
||||
popupClassName="relative flex max-h-[400px] w-[400px] flex-col overflow-hidden bg-components-panel-bg-blur p-0 backdrop-blur-[5px]"
|
||||
>
|
||||
<div ref={scrollRootRef} className="min-h-0 overflow-y-auto">
|
||||
<div className="sticky top-0 z-10 bg-components-panel-bg-blur p-2 pb-0.5 backdrop-blur-[5px]">
|
||||
<ComboboxInputGroup className="h-8 min-h-8 px-2">
|
||||
<span
|
||||
className="mr-0.5 i-ri-search-line size-4 shrink-0 text-text-tertiary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<ComboboxInput
|
||||
aria-label={t(
|
||||
($) => $['accessControlDialog.operateGroupAndMember.searchPlaceholder'],
|
||||
{ ns: 'app' },
|
||||
)}
|
||||
placeholder={t(
|
||||
($) => $['accessControlDialog.operateGroupAndMember.searchPlaceholder'],
|
||||
{ ns: 'app' },
|
||||
)}
|
||||
className="block h-4.5 grow px-1 py-0 text-[13px] text-text-primary"
|
||||
/>
|
||||
</ComboboxInputGroup>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<ComboboxStatus className="p-1">
|
||||
<Loading />
|
||||
</ComboboxStatus>
|
||||
) : (
|
||||
<>
|
||||
<PopoverTitle className="sr-only">{searchLabel}</PopoverTitle>
|
||||
<ScrollArea className="relative min-h-0 flex-1 overflow-hidden">
|
||||
<ScrollAreaViewport
|
||||
ref={scrollRootRef}
|
||||
role="region"
|
||||
aria-label={searchLabel}
|
||||
style={{ overflowX: 'hidden' }}
|
||||
>
|
||||
<ScrollAreaContent style={{ minWidth: 0 }}>
|
||||
<div className="sticky top-0 z-10 bg-components-panel-bg-blur p-2 pb-0.5 backdrop-blur-[5px]">
|
||||
<SearchInput
|
||||
aria-label={searchLabel}
|
||||
placeholder={searchLabel}
|
||||
value={keyword}
|
||||
onValueChange={setKeyword}
|
||||
/>
|
||||
</div>
|
||||
{shouldShowBreadcrumb && (
|
||||
<div className="flex h-7 items-center px-2 py-0.5">
|
||||
<SelectedGroupsBreadcrumb
|
||||
@ -165,62 +164,49 @@ export default function AddMemberOrGroupDialog({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{hasResults ? (
|
||||
<>
|
||||
<ComboboxList<Subject> className="max-h-none p-1">
|
||||
{(subject) => (
|
||||
<SubjectItem
|
||||
key={getSubjectValue(subject)}
|
||||
subject={subject}
|
||||
selectedGroups={specificGroups}
|
||||
onExpandGroup={(group) =>
|
||||
setSelectedGroupsForBreadcrumb((groups) => [...groups, group])
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</ComboboxList>
|
||||
{isFetchingNextPage && <Loading />}
|
||||
<div ref={anchorRef} className="h-0" />
|
||||
</>
|
||||
) : (
|
||||
<ComboboxEmpty className="flex h-7 items-center justify-center px-2 py-0.5">
|
||||
{t(($) => $['accessControlDialog.operateGroupAndMember.noResult'], { ns: 'app' })}
|
||||
</ComboboxEmpty>
|
||||
{hasResults && (
|
||||
<ul className="p-1">
|
||||
{candidates.map((subject) => (
|
||||
<SubjectItem
|
||||
key={`${subject.subjectType}:${subject.subjectId}`}
|
||||
subject={subject}
|
||||
selected={isSubjectSelected(subject)}
|
||||
onToggle={() => handleSubjectToggle(subject)}
|
||||
onExpandGroup={(group) =>
|
||||
setSelectedGroupsForBreadcrumb((groups) => [...groups, group])
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className={
|
||||
statusText ? 'flex min-h-7 items-center justify-center px-2 py-0.5' : 'h-0'
|
||||
}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<span className="sr-only">{statusText}</span>
|
||||
<div className="w-full" aria-hidden="true">
|
||||
<Loading />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
statusText
|
||||
)}
|
||||
</div>
|
||||
{isFetchingNextPage && <Loading />}
|
||||
<div ref={anchorRef} className="h-0" />
|
||||
</ScrollAreaContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar>
|
||||
<ScrollAreaThumb />
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
function groupToSubject(group: AccessControlGroup): SubjectGroup {
|
||||
return {
|
||||
subjectId: group.id,
|
||||
subjectType: SubjectType.GROUP,
|
||||
groupData: group,
|
||||
}
|
||||
}
|
||||
|
||||
function memberToSubject(member: AccessControlAccount): SubjectAccount {
|
||||
return {
|
||||
subjectId: member.id,
|
||||
subjectType: SubjectType.ACCOUNT,
|
||||
accountData: member,
|
||||
}
|
||||
}
|
||||
|
||||
function getSubjectLabel(subject: Subject) {
|
||||
if (subject.subjectType === SubjectType.GROUP) return (subject as SubjectGroup).groupData.name
|
||||
|
||||
return (subject as SubjectAccount).accountData.name
|
||||
}
|
||||
|
||||
function getSubjectValue(subject: Subject) {
|
||||
return `${subject.subjectType}:${subject.subjectId}`
|
||||
}
|
||||
|
||||
function isSameSubject(item: Subject, value: Subject) {
|
||||
return item.subjectId === value.subjectId && item.subjectType === value.subjectType
|
||||
}
|
||||
|
||||
@ -11,7 +11,7 @@ import type {
|
||||
import { Avatar } from '@langgenius/dify-ui/avatar'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { ComboboxItem, ComboboxItemText } from '@langgenius/dify-ui/combobox'
|
||||
import { Toggle } from '@langgenius/dify-ui/toggle'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { userProfileQueryOptions } from '@/features/account-profile/client'
|
||||
@ -19,62 +19,65 @@ import { SubjectType } from '@/models/access-control'
|
||||
|
||||
type SubjectItemProps = {
|
||||
subject: Subject
|
||||
selectedGroups: AccessControlGroup[]
|
||||
selected: boolean
|
||||
onToggle: () => void
|
||||
onExpandGroup: (group: AccessControlGroup) => void
|
||||
}
|
||||
|
||||
export function SubjectItem({ subject, selectedGroups, onExpandGroup }: SubjectItemProps) {
|
||||
export function SubjectItem({ subject, selected, onToggle, onExpandGroup }: SubjectItemProps) {
|
||||
if (subject.subjectType === SubjectType.GROUP)
|
||||
return (
|
||||
<GroupItem
|
||||
group={(subject as SubjectGroup).groupData}
|
||||
subject={subject}
|
||||
selectedGroups={selectedGroups}
|
||||
selected={selected}
|
||||
onToggle={onToggle}
|
||||
onExpand={onExpandGroup}
|
||||
/>
|
||||
)
|
||||
|
||||
return <MemberItem member={(subject as SubjectAccount).accountData} subject={subject} />
|
||||
return (
|
||||
<MemberItem
|
||||
member={(subject as SubjectAccount).accountData}
|
||||
selected={selected}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type GroupItemProps = {
|
||||
function GroupItem({
|
||||
group,
|
||||
selected,
|
||||
onToggle,
|
||||
onExpand,
|
||||
}: {
|
||||
group: AccessControlGroup
|
||||
subject: Subject
|
||||
selectedGroups: AccessControlGroup[]
|
||||
selected: boolean
|
||||
onToggle: () => void
|
||||
onExpand: (group: AccessControlGroup) => void
|
||||
}
|
||||
|
||||
function GroupItem({ group, subject, selectedGroups, onExpand }: GroupItemProps) {
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const isChecked = selectedGroups.some((selectedGroup) => selectedGroup.id === group.id)
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg hover:bg-state-base-hover">
|
||||
<BaseItem subject={subject}>
|
||||
{(selected) => (
|
||||
<>
|
||||
<SelectionBox checked={selected} />
|
||||
<ComboboxItemText className="flex grow items-center px-0">
|
||||
<div className="mr-2 size-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid">
|
||||
<div className="bg-access-app-icon-mask-bg flex size-full items-center justify-center">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="i-ri-organization-chart h-3.5 w-3.5 text-components-avatar-shape-fill-stop-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className="mr-1 system-sm-medium text-text-secondary">{group.name}</span>
|
||||
<span className="system-xs-regular text-text-tertiary">{group.groupSize}</span>
|
||||
</ComboboxItemText>
|
||||
</>
|
||||
)}
|
||||
</BaseItem>
|
||||
<li className="flex min-w-0 items-center gap-2 rounded-lg hover:bg-state-base-hover">
|
||||
<SubjectToggleButton selected={selected} onToggle={onToggle}>
|
||||
<div className="mr-2 size-5 shrink-0 overflow-hidden rounded-full bg-components-icon-bg-blue-solid">
|
||||
<div className="bg-access-app-icon-mask-bg flex size-full items-center justify-center">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="i-ri-organization-chart size-3.5 text-components-avatar-shape-fill-stop-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className="mr-1 min-w-0 truncate system-sm-medium text-text-secondary">
|
||||
{group.name}
|
||||
</span>
|
||||
<span className="shrink-0 system-xs-regular text-text-tertiary">{group.groupSize}</span>
|
||||
</SubjectToggleButton>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={isChecked}
|
||||
disabled={selected}
|
||||
variant="ghost-accent"
|
||||
className="mr-1 flex shrink-0 items-center justify-between py-1"
|
||||
onPointerDown={(event) => event.preventDefault()}
|
||||
onClick={() => onExpand(group)}
|
||||
>
|
||||
<span>
|
||||
@ -82,11 +85,19 @@ function GroupItem({ group, subject, selectedGroups, onExpand }: GroupItemProps)
|
||||
</span>
|
||||
<span aria-hidden="true" className="i-ri-arrow-right-s-line size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function MemberItem({ member, subject }: { member: AccessControlAccount; subject: Subject }) {
|
||||
function MemberItem({
|
||||
member,
|
||||
selected,
|
||||
onToggle,
|
||||
}: {
|
||||
member: AccessControlAccount
|
||||
selected: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
const { data: currentUser } = useSuspenseQuery({
|
||||
...userProfileQueryOptions(),
|
||||
select: (data) => data.profile,
|
||||
@ -94,52 +105,58 @@ function MemberItem({ member, subject }: { member: AccessControlAccount; subject
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<BaseItem subject={subject} className="pr-3">
|
||||
{(selected) => (
|
||||
<>
|
||||
<SelectionBox checked={selected} />
|
||||
<ComboboxItemText className="flex grow items-center px-0">
|
||||
<div className="mr-2 size-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid">
|
||||
<div className="bg-access-app-icon-mask-bg flex size-full items-center justify-center">
|
||||
<Avatar size="xxs" avatar={null} name={member.name} />
|
||||
</div>
|
||||
</div>
|
||||
<span className="mr-1 system-sm-medium text-text-secondary">{member.name}</span>
|
||||
{currentUser.email === member.email && (
|
||||
<span className="system-xs-regular text-text-tertiary">
|
||||
({t(($) => $.you, { ns: 'common' })})
|
||||
</span>
|
||||
)}
|
||||
</ComboboxItemText>
|
||||
<span className="system-xs-regular text-text-quaternary">{member.email}</span>
|
||||
</>
|
||||
)}
|
||||
</BaseItem>
|
||||
<li>
|
||||
<SubjectToggleButton selected={selected} onToggle={onToggle} className="pr-3">
|
||||
<div className="mr-2 size-5 shrink-0 overflow-hidden rounded-full bg-components-icon-bg-blue-solid">
|
||||
<div className="bg-access-app-icon-mask-bg flex size-full items-center justify-center">
|
||||
<Avatar size="xxs" avatar={null} name={member.name} />
|
||||
</div>
|
||||
</div>
|
||||
<span className="mr-1 min-w-0 truncate system-sm-medium text-text-secondary">
|
||||
{member.name}
|
||||
</span>
|
||||
{currentUser.email === member.email && (
|
||||
<span className="shrink-0 system-xs-regular text-text-tertiary">
|
||||
({t(($) => $.you, { ns: 'common' })})
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto min-w-0 truncate system-xs-regular text-text-quaternary">
|
||||
{member.email}
|
||||
</span>
|
||||
</SubjectToggleButton>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function BaseItem({
|
||||
function SubjectToggleButton({
|
||||
children,
|
||||
selected,
|
||||
onToggle,
|
||||
className,
|
||||
subject,
|
||||
}: {
|
||||
children: ReactNode
|
||||
selected: boolean
|
||||
onToggle: () => void
|
||||
className?: string
|
||||
subject: Subject
|
||||
children: (selected: boolean) => ReactNode
|
||||
}) {
|
||||
return (
|
||||
<ComboboxItem
|
||||
value={subject}
|
||||
className={cn(
|
||||
'mx-0 flex min-h-8 grow grid-cols-none items-center gap-2 rounded-lg p-1 pl-2',
|
||||
className,
|
||||
)}
|
||||
render={(props, state) => (
|
||||
<div {...props} className={props.className}>
|
||||
{children(state.selected)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Toggle
|
||||
pressed={selected}
|
||||
onPressedChange={onToggle}
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="medium"
|
||||
className={cn(
|
||||
'min-h-8 min-w-0 grow justify-start gap-2 p-1 pl-2 text-left whitespace-normal',
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<SelectionBox checked={selected} />
|
||||
{children}
|
||||
</Toggle>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
import type { AccessControlAccount, AccessControlGroup } from '@/models/access-control'
|
||||
import { Avatar } from '@langgenius/dify-ui/avatar'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { IconButton } from '@langgenius/dify-ui/icon-button'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { Infotip } from '../../base/infotip'
|
||||
@ -201,9 +202,9 @@ function BaseItem({ icon, onRemove, children }: BaseItemProps) {
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-4 cursor-pointer items-center justify-center border-none bg-transparent p-0 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
|
||||
<IconButton
|
||||
size="xs"
|
||||
className="shrink-0"
|
||||
aria-label={t(($) => $['operation.remove'], { ns: 'common' })}
|
||||
onClick={onRemove}
|
||||
>
|
||||
@ -211,7 +212,7 @@ function BaseItem({ icon, onRemove, children }: BaseItemProps) {
|
||||
aria-hidden="true"
|
||||
className="i-ri-close-circle-fill h-3.5 w-3.5 text-text-quaternary"
|
||||
/>
|
||||
</button>
|
||||
</IconButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -211,6 +211,8 @@ describe('DocumentPicker', () => {
|
||||
|
||||
await user.click(screen.getByRole('combobox', { name: 'Document 1' }))
|
||||
|
||||
expect(await screen.findByRole('status')).toHaveTextContent('common.noData')
|
||||
expect(
|
||||
(await screen.findByText('common.noData')).closest('[role="status"]'),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -4,19 +4,19 @@ import type { ParentMode, SimpleDocumentDetail } from '@/models/datasets'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxInputGroup,
|
||||
ComboboxPopup,
|
||||
ComboboxPortal,
|
||||
ComboboxPositioner,
|
||||
ComboboxStatus,
|
||||
ComboboxTrigger,
|
||||
ComboboxValue,
|
||||
} from '@langgenius/dify-ui/combobox'
|
||||
import { RiArrowDownSLine } from '@remixicon/react'
|
||||
import { useDebounce } from 'ahooks'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { GeneralChunk, ParentChildChunk } from '@/app/components/base/icons/src/vender/knowledge'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { ChunkingMode } from '@/models/datasets'
|
||||
import { useDocumentList } from '@/service/knowledge/use-document'
|
||||
@ -65,8 +65,6 @@ function DocumentPickerTriggerValue({
|
||||
const isGeneralMode = document?.doc_form === ChunkingMode.text
|
||||
const isParentChild = document?.doc_form === ChunkingMode.parentChild
|
||||
const isQAMode = document?.doc_form === ChunkingMode.qa
|
||||
const TypeIcon = isParentChild ? ParentChildChunk : GeneralChunk
|
||||
const ArrowIcon = RiArrowDownSLine
|
||||
const parentModeLabel = (() => {
|
||||
if (!parentMode) return '--'
|
||||
return parentMode === 'paragraph'
|
||||
@ -82,10 +80,21 @@ function DocumentPickerTriggerValue({
|
||||
<span className="max-w-70 min-w-0 truncate system-md-semibold text-text-primary">
|
||||
{document?.name || '--'}
|
||||
</span>
|
||||
<ArrowIcon className="size-4 shrink-0 text-text-primary" aria-hidden="true" />
|
||||
<span
|
||||
className="i-ri-arrow-down-s-line size-4 shrink-0 text-text-primary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
<span className="flex h-3 max-w-75 items-center gap-0.5 text-text-tertiary">
|
||||
<TypeIcon className="size-3 shrink-0" />
|
||||
<span
|
||||
className={cn(
|
||||
'size-3 shrink-0',
|
||||
isParentChild
|
||||
? 'i-custom-vender-knowledge-parent-child-chunk'
|
||||
: 'i-custom-vender-knowledge-general-chunk',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className={cn('truncate system-2xs-medium-uppercase', isParentChild && 'mt-0.5')}>
|
||||
{isGeneralMode && t(($) => $['chunkingMode.general'], { ns: 'dataset' })}
|
||||
{isQAMode && t(($) => $['chunkingMode.qa'], { ns: 'dataset' })}
|
||||
@ -152,35 +161,46 @@ export function DocumentPicker({ datasetId, value, parentMode, onChange }: Props
|
||||
{(document) => <DocumentPickerTriggerValue document={document} parentMode={parentMode} />}
|
||||
</ComboboxValue>
|
||||
</ComboboxTrigger>
|
||||
<ComboboxContent
|
||||
placement="bottom-start"
|
||||
sideOffset={0}
|
||||
popupClassName="w-[360px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-2 shadow-lg backdrop-blur-[5px]"
|
||||
>
|
||||
<ComboboxInputGroup className="h-8 min-h-8 px-2">
|
||||
<span
|
||||
className="mr-0.5 i-ri-search-line size-4 shrink-0 text-text-tertiary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<ComboboxInput
|
||||
<ComboboxPortal>
|
||||
<ComboboxPositioner placement="bottom-start" sideOffset={0}>
|
||||
<ComboboxPopup
|
||||
aria-label={t(($) => $['operation.search'], { ns: 'common' })}
|
||||
placeholder={t(($) => $['operation.search'], { ns: 'common' })}
|
||||
className="block h-4.5 grow px-1 py-0 text-[13px] text-text-primary"
|
||||
/>
|
||||
</ComboboxInputGroup>
|
||||
<DocumentList className="mt-2 data-empty:mt-0" />
|
||||
{data ? (
|
||||
<ComboboxEmpty className="p-0">
|
||||
<div className="mt-2 flex h-25 w-full items-center justify-center px-3 py-2 system-sm-regular text-text-tertiary">
|
||||
{t(($) => $.noData, { ns: 'common' })}
|
||||
</div>
|
||||
</ComboboxEmpty>
|
||||
) : (
|
||||
<ComboboxStatus className="mt-2 flex h-25 w-full items-center justify-center">
|
||||
<Loading />
|
||||
</ComboboxStatus>
|
||||
)}
|
||||
</ComboboxContent>
|
||||
className="w-90 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-2 shadow-lg backdrop-blur-[5px]"
|
||||
>
|
||||
<ComboboxInputGroup className="h-8 min-h-8 px-2">
|
||||
<span
|
||||
className="mr-0.5 i-ri-search-line size-4 shrink-0 text-text-tertiary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<ComboboxInput
|
||||
aria-label={t(($) => $['operation.search'], { ns: 'common' })}
|
||||
placeholder={t(($) => $['operation.search'], { ns: 'common' })}
|
||||
className="block h-4.5 grow px-1 py-0 text-[13px] text-text-primary"
|
||||
/>
|
||||
</ComboboxInputGroup>
|
||||
<DocumentList className="mt-2 data-empty:mt-0" />
|
||||
<ComboboxEmpty className="p-0">
|
||||
{data && (
|
||||
<div className="mt-2 flex h-25 w-full items-center justify-center px-3 py-2 system-sm-regular text-text-tertiary">
|
||||
{t(($) => $.noData, { ns: 'common' })}
|
||||
</div>
|
||||
)}
|
||||
</ComboboxEmpty>
|
||||
<ComboboxStatus
|
||||
className={cn(!data ? 'mt-2 flex h-25 w-full items-center justify-center' : 'h-0')}
|
||||
>
|
||||
{!data && (
|
||||
<>
|
||||
<span className="sr-only">{t(($) => $.loading, { ns: 'common' })}</span>
|
||||
<div className="w-full" aria-hidden="true">
|
||||
<Loading />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</ComboboxStatus>
|
||||
</ComboboxPopup>
|
||||
</ComboboxPositioner>
|
||||
</ComboboxPortal>
|
||||
</Combobox>
|
||||
)
|
||||
}
|
||||
|
||||
@ -61,6 +61,16 @@ describe('DatasetMetadataPicker', () => {
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.metadata.addMetadata' }))
|
||||
|
||||
expect(
|
||||
screen.getByRole('dialog', { name: 'dataset.metadata.addMetadata' }),
|
||||
).toBeInTheDocument()
|
||||
const searchInput = screen.getByRole('combobox', {
|
||||
name: 'dataset.metadata.selectMetadata.search',
|
||||
})
|
||||
const listbox = screen.getByRole('listbox')
|
||||
expect(searchInput).toHaveFocus()
|
||||
expect(searchInput).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(searchInput).toHaveAttribute('aria-controls', listbox.id)
|
||||
expect(await screen.findByRole('option', { name: /field_one/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('option', { name: /field_two/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('option', { name: /field_three/ })).toBeInTheDocument()
|
||||
@ -95,6 +105,50 @@ describe('DatasetMetadataPicker', () => {
|
||||
|
||||
expect(await screen.findByRole('status')).toHaveTextContent('common.noData')
|
||||
})
|
||||
|
||||
it('should clear only the search query and keep focus in the input', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSelectMetadata = vi.fn()
|
||||
renderDatasetMetadataPicker({ onSelectMetadata })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.metadata.addMetadata' }))
|
||||
const searchInput = screen.getByRole('combobox', {
|
||||
name: 'dataset.metadata.selectMetadata.search',
|
||||
})
|
||||
await user.type(searchInput, 'two')
|
||||
const clearButton = screen.getByRole('button', { name: 'common.operation.clear' })
|
||||
clearButton.focus()
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
expect(searchInput).toHaveValue('')
|
||||
expect(searchInput).toHaveFocus()
|
||||
expect(onSelectMetadata).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('option', { name: /field_one/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('option', { name: /field_three/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should reset the search query after the picker finishes closing', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderDatasetMetadataPicker()
|
||||
|
||||
const trigger = screen.getByRole('button', { name: 'dataset.metadata.addMetadata' })
|
||||
await user.click(trigger)
|
||||
await user.type(
|
||||
screen.getByRole('combobox', { name: 'dataset.metadata.selectMetadata.search' }),
|
||||
'two',
|
||||
)
|
||||
await user.keyboard('{Escape}')
|
||||
await waitFor(() => {
|
||||
expect(trigger).toHaveAttribute('aria-expanded', 'false')
|
||||
})
|
||||
|
||||
await user.click(trigger)
|
||||
expect(
|
||||
screen.getByRole('combobox', { name: 'dataset.metadata.selectMetadata.search' }),
|
||||
).toHaveValue('')
|
||||
expect(screen.getByRole('option', { name: /field_one/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('option', { name: /field_three/ })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Selection', () => {
|
||||
@ -120,6 +174,30 @@ describe('DatasetMetadataPicker', () => {
|
||||
})
|
||||
|
||||
describe('Actions', () => {
|
||||
it('should keep footer actions interactive until their click completes', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onOpenMetadataManagement = vi.fn()
|
||||
renderDatasetMetadataPicker({ onOpenMetadataManagement })
|
||||
|
||||
const trigger = screen.getByRole('button', { name: 'dataset.metadata.addMetadata' })
|
||||
await user.click(trigger)
|
||||
const manageButton = screen.getByRole('button', {
|
||||
name: 'dataset.metadata.selectMetadata.manageAction',
|
||||
})
|
||||
|
||||
await user.pointer({ keys: '[MouseLeft>]', target: manageButton })
|
||||
|
||||
expect(trigger).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(onOpenMetadataManagement).not.toHaveBeenCalled()
|
||||
|
||||
await user.pointer({ keys: '[/MouseLeft]', target: manageButton })
|
||||
|
||||
expect(onOpenMetadataManagement).toHaveBeenCalledOnce()
|
||||
await waitFor(() => {
|
||||
expect(trigger).toHaveAttribute('aria-expanded', 'false')
|
||||
})
|
||||
})
|
||||
|
||||
it('should switch to create view and save a new metadata item', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onCreateMetadata = vi.fn().mockResolvedValue(undefined)
|
||||
@ -204,6 +282,9 @@ describe('DatasetMetadataPicker', () => {
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'dataset.metadata.selectMetadata.manageAction' }),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByRole('listbox')).not.toContainElement(
|
||||
screen.getByRole('button', { name: 'dataset.metadata.selectMetadata.newAction' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import type { ComboboxChangeEventDetails, Placement } from '@langgenius/dify-ui/combobox'
|
||||
import type { PopoverContentProps } from '@langgenius/dify-ui/popover'
|
||||
import type { BuiltInMetadataItem, MetadataItem } from '../types'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxClear,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxInputGroup,
|
||||
@ -14,8 +14,9 @@ import {
|
||||
ComboboxList,
|
||||
ComboboxSeparator,
|
||||
} from '@langgenius/dify-ui/combobox'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { useState } from 'react'
|
||||
import { IconButton } from '@langgenius/dify-ui/icon-button'
|
||||
import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDatasetMetaData } from '@/service/knowledge/use-metadata'
|
||||
import { getIconClassName } from '../utils/get-icon'
|
||||
@ -30,11 +31,11 @@ const PickerView = {
|
||||
|
||||
type PickerView = (typeof PickerView)[keyof typeof PickerView]
|
||||
|
||||
export type DatasetMetadataPickerProps = {
|
||||
export type DatasetMetadataPickerProps = Pick<
|
||||
PopoverContentProps,
|
||||
'placement' | 'sideOffset' | 'alignOffset'
|
||||
> & {
|
||||
datasetId: string
|
||||
placement?: Placement
|
||||
sideOffset?: number
|
||||
alignOffset?: number
|
||||
onSelectMetadata: (metadata: MetadataItem) => void
|
||||
onCreateMetadata: (metadata: BuiltInMetadataItem) => void | Promise<void>
|
||||
onOpenMetadataManagement: () => void
|
||||
@ -44,14 +45,6 @@ function getMetadataLabel(metadata: MetadataItem) {
|
||||
return metadata.name
|
||||
}
|
||||
|
||||
function getMetadataValue(metadata: MetadataItem) {
|
||||
return metadata.id
|
||||
}
|
||||
|
||||
function isSameMetadata(item: MetadataItem, value: MetadataItem) {
|
||||
return item.id === value.id
|
||||
}
|
||||
|
||||
function metadataFilter(metadata: MetadataItem, query: string) {
|
||||
return metadata.name.toLowerCase().includes(query.toLowerCase())
|
||||
}
|
||||
@ -72,21 +65,21 @@ export function DatasetMetadataPicker({
|
||||
const [view, setView] = useState<PickerView>(PickerView.select)
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
const resetPicker = () => {
|
||||
const resetPickerState = () => {
|
||||
setView(PickerView.select)
|
||||
setQuery('')
|
||||
}
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
setOpen(nextOpen)
|
||||
if (!nextOpen) resetPicker()
|
||||
const handleOpenChangeComplete = (nextOpen: boolean) => {
|
||||
if (!nextOpen) resetPickerState()
|
||||
}
|
||||
|
||||
const handleInputValueChange = (inputValue: string, details: ComboboxChangeEventDetails) => {
|
||||
if (details.reason !== 'item-press') setQuery(inputValue)
|
||||
const handleStartMetadataCreation = () => {
|
||||
setView(PickerView.create)
|
||||
setQuery('')
|
||||
}
|
||||
|
||||
const handleMetadataChange = (metadata: MetadataItem | null) => {
|
||||
const handleSelectMetadata = (metadata: MetadataItem | null) => {
|
||||
if (!metadata) return
|
||||
|
||||
onSelectMetadata({
|
||||
@ -94,34 +87,31 @@ export function DatasetMetadataPicker({
|
||||
name: metadata.name,
|
||||
type: metadata.type,
|
||||
})
|
||||
setOpen(false)
|
||||
resetPicker()
|
||||
}
|
||||
|
||||
const handleCreateMetadata = async (metadata: BuiltInMetadataItem) => {
|
||||
try {
|
||||
await onCreateMetadata(metadata)
|
||||
resetPicker()
|
||||
resetPickerState()
|
||||
} catch {
|
||||
// Keep the create view open so callers can surface validation feedback and the user can correct the input.
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenManagement = () => {
|
||||
const handleOpenMetadataManagement = () => {
|
||||
setOpen(false)
|
||||
resetPicker()
|
||||
onOpenMetadataManagement()
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<Popover open={open} onOpenChange={setOpen} onOpenChangeComplete={handleOpenChangeComplete}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="small"
|
||||
aria-label={t(($) => $['metadata.addMetadata'], { ns: 'dataset' })}
|
||||
aria-expanded={open}
|
||||
className="flex h-6 w-full cursor-pointer items-center justify-center rounded-md border-0 bg-components-button-tertiary-bg px-2 py-0 text-xs font-medium text-components-button-tertiary-text hover:bg-components-button-tertiary-bg-hover focus-visible:bg-components-button-tertiary-bg-hover"
|
||||
className="w-full px-2 py-0"
|
||||
>
|
||||
<span className="flex min-w-0 items-center justify-center gap-1">
|
||||
<span
|
||||
@ -132,7 +122,7 @@ export function DatasetMetadataPicker({
|
||||
{t(($) => $['metadata.addMetadata'], { ns: 'dataset' })}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent
|
||||
@ -141,33 +131,35 @@ export function DatasetMetadataPicker({
|
||||
alignOffset={alignOffset}
|
||||
popupClassName="w-[320px] bg-components-panel-bg-blur backdrop-blur-[5px]"
|
||||
>
|
||||
<PopoverTitle className="sr-only">
|
||||
{t(($) => $['metadata.addMetadata'], { ns: 'dataset' })}
|
||||
</PopoverTitle>
|
||||
{view === PickerView.select ? (
|
||||
<Combobox<MetadataItem>
|
||||
inline
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
value={null}
|
||||
items={metadataItems}
|
||||
inputValue={query}
|
||||
onInputValueChange={handleInputValueChange}
|
||||
onValueChange={handleMetadataChange}
|
||||
onInputValueChange={setQuery}
|
||||
onValueChange={handleSelectMetadata}
|
||||
itemToStringLabel={getMetadataLabel}
|
||||
itemToStringValue={getMetadataValue}
|
||||
isItemEqualToValue={isSameMetadata}
|
||||
filter={metadataFilter}
|
||||
>
|
||||
<MetadataPickerSelectPanel
|
||||
query={query}
|
||||
onNewMetadata={() => {
|
||||
setView(PickerView.create)
|
||||
setQuery('')
|
||||
}}
|
||||
onOpenMetadataManagement={handleOpenManagement}
|
||||
onClearQuery={() => setQuery('')}
|
||||
onStartMetadataCreation={handleStartMetadataCreation}
|
||||
onOpenMetadataManagement={handleOpenMetadataManagement}
|
||||
/>
|
||||
</Combobox>
|
||||
) : (
|
||||
<CreateContent
|
||||
onSave={handleCreateMetadata}
|
||||
hasBack
|
||||
onBack={resetPicker}
|
||||
onClose={resetPicker}
|
||||
onBack={resetPickerState}
|
||||
onClose={resetPickerState}
|
||||
/>
|
||||
)}
|
||||
</PopoverContent>
|
||||
@ -177,14 +169,22 @@ export function DatasetMetadataPicker({
|
||||
|
||||
function MetadataPickerSelectPanel({
|
||||
query,
|
||||
onNewMetadata,
|
||||
onClearQuery,
|
||||
onStartMetadataCreation,
|
||||
onOpenMetadataManagement,
|
||||
}: {
|
||||
query: string
|
||||
onNewMetadata: () => void
|
||||
onClearQuery: () => void
|
||||
onStartMetadataCreation: () => void
|
||||
onOpenMetadataManagement: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleClearQuery = () => {
|
||||
onClearQuery()
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -195,11 +195,22 @@ function MetadataPickerSelectPanel({
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<ComboboxInput
|
||||
ref={inputRef}
|
||||
aria-label={t(($) => $[`${i18nPrefix}.search`], { ns: 'dataset' })}
|
||||
placeholder={t(($) => $[`${i18nPrefix}.search`], { ns: 'dataset' })}
|
||||
className="pl-2"
|
||||
/>
|
||||
{query && <ComboboxClear aria-label={t(($) => $['operation.clear'], { ns: 'common' })} />}
|
||||
{query && (
|
||||
<IconButton
|
||||
size="sm"
|
||||
aria-label={t(($) => $['operation.clear'], { ns: 'common' })}
|
||||
className="mr-1.5 shrink-0 hover:bg-components-input-bg-hover focus-visible:bg-components-input-bg-hover"
|
||||
onClick={handleClearQuery}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
>
|
||||
<span className="i-ri-close-line size-4" aria-hidden="true" />
|
||||
</IconButton>
|
||||
)}
|
||||
</ComboboxInputGroup>
|
||||
</div>
|
||||
<ComboboxList<MetadataItem>>
|
||||
@ -208,7 +219,7 @@ function MetadataPickerSelectPanel({
|
||||
<ComboboxEmpty>{t(($) => $.noData, { ns: 'common' })}</ComboboxEmpty>
|
||||
<ComboboxSeparator />
|
||||
<MetadataPickerActions
|
||||
onNewMetadata={onNewMetadata}
|
||||
onStartMetadataCreation={onStartMetadataCreation}
|
||||
onOpenMetadataManagement={onOpenMetadataManagement}
|
||||
/>
|
||||
</>
|
||||
@ -230,37 +241,33 @@ function MetadataOption({ metadata }: { metadata: MetadataItem }) {
|
||||
}
|
||||
|
||||
function MetadataPickerActions({
|
||||
onNewMetadata,
|
||||
onStartMetadataCreation,
|
||||
onOpenMetadataManagement,
|
||||
}: {
|
||||
onNewMetadata: () => void
|
||||
onStartMetadataCreation: () => void
|
||||
onOpenMetadataManagement: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between p-1">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex h-8 min-w-0 cursor-pointer items-center gap-1 rounded-lg border-none bg-transparent px-2 text-left text-text-secondary outline-hidden',
|
||||
'hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active',
|
||||
)}
|
||||
onClick={onNewMetadata}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="medium"
|
||||
className="min-w-0 justify-start gap-1 px-2 text-left text-text-secondary"
|
||||
onClick={onStartMetadataCreation}
|
||||
>
|
||||
<span className="i-ri-add-line size-4 shrink-0 text-text-tertiary" aria-hidden="true" />
|
||||
<span className="truncate system-sm-medium">
|
||||
{t(($) => $[`${i18nPrefix}.newAction`], { ns: 'dataset' })}
|
||||
</span>
|
||||
</button>
|
||||
</Button>
|
||||
<div className="flex h-8 shrink-0 items-center text-text-secondary">
|
||||
<div className="mx-1 h-3 w-px bg-divider-regular" />
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex h-8 cursor-pointer items-center gap-1 rounded-lg border-none bg-transparent px-2 text-left text-text-secondary outline-hidden',
|
||||
'hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active',
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="medium"
|
||||
className="justify-start gap-1 px-2 text-left text-text-secondary"
|
||||
onClick={onOpenMetadataManagement}
|
||||
>
|
||||
<span className="system-sm-medium">
|
||||
@ -270,7 +277,7 @@ function MetadataPickerActions({
|
||||
className="i-ri-arrow-right-up-line size-4 shrink-0 text-text-tertiary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -7,6 +7,17 @@ import { createConsoleQueryClient } from '@/test/console/query-data'
|
||||
import { ConfigurationMethodEnum, ModelStatusEnum, ModelTypeEnum } from '../../declarations'
|
||||
import { ModelSelector, SplitModelSelector } from '../index'
|
||||
|
||||
const makeModelItem = (overrides: Partial<ModelItem> = {}): ModelItem => ({
|
||||
model: 'gpt-4',
|
||||
label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' },
|
||||
model_type: ModelTypeEnum.textGeneration,
|
||||
fetch_from: ConfigurationMethodEnum.predefinedModel,
|
||||
status: ModelStatusEnum.active,
|
||||
model_properties: {},
|
||||
load_balancing_enabled: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const mockModelProviders = vi.hoisted(() => ({ current: [] as Model[] }))
|
||||
const mockSetSettingsDestination = vi.hoisted(() => vi.fn())
|
||||
|
||||
@ -34,23 +45,23 @@ vi.mock('../../provider-added-card/use-credential-panel-state', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../popup', async () => {
|
||||
const { ComboboxItem } = await vi.importActual<typeof import('@langgenius/dify-ui/combobox')>(
|
||||
'@langgenius/dify-ui/combobox',
|
||||
)
|
||||
|
||||
vi.mock('../popup', () => {
|
||||
return {
|
||||
default: ({
|
||||
onConfigureEmptyState,
|
||||
onHide,
|
||||
onOpenProviderSettings,
|
||||
onSelect,
|
||||
}: {
|
||||
onConfigureEmptyState?: () => void
|
||||
onHide: () => void
|
||||
onOpenProviderSettings?: () => void
|
||||
onSelect: (provider: string, model: ModelItem) => void
|
||||
}) => (
|
||||
<>
|
||||
<ComboboxItem value={{ provider: 'openai', model: 'gpt-4' }}>select</ComboboxItem>
|
||||
<button type="button" onClick={() => onSelect('openai', makeModelItem())}>
|
||||
select
|
||||
</button>
|
||||
<button type="button" onClick={onHide}>
|
||||
hide
|
||||
</button>
|
||||
@ -69,17 +80,6 @@ vi.mock('../popup', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
const makeModelItem = (overrides: Partial<ModelItem> = {}): ModelItem => ({
|
||||
model: 'gpt-4',
|
||||
label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' },
|
||||
model_type: ModelTypeEnum.textGeneration,
|
||||
fetch_from: ConfigurationMethodEnum.predefinedModel,
|
||||
status: ModelStatusEnum.active,
|
||||
model_properties: {},
|
||||
load_balancing_enabled: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const makeModel = (overrides: Partial<Model> = {}): Model => ({
|
||||
provider: 'openai',
|
||||
icon_small: { en_US: '', zh_Hans: '' },
|
||||
@ -103,10 +103,13 @@ describe('ModelSelector', () => {
|
||||
it('should toggle popup and close it after selecting a model', () => {
|
||||
renderWithQueryClient(<ModelSelector models={[makeModel()]} />)
|
||||
|
||||
const triggerButton = screen.getByRole('combobox')
|
||||
const triggerButton = screen.getByRole('button', { name: 'plugin.detailPanel.configureModel' })
|
||||
|
||||
fireEvent.click(triggerButton)
|
||||
expect(triggerButton).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(
|
||||
screen.getByRole('dialog', { name: 'plugin.detailPanel.configureModel' }),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('select')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('select'))
|
||||
@ -117,7 +120,7 @@ describe('ModelSelector', () => {
|
||||
const onValueChange = vi.fn()
|
||||
renderWithQueryClient(<ModelSelector models={[makeModel()]} onValueChange={onValueChange} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('combobox'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.detailPanel.configureModel' }))
|
||||
fireEvent.click(screen.getByText('select'))
|
||||
|
||||
expect(onValueChange).toHaveBeenCalledWith({
|
||||
@ -130,7 +133,7 @@ describe('ModelSelector', () => {
|
||||
it('should close popup when popup requests hide', () => {
|
||||
renderWithQueryClient(<ModelSelector models={[makeModel()]} />)
|
||||
|
||||
const triggerButton = screen.getByRole('combobox')
|
||||
const triggerButton = screen.getByRole('button', { name: 'plugin.detailPanel.configureModel' })
|
||||
fireEvent.click(triggerButton)
|
||||
expect(triggerButton).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(screen.getByText('hide')).toBeInTheDocument()
|
||||
@ -145,7 +148,7 @@ describe('ModelSelector', () => {
|
||||
<ModelSelector models={[makeModel()]} onConfigureEmptyState={onConfigureEmptyState} />,
|
||||
)
|
||||
|
||||
const triggerButton = screen.getByRole('combobox')
|
||||
const triggerButton = screen.getByRole('button', { name: 'plugin.detailPanel.configureModel' })
|
||||
fireEvent.click(triggerButton)
|
||||
expect(triggerButton).toHaveAttribute('aria-expanded', 'true')
|
||||
|
||||
@ -160,7 +163,7 @@ describe('ModelSelector', () => {
|
||||
const onHide = vi.fn()
|
||||
renderWithQueryClient(<ModelSelector models={[makeModel()]} onHide={onHide} />)
|
||||
|
||||
const triggerButton = screen.getByRole('combobox')
|
||||
const triggerButton = screen.getByRole('button', { name: 'plugin.detailPanel.configureModel' })
|
||||
await user.click(triggerButton)
|
||||
await user.click(screen.getByRole('button', { name: 'provider-settings' }))
|
||||
|
||||
@ -172,14 +175,14 @@ describe('ModelSelector', () => {
|
||||
it('should not open popup when disabled', () => {
|
||||
renderWithQueryClient(<ModelSelector models={[makeModel()]} disabled />)
|
||||
|
||||
fireEvent.click(screen.getByRole('combobox'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.detailPanel.configureModel' }))
|
||||
expect(screen.queryByText('select')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should let the split trigger own the combobox interaction', () => {
|
||||
renderWithQueryClient(<SplitModelSelector models={[makeModel()]} />)
|
||||
|
||||
const trigger = screen.getByRole('combobox')
|
||||
const trigger = screen.getByRole('button', { name: 'plugin.detailPanel.configureModel' })
|
||||
expect(trigger).toHaveAttribute('data-shape', 'split')
|
||||
})
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Model, ModelItem } from '../../declarations'
|
||||
import { Combobox } from '@langgenius/dify-ui/combobox'
|
||||
import { Popover } from '@langgenius/dify-ui/popover'
|
||||
import { render as renderComponent, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import {
|
||||
@ -11,7 +11,8 @@ import {
|
||||
} from '../../declarations'
|
||||
import { ModelSelectorTrigger } from '../model-selector-trigger'
|
||||
|
||||
const render = (node: ReactNode) => renderComponent(<Combobox>{node}</Combobox>)
|
||||
const render = (node: ReactNode) => renderComponent(<Popover>{node}</Popover>)
|
||||
const getTrigger = () => screen.getByRole('button', { name: 'plugin.detailPanel.configureModel' })
|
||||
|
||||
const mockUseQuery = vi.hoisted(() => vi.fn())
|
||||
const mockUseCredentialPanelState = vi.hoisted(() => vi.fn())
|
||||
@ -71,7 +72,7 @@ describe('ModelSelectorTrigger', () => {
|
||||
render(<ModelSelectorTrigger />)
|
||||
|
||||
expect(screen.getByText('plugin.detailPanel.configureModel')).toBeInTheDocument()
|
||||
expect(screen.getByRole('combobox')).toBeEnabled()
|
||||
expect(getTrigger()).toBeEnabled()
|
||||
})
|
||||
|
||||
it('should render selected model details when model is active', () => {
|
||||
@ -81,7 +82,7 @@ describe('ModelSelectorTrigger', () => {
|
||||
|
||||
expect(screen.getByText('GPT-4')).toBeInTheDocument()
|
||||
expect(screen.getByText('CHAT')).toBeInTheDocument()
|
||||
expect(screen.getByRole('combobox')).toBeEnabled()
|
||||
expect(getTrigger()).toBeEnabled()
|
||||
})
|
||||
|
||||
it('should render deprecated default model and disabled style when selection is missing', () => {
|
||||
@ -102,7 +103,7 @@ describe('ModelSelectorTrigger', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('combobox')).toBeDisabled()
|
||||
expect(getTrigger()).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
@ -118,11 +119,8 @@ describe('ModelSelectorTrigger', () => {
|
||||
expect(
|
||||
screen.getByText('common.modelProvider.selector.configureRequired'),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByRole('combobox')).toHaveAttribute(
|
||||
'data-model-status',
|
||||
'configure-required',
|
||||
)
|
||||
expect(screen.getByRole('combobox')).toHaveClass(
|
||||
expect(getTrigger()).toHaveAttribute('data-model-status', 'configure-required')
|
||||
expect(getTrigger()).toHaveClass(
|
||||
'data-[model-status=configure-required]:bg-components-input-bg-disabled',
|
||||
)
|
||||
})
|
||||
@ -167,7 +165,7 @@ describe('ModelSelectorTrigger', () => {
|
||||
screen.getByText('common.modelProvider.selector.apiKeyUnavailable'),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.queryByText('CHAT')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('combobox')).toHaveClass(
|
||||
expect(getTrigger()).toHaveClass(
|
||||
'data-[model-status=api-key-unavailable]:bg-components-input-bg-disabled',
|
||||
)
|
||||
})
|
||||
@ -208,7 +206,7 @@ describe('ModelSelectorTrigger', () => {
|
||||
)
|
||||
|
||||
expect(screen.queryByText('CHAT')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('combobox')).toHaveClass(
|
||||
expect(getTrigger()).toHaveClass(
|
||||
'data-[model-status=incompatible]:bg-components-input-bg-disabled',
|
||||
)
|
||||
await user.hover(screen.getByText('common.modelProvider.selector.incompatible'))
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import type { ReactElement, ReactNode } from 'react'
|
||||
import type { DefaultModel, Model, ModelItem } from '../../declarations'
|
||||
import type { ModelSelectorPreviewPayload } from '../popup-item'
|
||||
import { Combobox } from '@langgenius/dify-ui/combobox'
|
||||
import { createPreviewCardHandle } from '@langgenius/dify-ui/preview-card'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
@ -142,20 +141,17 @@ const makeProvider = (overrides: Record<string, unknown> = {}) => ({
|
||||
const previewCardProps = () => ({
|
||||
previewCardHandle: createPreviewCardHandle<ModelSelectorPreviewPayload>(),
|
||||
onPreviewCardClose: vi.fn(),
|
||||
onSelect: vi.fn(),
|
||||
})
|
||||
|
||||
const createComboboxNode = (node: ReactElement, onValueChange = vi.fn()) => (
|
||||
<Combobox filter={null} open onValueChange={onValueChange}>
|
||||
{node}
|
||||
</Combobox>
|
||||
)
|
||||
const createPopupItemNode = (node: ReactElement) => node
|
||||
|
||||
const renderWithCombobox = (node: ReactElement, onValueChange = vi.fn()) => {
|
||||
const renderPopupItem = (node: ReactElement) => {
|
||||
const queryClient = createConsoleQueryClient()
|
||||
queryClient.setQueryData(commonQueryKeys.modelProviderDetails, {
|
||||
data: [makeProvider()],
|
||||
})
|
||||
return renderWithConsoleQuery(createComboboxNode(node, onValueChange), { queryClient })
|
||||
return renderWithConsoleQuery(createPopupItemNode(node), { queryClient })
|
||||
}
|
||||
|
||||
describe('PopupItem', () => {
|
||||
@ -186,34 +182,36 @@ describe('PopupItem', () => {
|
||||
modelProviders: [],
|
||||
})
|
||||
|
||||
const { container } = renderWithCombobox(
|
||||
const { container } = renderPopupItem(
|
||||
<PopupItem {...previewCardProps()} model={makeModel()} onHide={vi.fn()} />,
|
||||
)
|
||||
|
||||
expect(container.textContent).toBe('')
|
||||
})
|
||||
|
||||
it('should select the combobox value when clicking an active model', () => {
|
||||
const onValueChange = vi.fn()
|
||||
renderWithCombobox(
|
||||
<PopupItem {...previewCardProps()} model={makeModel()} onHide={vi.fn()} />,
|
||||
onValueChange,
|
||||
it('should select the model when clicking an active model', () => {
|
||||
const onSelect = vi.fn()
|
||||
renderPopupItem(
|
||||
<PopupItem
|
||||
{...previewCardProps()}
|
||||
model={makeModel()}
|
||||
onSelect={onSelect}
|
||||
onHide={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('GPT-4'))
|
||||
|
||||
expect(onValueChange).toHaveBeenCalledWith(
|
||||
{ provider: 'openai', model: 'gpt-4' },
|
||||
expect.objectContaining({ reason: 'item-press' }),
|
||||
)
|
||||
expect(onSelect).toHaveBeenCalledWith('openai', expect.objectContaining({ model: 'gpt-4' }))
|
||||
})
|
||||
|
||||
it('should close the shared preview before pressing an active model', () => {
|
||||
const onPreviewCardClose = vi.fn()
|
||||
renderWithCombobox(
|
||||
renderPopupItem(
|
||||
<PopupItem
|
||||
previewCardHandle={createPreviewCardHandle<ModelSelectorPreviewPayload>()}
|
||||
onPreviewCardClose={onPreviewCardClose}
|
||||
onSelect={vi.fn()}
|
||||
model={makeModel()}
|
||||
onHide={vi.fn()}
|
||||
/>,
|
||||
@ -225,23 +223,23 @@ describe('PopupItem', () => {
|
||||
})
|
||||
|
||||
it('should not select the combobox value when model is not active', () => {
|
||||
const onValueChange = vi.fn()
|
||||
renderWithCombobox(
|
||||
const onSelect = vi.fn()
|
||||
renderPopupItem(
|
||||
<PopupItem
|
||||
{...previewCardProps()}
|
||||
model={makeModel({ models: [makeModelItem({ status: ModelStatusEnum.disabled })] })}
|
||||
onSelect={onSelect}
|
||||
onHide={vi.fn()}
|
||||
/>,
|
||||
onValueChange,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('GPT-4'))
|
||||
|
||||
expect(onValueChange).not.toHaveBeenCalled()
|
||||
expect(onSelect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should strike through deprecated model name', () => {
|
||||
renderWithCombobox(
|
||||
renderPopupItem(
|
||||
<PopupItem
|
||||
{...previewCardProps()}
|
||||
model={makeModel({ models: [makeModelItem({ deprecated: true })] })}
|
||||
@ -253,7 +251,7 @@ describe('PopupItem', () => {
|
||||
})
|
||||
|
||||
it('should render incompatible model names with tertiary text color', () => {
|
||||
renderWithCombobox(
|
||||
renderPopupItem(
|
||||
<PopupItem
|
||||
{...previewCardProps()}
|
||||
model={makeModel({
|
||||
@ -273,7 +271,7 @@ describe('PopupItem', () => {
|
||||
})
|
||||
|
||||
it('should render suggestion icon with tooltip for suggested models', async () => {
|
||||
renderWithCombobox(
|
||||
renderPopupItem(
|
||||
<PopupItem
|
||||
{...previewCardProps()}
|
||||
model={makeModel({
|
||||
@ -301,14 +299,14 @@ describe('PopupItem', () => {
|
||||
})
|
||||
|
||||
it('should open model modal when clicking add on unconfigured model', async () => {
|
||||
const onValueChange = vi.fn()
|
||||
const { rerender } = renderWithCombobox(
|
||||
const onSelect = vi.fn()
|
||||
const { rerender } = renderPopupItem(
|
||||
<PopupItem
|
||||
{...previewCardProps()}
|
||||
model={makeModel({ models: [makeModelItem({ status: ModelStatusEnum.noConfigure })] })}
|
||||
onSelect={onSelect}
|
||||
onHide={vi.fn()}
|
||||
/>,
|
||||
onValueChange,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('GPT-4'))
|
||||
@ -316,7 +314,7 @@ describe('PopupItem', () => {
|
||||
expect(addButton.closest('[aria-disabled="true"]')).toBeNull()
|
||||
fireEvent.click(addButton)
|
||||
|
||||
expect(onValueChange).not.toHaveBeenCalled()
|
||||
expect(onSelect).not.toHaveBeenCalled()
|
||||
await waitFor(() => {
|
||||
expect(mockSetShowModelModal).toHaveBeenCalled()
|
||||
})
|
||||
@ -328,7 +326,7 @@ describe('PopupItem', () => {
|
||||
expect(mockUpdateModelList).toHaveBeenCalledWith(ModelTypeEnum.textGeneration)
|
||||
|
||||
rerender(
|
||||
createComboboxNode(
|
||||
createPopupItemNode(
|
||||
<PopupItem
|
||||
{...previewCardProps()}
|
||||
model={makeModel({
|
||||
@ -359,7 +357,7 @@ describe('PopupItem', () => {
|
||||
|
||||
it('should show selected state when defaultModel matches', () => {
|
||||
const defaultModel: DefaultModel = { provider: 'openai', model: 'gpt-4' }
|
||||
renderWithCombobox(
|
||||
renderPopupItem(
|
||||
<PopupItem
|
||||
{...previewCardProps()}
|
||||
defaultModel={defaultModel}
|
||||
@ -368,13 +366,13 @@ describe('PopupItem', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('GPT-4'))!.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /GPT-4/ })).toHaveAttribute('aria-current', 'true')
|
||||
})
|
||||
|
||||
it('should fall back to english labels when the current language is unavailable', () => {
|
||||
mockUseLanguage.mockReturnValue('zh_Hans')
|
||||
|
||||
renderWithCombobox(
|
||||
renderPopupItem(
|
||||
<PopupItem
|
||||
{...previewCardProps()}
|
||||
model={makeModel({
|
||||
@ -390,21 +388,27 @@ describe('PopupItem', () => {
|
||||
})
|
||||
|
||||
it('should toggle collapsed state when clicking provider header', () => {
|
||||
renderWithCombobox(<PopupItem {...previewCardProps()} model={makeModel()} onHide={vi.fn()} />)
|
||||
renderPopupItem(<PopupItem {...previewCardProps()} model={makeModel()} onHide={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('GPT-4'))!.toBeInTheDocument()
|
||||
const providerTrigger = screen.getByRole('button', { name: 'OpenAI' })
|
||||
const modelButton = screen.getByRole('button', { name: /GPT-4/ })
|
||||
|
||||
fireEvent.click(screen.getByText('OpenAI'))
|
||||
expect(providerTrigger).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(modelButton).toBeVisible()
|
||||
|
||||
expect(screen.queryByText('GPT-4')).not.toBeInTheDocument()
|
||||
fireEvent.click(providerTrigger)
|
||||
|
||||
fireEvent.click(screen.getByText('OpenAI'))
|
||||
expect(providerTrigger).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(modelButton).not.toBeVisible()
|
||||
|
||||
expect(screen.getByText('GPT-4'))!.toBeInTheDocument()
|
||||
fireEvent.click(providerTrigger)
|
||||
|
||||
expect(providerTrigger).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(screen.getByRole('button', { name: /GPT-4/ })).toBeVisible()
|
||||
})
|
||||
|
||||
it('should show credential name when using custom provider', () => {
|
||||
renderWithCombobox(<PopupItem {...previewCardProps()} model={makeModel()} onHide={vi.fn()} />)
|
||||
renderPopupItem(<PopupItem {...previewCardProps()} model={makeModel()} onHide={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('my-api-key'))!.toBeInTheDocument()
|
||||
})
|
||||
@ -421,7 +425,7 @@ describe('PopupItem', () => {
|
||||
credits: 200,
|
||||
})
|
||||
|
||||
renderWithCombobox(<PopupItem {...previewCardProps()} model={makeModel()} onHide={vi.fn()} />)
|
||||
renderPopupItem(<PopupItem {...previewCardProps()} model={makeModel()} onHide={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('stale-key'))!.toBeInTheDocument()
|
||||
expect(document.querySelector('.bg-components-badge-status-light-error-bg')).not.toBeNull()
|
||||
@ -449,7 +453,7 @@ describe('PopupItem', () => {
|
||||
credits: 0,
|
||||
})
|
||||
|
||||
renderWithCombobox(<PopupItem {...previewCardProps()} model={makeModel()} onHide={vi.fn()} />)
|
||||
renderPopupItem(<PopupItem {...previewCardProps()} model={makeModel()} onHide={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText(/modelProvider\.selector\.configureRequired/))!.toBeInTheDocument()
|
||||
})
|
||||
@ -473,7 +477,7 @@ describe('PopupItem', () => {
|
||||
credits: 200,
|
||||
})
|
||||
|
||||
renderWithCombobox(<PopupItem {...previewCardProps()} model={makeModel()} onHide={vi.fn()} />)
|
||||
renderPopupItem(<PopupItem {...previewCardProps()} model={makeModel()} onHide={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText(/modelProvider\.selector\.aiCredits/))!.toBeInTheDocument()
|
||||
})
|
||||
@ -500,7 +504,7 @@ describe('PopupItem', () => {
|
||||
credits: 0,
|
||||
})
|
||||
|
||||
renderWithCombobox(<PopupItem {...previewCardProps()} model={makeModel()} onHide={vi.fn()} />)
|
||||
renderPopupItem(<PopupItem {...previewCardProps()} model={makeModel()} onHide={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText(/modelProvider\.selector\.creditsExhausted/))!.toBeInTheDocument()
|
||||
})
|
||||
@ -508,7 +512,7 @@ describe('PopupItem', () => {
|
||||
it('should close the dropdown through dropdown content callbacks', async () => {
|
||||
const onHide = vi.fn()
|
||||
|
||||
renderWithCombobox(<PopupItem {...previewCardProps()} model={makeModel()} onHide={onHide} />)
|
||||
renderPopupItem(<PopupItem {...previewCardProps()} model={makeModel()} onHide={onHide} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /my-api-key/ }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'close dropdown' }))
|
||||
@ -519,7 +523,7 @@ describe('PopupItem', () => {
|
||||
it('should keep the credential dropdown enabled for manage-only users', async () => {
|
||||
mockWorkspacePermissionKeys.value = ['credential.manage']
|
||||
|
||||
renderWithCombobox(<PopupItem {...previewCardProps()} model={makeModel()} onHide={vi.fn()} />)
|
||||
renderPopupItem(<PopupItem {...previewCardProps()} model={makeModel()} onHide={vi.fn()} />)
|
||||
|
||||
const trigger = screen.getByRole('button', { name: /my-api-key/ })
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@ import type { ModelProviderSummaryResponse } from '@dify/contracts/api/console/w
|
||||
import type { ReactElement } from 'react'
|
||||
import type { Model, ModelItem } from '../../declarations'
|
||||
import type { PopupProps } from '../popup'
|
||||
import { Combobox, ComboboxContent, ComboboxTrigger } from '@langgenius/dify-ui/combobox'
|
||||
import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useState } from 'react'
|
||||
@ -74,22 +74,18 @@ vi.mock('@/context/provider-context', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
type PopupTestProps = Omit<PopupProps, 'inputValue' | 'onInputValueChange'>
|
||||
type PopupTestProps = Omit<PopupProps, 'inputValue' | 'onInputValueChange' | 'onSelect'>
|
||||
|
||||
function PopupHarness(props: PopupTestProps) {
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
filter={null}
|
||||
<Popup
|
||||
{...props}
|
||||
inputValue={inputValue}
|
||||
open
|
||||
onInputValueChange={(newInputValue, details) => {
|
||||
if (details.reason !== 'item-press') setInputValue(newInputValue)
|
||||
}}
|
||||
>
|
||||
<Popup {...props} inputValue={inputValue} onInputValueChange={setInputValue} />
|
||||
</Combobox>
|
||||
onInputValueChange={setInputValue}
|
||||
onSelect={vi.fn()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -97,12 +93,18 @@ function PopupContentHarness(props: PopupTestProps) {
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
|
||||
return (
|
||||
<Combobox filter={null} inputValue={inputValue} open>
|
||||
<ComboboxTrigger aria-label="Selected model">Selected model</ComboboxTrigger>
|
||||
<ComboboxContent popupProps={{ 'aria-label': 'Model selector' }}>
|
||||
<Popup {...props} inputValue={inputValue} onInputValueChange={setInputValue} />
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
<Popover open>
|
||||
<PopoverTrigger>Selected model</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<PopoverTitle>Model selector</PopoverTitle>
|
||||
<Popup
|
||||
{...props}
|
||||
inputValue={inputValue}
|
||||
onInputValueChange={setInputValue}
|
||||
onSelect={vi.fn()}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1104,7 +1106,7 @@ describe('Popup', () => {
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep popup actions outside the model listbox and reachable by Tab', async () => {
|
||||
it('should expose popup actions in the dialog tab sequence without a listbox', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onConfigureEmptyState = vi.fn()
|
||||
const onOpenProviderSettings = vi.fn()
|
||||
@ -1123,7 +1125,7 @@ describe('Popup', () => {
|
||||
|
||||
expect(await screen.findByRole('dialog', { name: 'Model selector' })).toBeInTheDocument()
|
||||
|
||||
const searchInput = screen.getByRole('combobox', {
|
||||
const searchInput = screen.getByRole('searchbox', {
|
||||
name: 'datasetSettings.form.searchModel',
|
||||
})
|
||||
const configureButton = screen.getByRole('button', {
|
||||
@ -1132,10 +1134,7 @@ describe('Popup', () => {
|
||||
const providerSettingsButton = screen.getByRole('button', {
|
||||
name: /common\.modelProvider\.selector\.modelProviderSettings/,
|
||||
})
|
||||
const listbox = screen.getByRole('listbox')
|
||||
|
||||
expect(listbox).not.toContainElement(configureButton)
|
||||
expect(listbox).not.toContainElement(providerSettingsButton)
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(searchInput)
|
||||
await user.tab()
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import type { ComboboxChangeEventDetails } from '@langgenius/dify-ui/combobox'
|
||||
import type {
|
||||
ModelSelectorModel,
|
||||
ModelSelectorModelPredicate,
|
||||
@ -6,18 +5,17 @@ import type {
|
||||
ModelSelectorValue,
|
||||
} from './types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Combobox, ComboboxContent } from '@langgenius/dify-ui/combobox'
|
||||
import { Popover, PopoverContent, PopoverTitle } from '@langgenius/dify-ui/popover'
|
||||
import { useQueryState } from 'nuqs'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
settingsQueryParamName,
|
||||
settingsQueryParser,
|
||||
} from '@/app/components/header/account-setting/query-params'
|
||||
import { ModelStatusEnum } from '../declarations'
|
||||
import { getCurrentProviderAndModel } from '../hooks'
|
||||
import { ModelSelectorTrigger } from './model-selector-trigger'
|
||||
import Popup from './popup'
|
||||
import { getModelSelectorValueLabel, isSameModelSelectorValue } from './types'
|
||||
|
||||
const getModelProviderPluginId = (provider: string) => {
|
||||
const [organization, pluginName] = provider.split('/').filter(Boolean)
|
||||
@ -76,6 +74,7 @@ function ModelSelectorRoot({
|
||||
surface: 'default' | 'workflow'
|
||||
shape: 'standalone' | 'split'
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [settingsDestination, setSettingsDestination] = useQueryState(
|
||||
@ -83,14 +82,6 @@ function ModelSelectorRoot({
|
||||
settingsQueryParser,
|
||||
)
|
||||
const { currentProvider, currentModel } = getCurrentProviderAndModel(models, value)
|
||||
const currentValue = useMemo<ModelSelectorValue | null>(() => {
|
||||
if (!currentProvider || !currentModel) return null
|
||||
|
||||
return {
|
||||
provider: currentProvider.provider,
|
||||
model: currentModel.model,
|
||||
}
|
||||
}, [currentModel, currentProvider])
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(newOpen: boolean) => {
|
||||
@ -129,28 +120,6 @@ function ModelSelectorRoot({
|
||||
setSettingsDestination('provider')
|
||||
}, [handleHide, setSettingsDestination])
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
(value: ModelSelectorValue | null) => {
|
||||
if (!value) return
|
||||
|
||||
const provider = models.find((model) => model.provider === value.provider)
|
||||
const model = provider?.models.find((model) => model.model === value.model)
|
||||
|
||||
if (!provider || !model) return
|
||||
if (model.status !== ModelStatusEnum.active) return
|
||||
|
||||
handleSelect(provider.provider, model)
|
||||
},
|
||||
[handleSelect, models],
|
||||
)
|
||||
|
||||
const handleInputValueChange = useCallback(
|
||||
(inputValue: string, details: ComboboxChangeEventDetails) => {
|
||||
if (details.reason !== 'item-press') setInputValue(inputValue)
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const handleConfigureEmptyState = useCallback(() => {
|
||||
if (onConfigureEmptyState) {
|
||||
handleHide()
|
||||
@ -166,18 +135,7 @@ function ModelSelectorRoot({
|
||||
}, [handleHide, handleOpenSettings, onConfigureEmptyState, settingsDestination])
|
||||
|
||||
return (
|
||||
<Combobox<ModelSelectorValue>
|
||||
disabled={disabled}
|
||||
filter={null}
|
||||
inputValue={inputValue}
|
||||
isItemEqualToValue={isSameModelSelectorValue}
|
||||
itemToStringLabel={getModelSelectorValueLabel}
|
||||
open={open}
|
||||
value={currentValue}
|
||||
onInputValueChange={handleInputValueChange}
|
||||
onOpenChange={handleOpenChange}
|
||||
onValueChange={handleValueChange}
|
||||
>
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<ModelSelectorTrigger
|
||||
currentProvider={currentProvider}
|
||||
currentModel={currentModel}
|
||||
@ -195,12 +153,16 @@ function ModelSelectorRoot({
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<ComboboxContent
|
||||
<PopoverContent
|
||||
placement="bottom-start"
|
||||
popupClassName={cn(
|
||||
'flex max-h-[min(624px,var(--available-height,624px))] flex-col',
|
||||
'flex max-h-[min(624px,var(--available-height,624px))] w-(--anchor-width) max-w-[min(28rem,var(--available-width))] flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg',
|
||||
popupClassName,
|
||||
)}
|
||||
>
|
||||
<PopoverTitle className="sr-only">
|
||||
{t(($) => $['detailPanel.configureModel'], { ns: 'plugin' })}
|
||||
</PopoverTitle>
|
||||
<Popup
|
||||
defaultModel={value}
|
||||
inputValue={inputValue}
|
||||
@ -216,10 +178,11 @@ function ModelSelectorRoot({
|
||||
onConfigureEmptyState={handleConfigureEmptyState}
|
||||
onOpenMarketplace={onOpenMarketplace}
|
||||
onInputValueChange={setInputValue}
|
||||
onSelect={handleSelect}
|
||||
onHide={handleHide}
|
||||
/>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import type { ModelProviderQuotaGetPaid } from '@/types/model-provider'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from '@langgenius/dify-ui/collapsible'
|
||||
import { useId } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getMarketplaceCategoryUrl } from '@/app/components/plugins/marketplace/utils'
|
||||
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
@ -28,6 +30,7 @@ function MarketplaceSection({
|
||||
onOpenMarketplace,
|
||||
}: MarketplaceSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const headingId = useId()
|
||||
|
||||
if (marketplaceProviders.length === 0) return null
|
||||
|
||||
@ -36,34 +39,37 @@ function MarketplaceSection({
|
||||
<div className="py-2">
|
||||
<div className="h-px bg-divider-subtle" />
|
||||
</div>
|
||||
<div>
|
||||
<Collapsible
|
||||
open={!marketplaceCollapsed}
|
||||
onOpenChange={(open) => onMarketplaceCollapsedChange(!open)}
|
||||
render={<section aria-labelledby={headingId} />}
|
||||
>
|
||||
<div className="flex h-5.5 items-center pr-2 pl-4">
|
||||
<button
|
||||
type="button"
|
||||
className="flex flex-1 cursor-pointer items-center border-0 bg-transparent p-0 text-left system-sm-medium text-text-primary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
|
||||
onClick={() => onMarketplaceCollapsedChange(!marketplaceCollapsed)}
|
||||
<CollapsibleTrigger
|
||||
id={headingId}
|
||||
className="group/marketplace min-h-0 flex-1 justify-start gap-0 rounded-none p-0 system-sm-medium text-text-primary hover:not-data-disabled:bg-transparent hover:not-data-disabled:text-text-primary data-panel-open:text-text-primary"
|
||||
>
|
||||
{t(($) => $['modelProvider.selector.fromMarketplace'], { ns: 'common' })}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'i-custom-vender-solid-general-arrow-down-round-fill size-4 text-text-quaternary',
|
||||
marketplaceCollapsed && '-rotate-90',
|
||||
'i-custom-vender-solid-general-arrow-down-round-fill size-4 -rotate-90 text-text-quaternary transition-transform group-data-panel-open/marketplace:rotate-0 motion-reduce:transition-none',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
{!marketplaceCollapsed && (
|
||||
<div className="px-1 pb-1">
|
||||
<CollapsiblePanel>
|
||||
<ul className="px-1 pb-1">
|
||||
{marketplaceProviders.map((key) => {
|
||||
const Icon = providerIconMap[key]
|
||||
const isInstalling = installingProvider === key
|
||||
return (
|
||||
<div
|
||||
<li
|
||||
key={key}
|
||||
className="group flex cursor-pointer items-center gap-1 rounded-lg py-0.5 pr-0.5 pl-3 hover:bg-state-base-hover"
|
||||
className="group flex cursor-pointer items-center gap-1 rounded-lg py-0.5 pr-0.5 pl-3 focus-within:bg-state-base-hover hover:bg-state-base-hover"
|
||||
>
|
||||
<div className="flex flex-1 items-center gap-2 py-0.5">
|
||||
<Icon className="size-5 shrink-0 rounded-md" />
|
||||
<Icon aria-hidden="true" className="size-5 shrink-0 rounded-md" />
|
||||
<span className="system-sm-regular text-text-secondary">
|
||||
{modelNameMap[key]}
|
||||
</span>
|
||||
@ -72,28 +78,36 @@ function MarketplaceSection({
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
aria-busy={isInstalling || undefined}
|
||||
className={cn(
|
||||
'shrink-0 backdrop-blur-[5px]',
|
||||
!isInstalling && 'hidden group-hover:flex',
|
||||
!isInstalling &&
|
||||
'opacity-0 group-focus-within:opacity-100 group-hover:opacity-100 focus-visible:opacity-100',
|
||||
)}
|
||||
disabled={isInstalling}
|
||||
onClick={() => onInstallPlugin(key)}
|
||||
>
|
||||
{isInstalling && (
|
||||
<span className="i-ri-loader-2-line size-3.5 animate-spin" />
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="i-ri-loader-2-line size-3.5 animate-spin"
|
||||
/>
|
||||
)}
|
||||
{isInstalling
|
||||
? t(($) => $['installModal.installing'], { ns: 'plugin' })
|
||||
: t(($) => $['modelProvider.selector.install'], { ns: 'common' })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
<div className="px-1 pb-1">
|
||||
{onOpenMarketplace ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full cursor-pointer items-center gap-0.5 border-0 bg-transparent px-3 py-1.5 text-left focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
className="h-auto w-full justify-start gap-0.5 px-3 py-1.5 text-left"
|
||||
onClick={onOpenMarketplace}
|
||||
>
|
||||
<span className="flex-1 system-xs-regular text-text-accent">
|
||||
@ -105,10 +119,10 @@ function MarketplaceSection({
|
||||
className="i-ri-arrow-right-up-line size-3! text-text-accent"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</Button>
|
||||
) : (
|
||||
<a
|
||||
className="flex cursor-pointer items-center gap-0.5 px-3 py-1.5"
|
||||
className="flex cursor-pointer items-center gap-0.5 rounded-md px-3 py-1.5 outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
href={getMarketplaceCategoryUrl(PluginCategoryEnum.model, { theme })}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@ -125,8 +139,8 @@ function MarketplaceSection({
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsiblePanel>
|
||||
</Collapsible>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { ModelSelectorModel, ModelSelectorProvider, ModelSelectorValue } from './types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { ComboboxTrigger } from '@langgenius/dify-ui/combobox'
|
||||
import { PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -12,7 +12,7 @@ import {
|
||||
} from '../derive-model-status'
|
||||
import ModelIcon from '../model-icon'
|
||||
import ModelName from '../model-name'
|
||||
import { useCredentialPanelState } from '../provider-added-card/use-credential-panel-state'
|
||||
import { useCredentialPanelState as useCredentialPanelInfo } from '../provider-added-card/use-credential-panel-state'
|
||||
|
||||
type ModelSelectorTriggerProps = {
|
||||
currentProvider?: ModelSelectorProvider
|
||||
@ -52,7 +52,7 @@ function ModelSelectorTrigger({
|
||||
enabled: !!providerId,
|
||||
select: ({ data }) => data.find((provider) => provider.provider === providerId),
|
||||
})
|
||||
const credentialPanel = useCredentialPanelState(resolvedProvider)
|
||||
const credentialPanel = useCredentialPanelInfo(resolvedProvider)
|
||||
|
||||
const status = deriveModelStatus(
|
||||
isSelected ? currentModel?.model : defaultModel?.model,
|
||||
@ -93,26 +93,33 @@ function ModelSelectorTrigger({
|
||||
<TooltipTrigger
|
||||
disabled={!triggerTooltipLabel || disabled}
|
||||
render={
|
||||
<ComboboxTrigger
|
||||
aria-label={t(($) => $['detailPanel.configureModel'], { ns: 'plugin' })}
|
||||
data-deprecated={isDeprecated ? '' : undefined}
|
||||
data-model-status={status}
|
||||
data-shape={shape}
|
||||
data-size={size}
|
||||
data-surface={surface}
|
||||
<PopoverTrigger
|
||||
disabled={disabled}
|
||||
icon={!disabled && shape !== 'split' && (isActive || isEmpty) ? undefined : false}
|
||||
size={size}
|
||||
className={cn(
|
||||
'data-[size=small]:h-6 data-[size=small]:gap-px data-[size=small]:rounded-md data-[size=small]:p-0.5',
|
||||
'data-[size=medium]:h-8 data-[size=medium]:gap-0.5 data-[size=medium]:rounded-lg data-[size=medium]:p-1',
|
||||
'data-[surface=workflow]:bg-workflow-block-parma-bg data-[surface=workflow]:hover:bg-workflow-block-parma-bg data-[surface=workflow]:data-popup-open:bg-workflow-block-parma-bg',
|
||||
'data-[model-status=api-key-unavailable]:bg-components-input-bg-disabled data-[model-status=configure-required]:bg-components-input-bg-disabled data-[model-status=credits-exhausted]:bg-components-input-bg-disabled data-[model-status=disabled]:bg-components-input-bg-disabled data-[model-status=incompatible]:bg-components-input-bg-disabled',
|
||||
'data-disabled:data-[model-status=active]:bg-components-input-bg-normal! data-disabled:data-[model-status=empty]:bg-components-input-bg-normal! data-disabled:data-[surface=workflow]:bg-workflow-block-parma-bg!',
|
||||
'data-[shape=split]:relative data-[shape=split]:min-w-0 data-[shape=split]:flex-1 data-[shape=split]:rounded-l-lg! data-[shape=split]:rounded-r-none! data-[shape=split]:focus-visible:z-1',
|
||||
'data-[surface=workflow]:data-deprecated:[&>span]:opacity-50',
|
||||
className,
|
||||
)}
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t(($) => $['detailPanel.configureModel'], { ns: 'plugin' })}
|
||||
data-deprecated={isDeprecated ? '' : undefined}
|
||||
data-model-status={status}
|
||||
data-shape={shape}
|
||||
data-size={size}
|
||||
data-surface={surface}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'group/model-selector-trigger flex w-full min-w-0 items-center border-0 bg-components-input-bg-normal text-left text-components-input-text-filled outline-hidden transition-colors',
|
||||
'hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-state-base-hover-alt',
|
||||
'disabled:cursor-not-allowed disabled:text-components-input-text-filled-disabled motion-reduce:transition-none',
|
||||
'data-[size=small]:h-6 data-[size=small]:gap-px data-[size=small]:rounded-md data-[size=small]:p-0.5',
|
||||
'data-[size=medium]:h-8 data-[size=medium]:gap-0.5 data-[size=medium]:rounded-lg data-[size=medium]:p-1',
|
||||
'data-[surface=workflow]:bg-workflow-block-parma-bg data-[surface=workflow]:hover:bg-workflow-block-parma-bg data-[surface=workflow]:data-popup-open:bg-workflow-block-parma-bg',
|
||||
'data-[model-status=api-key-unavailable]:bg-components-input-bg-disabled data-[model-status=configure-required]:bg-components-input-bg-disabled data-[model-status=credits-exhausted]:bg-components-input-bg-disabled data-[model-status=disabled]:bg-components-input-bg-disabled data-[model-status=incompatible]:bg-components-input-bg-disabled',
|
||||
'disabled:data-[model-status=active]:bg-components-input-bg-normal! disabled:data-[model-status=empty]:bg-components-input-bg-normal! disabled:data-[surface=workflow]:bg-workflow-block-parma-bg!',
|
||||
'data-[shape=split]:relative data-[shape=split]:min-w-0 data-[shape=split]:flex-1 data-[shape=split]:rounded-l-lg! data-[shape=split]:rounded-r-none! data-[shape=split]:focus-visible:z-1',
|
||||
'data-[surface=workflow]:data-deprecated:[&>span]:opacity-50',
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span className="flex min-w-0 grow items-center gap-0.5">
|
||||
{isEmpty ? (
|
||||
@ -123,7 +130,10 @@ function ModelSelectorTrigger({
|
||||
)}
|
||||
>
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-subtle">
|
||||
<span className="i-ri-brain-2-line size-3.5 text-text-quaternary" />
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="i-ri-brain-2-line size-3.5 text-text-quaternary"
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
@ -186,7 +196,13 @@ function ModelSelectorTrigger({
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</ComboboxTrigger>
|
||||
{!disabled && shape !== 'split' && (isActive || isEmpty) && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="i-ri-arrow-down-s-line size-4 shrink-0 text-text-quaternary transition-colors group-hover/model-selector-trigger:text-text-secondary group-data-popup-open/model-selector-trigger:text-text-secondary"
|
||||
/>
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
}
|
||||
/>
|
||||
{triggerTooltipLabel && (
|
||||
|
||||
@ -11,7 +11,7 @@ function ModelSelectorEmptyState({ onConfigure }: ModelSelectorEmptyStateProps)
|
||||
return (
|
||||
<div className="mx-2 flex flex-col gap-2 rounded-[10px] bg-linear-to-r from-state-base-hover to-background-gradient-mask-transparent p-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg shadow-lg backdrop-blur-[5px]">
|
||||
<span className="i-ri-brain-2-line size-5 text-text-tertiary" />
|
||||
<span aria-hidden="true" className="i-ri-brain-2-line size-5 text-text-tertiary" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="system-sm-medium text-text-secondary">
|
||||
@ -23,7 +23,7 @@ function ModelSelectorEmptyState({ onConfigure }: ModelSelectorEmptyStateProps)
|
||||
</div>
|
||||
<Button variant="primary" className="w-27" onClick={onConfigure}>
|
||||
{t(($) => $['modelProvider.selector.configure'], { ns: 'common' })}
|
||||
<span className="i-ri-arrow-right-line size-4" />
|
||||
<span aria-hidden="true" className="i-ri-arrow-right-line size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -6,13 +6,14 @@ import type {
|
||||
ModelSelectorProvider,
|
||||
ModelSelectorValue,
|
||||
} from './types'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { ComboboxGroup, ComboboxItem, ComboboxItemIndicator } from '@langgenius/dify-ui/combobox'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from '@langgenius/dify-ui/collapsible'
|
||||
import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { PreviewCardTrigger } from '@langgenius/dify-ui/preview-card'
|
||||
import { StatusDot } from '@langgenius/dify-ui/status-dot'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useCallback, useId, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
@ -45,8 +46,10 @@ type PopupItemProps = {
|
||||
modelSuggestionPredicate?: ModelSelectorModelPredicate
|
||||
previewCardHandle: PreviewCardHandle
|
||||
onPreviewCardClose: () => void
|
||||
onSelect: (provider: string, model: ModelSelectorModel) => void
|
||||
onHide: () => void
|
||||
}
|
||||
|
||||
function PopupItem({
|
||||
defaultModel,
|
||||
model,
|
||||
@ -54,12 +57,15 @@ function PopupItem({
|
||||
modelSuggestionPredicate,
|
||||
previewCardHandle,
|
||||
onPreviewCardClose,
|
||||
onSelect,
|
||||
onHide,
|
||||
}: PopupItemProps) {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [modelsOpen, setModelsOpen] = useState(true)
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false)
|
||||
const providerHeadingId = useId()
|
||||
const { t } = useTranslation()
|
||||
const language = useLanguage()
|
||||
const providerLabel = renderI18nObject(model.label, language)
|
||||
const suggestionTip = t(($) => $['modelProvider.selector.suggestionTip'], { ns: 'common' })
|
||||
const { setShowModelModal } = useModalContext()
|
||||
const { modelProviders } = useProviderContext()
|
||||
@ -69,10 +75,16 @@ function PopupItem({
|
||||
const { providerDetail, loadProviderDetail } = useLazyModelProviderDetail(model.provider)
|
||||
const { canUseCredential, canCreateCredential, canManageCredential } = useCredentialPermissions()
|
||||
const canOpenCredentialDropdown = canUseCredential || canCreateCredential || canManageCredential
|
||||
const handleOpenModelModal = async () => {
|
||||
if (!canCreateCredential) return
|
||||
const state = useCredentialPanelInfo(currentProvider)
|
||||
const { isChangingPriority, handleChangePriority } = useChangeProviderPriority(currentProvider)
|
||||
const isUsingCredits = state.priority === 'credits'
|
||||
const hasCredits = !state.isCreditsExhausted
|
||||
const isApiKeyActive = state.variant === 'api-active' || state.variant === 'api-fallback'
|
||||
const { credentialName } = state
|
||||
|
||||
const handleOpenModelModal = async () => {
|
||||
if (!canCreateCredential || !currentProvider) return
|
||||
|
||||
if (!currentProvider) return
|
||||
const detail = await loadProviderDetail()
|
||||
if (!detail) return
|
||||
setShowModelModal({
|
||||
@ -84,34 +96,16 @@ function PopupItem({
|
||||
updateModelProviders()
|
||||
|
||||
const modelType = model.models[0]!.model_type
|
||||
|
||||
if (modelType) updateModelList(modelType as ModelType)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const state = useCredentialPanelInfo(currentProvider)
|
||||
const { isChangingPriority, handleChangePriority } = useChangeProviderPriority(currentProvider)
|
||||
const groupItems = useMemo(
|
||||
() =>
|
||||
model.models
|
||||
.filter((modelItem) => modelItem.status !== ModelStatusEnum.noConfigure)
|
||||
.map((modelItem) => ({
|
||||
provider: model.provider,
|
||||
model: modelItem.model,
|
||||
})),
|
||||
[model.models, model.provider],
|
||||
)
|
||||
|
||||
const isUsingCredits = state.priority === 'credits'
|
||||
const hasCredits = !state.isCreditsExhausted
|
||||
const isApiKeyActive = state.variant === 'api-active' || state.variant === 'api-fallback'
|
||||
const { credentialName } = state
|
||||
|
||||
const handleCloseDropdown = useCallback(() => {
|
||||
setDropdownOpen(false)
|
||||
onHide()
|
||||
}, [onHide])
|
||||
|
||||
const handleDropdownOpenChange = async (nextOpen: boolean) => {
|
||||
if (!nextOpen) {
|
||||
setDropdownOpen(false)
|
||||
@ -124,40 +118,51 @@ function PopupItem({
|
||||
if (!currentProvider) return null
|
||||
|
||||
return (
|
||||
<ComboboxGroup className="mb-1" items={groupItems}>
|
||||
<div className="sticky top-0 z-1 flex h-5.5 min-w-0 items-center justify-between gap-2 bg-components-panel-bg px-3 text-xs font-medium text-text-tertiary">
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 cursor-pointer items-center border-0 bg-transparent p-0 text-left focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
|
||||
onClick={() => setCollapsed((prev) => !prev)}
|
||||
<Collapsible
|
||||
open={modelsOpen}
|
||||
onOpenChange={setModelsOpen}
|
||||
className="mb-1"
|
||||
render={<section aria-labelledby={providerHeadingId} />}
|
||||
>
|
||||
<div className="sticky top-0 z-1 flex min-h-5.5 min-w-0 items-center justify-between gap-2 bg-components-panel-bg px-3 text-xs font-medium text-text-tertiary">
|
||||
<CollapsibleTrigger
|
||||
id={providerHeadingId}
|
||||
className="group/provider min-h-0 w-auto min-w-0 justify-start gap-0 rounded-none p-0 text-xs font-medium text-text-tertiary hover:not-data-disabled:bg-transparent hover:not-data-disabled:text-text-tertiary data-panel-open:text-text-tertiary"
|
||||
>
|
||||
<span className="truncate">{renderI18nObject(model.label, language)}</span>
|
||||
<span className="truncate">{providerLabel}</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'i-custom-vender-solid-general-arrow-down-round-fill size-4 shrink-0 text-text-quaternary',
|
||||
collapsed && '-rotate-90',
|
||||
'i-custom-vender-solid-general-arrow-down-round-fill size-4 shrink-0 -rotate-90 text-text-quaternary transition-transform group-data-panel-open/provider:rotate-0 motion-reduce:transition-none',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<Popover open={dropdownOpen} onOpenChange={handleDropdownOpenChange}>
|
||||
<PopoverTrigger
|
||||
disabled={!canOpenCredentialDropdown}
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className="flex max-w-[50%] min-w-0 shrink-0 cursor-pointer items-center rounded-md px-1.5 py-1 system-xs-medium text-text-tertiary outline-hidden hover:bg-components-button-ghost-bg-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
className="max-w-[50%] min-w-0 shrink-0 gap-0 px-1.5 py-1 system-xs-medium text-text-tertiary"
|
||||
>
|
||||
{isUsingCredits ? (
|
||||
hasCredits ? (
|
||||
<>
|
||||
<span className="i-custom-vender-line-financeandecommerce-credits-coin size-3" />
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="i-custom-vender-line-financeandecommerce-credits-coin size-3"
|
||||
/>
|
||||
<span className="ml-1 truncate">
|
||||
{t(($) => $['modelProvider.selector.aiCredits'], { ns: 'common' })}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="i-ri-alert-fill size-3 shrink-0 text-text-warning-secondary" />
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="i-ri-alert-fill size-3 shrink-0 text-text-warning-secondary"
|
||||
/>
|
||||
<span className="ml-1 truncate text-text-warning">
|
||||
{t(($) => $['modelProvider.selector.creditsExhausted'], { ns: 'common' })}
|
||||
</span>
|
||||
@ -177,12 +182,16 @@ function PopupItem({
|
||||
</>
|
||||
)}
|
||||
{canOpenCredentialDropdown && (
|
||||
<span className="i-ri-arrow-down-s-line size-3.5! shrink-0 translate-y-px text-text-tertiary" />
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="i-ri-arrow-down-s-line size-3.5! shrink-0 translate-y-px text-text-tertiary"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent placement="bottom-end">
|
||||
<PopoverTitle className="sr-only">{providerLabel}</PopoverTitle>
|
||||
{providerDetail && (
|
||||
<DropdownContent
|
||||
provider={providerDetail}
|
||||
@ -195,99 +204,105 @@ function PopupItem({
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
{!collapsed &&
|
||||
model.models.map((modelItem) => {
|
||||
const isModelCompatible = modelPredicate?.(model, modelItem) ?? true
|
||||
const isModelSuggested = modelSuggestionPredicate?.(model, modelItem) ?? false
|
||||
const rowClassName = cn(
|
||||
'group relative mx-1 flex h-8 min-w-0 items-center gap-1 rounded-lg px-3 py-1.5 text-left',
|
||||
modelItem.status === ModelStatusEnum.active
|
||||
? 'cursor-pointer hover:bg-state-base-hover'
|
||||
: 'cursor-not-allowed hover:bg-state-base-hover-alt',
|
||||
)
|
||||
const rowContent = (
|
||||
<>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<ModelIcon
|
||||
className={cn('size-5 shrink-0')}
|
||||
provider={model}
|
||||
modelName={modelItem.model}
|
||||
/>
|
||||
<ModelName
|
||||
className={cn(
|
||||
'system-sm-medium text-text-secondary',
|
||||
!isModelCompatible && 'text-text-quaternary',
|
||||
modelItem.status !== ModelStatusEnum.active && 'opacity-60',
|
||||
)}
|
||||
modelItem={modelItem}
|
||||
nameClassName={modelItem.deprecated ? 'line-through' : undefined}
|
||||
>
|
||||
{isModelSuggested && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span
|
||||
aria-label={suggestionTip}
|
||||
className="i-ri-shield-star-line size-3.5 shrink-0 text-text-accent-secondary"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<TooltipContent placement="top">{suggestionTip}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</ModelName>
|
||||
</div>
|
||||
{defaultModel?.model === modelItem.model &&
|
||||
defaultModel.provider === currentProvider.provider && (
|
||||
<ComboboxItemIndicator className="shrink-0 text-text-accent">
|
||||
<span
|
||||
className="i-custom-vender-line-general-check size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</ComboboxItemIndicator>
|
||||
<CollapsiblePanel>
|
||||
<ul className="pb-1">
|
||||
{model.models.map((modelItem) => {
|
||||
const isModelCompatible = modelPredicate?.(model, modelItem) ?? true
|
||||
const isModelSuggested = modelSuggestionPredicate?.(model, modelItem) ?? false
|
||||
const isSelected =
|
||||
defaultModel?.model === modelItem.model &&
|
||||
defaultModel.provider === currentProvider.provider
|
||||
const needsConfiguration = modelItem.status === ModelStatusEnum.noConfigure
|
||||
const isSelectable = modelItem.status === ModelStatusEnum.active
|
||||
const rowClassName = cn(
|
||||
'group relative mx-1 flex h-8 min-w-0 items-center gap-1 rounded-lg px-3 py-1.5 text-left',
|
||||
isSelectable
|
||||
? 'cursor-pointer hover:bg-state-base-hover'
|
||||
: 'cursor-not-allowed hover:bg-state-base-hover-alt',
|
||||
)
|
||||
const rowContent = (
|
||||
<>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<ModelIcon
|
||||
className="size-5 shrink-0"
|
||||
provider={model}
|
||||
modelName={modelItem.model}
|
||||
/>
|
||||
<ModelName
|
||||
className={cn(
|
||||
'system-sm-medium text-text-secondary',
|
||||
!isModelCompatible && 'text-text-quaternary',
|
||||
!isSelectable && 'opacity-60',
|
||||
)}
|
||||
modelItem={modelItem}
|
||||
nameClassName={modelItem.deprecated ? 'line-through' : undefined}
|
||||
>
|
||||
{isModelSuggested && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span
|
||||
aria-label={suggestionTip}
|
||||
className="i-ri-shield-star-line size-3.5 shrink-0 text-text-accent-secondary"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<TooltipContent placement="top">{suggestionTip}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</ModelName>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="i-custom-vender-line-general-check size-4 shrink-0 text-text-accent"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
const itemRender =
|
||||
modelItem.status === ModelStatusEnum.noConfigure ? (
|
||||
</>
|
||||
)
|
||||
const row = needsConfiguration ? (
|
||||
<div className={rowClassName} onPointerDown={onPreviewCardClose}>
|
||||
{rowContent}
|
||||
{canCreateCredential && (
|
||||
<button
|
||||
type="button"
|
||||
className="hidden cursor-pointer text-xs font-medium text-text-accent group-hover:block"
|
||||
onClick={handleOpenModelModal}
|
||||
<Button
|
||||
variant="ghost-accent"
|
||||
size="small"
|
||||
className="h-auto shrink-0 p-0 text-xs opacity-0 group-focus-within:opacity-100 group-hover:opacity-100 hover:bg-transparent focus-visible:opacity-100"
|
||||
onClick={() => void handleOpenModelModal()}
|
||||
>
|
||||
{t(($) => $['operation.add'], { ns: 'common' }).toLocaleUpperCase()}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<ComboboxItem
|
||||
value={{
|
||||
provider: model.provider,
|
||||
model: modelItem.model,
|
||||
}}
|
||||
disabled={modelItem.status !== ModelStatusEnum.active}
|
||||
className={rowClassName}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="medium"
|
||||
aria-current={isSelected ? 'true' : undefined}
|
||||
disabled={!isSelectable}
|
||||
className={cn(rowClassName, 'w-[calc(100%-0.5rem)] justify-start')}
|
||||
onPointerDown={onPreviewCardClose}
|
||||
onClick={() => onSelect(model.provider, modelItem)}
|
||||
>
|
||||
{rowContent}
|
||||
</ComboboxItem>
|
||||
</Button>
|
||||
)
|
||||
|
||||
return (
|
||||
<PreviewCardTrigger
|
||||
key={modelItem.model}
|
||||
delay={150}
|
||||
closeDelay={150}
|
||||
handle={previewCardHandle}
|
||||
payload={{ provider: model, modelItem }}
|
||||
render={itemRender}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</ComboboxGroup>
|
||||
return (
|
||||
<li key={modelItem.model}>
|
||||
<PreviewCardTrigger
|
||||
delay={150}
|
||||
closeDelay={150}
|
||||
handle={previewCardHandle}
|
||||
payload={{ provider: model, modelItem }}
|
||||
render={row}
|
||||
/>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</CollapsiblePanel>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { ComboboxInput, ComboboxInputGroup } from '@langgenius/dify-ui/combobox'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import {
|
||||
ScrollArea,
|
||||
ScrollAreaContent,
|
||||
@ -9,6 +8,7 @@ import {
|
||||
ScrollAreaViewport,
|
||||
} from '@langgenius/dify-ui/scroll-area'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SearchInput } from '@/app/components/base/search-input'
|
||||
|
||||
type ModelSelectorSearchHeaderProps = {
|
||||
inputValue: string
|
||||
@ -22,37 +22,13 @@ export function ModelSelectorSearchHeader({
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="shrink-0 bg-components-panel-bg px-2 pt-2 pb-1">
|
||||
<ComboboxInputGroup
|
||||
className={cn(
|
||||
'h-8 min-h-8 px-2',
|
||||
inputValue
|
||||
? 'border-components-input-border-active bg-components-input-bg-active shadow-xs'
|
||||
: 'border-transparent bg-components-input-bg-normal',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={`mr-0.5 i-ri-search-line size-4 shrink-0 ${inputValue ? 'text-text-tertiary' : 'text-text-quaternary'} `}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<ComboboxInput
|
||||
aria-label={t(($) => $['form.searchModel'], { ns: 'datasetSettings' }) || ''}
|
||||
className="block h-4.5 grow px-1 py-0 text-[13px] text-text-primary"
|
||||
placeholder={t(($) => $['form.searchModel'], { ns: 'datasetSettings' }) || ''}
|
||||
/>
|
||||
{inputValue && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t(($) => $['operation.clear'], { ns: 'common' }) || 'Clear'}
|
||||
className="ml-1.5 flex size-3.5 shrink-0 cursor-pointer items-center justify-center rounded-none text-text-quaternary outline-hidden hover:bg-transparent hover:text-text-quaternary focus-visible:bg-transparent focus-visible:ring-1 focus-visible:ring-components-input-border-active"
|
||||
onClick={() => onInputValueChange('')}
|
||||
onPointerDown={(event) => event.preventDefault()}
|
||||
>
|
||||
<span className="i-custom-vender-solid-general-x-circle size-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</ComboboxInputGroup>
|
||||
</div>
|
||||
<SearchInput
|
||||
aria-label={t(($) => $['form.searchModel'], { ns: 'datasetSettings' }) || ''}
|
||||
className="mx-2 mt-2 mb-1 shrink-0"
|
||||
placeholder={t(($) => $['form.searchModel'], { ns: 'datasetSettings' }) || ''}
|
||||
value={inputValue}
|
||||
onValueChange={onInputValueChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -101,9 +77,10 @@ export function ShowIncompatibleModelsButton({
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-10 w-full cursor-pointer items-center px-4 text-left system-xs-regular text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="medium"
|
||||
className="h-10 w-full justify-start rounded-none px-4 text-left system-xs-regular text-text-tertiary"
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="min-w-0 truncate">
|
||||
@ -111,7 +88,7 @@ export function ShowIncompatibleModelsButton({
|
||||
? t(($) => $['modelProvider.selector.hideIncompatibleModels'], { ns: 'common' })
|
||||
: t(($) => $['modelProvider.selector.showIncompatibleModels'], { ns: 'common' })}
|
||||
</span>
|
||||
</button>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@ -124,16 +101,17 @@ export function ModelProviderSettingsFooter({ onOpenSettings }: ModelProviderSet
|
||||
|
||||
return (
|
||||
<div className="shrink-0 border-t border-divider-subtle p-1">
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-1 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="medium"
|
||||
className="w-full justify-start gap-2 px-3 py-1 text-text-tertiary"
|
||||
onClick={onOpenSettings}
|
||||
>
|
||||
<span aria-hidden className="i-ri-equalizer-2-line size-4 shrink-0" />
|
||||
<span className="system-xs-medium">
|
||||
{t(($) => $['modelProvider.selector.modelProviderSettings'], { ns: 'common' })}
|
||||
</span>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import type { ModelSelectorPreviewPayload } from './popup-item'
|
||||
import type {
|
||||
ModelSelectorModel,
|
||||
ModelSelectorModelPredicate,
|
||||
ModelSelectorProvider,
|
||||
ModelSelectorValue,
|
||||
} from './types'
|
||||
import type { ModelProviderQuotaGetPaid } from '@/types/model-provider'
|
||||
import { ComboboxList } from '@langgenius/dify-ui/combobox'
|
||||
import {
|
||||
createPreviewCardHandle,
|
||||
PreviewCard,
|
||||
@ -61,6 +61,7 @@ export type PopupProps = {
|
||||
modelSuggestionPredicate?: ModelSelectorModelPredicate
|
||||
onConfigureEmptyState?: () => void
|
||||
onInputValueChange: (value: string) => void
|
||||
onSelect: (provider: string, model: ModelSelectorModel) => void
|
||||
onOpenMarketplace?: () => void
|
||||
onHide: () => void
|
||||
}
|
||||
@ -74,6 +75,7 @@ function Popup({
|
||||
modelSuggestionPredicate,
|
||||
onConfigureEmptyState,
|
||||
onInputValueChange,
|
||||
onSelect,
|
||||
onOpenMarketplace,
|
||||
onHide,
|
||||
}: PopupProps) {
|
||||
@ -239,6 +241,13 @@ function Popup({
|
||||
)
|
||||
}, [enableMarketplace, modelProviderPlugins])
|
||||
|
||||
const searchStatus =
|
||||
!filteredModelList.length && installedModelList.length > 0
|
||||
? t(($) => $['modelProvider.selector.noModelFoundForSearch'], {
|
||||
ns: 'common',
|
||||
query: inputValue,
|
||||
})
|
||||
: null
|
||||
const handleClosePreviewCard = useCallback(() => {
|
||||
previewCardHandle.close()
|
||||
}, [previewCardHandle])
|
||||
@ -251,34 +260,37 @@ function Popup({
|
||||
{showCreditsExhaustedAlert && (
|
||||
<CreditsExhaustedAlert hasApiKeyFallback={hasApiKeyFallback} />
|
||||
)}
|
||||
<ComboboxList className="max-h-none overflow-visible p-0">
|
||||
<div className="pb-1">
|
||||
{filteredModelList.map((model) => (
|
||||
<PopupItem
|
||||
key={model.provider}
|
||||
defaultModel={defaultModel}
|
||||
model={model}
|
||||
modelPredicate={modelPredicate}
|
||||
modelSuggestionPredicate={modelSuggestionPredicate}
|
||||
previewCardHandle={previewCardHandle}
|
||||
onPreviewCardClose={handleClosePreviewCard}
|
||||
onHide={onHide}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ComboboxList>
|
||||
<div className="pb-1">
|
||||
{filteredModelList.map((model) => (
|
||||
<PopupItem
|
||||
key={model.provider}
|
||||
defaultModel={defaultModel}
|
||||
model={model}
|
||||
modelPredicate={modelPredicate}
|
||||
modelSuggestionPredicate={modelSuggestionPredicate}
|
||||
previewCardHandle={previewCardHandle}
|
||||
onPreviewCardClose={handleClosePreviewCard}
|
||||
onSelect={onSelect}
|
||||
onHide={onHide}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="pb-1">
|
||||
{!filteredModelList.length && !installedModelList.length && (
|
||||
<ModelSelectorEmptyState onConfigure={onConfigureEmptyState ?? onHide} />
|
||||
)}
|
||||
{!filteredModelList.length && installedModelList.length > 0 && (
|
||||
<div className="px-3 py-1.5 text-center text-xs/4.5 break-all text-text-tertiary">
|
||||
{t(($) => $['modelProvider.selector.noModelFoundForSearch'], {
|
||||
ns: 'common',
|
||||
query: inputValue,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className={
|
||||
searchStatus
|
||||
? 'px-3 py-1.5 text-center text-xs/4.5 break-all text-text-tertiary'
|
||||
: 'h-0'
|
||||
}
|
||||
>
|
||||
{searchStatus}
|
||||
</div>
|
||||
{scopeFeatures.length > 0 && <CompatibleModelsNotice />}
|
||||
{shouldShowModelPredicateReveal && (
|
||||
<ShowIncompatibleModelsButton
|
||||
|
||||
@ -35,10 +35,3 @@ export type ModelSelectorModelPredicate = (
|
||||
provider: ModelSelectorProvider,
|
||||
modelItem: ModelSelectorModel,
|
||||
) => boolean
|
||||
|
||||
export const isSameModelSelectorValue = (
|
||||
itemValue: ModelSelectorValue,
|
||||
value: ModelSelectorValue,
|
||||
) => itemValue.provider === value.provider && itemValue.model === value.model
|
||||
|
||||
export const getModelSelectorValueLabel = (value: ModelSelectorValue) => value.model
|
||||
|
||||
@ -0,0 +1,126 @@
|
||||
import type { AppPartial } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useState } from 'react'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { AppPicker } from '../app-picker'
|
||||
|
||||
vi.mock('@/app/components/base/app-icon', () => ({
|
||||
default: () => <span aria-hidden="true" data-testid="app-icon" />,
|
||||
}))
|
||||
|
||||
const app = {
|
||||
id: 'app-1',
|
||||
name: 'Workflow App',
|
||||
mode: AppModeEnum.WORKFLOW,
|
||||
icon_type: 'emoji',
|
||||
icon: 'W',
|
||||
icon_background: '#FFFFFF',
|
||||
icon_url: null,
|
||||
} satisfies AppPartial
|
||||
|
||||
function renderAppPicker(
|
||||
overrides: Partial<Omit<React.ComponentProps<typeof AppPicker>, 'isShow' | 'onShowChange'>> = {},
|
||||
) {
|
||||
const props = {
|
||||
disabled: false,
|
||||
trigger: <span>Choose app</span>,
|
||||
onSelect: vi.fn(),
|
||||
apps: [app],
|
||||
isLoading: false,
|
||||
hasMore: false,
|
||||
onLoadMore: vi.fn(),
|
||||
searchText: '',
|
||||
onSearchChange: vi.fn(),
|
||||
...overrides,
|
||||
}
|
||||
|
||||
function AppPickerHarness() {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return <AppPicker {...props} isShow={open} onShowChange={setOpen} />
|
||||
}
|
||||
|
||||
return { props, ...render(<AppPickerHarness />) }
|
||||
}
|
||||
|
||||
describe('AppPicker', () => {
|
||||
it('should expose a named dialog and keep app choices in the listbox', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderAppPicker()
|
||||
await user.click(screen.getByRole('combobox', { name: 'app.appSelector.label' }))
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: 'app.appSelector.label' })
|
||||
const listbox = screen.getByRole('listbox')
|
||||
const option = screen.getByRole('option', { name: /Workflow App/ })
|
||||
|
||||
expect(dialog).toBeInTheDocument()
|
||||
expect(listbox).toContainElement(option)
|
||||
})
|
||||
|
||||
it('should not show the empty state while loading', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderAppPicker({ apps: [], isLoading: true })
|
||||
await user.click(screen.getByRole('combobox', { name: 'app.appSelector.label' }))
|
||||
|
||||
expect(
|
||||
screen
|
||||
.getAllByRole('status')
|
||||
.some((status) => status.textContent?.includes('common.loading')),
|
||||
).toBe(true)
|
||||
expect(screen.queryByText('common.noData')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep load more outside the listbox and inside the named scroll region', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderAppPicker({ hasMore: true })
|
||||
await user.click(screen.getByRole('combobox', { name: 'app.appSelector.label' }))
|
||||
|
||||
const listbox = screen.getByRole('listbox')
|
||||
const scrollRegion = screen.getByRole('region', { name: 'app.appSelector.label' })
|
||||
const loadMoreButton = screen.getByRole('button', { name: 'workflow.common.loadMore' })
|
||||
|
||||
expect(listbox).not.toContainElement(loadMoreButton)
|
||||
expect(scrollRegion).toContainElement(listbox)
|
||||
expect(scrollRegion).toContainElement(loadMoreButton)
|
||||
})
|
||||
|
||||
it('should clear only the search query and keep focus in the input', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSelect = vi.fn()
|
||||
|
||||
function AppPickerHarness() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [searchText, setSearchText] = useState('')
|
||||
|
||||
return (
|
||||
<AppPicker
|
||||
disabled={false}
|
||||
trigger={<span>Choose app</span>}
|
||||
isShow={open}
|
||||
onShowChange={setOpen}
|
||||
onSelect={onSelect}
|
||||
apps={[app]}
|
||||
isLoading={false}
|
||||
hasMore={false}
|
||||
onLoadMore={vi.fn()}
|
||||
searchText={searchText}
|
||||
onSearchChange={setSearchText}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
render(<AppPickerHarness />)
|
||||
await user.click(screen.getByRole('combobox', { name: 'app.appSelector.label' }))
|
||||
const searchInput = screen.getByRole('combobox', { name: 'app.appSelector.placeholder' })
|
||||
await user.type(searchInput, 'workflow')
|
||||
const clearButton = screen.getByRole('button', { name: 'common.operation.clear' })
|
||||
clearButton.focus()
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
expect(searchInput).toHaveValue('')
|
||||
expect(searchInput).toHaveFocus()
|
||||
expect(onSelect).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('option', { name: /Workflow App/ })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -7,17 +7,27 @@ import { zIconType } from '@dify/contracts/api/console/apps/zod.gen'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxInputGroup,
|
||||
ComboboxItem,
|
||||
ComboboxItemText,
|
||||
ComboboxList,
|
||||
ComboboxPopup,
|
||||
ComboboxPortal,
|
||||
ComboboxPositioner,
|
||||
ComboboxStatus,
|
||||
ComboboxTrigger,
|
||||
} from '@langgenius/dify-ui/combobox'
|
||||
import { useCallback } from 'react'
|
||||
import { IconButton } from '@langgenius/dify-ui/icon-button'
|
||||
import {
|
||||
ScrollArea,
|
||||
ScrollAreaContent,
|
||||
ScrollAreaScrollbar,
|
||||
ScrollAreaThumb,
|
||||
ScrollAreaViewport,
|
||||
} from '@langgenius/dify-ui/scroll-area'
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
@ -105,6 +115,7 @@ export function AppPicker({
|
||||
onSearchChange,
|
||||
}: AppPickerProps) {
|
||||
const { t } = useTranslation()
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
(app: AppPartial | null) => {
|
||||
@ -116,6 +127,11 @@ export function AppPicker({
|
||||
[onSelect, onShowChange],
|
||||
)
|
||||
|
||||
const handleClearSearch = () => {
|
||||
onSearchChange('')
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
return (
|
||||
<Combobox<AppPartial>
|
||||
items={apps}
|
||||
@ -136,56 +152,76 @@ export function AppPicker({
|
||||
>
|
||||
{trigger}
|
||||
</ComboboxTrigger>
|
||||
<ComboboxContent
|
||||
placement={placement}
|
||||
sideOffset={offset}
|
||||
popupClassName="border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
|
||||
>
|
||||
<div className="relative flex max-h-100 min-h-20 w-89 flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs">
|
||||
<div className="p-2 pb-1">
|
||||
<ComboboxInputGroup className="h-8 min-h-8 px-2">
|
||||
<span
|
||||
className="mr-0.5 i-ri-search-line size-4 shrink-0 text-text-tertiary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<ComboboxInput
|
||||
aria-label={t(($) => $['appSelector.placeholder'], { ns: 'app' })}
|
||||
placeholder={t(($) => $['appSelector.placeholder'], { ns: 'app' })}
|
||||
className="block h-4.5 grow px-1 py-0 text-[13px] text-text-primary"
|
||||
/>
|
||||
{searchText && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t(($) => $['operation.clear'], { ns: 'common' })}
|
||||
className="ml-1.5 flex size-3.5 shrink-0 cursor-pointer items-center justify-center rounded-none text-text-quaternary outline-hidden hover:bg-transparent hover:text-text-quaternary focus-visible:ring-1 focus-visible:ring-components-input-border-active"
|
||||
onClick={() => onSearchChange('')}
|
||||
>
|
||||
<ComboboxPortal>
|
||||
<ComboboxPositioner placement={placement} sideOffset={offset}>
|
||||
<ComboboxPopup
|
||||
aria-label={t(($) => $['appSelector.label'], { ns: 'app' })}
|
||||
className="border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
|
||||
>
|
||||
<div className="relative flex max-h-100 min-h-20 w-89 flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs">
|
||||
<div className="p-2 pb-1">
|
||||
<ComboboxInputGroup className="h-8 min-h-8 px-2">
|
||||
<span
|
||||
className="i-custom-vender-solid-general-x-circle size-3.5"
|
||||
className="mr-0.5 i-ri-search-line size-4 shrink-0 text-text-tertiary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</ComboboxInputGroup>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-1">
|
||||
{isLoading && <ComboboxStatus>{t(($) => $.loading, { ns: 'common' })}</ComboboxStatus>}
|
||||
<ComboboxList<AppPartial> className="max-h-none p-0">
|
||||
{(app) => <AppPickerOption key={app.id} app={app} />}
|
||||
</ComboboxList>
|
||||
<ComboboxEmpty>{t(($) => $.noData, { ns: 'common' })}</ComboboxEmpty>
|
||||
{hasMore && (
|
||||
<div className="flex justify-center px-3 py-2">
|
||||
<Button size="small" disabled={isLoading} onClick={() => onLoadMore()}>
|
||||
{isLoading
|
||||
? t(($) => $.loading, { ns: 'common' })
|
||||
: t(($) => $['common.loadMore'], { ns: 'workflow' })}
|
||||
</Button>
|
||||
<ComboboxInput
|
||||
ref={inputRef}
|
||||
aria-label={t(($) => $['appSelector.placeholder'], { ns: 'app' })}
|
||||
placeholder={t(($) => $['appSelector.placeholder'], { ns: 'app' })}
|
||||
className="block h-4.5 grow px-1 py-0 text-[13px] text-text-primary"
|
||||
/>
|
||||
{searchText && (
|
||||
<IconButton
|
||||
size="xs"
|
||||
aria-label={t(($) => $['operation.clear'], { ns: 'common' })}
|
||||
className="ml-1.5 size-3.5 shrink-0 rounded-none text-text-quaternary hover:bg-transparent hover:text-text-quaternary focus-visible:ring-1 focus-visible:ring-components-input-border-active"
|
||||
onClick={handleClearSearch}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
>
|
||||
<span
|
||||
className="i-custom-vender-solid-general-x-circle size-3.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</IconButton>
|
||||
)}
|
||||
</ComboboxInputGroup>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ComboboxContent>
|
||||
<ScrollArea className="relative min-h-0 flex-1 overflow-hidden">
|
||||
<ScrollAreaViewport
|
||||
role="region"
|
||||
aria-label={t(($) => $['appSelector.label'], { ns: 'app' })}
|
||||
style={{ overflowX: 'hidden' }}
|
||||
>
|
||||
<ScrollAreaContent className="p-1" style={{ minWidth: 0 }}>
|
||||
<ComboboxStatus>
|
||||
{isLoading ? t(($) => $.loading, { ns: 'common' }) : null}
|
||||
</ComboboxStatus>
|
||||
<ComboboxList<AppPartial> className="max-h-none overflow-visible p-0">
|
||||
{(app) => <AppPickerOption key={app.id} app={app} />}
|
||||
</ComboboxList>
|
||||
<ComboboxEmpty>
|
||||
{!isLoading ? t(($) => $.noData, { ns: 'common' }) : null}
|
||||
</ComboboxEmpty>
|
||||
{hasMore && (
|
||||
<div className="flex justify-center px-3 py-2">
|
||||
<Button size="small" disabled={isLoading} onClick={() => onLoadMore()}>
|
||||
{isLoading
|
||||
? t(($) => $.loading, { ns: 'common' })
|
||||
: t(($) => $['common.loadMore'], { ns: 'workflow' })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</ScrollAreaContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar>
|
||||
<ScrollAreaThumb />
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</ComboboxPopup>
|
||||
</ComboboxPositioner>
|
||||
</ComboboxPortal>
|
||||
</Combobox>
|
||||
)
|
||||
}
|
||||
|
||||
@ -130,7 +130,10 @@ export function AppSelector({
|
||||
<button
|
||||
{...props}
|
||||
type="button"
|
||||
className={cn('block w-full border-0 bg-transparent p-0 text-left', props.className)}
|
||||
className={cn(
|
||||
'block w-full rounded-lg border-0 bg-transparent p-0 text-left outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid',
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
<AppTrigger open={state.open} appDetail={currentAppInfo} />
|
||||
</button>
|
||||
|
||||
@ -177,7 +177,6 @@ const SnippetCard = ({
|
||||
<div className="flex w-0 grow items-center gap-1">
|
||||
<div className="mr-10.25 min-w-0 grow">
|
||||
<TagSelector
|
||||
placement="bottom-start"
|
||||
type="snippet"
|
||||
targetId={snippet.id}
|
||||
value={snippet.tags}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { AgentSelectorContent } from '../agent-selector'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@ -33,14 +34,20 @@ vi.mock('@/service/client', () => ({
|
||||
const manageInConsoleLabel = /manageInAgentConsole/
|
||||
const startFromScratchLabel = /startFromScratch/
|
||||
|
||||
const renderSelector = async ({ onStartFromScratch }: { onStartFromScratch?: () => void } = {}) => {
|
||||
const renderSelector = async ({
|
||||
onOpenChange = vi.fn(),
|
||||
onStartFromScratch,
|
||||
}: {
|
||||
onOpenChange?: (open: boolean) => void
|
||||
onStartFromScratch?: () => void
|
||||
} = {}) => {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AgentSelectorContent
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
onOpenChange={onOpenChange}
|
||||
onSelect={vi.fn()}
|
||||
onStartFromScratch={onStartFromScratch}
|
||||
/>
|
||||
@ -57,9 +64,39 @@ describe('AgentSelectorContent', () => {
|
||||
})
|
||||
|
||||
it('offers the Agent Console link with agent.manage', async () => {
|
||||
await renderSelector({ onStartFromScratch: vi.fn() })
|
||||
|
||||
const listbox = screen.getByRole('listbox')
|
||||
const startButton = screen.getByRole('button', { name: startFromScratchLabel })
|
||||
const manageLink = screen.getByRole('link', { name: manageInConsoleLabel })
|
||||
|
||||
expect(manageLink).toHaveAttribute('href', '/agents')
|
||||
expect(listbox).not.toContainElement(manageLink)
|
||||
expect(startButton).toHaveClass(
|
||||
'h-7',
|
||||
'rounded-md',
|
||||
'px-2',
|
||||
'py-1.5',
|
||||
'system-sm-regular',
|
||||
'text-text-secondary',
|
||||
)
|
||||
expect(manageLink).toHaveClass(
|
||||
'h-7',
|
||||
'rounded-md',
|
||||
'px-2',
|
||||
'py-1.5',
|
||||
'system-sm-regular',
|
||||
'text-text-secondary',
|
||||
)
|
||||
})
|
||||
|
||||
it('should keep the listbox as the only scroll owner for agent options', async () => {
|
||||
await renderSelector()
|
||||
|
||||
expect(screen.getByText(manageInConsoleLabel)).toBeInTheDocument()
|
||||
const listbox = screen.getByRole('listbox')
|
||||
|
||||
expect(listbox).toHaveClass('max-h-54', 'overflow-y-auto', 'outline-hidden')
|
||||
expect(listbox.querySelector('.overflow-y-auto')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the Agent Console link without agent.manage', async () => {
|
||||
@ -75,10 +112,45 @@ describe('AgentSelectorContent', () => {
|
||||
|
||||
await renderSelector({ onStartFromScratch: vi.fn() })
|
||||
|
||||
expect(screen.getByText(startFromScratchLabel)).toBeInTheDocument()
|
||||
const listbox = screen.getByRole('listbox')
|
||||
const startButton = screen.getByRole('button', { name: startFromScratchLabel })
|
||||
|
||||
expect(listbox).not.toContainElement(startButton)
|
||||
expect(screen.queryByText(manageInConsoleLabel)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should move focus from the combobox to actions outside the listbox', async () => {
|
||||
const user = userEvent.setup()
|
||||
await renderSelector({ onStartFromScratch: vi.fn() })
|
||||
|
||||
const input = screen.getByRole('combobox')
|
||||
const startButton = screen.getByRole('button', { name: startFromScratchLabel })
|
||||
const listbox = screen.getByRole('listbox')
|
||||
|
||||
expect(listbox).not.toContainElement(startButton)
|
||||
input.focus()
|
||||
await user.tab()
|
||||
|
||||
expect(startButton).toHaveFocus()
|
||||
})
|
||||
|
||||
it('does not dismiss the combobox when pressing a footer action', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onOpenChange = vi.fn()
|
||||
const onStartFromScratch = vi.fn()
|
||||
await renderSelector({ onOpenChange, onStartFromScratch })
|
||||
|
||||
const startButton = screen.getByRole('button', { name: startFromScratchLabel })
|
||||
|
||||
await user.pointer({ keys: '[MouseLeft>]', target: startButton })
|
||||
|
||||
expect(onOpenChange).not.toHaveBeenCalled()
|
||||
|
||||
await user.pointer({ keys: '[/MouseLeft]', target: startButton })
|
||||
|
||||
expect(onStartFromScratch).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('renders no action row when neither action is available', async () => {
|
||||
mocks.canManageAgents = false
|
||||
|
||||
|
||||
@ -450,14 +450,14 @@ describe('Blocks', () => {
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Agent/ }))
|
||||
const consoleLink = await screen.findByRole('option', {
|
||||
const consoleLink = await screen.findByRole('link', {
|
||||
name: 'agentV2.roster.nodeSelector.manageInAgentConsole',
|
||||
})
|
||||
expect(consoleLink).toHaveAttribute('href', '/agents')
|
||||
expect(consoleLink).toHaveAttribute('target', '_blank')
|
||||
expect(consoleLink).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
await user.click(
|
||||
await screen.findByRole('option', { name: 'agentV2.roster.nodeSelector.startFromScratch' }),
|
||||
await screen.findByRole('button', { name: 'agentV2.roster.nodeSelector.startFromScratch' }),
|
||||
)
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(BlockEnum.AgentV2, {
|
||||
|
||||
@ -2,6 +2,7 @@ import type { AgentInviteOptionResponse } from '@dify/contracts/api/console/agen
|
||||
import type { ComboboxChangeEventDetails } from '@langgenius/dify-ui/combobox'
|
||||
import type { NodeDefault } from '../types'
|
||||
import type { AgentRosterNodeData } from './types'
|
||||
import { Button, buttonVariants } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Combobox,
|
||||
@ -28,10 +29,6 @@ import BlockIcon from '../block-icon'
|
||||
|
||||
const AGENT_SELECTOR_PAGE_SIZE = 8
|
||||
|
||||
type AgentSelectorOption = AgentInviteOptionResponse | AgentSelectorActionOption
|
||||
|
||||
type AgentSelectorActionOption = 'start-from-scratch' | 'manage-in-agent-console'
|
||||
|
||||
export function AgentSelectorContent({
|
||||
open,
|
||||
onOpenChange,
|
||||
@ -62,55 +59,42 @@ export function AgentSelectorContent({
|
||||
})
|
||||
const agents = agentsQuery.data?.data ?? []
|
||||
const canManageAgents = useCanManageAgents()
|
||||
const actionOptions: AgentSelectorActionOption[] = [
|
||||
// Start from scratch stays available to everyone: it only writes the node's
|
||||
// own inline draft and never reaches the Agent Console.
|
||||
...(onStartFromScratch ? (['start-from-scratch'] as const) : []),
|
||||
...(canManageAgents ? (['manage-in-agent-console'] as const) : []),
|
||||
]
|
||||
const options: AgentSelectorOption[] = [...agents, ...actionOptions]
|
||||
const getOptionLabel = (option: AgentSelectorOption) => {
|
||||
if (isAgentSelectorActionOption(option)) {
|
||||
if (option === 'start-from-scratch')
|
||||
return t(($) => $['roster.nodeSelector.startFromScratch'], { ns: 'agentV2' })
|
||||
|
||||
return t(($) => $['roster.nodeSelector.manageInAgentConsole'], { ns: 'agentV2' })
|
||||
}
|
||||
|
||||
return option.name
|
||||
}
|
||||
const handleInputValueChange = (nextSearchText: string, details: ComboboxChangeEventDetails) => {
|
||||
if (details.reason !== 'item-press') setSearchText(nextSearchText)
|
||||
}
|
||||
const handleValueChange = (option: AgentSelectorOption | null) => {
|
||||
if (!option) return
|
||||
|
||||
if (isAgentSelectorActionOption(option)) {
|
||||
if (option === 'start-from-scratch') onStartFromScratch?.()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (!option.active_config_snapshot_id) {
|
||||
const handleValueChange = (agent: AgentInviteOptionResponse | null) => {
|
||||
if (!agent) return
|
||||
if (!agent.active_config_snapshot_id) {
|
||||
toast.error(t(($) => $['nodes.agent.modelNotSelected'], { ns: 'workflow' }))
|
||||
return
|
||||
}
|
||||
|
||||
onSelect(toAgentRosterNodeData(option))
|
||||
onSelect(toAgentRosterNodeData(agent))
|
||||
}
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (!nextOpen) onOpenChange(false)
|
||||
}
|
||||
const isLoading = agentsQuery.isPending
|
||||
const statusText = isLoading
|
||||
? t(($) => $.loading, { ns: 'common' })
|
||||
: agentsQuery.isError
|
||||
? t(($) => $['roster.loadingError'], { ns: 'agentV2' })
|
||||
: agents.length === 0
|
||||
? debouncedSearchText
|
||||
? t(($) => $['roster.emptySearch'], { ns: 'agentV2' })
|
||||
: t(($) => $['roster.empty'], { ns: 'agentV2' })
|
||||
: null
|
||||
const hasActions = !!onStartFromScratch || canManageAgents
|
||||
|
||||
return (
|
||||
<div className="w-60 overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm">
|
||||
<Combobox<AgentSelectorOption>
|
||||
<Combobox<AgentInviteOptionResponse>
|
||||
filter={null}
|
||||
inline
|
||||
inputValue={searchText}
|
||||
items={options}
|
||||
itemToStringLabel={getOptionLabel}
|
||||
itemToStringValue={getAgentSelectorOptionValue}
|
||||
items={agents}
|
||||
itemToStringLabel={(agent) => agent.name}
|
||||
itemToStringValue={(agent) => agent.id}
|
||||
open={open}
|
||||
value={null}
|
||||
onInputValueChange={handleInputValueChange}
|
||||
@ -130,84 +114,84 @@ export function AgentSelectorContent({
|
||||
/>
|
||||
</ComboboxInputGroup>
|
||||
</div>
|
||||
<ComboboxList className="max-h-none overflow-visible p-0">
|
||||
<div role="presentation" className="max-h-54 overflow-y-auto p-1">
|
||||
{isLoading && (
|
||||
<AgentSelectorLoadingSkeleton label={t(($) => $.loading, { ns: 'common' })} />
|
||||
<ComboboxStatus className="system-xs-regular">{statusText}</ComboboxStatus>
|
||||
{isLoading ? (
|
||||
<div className="max-h-54 overflow-hidden p-1">
|
||||
<AgentSelectorLoadingSkeleton />
|
||||
</div>
|
||||
) : (
|
||||
<ComboboxList className="max-h-54 p-1 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-state-accent-solid focus-visible:outline-solid">
|
||||
{!agentsQuery.isError &&
|
||||
agents.map((agent) => <AgentSelectorItem key={agent.id} agent={agent} />)}
|
||||
</ComboboxList>
|
||||
)}
|
||||
{hasActions && (
|
||||
<div className="border-t border-divider-subtle p-1">
|
||||
{onStartFromScratch && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="medium"
|
||||
className="h-7 w-full justify-start gap-2 rounded-md px-2 py-1.5 text-left system-sm-regular text-text-secondary"
|
||||
onClick={onStartFromScratch}
|
||||
>
|
||||
<span aria-hidden className="i-ri-add-line size-4 shrink-0 text-text-tertiary" />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{t(($) => $['roster.nodeSelector.startFromScratch'], { ns: 'agentV2' })}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
{!isLoading && agentsQuery.isError && (
|
||||
<ComboboxStatus className="px-3 py-2 system-xs-regular">
|
||||
{t(($) => $['roster.loadingError'], { ns: 'agentV2' })}
|
||||
</ComboboxStatus>
|
||||
)}
|
||||
{!isLoading && !agentsQuery.isError && (
|
||||
<>
|
||||
{agents.length === 0 && (
|
||||
<ComboboxStatus className="px-3 py-2 system-xs-regular">
|
||||
{debouncedSearchText
|
||||
? t(($) => $['roster.emptySearch'], { ns: 'agentV2' })
|
||||
: t(($) => $['roster.empty'], { ns: 'agentV2' })}
|
||||
</ComboboxStatus>
|
||||
{canManageAgents && (
|
||||
<Link
|
||||
href="/agents"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
buttonVariants({ variant: 'ghost', size: 'medium' }),
|
||||
'h-7 w-full justify-start gap-2 rounded-md px-2 py-1.5 text-left system-sm-regular text-text-secondary',
|
||||
)}
|
||||
{agents.map((agent) => (
|
||||
<AgentSelectorItem key={agent.id} agent={agent} />
|
||||
))}
|
||||
</>
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="i-ri-arrow-right-up-line size-4 shrink-0 text-text-tertiary"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{t(($) => $['roster.nodeSelector.manageInAgentConsole'], { ns: 'agentV2' })}
|
||||
</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
{actionOptions.length > 0 && (
|
||||
<div role="presentation" className="border-t border-divider-subtle p-1">
|
||||
{actionOptions.map((option) => (
|
||||
<AgentSelectorActionItem key={option} option={option} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ComboboxList>
|
||||
)}
|
||||
</Combobox>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentSelectorLoadingSkeleton({ label }: { label: string }) {
|
||||
function AgentSelectorLoadingSkeleton() {
|
||||
return (
|
||||
<ComboboxStatus className="p-0">
|
||||
<span className="sr-only">{label}</span>
|
||||
<div className="relative overflow-hidden" aria-hidden>
|
||||
<div className="p-1">
|
||||
{['skeleton-1', 'skeleton-2', 'skeleton-3', 'skeleton-4'].map((key, index) => (
|
||||
<div
|
||||
key={key}
|
||||
className={cn(
|
||||
'flex items-center gap-2 py-1.5 pr-3 pl-2 opacity-20',
|
||||
index === 3 && 'opacity-10',
|
||||
)}
|
||||
>
|
||||
<div className="size-8 shrink-0 rounded-full bg-text-quaternary" />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<div className="h-2 w-20 rounded-xs bg-text-quaternary" />
|
||||
<div className="h-2 w-28 rounded-xs bg-text-quaternary" />
|
||||
</div>
|
||||
<div className="relative overflow-hidden" aria-hidden>
|
||||
<div className="p-1">
|
||||
{['skeleton-1', 'skeleton-2', 'skeleton-3', 'skeleton-4'].map((key, index) => (
|
||||
<div
|
||||
key={key}
|
||||
className={cn(
|
||||
'flex items-center gap-2 py-1.5 pr-3 pl-2 opacity-20',
|
||||
index === 3 && 'opacity-10',
|
||||
)}
|
||||
>
|
||||
<div className="size-8 shrink-0 rounded-full bg-text-quaternary" />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<div className="h-2 w-20 rounded-xs bg-text-quaternary" />
|
||||
<div className="h-2 w-28 rounded-xs bg-text-quaternary" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="pointer-events-none absolute inset-0 bg-linear-to-b from-components-panel-bg-transparent to-background-default-subtle" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ComboboxStatus>
|
||||
<div className="pointer-events-none absolute inset-0 bg-linear-to-b from-components-panel-bg-transparent to-background-default-subtle" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getAgentSelectorOptionValue(option: AgentSelectorOption) {
|
||||
if (isAgentSelectorActionOption(option)) return option
|
||||
|
||||
return option.id
|
||||
}
|
||||
|
||||
function isAgentSelectorActionOption(
|
||||
option: AgentSelectorOption,
|
||||
): option is AgentSelectorActionOption {
|
||||
return typeof option === 'string'
|
||||
}
|
||||
|
||||
function toAgentRosterNodeData(agent: AgentInviteOptionResponse): AgentRosterNodeData {
|
||||
return {
|
||||
description: agent.description,
|
||||
@ -250,38 +234,6 @@ function AgentSelectorItem({ agent }: { agent: AgentInviteOptionResponse }) {
|
||||
)
|
||||
}
|
||||
|
||||
function AgentSelectorActionItem({ option }: { option: AgentSelectorActionOption }) {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const isStartFromScratch = option === 'start-from-scratch'
|
||||
|
||||
return (
|
||||
<ComboboxItem
|
||||
value={option}
|
||||
render={
|
||||
isStartFromScratch ? undefined : (
|
||||
<Link href="/agents" target="_blank" rel="noopener noreferrer" />
|
||||
)
|
||||
}
|
||||
className="flex min-h-7 w-full grid-cols-none items-center gap-2 rounded-md px-2 py-1.5 text-left system-sm-regular text-text-secondary hover:bg-state-base-hover hover:text-text-secondary focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid focus-visible:outline-hidden data-highlighted:bg-state-base-hover data-highlighted:text-text-secondary"
|
||||
>
|
||||
<ComboboxItemText className="flex items-center gap-2 px-0 system-sm-regular text-text-secondary">
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'size-4 shrink-0 text-text-tertiary',
|
||||
isStartFromScratch ? 'i-ri-add-line' : 'i-ri-arrow-right-up-line',
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{isStartFromScratch
|
||||
? t(($) => $['roster.nodeSelector.startFromScratch'])
|
||||
: t(($) => $['roster.nodeSelector.manageInAgentConsole'])}
|
||||
</span>
|
||||
</ComboboxItemText>
|
||||
</ComboboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
export function AgentBlockItem({
|
||||
block,
|
||||
onSelect,
|
||||
@ -303,9 +255,10 @@ export function AgentBlockItem({
|
||||
<PopoverTrigger
|
||||
openOnHover
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-full cursor-pointer items-center rounded-lg px-3 text-left hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid focus-visible:outline-hidden data-popup-open:bg-state-base-hover"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="medium"
|
||||
className="w-full justify-start gap-0 px-3 text-left data-popup-open:bg-state-base-hover"
|
||||
>
|
||||
<BlockIcon className="mr-2 shrink-0" type={block.metaData.type} />
|
||||
<span className="min-w-0 grow truncate system-sm-medium text-text-secondary">
|
||||
@ -321,7 +274,7 @@ export function AgentBlockItem({
|
||||
aria-hidden
|
||||
className="i-custom-vender-solid-general-arrow-down-round-fill size-4 shrink-0 -rotate-90 text-text-tertiary"
|
||||
/>
|
||||
</button>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent
|
||||
|
||||
@ -110,4 +110,48 @@ describe('AgentLogSourcePicker', () => {
|
||||
expect(workflowOption).toHaveAttribute('data-selected')
|
||||
expect(workflowOption.querySelector('.i-ri-check-line')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show one named popup state and keep retry outside the listbox', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onRetry = vi.fn()
|
||||
|
||||
render(
|
||||
<AgentLogSourcePicker
|
||||
value={[]}
|
||||
groups={[]}
|
||||
isLoading={false}
|
||||
isError
|
||||
onRetry={onRetry}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('combobox', {
|
||||
name: 'agentV2.agentDetail.logs.filters.source.label',
|
||||
}),
|
||||
)
|
||||
|
||||
const searchInput = screen.getByRole('combobox', {
|
||||
name: 'agentV2.agentDetail.logs.filters.source.searchLabel',
|
||||
})
|
||||
const retryButton = screen.getByRole('button', { name: 'common.operation.retry' })
|
||||
|
||||
expect(
|
||||
screen.getByRole('dialog', {
|
||||
name: 'agentV2.agentDetail.logs.filters.source.label',
|
||||
}),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByText('agentV2.agentDetail.logs.filters.source.empty'),
|
||||
).not.toBeInTheDocument()
|
||||
searchInput.focus()
|
||||
expect(searchInput).toHaveFocus()
|
||||
|
||||
await user.tab()
|
||||
expect(retryButton).toHaveFocus()
|
||||
await user.click(retryButton)
|
||||
expect(onRetry).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@ -3,13 +3,11 @@ import type {
|
||||
AgentLogSourceResponse,
|
||||
} from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { TFunction } from 'i18next'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxCollection,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxGroup,
|
||||
ComboboxGroupLabel,
|
||||
@ -18,6 +16,10 @@ import {
|
||||
ComboboxItem,
|
||||
ComboboxItemText,
|
||||
ComboboxList,
|
||||
ComboboxPopup,
|
||||
ComboboxPortal,
|
||||
ComboboxPositioner,
|
||||
ComboboxStatus,
|
||||
ComboboxTrigger,
|
||||
ComboboxValue,
|
||||
} from '@langgenius/dify-ui/combobox'
|
||||
@ -91,80 +93,84 @@ export function AgentLogSourcePicker({
|
||||
}}
|
||||
</ComboboxValue>
|
||||
</ComboboxTrigger>
|
||||
<ComboboxContent popupClassName="w-80 p-0">
|
||||
<div className="p-2 pb-1">
|
||||
<ComboboxInputGroup className="h-8 min-h-8 px-2">
|
||||
<span
|
||||
aria-hidden
|
||||
className="mr-0.5 i-ri-search-line size-4 shrink-0 text-components-input-text-placeholder"
|
||||
/>
|
||||
<ComboboxInput
|
||||
aria-label={t(($) => $['agentDetail.logs.filters.source.searchLabel'])}
|
||||
placeholder={t(($) => $['agentDetail.logs.filters.source.searchPlaceholder'])}
|
||||
className="block h-4.5 grow px-1 py-0 system-sm-regular text-components-input-text-filled"
|
||||
/>
|
||||
</ComboboxInputGroup>
|
||||
</div>
|
||||
{isLoading && (
|
||||
<SourcePickerStatus>
|
||||
{t(($) => $['agentDetail.logs.filters.source.loading'])}
|
||||
</SourcePickerStatus>
|
||||
)}
|
||||
{isError && (
|
||||
<SourcePickerStatus className="flex items-center justify-center gap-2">
|
||||
<span>{t(($) => $['agentDetail.logs.filters.source.loadFailed'])}</span>
|
||||
<Button variant="secondary" size="small" onClick={onRetry}>
|
||||
{t(($) => $['operation.retry'], { ns: 'common' })}
|
||||
</Button>
|
||||
</SourcePickerStatus>
|
||||
)}
|
||||
{!isLoading && !isError && (
|
||||
<>
|
||||
<ComboboxList<AgentLogSourceComboboxGroup> className="max-h-69 p-2 pt-1">
|
||||
{(group) => (
|
||||
<ComboboxGroup key={group.type} items={group.items}>
|
||||
<ComboboxGroupLabel className="px-1 pt-2 pb-1">
|
||||
{getSourceGroupLabel(group, t)}
|
||||
</ComboboxGroupLabel>
|
||||
<ComboboxCollection<AgentLogSourceResponse>>
|
||||
{(source) => (
|
||||
<ComboboxItem
|
||||
key={source.id}
|
||||
value={source}
|
||||
className="min-h-7 grid-cols-[1fr] gap-0 px-1 py-1"
|
||||
render={(props, state) => (
|
||||
<div {...props} className={props.className}>
|
||||
<ComboboxItemText className="flex min-w-0 items-center gap-2 px-0 system-sm-regular">
|
||||
<SourceCheckbox checked={state.selected} />
|
||||
<LogSourceIcon source={source} />
|
||||
<span className="min-w-0 flex-1 truncate">{source.app_name}</span>
|
||||
</ComboboxItemText>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</ComboboxCollection>
|
||||
</ComboboxGroup>
|
||||
<ComboboxPortal>
|
||||
<ComboboxPositioner>
|
||||
<ComboboxPopup
|
||||
aria-label={t(($) => $['agentDetail.logs.filters.source.label'])}
|
||||
className="w-80 p-0"
|
||||
>
|
||||
<div className="p-2 pb-1">
|
||||
<ComboboxInputGroup className="h-8 min-h-8 px-2">
|
||||
<span
|
||||
aria-hidden
|
||||
className="mr-0.5 i-ri-search-line size-4 shrink-0 text-components-input-text-placeholder"
|
||||
/>
|
||||
<ComboboxInput
|
||||
aria-label={t(($) => $['agentDetail.logs.filters.source.searchLabel'])}
|
||||
placeholder={t(($) => $['agentDetail.logs.filters.source.searchPlaceholder'])}
|
||||
className="block h-4.5 grow px-1 py-0 system-sm-regular text-components-input-text-filled"
|
||||
/>
|
||||
</ComboboxInputGroup>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
isLoading || isError
|
||||
? 'flex items-center justify-center gap-2 px-3 py-3 text-center system-xs-regular text-text-tertiary'
|
||||
: 'h-0',
|
||||
)}
|
||||
</ComboboxList>
|
||||
>
|
||||
<ComboboxStatus className="p-0 system-xs-regular">
|
||||
{isLoading
|
||||
? t(($) => $['agentDetail.logs.filters.source.loading'])
|
||||
: isError
|
||||
? t(($) => $['agentDetail.logs.filters.source.loadFailed'])
|
||||
: null}
|
||||
</ComboboxStatus>
|
||||
{isError && (
|
||||
<Button variant="secondary" size="small" onClick={onRetry}>
|
||||
{t(($) => $['operation.retry'], { ns: 'common' })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{!isLoading && !isError && (
|
||||
<ComboboxList<AgentLogSourceComboboxGroup> className="max-h-69 p-2 pt-1">
|
||||
{(group) => (
|
||||
<ComboboxGroup key={group.type} items={group.items}>
|
||||
<ComboboxGroupLabel className="px-1 pt-2 pb-1">
|
||||
{getSourceGroupLabel(group, t)}
|
||||
</ComboboxGroupLabel>
|
||||
<ComboboxCollection<AgentLogSourceResponse>>
|
||||
{(source) => (
|
||||
<ComboboxItem
|
||||
key={source.id}
|
||||
value={source}
|
||||
className="min-h-7 grid-cols-[1fr] gap-0 px-1 py-1"
|
||||
render={(props, state) => (
|
||||
<div {...props} className={props.className}>
|
||||
<ComboboxItemText className="flex min-w-0 items-center gap-2 px-0 system-sm-regular">
|
||||
<SourceCheckbox checked={state.selected} />
|
||||
<LogSourceIcon source={source} />
|
||||
<span className="min-w-0 flex-1 truncate">{source.app_name}</span>
|
||||
</ComboboxItemText>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</ComboboxCollection>
|
||||
</ComboboxGroup>
|
||||
)}
|
||||
</ComboboxList>
|
||||
)}
|
||||
<ComboboxEmpty className="px-3 py-3 text-center system-xs-regular">
|
||||
{t(($) => $['agentDetail.logs.filters.source.empty'])}
|
||||
{!isLoading && !isError ? t(($) => $['agentDetail.logs.filters.source.empty']) : null}
|
||||
</ComboboxEmpty>
|
||||
</>
|
||||
)}
|
||||
</ComboboxContent>
|
||||
</ComboboxPopup>
|
||||
</ComboboxPositioner>
|
||||
</ComboboxPortal>
|
||||
</Combobox>
|
||||
)
|
||||
}
|
||||
|
||||
function SourcePickerStatus({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={cn('px-3 py-3 text-center system-xs-regular text-text-tertiary', className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SourceCheckbox({ checked }: { checked: boolean }) {
|
||||
return (
|
||||
<span
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { TagResponse as Tag } from '@dify/contracts/api/console/tags/types.gen'
|
||||
import { screen } from '@testing-library/react'
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { render } from '@/test/console/render'
|
||||
import { TagFilter } from '../components/tag-filter'
|
||||
@ -213,6 +213,24 @@ describe('TagFilter', () => {
|
||||
expect(onOpenTagManagement).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should expose popup actions after the combobox in keyboard order', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TagFilter {...defaultProps} />)
|
||||
|
||||
await user.click(screen.getByText(i18n.placeholder))
|
||||
|
||||
const input = screen.getByRole('combobox', { name: i18n.selectorPlaceholder })
|
||||
const manageButton = screen.getByRole('button', { name: i18n.manageTags })
|
||||
const listbox = screen.getByRole('listbox')
|
||||
|
||||
expect(screen.getByRole('dialog', { name: i18n.placeholder })).toBeInTheDocument()
|
||||
expect(listbox).not.toContainElement(manageButton)
|
||||
await waitFor(() => expect(input).toHaveFocus())
|
||||
|
||||
await user.tab()
|
||||
expect(manageButton).toHaveFocus()
|
||||
})
|
||||
|
||||
it('should hide tag management action without tag management permission', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockWorkspacePermissionKeys.value = []
|
||||
@ -282,9 +300,11 @@ describe('TagFilter', () => {
|
||||
expect(screen.queryByText('Backend')).not.toBeInTheDocument()
|
||||
|
||||
const clearButton = screen.getByRole('button', { name: i18n.operationClear })
|
||||
await user.click(clearButton)
|
||||
clearButton.focus()
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
expect(searchInput).toHaveValue('')
|
||||
expect(searchInput).toHaveFocus()
|
||||
|
||||
expect(screen.getByText('Backend')).toBeInTheDocument()
|
||||
expect(screen.getByText('Frontend')).toBeInTheDocument()
|
||||
|
||||
@ -144,9 +144,13 @@ describe('TagSearchContent', () => {
|
||||
expect(input).toHaveValue('Back')
|
||||
vi.clearAllMocks()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: i18n.operationClear }))
|
||||
const clearButton = screen.getByRole('button', { name: i18n.operationClear })
|
||||
await user.pointer({ keys: '[MouseLeft>]', target: clearButton })
|
||||
expect(input).toHaveFocus()
|
||||
await user.pointer({ keys: '[/MouseLeft]', target: clearButton })
|
||||
|
||||
expect(input).toHaveValue('')
|
||||
expect(input).toHaveFocus()
|
||||
expect(onValueChangeSpy).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('option', { name: /Frontend/i })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
|
||||
@ -18,7 +18,6 @@ export const AppCardTags = ({
|
||||
}: AppCardTagsProps) => {
|
||||
return (
|
||||
<TagSelector
|
||||
placement="bottom-start"
|
||||
type="app"
|
||||
targetId={appId}
|
||||
value={tags}
|
||||
|
||||
@ -20,7 +20,6 @@ export const DatasetCardTags = ({
|
||||
canBindOrUnbindTags,
|
||||
}: DatasetCardTagsProps) => (
|
||||
<TagSelector
|
||||
placement="bottom-start"
|
||||
type="knowledge"
|
||||
targetId={datasetId}
|
||||
value={tags}
|
||||
|
||||
@ -1,7 +1,14 @@
|
||||
import type { TagResponse as Tag, TagType } from '@dify/contracts/api/console/tags/types.gen'
|
||||
import type { ComboboxProps } from '@langgenius/dify-ui/combobox'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Combobox, ComboboxContent, ComboboxTrigger } from '@langgenius/dify-ui/combobox'
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxPopup,
|
||||
ComboboxPortal,
|
||||
ComboboxPositioner,
|
||||
ComboboxTrigger,
|
||||
} 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 { useTranslation } from 'react-i18next'
|
||||
@ -115,10 +122,10 @@ export const TagFilter = ({
|
||||
</span>
|
||||
</ComboboxTrigger>
|
||||
{!!value.length && (
|
||||
<button
|
||||
type="button"
|
||||
<IconButton
|
||||
size="xs"
|
||||
aria-label={t(($) => $['operation.clear'], { ns: 'common' })}
|
||||
className="group/clear absolute top-1/2 right-2 -translate-y-1/2 rounded-md border-none bg-transparent p-px focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
|
||||
className="group/clear absolute top-1/2 right-2 -translate-y-1/2"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onChange([])
|
||||
@ -128,21 +135,24 @@ export const TagFilter = ({
|
||||
className="size-3.5 text-text-tertiary group-hover/clear:text-text-secondary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</IconButton>
|
||||
)}
|
||||
<ComboboxContent
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
popupClassName="w-[240px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-0 shadow-lg backdrop-blur-[5px]"
|
||||
>
|
||||
<TagSearchContent
|
||||
type={type}
|
||||
inputValue={inputValue}
|
||||
onInputValueChange={setInputValue}
|
||||
onOpenTagManagement={onOpenTagManagement}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
</ComboboxContent>
|
||||
<ComboboxPortal>
|
||||
<ComboboxPositioner placement="bottom-start" sideOffset={4}>
|
||||
<ComboboxPopup
|
||||
aria-label={triggerLabel}
|
||||
className="w-60 rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-0 shadow-lg backdrop-blur-[5px]"
|
||||
>
|
||||
<TagSearchContent
|
||||
type={type}
|
||||
inputValue={inputValue}
|
||||
onInputValueChange={setInputValue}
|
||||
onOpenTagManagement={onOpenTagManagement}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
</ComboboxPopup>
|
||||
</ComboboxPositioner>
|
||||
</ComboboxPortal>
|
||||
</div>
|
||||
</Combobox>
|
||||
)
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { TagType } from '@dify/contracts/api/console/tags/types.gen'
|
||||
import type { TagComboboxItem } from './tag-combobox-item'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import {
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
@ -11,8 +12,9 @@ import {
|
||||
ComboboxSeparator,
|
||||
useComboboxFilteredItems,
|
||||
} from '@langgenius/dify-ui/combobox'
|
||||
import { IconButton } from '@langgenius/dify-ui/icon-button'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { Fragment } from 'react'
|
||||
import { Fragment, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { workspacePermissionKeysAtom } from '@/context/permission-state'
|
||||
import { hasPermission } from '@/utils/permission'
|
||||
@ -37,12 +39,18 @@ export const TagSearchContent = ({
|
||||
canBindOrUnbindTags = false,
|
||||
}: TagSearchContentProps) => {
|
||||
const { t } = useTranslation()
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
|
||||
const canManageTags = hasPermission(workspacePermissionKeys, getTagManagePermissionKey(type))
|
||||
const filteredItems = useComboboxFilteredItems<TagComboboxItem>()
|
||||
const realItemCount = filteredItems.filter((tag) => !isCreateTagOption(tag)).length
|
||||
const placeholder = t(($) => $['tag.selectorPlaceholder'], { ns: 'common' }) || ''
|
||||
|
||||
const handleClearInput = () => {
|
||||
onInputValueChange('')
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<div className="p-2 pb-1">
|
||||
@ -52,21 +60,22 @@ export const TagSearchContent = ({
|
||||
className="ml-2 i-ri-search-line size-4 shrink-0 text-text-tertiary"
|
||||
/>
|
||||
<ComboboxInput
|
||||
ref={inputRef}
|
||||
aria-label={placeholder}
|
||||
name={`tag-search-${type}`}
|
||||
placeholder={placeholder}
|
||||
className="pl-2"
|
||||
/>
|
||||
{inputValue && (
|
||||
<button
|
||||
type="button"
|
||||
<IconButton
|
||||
size="sm"
|
||||
aria-label={t(($) => $['operation.clear'], { ns: 'common' })}
|
||||
className="mr-1.5 flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-md text-text-tertiary outline-hidden hover:bg-components-input-bg-hover hover:text-text-secondary focus-visible:bg-components-input-bg-hover focus-visible:text-text-secondary focus-visible:inset-ring-1 focus-visible:inset-ring-components-input-border-active"
|
||||
onClick={() => onInputValueChange('')}
|
||||
onPointerDown={(event) => event.preventDefault()}
|
||||
className="mr-1.5 shrink-0 hover:bg-components-input-bg-hover focus-visible:bg-components-input-bg-hover"
|
||||
onClick={handleClearInput}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
>
|
||||
<span className="i-ri-close-line size-4" aria-hidden="true" />
|
||||
</button>
|
||||
</IconButton>
|
||||
)}
|
||||
</ComboboxInputGroup>
|
||||
</div>
|
||||
@ -119,9 +128,10 @@ export const TagSearchContent = ({
|
||||
<>
|
||||
<ComboboxSeparator />
|
||||
<div className="p-1">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full cursor-pointer touch-manipulation items-center gap-x-1 rounded-lg px-2 py-1.5 text-left outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="medium"
|
||||
className="w-full justify-start gap-x-1 px-2 py-1.5 text-left"
|
||||
onClick={() => {
|
||||
onOpenTagManagement?.()
|
||||
onClose?.()
|
||||
@ -134,7 +144,7 @@ export const TagSearchContent = ({
|
||||
<span className="min-w-0 grow truncate px-1 system-md-regular text-text-secondary">
|
||||
{t(($) => $['tag.manageTags'], { ns: 'common' })}
|
||||
</span>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
import type { TagResponse as Tag, TagType } from '@dify/contracts/api/console/tags/types.gen'
|
||||
import type {
|
||||
ComboboxContentProps,
|
||||
ComboboxProps,
|
||||
ComboboxTriggerProps,
|
||||
} from '@langgenius/dify-ui/combobox'
|
||||
import type { ComboboxProps, ComboboxTriggerProps } from '@langgenius/dify-ui/combobox'
|
||||
import type { TagComboboxItem } from './tag-combobox-item'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Combobox, ComboboxContent, ComboboxTrigger } from '@langgenius/dify-ui/combobox'
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxPopup,
|
||||
ComboboxPortal,
|
||||
ComboboxPositioner,
|
||||
ComboboxTrigger,
|
||||
} from '@langgenius/dify-ui/combobox'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
@ -47,18 +49,7 @@ type TagSelectorRootProps = Omit<
|
||||
| 'onOpenChangeComplete'
|
||||
| 'children'
|
||||
>
|
||||
type TagSelectorContentProps = Pick<
|
||||
ComboboxContentProps,
|
||||
| 'placement'
|
||||
| 'sideOffset'
|
||||
| 'alignOffset'
|
||||
| 'portalProps'
|
||||
| 'positionerProps'
|
||||
| 'popupProps'
|
||||
| 'popupClassName'
|
||||
>
|
||||
export type TagSelectorProps = TagSelectorRootProps &
|
||||
TagSelectorContentProps &
|
||||
Pick<ComboboxTriggerProps, 'className' | 'onClick'> & {
|
||||
targetId: string
|
||||
type: TagType
|
||||
@ -77,13 +68,6 @@ export const TagSelector = ({
|
||||
onClick,
|
||||
onOpenTagManagement = () => {},
|
||||
onTagsChange,
|
||||
placement = 'bottom-start',
|
||||
sideOffset = 4,
|
||||
alignOffset = 0,
|
||||
portalProps,
|
||||
positionerProps,
|
||||
popupProps,
|
||||
popupClassName,
|
||||
...rootProps
|
||||
}: TagSelectorProps) => {
|
||||
const { t } = useTranslation()
|
||||
@ -274,27 +258,23 @@ export const TagSelector = ({
|
||||
className="pointer-events-none absolute top-0 right-0 h-full w-20 bg-tag-selector-mask-bg group-hover/tag-area:hidden group-focus-visible/tag-area:hidden group-data-popup-open/tag-area:hidden"
|
||||
/>
|
||||
</ComboboxTrigger>
|
||||
<ComboboxContent
|
||||
placement={placement}
|
||||
sideOffset={sideOffset}
|
||||
alignOffset={alignOffset}
|
||||
portalProps={portalProps}
|
||||
positionerProps={positionerProps}
|
||||
popupProps={popupProps}
|
||||
popupClassName={cn(
|
||||
'w-(--anchor-width) min-w-60 rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-0 shadow-lg backdrop-blur-[5px]',
|
||||
popupClassName,
|
||||
)}
|
||||
>
|
||||
<TagSearchContent
|
||||
type={type}
|
||||
inputValue={inputValue}
|
||||
onInputValueChange={setInputValue}
|
||||
canBindOrUnbindTags={canBindOrUnbindTags}
|
||||
onOpenTagManagement={onOpenTagManagement}
|
||||
onClose={() => handleOpenChange(false)}
|
||||
/>
|
||||
</ComboboxContent>
|
||||
<ComboboxPortal>
|
||||
<ComboboxPositioner placement="bottom-start" sideOffset={4}>
|
||||
<ComboboxPopup
|
||||
aria-label={triggerLabel}
|
||||
className="w-(--anchor-width) min-w-60 rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-0 shadow-lg backdrop-blur-[5px]"
|
||||
>
|
||||
<TagSearchContent
|
||||
type={type}
|
||||
inputValue={inputValue}
|
||||
onInputValueChange={setInputValue}
|
||||
canBindOrUnbindTags={canBindOrUnbindTags}
|
||||
onOpenTagManagement={onOpenTagManagement}
|
||||
onClose={() => handleOpenChange(false)}
|
||||
/>
|
||||
</ComboboxPopup>
|
||||
</ComboboxPositioner>
|
||||
</ComboboxPortal>
|
||||
</Combobox>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user