From f4ad6ce978140e7045971db4d2e5a0dc161c628b Mon Sep 17 00:00:00 2001 From: Joel Date: Thu, 16 Jul 2026 18:18:30 +0800 Subject: [PATCH] chore: show package installed status in marketplace page (#39146) --- .../__tests__/index.spec.tsx | 12 +++- .../__tests__/use-refresh-plugin-list.spec.ts | 5 +- .../hooks/use-refresh-plugin-list.tsx | 7 +- .../list/__tests__/card-wrapper.spec.tsx | 12 ++++ .../marketplace/list/__tests__/index.spec.tsx | 72 +++++++++++++++++++ .../plugins/marketplace/list/card-wrapper.tsx | 16 +++-- .../plugins/marketplace/list/index.tsx | 30 ++++++++ .../marketplace/list/list-with-collection.tsx | 11 ++- 8 files changed, 156 insertions(+), 9 deletions(-) diff --git a/web/app/components/header/account-setting/data-source-page-new/__tests__/index.spec.tsx b/web/app/components/header/account-setting/data-source-page-new/__tests__/index.spec.tsx index 4fc6e543e45..5db6c0ea91f 100644 --- a/web/app/components/header/account-setting/data-source-page-new/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/data-source-page-new/__tests__/index.spec.tsx @@ -13,7 +13,11 @@ import { useInvalidDataSourceListAuth, } from '@/service/use-datasource' import { useInvalidDataSourceList } from '@/service/use-pipeline' -import { useInstalledPluginList, useInvalidateInstalledPluginList } from '@/service/use-plugins' +import { + useCheckInstalled, + useInstalledPluginList, + useInvalidateInstalledPluginList, +} from '@/service/use-plugins' import { useDataSourceAuthUpdate, useMarketplaceAllPlugins } from '../hooks' import DataSourcePage from '../index' @@ -42,6 +46,7 @@ vi.mock('@/service/use-pipeline', () => ({ })) vi.mock('@/service/use-plugins', () => ({ + useCheckInstalled: vi.fn(), useInstalledPluginList: vi.fn(), useInvalidateInstalledPluginList: vi.fn(), })) @@ -186,6 +191,11 @@ describe('DataSourcePage Component', () => { vi.mocked(useInstalledPluginList).mockReturnValue({ data: { plugins: [], total: 0 }, } as unknown as ReturnType) + vi.mocked(useCheckInstalled).mockReturnValue({ + data: { plugins: [] }, + isLoading: false, + error: null, + } as unknown as ReturnType) vi.mocked(usePluginsWithLatestVersion).mockImplementation( (plugins = []) => plugins as PluginDetail[], ) diff --git a/web/app/components/plugins/install-plugin/hooks/__tests__/use-refresh-plugin-list.spec.ts b/web/app/components/plugins/install-plugin/hooks/__tests__/use-refresh-plugin-list.spec.ts index bc784aac817..7e61ad44379 100644 --- a/web/app/components/plugins/install-plugin/hooks/__tests__/use-refresh-plugin-list.spec.ts +++ b/web/app/components/plugins/install-plugin/hooks/__tests__/use-refresh-plugin-list.spec.ts @@ -4,6 +4,7 @@ import { PluginCategoryEnum } from '../../../types' // Mock invalidation / refresh functions const mockInvalidateInstalledPluginList = vi.fn() +const mockInvalidateCheckInstalled = vi.fn() const mockRefetchLLMModelList = vi.fn() const mockRefetchEmbeddingModelList = vi.fn() const mockRefetchRerankModelList = vi.fn() @@ -20,6 +21,7 @@ const mockInvalidateAllTriggerPlugins = vi.fn() const mockInvalidateRAGRecommendedPlugins = vi.fn() vi.mock('@/service/use-plugins', () => ({ + useInvalidateCheckInstalled: () => mockInvalidateCheckInstalled, useInvalidateInstalledPluginList: () => mockInvalidateInstalledPluginList, })) @@ -80,12 +82,13 @@ describe('useRefreshPluginList', () => { vi.clearAllMocks() }) - it('should always invalidate installed plugin list', () => { + it('should always invalidate installed plugin list and installation checks', () => { const { result } = renderHook(() => useRefreshPluginList()) result.current.refreshPluginList() expect(mockInvalidateInstalledPluginList).toHaveBeenCalledTimes(1) + expect(mockInvalidateCheckInstalled).toHaveBeenCalledTimes(1) }) it('should refresh tool providers for tool category manifest', () => { diff --git a/web/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list.tsx b/web/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list.tsx index 9c29cc8e8cf..049427b2c90 100644 --- a/web/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list.tsx +++ b/web/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list.tsx @@ -7,7 +7,10 @@ import { import { useProviderContext } from '@/context/provider-context' import { useInvalidDataSourceListAuth } from '@/service/use-datasource' import { useInvalidDataSourceList } from '@/service/use-pipeline' -import { useInvalidateInstalledPluginList } from '@/service/use-plugins' +import { + useInvalidateCheckInstalled, + useInvalidateInstalledPluginList, +} from '@/service/use-plugins' import { useInvalidateStrategyProviders } from '@/service/use-strategy' import { useInvalidateAllBuiltInTools, @@ -31,6 +34,7 @@ const SYSTEM_MODEL_TYPES = [ const useRefreshPluginList = () => { const invalidateInstalledPluginList = useInvalidateInstalledPluginList() + const invalidateCheckInstalled = useInvalidateCheckInstalled() const { mutate: refetchLLMModelList } = useModelList(ModelTypeEnum.textGeneration) const { mutate: refetchEmbeddingModelList } = useModelList(ModelTypeEnum.textEmbedding) const { mutate: refetchRerankModelList } = useModelList(ModelTypeEnum.rerank) @@ -57,6 +61,7 @@ const useRefreshPluginList = () => { ) => { // installed list invalidateInstalledPluginList() + invalidateCheckInstalled() // tool page, tool select if ((manifest && PluginCategoryEnum.tool.includes(manifest.category)) || refreshAllType) { diff --git a/web/app/components/plugins/marketplace/list/__tests__/card-wrapper.spec.tsx b/web/app/components/plugins/marketplace/list/__tests__/card-wrapper.spec.tsx index abbb5e61713..0e8d561f864 100644 --- a/web/app/components/plugins/marketplace/list/__tests__/card-wrapper.spec.tsx +++ b/web/app/components/plugins/marketplace/list/__tests__/card-wrapper.spec.tsx @@ -1,6 +1,7 @@ import type { ComponentProps } from 'react' import type { Plugin } from '@/app/components/plugins/types' import { fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { ThemeProvider } from 'next-themes' import { beforeEach, describe, expect, it, vi } from 'vitest' import { PluginCategoryEnum } from '@/app/components/plugins/types' @@ -104,6 +105,17 @@ describe('CardWrapper', () => { ).toBeInTheDocument() }) + it('shows a disabled installed action and prevents another installation', async () => { + const user = userEvent.setup() + renderCardWrapper({ showInstallButton: true, isInstalled: true }) + + const installedButton = screen.getByRole('button', { name: 'plugin.task.installed' }) + expect(installedButton).toBeDisabled() + + await user.click(installedButton) + expect(screen.queryByTestId('install-modal')).not.toBeInTheDocument() + }) + it('opens marketplace detail from the detail action', () => { const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null) diff --git a/web/app/components/plugins/marketplace/list/__tests__/index.spec.tsx b/web/app/components/plugins/marketplace/list/__tests__/index.spec.tsx index fec1564a36f..996f04e8684 100644 --- a/web/app/components/plugins/marketplace/list/__tests__/index.spec.tsx +++ b/web/app/components/plugins/marketplace/list/__tests__/index.spec.tsx @@ -24,6 +24,7 @@ vi.mock('#i18n', async () => { 'plugin.marketplace.noPluginFound': 'No plugins found', 'plugin.detailPanel.operation.install': 'Install', 'plugin.detailPanel.operation.detail': 'Detail', + 'plugin.task.installed': 'Installed', } return translations[fullKey] || key }), @@ -50,6 +51,19 @@ vi.mock('../../state', () => ({ useMarketplaceData: () => mockMarketplaceData, })) +const mockUseCheckInstalled = vi.hoisted(() => vi.fn()) +vi.mock('@/app/components/plugins/install-plugin/hooks/use-check-installed', () => ({ + default: mockUseCheckInstalled, +})) + +beforeEach(() => { + mockUseCheckInstalled.mockReturnValue({ + installedInfo: undefined, + isLoading: false, + error: null, + }) +}) + vi.mock('../../atoms', () => ({ useMarketplaceMoreClick: () => mockMoreClick, })) @@ -991,6 +1005,64 @@ describe('CardWrapper (via List integration)', () => { expect(screen.getByText('Detail')).toBeInTheDocument() }) + it('should render installed state for every card with an installed plugin id', () => { + const installedPlugin = createMockPlugin({ + name: 'installed-plugin', + plugin_id: 'plugin-a', + }) + const duplicateInstalledPlugin = createMockPlugin({ + name: 'installed-plugin-duplicate', + plugin_id: 'plugin-a', + }) + const uninstalledPlugin = createMockPlugin({ + name: 'uninstalled-plugin', + plugin_id: 'plugin-b', + }) + mockUseCheckInstalled.mockReturnValue({ + installedInfo: { + 'plugin-a': { + installedId: 'installation-a', + installedVersion: '1.0.0', + uniqueIdentifier: 'plugin-a@1.0.0', + }, + }, + isLoading: false, + error: null, + }) + + render( + , + ) + + expect(screen.getAllByRole('button', { name: 'Installed' })).toHaveLength(2) + expect(screen.getByRole('button', { name: 'Install' })).toBeInTheDocument() + }) + + it('should keep install available while installation status is pending', () => { + const plugin = createMockPlugin({ name: 'pending-status-plugin' }) + mockUseCheckInstalled.mockReturnValue({ + installedInfo: undefined, + isLoading: true, + error: null, + }) + + render( + , + ) + + expect(screen.getByRole('button', { name: 'Install' })).not.toHaveAttribute('aria-disabled') + }) + it('should call showInstallFromMarketplace when install button is clicked', () => { const plugin = createMockPlugin({ name: 'click-test-plugin' }) diff --git a/web/app/components/plugins/marketplace/list/card-wrapper.tsx b/web/app/components/plugins/marketplace/list/card-wrapper.tsx index df4890143b7..d9c4b8a7570 100644 --- a/web/app/components/plugins/marketplace/list/card-wrapper.tsx +++ b/web/app/components/plugins/marketplace/list/card-wrapper.tsx @@ -16,8 +16,13 @@ import { getPluginLinkInMarketplace } from '../utils' type CardWrapperProps = { plugin: Plugin showInstallButton?: boolean + isInstalled?: boolean } -const CardWrapperComponent = ({ plugin, showInstallButton }: CardWrapperProps) => { +const CardWrapperComponent = ({ + plugin, + showInstallButton, + isInstalled = false, +}: CardWrapperProps) => { const { t } = useTranslation() const { theme } = useTheme() const [ @@ -68,11 +73,14 @@ const CardWrapperComponent = ({ plugin, showInstallButton }: CardWrapperProps) = />
))} @@ -158,6 +164,7 @@ const ListWithCollection = ({ plugin={plugin} showInstallButton={showInstallButton} cardRender={cardRender} + isInstalled={installedPluginIds?.has(plugin.plugin_id)} /> ))}