fix(web): align combobox interaction semantics (#40826)

This commit is contained in:
yyh
2026-08-17 02:13:08 +00:00
committed by GitHub
parent 141cbf9832
commit facb60abfe
39 changed files with 1914 additions and 1454 deletions
@@ -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 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) return
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>
)
}