From bb3de5dd32fe47b4fb081163637b3018193e0395 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Thu, 7 May 2026 15:05:14 +0800 Subject: [PATCH 01/11] refactor: improve model selector search (#35875) --- pnpm-lock.yaml | 13 + pnpm-workspace.yaml | 1 + .../model-selector/__tests__/index.spec.tsx | 34 +- .../model-selector/__tests__/popover.spec.tsx | 34 +- .../__tests__/popup-item.spec.tsx | 95 +++--- .../model-selector/__tests__/popup.spec.tsx | 312 ++++++++++++++---- .../model-selector/feature-icon.tsx | 76 +---- .../model-selector/index.tsx | 147 ++++++--- .../model-selector/marketplace-section.tsx | 14 +- .../model-selector/model-search.ts | 194 +++++++++++ .../model-selector/model-selector-trigger.tsx | 25 +- .../model-selector/popup-empty-state.tsx | 5 +- .../model-selector/popup-item.tsx | 276 ++++++++-------- .../model-selector/popup-layout.tsx | 75 +++-- .../model-selector/popup.tsx | 133 +++----- .../model-selector/types.ts | 11 + .../__tests__/input-var-list.spec.tsx | 4 +- web/package.json | 1 + 18 files changed, 887 insertions(+), 563 deletions(-) create mode 100644 web/app/components/header/account-setting/model-provider-page/model-selector/model-search.ts create mode 100644 web/app/components/header/account-setting/model-provider-page/model-selector/types.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d2903da1ad..e367aaef67 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -342,6 +342,9 @@ catalogs: fast-deep-equal: specifier: 3.1.3 version: 3.1.3 + fuse.js: + specifier: 7.2.0 + version: 7.2.0 happy-dom: specifier: 20.9.0 version: 20.9.0 @@ -1024,6 +1027,9 @@ importers: fast-deep-equal: specifier: 'catalog:' version: 3.1.3 + fuse.js: + specifier: 'catalog:' + version: 7.2.0 hast-util-to-jsx-runtime: specifier: 'catalog:' version: 2.3.6 @@ -6054,6 +6060,10 @@ packages: functional-red-black-tree@1.0.1: resolution: {integrity: sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==} + fuse.js@7.2.0: + resolution: {integrity: sha512-zf4vdcIGpjNKTuXwug33Hm2okqX6a0t2ZEbez+o9oBJQSNhVJ5AqERfeiRD3r8HcLqP66MrjdkmzxrncbAOTUQ==} + engines: {node: '>=10'} + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -13637,6 +13647,8 @@ snapshots: functional-red-black-tree@1.0.1: {} + fuse.js@7.2.0: {} + gensync@1.0.0-beta.2: {} get-caller-file@2.0.5: {} @@ -16793,6 +16805,7 @@ time: eslint-markdown@0.7.0: '2026-04-25T11:31:20.226Z' eslint-plugin-better-tailwindcss@4.5.0: '2026-04-28T06:24:47.281Z' eslint@10.2.1: '2026-04-17T20:17:44.852Z' + fuse.js@7.2.0: '2026-04-02T21:14:38.087Z' hono@4.12.15: '2026-04-24T06:51:10.290Z' i18next@26.0.8: '2026-04-24T19:20:14.685Z' js-yaml@4.1.1: '2025-11-12T15:18:03.524Z' diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ee6ccf00df..868920cf94 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -167,6 +167,7 @@ catalog: eslint-plugin-sonarjs: 4.0.3 eslint-plugin-storybook: 10.3.5 fast-deep-equal: 3.1.3 + fuse.js: 7.2.0 happy-dom: 20.9.0 hast-util-to-jsx-runtime: 2.3.6 hono: 4.12.15 diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/index.spec.tsx index 2bcb7b712b..4119bca6ae 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/index.spec.tsx @@ -25,18 +25,22 @@ vi.mock('../model-selector-trigger', () => ({ }, })) -vi.mock('../popup', () => ({ - default: ({ onHide, onSelect }: { onHide: () => void, onSelect: (provider: string, model: ModelItem) => void }) => ( - <> - - - - ), -})) +vi.mock('../popup', async () => { + const { ComboboxItem } = await vi.importActual('@langgenius/dify-ui/combobox') + + return { + default: ({ onHide }: { onHide: () => void }) => ( + <> + + select + + + + ), + } +}) const makeModelItem = (overrides: Partial = {}): ModelItem => ({ model: 'gpt-4', @@ -82,7 +86,7 @@ describe('ModelSelector', () => { it('should toggle popup and close it after selecting a model', () => { renderWithQueryClient() - const triggerButton = screen.getByRole('button', { name: 'empty-trigger' }) + const triggerButton = screen.getByRole('combobox') fireEvent.click(triggerButton) expect(triggerButton).toHaveAttribute('aria-expanded', 'true') @@ -96,7 +100,7 @@ describe('ModelSelector', () => { const onSelect = vi.fn() renderWithQueryClient() - fireEvent.click(screen.getByText('empty-trigger')) + fireEvent.click(screen.getByRole('combobox')) fireEvent.click(screen.getByText('select')) expect(onSelect).toHaveBeenCalledWith({ provider: 'openai', model: 'gpt-4' }) @@ -105,7 +109,7 @@ describe('ModelSelector', () => { it('should close popup when popup requests hide', () => { renderWithQueryClient() - const triggerButton = screen.getByRole('button', { name: 'empty-trigger' }) + const triggerButton = screen.getByRole('combobox') fireEvent.click(triggerButton) expect(triggerButton).toHaveAttribute('aria-expanded', 'true') expect(screen.getByText('hide')).toBeInTheDocument() diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popover.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popover.spec.tsx index d7501672f4..528a5416ee 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popover.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popover.spec.tsx @@ -1,14 +1,6 @@ -import type { ReactNode } from 'react' -import { act, fireEvent, render, screen } from '@testing-library/react' +import { fireEvent, render, screen } from '@testing-library/react' import ModelSelector from '../index' -type PopoverProps = { - children: ReactNode - onOpenChange?: (open: boolean) => void -} - -let latestOnOpenChange: PopoverProps['onOpenChange'] - vi.mock('../../hooks', () => ({ useCurrentProviderAndModel: () => ({ currentProvider: undefined, @@ -16,15 +8,6 @@ vi.mock('../../hooks', () => ({ }), })) -vi.mock('@langgenius/dify-ui/popover', () => ({ - Popover: ({ children, onOpenChange }: PopoverProps) => { - latestOnOpenChange = onOpenChange - return
{children}
- }, - PopoverTrigger: ({ render }: { render: ReactNode }) => <>{render}, - PopoverContent: ({ children }: { children: ReactNode }) =>
{children}
, -})) - vi.mock('../model-selector-trigger', () => ({ default: ({ open, readonly }: { open: boolean, readonly?: boolean }) => ( @@ -43,19 +26,16 @@ vi.mock('../popup', () => ({ ), })) -describe('ModelSelector popover branches', () => { +describe('ModelSelector combobox branches', () => { beforeEach(() => { vi.clearAllMocks() - latestOnOpenChange = undefined }) - it('should open and close through popover callbacks when editable', () => { + it('should open and close through combobox trigger when editable', () => { const onHide = vi.fn() render() - act(() => { - latestOnOpenChange?.(true) - }) + fireEvent.click(screen.getByRole('combobox')) expect(screen.getByText('open-editable')).toBeInTheDocument() @@ -65,12 +45,10 @@ describe('ModelSelector popover branches', () => { expect(onHide).toHaveBeenCalledTimes(1) }) - it('should ignore popover open changes when readonly', () => { + it('should ignore combobox open requests when readonly', () => { render() - act(() => { - latestOnOpenChange?.(true) - }) + fireEvent.click(screen.getByRole('combobox')) expect(screen.getByText('closed-readonly')).toBeInTheDocument() }) diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup-item.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup-item.spec.tsx index 341a9c6abc..3c4fea6f51 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup-item.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup-item.spec.tsx @@ -1,4 +1,6 @@ +import type { ReactElement, ReactNode } from 'react' import type { DefaultModel, Model, ModelItem } from '../../declarations' +import { Combobox } from '@langgenius/dify-ui/combobox' import { fireEvent, render, screen } from '@testing-library/react' import { ConfigurationMethodEnum, @@ -25,7 +27,7 @@ vi.mock('../../hooks', async () => { }) vi.mock('../../model-badge', () => ({ - default: ({ children }: { children: React.ReactNode }) => {children}, + default: ({ children }: { children: ReactNode }) => {children}, })) vi.mock('../../model-icon', () => ({ @@ -41,13 +43,7 @@ vi.mock('../feature-icon', () => ({ })) vi.mock('@/app/components/base/tooltip', () => ({ - default: ({ children }: { children: React.ReactNode }) =>
{children}
, -})) - -vi.mock('@langgenius/dify-ui/popover', () => ({ - Popover: ({ children }: { children: React.ReactNode }) =>
{children}
, - PopoverTrigger: ({ render }: { render: React.ReactNode }) => <>{render}, - PopoverContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + default: ({ children }: { children: ReactNode }) =>
{children}
, })) const mockCredentialPanelState = vi.hoisted(() => vi.fn()) @@ -114,6 +110,24 @@ const makeProvider = (overrides: Record = {}) => ({ ...overrides, }) +const createComboboxNode = ( + node: ReactElement, + onValueChange = vi.fn(), +) => ( + + {node} + +) + +const renderWithCombobox = ( + node: ReactElement, + onValueChange = vi.fn(), +) => { + return render( + createComboboxNode(node, onValueChange), + ) +} + describe('PopupItem', () => { beforeEach(() => { vi.clearAllMocks() @@ -141,48 +155,51 @@ describe('PopupItem', () => { modelProviders: [], }) - const { container } = render( - , + const { container } = renderWithCombobox( + , ) - expect(container.innerHTML).toBe('') + expect(container.textContent).toBe('') }) - it('should call onSelect when clicking an active model', () => { - const onSelect = vi.fn() - render() + it('should select the combobox value when clicking an active model', () => { + const onValueChange = vi.fn() + renderWithCombobox(, onValueChange) fireEvent.click(screen.getByText('GPT-4')) - expect(onSelect).toHaveBeenCalledWith('openai', expect.objectContaining({ model: 'gpt-4' })) + expect(onValueChange).toHaveBeenCalledWith( + { provider: 'openai', model: 'gpt-4' }, + expect.objectContaining({ reason: 'item-press' }), + ) }) - it('should not call onSelect when model is not active', () => { - const onSelect = vi.fn() - render( + it('should not select the combobox value when model is not active', () => { + const onValueChange = vi.fn() + renderWithCombobox( , + onValueChange, ) fireEvent.click(screen.getByText('GPT-4')) - expect(onSelect).not.toHaveBeenCalled() + expect(onValueChange).not.toHaveBeenCalled() }) it('should open model modal when clicking add on unconfigured model', () => { - const { rerender } = render( - , + const onValueChange = vi.fn() + const { rerender } = renderWithCombobox( + , + onValueChange, ) + fireEvent.click(screen.getByText('GPT-4')) fireEvent.click(screen.getByText('COMMON.OPERATION.ADD')) + expect(onValueChange).not.toHaveBeenCalled() expect(mockSetShowModelModal).toHaveBeenCalled() const call = mockSetShowModelModal.mock.calls[0]![0] as { onSaveCallback?: () => void } @@ -191,15 +208,14 @@ describe('PopupItem', () => { expect(mockUpdateModelProviders).toHaveBeenCalled() expect(mockUpdateModelList).toHaveBeenCalledWith(ModelTypeEnum.textGeneration) - rerender( + rerender(createComboboxNode( , - ) + )) fireEvent.click(screen.getByText('COMMON.OPERATION.ADD')) const call2 = mockSetShowModelModal.mock.calls.at(-1)?.[0] as { onSaveCallback?: () => void } | undefined @@ -211,11 +227,10 @@ describe('PopupItem', () => { it('should show selected state when defaultModel matches', () => { const defaultModel: DefaultModel = { provider: 'openai', model: 'gpt-4' } - render( + renderWithCombobox( , ) @@ -226,13 +241,12 @@ describe('PopupItem', () => { it('should fall back to english labels when the current language is unavailable', () => { mockUseLanguage.mockReturnValue('zh_Hans') - render( + renderWithCombobox( , ) @@ -242,7 +256,7 @@ describe('PopupItem', () => { }) it('should toggle collapsed state when clicking provider header', () => { - render() + renderWithCombobox() expect(screen.getByText('GPT-4'))!.toBeInTheDocument() @@ -256,7 +270,7 @@ describe('PopupItem', () => { }) it('should show credential name when using custom provider', () => { - render() + renderWithCombobox() expect(screen.getByText('my-api-key'))!.toBeInTheDocument() }) @@ -273,7 +287,7 @@ describe('PopupItem', () => { credits: 200, }) - render() + renderWithCombobox() expect(screen.getByText('stale-key'))!.toBeInTheDocument() expect(document.querySelector('.bg-components-badge-status-light-error-bg')).not.toBeNull() @@ -299,7 +313,7 @@ describe('PopupItem', () => { credits: 0, }) - render() + renderWithCombobox() expect(screen.getByText(/modelProvider\.selector\.configureRequired/))!.toBeInTheDocument() }) @@ -321,7 +335,7 @@ describe('PopupItem', () => { credits: 200, }) - render() + renderWithCombobox() expect(screen.getByText(/modelProvider\.selector\.aiCredits/))!.toBeInTheDocument() }) @@ -346,7 +360,7 @@ describe('PopupItem', () => { credits: 0, }) - render() + renderWithCombobox() expect(screen.getByText(/modelProvider\.selector\.creditsExhausted/))!.toBeInTheDocument() }) @@ -354,8 +368,9 @@ describe('PopupItem', () => { it('should close the dropdown through dropdown content callbacks', () => { const onHide = vi.fn() - render() + renderWithCombobox() + fireEvent.click(screen.getByRole('button', { name: /my-api-key/ })) fireEvent.click(screen.getByRole('button', { name: 'close dropdown' })) expect(onHide).toHaveBeenCalled() diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup.spec.tsx index 42232a71c0..4776ad2268 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup.spec.tsx @@ -1,7 +1,11 @@ import type { ReactElement } from 'react' import type { Model, ModelItem, ModelProvider } from '../../declarations' +import type { PopupProps } from '../popup' import type { SystemFeatures } from '@/types/feature' +import { Combobox } from '@langgenius/dify-ui/combobox' import { fireEvent, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useState } from 'react' import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' import { ConfigurationMethodEnum, @@ -69,10 +73,31 @@ vi.mock('@/context/provider-context', () => ({ useProviderContext: () => ({ modelProviders: mockContextModelProviders.current }), })) -const renderPopup = (ui: ReactElement) => renderWithSystemFeatures(ui, { - // The Popup component never inspects trial_models beyond passing them - // through, so an opaque string[] is enough; cast to satisfy the - // ModelProviderQuotaGetPaid[] declared on SystemFeatures. +type PopupTestProps = Omit + +function PopupHarness(props: PopupTestProps) { + const [inputValue, setInputValue] = useState('') + + return ( + { + if (details.reason !== 'item-press') + setInputValue(newInputValue) + }} + > + + + ) +} + +const renderPopup = (ui: ReactElement) => renderWithSystemFeatures(ui, { systemFeatures: { trial_models: mockTrialModels.current as unknown as SystemFeatures['trial_models'] }, }) @@ -193,11 +218,12 @@ describe('Popup', () => { }) }) - it('should filter models by search and allow clearing search', () => { - const { container } = renderPopup( - { + const user = userEvent.setup() + + renderPopup( + , ) @@ -205,18 +231,21 @@ describe('Popup', () => { expect(screen.getByText('openai'))!.toBeInTheDocument() const input = screen.getByPlaceholderText('datasetSettings.form.searchModel') - fireEvent.change(input, { target: { value: 'not-found' } }) + await user.click(input) + await user.keyboard('not-found') expect(screen.getByText('No model found for \u201Cnot-found\u201D'))!.toBeInTheDocument() - const clearIcon = container.querySelector('.i-custom-vender-solid-general-x-circle') - expect(clearIcon)!.toBeInTheDocument() - fireEvent.click(clearIcon!) + const clearButton = screen.getByRole('button', { name: 'common.operation.clear' }) + expect(clearButton)!.toBeInTheDocument() + await user.click(clearButton) + expect((input as HTMLInputElement).value).toBe('') + expect(input).toHaveFocus() }) it('should show matching models when searching by model name', () => { renderPopup( - { models: [makeModelItem({ model: 'claude-3', label: { en_US: 'Claude 3', zh_Hans: 'Claude 3' } })], }), ]} - onSelect={vi.fn()} onHide={vi.fn()} />, ) @@ -246,7 +274,7 @@ describe('Popup', () => { it('should show empty search placeholder when no provider or model name matches', () => { renderPopup( - { ], }), ]} - onSelect={vi.fn()} onHide={vi.fn()} />, ) @@ -272,7 +299,7 @@ describe('Popup', () => { it('should show all models of a provider when searching by provider label', () => { renderPopup( - { ], }), ]} - onSelect={vi.fn()} onHide={vi.fn()} />, ) @@ -307,9 +333,183 @@ describe('Popup', () => { expect(screen.queryByText('claude-3')).not.toBeInTheDocument() }) + it('should fuzzy match provider labels and keep all compatible provider models visible', () => { + renderPopup( + , + ) + + fireEvent.change( + screen.getByPlaceholderText('datasetSettings.form.searchModel'), + { target: { value: 'opnai' } }, + ) + + expect(screen.getByText('openai'))!.toBeInTheDocument() + expect(screen.getByText('gpt-4'))!.toBeInTheDocument() + expect(screen.getByText('gpt-4o'))!.toBeInTheDocument() + expect(screen.queryByText('anthropic')).not.toBeInTheDocument() + }) + + it('should match model labels without expanding unmatched provider models', () => { + renderPopup( + , + ) + + fireEvent.change( + screen.getByPlaceholderText('datasetSettings.form.searchModel'), + { target: { value: 'claude3' } }, + ) + + expect(screen.queryByText('openai')).not.toBeInTheDocument() + expect(screen.getByText('anthropic'))!.toBeInTheDocument() + expect(screen.getByText('claude-3'))!.toBeInTheDocument() + expect(screen.queryByText('claude-instant')).not.toBeInTheDocument() + }) + + it('should match model names without separators', () => { + renderPopup( + , + ) + + fireEvent.change( + screen.getByPlaceholderText('datasetSettings.form.searchModel'), + { target: { value: 'gpt5.4' } }, + ) + + expect(screen.getByText('gpt-5.4'))!.toBeInTheDocument() + expect(screen.getByText('gpt-5.4-2026-03-05'))!.toBeInTheDocument() + expect(screen.getByText('gpt-5.4-mini'))!.toBeInTheDocument() + expect(screen.getByText('gpt-5.4-nano'))!.toBeInTheDocument() + expect(screen.queryByText('gpt-5.3-chat-latest')).not.toBeInTheDocument() + expect(screen.queryByText('gpt-5.2')).not.toBeInTheDocument() + expect(screen.queryByText('gpt-4.1')).not.toBeInTheDocument() + }) + + it('should not fuzzy match unrelated providers that share the langgenius namespace', () => { + renderPopup( + , + ) + + fireEvent.change( + screen.getByPlaceholderText('datasetSettings.form.searchModel'), + { target: { value: 'openai' } }, + ) + + expect(screen.getByText('langgenius/openai/openai'))!.toBeInTheDocument() + expect(screen.getByText('langgenius/openai_api_compatible/openai_api_compatible'))!.toBeInTheDocument() + expect(screen.queryByText('langgenius/openrouter/openrouter')).not.toBeInTheDocument() + }) + + it('should fuzzy match provider names without matching every langgenius provider', () => { + renderPopup( + , + ) + + fireEvent.change( + screen.getByPlaceholderText('datasetSettings.form.searchModel'), + { target: { value: 'gemni' } }, + ) + + expect(screen.getByText('langgenius/gemini/google'))!.toBeInTheDocument() + expect(screen.queryByText('langgenius/zhipuai/zhipuai')).not.toBeInTheDocument() + expect(screen.queryByText('langgenius/tongyi/tongyi')).not.toBeInTheDocument() + }) + it('should match by model provider key when model label does not contain the search text', () => { renderPopup( - { ], }), ]} - onSelect={vi.fn()} onHide={vi.fn()} />, ) @@ -337,7 +536,7 @@ describe('Popup', () => { mockSupportFunctionCall.mockReturnValue(false) renderPopup( - { ], }), ]} - onSelect={vi.fn()} onHide={vi.fn()} scopeFeatures={[ModelFeatureEnum.toolCall]} />, @@ -366,9 +564,8 @@ describe('Popup', () => { it('should not show compatible-only helper text when no scope features are applied', () => { renderPopup( - , ) @@ -378,9 +575,8 @@ describe('Popup', () => { it('should show compatible-only helper text when scope features are applied', () => { renderPopup( - , @@ -392,9 +588,8 @@ describe('Popup', () => { it('should keep search and footer outside the scrollable model list', () => { renderPopup( - , @@ -417,9 +612,8 @@ describe('Popup', () => { mockSupportFunctionCall.mockReturnValue(false) const { unmount } = renderPopup( - , @@ -429,9 +623,8 @@ describe('Popup', () => { unmount() mockSupportFunctionCall.mockReturnValue(true) const { unmount: unmount2 } = renderPopup( - , @@ -440,9 +633,8 @@ describe('Popup', () => { unmount2() const { unmount: unmount3 } = renderPopup( - , @@ -451,9 +643,8 @@ describe('Popup', () => { unmount3() renderPopup( - , @@ -465,7 +656,7 @@ describe('Popup', () => { mockLanguage = 'fr_FR' renderPopup( - { ], }), ]} - onSelect={vi.fn()} onHide={vi.fn()} />, ) @@ -504,9 +694,8 @@ describe('Popup', () => { ] renderPopup( - , ) @@ -531,9 +720,8 @@ describe('Popup', () => { ] renderPopup( - , ) @@ -561,9 +749,8 @@ describe('Popup', () => { ] renderPopup( - , ) @@ -574,9 +761,8 @@ describe('Popup', () => { it('should open provider settings when clicking footer link', () => { const onHide = vi.fn() renderPopup( - , ) @@ -592,9 +778,8 @@ describe('Popup', () => { it('should show empty state when no providers are configured', () => { const onHide = vi.fn() renderPopup( - , ) @@ -613,9 +798,8 @@ describe('Popup', () => { mockContextModelProviders.current = [makeContextProvider({ provider: 'test-openai' })] renderPopup( - , ) @@ -635,9 +819,8 @@ describe('Popup', () => { })] renderPopup( - , ) @@ -660,9 +843,8 @@ describe('Popup', () => { })] renderPopup( - , ) @@ -674,9 +856,8 @@ describe('Popup', () => { it('should toggle marketplace section collapse', () => { renderPopup( - , ) @@ -699,9 +880,8 @@ describe('Popup', () => { mockInstallMutateAsync.mockResolvedValue({ all_installed: true, task_id: 'task-1' }) renderPopup( - , ) @@ -722,9 +902,8 @@ describe('Popup', () => { mockInstallMutateAsync.mockRejectedValue(new Error('Install failed')) renderPopup( - , ) @@ -736,7 +915,6 @@ describe('Popup', () => { expect(mockInstallMutateAsync).toHaveBeenCalled() }) - // Should not crash, install buttons should still be available expect(screen.getAllByText(/common\.modelProvider\.selector\.install/).length).toBeGreaterThan(0) }) @@ -748,9 +926,8 @@ describe('Popup', () => { mockCheck.mockResolvedValue(undefined) renderPopup( - , ) @@ -774,9 +951,8 @@ describe('Popup', () => { mockMarketplacePlugins.isLoading = true renderPopup( - , ) @@ -792,9 +968,8 @@ describe('Popup', () => { mockMarketplacePlugins.current = [] renderPopup( - , ) @@ -808,13 +983,12 @@ describe('Popup', () => { it('should sort the selected provider to the top when a default model is provided', () => { renderPopup( - , ) diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/feature-icon.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/feature-icon.tsx index cc5e0154d7..69dff3a158 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/feature-icon.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/feature-icon.tsx @@ -1,17 +1,7 @@ -import type { FC } from 'react' import { cn } from '@langgenius/dify-ui/cn' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' -import { - RiFileTextLine, - RiFilmAiLine, - RiImageCircleAiLine, - RiVoiceAiFill, -} from '@remixicon/react' import { useTranslation } from 'react-i18next' -import { - ModelFeatureEnum, - ModelFeatureTextEnum, -} from '../declarations' +import { ModelFeatureEnum, ModelFeatureTextEnum } from '../declarations' import ModelBadge from '../model-badge' type FeatureIconProps = { @@ -19,56 +9,20 @@ type FeatureIconProps = { className?: string showFeaturesLabel?: boolean } -const FeatureIcon: FC = ({ +function FeatureIcon({ className, feature, showFeaturesLabel, -}) => { +}: FeatureIconProps) { const { t } = useTranslation() - // if (feature === ModelFeatureEnum.agentThought) { - // return ( - // - // - // - // - // - // ) - // } - - // if (feature === ModelFeatureEnum.toolCall) { - // return ( - // - // - // - // - // - // ) - // } - - // if (feature === ModelFeatureEnum.multiToolCall) { - // return ( - // - // - // - // - // - // ) - // } - if (feature === ModelFeatureEnum.vision) { if (showFeaturesLabel) { return ( - + ) @@ -81,11 +35,11 @@ const FeatureIcon: FC = ({
- +
)} @@ -103,7 +57,7 @@ const FeatureIcon: FC = ({ - + ) @@ -116,11 +70,11 @@ const FeatureIcon: FC = ({
- +
)} @@ -138,7 +92,7 @@ const FeatureIcon: FC = ({ - + ) @@ -151,11 +105,11 @@ const FeatureIcon: FC = ({
- +
)} @@ -173,7 +127,7 @@ const FeatureIcon: FC = ({ - + ) @@ -186,11 +140,11 @@ const FeatureIcon: FC = ({
- +
)} diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/index.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/index.tsx index 835821fd59..debd06d7cd 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/index.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/index.tsx @@ -1,19 +1,15 @@ -import type { FC } from 'react' -import type { - DefaultModel, - Model, - ModelFeatureEnum, - ModelItem, -} from '../declarations' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' -import { useState } from 'react' +import type { ComboboxRootChangeEventDetails } from '@langgenius/dify-ui/combobox' +import type { DefaultModel, Model, ModelFeatureEnum, ModelItem } from '../declarations' +import type { ModelSelectorValue } from './types' +import { cn } from '@langgenius/dify-ui/cn' +import { Combobox, ComboboxContent, ComboboxTrigger } from '@langgenius/dify-ui/combobox' +import { useCallback, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { ModelStatusEnum } from '../declarations' import { useCurrentProviderAndModel } from '../hooks' import ModelSelectorTrigger from './model-selector-trigger' import Popup from './popup' +import { getModelSelectorValueLabel, isSameModelSelectorValue } from './types' type ModelSelectorProps = { defaultModel?: DefaultModel @@ -27,7 +23,7 @@ type ModelSelectorProps = { deprecatedClassName?: string showDeprecatedWarnIcon?: boolean } -const ModelSelector: FC = ({ +function ModelSelector({ defaultModel, modelList, triggerClassName, @@ -38,8 +34,10 @@ const ModelSelector: FC = ({ scopeFeatures = [], deprecatedClassName, showDeprecatedWarnIcon = true, -}) => { +}: ModelSelectorProps) { + const { t } = useTranslation() const [open, setOpen] = useState(false) + const [inputValue, setInputValue] = useState('') const { currentProvider, currentModel, @@ -47,62 +45,103 @@ const ModelSelector: FC = ({ modelList, defaultModel, ) + const currentValue = useMemo(() => { + if (!currentProvider || !currentModel) + return null - const handleSelect = (provider: string, model: ModelItem) => { + return { + provider: currentProvider.provider, + model: currentModel.model, + } + }, [currentModel, currentProvider]) + + const handleOpenChange = useCallback((newOpen: boolean) => { + if (readonly) + return + + setOpen(newOpen) + if (!newOpen) + setInputValue('') + }, [readonly]) + + const handleSelect = useCallback((provider: string, model: ModelItem) => { setOpen(false) + setInputValue('') if (onSelect) onSelect({ provider, model: model.model }) - } + }, [onSelect]) + + const handleValueChange = useCallback((value: ModelSelectorValue | null) => { + if (!value) + return + + const provider = modelList.find(model => model.provider === value.provider) + const model = provider?.models.find(model => model.model === value.model) + + if (!provider || !model) + return + if (model.status !== ModelStatusEnum.active) + return + + handleSelect(provider.provider, model) + }, [handleSelect, modelList]) + + const handleInputValueChange = useCallback((inputValue: string, details: ComboboxRootChangeEventDetails) => { + if (details.reason !== 'item-press') + setInputValue(inputValue) + }, []) + + const handleHide = useCallback(() => { + setOpen(false) + setInputValue('') + onHide?.() + }, [onHide]) return ( - + filter={null} + inputValue={inputValue} + isItemEqualToValue={isSameModelSelectorValue} + itemToStringLabel={getModelSelectorValueLabel} open={open} - onOpenChange={(newOpen) => { - if (readonly) - return - setOpen(newOpen) - }} + value={currentValue} + onInputValueChange={handleInputValueChange} + onOpenChange={handleOpenChange} + onValueChange={handleValueChange} > - - - - )} - /> - + + + { - setOpen(false) - onHide?.() - }} + onInputValueChange={setInputValue} + onHide={handleHide} /> - - + + ) } diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/marketplace-section.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/marketplace-section.tsx index 469e3c201f..33079a80c6 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/marketplace-section.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/marketplace-section.tsx @@ -1,4 +1,3 @@ -import type { FC } from 'react' import type { ModelProviderQuotaGetPaid } from '@/types/model-provider' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' @@ -16,7 +15,7 @@ type MarketplaceSectionProps = { onInstallPlugin: (key: ModelProviderQuotaGetPaid) => void | Promise } -const MarketplaceSection: FC = ({ +function MarketplaceSection({ marketplaceProviders, marketplaceCollapsed, installingProvider, @@ -24,7 +23,7 @@ const MarketplaceSection: FC = ({ theme, onMarketplaceCollapsedChange, onInstallPlugin, -}) => { +}: MarketplaceSectionProps) { const { t } = useTranslation() if (marketplaceProviders.length === 0) @@ -36,14 +35,15 @@ const MarketplaceSection: FC = ({
-
-
+
+
{!marketplaceCollapsed && (
diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/model-search.ts b/web/app/components/header/account-setting/model-provider-page/model-selector/model-search.ts new file mode 100644 index 0000000000..9870b70ea9 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/model-search.ts @@ -0,0 +1,194 @@ +import type { DefaultModel, Model, ModelItem, TypeWithI18N } from '../declarations' +import Fuse from 'fuse.js' +import { supportFunctionCall } from '@/utils/tool-call' +import { ModelFeatureEnum } from '../declarations' + +type ProviderSearchEntry = { + provider: string + labels: string[] + providerKeys: string[] +} + +type ModelSearchEntry = { + provider: string + model: string + normalizedLabels: string[] +} + +type SearchMatches = { + providers: Set + models: Set +} + +type ModelSelectorSearchIndex = { + search: (query: string) => SearchMatches +} + +type FilterModelSelectorModelsParams = { + aiCreditVisibleProviders: Set + defaultModel?: DefaultModel + inputValue: string + installedModelList: Model[] + scopeFeatures: ModelFeatureEnum[] + searchIndex: ModelSelectorSearchIndex +} + +const providerSearchOptions = { + ignoreDiacritics: true, + ignoreLocation: true, + minMatchCharLength: 2, + shouldSort: false, + threshold: 0.25, + keys: [ + { name: 'labels', weight: 2 }, + { name: 'providerKeys', weight: 1 }, + ], +} + +const modelSearchOptions = { + ignoreDiacritics: true, + shouldSort: false, + useExtendedSearch: true, + keys: [ + 'normalizedLabels', + ], +} + +const normalizeModelSearchValue = (value: string) => ( + value + .toLowerCase() + .normalize('NFKD') + .replace(/[^\p{Letter}\p{Number}]+/gu, '') +) + +const looksLikeModelQuery = (value: string) => /\d/.test(value) + +const getLabelSearchValues = (label: TypeWithI18N, language: string) => { + if (label[language] !== undefined) + return [label[language]] + + return Array.from(new Set(Object.values(label))) +} + +const getProviderKeySearchValues = (provider: string) => { + const keys = provider + .split('/') + .filter(part => part && part !== 'langgenius') + + return Array.from(new Set([ + ...keys, + ...keys.map(normalizeModelSearchValue), + ])) +} + +const createModelSearchKey = (provider: string, model: string) => `${provider}/${model}` + +const modelSupportsScopeFeatures = (modelItem: ModelItem, scopeFeatures: ModelFeatureEnum[]) => { + if (scopeFeatures.length === 0) + return true + + return scopeFeatures.every((feature) => { + if (feature === ModelFeatureEnum.toolCall) + return supportFunctionCall(modelItem.features) + + return modelItem.features?.includes(feature) ?? false + }) +} + +export const createModelSelectorSearchIndex = (installedModelList: Model[], language: string): ModelSelectorSearchIndex => { + const providerEntries = installedModelList.map((model) => { + return { + provider: model.provider, + labels: getLabelSearchValues(model.label, language), + providerKeys: getProviderKeySearchValues(model.provider), + } + }) + const modelEntries = installedModelList.flatMap(model => + model.models.map((modelItem) => { + const labels = getLabelSearchValues(modelItem.label, language) + + return { + provider: model.provider, + model: modelItem.model, + normalizedLabels: Array.from(new Set([ + modelItem.model, + ...labels, + ].map(normalizeModelSearchValue))), + } + }), + ) + const providerFuse = new Fuse(providerEntries, providerSearchOptions) + const modelFuse = new Fuse(modelEntries, modelSearchOptions) + + return { + search: (query) => { + const trimmedQuery = query.trim() + + if (!trimmedQuery) + return { providers: new Set(), models: new Set() } + + const normalizedQuery = normalizeModelSearchValue(trimmedQuery) + const providerMatches = looksLikeModelQuery(trimmedQuery) + ? new Set() + : new Set(providerFuse.search(trimmedQuery).map(({ item }) => item.provider)) + const modelMatches = normalizedQuery + ? new Set( + modelFuse + .search(`'${normalizedQuery}`) + .map(({ item }) => createModelSearchKey(item.provider, item.model)), + ) + : new Set() + + return { + providers: providerMatches, + models: modelMatches, + } + }, + } +} + +export const filterModelSelectorModels = ({ + aiCreditVisibleProviders, + defaultModel, + inputValue, + installedModelList, + scopeFeatures, + searchIndex, +}: FilterModelSelectorModelsParams) => { + const trimmedInputValue = inputValue.trim() + const matches = trimmedInputValue + ? searchIndex.search(trimmedInputValue) + : { providers: new Set(), models: new Set() } + + const filtered = installedModelList.map((model) => { + const providerMatched = matches.providers.has(model.provider) + const filteredModels = model.models + .filter((modelItem) => { + if (!trimmedInputValue || providerMatched) + return true + + return matches.models.has(createModelSearchKey(model.provider, modelItem.model)) + }) + .filter(modelItem => modelSupportsScopeFeatures(modelItem, scopeFeatures)) + + if ( + (trimmedInputValue && filteredModels.length === 0) + || (!trimmedInputValue && filteredModels.length === 0 && !aiCreditVisibleProviders.has(model.provider)) + ) { + return null + } + + return { ...model, models: filteredModels } + }).filter((model): model is Model => model !== null) + + if (defaultModel?.provider) { + filtered.sort((a, b) => { + const aSelected = a.provider === defaultModel.provider ? 0 : 1 + const bSelected = b.provider === defaultModel.provider ? 0 : 1 + + return aSelected - bSelected + }) + } + + return filtered +} diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/model-selector-trigger.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/model-selector-trigger.tsx index 6b9bcae8dc..e63b94aea3 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/model-selector-trigger.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/model-selector-trigger.tsx @@ -1,18 +1,9 @@ -import type { FC } from 'react' -import type { - DefaultModel, - Model, - ModelItem, -} from '../declarations' +import type { DefaultModel, Model, ModelItem } from '../declarations' import { cn } from '@langgenius/dify-ui/cn' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { useTranslation } from 'react-i18next' import { useProviderContext } from '@/context/provider-context' -import { - DERIVED_MODEL_STATUS_BADGE_I18N, - DERIVED_MODEL_STATUS_TOOLTIP_I18N, - deriveModelStatus, -} from '../derive-model-status' +import { DERIVED_MODEL_STATUS_BADGE_I18N, DERIVED_MODEL_STATUS_TOOLTIP_I18N, deriveModelStatus } from '../derive-model-status' import ModelIcon from '../model-icon' import ModelName from '../model-name' import { useCredentialPanelState } from '../provider-added-card/use-credential-panel-state' @@ -28,7 +19,7 @@ type ModelSelectorTriggerProps = { showDeprecatedWarnIcon?: boolean } -const ModelSelectorTrigger: FC = ({ +function ModelSelectorTrigger({ currentProvider, currentModel, defaultModel, @@ -37,7 +28,7 @@ const ModelSelectorTrigger: FC = ({ className, deprecatedClassName, showDeprecatedWarnIcon = true, -}) => { +}: ModelSelectorTriggerProps) { const { t } = useTranslation() const { modelProviders } = useProviderContext() @@ -100,7 +91,7 @@ const ModelSelectorTrigger: FC = ({ /> )} -
+
{isSelected && ( = ({ render={(
@@ -150,7 +141,7 @@ const ModelSelectorTrigger: FC = ({ +
{deprecatedStatusLabel} diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/popup-empty-state.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/popup-empty-state.tsx index dafd26387b..c68e2df8fe 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/popup-empty-state.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/popup-empty-state.tsx @@ -1,4 +1,3 @@ -import type { FC } from 'react' import { Button } from '@langgenius/dify-ui/button' import { useTranslation } from 'react-i18next' @@ -6,9 +5,9 @@ type ModelSelectorEmptyStateProps = { onConfigure: () => void } -const ModelSelectorEmptyState: FC = ({ +function ModelSelectorEmptyState({ onConfigure, -}) => { +}: ModelSelectorEmptyStateProps) { const { t } = useTranslation() return ( diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.tsx index ff9e6575bb..7a1ed57856 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.tsx @@ -1,60 +1,34 @@ -import type { FC } from 'react' -import type { - DefaultModel, - Model, - ModelItem, -} from '../declarations' +import type { DefaultModel, Model } from '../declarations' import { cn } from '@langgenius/dify-ui/cn' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' -import { - PreviewCard, - PreviewCardContent, - PreviewCardTrigger, -} from '@langgenius/dify-ui/preview-card' -import { useCallback, useState } from 'react' +import { ComboboxGroup, ComboboxItem, ComboboxItemIndicator } from '@langgenius/dify-ui/combobox' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' +import { PreviewCard, PreviewCardContent, PreviewCardTrigger } from '@langgenius/dify-ui/preview-card' +import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { CreditsCoin } from '@/app/components/base/icons/src/vender/line/financeAndECommerce' import { useModalContext } from '@/context/modal-context' import { useProviderContext } from '@/context/provider-context' -import { - ConfigurationMethodEnum, - ModelFeatureEnum, - ModelStatusEnum, - ModelTypeEnum, -} from '../declarations' -import { - useLanguage, - useUpdateModelList, - useUpdateModelProviders, -} from '../hooks' +import { ConfigurationMethodEnum, ModelFeatureEnum, ModelStatusEnum, ModelTypeEnum } from '../declarations' +import { useLanguage, useUpdateModelList, useUpdateModelProviders } from '../hooks' import ModelBadge from '../model-badge' import ModelIcon from '../model-icon' import ModelName from '../model-name' import DropdownContent from '../provider-added-card/model-auth-dropdown/dropdown-content' import { useChangeProviderPriority } from '../provider-added-card/use-change-provider-priority' import { useCredentialPanelState } from '../provider-added-card/use-credential-panel-state' -import { - modelTypeFormat, - sizeFormat, -} from '../utils' +import { modelTypeFormat, sizeFormat } from '../utils' import FeatureIcon from './feature-icon' type PopupItemProps = { defaultModel?: DefaultModel model: Model - onSelect: (provider: string, model: ModelItem) => void onHide: () => void } -const PopupItem: FC = ({ +function PopupItem({ defaultModel, model, - onSelect, onHide, -}) => { +}: PopupItemProps) { const [collapsed, setCollapsed] = useState(false) const [dropdownOpen, setDropdownOpen] = useState(false) const { t } = useTranslation() @@ -64,12 +38,6 @@ const PopupItem: FC = ({ const updateModelList = useUpdateModelList() const updateModelProviders = useUpdateModelProviders() const currentProvider = modelProviders.find(provider => provider.provider === model.provider) - const handleSelect = (provider: string, modelItem: ModelItem) => { - if (modelItem.status !== ModelStatusEnum.active) - return - - onSelect(provider, modelItem) - } const handleOpenModelModal = () => { if (!currentProvider) return @@ -91,6 +59,12 @@ const PopupItem: FC = ({ const state = useCredentialPanelState(currentProvider) const { isChangingPriority, handleChangePriority } = useChangeProviderPriority(currentProvider) + const groupItems = useMemo(() => model.models + .filter(modelItem => modelItem.status !== ModelStatusEnum.noConfigure) + .map(modelItem => ({ + provider: model.provider, + model: modelItem.model, + })), [model.models, model.provider]) const isUsingCredits = state.priority === 'credits' const hasCredits = !state.isCreditsExhausted @@ -106,33 +80,33 @@ const PopupItem: FC = ({ return null return ( -
- {/* Keep the sticky provider header above model rows while the list scrolls. */} -
-
+
+
+ {model.label[language] || model.label.en_US} + + + )} /> @@ -164,100 +138,118 @@ const PopupItem: FC = ({
- {!collapsed && model.models.map(modelItem => ( - // Preview is supplementary: every field in it (name / type / mode / context size / capabilities) - // is reachable from the model's own configuration surface once the row is selected. - // Touch + screen reader users rely on the button's primary onClick, not the preview. - - handleSelect(model.provider, modelItem)} + {!collapsed && model.models.map((modelItem) => { + const rowClassName = cn( + 'group relative mx-1 flex h-8 min-w-0 items-center gap-1 rounded-lg px-3 py-1.5 text-left', + modelItem.status === ModelStatusEnum.active ? 'cursor-pointer hover:bg-state-base-hover' : 'cursor-not-allowed hover:bg-state-base-hover-alt', + ) + const rowContent = ( + <> +
+ + +
+ { + defaultModel?.model === modelItem.model && defaultModel.provider === currentProvider.provider && ( + + + ) + } + + ) + const itemRender = modelItem.status === ModelStatusEnum.noConfigure + ? ( +
+ {rowContent} + +
+ ) + : ( + -
+ {rowContent} + + ) + + return ( + + + +
+
- +
{modelItem.label[language] || modelItem.label.en_US}
- { - defaultModel?.model === modelItem.model && defaultModel.provider === currentProvider.provider && ( - - ) - } - { - modelItem.status === ModelStatusEnum.noConfigure && ( -
- {t('operation.add', { ns: 'common' }).toLocaleUpperCase()} +
+ {!!modelItem.model_type && ( + + {modelTypeFormat(modelItem.model_type)} + + )} + {!!modelItem.model_properties.mode && ( + + {(modelItem.model_properties.mode as string).toLocaleUpperCase()} + + )} + {!!modelItem.model_properties.context_size && ( + + {sizeFormat(modelItem.model_properties.context_size as number)} + + )} +
+ {[ModelTypeEnum.textGeneration, ModelTypeEnum.textEmbedding, ModelTypeEnum.rerank].includes(modelItem.model_type as ModelTypeEnum) + && modelItem.features?.some(feature => [ModelFeatureEnum.vision, ModelFeatureEnum.audio, ModelFeatureEnum.video, ModelFeatureEnum.document].includes(feature)) + && ( +
+
{t('model.capabilities', { ns: 'common' })}
+
+ {modelItem.features?.map(feature => ( + + ))} +
- ) - } - - )} - /> - -
-
- -
{modelItem.label[language] || modelItem.label.en_US}
+ )}
-
- {!!modelItem.model_type && ( - - {modelTypeFormat(modelItem.model_type)} - - )} - {!!modelItem.model_properties.mode && ( - - {(modelItem.model_properties.mode as string).toLocaleUpperCase()} - - )} - {!!modelItem.model_properties.context_size && ( - - {sizeFormat(modelItem.model_properties.context_size as number)} - - )} -
- {[ModelTypeEnum.textGeneration, ModelTypeEnum.textEmbedding, ModelTypeEnum.rerank].includes(modelItem.model_type as ModelTypeEnum) - && modelItem.features?.some(feature => [ModelFeatureEnum.vision, ModelFeatureEnum.audio, ModelFeatureEnum.video, ModelFeatureEnum.document].includes(feature)) - && ( -
-
{t('model.capabilities', { ns: 'common' })}
-
- {modelItem.features?.map(feature => ( - - ))} -
-
- )} -
-
- - ))} -
+ + + ) + })} + ) } diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/popup-layout.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/popup-layout.tsx index 50bd098af1..95c930ef28 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/popup-layout.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/popup-layout.tsx @@ -1,4 +1,6 @@ -import type { FC, ReactNode } from 'react' +import type { ReactNode } from 'react' +import { cn } from '@langgenius/dify-ui/cn' +import { ComboboxInput, ComboboxInputGroup } from '@langgenius/dify-ui/combobox' import { ScrollAreaContent, ScrollAreaRoot, @@ -12,9 +14,9 @@ type ModelSelectorPopupFrameProps = { children: ReactNode } -export const ModelSelectorPopupFrame: FC = ({ +export function ModelSelectorPopupFrame({ children, -}) => { +}: ModelSelectorPopupFrameProps) { return (
{children} @@ -23,44 +25,52 @@ export const ModelSelectorPopupFrame: FC = ({ } type ModelSelectorSearchHeaderProps = { - searchText: string - onSearchTextChange: (value: string) => void + inputValue: string + onInputValueChange: (value: string) => void } -export const ModelSelectorSearchHeader: FC = ({ - searchText, - onSearchTextChange, -}) => { +export function ModelSelectorSearchHeader({ + inputValue, + onInputValueChange, +}: ModelSelectorSearchHeaderProps) { const { t } = useTranslation() return (
-
+
) } @@ -70,22 +80,19 @@ type ModelSelectorScrollBodyProps = { label: string } -export const ModelSelectorScrollBody: FC = ({ +export function ModelSelectorScrollBody({ children, label, -}) => { +}: ModelSelectorScrollBodyProps) { return ( - - {children} - + {children} - {/* Keep the overlay scrollbar above sticky provider headers inside this scroll area. */} @@ -93,7 +100,7 @@ export const ModelSelectorScrollBody: FC = ({ ) } -export const CompatibleModelsNotice = () => { +export function CompatibleModelsNotice() { const { t } = useTranslation() return ( @@ -110,9 +117,9 @@ type ModelProviderSettingsFooterProps = { onOpenSettings: () => void } -export const ModelProviderSettingsFooter: FC = ({ +export function ModelProviderSettingsFooter({ onOpenSettings, -}) => { +}: ModelProviderSettingsFooterProps) { const { t } = useTranslation() return ( diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/popup.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/popup.tsx index 86bac84310..d8d873f26e 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/popup.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/popup.tsx @@ -1,10 +1,6 @@ -import type { FC } from 'react' -import type { - DefaultModel, - Model, - ModelItem, -} from '../declarations' +import type { DefaultModel, Model, ModelFeatureEnum } from '../declarations' import type { ModelProviderQuotaGetPaid } from '@/types/model-provider' +import { ComboboxList } from '@langgenius/dify-ui/combobox' import { useSuspenseQuery } from '@tanstack/react-query' import { useTheme } from 'next-themes' import { useCallback, useMemo, useState } from 'react' @@ -16,46 +12,37 @@ import { useModalContext } from '@/context/modal-context' import { useProviderContext } from '@/context/provider-context' import { systemFeaturesQueryOptions } from '@/service/system-features' import { useInstallPackageFromMarketPlace } from '@/service/use-plugins' -import { supportFunctionCall } from '@/utils/tool-call' -import { - CustomConfigurationStatusEnum, - ModelFeatureEnum, - ModelStatusEnum, -} from '../declarations' +import { CustomConfigurationStatusEnum, ModelStatusEnum } from '../declarations' import { useLanguage, useMarketplaceAllPlugins } from '../hooks' import CreditsExhaustedAlert from '../provider-added-card/model-auth-dropdown/credits-exhausted-alert' import { useTrialCredits } from '../provider-added-card/use-trial-credits' import { providerSupportsCredits } from '../supports-credits' import { MODEL_PROVIDER_QUOTA_GET_PAID, providerKeyToPluginId } from '../utils' import MarketplaceSection from './marketplace-section' +import { createModelSelectorSearchIndex, filterModelSelectorModels } from './model-search' import ModelSelectorEmptyState from './popup-empty-state' import PopupItem from './popup-item' -import { - CompatibleModelsNotice, - ModelProviderSettingsFooter, - ModelSelectorPopupFrame, - ModelSelectorScrollBody, - ModelSelectorSearchHeader, -} from './popup-layout' +import { CompatibleModelsNotice, ModelProviderSettingsFooter, ModelSelectorPopupFrame, ModelSelectorScrollBody, ModelSelectorSearchHeader } from './popup-layout' -type PopupProps = { +export type PopupProps = { defaultModel?: DefaultModel + inputValue: string modelList: Model[] - onSelect: (provider: string, model: ModelItem) => void scopeFeatures?: ModelFeatureEnum[] + onInputValueChange: (value: string) => void onHide: () => void } -const Popup: FC = ({ +function Popup({ defaultModel, + inputValue, modelList, - onSelect, scopeFeatures = [], + onInputValueChange, onHide, -}) => { +}: PopupProps) { const { t } = useTranslation() const { theme } = useTheme() const language = useLanguage() - const [searchText, setSearchText] = useState('') const [marketplaceCollapsed, setMarketplaceCollapsed] = useState(false) const { setShowAccountSettingModal } = useModalContext() const { modelProviders } = useProviderContext() @@ -142,57 +129,18 @@ const Popup: FC = ({ return [...installedMarketplaceModels, ...otherModels] }, [aiCreditVisibleProviders, installedProviderMap, modelList]) - const filteredModelList = useMemo(() => { - const normalizedSearch = searchText.toLowerCase() - const matchesLabel = (label: Record) => { - if (label[language] !== undefined) - return label[language].toLowerCase().includes(normalizedSearch) - return Object.values(label).some(value => - value.toLowerCase().includes(normalizedSearch), - ) - } - - const filtered = installedModelList.map((model) => { - const providerMatched = !!searchText && ( - matchesLabel(model.label) - || model.provider.toLowerCase().includes(normalizedSearch) - ) - - const filteredModels = model.models - .filter((modelItem) => { - if (!searchText || providerMatched) - return true - return matchesLabel(modelItem.label) - }) - .filter((modelItem) => { - if (scopeFeatures.length === 0) - return true - return scopeFeatures.every((feature) => { - if (feature === ModelFeatureEnum.toolCall) - return supportFunctionCall(modelItem.features) - return modelItem.features?.includes(feature) ?? false - }) - }) - if ( - (searchText && filteredModels.length === 0) - || (!searchText && filteredModels.length === 0 && !aiCreditVisibleProviders.has(model.provider)) - ) { - return null - } - - return { ...model, models: filteredModels } - }).filter((model): model is Model => model !== null) - - if (defaultModel?.provider) { - filtered.sort((a, b) => { - const aSelected = a.provider === defaultModel.provider ? 0 : 1 - const bSelected = b.provider === defaultModel.provider ? 0 : 1 - return aSelected - bSelected - }) - } - - return filtered - }, [aiCreditVisibleProviders, defaultModel?.provider, installedModelList, language, scopeFeatures, searchText]) + const searchIndex = useMemo( + () => createModelSelectorSearchIndex(installedModelList, language), + [installedModelList, language], + ) + const filteredModelList = useMemo(() => filterModelSelectorModels({ + aiCreditVisibleProviders, + defaultModel, + inputValue, + installedModelList, + scopeFeatures, + searchIndex, + }), [aiCreditVisibleProviders, defaultModel, inputValue, installedModelList, scopeFeatures, searchIndex]) const marketplaceProviders = useMemo(() => { const installedProviders = new Set(modelProviders.map(provider => provider.provider)) @@ -207,33 +155,36 @@ const Popup: FC = ({ return ( {showCreditsExhaustedAlert && ( )} + +
+ { + filteredModelList.map(model => ( + + )) + } +
+
- { - filteredModelList.map(model => ( - - )) - } {!filteredModelList.length && !installedModelList.length && ( )} {!filteredModelList.length && installedModelList.length > 0 && ( -
- {`No model found for \u201C${searchText}\u201D`} +
+ {`No model found for \u201C${inputValue}\u201D`}
)} {scopeFeatures.length > 0 && ( diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/types.ts b/web/app/components/header/account-setting/model-provider-page/model-selector/types.ts new file mode 100644 index 0000000000..93314a6208 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/types.ts @@ -0,0 +1,11 @@ +export type ModelSelectorValue = { + provider: string + model: string +} + +export const isSameModelSelectorValue = ( + itemValue: ModelSelectorValue, + value: ModelSelectorValue, +) => itemValue.provider === value.provider && itemValue.model === value.model + +export const getModelSelectorValueLabel = (itemValue: ModelSelectorValue) => itemValue.model diff --git a/web/app/components/workflow/nodes/tool/components/__tests__/input-var-list.spec.tsx b/web/app/components/workflow/nodes/tool/components/__tests__/input-var-list.spec.tsx index 4ccf2b1061..01bfceb4a5 100644 --- a/web/app/components/workflow/nodes/tool/components/__tests__/input-var-list.spec.tsx +++ b/web/app/components/workflow/nodes/tool/components/__tests__/input-var-list.spec.tsx @@ -491,8 +491,8 @@ describe('InputVarList', () => { }) await user.click(screen.getByText('workflow:errorMsg.configureModel')) - await user.click(await screen.findByRole('button', { name: 'plugin.detailPanel.configureModel' })) - await user.click(await screen.findByRole('button', { name: /GPT-4o/i })) + await user.click(await screen.findByRole('combobox', { name: 'plugin.detailPanel.configureModel' })) + await user.click(await screen.findByRole('option', { name: /GPT-4o/i })) expect(onChange).toHaveBeenLastCalledWith({ assistant: { diff --git a/web/package.json b/web/package.json index 917ace3025..0dd9dfbde5 100644 --- a/web/package.json +++ b/web/package.json @@ -96,6 +96,7 @@ "emoji-mart": "catalog:", "es-toolkit": "catalog:", "fast-deep-equal": "catalog:", + "fuse.js": "catalog:", "hast-util-to-jsx-runtime": "catalog:", "html-entities": "catalog:", "html-to-image": "catalog:", From 8b77ec7f31e0a37bfc3c88bd6016cfac3366a278 Mon Sep 17 00:00:00 2001 From: Joel Date: Thu, 7 May 2026 15:46:47 +0800 Subject: [PATCH 02/11] fix: transfer workspace dropdown not show (#35876) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- eslint-suppressions.json | 5 +- .../__tests__/index.spec.tsx | 21 +- .../transfer-ownership-modal/index.tsx | 181 +++++++++--------- 3 files changed, 103 insertions(+), 104 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index b4876dcf45..3692f9cc6a 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -2579,11 +2579,8 @@ "erasable-syntax-only/enums": { "count": 1 }, - "no-restricted-imports": { - "count": 1 - }, "ts/no-explicit-any": { - "count": 3 + "count": 2 } }, "web/app/components/header/account-setting/model-provider-page/declarations.ts": { diff --git a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/__tests__/index.spec.tsx b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/__tests__/index.spec.tsx index 9e05a93c7a..56a237d741 100644 --- a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/__tests__/index.spec.tsx @@ -183,18 +183,20 @@ describe('TransferOwnershipModal', () => { }) }) - it('should show error when sending verification email fails', async () => { + it('should not show a modal-level toast and should stay on start step when sending verification email fails', async () => { const user = userEvent.setup() vi.mocked(sendOwnerEmail).mockRejectedValue(new Error('network error')) renderModal() await user.click(screen.getByTestId('transfer-modal-send-code')) + // The base service layer surfaces the real backend error. The modal itself + // must NOT show an additional toast (e.g. "Error sending verification code: undefined"). await waitFor(() => { - expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ - type: 'error', - message: expect.stringContaining('network error'), - })) + expect(sendOwnerEmail).toHaveBeenCalled() }) + expect(mockNotify).not.toHaveBeenCalled() + // Should remain on the start step instead of advancing to the verify step. + expect(screen.getByTestId('transfer-modal-send-code')).toBeInTheDocument() }) it('should show error when ownership transfer fails', async () => { @@ -229,7 +231,7 @@ describe('TransferOwnershipModal', () => { }) }) - it('should show fallback error prefix when sendOwnerEmail throws null', async () => { + it('should swallow null rejection from sendOwnerEmail without showing a modal-level toast', async () => { const user = userEvent.setup() vi.mocked(sendOwnerEmail).mockRejectedValue(null) @@ -237,11 +239,10 @@ describe('TransferOwnershipModal', () => { await user.click(screen.getByTestId('transfer-modal-send-code')) await waitFor(() => { - expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ - type: 'error', - message: expect.stringContaining('Error sending verification code:'), - })) + expect(sendOwnerEmail).toHaveBeenCalled() }) + expect(mockNotify).not.toHaveBeenCalled() + expect(screen.getByTestId('transfer-modal-send-code')).toBeInTheDocument() }) it('should show fallback error prefix when verifyOwnerEmail throws null', async () => { diff --git a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.tsx b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.tsx index 85a3ac3b22..cadc2dc967 100644 --- a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.tsx +++ b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.tsx @@ -1,11 +1,10 @@ import { Button } from '@langgenius/dify-ui/button' +import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' import { toast } from '@langgenius/dify-ui/toast' -import { noop } from 'es-toolkit/function' import * as React from 'react' import { useCallback, useState } from 'react' import { Trans, useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' -import Modal from '@/app/components/base/modal' import { useAppContext } from '@/context/app-context' import { ownershipTransfer, sendOwnerEmail, verifyOwnerEmail } from '@/service/common' import MemberSelector from './member-selector' @@ -52,15 +51,10 @@ const TransferOwnershipModal = ({ onClose, show }: Props) => { }, 1000)) } const sendEmail = async () => { - try { - const res = await sendOwnerEmail({}) - startCount() - if (res.data) - setStepToken(res.data) - } - catch (error) { - toast.error(`Error sending verification code: ${error ? (error as any).message : ''}`) - } + const res = await sendOwnerEmail({}) + startCount() + if (res.data) + setStepToken(res.data) } const verifyEmailAddress = async (code: string, token: string, callback?: () => void) => { try { @@ -81,8 +75,13 @@ const TransferOwnershipModal = ({ onClose, show }: Props) => { } } const sendCodeToOriginEmail = async () => { - await sendEmail() - setStep(STEP.verify) + try { + await sendEmail() + setStep(STEP.verify) + } + catch { + // The base service layer already surfaces the backend error (e.g. rate-limit) as a toast. + } } const handleVerifyOriginEmail = async () => { await verifyEmailAddress(code, stepToken, () => setStep(STEP.transfer)) @@ -104,85 +103,87 @@ const TransferOwnershipModal = ({ onClose, show }: Props) => { } } return ( - -
-
-
- {step === STEP.start && ( - <> -
{t('members.transferModal.title', { ns: 'common' })}
-
-
{t('members.transferModal.warning', { ns: 'common', workspace: currentWorkspace.name.replace(/'/g, '’') })}
-
{t('members.transferModal.warningTip', { ns: 'common' })}
-
- }} values={{ email: userProfile.email }} /> + + +
+
+
+ {step === STEP.start && ( + <> +
{t('members.transferModal.title', { ns: 'common' })}
+
+
{t('members.transferModal.warning', { ns: 'common', workspace: currentWorkspace.name.replace(/'/g, '’') })}
+
{t('members.transferModal.warningTip', { ns: 'common' })}
+
+ }} values={{ email: userProfile.email }} /> +
-
-
-
- - -
- - )} - {step === STEP.verify && ( - <> -
{t('members.transferModal.verifyEmail', { ns: 'common' })}
-
-
- }} values={{ email: userProfile.email }} /> +
+
+ +
-
{t('members.transferModal.verifyContent2', { ns: 'common' })}
-
-
-
{t('members.transferModal.codeLabel', { ns: 'common' })}
- setCode(e.target.value)} maxLength={6} /> -
-
- - -
-
- {t('members.transferModal.resendTip', { ns: 'common' })} - {time > 0 && ({t('members.transferModal.resendCount', { ns: 'common', count: time })})} - {!time && ( - - {t('members.transferModal.resend', { ns: 'common' })} - - )} -
- - )} - {step === STEP.transfer && ( - <> -
{t('members.transferModal.title', { ns: 'common' })}
-
-
{t('members.transferModal.warning', { ns: 'common', workspace: currentWorkspace.name.replace(/'/g, '’') })}
-
{t('members.transferModal.warningTip', { ns: 'common' })}
-
-
-
{t('members.transferModal.transferLabel', { ns: 'common' })}
- -
-
- - -
- - )} - + + )} + {step === STEP.verify && ( + <> +
{t('members.transferModal.verifyEmail', { ns: 'common' })}
+
+
+ }} values={{ email: userProfile.email }} /> +
+
{t('members.transferModal.verifyContent2', { ns: 'common' })}
+
+
+
{t('members.transferModal.codeLabel', { ns: 'common' })}
+ setCode(e.target.value)} maxLength={6} /> +
+
+ + +
+
+ {t('members.transferModal.resendTip', { ns: 'common' })} + {time > 0 && ({t('members.transferModal.resendCount', { ns: 'common', count: time })})} + {!time && ( + + {t('members.transferModal.resend', { ns: 'common' })} + + )} +
+ + )} + {step === STEP.transfer && ( + <> +
{t('members.transferModal.title', { ns: 'common' })}
+
+
{t('members.transferModal.warning', { ns: 'common', workspace: currentWorkspace.name.replace(/'/g, '’') })}
+
{t('members.transferModal.warningTip', { ns: 'common' })}
+
+
+
{t('members.transferModal.transferLabel', { ns: 'common' })}
+ +
+
+ + +
+ + )} + +
) } export default TransferOwnershipModal From cd66559ebf470380f97b0b643e05e9f4999be222 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Thu, 7 May 2026 16:55:13 +0800 Subject: [PATCH 03/11] refactor(web): migrate tag controls to combobox (#35881) --- eslint-suppressions.json | 3 - .../apps/app-card-operations-flow.test.tsx | 10 + .../apps/app-list-browsing-flow.test.tsx | 18 +- .../apps/__tests__/app-card.spec.tsx | 9 + web/app/components/apps/app-card.tsx | 6 +- .../tool-form/__tests__/item.spec.tsx | 5 +- .../nodes/tool/components/tool-form/item.tsx | 18 +- .../__tests__/dataset-card-tags.spec.tsx | 27 +- .../__tests__/tag-filter.spec.tsx | 16 +- .../__tests__/tag-panel.spec.tsx | 632 ++++-------------- .../__tests__/tag-selector.spec.tsx | 453 ++++--------- .../__tests__/app-card-tags.spec.tsx | 17 +- .../components/app-card-tags.tsx | 7 +- .../components/dataset-card-tags.tsx | 7 +- .../components/tag-combobox-item.ts | 15 + .../tag-management/components/tag-filter.tsx | 178 +++-- .../tag-management/components/tag-panel.tsx | 185 +++-- .../components/tag-selector.tsx | 214 ++++-- .../tag-management/components/tag-trigger.tsx | 8 +- .../tag-management-modal.stories.tsx | 122 ---- 20 files changed, 670 insertions(+), 1280 deletions(-) create mode 100644 web/features/tag-management/components/tag-combobox-item.ts delete mode 100644 web/features/tag-management/tag-management-modal.stories.tsx diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 3692f9cc6a..59ac466da8 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 b0854072d2..ef3bee5167 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 e6b83bd69d..4829adacf0 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 4edf5604da..c841617474 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 06c5c8a9d8..f623f8ce53 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 e935085fe9..42478ca34d 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 6cd13984f0..1cb3179135 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 b79e16d5e9..7c950becc3 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 a00c9ac93d..f19cb2a181 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 1f1d0a241b..65b2f0c285 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, +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[] = [] - - 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(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('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('renders search, selected tags, unselected tags, and management action', () => { + render() - 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() - }) + 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() + it('filters options by the controlled combobox input value', async () => { + const user = userEvent.setup() + render() - 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() - - 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('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() - it('should select an unselected tag when clicked', async () => { - const user = userEvent.setup() - render() + 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() - - 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() - - 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() - it('should create a new tag when clicking the create option', async () => { - const user = userEvent.setup() - render() + 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() - - 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() - }) + 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() + 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')) - 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() + it('updates only the combobox draft value when selecting and deselecting options', async () => { + const user = userEvent.setup() + render() - 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() - // 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() - it('should handle empty tagList', () => { - render() - 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( - , - ) - // 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() - 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() + expect(screen.getByText(i18n.noTag)).toBeInTheDocument() + }) - it('should handle knowledge type tags correctly', () => { - render( - , - ) - expect(screen.getByText('KnowledgeDB'))!.toBeInTheDocument() - }) + it('opens tag management through a semantic button', async () => { + const user = userEvent.setup() + const onOpenTagManagement = vi.fn() + render() + + await user.click(screen.getByRole('button', { name: i18n.manageTags })) + + expect(onOpenTagManagement).toHaveBeenCalledTimes(1) + }) + + 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 01983dff7b..c1419998d1 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('renders selected tag names in the combobox trigger', () => { + 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('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() + }) - 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('opens a searchable combobox popup', async () => { + const user = userEvent.setup() + render() - 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() - }) + await user.click(screen.getByRole('combobox', { name: /Frontend/i })) - it('should render when minWidth is provided', () => { - render() - 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() + + 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( - , - ) - // '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() - it('should display multiple tag names when multiple are selected', () => { - render( - , - ) - 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() + it('does not submit unchanged draft selections on close', async () => { + const user = userEvent.setup() + const onTagsChange = vi.fn() + render() - 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() + + 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() + 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.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) - - 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() - - 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() - - 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() - - 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() - }) - - 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) - - 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() - - 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() - render() + 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( - , - ) - // 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') - - 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() }) }) 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 916202d6fd..26d6cf67aa 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 034cb30023..91c0b6783f 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 5376dc0690..50131b1ce7 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 0000000000..4a5846afea --- /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 33b1bb0feb..6f7ac1dd93 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 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 ( -
- -
- -
-
- {!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 57b5b53a4b..5576b6cbc1 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) => { - 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 - 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() + const realItemCount = filteredItems.filter(tag => !isCreateTagOption(tag)).length + const hasCreateOption = filteredItems.some(isCreateTagOption) + const placeholder = t('tag.selectorPlaceholder', { ns: 'common' }) || '' + return ( -
+
- handleKeywordsChange(e.target.value)} onClear={() => handleKeywordsChange('')} /> + +
- {keywords && notExisted && ( -
-
- -
- {`${t('tag.create', { ns: 'common' })} `} - {`'${keywords}'`} -
-
-
+ {filteredItems.length > 0 && ( + + {(tag: TagComboboxItem, index) => { + if (isCreateTagOption(tag)) { + return ( + + + + + + {realItemCount > 0 && } + + ) + } + + return ( + + {tag.name} + + + ) + }} + )} - {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} -
-
- ))} -
- )} - {!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 ab595ccc20..f37f18f326 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]) - 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 ( - - + - - + handleOpenChange(false)} /> - - + + ) } diff --git a/web/features/tag-management/components/tag-trigger.tsx b/web/features/tag-management/components/tag-trigger.tsx index e1df9dce87..49b08b4f76 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 ? ( -
- +
+