diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 3692f9cc6a30ca..59ac466da891dc 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -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 } diff --git a/web/__tests__/apps/app-card-operations-flow.test.tsx b/web/__tests__/apps/app-card-operations-flow.test.tsx index b0854072d25118..ef3bee5167356f 100644 --- a/web/__tests__/apps/app-card-operations-flow.test.tsx +++ b/web/__tests__/apps/app-card-operations-flow.test.tsx @@ -53,6 +53,16 @@ vi.mock('@/next/navigation', () => ({ }), })) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useQuery: () => ({ + data: [], + }), + } +}) + // Mock headless UI Popover so it renders content without transition vi.mock('@headlessui/react', async () => { const actual = await vi.importActual('@headlessui/react') diff --git a/web/__tests__/apps/app-list-browsing-flow.test.tsx b/web/__tests__/apps/app-list-browsing-flow.test.tsx index e6b83bd69d2457..4829adacf0e9f9 100644 --- a/web/__tests__/apps/app-list-browsing-flow.test.tsx +++ b/web/__tests__/apps/app-list-browsing-flow.test.tsx @@ -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() 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') }) }) diff --git a/web/app/components/apps/__tests__/app-card.spec.tsx b/web/app/components/apps/__tests__/app-card.spec.tsx index 4edf5604dada09..c84161747470ba 100644 --- a/web/app/components/apps/__tests__/app-card.spec.tsx +++ b/web/app/components/apps/__tests__/app-card.spec.tsx @@ -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() + 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() diff --git a/web/app/components/apps/app-card.tsx b/web/app/components/apps/app-card.tsx index 06c5c8a9d84e72..f623f8ce53fd6d 100644 --- a/web/app/components/apps/app-card.tsx +++ b/web/app/components/apps/app-card.tsx @@ -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" >
@@ -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', )} >
@@ -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() diff --git a/web/app/components/workflow/nodes/tool/components/tool-form/__tests__/item.spec.tsx b/web/app/components/workflow/nodes/tool/components/tool-form/__tests__/item.spec.tsx index e935085fe90490..42478ca34dcbd0 100644 --- a/web/app/components/workflow/nodes/tool/components/tool-form/__tests__/item.spec.tsx +++ b/web/app/components/workflow/nodes/tool/components/tool-form/__tests__/item.spec.tsx @@ -171,7 +171,7 @@ describe('tool/tool-form/item', () => { } as unknown as SchemaRoot, }) - const { container } = render( + render( { />, ) - 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' })) diff --git a/web/app/components/workflow/nodes/tool/components/tool-form/item.tsx b/web/app/components/workflow/nodes/tool/components/tool-form/item.tsx index 6cd13984f0e2c3..1cb3179135c7b6 100644 --- a/web/app/components/workflow/nodes/tool/components/tool-form/item.tsx +++ b/web/app/components/workflow/nodes/tool/components/tool-form/item.tsx @@ -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 = ({
*
)} {!showDescription && tooltip && ( - - {tooltip[language] || tooltip.en_US} -
- )} - triggerClassName="ml-1 w-4 h-4" - asChild={false} - /> + + {tooltip[language] || tooltip.en_US} + )} {showSchemaButton && ( <> diff --git a/web/features/tag-management/__tests__/dataset-card-tags.spec.tsx b/web/features/tag-management/__tests__/dataset-card-tags.spec.tsx index b79e16d5e99bab..7c950becc31e4a 100644 --- a/web/features/tag-management/__tests__/dataset-card-tags.spec.tsx +++ b/web/features/tag-management/__tests__/dataset-card-tags.spec.tsx @@ -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 }) => (
-
{selectedTagIds.join(',')}
+
{value.map(tag => tag.id).join(',')}
- {selectedTags.length} + {value.length} {' '} tags
@@ -75,7 +73,9 @@ describe('DatasetCardTags', () => { const onClick = vi.fn() const { container } = render() - 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() - 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() - 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() diff --git a/web/features/tag-management/__tests__/tag-filter.spec.tsx b/web/features/tag-management/__tests__/tag-filter.spec.tsx index a00c9ac93dda89..f19cb2a18115a9 100644 --- a/web/features/tag-management/__tests__/tag-filter.spec.tsx +++ b/web/features/tag-management/__tests__/tag-filter.spec.tsx @@ -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('') diff --git a/web/features/tag-management/__tests__/tag-panel.spec.tsx b/web/features/tag-management/__tests__/tag-panel.spec.tsx index 1f1d0a241b4ba1..65b2f0c2856f71 100644 --- a/web/features/tag-management/__tests__/tag-panel.spec.tsx +++ b/web/features/tag-management/__tests__/tag-panel.spec.tsx @@ -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, -})) - -// Hoisted mocks -const { createTag, bindTag, unBindTag } = vi.hoisted(() => ({ - createTag: vi.fn(), - bindTag: vi.fn(), - unBindTag: vi.fn(), +const { onValueChangeSpy } = vi.hoisted(() => ({ + onValueChangeSpy: 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[] = [] - - 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 +} + +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(value) + const [inputValue, setInputValue] = useState('') + const items = useMemo(() => { + 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 ( + { + onValueChangeSpy(nextTags) + if (nextTags.some(isCreateTagOption)) + return + setSelectedTags(nextTags) + }} + inputValue={inputValue} + onInputValueChange={setInputValue} + filter={tagFilter} + itemToStringLabel={tagToString} + isItemEqualToValue={isSameTag} + > + + + ) } -describe('Panel', () => { +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() - expect(screen.getByPlaceholderText(i18n.selectorPlaceholder))!.toBeInTheDocument() - }) - - it('should render the search input', () => { - render() - 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['i18n'], - ready: true, - } as unknown as ReturnType - - vi.spyOn(ReactI18next, 'useTranslation').mockReturnValueOnce(mockedTranslation) - - render() - - expect(screen.getByRole('textbox'))!.toHaveAttribute('placeholder', '') - }) - - it('should render selected tags from selectedTags prop', () => { - render() - expect(screen.getByText('Frontend'))!.toBeInTheDocument() - }) - - it('should render unselected tags matching the type', () => { - render() - // 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() - // 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() - expect(screen.getByText(i18n.manageTags))!.toBeInTheDocument() - }) - - it('should show no-tag message when there are no tags', () => { - render() - expect(screen.getByText(i18n.noTag))!.toBeInTheDocument() - }) - - it('should not show no-tag message when tags exist', () => { - render() - expect(screen.queryByText(i18n.noTag)).not.toBeInTheDocument() - }) - }) + it('renders search, selected tags, unselected tags, and management action', () => { + render() - describe('Search / Filter', () => { - it('should filter tags by keyword', async () => { - const user = userEvent.setup() - render() - - const input = screen.getByPlaceholderText(i18n.selectorPlaceholder) - await user.type(input, '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() - - 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() - - 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() - - 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() - - 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('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('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('filters options by the controlled combobox input value', async () => { + const user = userEvent.setup() + render() - it('should select an unselected tag when clicked', async () => { - const user = userEvent.setup() - render() + await user.type(screen.getByRole('combobox', { name: i18n.selectorPlaceholder }), 'Back') - const backendRowBeforeSelect = getTagRow('Backend') - expect(within(backendRowBeforeSelect).queryByTestId('check-icon-tag-2')).not.toBeInTheDocument() + expect(screen.getByRole('option', { name: /Backend/i })).toBeInTheDocument() + expect(screen.queryByRole('option', { name: /API/i })).not.toBeInTheDocument() + }) - await user.click(screen.getByText('Backend')) + it('clears only the search input from the input clear button', async () => { + const user = userEvent.setup() + render() - const backendRowAfterSelect = getTagRow('Backend') - expect(within(backendRowAfterSelect).getByTestId('check-icon-tag-2'))!.toBeInTheDocument() - }) + const input = screen.getByRole('combobox', { name: i18n.selectorPlaceholder }) + await user.type(input, 'Back') + expect(input).toHaveValue('Back') + vi.clearAllMocks() - it('should deselect a selected tag when clicked', async () => { - const user = userEvent.setup() - render() + await user.click(screen.getByRole('button', { name: i18n.operationClear })) - const frontendRowBeforeDeselect = getTagRow('Frontend') - expect(within(frontendRowBeforeDeselect).getByTestId('check-icon-tag-1'))!.toBeInTheDocument() + expect(input).toHaveValue('') + expect(onValueChangeSpy).not.toHaveBeenCalled() + expect(screen.getByRole('option', { name: /Frontend/i })).toHaveAttribute('aria-selected', 'true') + }) - await user.click(screen.getByText('Frontend')) + it('shows a create option when the query is not an existing tag name', async () => { + const user = userEvent.setup() + render() - const frontendRowAfterDeselect = getTagRow('Frontend') - expect(within(frontendRowAfterDeselect).queryByTestId('check-icon-tag-1')).not.toBeInTheDocument() - }) + await user.type(screen.getByRole('combobox', { name: i18n.selectorPlaceholder }), 'BrandNewTag') - it('should toggle tag selection on multiple clicks', async () => { - const user = userEvent.setup() - render() + expect(screen.getByTestId('create-tag-option')).toHaveTextContent(i18n.create) + expect(screen.getByTestId('create-tag-option')).toHaveTextContent('BrandNewTag') + }) - const backendRowBeforeToggle = getTagRow('Backend') - expect(within(backendRowBeforeToggle).queryByTestId('check-icon-tag-2')).not.toBeInTheDocument() + it('does not show a create option for an exact existing tag name', async () => { + const user = userEvent.setup() + render() - await user.click(screen.getByText('Backend')) + await user.type(screen.getByRole('combobox', { name: i18n.selectorPlaceholder }), 'Frontend') - const backendRowAfterFirstClick = getTagRow('Backend') - expect(within(backendRowAfterFirstClick).getByTestId('check-icon-tag-2'))!.toBeInTheDocument() + expect(screen.queryByTestId('create-tag-option')).not.toBeInTheDocument() + }) - await user.click(screen.getByText('Backend')) + it('updates only the combobox draft value when selecting and deselecting options', async () => { + const user = userEvent.setup() + render() - const backendRowAfterSecondClick = getTagRow('Backend') - expect(within(backendRowAfterSecondClick).queryByTestId('check-icon-tag-2')).not.toBeInTheDocument() - }) - }) + await user.click(screen.getByRole('option', { name: /Backend/i })) + expect(onValueChangeSpy).toHaveBeenLastCalledWith(expect.arrayContaining([expect.objectContaining({ id: 'tag-2' })])) - describe('Tag Creation', () => { - beforeEach(() => { - // notExisted requires all tags to be same type, so remove knowledgeTag - }) - - it('should create a new tag when clicking the create option', async () => { - const user = userEvent.setup() - render() - - 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() - - 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() - - 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() - - 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() - - // 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() - }) + await user.click(screen.getByRole('option', { name: /Backend/i })) + expect(onValueChangeSpy).toHaveBeenLastCalledWith([expect.objectContaining({ id: 'tag-1' })]) }) - describe('Binding Selection State', () => { - it('should not submit tag bindings on panel unmount', async () => { - const user = userEvent.setup() - const { unmount } = render() + it('routes create option activation through the combobox value change API', async () => { + const user = userEvent.setup() + render() - await user.click(screen.getByText('Backend')) - unmount() + const input = screen.getByRole('combobox', { name: i18n.selectorPlaceholder }) + await user.type(input, 'BrandNewTag') + await user.click(screen.getByTestId('create-tag-option')) + + expect(onValueChangeSpy).toHaveBeenLastCalledWith(expect.arrayContaining([ + expect.objectContaining({ + isCreateOption: true, + name: 'BrandNewTag', + }), + ])) + }) - await act(async () => { }) - expect(bindTag).not.toHaveBeenCalled() - expect(unBindTag).not.toHaveBeenCalled() - expect(mockNotify).not.toHaveBeenCalled() - }) + it('renders the empty state when no tags exist and no search is active', () => { + render() + expect(screen.getByText(i18n.noTag)).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() + it('opens tag management through a semantic button', async () => { + const user = userEvent.setup() + const onOpenTagManagement = vi.fn() + render() - await user.click(screen.getByText(i18n.manageTags)) + await user.click(screen.getByRole('button', { name: i18n.manageTags })) - expect(onOpenTagManagement).toHaveBeenCalledTimes(1) - }) + expect(onOpenTagManagement).toHaveBeenCalledTimes(1) }) - describe('Edge Cases', () => { - it('should handle empty value array', () => { - render() - // 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('should handle empty tagList', () => { - render() - expect(screen.getByText(i18n.noTag))!.toBeInTheDocument() - }) - - it('should handle all tags already selected', () => { - render( - , - ) - // 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() - }) - - 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() - 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('should handle knowledge type tags correctly', () => { - render( - , - ) - expect(screen.getByText('KnowledgeDB'))!.toBeInTheDocument() - }) + it('renders knowledge tags when the panel type is knowledge', () => { + render() + expect(screen.getByRole('option', { name: /KnowledgeDB/i })).toBeInTheDocument() }) }) diff --git a/web/features/tag-management/__tests__/tag-selector.spec.tsx b/web/features/tag-management/__tests__/tag-selector.spec.tsx index 01983dff7b0f7c..c1419998d16fe3 100644 --- a/web/features/tag-management/__tests__/tag-selector.spec.tsx +++ b/web/features/tag-management/__tests__/tag-selector.spec.tsx @@ -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 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() - expect(screen.getByText('Frontend'))!.toBeInTheDocument() - }) - - it('should render TagSelector add-tag placeholder when defaultProps are overridden with empty selectedTags and value', () => { - render() - expect(screen.getByText(i18n.addTag))!.toBeInTheDocument() - }) - - it('should render nothing when isPopover is false', () => { - const { container } = render() - // Only the empty fragment wrapper - // Only the empty fragment wrapper - expect(container)!.toBeEmptyDOMElement() - }) - - it('should render the popover trigger button', () => { - render() - // The trigger is wrapped in a PopoverButton - // The trigger is wrapped in a PopoverButton - expect(screen.getByRole('button'))!.toBeInTheDocument() - }) - - it('should render when minWidth is provided', () => { - render() - expect(screen.getByRole('button'))!.toBeInTheDocument() - }) + it('renders selected tag names in the combobox trigger', () => { + render() + expect(screen.getByText('Frontend')).toBeInTheDocument() }) - 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( - , - ) - // '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('should display multiple tag names when multiple are selected', () => { - render( - , - ) - expect(screen.getByText('Frontend'))!.toBeInTheDocument() - expect(screen.getByText('Backend'))!.toBeInTheDocument() - }) + it('renders the add tag trigger when no current tag is visible in the workspace tag list', () => { + render() + expect(screen.queryByText('Orphan')).not.toBeInTheDocument() + expect(screen.getByText(i18n.addTag)).toBeInTheDocument() }) - describe('Popover Interaction', () => { - it('should show the panel when the trigger is clicked', async () => { - const user = userEvent.setup() - render() - - await user.click(screen.getByRole('button')) - - // Panel renders the search input and manage tags - await waitFor(() => { - expect(screen.getByPlaceholderText(i18n.selectorPlaceholder))!.toBeInTheDocument() - expect(screen.getByText(i18n.manageTags))!.toBeInTheDocument() - }) - }) - - it('should show unselected tags in the panel', async () => { - const user = userEvent.setup() - render() - - await user.click(screen.getByRole('button')) - - 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() - - 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() - - const triggerButton = screen.getByRole('button', { name: /Frontend/i }) - await user.click(triggerButton) + it('opens a searchable combobox popup', async () => { + const user = userEvent.setup() + render() - await screen.findByPlaceholderText(i18n.selectorPlaceholder) - await user.click(getPanelTagRow('Backend')) + await user.click(screen.getByRole('combobox', { name: /Frontend/i })) - // 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() - - 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', - }) - }) - }) + expect(await screen.findByRole('combobox', { name: i18n.selectorPlaceholder })).toBeInTheDocument() + expect(screen.getByText(i18n.manageTags)).toBeInTheDocument() + expect(screen.getByRole('option', { name: /Backend/i })).toBeInTheDocument() + }) - it('should unbind a deselected tag when closing the panel', async () => { - const user = userEvent.setup() - render() + it('applies added tags only when the popup closes', async () => { + const user = userEvent.setup() + render() - const triggerButton = screen.getByRole('button', { name: /Frontend/i }) - await user.click(triggerButton) + const trigger = screen.getByRole('combobox', { name: /Frontend/i }) + await user.click(trigger) + await user.click(await screen.findByRole('option', { name: /Backend/i })) - await screen.findByPlaceholderText(i18n.selectorPlaceholder) - await user.click(getPanelTagRow('Frontend')) + expect(bindTag).not.toHaveBeenCalled() - // Close panel to trigger unmount side effects. - await user.click(triggerButton) + await user.click(trigger) - await waitFor(() => { - expect(unBindTag).toHaveBeenCalledTimes(1) - expect(unBindTag).toHaveBeenCalledWith('tag-1', 'target-1', 'app') - }) + await waitFor(() => { + expect(bindTag).toHaveBeenCalledWith(['tag-2'], '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() - - 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() - - 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() + expect(mockToast.success).toHaveBeenCalledWith(i18n.modifiedSuccessfully, { + id: 'tag-bindings-app-target-1', }) + }) - it('should notify tag changes after bindings are applied successfully', async () => { - const user = userEvent.setup() - const onTagsChange = vi.fn() - render() - - const triggerButton = screen.getByRole('button', { name: /Frontend/i }) - await user.click(triggerButton) + it('applies removed tags only when the popup closes', async () => { + const user = userEvent.setup() + render() - await screen.findByPlaceholderText(i18n.selectorPlaceholder) - await user.click(getPanelTagRow('Backend')) - await user.click(triggerButton) + 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(onTagsChange).toHaveBeenCalledTimes(1) - }) + await waitFor(() => { + expect(unBindTag).toHaveBeenCalledWith('tag-1', 'target-1', 'app') }) + }) - 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() + it('does not submit unchanged draft selections on close', async () => { + const user = userEvent.setup() + const onTagsChange = vi.fn() + render() + + const trigger = screen.getByRole('combobox', { name: /Frontend/i }) + await user.click(trigger) + await screen.findByRole('combobox', { name: i18n.selectorPlaceholder }) + await user.click(trigger) + + expect(bindTag).not.toHaveBeenCalled() + expect(unBindTag).not.toHaveBeenCalled() + expect(mockToast.success).not.toHaveBeenCalled() + expect(mockToast.error).not.toHaveBeenCalled() + expect(onTagsChange).not.toHaveBeenCalled() + }) - const triggerButton = screen.getByRole('button', { name: /Frontend/i }) - await user.click(triggerButton) + it('notifies after apply settles with success or error', async () => { + const user = userEvent.setup() + const onTagsChange = vi.fn() + render() - await screen.findByPlaceholderText(i18n.selectorPlaceholder) - await user.click(getPanelTagRow('Frontend')) - await user.click(triggerButton) + 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) - }) + await waitFor(() => { + expect(onTagsChange).toHaveBeenCalledTimes(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 }) - - render() + it('shows an error toast when applying bindings fails', async () => { + const user = userEvent.setup() + vi.mocked(unBindTag).mockRejectedValueOnce(new Error('Unbind failed')) + render() - 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.getByPlaceholderText(i18n.selectorPlaceholder))!.toBeInTheDocument() + await waitFor(() => { + expect(mockToast.error).toHaveBeenCalledWith(i18n.modifiedUnsuccessfully, { + id: 'tag-bindings-app-target-1', }) - - 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( - , - ) - // 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( - , - ) - - 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') + it('creates a tag with the current tag type without binding it implicitly', async () => { + const user = userEvent.setup() + render() - const createOption = await screen.findByTestId('create-tag-option') - await user.click(createOption) + 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 waitFor(() => { - expect(createTag).toHaveBeenCalledWith('NewKnowledgeTag', 'knowledge') - }) + await waitFor(() => { + expect(createTag).toHaveBeenCalledWith('NewKnowledgeTag', 'knowledge') }) + expect(bindTag).not.toHaveBeenCalled() }) }) diff --git a/web/features/tag-management/components/__tests__/app-card-tags.spec.tsx b/web/features/tag-management/components/__tests__/app-card-tags.spec.tsx index 916202d6fd485f..26d6cf67aa5bd4 100644 --- a/web/features/tag-management/components/__tests__/app-card-tags.spec.tsx +++ b/web/features/tag-management/components/__tests__/app-card-tags.spec.tsx @@ -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', () => ({
{props.targetId} {props.type} - {props.selectedTagIds.join(',')} - {props.selectedTags.map(tag => tag.name).join(',')} + {props.value.map(tag => tag.id).join(',')} + {props.value.map(tag => tag.name).join(',')}
@@ -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: [], })) }) }) diff --git a/web/features/tag-management/components/app-card-tags.tsx b/web/features/tag-management/components/app-card-tags.tsx index 034cb30023c1c9..91c0b6783f5060 100644 --- a/web/features/tag-management/components/app-card-tags.tsx +++ b/web/features/tag-management/components/app-card-tags.tsx @@ -17,15 +17,14 @@ export const AppCardTags = ({ return (
tag.id)} - selectedTags={tags} + value={tags} onOpenTagManagement={onOpenTagManagement} onTagsChange={onTagsChange} /> -
+
) } diff --git a/web/features/tag-management/components/dataset-card-tags.tsx b/web/features/tag-management/components/dataset-card-tags.tsx index 5376dc0690fe1b..50131b1ce7bd46 100644 --- a/web/features/tag-management/components/dataset-card-tags.tsx +++ b/web/features/tag-management/components/dataset-card-tags.tsx @@ -26,17 +26,16 @@ export const DatasetCardTags = ({ >
tag.id)} - selectedTags={tags} + value={tags} onOpenTagManagement={onOpenTagManagement} onTagsChange={onTagsChange} />
) diff --git a/web/features/tag-management/components/tag-combobox-item.ts b/web/features/tag-management/components/tag-combobox-item.ts new file mode 100644 index 00000000000000..4a5846afeafb99 --- /dev/null +++ b/web/features/tag-management/components/tag-combobox-item.ts @@ -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 +} diff --git a/web/features/tag-management/components/tag-filter.tsx b/web/features/tag-management/components/tag-filter.tsx index 33b1bb0feb1e25..6f7ac1dd933ed7 100644 --- a/web/features/tag-management/components/tag-filter.tsx +++ b/web/features/tag-management/components/tag-filter.tsx @@ -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['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 filteredTagList = useMemo(() => { - return tagList.filter(tag => tag.type === type && tag.name.includes(keywords)) - }, [type, tagList, keywords]) + 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 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 ( -
- -
- -
-
- {!value.length && t('tag.placeholder', { ns: 'common' })} - {!!value.length && currentTag?.name} -
- {value.length > 1 && ( -
{`+${value.length - 1}`}
- )} - {!value.length && ( -
- -
- )} - + + > + + + + + + {!value.length && t('tag.placeholder', { ns: 'common' })} + {!!value.length && currentTagName} + + {value.length > 1 && ( + {`+${value.length - 1}`} + )} + {!value.length && ( + + + + )} + + {!!value.length && ( )} - -
-
- setKeywords(e.target.value)} - onClear={() => setKeywords('')} - /> -
-
- {filteredTagList.map(tag => ( -
selectTag(tag)} - > -
{tag.name}
- {value.includes(tag.id) && } -
- ))} - {!filteredTagList.length && ( -
- -
{t('tag.noTag', { ns: 'common' })}
-
- )} -
-
-
-
{ - onOpenTagManagement() - setOpen(false) - }} - > - -
- {t('tag.manageTags', { ns: 'common' })} -
-
-
-
- + setOpen(false)} + /> +
- + ) } diff --git a/web/features/tag-management/components/tag-panel.tsx b/web/features/tag-management/components/tag-panel.tsx index 57b5b53a4bc9f0..5576b6cbc11317 100644 --- a/web/features/tag-management/components/tag-panel.tsx +++ b/web/features/tag-management/components/tag-panel.tsx @@ -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) => { + +export const TagPanel = ({ + type, + inputValue, + onInputValueChange, + onOpenTagManagement, + onClose, +}: TagPanelProps) => { const { t } = useTranslation() - const { type, selectedTagIds, selectedTags, tagList, onOpenTagManagement, onClose } = props - const createTagMutation = useCreateTagMutation() - const [localDraftTagIds, setLocalDraftTagIds] = useState(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 + const filteredItems = useComboboxFilteredItems() + const realItemCount = filteredItems.filter(tag => !isCreateTagOption(tag)).length + const hasCreateOption = filteredItems.some(isCreateTagOption) + const placeholder = t('tag.selectorPlaceholder', { ns: 'common' }) || '' - 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]) - } return ( -
+
- handleKeywordsChange(e.target.value)} onClear={() => handleKeywordsChange('')} /> + +
- {keywords && notExisted && ( -
-
- -
- {`${t('tag.create', { ns: 'common' })} `} - {`'${keywords}'`} -
-
-
- )} - {keywords && notExisted && filteredTagList.length > 0 && ()} - {(filteredTagList.length > 0 || filteredSelectedTagList.length > 0) && ( -
- {filteredSelectedTagList.map(tag => ( -
selectTag(tag.id)} data-testid="tag-row"> - -
- {tag.name} -
-
- ))} - {filteredTagList.map(tag => ( -
selectTag(tag.id)} data-testid="tag-row"> - -
- {tag.name} -
-
- ))} -
+ {filteredItems.length > 0 && ( + + {(tag: TagComboboxItem, index) => { + if (isCreateTagOption(tag)) { + return ( + + + + + + {realItemCount > 0 && } + + ) + } + + return ( + + {tag.name} + + + ) + }} + )} - {!keywords && !filteredTagList.length && !filteredSelectedTagList.length && ( + {!hasCreateOption && realItemCount === 0 && (
- +
)} - +
-
{ onOpenTagManagement?.() onClose?.() }} > - -
+
-
+ +
) diff --git a/web/features/tag-management/components/tag-selector.tsx b/web/features/tag-management/components/tag-selector.tsx index ab595ccc205480..f37f18f326772c 100644 --- a/web/features/tag-management/components/tag-selector.tsx +++ b/web/features/tag-management/components/tag-selector.tsx @@ -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['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, + | 'items' + | 'multiple' + | 'value' + | 'defaultValue' + | 'onValueChange' + | 'inputValue' + | 'defaultInputValue' + | 'onInputValueChange' + | 'filter' + | 'itemToStringLabel' + | 'isItemEqualToValue' + | 'open' + | 'defaultOpen' + | 'onOpenChange' + | 'onOpenChangeComplete' + | 'children' +> +type TagSelectorContentProps = Pick, '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(selectedTagIds) + const [draftTags, setDraftTags] = useState(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(() => { + const tagIds = new Set() + 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]) + + 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 + } - if (!isPopover) - return null + setDraftTags(nextTags.filter(tag => !isCreateTagOption(tag))) + }, [createNewTag]) return ( - - + - - + handleOpenChange(false)} /> - - + + ) } diff --git a/web/features/tag-management/components/tag-trigger.tsx b/web/features/tag-management/components/tag-trigger.tsx index e1df9dce876b58..49b08b4f76d742 100644 --- a/web/features/tag-management/components/tag-trigger.tsx +++ b/web/features/tag-management/components/tag-trigger.tsx @@ -13,8 +13,8 @@ export const TagTrigger = ({
{!tags.length ? ( -
- +
+