refactor(web): migrate tag controls to combobox (#35881)

This commit is contained in:
yyh 2026-05-07 16:55:13 +08:00 committed by GitHub
parent 8b77ec7f31
commit cd66559ebf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 670 additions and 1280 deletions

View File

@ -4519,9 +4519,6 @@
}
},
"web/app/components/workflow/nodes/tool/components/tool-form/item.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}

View File

@ -53,6 +53,16 @@ vi.mock('@/next/navigation', () => ({
}),
}))
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
useQuery: () => ({
data: [],
}),
}
})
// Mock headless UI Popover so it renders content without transition
vi.mock('@headlessui/react', async () => {
const actual = await vi.importActual<typeof import('@headlessui/react')>('@headlessui/react')

View File

@ -9,7 +9,7 @@ import type { ReactElement, ReactNode } from 'react'
*/
import type { AppListResponse } from '@/models/app'
import type { App } from '@/types/app'
import { fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features'
import List from '@/app/components/apps/list'
@ -92,6 +92,9 @@ vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
useQuery: () => ({
data: [],
}),
useInfiniteQuery: () => ({
data: { pages: mockPages },
isLoading: mockIsLoading,
@ -360,13 +363,18 @@ describe('App List Browsing Flow', () => {
expect(input).toBeInTheDocument()
})
it('should allow typing in search input', () => {
it('should update search query when typing in search input', async () => {
mockPages = [createPage([createMockApp()])]
renderList()
const { onUrlUpdate } = renderList()
const input = document.querySelector('input')!
const input = screen.getByPlaceholderText('common.operation.search')
fireEvent.change(input, { target: { value: 'test search' } })
expect(input.value).toBe('test search')
await waitFor(() => {
expect(onUrlUpdate).toHaveBeenCalled()
})
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1]![0]
expect(lastCall.searchParams.get('keywords')).toBe('test search')
})
})

View File

@ -486,6 +486,15 @@ describe('AppCard', () => {
expect(screen.getByTestId('dropdown-menu')).toHaveAttribute('data-modal', 'false')
})
it('should reveal operations trigger when card receives keyboard focus', () => {
render(<AppCard app={mockApp} />)
const operationsTriggerWrapper = screen.getByTestId('dropdown-menu-trigger').closest('.absolute')
expect(operationsTriggerWrapper).toHaveClass('group-focus-within:pointer-events-auto')
expect(operationsTriggerWrapper).toHaveClass('group-focus-within:opacity-100')
expect(screen.getByTestId('dropdown-menu-trigger')).toHaveClass('focus-visible:ring-1')
})
it('should show edit option when dropdown menu is opened', async () => {
render(<AppCard app={mockApp} />)

View File

@ -425,7 +425,7 @@ const AppCard = ({ app, onlineUsers = [], onRefresh, onOpenTagManagement = () =>
e.preventDefault()
getRedirection(isCurrentWorkspaceEditor, app, push)
}}
className="group relative col-span-1 inline-flex h-[160px] cursor-pointer flex-col rounded-xl border border-solid border-components-card-border bg-components-card-bg shadow-sm transition-all duration-200 ease-in-out hover:shadow-lg"
className="group relative col-span-1 inline-flex h-[160px] cursor-pointer flex-col rounded-xl border border-solid border-components-card-border bg-components-card-bg shadow-sm transition-shadow duration-200 ease-in-out hover:shadow-lg"
>
<div className="flex h-[66px] shrink-0 grow-0 items-center gap-3 px-[14px] pt-[14px] pb-3">
<div className="relative shrink-0">
@ -524,7 +524,7 @@ const AppCard = ({ app, onlineUsers = [], onRefresh, onOpenTagManagement = () =>
'absolute top-1/2 right-[6px] flex -translate-y-1/2 items-center transition-opacity',
isOperationsMenuOpen
? 'pointer-events-auto opacity-100'
: 'pointer-events-none opacity-0 group-hover:pointer-events-auto group-hover:opacity-100',
: 'pointer-events-none opacity-0 group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100',
)}
>
<div className="mx-1 h-[14px] w-px shrink-0 bg-divider-regular" />
@ -533,7 +533,7 @@ const AppCard = ({ app, onlineUsers = [], onRefresh, onOpenTagManagement = () =>
aria-label={t('operation.more', { ns: 'common' })}
className={cn(
isOperationsMenuOpen ? 'bg-state-base-hover shadow-none' : 'bg-transparent',
'flex h-8 w-8 items-center justify-center rounded-md border-none p-2 hover:bg-state-base-hover',
'flex h-8 w-8 items-center justify-center rounded-md border-none p-2 hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:ring-inset',
)}
onClick={(e) => {
e.stopPropagation()

View File

@ -171,7 +171,7 @@ describe('tool/tool-form/item', () => {
} as unknown as SchemaRoot,
})
const { container } = render(
render(
<ToolFormItem
readOnly={false}
nodeId="tool-node"
@ -182,7 +182,8 @@ describe('tool/tool-form/item', () => {
/>,
)
fireEvent.mouseEnter(container.querySelector('svg')?.parentElement as HTMLElement)
const infotipTrigger = screen.getByRole('button', { name: 'Select from tools' })
fireEvent.click(infotipTrigger)
expect(screen.getByText('Select from tools'))!.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'JSON Schema' }))

View File

@ -9,7 +9,7 @@ import {
RiBracesLine,
} from '@remixicon/react'
import { useBoolean } from 'ahooks'
import Tooltip from '@/app/components/base/tooltip'
import { Infotip } from '@/app/components/base/infotip'
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { SchemaModal } from '@/app/components/plugins/plugin-detail-panel/tool-selector/components'
@ -100,15 +100,13 @@ const ToolFormItem: FC<Props> = ({
<div className="ml-1 system-xs-regular text-text-destructive-secondary">*</div>
)}
{!showDescription && tooltip && (
<Tooltip
popupContent={(
<div className="w-[200px]">
{tooltip[language] || tooltip.en_US}
</div>
)}
triggerClassName="ml-1 w-4 h-4"
asChild={false}
/>
<Infotip
aria-label={tooltip[language] || tooltip.en_US}
className="ml-1"
popupClassName="w-[200px]"
>
{tooltip[language] || tooltip.en_US}
</Infotip>
)}
{showSchemaButton && (
<>

View File

@ -3,17 +3,15 @@ import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { DatasetCardTags } from '../components/dataset-card-tags'
// Mock TagSelector as it's a complex component from base
vi.mock('@/features/tag-management/components/tag-selector', () => ({
TagSelector: ({ selectedTagIds, selectedTags, onOpenTagManagement }: {
selectedTagIds: string[]
selectedTags: Tag[]
TagSelector: ({ value, onOpenTagManagement }: {
value: Tag[]
onOpenTagManagement?: () => void
}) => (
<div data-testid="tag-selector">
<div data-testid="tag-values">{selectedTagIds.join(',')}</div>
<div data-testid="tag-values">{value.map(tag => tag.id).join(',')}</div>
<div data-testid="selected-count">
{selectedTags.length}
{value.length}
{' '}
tags
</div>
@ -75,7 +73,9 @@ describe('DatasetCardTags', () => {
const onClick = vi.fn()
const { container } = render(<DatasetCardTags {...defaultProps} onClick={onClick} />)
const wrapper = container.firstChild as HTMLElement
const wrapper = container.firstElementChild
if (!wrapper)
throw new Error('Expected dataset card tag wrapper')
fireEvent.click(wrapper)
expect(onClick).toHaveBeenCalledTimes(1)
@ -94,13 +94,17 @@ describe('DatasetCardTags', () => {
describe('Styles', () => {
it('should have opacity class when embedding is not available', () => {
const { container } = render(<DatasetCardTags {...defaultProps} embeddingAvailable={false} />)
const wrapper = container.firstChild as HTMLElement
const wrapper = container.firstElementChild
if (!wrapper)
throw new Error('Expected dataset card tag wrapper')
expect(wrapper).toHaveClass('opacity-30')
})
it('should not have opacity class when embedding is available', () => {
const { container } = render(<DatasetCardTags {...defaultProps} embeddingAvailable={true} />)
const wrapper = container.firstChild as HTMLElement
const wrapper = container.firstElementChild
if (!wrapper)
throw new Error('Expected dataset card tag wrapper')
expect(wrapper).not.toHaveClass('opacity-30')
})
@ -109,6 +113,7 @@ describe('DatasetCardTags', () => {
const maskDiv = container.querySelector('.bg-tag-selector-mask-bg')
expect(maskDiv).toBeInTheDocument()
expect(maskDiv).toHaveClass('group-hover/tag-area:hidden')
expect(maskDiv).toHaveClass('group-focus-within/tag-area:hidden')
expect(maskDiv).toHaveClass('group-hover:bg-tag-selector-mask-hover-bg')
})
@ -139,10 +144,10 @@ describe('DatasetCardTags', () => {
})
it('should handle many tags', () => {
const manyTags: Tag[] = Array.from({ length: 20 }, (_, i) => ({
const manyTags: Tag[] = Array.from({ length: 20 }, (_, i): Tag => ({
id: `tag-${i}`,
name: `Tag ${i}`,
type: 'knowledge' as const,
type: 'knowledge',
binding_count: 0,
}))
render(<DatasetCardTags {...defaultProps} tags={manyTags} />)

View File

@ -27,6 +27,8 @@ const defaultProps = {
// Helper: the i18n mock renders "ns.key" format (dot-separated)
const i18n = {
placeholder: 'common.tag.placeholder',
selectorPlaceholder: 'common.tag.selectorPlaceholder',
operationClear: 'common.operation.clear',
noTag: 'common.tag.noTag',
manageTags: 'common.tag.manageTags',
}
@ -158,11 +160,9 @@ describe('TagFilter', () => {
await user.click(screen.getByText('Frontend'))
// The Check icon should be rendered for the selected tag
const tagItem = screen.getByTitle('Frontend')
const tagItem = screen.getByRole('option', { name: /Frontend/i })
expect(tagItem).toBeInTheDocument()
// The parent container of the tag has a Check SVG sibling
const checkIcons = screen.getAllByTestId('tag-filter-selected-icon')
expect(checkIcons?.length).toBeGreaterThanOrEqual(1)
expect(tagItem).toHaveAttribute('aria-selected', 'true')
})
it('should clear all selected tags when clear button is clicked', async () => {
@ -197,7 +197,7 @@ describe('TagFilter', () => {
await user.click(screen.getByText(i18n.placeholder))
const searchInput = screen.getByRole('textbox')
const searchInput = screen.getByRole('combobox', { name: i18n.selectorPlaceholder })
await user.type(searchInput, 'Front')
expect(screen.getByText('Frontend')).toBeInTheDocument()
@ -212,7 +212,7 @@ describe('TagFilter', () => {
await user.click(screen.getByText(i18n.placeholder))
const searchInput = screen.getByRole('textbox')
const searchInput = screen.getByRole('combobox', { name: i18n.selectorPlaceholder })
await user.type(searchInput, 'NonExistentTag')
expect(screen.getByText(i18n.noTag)).toBeInTheDocument()
@ -225,12 +225,12 @@ describe('TagFilter', () => {
await user.click(screen.getByText(i18n.placeholder))
const searchInput = screen.getByRole('textbox')
const searchInput = screen.getByRole('combobox', { name: i18n.selectorPlaceholder })
await user.type(searchInput, 'Front')
expect(screen.queryByText('Backend')).not.toBeInTheDocument()
const clearButton = screen.getByTestId('input-clear')
const clearButton = screen.getByRole('button', { name: i18n.operationClear })
await user.click(clearButton)
expect(searchInput).toHaveValue('')

View File

@ -1,82 +1,22 @@
import type { Tag } from '@/contract/console/tags'
import { render, screen, waitFor, within } from '@testing-library/react'
import type { TagComboboxItem } from '../components/tag-combobox-item'
import type { Tag, TagType } from '@/contract/console/tags'
import { Combobox } from '@langgenius/dify-ui/combobox'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { act } from 'react'
import * as ReactI18next from 'react-i18next'
import { useMemo, useState } from 'react'
import { isCreateTagOption } from '../components/tag-combobox-item'
import { TagPanel } from '../components/tag-panel'
const { mockNotify, mockToast } = vi.hoisted(() => {
const mockNotify = vi.fn()
const mockToast = Object.assign(mockNotify, {
success: vi.fn((message, options) => mockNotify({ type: 'success', message, ...options })),
error: vi.fn((message, options) => mockNotify({ type: 'error', message, ...options })),
warning: vi.fn((message, options) => mockNotify({ type: 'warning', message, ...options })),
info: vi.fn((message, options) => mockNotify({ type: 'info', message, ...options })),
dismiss: vi.fn(),
update: vi.fn(),
promise: vi.fn(),
})
return { mockNotify, mockToast }
})
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: mockToast,
const { onValueChangeSpy } = vi.hoisted(() => ({
onValueChangeSpy: vi.fn(),
}))
// Hoisted mocks
const { createTag, bindTag, unBindTag } = vi.hoisted(() => ({
createTag: vi.fn(),
bindTag: vi.fn(),
unBindTag: vi.fn(),
}))
vi.mock('../hooks/use-tag-mutations', () => ({
useCreateTagMutation: () => {
const mutation = {
isPending: false,
mutate: ({ body }: { body: { name: string, type: 'app' | 'knowledge' } }, options?: { onSuccess?: (tag: Tag) => void, onError?: () => void }) => {
mutation.isPending = true
const tag = { id: 'new-tag', name: body.name, type: body.type, binding_count: 0 } as Tag
Promise.resolve(createTag(body.name, body.type))
.then(() => options?.onSuccess?.(tag))
.catch(() => options?.onError?.())
.finally(() => {
mutation.isPending = false
})
},
}
return mutation
},
useApplyTagBindingsMutation: () => ({
mutate: (
{ currentTagIds, nextTagIds, targetId, type }: { currentTagIds: string[], nextTagIds: string[], targetId: string, type: 'app' | 'knowledge' },
options?: { onSuccess?: () => void, onError?: () => void },
) => {
const addTagIds = nextTagIds.filter(tagId => !currentTagIds.includes(tagId))
const removeTagIds = currentTagIds.filter(tagId => !nextTagIds.includes(tagId))
const operations: Promise<unknown>[] = []
if (addTagIds.length)
operations.push(Promise.resolve(bindTag(addTagIds, targetId, type)))
operations.push(...removeTagIds.map(tagId => Promise.resolve(unBindTag(tagId, targetId, type))))
Promise.all(operations)
.then(() => options?.onSuccess?.())
.catch(() => options?.onError?.())
},
}),
}))
// i18n mock renders "ns.key" format (dot-separated)
const i18n = {
selectorPlaceholder: 'common.tag.selectorPlaceholder',
operationClear: 'common.operation.clear',
create: 'common.tag.create',
created: 'common.tag.created',
failed: 'common.tag.failed',
noTag: 'common.tag.noTag',
manageTags: 'common.tag.manageTags',
modifiedSuccessfully: 'common.actionMsg.modifiedSuccessfully',
modifiedUnsuccessfully: 'common.actionMsg.modifiedUnsuccessfully',
}
const appTags: Tag[] = [
@ -87,461 +27,171 @@ const appTags: Tag[] = [
const knowledgeTag: Tag = { id: 'tag-k1', name: 'KnowledgeDB', type: 'knowledge', binding_count: 2 }
const defaultProps = {
targetId: 'target-1',
type: 'app' as const,
selectedTagIds: ['tag-1'!], // tag-1 is already selected/bound
selectedTags: [appTags[0]!], // pre-selected tags shown separately
tagList: [...appTags, knowledgeTag],
type PanelHarnessProps = {
type?: TagType
value?: Tag[]
tagList?: Tag[]
onOpenTagManagement?: () => void
}
describe('Panel', () => {
const tagToString = (tag: TagComboboxItem) => tag.name
const isSameTag = (item: TagComboboxItem, value: TagComboboxItem) => item.id === value.id
const tagFilter = (tag: TagComboboxItem, query: string) => tag.name.includes(query)
const PanelHarness = ({
type = 'app',
value = [appTags[0]!],
tagList = [...appTags, knowledgeTag],
onOpenTagManagement,
}: PanelHarnessProps) => {
const [selectedTags, setSelectedTags] = useState<Tag[]>(value)
const [inputValue, setInputValue] = useState('')
const items = useMemo<TagComboboxItem[]>(() => {
const tags = tagList.filter(tag => tag.type === type)
if (!inputValue || tags.some(tag => tag.name === inputValue))
return tags
return [{
id: `__create_tag__:${inputValue}`,
name: inputValue,
type,
binding_count: 0,
isCreateOption: true,
}, ...tags]
}, [inputValue, tagList, type])
return (
<Combobox
items={items}
multiple
value={selectedTags}
onValueChange={(nextTags) => {
onValueChangeSpy(nextTags)
if (nextTags.some(isCreateTagOption))
return
setSelectedTags(nextTags)
}}
inputValue={inputValue}
onInputValueChange={setInputValue}
filter={tagFilter}
itemToStringLabel={tagToString}
isItemEqualToValue={isSameTag}
>
<TagPanel
type={type}
inputValue={inputValue}
onInputValueChange={setInputValue}
onOpenTagManagement={onOpenTagManagement}
/>
</Combobox>
)
}
describe('TagPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(createTag).mockResolvedValue({ id: 'new-tag', name: 'NewTag', type: 'app', binding_count: 0 })
vi.mocked(bindTag).mockResolvedValue(undefined)
vi.mocked(unBindTag).mockResolvedValue(undefined)
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<TagPanel {...defaultProps} tagList={appTags} />)
expect(screen.getByPlaceholderText(i18n.selectorPlaceholder))!.toBeInTheDocument()
})
it('renders search, selected tags, unselected tags, and management action', () => {
render(<PanelHarness />)
it('should render the search input', () => {
render(<TagPanel {...defaultProps} tagList={appTags} />)
const input = screen.getByPlaceholderText(i18n.selectorPlaceholder)
expect(input)!.toBeInTheDocument()
expect(input.tagName).toBe('INPUT')
})
it('should fallback to empty placeholder when translation is empty', () => {
const mockedTranslation = {
t: vi.fn().mockReturnValue(''),
i18n: {} as ReturnType<typeof ReactI18next.useTranslation>['i18n'],
ready: true,
} as unknown as ReturnType<typeof ReactI18next.useTranslation>
vi.spyOn(ReactI18next, 'useTranslation').mockReturnValueOnce(mockedTranslation)
render(<TagPanel {...defaultProps} tagList={appTags} />)
expect(screen.getByRole('textbox'))!.toHaveAttribute('placeholder', '')
})
it('should render selected tags from selectedTags prop', () => {
render(<TagPanel {...defaultProps} tagList={appTags} />)
expect(screen.getByText('Frontend'))!.toBeInTheDocument()
})
it('should render unselected tags matching the type', () => {
render(<TagPanel {...defaultProps} tagList={appTags} />)
// tag-2 and tag-3 are app type and not in value[]
// tag-2 and tag-3 are app type and not in value[]
expect(screen.getByText('Backend'))!.toBeInTheDocument()
expect(screen.getByText('API'))!.toBeInTheDocument()
})
it('should not render tags of a different type', () => {
render(<TagPanel {...defaultProps} tagList={appTags} />)
// knowledgeTag is type 'knowledge', should not appear
// knowledgeTag is type 'knowledge', should not appear
// knowledgeTag is type 'knowledge', should not appear
// knowledgeTag is type 'knowledge', should not appear
// knowledgeTag is type 'knowledge', should not appear
// knowledgeTag is type 'knowledge', should not appear
// knowledgeTag is type 'knowledge', should not appear
// knowledgeTag is type 'knowledge', should not appear
// knowledgeTag is type 'knowledge', should not appear
// knowledgeTag is type 'knowledge', should not appear
// knowledgeTag is type 'knowledge', should not appear
// knowledgeTag is type 'knowledge', should not appear
// knowledgeTag is type 'knowledge', should not appear
// knowledgeTag is type 'knowledge', should not appear
// knowledgeTag is type 'knowledge', should not appear
// knowledgeTag is type 'knowledge', should not appear
// knowledgeTag is type 'knowledge', should not appear
// knowledgeTag is type 'knowledge', should not appear
// knowledgeTag is type 'knowledge', should not appear
// knowledgeTag is type 'knowledge', should not appear
// knowledgeTag is type 'knowledge', should not appear
// knowledgeTag is type 'knowledge', should not appear
// knowledgeTag is type 'knowledge', should not appear
// knowledgeTag is type 'knowledge', should not appear
// knowledgeTag is type 'knowledge', should not appear
// knowledgeTag is type 'knowledge', should not appear
// knowledgeTag is type 'knowledge', should not appear
// knowledgeTag is type 'knowledge', should not appear
// knowledgeTag is type 'knowledge', should not appear
// knowledgeTag is type 'knowledge', should not appear
// knowledgeTag is type 'knowledge', should not appear
// knowledgeTag is type 'knowledge', should not appear
expect(screen.queryByText('KnowledgeDB')).not.toBeInTheDocument()
})
it('should render the manage tags button', () => {
render(<TagPanel {...defaultProps} tagList={appTags} />)
expect(screen.getByText(i18n.manageTags))!.toBeInTheDocument()
})
it('should show no-tag message when there are no tags', () => {
render(<TagPanel {...defaultProps} selectedTagIds={[]} selectedTags={[]} tagList={[]} />)
expect(screen.getByText(i18n.noTag))!.toBeInTheDocument()
})
it('should not show no-tag message when tags exist', () => {
render(<TagPanel {...defaultProps} tagList={appTags} />)
expect(screen.queryByText(i18n.noTag)).not.toBeInTheDocument()
})
expect(screen.getByRole('combobox', { name: i18n.selectorPlaceholder })).toBeInTheDocument()
expect(screen.getByRole('option', { name: /Frontend/i })).toBeInTheDocument()
expect(screen.getByRole('option', { name: /Backend/i })).toBeInTheDocument()
expect(screen.queryByText('KnowledgeDB')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: i18n.manageTags })).toBeInTheDocument()
})
describe('Search / Filter', () => {
it('should filter tags by keyword', async () => {
const user = userEvent.setup()
render(<TagPanel {...defaultProps} tagList={appTags} />)
it('filters options by the controlled combobox input value', async () => {
const user = userEvent.setup()
render(<PanelHarness />)
const input = screen.getByPlaceholderText(i18n.selectorPlaceholder)
await user.type(input, 'Back')
await user.type(screen.getByRole('combobox', { name: i18n.selectorPlaceholder }), 'Back')
expect(screen.getByText('Backend'))!.toBeInTheDocument()
expect(screen.queryByText('API')).not.toBeInTheDocument()
})
it('should filter selected tags by keyword', async () => {
const user = userEvent.setup()
render(<TagPanel {...defaultProps} tagList={appTags} />)
const input = screen.getByPlaceholderText(i18n.selectorPlaceholder)
await user.type(input, 'Front')
expect(screen.getByText('Frontend'))!.toBeInTheDocument()
expect(screen.queryByText('Backend')).not.toBeInTheDocument()
})
it('should show create option when keyword does not match any tag', async () => {
const user = userEvent.setup()
// notExisted uses .every(tag => tag.type === type && tag.name !== keywords)
// so store must only contain same-type tags for notExisted to be true
render(<TagPanel {...defaultProps} tagList={appTags} />)
const input = screen.getByPlaceholderText(i18n.selectorPlaceholder)
await user.type(input, 'BrandNewTag')
// The create row shows "Create 'BrandNewTag'"
// The create row shows "Create 'BrandNewTag'"
expect(screen.getByText(/BrandNewTag/))!.toBeInTheDocument()
expect(screen.getByText(i18n.create, { exact: false }))!.toBeInTheDocument()
})
it('should not show create option when keyword matches an existing tag name', async () => {
const user = userEvent.setup()
// Use only same-type tags so we can verify name matching specifically
render(<TagPanel {...defaultProps} tagList={appTags} />)
const input = screen.getByPlaceholderText(i18n.selectorPlaceholder)
await user.type(input, 'Frontend')
// 'Frontend' matches tag-1 name, so notExisted = false
// 'Frontend' matches tag-1 name, so notExisted = false
// 'Frontend' matches tag-1 name, so notExisted = false
// 'Frontend' matches tag-1 name, so notExisted = false
// 'Frontend' matches tag-1 name, so notExisted = false
// 'Frontend' matches tag-1 name, so notExisted = false
// 'Frontend' matches tag-1 name, so notExisted = false
// 'Frontend' matches tag-1 name, so notExisted = false
// 'Frontend' matches tag-1 name, so notExisted = false
// 'Frontend' matches tag-1 name, so notExisted = false
// 'Frontend' matches tag-1 name, so notExisted = false
// 'Frontend' matches tag-1 name, so notExisted = false
// 'Frontend' matches tag-1 name, so notExisted = false
// 'Frontend' matches tag-1 name, so notExisted = false
// 'Frontend' matches tag-1 name, so notExisted = false
// 'Frontend' matches tag-1 name, so notExisted = false
// 'Frontend' matches tag-1 name, so notExisted = false
// 'Frontend' matches tag-1 name, so notExisted = false
// 'Frontend' matches tag-1 name, so notExisted = false
// 'Frontend' matches tag-1 name, so notExisted = false
// 'Frontend' matches tag-1 name, so notExisted = false
// 'Frontend' matches tag-1 name, so notExisted = false
// 'Frontend' matches tag-1 name, so notExisted = false
// 'Frontend' matches tag-1 name, so notExisted = false
// 'Frontend' matches tag-1 name, so notExisted = false
// 'Frontend' matches tag-1 name, so notExisted = false
// 'Frontend' matches tag-1 name, so notExisted = false
// 'Frontend' matches tag-1 name, so notExisted = false
// 'Frontend' matches tag-1 name, so notExisted = false
// 'Frontend' matches tag-1 name, so notExisted = false
// 'Frontend' matches tag-1 name, so notExisted = false
// 'Frontend' matches tag-1 name, so notExisted = false
expect(screen.queryByText(i18n.create, { exact: false })).not.toBeInTheDocument()
})
it('should clear search when clear button is clicked', async () => {
const user = userEvent.setup()
render(<TagPanel {...defaultProps} tagList={appTags} />)
const input = screen.getByPlaceholderText(i18n.selectorPlaceholder)
await user.type(input, 'Back')
expect(input)!.toHaveValue('Back')
// The Input component renders a clear icon with data-testid="input-clear"
const clearButton = screen.getByTestId('input-clear')
await user.click(clearButton)
expect(input)!.toHaveValue('')
// All tags should be visible again
// All tags should be visible again
expect(screen.getByText('Backend'))!.toBeInTheDocument()
expect(screen.getByText('API'))!.toBeInTheDocument()
})
expect(screen.getByRole('option', { name: /Backend/i })).toBeInTheDocument()
expect(screen.queryByRole('option', { name: /API/i })).not.toBeInTheDocument()
})
describe('Tag Selection', () => {
const getTagRow = (tagName: string) => {
const row = screen.getByText(tagName).closest('[data-testid="tag-row"]')
expect(row).not.toBeNull()
return row as HTMLElement
}
it('clears only the search input from the input clear button', async () => {
const user = userEvent.setup()
render(<PanelHarness />)
it('should select an unselected tag when clicked', async () => {
const user = userEvent.setup()
render(<TagPanel {...defaultProps} tagList={appTags} />)
const input = screen.getByRole('combobox', { name: i18n.selectorPlaceholder })
await user.type(input, 'Back')
expect(input).toHaveValue('Back')
vi.clearAllMocks()
const backendRowBeforeSelect = getTagRow('Backend')
expect(within(backendRowBeforeSelect).queryByTestId('check-icon-tag-2')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: i18n.operationClear }))
await user.click(screen.getByText('Backend'))
const backendRowAfterSelect = getTagRow('Backend')
expect(within(backendRowAfterSelect).getByTestId('check-icon-tag-2'))!.toBeInTheDocument()
})
it('should deselect a selected tag when clicked', async () => {
const user = userEvent.setup()
render(<TagPanel {...defaultProps} tagList={appTags} />)
const frontendRowBeforeDeselect = getTagRow('Frontend')
expect(within(frontendRowBeforeDeselect).getByTestId('check-icon-tag-1'))!.toBeInTheDocument()
await user.click(screen.getByText('Frontend'))
const frontendRowAfterDeselect = getTagRow('Frontend')
expect(within(frontendRowAfterDeselect).queryByTestId('check-icon-tag-1')).not.toBeInTheDocument()
})
it('should toggle tag selection on multiple clicks', async () => {
const user = userEvent.setup()
render(<TagPanel {...defaultProps} tagList={appTags} />)
const backendRowBeforeToggle = getTagRow('Backend')
expect(within(backendRowBeforeToggle).queryByTestId('check-icon-tag-2')).not.toBeInTheDocument()
await user.click(screen.getByText('Backend'))
const backendRowAfterFirstClick = getTagRow('Backend')
expect(within(backendRowAfterFirstClick).getByTestId('check-icon-tag-2'))!.toBeInTheDocument()
await user.click(screen.getByText('Backend'))
const backendRowAfterSecondClick = getTagRow('Backend')
expect(within(backendRowAfterSecondClick).queryByTestId('check-icon-tag-2')).not.toBeInTheDocument()
})
expect(input).toHaveValue('')
expect(onValueChangeSpy).not.toHaveBeenCalled()
expect(screen.getByRole('option', { name: /Frontend/i })).toHaveAttribute('aria-selected', 'true')
})
describe('Tag Creation', () => {
beforeEach(() => {
// notExisted requires all tags to be same type, so remove knowledgeTag
})
it('shows a create option when the query is not an existing tag name', async () => {
const user = userEvent.setup()
render(<PanelHarness />)
it('should create a new tag when clicking the create option', async () => {
const user = userEvent.setup()
render(<TagPanel {...defaultProps} tagList={appTags} />)
await user.type(screen.getByRole('combobox', { name: i18n.selectorPlaceholder }), 'BrandNewTag')
const input = screen.getByPlaceholderText(i18n.selectorPlaceholder)
await user.type(input, 'BrandNewTag')
const createOption = await screen.findByTestId('create-tag-option')
await user.click(createOption)
await waitFor(() => {
expect(createTag).toHaveBeenCalledWith('BrandNewTag', 'app')
})
})
it('should show success notification after tag creation', async () => {
const user = userEvent.setup()
render(<TagPanel {...defaultProps} tagList={appTags} />)
const input = screen.getByPlaceholderText(i18n.selectorPlaceholder)
await user.type(input, 'BrandNewTag')
const createOption = await screen.findByTestId('create-tag-option')
await user.click(createOption)
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'success',
message: i18n.created,
})
})
})
it('should clear keywords after successful tag creation', async () => {
const user = userEvent.setup()
render(<TagPanel {...defaultProps} tagList={appTags} />)
const input = screen.getByPlaceholderText(i18n.selectorPlaceholder)
await user.type(input, 'BrandNewTag')
const createOption = await screen.findByTestId('create-tag-option')
await user.click(createOption)
await waitFor(() => {
expect(input)!.toHaveValue('')
})
})
it('should show error notification when tag creation fails', async () => {
const user = userEvent.setup()
vi.mocked(createTag).mockRejectedValue(new Error('Creation failed'))
render(<TagPanel {...defaultProps} tagList={appTags} />)
const input = screen.getByPlaceholderText(i18n.selectorPlaceholder)
await user.type(input, 'FailTag')
const createOption = await screen.findByTestId('create-tag-option')
await user.click(createOption)
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: i18n.failed,
})
})
})
it('should not create tag when keywords is empty', () => {
render(<TagPanel {...defaultProps} tagList={appTags} />)
// The create option should not appear when no keywords
// The create option should not appear when no keywords
// The create option should not appear when no keywords
// The create option should not appear when no keywords
// The create option should not appear when no keywords
// The create option should not appear when no keywords
// The create option should not appear when no keywords
// The create option should not appear when no keywords
// The create option should not appear when no keywords
// The create option should not appear when no keywords
// The create option should not appear when no keywords
// The create option should not appear when no keywords
// The create option should not appear when no keywords
// The create option should not appear when no keywords
// The create option should not appear when no keywords
// The create option should not appear when no keywords
// The create option should not appear when no keywords
// The create option should not appear when no keywords
// The create option should not appear when no keywords
// The create option should not appear when no keywords
// The create option should not appear when no keywords
// The create option should not appear when no keywords
// The create option should not appear when no keywords
// The create option should not appear when no keywords
// The create option should not appear when no keywords
// The create option should not appear when no keywords
// The create option should not appear when no keywords
// The create option should not appear when no keywords
// The create option should not appear when no keywords
// The create option should not appear when no keywords
// The create option should not appear when no keywords
// The create option should not appear when no keywords
expect(screen.queryByText(i18n.create, { exact: false })).not.toBeInTheDocument()
expect(createTag).not.toHaveBeenCalled()
})
expect(screen.getByTestId('create-tag-option')).toHaveTextContent(i18n.create)
expect(screen.getByTestId('create-tag-option')).toHaveTextContent('BrandNewTag')
})
describe('Binding Selection State', () => {
it('should not submit tag bindings on panel unmount', async () => {
const user = userEvent.setup()
const { unmount } = render(<TagPanel {...defaultProps} tagList={appTags} />)
it('does not show a create option for an exact existing tag name', async () => {
const user = userEvent.setup()
render(<PanelHarness />)
await user.click(screen.getByText('Backend'))
unmount()
await user.type(screen.getByRole('combobox', { name: i18n.selectorPlaceholder }), 'Frontend')
await act(async () => { })
expect(bindTag).not.toHaveBeenCalled()
expect(unBindTag).not.toHaveBeenCalled()
expect(mockNotify).not.toHaveBeenCalled()
})
expect(screen.queryByTestId('create-tag-option')).not.toBeInTheDocument()
})
describe('Manage Tags Modal', () => {
it('should open the tag management modal when manage tags is clicked', async () => {
const user = userEvent.setup()
const onOpenTagManagement = vi.fn()
render(<TagPanel {...defaultProps} onOpenTagManagement={onOpenTagManagement} />)
it('updates only the combobox draft value when selecting and deselecting options', async () => {
const user = userEvent.setup()
render(<PanelHarness />)
await user.click(screen.getByText(i18n.manageTags))
await user.click(screen.getByRole('option', { name: /Backend/i }))
expect(onValueChangeSpy).toHaveBeenLastCalledWith(expect.arrayContaining([expect.objectContaining({ id: 'tag-2' })]))
expect(onOpenTagManagement).toHaveBeenCalledTimes(1)
})
await user.click(screen.getByRole('option', { name: /Backend/i }))
expect(onValueChangeSpy).toHaveBeenLastCalledWith([expect.objectContaining({ id: 'tag-1' })])
})
describe('Edge Cases', () => {
it('should handle empty value array', () => {
render(<TagPanel {...defaultProps} selectedTagIds={[]} selectedTags={[]} />)
// All app-type tags should appear in the unselected list
// All app-type tags should appear in the unselected list
expect(screen.getByText('Frontend'))!.toBeInTheDocument()
expect(screen.getByText('Backend'))!.toBeInTheDocument()
expect(screen.getByText('API'))!.toBeInTheDocument()
})
it('routes create option activation through the combobox value change API', async () => {
const user = userEvent.setup()
render(<PanelHarness />)
it('should handle empty tagList', () => {
render(<TagPanel {...defaultProps} selectedTagIds={[]} selectedTags={[]} tagList={[]} />)
expect(screen.getByText(i18n.noTag))!.toBeInTheDocument()
})
const input = screen.getByRole('combobox', { name: i18n.selectorPlaceholder })
await user.type(input, 'BrandNewTag')
await user.click(screen.getByTestId('create-tag-option'))
it('should handle all tags already selected', () => {
render(
<TagPanel
{...defaultProps}
selectedTagIds={['tag-1', 'tag-2', 'tag-3']}
selectedTags={appTags}
/>,
)
// All app tags appear in selectedTags, filteredTagList should be empty
// All app tags appear in selectedTags, filteredTagList should be empty
expect(screen.getByText('Frontend'))!.toBeInTheDocument()
expect(screen.getByText('Backend'))!.toBeInTheDocument()
expect(screen.getByText('API'))!.toBeInTheDocument()
})
expect(onValueChangeSpy).toHaveBeenLastCalledWith(expect.arrayContaining([
expect.objectContaining({
isCreateOption: true,
name: 'BrandNewTag',
}),
]))
})
it('should show divider between create option and tag list when both present', async () => {
const user = userEvent.setup()
// Only same-type tags for notExisted to work
render(<TagPanel {...defaultProps} tagList={appTags} />)
const input = screen.getByPlaceholderText(i18n.selectorPlaceholder)
await user.type(input, 'Back')
// 'Back' matches Backend (unselected), notExisted is true (no tag named 'Back')
// filteredTagList has items, so the conditional divider between create-option and tag-list renders
const dividers = screen.getAllByTestId('divider')
expect(dividers.length).toBeGreaterThanOrEqual(2)
})
it('renders the empty state when no tags exist and no search is active', () => {
render(<PanelHarness value={[]} tagList={[]} />)
expect(screen.getByText(i18n.noTag)).toBeInTheDocument()
})
it('should handle knowledge type tags correctly', () => {
render(
<TagPanel
{...defaultProps}
type="knowledge"
selectedTagIds={[]}
selectedTags={[]}
tagList={[knowledgeTag]}
/>,
)
expect(screen.getByText('KnowledgeDB'))!.toBeInTheDocument()
})
it('opens tag management through a semantic button', async () => {
const user = userEvent.setup()
const onOpenTagManagement = vi.fn()
render(<PanelHarness onOpenTagManagement={onOpenTagManagement} />)
await user.click(screen.getByRole('button', { name: i18n.manageTags }))
expect(onOpenTagManagement).toHaveBeenCalledTimes(1)
})
it('renders knowledge tags when the panel type is knowledge', () => {
render(<PanelHarness type="knowledge" value={[]} />)
expect(screen.getByRole('option', { name: /KnowledgeDB/i })).toBeInTheDocument()
})
})

View File

@ -1,5 +1,6 @@
import type { ComponentProps } from 'react'
import type { Tag } from '@/contract/console/tags'
import { render, screen, waitFor, within } from '@testing-library/react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { TagSelector } from '../components/tag-selector'
@ -16,16 +17,17 @@ const { mockToast } = vi.hoisted(() => {
return { mockToast }
})
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: mockToast,
}))
vi.mock('@langgenius/dify-ui/toast', () => ({ toast: mockToast }))
const { mockUseQueryData, createTag, bindTag, unBindTag } = vi.hoisted(() => ({
mockUseQueryData: { current: [] as Tag[] },
createTag: vi.fn(),
bindTag: vi.fn(),
unBindTag: vi.fn(),
}))
const { mockUseQueryData, createTag, bindTag, unBindTag } = vi.hoisted(() => {
const mockUseQueryData: { current: Tag[] } = { current: [] }
return {
mockUseQueryData,
createTag: vi.fn(),
bindTag: vi.fn(),
unBindTag: vi.fn(),
}
})
vi.mock('@tanstack/react-query', () => ({
useQuery: () => ({ data: mockUseQueryData.current }),
@ -35,14 +37,10 @@ vi.mock('../hooks/use-tag-mutations', () => ({
useCreateTagMutation: () => ({
isPending: false,
mutate: ({ body }: { body: { name: string, type: 'app' | 'knowledge' } }, options?: { onSuccess?: (tag: Tag) => void, onError?: () => void }) => {
try {
const tag = { id: 'new-tag', name: body.name, type: body.type, binding_count: 0 } as Tag
createTag(body.name, body.type)
options?.onSuccess?.(tag)
}
catch {
options?.onError?.()
}
const tag: Tag = { id: 'new-tag', name: body.name, type: body.type, binding_count: 0 }
Promise.resolve(createTag(body.name, body.type))
.then(() => options?.onSuccess?.(tag))
.catch(() => options?.onError?.())
},
}),
useApplyTagBindingsMutation: () => ({
@ -66,12 +64,10 @@ vi.mock('../hooks/use-tag-mutations', () => ({
}),
}))
// i18n keys rendered in "ns.key" format
const i18n = {
addTag: 'common.tag.addTag',
selectorPlaceholder: 'common.tag.selectorPlaceholder',
manageTags: 'common.tag.manageTags',
noTag: 'common.tag.noTag',
modifiedSuccessfully: 'common.actionMsg.modifiedSuccessfully',
modifiedUnsuccessfully: 'common.actionMsg.modifiedUnsuccessfully',
}
@ -83,18 +79,11 @@ const appTags: Tag[] = [
const defaultProps = {
targetId: 'target-1',
type: 'app' as const,
selectedTagIds: ['tag-1'!],
selectedTags: [appTags[0]!],
}
type: 'app',
value: [appTags[0]!],
} satisfies ComponentProps<typeof TagSelector>
describe('TagSelector', () => {
const getPanelTagRow = (tagName: string) => {
const row = screen.getAllByTestId('tag-row').find(tagRow => within(tagRow).queryByText(tagName))
expect(row).toBeDefined()
return row as HTMLElement
}
beforeEach(() => {
vi.clearAllMocks()
mockUseQueryData.current = appTags
@ -103,340 +92,122 @@ describe('TagSelector', () => {
vi.mocked(unBindTag).mockResolvedValue(undefined)
})
describe('Rendering', () => {
it('should render TagSelector trigger with selected tag names from defaultProps when isPopover defaults to true', () => {
render(<TagSelector {...defaultProps} />)
expect(screen.getByText('Frontend'))!.toBeInTheDocument()
})
it('renders selected tag names in the combobox trigger', () => {
render(<TagSelector {...defaultProps} />)
expect(screen.getByText('Frontend')).toBeInTheDocument()
})
it('should render TagSelector add-tag placeholder when defaultProps are overridden with empty selectedTags and value', () => {
render(<TagSelector {...defaultProps} selectedTags={[]} selectedTagIds={[]} />)
expect(screen.getByText(i18n.addTag))!.toBeInTheDocument()
})
it('renders the add tag trigger when no current tag is visible in the workspace tag list', () => {
render(<TagSelector {...defaultProps} value={[{ id: 'orphan', name: 'Orphan', type: 'app', binding_count: 0 }]} />)
expect(screen.queryByText('Orphan')).not.toBeInTheDocument()
expect(screen.getByText(i18n.addTag)).toBeInTheDocument()
})
it('should render nothing when isPopover is false', () => {
const { container } = render(<TagSelector {...defaultProps} isPopover={false} />)
// Only the empty fragment wrapper
// Only the empty fragment wrapper
expect(container)!.toBeEmptyDOMElement()
})
it('opens a searchable combobox popup', async () => {
const user = userEvent.setup()
render(<TagSelector {...defaultProps} />)
it('should render the popover trigger button', () => {
render(<TagSelector {...defaultProps} />)
// The trigger is wrapped in a PopoverButton
// The trigger is wrapped in a PopoverButton
expect(screen.getByRole('button'))!.toBeInTheDocument()
})
await user.click(screen.getByRole('combobox', { name: /Frontend/i }))
it('should render when minWidth is provided', () => {
render(<TagSelector {...defaultProps} minWidth="320px" />)
expect(screen.getByRole('button'))!.toBeInTheDocument()
expect(await screen.findByRole('combobox', { name: i18n.selectorPlaceholder })).toBeInTheDocument()
expect(screen.getByText(i18n.manageTags)).toBeInTheDocument()
expect(screen.getByRole('option', { name: /Backend/i })).toBeInTheDocument()
})
it('applies added tags only when the popup closes', async () => {
const user = userEvent.setup()
render(<TagSelector {...defaultProps} />)
const trigger = screen.getByRole('combobox', { name: /Frontend/i })
await user.click(trigger)
await user.click(await screen.findByRole('option', { name: /Backend/i }))
expect(bindTag).not.toHaveBeenCalled()
await user.click(trigger)
await waitFor(() => {
expect(bindTag).toHaveBeenCalledWith(['tag-2'], 'target-1', 'app')
})
expect(mockToast.success).toHaveBeenCalledWith(i18n.modifiedSuccessfully, {
id: 'tag-bindings-app-target-1',
})
})
describe('Props', () => {
it('should filter selectedTags to only those present in store tagList', () => {
const unknownTag: Tag = { id: 'unknown', name: 'Unknown', type: 'app', binding_count: 0 }
render(
<TagSelector
{...defaultProps}
selectedTags={[appTags[0]!, unknownTag]}
selectedTagIds={['tag-1', 'unknown']}
/>,
)
// 'Frontend' is in tagList, 'Unknown' is not
// 'Frontend' is in tagList, 'Unknown' is not
expect(screen.getByText('Frontend'))!.toBeInTheDocument()
expect(screen.queryByText('Unknown')).not.toBeInTheDocument()
})
it('applies removed tags only when the popup closes', async () => {
const user = userEvent.setup()
render(<TagSelector {...defaultProps} />)
it('should display multiple tag names when multiple are selected', () => {
render(
<TagSelector
{...defaultProps}
selectedTags={appTags}
selectedTagIds={['tag-1', 'tag-2']}
/>,
)
expect(screen.getByText('Frontend'))!.toBeInTheDocument()
expect(screen.getByText('Backend'))!.toBeInTheDocument()
const trigger = screen.getByRole('combobox', { name: /Frontend/i })
await user.click(trigger)
await user.click(await screen.findByRole('option', { name: /Frontend/i }))
await user.click(trigger)
await waitFor(() => {
expect(unBindTag).toHaveBeenCalledWith('tag-1', 'target-1', 'app')
})
})
describe('Popover Interaction', () => {
it('should show the panel when the trigger is clicked', async () => {
const user = userEvent.setup()
render(<TagSelector {...defaultProps} />)
it('does not submit unchanged draft selections on close', async () => {
const user = userEvent.setup()
const onTagsChange = vi.fn()
render(<TagSelector {...defaultProps} onTagsChange={onTagsChange} />)
await user.click(screen.getByRole('button'))
const trigger = screen.getByRole('combobox', { name: /Frontend/i })
await user.click(trigger)
await screen.findByRole('combobox', { name: i18n.selectorPlaceholder })
await user.click(trigger)
// Panel renders the search input and manage tags
await waitFor(() => {
expect(screen.getByPlaceholderText(i18n.selectorPlaceholder))!.toBeInTheDocument()
expect(screen.getByText(i18n.manageTags))!.toBeInTheDocument()
})
expect(bindTag).not.toHaveBeenCalled()
expect(unBindTag).not.toHaveBeenCalled()
expect(mockToast.success).not.toHaveBeenCalled()
expect(mockToast.error).not.toHaveBeenCalled()
expect(onTagsChange).not.toHaveBeenCalled()
})
it('notifies after apply settles with success or error', async () => {
const user = userEvent.setup()
const onTagsChange = vi.fn()
render(<TagSelector {...defaultProps} onTagsChange={onTagsChange} />)
const trigger = screen.getByRole('combobox', { name: /Frontend/i })
await user.click(trigger)
await user.click(await screen.findByRole('option', { name: /Backend/i }))
await user.click(trigger)
await waitFor(() => {
expect(onTagsChange).toHaveBeenCalledTimes(1)
})
})
it('should show unselected tags in the panel', async () => {
const user = userEvent.setup()
render(<TagSelector {...defaultProps} />)
it('shows an error toast when applying bindings fails', async () => {
const user = userEvent.setup()
vi.mocked(unBindTag).mockRejectedValueOnce(new Error('Unbind failed'))
render(<TagSelector {...defaultProps} />)
await user.click(screen.getByRole('button'))
const trigger = screen.getByRole('combobox', { name: /Frontend/i })
await user.click(trigger)
await user.click(await screen.findByRole('option', { name: /Frontend/i }))
await user.click(trigger)
await waitFor(() => {
expect(screen.getByText('Backend'))!.toBeInTheDocument()
})
})
it('should show the no-tag message when tag list is empty', async () => {
const user = userEvent.setup()
mockUseQueryData.current = []
render(<TagSelector {...defaultProps} selectedTags={[]} selectedTagIds={[]} />)
await user.click(screen.getByRole('button'))
await waitFor(() => {
expect(screen.getByText(i18n.noTag))!.toBeInTheDocument()
})
})
it('should bind a newly selected tag when closing the panel', async () => {
const user = userEvent.setup()
render(<TagSelector {...defaultProps} />)
const triggerButton = screen.getByRole('button', { name: /Frontend/i })
await user.click(triggerButton)
await screen.findByPlaceholderText(i18n.selectorPlaceholder)
await user.click(getPanelTagRow('Backend'))
// Close panel to trigger unmount side effects.
await user.click(triggerButton)
await waitFor(() => {
expect(bindTag).toHaveBeenCalledTimes(1)
expect(bindTag).toHaveBeenCalledWith(['tag-2'], 'target-1', 'app')
})
})
it('should show one success toast when tag bindings are applied on close', async () => {
const user = userEvent.setup()
render(<TagSelector {...defaultProps} />)
const triggerButton = screen.getByRole('button', { name: /Frontend/i })
await user.click(triggerButton)
await screen.findByPlaceholderText(i18n.selectorPlaceholder)
await user.click(getPanelTagRow('Backend'))
await user.click(triggerButton)
await waitFor(() => {
expect(mockToast.success).toHaveBeenCalledWith(i18n.modifiedSuccessfully, {
id: 'tag-bindings-app-target-1',
})
})
})
it('should unbind a deselected tag when closing the panel', async () => {
const user = userEvent.setup()
render(<TagSelector {...defaultProps} />)
const triggerButton = screen.getByRole('button', { name: /Frontend/i })
await user.click(triggerButton)
await screen.findByPlaceholderText(i18n.selectorPlaceholder)
await user.click(getPanelTagRow('Frontend'))
// Close panel to trigger unmount side effects.
await user.click(triggerButton)
await waitFor(() => {
expect(unBindTag).toHaveBeenCalledTimes(1)
expect(unBindTag).toHaveBeenCalledWith('tag-1', 'target-1', 'app')
})
})
it('should show one error toast when applying tag bindings fails on close', async () => {
const user = userEvent.setup()
vi.mocked(unBindTag).mockRejectedValueOnce(new Error('Unbind failed'))
render(<TagSelector {...defaultProps} />)
const triggerButton = screen.getByRole('button', { name: /Frontend/i })
await user.click(triggerButton)
await screen.findByPlaceholderText(i18n.selectorPlaceholder)
await user.click(getPanelTagRow('Frontend'))
await user.click(triggerButton)
await waitFor(() => {
expect(mockToast.error).toHaveBeenCalledWith(i18n.modifiedUnsuccessfully, {
id: 'tag-bindings-app-target-1',
})
})
})
it('should not apply bindings when the selection is unchanged on close', async () => {
const user = userEvent.setup()
const onTagsChange = vi.fn()
render(<TagSelector {...defaultProps} onTagsChange={onTagsChange} />)
const triggerButton = screen.getByRole('button', { name: /Frontend/i })
await user.click(triggerButton)
await screen.findByPlaceholderText(i18n.selectorPlaceholder)
await user.click(triggerButton)
expect(bindTag).not.toHaveBeenCalled()
expect(unBindTag).not.toHaveBeenCalled()
expect(mockToast.success).not.toHaveBeenCalled()
expect(mockToast.error).not.toHaveBeenCalled()
expect(onTagsChange).not.toHaveBeenCalled()
})
it('should notify tag changes after bindings are applied successfully', async () => {
const user = userEvent.setup()
const onTagsChange = vi.fn()
render(<TagSelector {...defaultProps} onTagsChange={onTagsChange} />)
const triggerButton = screen.getByRole('button', { name: /Frontend/i })
await user.click(triggerButton)
await screen.findByPlaceholderText(i18n.selectorPlaceholder)
await user.click(getPanelTagRow('Backend'))
await user.click(triggerButton)
await waitFor(() => {
expect(onTagsChange).toHaveBeenCalledTimes(1)
})
})
it('should notify tag changes after applying bindings settles with an error', async () => {
const user = userEvent.setup()
const onTagsChange = vi.fn()
vi.mocked(unBindTag).mockRejectedValueOnce(new Error('Unbind failed'))
render(<TagSelector {...defaultProps} onTagsChange={onTagsChange} />)
const triggerButton = screen.getByRole('button', { name: /Frontend/i })
await user.click(triggerButton)
await screen.findByPlaceholderText(i18n.selectorPlaceholder)
await user.click(getPanelTagRow('Frontend'))
await user.click(triggerButton)
await waitFor(() => {
expect(onTagsChange).toHaveBeenCalledTimes(1)
await waitFor(() => {
expect(mockToast.error).toHaveBeenCalledWith(i18n.modifiedUnsuccessfully, {
id: 'tag-bindings-app-target-1',
})
})
})
describe('Data Fetching', () => {
it('should create tags through the mutation hook', async () => {
const user = userEvent.setup()
vi.mocked(createTag).mockResolvedValue({ id: 'new-tag', name: 'BrandNewTag', type: 'app', binding_count: 0 })
it('creates a tag with the current tag type without binding it implicitly', async () => {
const user = userEvent.setup()
render(<TagSelector {...defaultProps} type="knowledge" value={[]} />)
render(<TagSelector {...defaultProps} />)
await user.click(screen.getByRole('combobox', { name: i18n.addTag }))
await user.type(await screen.findByRole('combobox', { name: i18n.selectorPlaceholder }), 'NewKnowledgeTag')
await user.click(await screen.findByTestId('create-tag-option'))
await user.click(screen.getByRole('button'))
await waitFor(() => {
expect(screen.getByPlaceholderText(i18n.selectorPlaceholder))!.toBeInTheDocument()
})
const input = screen.getByPlaceholderText(i18n.selectorPlaceholder)
await user.type(input, 'BrandNewTag')
const createOption = await screen.findByTestId('create-tag-option')
await user.click(createOption)
await waitFor(() => {
expect(createTag).toHaveBeenCalledWith('BrandNewTag', 'app')
})
expect(mockUseQueryData.current).toEqual(appTags)
})
})
describe('Edge Cases', () => {
it('should handle selectedTags with no matching tags in store', () => {
const orphanTags: Tag[] = [
{ id: 'orphan-1', name: 'Orphan', type: 'app', binding_count: 0 },
]
render(
<TagSelector
{...defaultProps}
selectedTags={orphanTags}
selectedTagIds={['orphan-1']}
/>,
)
// Orphan tag is not in store tagList, so tags memo returns []
// Orphan tag is not in store tagList, so tags memo returns []
// Orphan tag is not in store tagList, so tags memo returns []
// Orphan tag is not in store tagList, so tags memo returns []
// Orphan tag is not in store tagList, so tags memo returns []
// Orphan tag is not in store tagList, so tags memo returns []
// Orphan tag is not in store tagList, so tags memo returns []
// Orphan tag is not in store tagList, so tags memo returns []
// Orphan tag is not in store tagList, so tags memo returns []
// Orphan tag is not in store tagList, so tags memo returns []
// Orphan tag is not in store tagList, so tags memo returns []
// Orphan tag is not in store tagList, so tags memo returns []
// Orphan tag is not in store tagList, so tags memo returns []
// Orphan tag is not in store tagList, so tags memo returns []
// Orphan tag is not in store tagList, so tags memo returns []
// Orphan tag is not in store tagList, so tags memo returns []
// Orphan tag is not in store tagList, so tags memo returns []
// Orphan tag is not in store tagList, so tags memo returns []
// Orphan tag is not in store tagList, so tags memo returns []
// Orphan tag is not in store tagList, so tags memo returns []
// Orphan tag is not in store tagList, so tags memo returns []
// Orphan tag is not in store tagList, so tags memo returns []
// Orphan tag is not in store tagList, so tags memo returns []
// Orphan tag is not in store tagList, so tags memo returns []
// Orphan tag is not in store tagList, so tags memo returns []
// Orphan tag is not in store tagList, so tags memo returns []
// Orphan tag is not in store tagList, so tags memo returns []
// Orphan tag is not in store tagList, so tags memo returns []
// Orphan tag is not in store tagList, so tags memo returns []
// Orphan tag is not in store tagList, so tags memo returns []
// Orphan tag is not in store tagList, so tags memo returns []
// Orphan tag is not in store tagList, so tags memo returns []
expect(screen.queryByText('Orphan')).not.toBeInTheDocument()
expect(screen.getByText(i18n.addTag))!.toBeInTheDocument()
})
it('should handle knowledge type', async () => {
const user = userEvent.setup()
const knowledgeTags: Tag[] = [
{ id: 'k-1', name: 'KnowledgeDB', type: 'knowledge', binding_count: 2 },
]
mockUseQueryData.current = knowledgeTags
render(
<TagSelector
{...defaultProps}
type="knowledge"
selectedTags={knowledgeTags}
selectedTagIds={['k-1']}
/>,
)
expect(screen.getByText('KnowledgeDB'))!.toBeInTheDocument()
// Open popover and verify panel uses knowledge type
await user.click(screen.getByRole('button'))
await waitFor(() => {
expect(screen.getByPlaceholderText(i18n.selectorPlaceholder))!.toBeInTheDocument()
})
const input = screen.getByPlaceholderText(i18n.selectorPlaceholder)
await user.type(input, 'NewKnowledgeTag')
const createOption = await screen.findByTestId('create-tag-option')
await user.click(createOption)
await waitFor(() => {
expect(createTag).toHaveBeenCalledWith('NewKnowledgeTag', 'knowledge')
})
await waitFor(() => {
expect(createTag).toHaveBeenCalledWith('NewKnowledgeTag', 'knowledge')
})
expect(bindTag).not.toHaveBeenCalled()
})
})

View File

@ -9,11 +9,10 @@ vi.mock('@/features/tag-management/components/tag-selector', () => ({
TagSelector: (props: {
onOpenTagManagement?: () => void
onTagsChange?: () => void
position: string
selectedTagIds: string[]
selectedTags: Tag[]
placement: string
targetId: string
type: string
value: Tag[]
}) => {
renderTagSelector(props)
@ -21,8 +20,8 @@ vi.mock('@/features/tag-management/components/tag-selector', () => ({
<div data-testid="tag-selector">
<span data-testid="target-id">{props.targetId}</span>
<span data-testid="tag-type">{props.type}</span>
<span data-testid="selected-tag-ids">{props.selectedTagIds.join(',')}</span>
<span data-testid="selected-tag-names">{props.selectedTags.map(tag => tag.name).join(',')}</span>
<span data-testid="selected-tag-ids">{props.value.map(tag => tag.id).join(',')}</span>
<span data-testid="selected-tag-names">{props.value.map(tag => tag.name).join(',')}</span>
<button type="button" onClick={props.onOpenTagManagement}>Manage Tags</button>
<button type="button" onClick={props.onTagsChange}>Tags Changed</button>
</div>
@ -50,11 +49,10 @@ describe('AppCardTags', () => {
expect(screen.getByTestId('selected-tag-ids')).toHaveTextContent('tag-1,tag-2')
expect(screen.getByTestId('selected-tag-names')).toHaveTextContent('Frontend,Backend')
expect(renderTagSelector).toHaveBeenCalledWith(expect.objectContaining({
position: 'bl',
placement: 'bottom-start',
targetId: 'app-1',
type: 'app',
selectedTagIds: ['tag-1', 'tag-2'],
selectedTags: tags,
value: tags,
}))
})
})
@ -87,8 +85,7 @@ describe('AppCardTags', () => {
expect(screen.getByTestId('selected-tag-ids')).toHaveTextContent('')
expect(renderTagSelector).toHaveBeenCalledWith(expect.objectContaining({
selectedTagIds: [],
selectedTags: [],
value: [],
}))
})
})

View File

@ -17,15 +17,14 @@ export const AppCardTags = ({
return (
<div className="group/tag-area relative min-w-0 overflow-hidden">
<TagSelector
position="bl"
placement="bottom-start"
type="app"
targetId={appId}
selectedTagIds={tags.map(tag => tag.id)}
selectedTags={tags}
value={tags}
onOpenTagManagement={onOpenTagManagement}
onTagsChange={onTagsChange}
/>
<div className="pointer-events-none absolute top-0 right-0 z-5 h-full w-20 bg-tag-selector-mask-bg group-hover:bg-tag-selector-mask-hover-bg group-hover/tag-area:hidden" />
<div className="pointer-events-none absolute top-0 right-0 h-full w-20 bg-tag-selector-mask-bg group-focus-within/tag-area:hidden group-hover:bg-tag-selector-mask-hover-bg group-hover/tag-area:hidden" />
</div>
)
}

View File

@ -26,17 +26,16 @@ export const DatasetCardTags = ({
>
<div className="w-full">
<TagSelector
position="bl"
placement="bottom-start"
type="knowledge"
targetId={datasetId}
selectedTagIds={tags.map(tag => tag.id)}
selectedTags={tags}
value={tags}
onOpenTagManagement={onOpenTagManagement}
onTagsChange={onTagsChange}
/>
</div>
<div
className="absolute top-0 right-0 z-5 h-full w-20 bg-tag-selector-mask-bg group-hover:bg-tag-selector-mask-hover-bg group-hover/tag-area:hidden"
className="absolute top-0 right-0 h-full w-20 bg-tag-selector-mask-bg group-focus-within/tag-area:hidden group-hover:bg-tag-selector-mask-hover-bg group-hover/tag-area:hidden"
/>
</div>
)

View File

@ -0,0 +1,15 @@
import type { Tag, TagType } from '@/contract/console/tags'
type CreateTagOption = {
id: string
name: string
type: TagType
binding_count: number
isCreateOption: true
}
export type TagComboboxItem = Tag | CreateTagOption
export const isCreateTagOption = (tag: TagComboboxItem): tag is CreateTagOption => {
return 'isCreateOption' in tag
}

View File

@ -1,22 +1,21 @@
import type { Tag } from '@/contract/console/tags'
import type { ComboboxRootProps } from '@langgenius/dify-ui/combobox'
import type { Tag, TagType } from '@/contract/console/tags'
import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { Combobox, ComboboxContent, ComboboxTrigger } from '@langgenius/dify-ui/combobox'
import { useQuery } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Tag01Icon from '@/app/components/base/icons/src/vender/line/financeAndECommerce/Tag01'
import Tag03Icon from '@/app/components/base/icons/src/vender/line/financeAndECommerce/Tag03'
import CheckIcon from '@/app/components/base/icons/src/vender/line/general/Check'
import XCircleIcon from '@/app/components/base/icons/src/vender/solid/general/XCircle'
import Input from '@/app/components/base/input'
import { consoleQuery } from '@/service/client'
import { TagPanel } from './tag-panel'
const tagFilterComboboxFilter: NonNullable<ComboboxRootProps<Tag, true>['filter']> = (tag, query) => tag.name.includes(query)
const tagToString = (tag: Tag) => tag.name
const isSameTag = (item: Tag, value: Tag) => item.id === value.id
type TagFilterProps = {
type: 'knowledge' | 'app'
type: TagType
value: string[]
onChange: (v: string[]) => void
onOpenTagManagement?: () => void
@ -29,6 +28,7 @@ export const TagFilter = ({
}: TagFilterProps) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [inputValue, setInputValue] = useState('')
const { data: tagList = [] } = useQuery(consoleQuery.tags.list.queryOptions({
input: {
@ -38,119 +38,93 @@ export const TagFilter = ({
},
}))
const [keywords, setKeywords] = useState('')
const tagById = useMemo(() => new Map(tagList.map(tag => [tag.id, tag])), [tagList])
const items = useMemo(() => tagList.filter(tag => tag.type === type), [tagList, type])
const selectedTags = useMemo(() => {
return value.flatMap((tagId) => {
const tag = tagById.get(tagId)
return tag ? [tag] : []
})
}, [tagById, value])
const filteredTagList = useMemo(() => {
return tagList.filter(tag => tag.type === type && tag.name.includes(keywords))
}, [type, tagList, keywords])
const currentTag = useMemo(() => {
return tagList.find(tag => tag.id === value[0])
}, [value, tagList])
const selectTag = (tag: Tag) => {
if (value.includes(tag.id))
onChange(value.filter(v => v !== tag.id))
else
onChange([...value, tag.id])
}
const firstTagId = value[0]
const currentTagName = firstTagId ? tagById.get(firstTagId)?.name : undefined
const triggerLabel = selectedTags.length ? selectedTags.map(tag => tag.name).join(', ') : t('tag.placeholder', { ns: 'common' })
const handleValueChange = useCallback((nextTags: Tag[]) => {
const unknownTagIds = value.filter(tagId => !tagById.has(tagId))
onChange([...unknownTagIds, ...nextTags.map(tag => tag.id)])
}, [onChange, tagById, value])
return (
<Popover
<Combobox
open={open}
onOpenChange={setOpen}
items={items}
multiple
value={selectedTags}
onValueChange={handleValueChange}
inputValue={inputValue}
onInputValueChange={setInputValue}
filter={tagFilterComboboxFilter}
itemToStringLabel={tagToString}
isItemEqualToValue={isSameTag}
>
<div className="relative">
<PopoverTrigger
render={(
<button
type="button"
className={cn(
'flex h-8 max-w-[240px] min-w-[112px] cursor-pointer items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-2 text-left select-none',
!!value.length && 'pr-6 shadow-xs',
)}
>
<div className="p-px">
<Tag01Icon className="h-3.5 w-3.5 text-text-tertiary" data-testid="tag-filter-trigger-icon" />
</div>
<div className="min-w-0 truncate text-[13px] leading-[18px] text-text-secondary">
{!value.length && t('tag.placeholder', { ns: 'common' })}
{!!value.length && currentTag?.name}
</div>
{value.length > 1 && (
<div className="shrink-0 text-xs leading-[18px] font-medium text-text-tertiary">{`+${value.length - 1}`}</div>
)}
{!value.length && (
<div className="shrink-0 p-px">
<span aria-hidden className="i-ri-arrow-down-s-line h-3.5 w-3.5 text-text-tertiary" data-testid="tag-filter-arrow-down-icon" />
</div>
)}
</button>
<ComboboxTrigger
aria-label={triggerLabel}
icon={false}
className={cn(
'flex h-8 max-w-60 min-w-28 cursor-pointer items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-2 py-0 text-left select-none hover:bg-components-input-bg-normal focus-visible:bg-components-input-bg-normal data-open:bg-components-input-bg-normal',
!!value.length && 'pr-6 shadow-xs',
)}
/>
>
<span className="flex min-w-0 items-center gap-1">
<span className="p-px">
<Tag01Icon className="h-3.5 w-3.5 text-text-tertiary" data-testid="tag-filter-trigger-icon" />
</span>
<span className="min-w-0 truncate text-[13px] leading-4.5 text-text-secondary">
{!value.length && t('tag.placeholder', { ns: 'common' })}
{!!value.length && currentTagName}
</span>
{value.length > 1 && (
<span className="shrink-0 text-xs leading-4.5 font-medium text-text-tertiary">{`+${value.length - 1}`}</span>
)}
{!value.length && (
<span className="shrink-0 p-px">
<span aria-hidden className="i-ri-arrow-down-s-line h-3.5 w-3.5 text-text-tertiary" data-testid="tag-filter-arrow-down-icon" />
</span>
)}
</span>
</ComboboxTrigger>
{!!value.length && (
<button
type="button"
aria-label={t('operation.clear', { ns: 'common' })}
className="group/clear absolute top-1/2 right-2 -translate-y-1/2 p-px"
onClick={() => onChange([])}
onClick={(event) => {
event.stopPropagation()
onChange([])
}}
data-testid="tag-filter-clear-button"
>
<XCircleIcon className="h-3.5 w-3.5 text-text-tertiary group-hover/clear:text-text-secondary" />
</button>
)}
<PopoverContent
<ComboboxContent
placement="bottom-start"
sideOffset={4}
popupClassName="w-[240px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]"
popupClassName="w-[240px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-0 shadow-lg backdrop-blur-[5px]"
>
<div className="relative">
<div className="p-2">
<Input
showLeftIcon
showClearIcon
value={keywords}
onChange={e => setKeywords(e.target.value)}
onClear={() => setKeywords('')}
/>
</div>
<div className="max-h-72 overflow-auto p-1">
{filteredTagList.map(tag => (
<div
key={tag.id}
className="flex cursor-pointer items-center gap-2 rounded-lg py-[6px] pr-2 pl-3 select-none hover:bg-state-base-hover"
onClick={() => selectTag(tag)}
>
<div title={tag.name} className="grow truncate text-sm leading-5 text-text-tertiary">{tag.name}</div>
{value.includes(tag.id) && <CheckIcon className="h-4 w-4 shrink-0 text-text-secondary" data-testid="tag-filter-selected-icon" />}
</div>
))}
{!filteredTagList.length && (
<div className="flex flex-col items-center gap-1 p-3">
<Tag03Icon className="h-6 w-6 text-text-tertiary" />
<div className="text-xs leading-[14px] text-text-tertiary">{t('tag.noTag', { ns: 'common' })}</div>
</div>
)}
</div>
<div className="border-t-[0.5px] border-divider-regular" />
<div className="p-1">
<div
className="flex cursor-pointer items-center gap-2 rounded-lg py-[6px] pr-2 pl-3 select-none hover:bg-state-base-hover"
onClick={() => {
onOpenTagManagement()
setOpen(false)
}}
>
<Tag03Icon className="h-4 w-4 text-text-tertiary" />
<div className="grow truncate text-sm leading-5 text-text-secondary">
{t('tag.manageTags', { ns: 'common' })}
</div>
</div>
</div>
</div>
</PopoverContent>
<TagPanel
type={type}
inputValue={inputValue}
onInputValueChange={setInputValue}
onOpenTagManagement={onOpenTagManagement}
onClose={() => setOpen(false)}
/>
</ComboboxContent>
</div>
</Popover>
</Combobox>
)
}

View File

@ -1,129 +1,112 @@
import type { Tag, TagType } from '@/contract/console/tags'
import { toast } from '@langgenius/dify-ui/toast'
import { noop } from 'es-toolkit/function'
import { useMemo, useState } from 'react'
import type { TagComboboxItem } from './tag-combobox-item'
import type { TagType } from '@/contract/console/tags'
import { ComboboxInput, ComboboxInputGroup, ComboboxItem, ComboboxItemIndicator, ComboboxItemText, ComboboxList, ComboboxSeparator, useComboboxFilteredItems } from '@langgenius/dify-ui/combobox'
import { Fragment } from 'react'
import { useTranslation } from 'react-i18next'
import Checkbox from '@/app/components/base/checkbox'
import Divider from '@/app/components/base/divider'
import Input from '@/app/components/base/input'
import { useCreateTagMutation } from '../hooks/use-tag-mutations'
import { isCreateTagOption } from './tag-combobox-item'
type TagPanelProps = {
type: TagType
selectedTagIds: string[]
selectedTags: Tag[]
inputValue: string
onInputValueChange: (value: string) => void
onOpenTagManagement?: () => void
tagList: Tag[]
draftTagIds?: string[]
onDraftTagIdsChange?: (tagIds: string[]) => void
onClose?: () => void
}
export const TagPanel = (props: TagPanelProps) => {
const { t } = useTranslation()
const { type, selectedTagIds, selectedTags, tagList, onOpenTagManagement, onClose } = props
const createTagMutation = useCreateTagMutation()
const [localDraftTagIds, setLocalDraftTagIds] = useState<string[]>(selectedTagIds)
const draftTagIds = props.draftTagIds ?? localDraftTagIds
const onDraftTagIdsChange = props.onDraftTagIdsChange ?? setLocalDraftTagIds
const [keywords, setKeywords] = useState('')
const handleKeywordsChange = (value: string) => {
setKeywords(value)
}
const notExisted = useMemo(() => {
return tagList.every(tag => tag.type === type && tag.name !== keywords)
}, [type, tagList, keywords])
const filteredSelectedTagList = useMemo(() => {
return selectedTags.filter(tag => tag.name.includes(keywords))
}, [keywords, selectedTags])
const filteredTagList = useMemo(() => {
return tagList.filter(tag => tag.type === type && !selectedTagIds.includes(tag.id) && tag.name.includes(keywords))
}, [type, tagList, selectedTagIds, keywords])
const createNewTag = () => {
if (!keywords)
return
if (createTagMutation.isPending)
return
createTagMutation.mutate({
body: {
name: keywords,
type,
},
}, {
onSuccess: () => {
toast.success(t('tag.created', { ns: 'common' }))
setKeywords('')
},
onError: () => {
toast.error(t('tag.failed', { ns: 'common' }))
},
})
}
const selectTag = (tagId: string) => {
if (draftTagIds.includes(tagId))
onDraftTagIdsChange(draftTagIds.filter(v => v !== tagId))
else
onDraftTagIdsChange([...draftTagIds, tagId])
}
export const TagPanel = ({
type,
inputValue,
onInputValueChange,
onOpenTagManagement,
onClose,
}: TagPanelProps) => {
const { t } = useTranslation()
const filteredItems = useComboboxFilteredItems<TagComboboxItem>()
const realItemCount = filteredItems.filter(tag => !isCreateTagOption(tag)).length
const hasCreateOption = filteredItems.some(isCreateTagOption)
const placeholder = t('tag.selectorPlaceholder', { ns: 'common' }) || ''
return (
<div className="relative w-full rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur">
<div className="relative w-full">
<div className="p-2 pb-1">
<Input showLeftIcon showClearIcon value={keywords} placeholder={t('tag.selectorPlaceholder', { ns: 'common' }) || ''} onChange={e => handleKeywordsChange(e.target.value)} onClear={() => handleKeywordsChange('')} />
<ComboboxInputGroup className="border-divider-subtle bg-components-input-bg-normal">
<span aria-hidden="true" className="ml-2 i-ri-search-line h-4 w-4 shrink-0 text-text-tertiary" />
<ComboboxInput
aria-label={placeholder}
name={`tag-search-${type}`}
placeholder={placeholder}
className="pl-2"
/>
{inputValue && (
<button
type="button"
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:ring-1 focus-visible:ring-components-input-border-active focus-visible:ring-inset"
onClick={() => onInputValueChange('')}
onPointerDown={event => event.preventDefault()}
data-testid="tag-search-clear-button"
>
<span className="i-ri-close-line size-4" aria-hidden="true" />
</button>
)}
</ComboboxInputGroup>
</div>
{keywords && notExisted && (
<div className="p-1">
<div className="flex cursor-pointer items-center gap-x-1 rounded-lg px-2 py-1.5 hover:bg-state-base-hover" data-testid="create-tag-option" onClick={createNewTag}>
<span className="i-ri-add-line h-4 w-4 text-text-tertiary" />
<div className="grow truncate px-1 system-md-regular text-text-secondary">
{`${t('tag.create', { ns: 'common' })} `}
<span className="system-md-medium">{`'${keywords}'`}</span>
</div>
</div>
</div>
{filteredItems.length > 0 && (
<ComboboxList className="max-h-58">
{(tag: TagComboboxItem, index) => {
if (isCreateTagOption(tag)) {
return (
<Fragment key={tag.id}>
<ComboboxItem
value={tag}
index={index}
data-testid="create-tag-option"
>
<ComboboxItemText className="flex items-center gap-x-1 px-0">
<span aria-hidden="true" className="i-ri-add-line h-4 w-4 shrink-0 text-text-tertiary" />
<span className="min-w-0 grow truncate px-1 system-md-regular text-text-secondary">
{`${t('tag.create', { ns: 'common' })} `}
<span className="system-md-medium">{`'${tag.name}'`}</span>
</span>
</ComboboxItemText>
</ComboboxItem>
{realItemCount > 0 && <ComboboxSeparator />}
</Fragment>
)
}
return (
<ComboboxItem key={tag.id} value={tag} index={index}>
<ComboboxItemText title={tag.name}>{tag.name}</ComboboxItemText>
<ComboboxItemIndicator />
</ComboboxItem>
)
}}
</ComboboxList>
)}
{keywords && notExisted && filteredTagList.length > 0 && (<Divider type="horizontal" className="my-0 h-px bg-divider-subtle" />)}
{(filteredTagList.length > 0 || filteredSelectedTagList.length > 0) && (
<div className="max-h-[232px] overflow-y-auto p-1">
{filteredSelectedTagList.map(tag => (
<div key={tag.id} className="flex cursor-pointer items-center gap-x-1 rounded-lg px-2 py-1.5 hover:bg-state-base-hover" onClick={() => selectTag(tag.id)} data-testid="tag-row">
<Checkbox className="shrink-0" checked={draftTagIds.includes(tag.id)} onCheck={noop} id={tag.id} />
<div title={tag.name} className="grow truncate px-1 system-md-regular text-text-secondary">
{tag.name}
</div>
</div>
))}
{filteredTagList.map(tag => (
<div key={tag.id} className="flex cursor-pointer items-center gap-x-1 rounded-lg px-2 py-1.5 hover:bg-state-base-hover" onClick={() => selectTag(tag.id)} data-testid="tag-row">
<Checkbox className="shrink-0" checked={draftTagIds.includes(tag.id)} onCheck={noop} id={tag.id} />
<div title={tag.name} className="grow truncate px-1 system-md-regular text-text-secondary">
{tag.name}
</div>
</div>
))}
</div>
)}
{!keywords && !filteredTagList.length && !filteredSelectedTagList.length && (
{!hasCreateOption && realItemCount === 0 && (
<div className="p-1">
<div className="flex flex-col items-center gap-y-1 p-3">
<span className="i-ri-price-tag-3-line h-6 w-6 text-text-quaternary" />
<span aria-hidden="true" className="i-ri-price-tag-3-line h-6 w-6 text-text-quaternary" />
<div className="system-xs-regular text-text-tertiary">{t('tag.noTag', { ns: 'common' })}</div>
</div>
</div>
)}
<Divider type="horizontal" className="my-0 h-px bg-divider-subtle" />
<ComboboxSeparator />
<div className="p-1">
<div
className="flex cursor-pointer items-center gap-x-1 rounded-lg px-2 py-1.5 hover:bg-state-base-hover"
<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-1 focus-visible:ring-components-input-border-active"
onClick={() => {
onOpenTagManagement?.()
onClose?.()
}}
>
<span className="i-ri-price-tag-3-line h-4 w-4 text-text-tertiary" />
<div className="grow truncate px-1 system-md-regular text-text-secondary">
<span aria-hidden="true" className="i-ri-price-tag-3-line h-4 w-4 text-text-tertiary" />
<span className="min-w-0 grow truncate px-1 system-md-regular text-text-secondary">
{t('tag.manageTags', { ns: 'common' })}
</div>
</div>
</span>
</button>
</div>
</div>
)

View File

@ -1,46 +1,73 @@
import type { Tag } from '@/contract/console/tags'
import type { ComboboxRootProps } from '@langgenius/dify-ui/combobox'
import type { ComponentProps } from 'react'
import type { TagComboboxItem } from './tag-combobox-item'
import type { Tag, TagType } from '@/contract/console/tags'
import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { Combobox, ComboboxContent, ComboboxTrigger } from '@langgenius/dify-ui/combobox'
import { toast } from '@langgenius/dify-ui/toast'
import { useQuery } from '@tanstack/react-query'
import { useCallback, useState } from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { useApplyTagBindingsMutation } from '../hooks/use-tag-mutations'
import { useApplyTagBindingsMutation, useCreateTagMutation } from '../hooks/use-tag-mutations'
import { isCreateTagOption } from './tag-combobox-item'
import { TagPanel } from './tag-panel'
import { TagTrigger } from './tag-trigger'
type TagSelectorProps = {
const TAG_COMBOBOX_FILTER: NonNullable<ComboboxRootProps<TagComboboxItem, true>['filter']> = (tag, query) => tag.name.includes(query)
const tagToString = (tag: TagComboboxItem) => tag.name
const isSameTag = (item: TagComboboxItem, value: TagComboboxItem) => item.id === value.id
type TagSelectorRootProps = Omit<
ComboboxRootProps<TagComboboxItem, true>,
| 'items'
| 'multiple'
| 'value'
| 'defaultValue'
| 'onValueChange'
| 'inputValue'
| 'defaultInputValue'
| 'onInputValueChange'
| 'filter'
| 'itemToStringLabel'
| 'isItemEqualToValue'
| 'open'
| 'defaultOpen'
| 'onOpenChange'
| 'onOpenChangeComplete'
| 'children'
>
type TagSelectorContentProps = Pick<ComponentProps<typeof ComboboxContent>, 'placement' | 'sideOffset' | 'alignOffset' | 'portalProps' | 'positionerProps' | 'popupProps' | 'popupClassName'>
type TagSelectorProps = TagSelectorRootProps & TagSelectorContentProps & {
targetId: string
isPopover?: boolean
position?: 'bl' | 'br'
type: 'knowledge' | 'app'
selectedTagIds: string[]
selectedTags: Tag[]
type: TagType
value: Tag[]
onOpenTagManagement?: () => void
onTagsChange?: () => void
minWidth?: number | string
}
export const TagSelector = ({
targetId,
isPopover = true,
position,
type,
selectedTagIds,
selectedTags,
value,
onOpenTagManagement = () => {},
onTagsChange,
minWidth,
placement = 'bottom-start',
sideOffset = 4,
alignOffset = 0,
portalProps,
positionerProps,
popupProps,
popupClassName,
...rootProps
}: TagSelectorProps) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [draftTagIds, setDraftTagIds] = useState<string[]>(selectedTagIds)
const [draftTags, setDraftTags] = useState<Tag[]>(value)
const [inputValue, setInputValue] = useState('')
const applyTagBindingsMutation = useApplyTagBindingsMutation()
const createTagMutation = useCreateTagMutation()
const { data: tagList = [] } = useQuery(consoleQuery.tags.list.queryOptions({
input: {
query: {
@ -49,20 +76,51 @@ export const TagSelector = ({
},
}))
const tagNames = selectedTags.length
? selectedTags.filter(selectedTag => tagList.find(tag => tag.id === selectedTag.id)).map(tag => tag.name)
: []
const placement = position === 'bl'
? 'bottom-start'
: position === 'br'
? 'bottom-end'
: 'bottom'
const resolvedMinWidth = minWidth == null
? undefined
: typeof minWidth === 'number' ? `${minWidth}px` : minWidth
const selectedTagIds = useMemo(() => value.map(tag => tag.id), [value])
const tagNames = useMemo(() => {
if (!value.length)
return []
const tagNameById = new Map(tagList.map(tag => [tag.id, tag.name]))
return value.flatMap((tag) => {
const tagName = tagNameById.get(tag.id)
return tagName ? [tagName] : []
})
}, [tagList, value])
const triggerLabel = tagNames.length ? tagNames.join(', ') : t('tag.addTag', { ns: 'common' })
const items = useMemo<TagComboboxItem[]>(() => {
const tagIds = new Set<string>()
const nextItems: TagComboboxItem[] = []
for (const tag of tagList) {
if (tag.type !== type)
continue
tagIds.add(tag.id)
nextItems.push(tag)
}
for (const tag of value) {
if (tag.type === type && !tagIds.has(tag.id))
nextItems.push(tag)
}
if (inputValue && nextItems.every(tag => tag.name !== inputValue)) {
nextItems.unshift({
id: `__create_tag__:${inputValue}`,
name: inputValue,
type,
binding_count: 0,
isCreateOption: true,
})
}
return nextItems
}, [inputValue, tagList, type, value])
const applyTagBindings = useCallback(() => {
const draftTagIds = draftTags.map(tag => tag.id)
const draftTagIdSet = new Set(draftTagIds)
const tagSelectionChanged = selectedTagIds.length !== draftTagIds.length
|| selectedTagIds.some(tagId => !draftTagIdSet.has(tagId))
@ -92,53 +150,91 @@ export const TagSelector = ({
onTagsChange?.()
},
})
}, [applyTagBindingsMutation, draftTagIds, onTagsChange, selectedTagIds, t, targetId, type])
}, [applyTagBindingsMutation, draftTags, onTagsChange, selectedTagIds, t, targetId, type])
const handleOpenChange = useCallback((nextOpen: boolean) => {
if (nextOpen)
setDraftTagIds(selectedTagIds)
else
if (nextOpen) {
setDraftTags(value)
}
else {
applyTagBindings()
}
setOpen(nextOpen)
}, [applyTagBindings, selectedTagIds])
}, [applyTagBindings, value])
if (!isPopover)
return null
const createNewTag = useCallback((name: string) => {
if (!name || createTagMutation.isPending)
return
createTagMutation.mutate({
body: {
name,
type,
},
}, {
onSuccess: () => {
toast.success(t('tag.created', { ns: 'common' }))
setInputValue('')
},
onError: () => {
toast.error(t('tag.failed', { ns: 'common' }))
},
})
}, [createTagMutation, t, type])
const handleValueChange = useCallback((nextTags: TagComboboxItem[]) => {
const createOption = nextTags.find(isCreateTagOption)
if (createOption) {
createNewTag(createOption.name)
return
}
setDraftTags(nextTags.filter(tag => !isCreateTagOption(tag)))
}, [createNewTag])
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger
<Combobox
{...rootProps}
open={open}
onOpenChange={handleOpenChange}
items={items}
multiple
value={draftTags}
onValueChange={handleValueChange}
inputValue={inputValue}
onInputValueChange={setInputValue}
filter={TAG_COMBOBOX_FILTER}
itemToStringLabel={tagToString}
isItemEqualToValue={isSameTag}
>
<ComboboxTrigger
aria-label={triggerLabel}
className={cn(
open ? 'bg-state-base-hover' : 'bg-transparent',
'block w-full rounded-lg border-0 p-0 text-left focus:outline-hidden focus-visible:ring-1 focus-visible:ring-components-input-border-hover',
'block h-auto w-full rounded-lg border-0 bg-transparent p-0 text-left hover:bg-transparent focus:outline-hidden focus-visible:bg-transparent focus-visible:ring-1 focus-visible:ring-components-input-border-hover focus-visible:ring-inset data-open:bg-state-base-hover data-open:hover:bg-state-base-hover',
)}
icon={false}
>
<TagTrigger tags={tagNames} />
</PopoverTrigger>
<PopoverContent
</ComboboxTrigger>
<ComboboxContent
placement={placement}
sideOffset={4}
popupClassName="overflow-hidden rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]"
popupProps={{
style: {
width: 'var(--anchor-width, auto)',
minWidth: resolvedMinWidth,
},
}}
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)}
>
<TagPanel
type={type}
selectedTagIds={selectedTagIds}
selectedTags={selectedTags}
draftTagIds={draftTagIds}
onDraftTagIdsChange={setDraftTagIds}
tagList={tagList}
inputValue={inputValue}
onInputValueChange={setInputValue}
onOpenTagManagement={onOpenTagManagement}
onClose={() => handleOpenChange(false)}
/>
</PopoverContent>
</Popover>
</ComboboxContent>
</Combobox>
)
}

View File

@ -13,8 +13,8 @@ export const TagTrigger = ({
<div className="flex w-full cursor-pointer items-center gap-1 overflow-hidden rounded-lg p-1 hover:bg-state-base-hover">
{!tags.length
? (
<div className="flex max-w-full min-w-0 items-center gap-x-0.5 rounded-[5px] border border-dashed border-divider-deep bg-components-badge-bg-dimm px-[5px] py-[3px]">
<span className="i-ri-price-tag-3-line h-3 w-3 shrink-0 text-text-quaternary" />
<div className="flex max-w-full min-w-0 items-center gap-x-0.5 rounded-[5px] border border-dashed border-divider-deep bg-components-badge-bg-dimm px-1.25 py-0.75">
<span aria-hidden="true" className="i-ri-price-tag-3-line h-3 w-3 shrink-0 text-text-quaternary" />
<div className="truncate system-2xs-medium-uppercase text-text-tertiary">
{t('tag.addTag', { ns: 'common' })}
</div>
@ -27,10 +27,10 @@ export const TagTrigger = ({
return (
<div
key={content}
className="flex max-w-[120px] min-w-0 shrink-0 items-center gap-x-0.5 rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-[5px] py-[3px]"
className="flex max-w-30 min-w-0 shrink-0 items-center gap-x-0.5 rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1.25 py-0.75"
data-testid={`tag-badge-${content}`}
>
<span className="i-ri-price-tag-3-line h-3 w-3 shrink-0 text-text-quaternary" />
<span aria-hidden="true" className="i-ri-price-tag-3-line h-3 w-3 shrink-0 text-text-quaternary" />
<div className="truncate system-2xs-medium-uppercase text-text-tertiary">
{content}
</div>

View File

@ -1,122 +0,0 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import type { Tag } from '@/contract/console/tags'
import { ToastHost } from '@langgenius/dify-ui/toast'
import { useEffect, useState } from 'react'
import { TagManagementModal } from '@/features/tag-management/components/tag-management-modal'
const INITIAL_TAGS: Tag[] = [
{ id: 'tag-product', name: 'Product', type: 'app', binding_count: 12 },
{ id: 'tag-growth', name: 'Growth', type: 'app', binding_count: 4 },
{ id: 'tag-beta', name: 'Beta User', type: 'app', binding_count: 2 },
{ id: 'tag-rag', name: 'RAG', type: 'knowledge', binding_count: 3 },
{ id: 'tag-updates', name: 'Release Notes', type: 'knowledge', binding_count: 6 },
]
const TagManagementPlayground = ({
type = 'app',
}: {
type?: 'app' | 'knowledge'
}) => {
const [showModal, setShowModal] = useState(true)
useEffect(() => {
const originalFetch = globalThis.fetch?.bind(globalThis)
let tags = [...INITIAL_TAGS]
const handler = async (input: RequestInfo | URL, init?: RequestInit) => {
const request = input instanceof Request ? input : new Request(input, init)
const url = request.url
const method = request.method.toUpperCase()
const parsedUrl = new URL(url, window.location.origin)
if (parsedUrl.pathname.endsWith('/tags')) {
if (method === 'GET') {
const tagType = parsedUrl.searchParams.get('type') || 'app'
const payload = tags.filter(tag => tag.type === tagType)
return new Response(JSON.stringify(payload), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}
if (method === 'POST') {
const body = await request.clone().json() as { name: string, type: Tag['type'] }
const newTag: Tag = {
id: `tag-${Date.now()}`,
name: body.name,
type: body.type,
binding_count: 0,
}
tags = [newTag, ...tags]
return new Response(JSON.stringify(newTag), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}
}
if (parsedUrl.pathname.endsWith('/tag-bindings') || parsedUrl.pathname.endsWith('/tag-bindings/remove')) {
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}
if (originalFetch)
return originalFetch(request)
throw new Error(`Unhandled request in mock fetch: ${url}`)
}
globalThis.fetch = handler as typeof globalThis.fetch
return () => {
if (originalFetch)
globalThis.fetch = originalFetch
}
}, [])
return (
<>
<ToastHost />
<div className="flex w-full max-w-xl flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
<button
type="button"
className="self-start rounded-md border border-divider-subtle bg-background-default px-3 py-1.5 text-xs font-medium text-text-secondary hover:bg-state-base-hover"
onClick={() => setShowModal(true)}
>
Manage tags
</button>
<p className="text-xs text-text-tertiary">Mocked tag management flows with create and bind actions.</p>
</div>
<TagManagementModal show={showModal} type={type} onClose={() => setShowModal(false)} />
</>
)
}
const meta = {
title: 'Base/Data Display/TagManagementModal',
component: TagManagementPlayground,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Complete tag management modal with mocked service calls for browsing and creating tags.',
},
},
},
argTypes: {
type: {
control: 'radio',
options: ['app', 'knowledge'],
},
},
args: {
type: 'app',
},
tags: ['autodocs'],
} satisfies Meta<typeof TagManagementPlayground>
export default meta
type Story = StoryObj<typeof meta>
export const Playground: Story = {}