fix: exclude installed plugins and localize marketplace search results (#42199)

This commit is contained in:
Joel authored and GitHub committed 2026-09-11 09:59:13 +00:00
1 parent 92d64db112
commit bb02d3efcc
6 files changed
+324 -8

No files matched your search

@@ -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(
<ToolBrowser
searchText="search"
tags={[]}
onSelect={vi.fn()}
buildInTools={[
createToolProvider({
plugin_id: installedPlugin.plugin_id,
label: { en_US: 'Web Toolkit', zh_Hans: 'Web Toolkit' },
}),
]}
customTools={[]}
workflowTools={[]}
mcpTools={[]}
/>,
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(
<ToolBrowser
searchText=""
tags={['search']}
onSelect={vi.fn()}
buildInTools={installedPlugins.map((plugin) =>
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(<ToolBrowser {...props} />, true)
expect(await screen.findByText('New Plugin')).toBeInTheDocument()
expect(screen.getByText('Available Plugin')).toBeInTheDocument()
rerender(
<ToolBrowser
{...props}
buildInTools={[createToolProvider({ plugin_id: newlyInstalledPlugin.plugin_id })]}
/>,
)
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(),
@@ -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<typeof useAllBuiltInTools>)
mockUseMarketplacePlugins.mockImplementation(
(params) =>
({
data: params
? {
pages: [
{
plugins: [installedPlugin, availablePlugin],
total: 2,
page: 1,
page_size: 40,
},
],
pageParams: [1],
}
: undefined,
isFetching: false,
}) as ReturnType<typeof useMarketplacePlugins>,
)
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<typeof useMarketplacePlugins>,
)
const props = { scope, onSelect: vi.fn(), onSelectMultiple: vi.fn() }
const { rerender } = renderWithConsoleQuery(<ToolPickerContent {...props} />, {
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<typeof useAllBuiltInTools>)
rerender(<ToolPickerContent {...props} />)
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({
@@ -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<typeof useTranslation>['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(
<Item
payload={createPlugin({
label: { en_US: 'Search Plugin', zh_Hans: '搜索插件', [language]: label },
brief: { en_US: 'Searches documents', zh_Hans: '搜索文档', [language]: brief },
})}
/>,
)
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(
<Item
payload={createPlugin({
label: { en_US: 'Search Plugin', zh_Hans: '搜索插件' },
brief: { en_US: 'Searches documents', zh_Hans: '搜索文档' },
})}
/>,
)
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()
@@ -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<string, string> | 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()
@@ -83,6 +83,8 @@ type ToolBrowserProps = {
searchText: string
tags: ListProps['tags']
buildInTools: ToolWithProvider[]
/** Full installed plugin IDs when selectable providers are scoped. */
installedPluginIds?: ReadonlySet<string>
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<ListRef>(null)
@@ -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 || []}