mirror of
https://github.com/langgenius/dify.git
synced 2026-11-13 14:51:39 +08:00
fix: exclude installed plugins and localize marketplace search results (#42199)
This commit is contained in:
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({
|
||||
|
||||
+49
-1
@@ -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 || []}
|
||||
|
||||
Reference in new issue
Block a user