From bb02d3efcc46fce0a516bfd7af18ccfc27cf9f4e Mon Sep 17 00:00:00 2001 From: Joel Date: Fri, 11 Sep 2026 09:59:13 +0000 Subject: [PATCH] fix: exclude installed plugins and localize marketplace search results (#42199) --- .../__tests__/tool-browser.spec.tsx | 124 +++++++++++++++- .../__tests__/tool-picker.spec.tsx | 136 +++++++++++++++++- .../__tests__/item-list.spec.tsx | 50 ++++++- .../marketplace-plugin/item.tsx | 6 +- .../workflow/block-selector/tool-browser.tsx | 11 +- .../workflow/block-selector/tool-picker.tsx | 5 + 6 files changed, 324 insertions(+), 8 deletions(-) diff --git a/web/app/components/workflow/block-selector/__tests__/tool-browser.spec.tsx b/web/app/components/workflow/block-selector/__tests__/tool-browser.spec.tsx index 094518c0726..50336b916e2 100644 --- a/web/app/components/workflow/block-selector/__tests__/tool-browser.spec.tsx +++ b/web/app/components/workflow/block-selector/__tests__/tool-browser.spec.tsx @@ -10,7 +10,7 @@ import useTheme from '@/hooks/use-theme' import { renderWithConsoleQuery } from '@/test/console/query-data' import { Theme } from '@/types/app' import ToolBrowser from '../tool-browser' -import { createToolProvider } from './factories' +import { createPlugin, createToolProvider } from './factories' vi.mock('@/context/i18n', () => ({ useGetLanguage: vi.fn(), @@ -333,6 +333,128 @@ describe('ToolBrowser', () => { expect(screen.queryByText('workflow.tabs.noPluginsFound')).not.toBeInTheDocument() }) + it('excludes an installed marketplace plugin even when its provider does not match the search', async () => { + const installedPlugin = createPlugin({ + plugin_id: 'installed-search', + label: { en_US: 'Search Plugin', zh_Hans: 'Search Plugin' }, + }) + mockUseMarketplacePlugins.mockImplementation((params) => + createMarketplacePluginsMock({ + data: params ? createMarketplaceData([installedPlugin]) : undefined, + }), + ) + + render( + , + true, + ) + + expect(await screen.findByText('workflow.tabs.noPluginsFound')).toBeInTheDocument() + expect(screen.queryByText('Search Plugin')).not.toBeInTheDocument() + expect(screen.queryByText('Web Toolkit')).not.toBeInTheDocument() + }) + + it('excludes installed plugins across marketplace pages when filtering only by tag', async () => { + const installedPlugins = ['first', 'second'].map((page) => + createPlugin({ + plugin_id: `installed-${page}`, + label: { en_US: `Installed ${page}`, zh_Hans: `Installed ${page}` }, + }), + ) + const availablePlugin = createPlugin({ + plugin_id: 'available-plugin', + label: { en_US: 'Available Plugin', zh_Hans: 'Available Plugin' }, + }) + mockUseMarketplacePlugins.mockImplementation((params) => + createMarketplacePluginsMock({ + data: params + ? { + pages: [ + { + plugins: [...installedPlugins.slice(0, 1), availablePlugin], + total: 3, + page: 1, + page_size: 2, + }, + { plugins: installedPlugins.slice(1), total: 3, page: 2, page_size: 2 }, + ], + pageParams: [1, 2], + } + : undefined, + }), + ) + + render( + + createToolProvider({ id: `${plugin.plugin_id}/provider`, plugin_id: plugin.plugin_id }), + )} + customTools={[]} + workflowTools={[]} + mcpTools={[]} + />, + true, + ) + + expect(await screen.findByText('Available Plugin')).toBeInTheDocument() + expect(screen.queryByText('Installed first')).not.toBeInTheDocument() + expect(screen.queryByText('Installed second')).not.toBeInTheDocument() + }) + + it('removes a newly installed marketplace result when the provider list refreshes', async () => { + const newlyInstalledPlugin = createPlugin({ + plugin_id: 'new-plugin', + label: { en_US: 'New Plugin', zh_Hans: 'New Plugin' }, + }) + const availablePlugin = createPlugin({ + plugin_id: 'available-plugin', + label: { en_US: 'Available Plugin', zh_Hans: 'Available Plugin' }, + }) + const data = createMarketplaceData([newlyInstalledPlugin, availablePlugin]) + mockUseMarketplacePlugins.mockImplementation((params) => + createMarketplacePluginsMock({ data: params ? data : undefined }), + ) + const props = { + searchText: 'plugin', + tags: [], + onSelect: vi.fn(), + buildInTools: [], + customTools: [], + workflowTools: [], + mcpTools: [], + } + const { rerender } = render(, true) + + expect(await screen.findByText('New Plugin')).toBeInTheDocument() + expect(screen.getByText('Available Plugin')).toBeInTheDocument() + + rerender( + , + ) + + expect(screen.queryByText('New Plugin')).not.toBeInTheDocument() + expect(screen.getByText('Available Plugin')).toBeInTheDocument() + }) + it('debounces marketplace requests across search and tag changes', async () => { const baseProps = { onSelect: vi.fn(), diff --git a/web/app/components/workflow/block-selector/__tests__/tool-picker.spec.tsx b/web/app/components/workflow/block-selector/__tests__/tool-picker.spec.tsx index c37614a3700..3faf345a598 100644 --- a/web/app/components/workflow/block-selector/__tests__/tool-picker.spec.tsx +++ b/web/app/components/workflow/block-selector/__tests__/tool-picker.spec.tsx @@ -27,7 +27,7 @@ import { } from '@/service/use-tools' import { renderWithConsoleQuery } from '@/test/console/query-data' import { Theme } from '@/types/app' -import ToolPicker from '../tool-picker' +import ToolPicker, { ToolPickerContent } from '../tool-picker' const mockNotify = vi.fn() const mockInvalidateBuiltInTools = vi.fn() @@ -550,6 +550,140 @@ describe('ToolPicker', () => { ]) }) + it.each([ + { + scope: 'custom', + filter: 'search', + visibleProvider: 'Custom Provider', + hiddenProvider: 'Workflow Tool', + }, + { + scope: 'custom', + filter: 'tag', + visibleProvider: 'Custom Provider', + hiddenProvider: 'Workflow Tool', + }, + { + scope: 'workflow', + filter: 'search', + visibleProvider: 'Workflow Tool', + hiddenProvider: 'Custom Provider', + }, + { + scope: 'workflow', + filter: 'tag', + visibleProvider: 'Workflow Tool', + hiddenProvider: 'Custom Provider', + }, + ] as const)( + 'excludes installed marketplace plugins from $scope pickers filtered by $filter', + async ({ scope, filter, visibleProvider, hiddenProvider }) => { + const user = userEvent.setup() + const installedPlugin = createPlugin({ + plugin_id: 'installed-plugin', + label: { en_US: 'Installed Plugin' }, + }) + const availablePlugin = createPlugin({ + plugin_id: 'available-plugin', + label: { en_US: 'Available Plugin' }, + }) + mockUseAllBuiltInTools.mockReturnValue({ + data: [createToolProvider({ ...builtInTools[0], plugin_id: installedPlugin.plugin_id })], + } as ReturnType) + mockUseMarketplacePlugins.mockImplementation( + (params) => + ({ + data: params + ? { + pages: [ + { + plugins: [installedPlugin, availablePlugin], + total: 2, + page: 1, + page_size: 40, + }, + ], + pageParams: [1], + } + : undefined, + isFetching: false, + }) as ReturnType, + ) + + renderToolPicker({ isShow: true, scope, selectedTools: [] }) + + expect(screen.getByText(visibleProvider)).toBeInTheDocument() + expect(screen.queryByText(hiddenProvider)).not.toBeInTheDocument() + expect(screen.queryByText('Built-in Provider')).not.toBeInTheDocument() + + if (filter === 'search') { + await user.type(screen.getByRole('searchbox', { name: 'plugin.searchTools' }), 'plugin') + } else { + await user.click(screen.getByRole('button', { name: 'pluginTags.allTags' })) + await user.click(screen.getByRole('checkbox', { name: 'Weather' })) + await user.keyboard('{Escape}') + } + + expect(await screen.findByText('Available Plugin')).toBeInTheDocument() + expect(screen.queryByText('Installed Plugin')).not.toBeInTheDocument() + expect(screen.queryByText('Built-in Provider')).not.toBeInTheDocument() + }, + ) + + it.each(['custom', 'workflow'] as const)( + 'removes newly installed marketplace plugins when %s picker data refreshes', + async (scope) => { + const user = userEvent.setup() + const newlyInstalledPlugin = createPlugin({ + plugin_id: 'new-plugin', + label: { en_US: 'New Plugin' }, + }) + const availablePlugin = createPlugin({ + plugin_id: 'available-plugin', + label: { en_US: 'Available Plugin' }, + }) + mockUseMarketplacePlugins.mockImplementation( + (params) => + ({ + data: params + ? { + pages: [ + { + plugins: [newlyInstalledPlugin, availablePlugin], + total: 2, + page: 1, + page_size: 40, + }, + ], + pageParams: [1], + } + : undefined, + isFetching: false, + }) as ReturnType, + ) + const props = { scope, onSelect: vi.fn(), onSelectMultiple: vi.fn() } + const { rerender } = renderWithConsoleQuery(, { + systemFeatures: { enable_marketplace: true }, + }) + + await user.type(screen.getByRole('searchbox', { name: 'plugin.searchTools' }), 'plugin') + expect(await screen.findByText('New Plugin')).toBeInTheDocument() + expect(screen.getByText('Available Plugin')).toBeInTheDocument() + + mockUseAllBuiltInTools.mockReturnValue({ + data: [ + ...builtInTools, + createToolProvider({ id: 'new-provider', plugin_id: newlyInstalledPlugin.plugin_id }), + ], + } as ReturnType) + rerender() + + expect(screen.queryByText('New Plugin')).not.toBeInTheDocument() + expect(screen.getByText('Available Plugin')).toBeInTheDocument() + expect(screen.queryByText('Built-in Provider')).not.toBeInTheDocument() + }, + ) + it('should create a custom collection from the add button and refresh custom tools', async () => { const user = userEvent.setup() renderToolPicker({ diff --git a/web/app/components/workflow/block-selector/marketplace-plugin/__tests__/item-list.spec.tsx b/web/app/components/workflow/block-selector/marketplace-plugin/__tests__/item-list.spec.tsx index 660c501bc14..c8134b9ecf1 100644 --- a/web/app/components/workflow/block-selector/marketplace-plugin/__tests__/item-list.spec.tsx +++ b/web/app/components/workflow/block-selector/marketplace-plugin/__tests__/item-list.spec.tsx @@ -1,5 +1,6 @@ -import { render, screen } from '@testing-library/react' +import { render, renderHook, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { useTranslation } from 'react-i18next' import { PluginCategoryEnum } from '@/app/components/plugins/types' import { createPlugin } from '../../__tests__/factories' import useStickyScroll, { ScrollPosition } from '../../use-sticky-scroll' @@ -61,14 +62,61 @@ vi.mock('@/utils/var', async (importOriginal) => ({ })) describe('Marketplace plugin selector components', () => { + let i18n: ReturnType['i18n'] + beforeEach(() => { vi.clearAllMocks() + i18n = renderHook(() => useTranslation()).result.current.i18n + i18n.language = 'en-US' vi.mocked(useStickyScroll).mockReturnValue({ handleScroll: vi.fn(), scrollPosition: ScrollPosition.belowTheWrap, }) }) + it.each([ + { locale: 'zh-Hans', language: 'zh_Hans', label: '搜索插件', brief: '搜索文档' }, + { locale: 'ja-JP', language: 'ja_JP', label: '検索プラグイン', brief: 'ドキュメントを検索' }, + { + locale: 'pt-BR', + language: 'pt_BR', + label: 'Plugin de pesquisa', + brief: 'Pesquisa documentos', + }, + ])('should display marketplace metadata in $locale', ({ locale, language, label, brief }) => { + i18n.language = locale + + render( + , + ) + + expect(screen.getByText(label)).toBeInTheDocument() + expect(screen.getByText(brief)).toBeInTheDocument() + expect(screen.queryByText('Search Plugin')).not.toBeInTheDocument() + expect(screen.queryByText('Searches documents')).not.toBeInTheDocument() + }) + + it('should fall back to English when marketplace metadata has no current-language translation', () => { + i18n.language = 'ja-JP' + + render( + , + ) + + expect(screen.getByText('Search Plugin')).toBeInTheDocument() + expect(screen.getByText('Searches documents')).toBeInTheDocument() + }) + it('should render marketplace plugin metadata and open install modal', async () => { const user = userEvent.setup() diff --git a/web/app/components/workflow/block-selector/marketplace-plugin/item.tsx b/web/app/components/workflow/block-selector/marketplace-plugin/item.tsx index f04d6567854..29f26d4025b 100644 --- a/web/app/components/workflow/block-selector/marketplace-plugin/item.tsx +++ b/web/app/components/workflow/block-selector/marketplace-plugin/item.tsx @@ -8,7 +8,7 @@ import { useTranslation } from 'react-i18next' import { PluginInstallPermissionProvider } from '@/app/components/plugins/install-plugin/components/plugin-install-permission-provider' import useWorkspacePluginInstallPermission from '@/app/components/plugins/install-plugin/hooks/use-workspace-plugin-install-permission' import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace' -import { useLocale } from '@/context/i18n' +import { useGetLanguage } from '@/context/i18n' import { formatNumber } from '@/utils/format' import Action from './action' @@ -19,9 +19,9 @@ type Props = Readonly<{ function Item({ payload }: Props) { const { t } = useTranslation() const [open, setOpen] = useState(false) - const locale = useLocale() + const language = useGetLanguage() const getLocalizedText = (obj: Record | undefined) => - obj?.[locale] || obj?.['en-US'] || obj?.en_US || '' + obj?.[language] || obj?.['en-US'] || obj?.en_US || '' const [isShowInstallModal, { setTrue: showInstallModal, setFalse: hideInstallModal }] = useBoolean(false) const { canInstallPlugin, currentDifyVersion } = useWorkspacePluginInstallPermission() diff --git a/web/app/components/workflow/block-selector/tool-browser.tsx b/web/app/components/workflow/block-selector/tool-browser.tsx index a684946446e..0f27ef410d3 100644 --- a/web/app/components/workflow/block-selector/tool-browser.tsx +++ b/web/app/components/workflow/block-selector/tool-browser.tsx @@ -83,6 +83,8 @@ type ToolBrowserProps = { searchText: string tags: ListProps['tags'] buildInTools: ToolWithProvider[] + /** Full installed plugin IDs when selectable providers are scoped. */ + installedPluginIds?: ReadonlySet customTools: ToolWithProvider[] workflowTools: ToolWithProvider[] mcpTools: ToolWithProvider[] @@ -109,6 +111,7 @@ function ToolBrowser({ canNotSelectMultiple, onSelectMultiple, buildInTools, + installedPluginIds, workflowTools, customTools, mcpTools = [], @@ -233,9 +236,13 @@ function ToolBrowser({ ) const { data: marketplacePluginsData, isFetching: isMarketplaceFetching } = useMarketplacePlugins(marketplaceSearchParams) + const installedPluginLookup = installedPluginIds ?? providerMap const notInstalledPlugins = useMemo( - () => marketplacePluginsData?.pages.flatMap((page) => page.plugins) ?? [], - [marketplacePluginsData?.pages], + () => + marketplacePluginsData?.pages.flatMap((page) => + page.plugins.filter((plugin) => !installedPluginLookup.has(plugin.plugin_id)), + ) ?? [], + [marketplacePluginsData?.pages, installedPluginLookup], ) const pluginRef = useRef(null) diff --git a/web/app/components/workflow/block-selector/tool-picker.tsx b/web/app/components/workflow/block-selector/tool-picker.tsx index 493d5cef10f..f5df700f804 100644 --- a/web/app/components/workflow/block-selector/tool-picker.tsx +++ b/web/app/components/workflow/block-selector/tool-picker.tsx @@ -70,6 +70,10 @@ export function ToolPickerContent({ select: (s) => s.enable_marketplace, }) const { data: buildInTools } = useAllBuiltInTools() + const installedPluginIds = useMemo( + () => new Set(buildInTools?.map((provider) => provider.plugin_id || provider.id)), + [buildInTools], + ) const shouldFetchCustomTools = scope !== 'plugins' && scope !== 'workflow' const { data: customTools } = useAllCustomTools(shouldFetchCustomTools) const invalidateCustomTools = useInvalidateAllCustomTools() @@ -176,6 +180,7 @@ export function ToolPickerContent({ onSelect={handleSelect as OnSelectBlock} onSelectMultiple={handleSelectMultiple} buildInTools={builtinToolList || []} + installedPluginIds={installedPluginIds} customTools={customToolList || []} workflowTools={workflowToolList || []} mcpTools={mcpTools || []}