mirror of
https://github.com/langgenius/dify.git
synced 2026-05-09 12:59:18 +08:00
refactor(web): migrate tag controls to combobox (#35881)
This commit is contained in:
parent
8b77ec7f31
commit
cd66559ebf
@ -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
|
||||
}
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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} />)
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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' }))
|
||||
|
||||
@ -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 && (
|
||||
<>
|
||||
|
||||
@ -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} />)
|
||||
|
||||
@ -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('')
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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: [],
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
15
web/features/tag-management/components/tag-combobox-item.ts
Normal file
15
web/features/tag-management/components/tag-combobox-item.ts
Normal 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
|
||||
}
|
||||
@ -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>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 = {}
|
||||
Loading…
Reference in New Issue
Block a user