chore: show package installed status in marketplace page (#39146)

This commit is contained in:
Joel 2026-07-16 18:18:30 +08:00 committed by GitHub
parent e9c0e9de9e
commit f4ad6ce978
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 156 additions and 9 deletions

View File

@ -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<typeof useInstalledPluginList>)
vi.mocked(useCheckInstalled).mockReturnValue({
data: { plugins: [] },
isLoading: false,
error: null,
} as unknown as ReturnType<typeof useCheckInstalled>)
vi.mocked(usePluginsWithLatestVersion).mockImplementation(
(plugins = []) => plugins as PluginDetail[],
)

View File

@ -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', () => {

View File

@ -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) {

View File

@ -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)

View File

@ -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(
<List
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
plugins={[installedPlugin, duplicateInstalledPlugin, uninstalledPlugin]}
showInstallButton={true}
/>,
)
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(
<List
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
plugins={[plugin]}
showInstallButton={true}
/>,
)
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' })

View File

@ -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) =
/>
<div className="pointer-events-none absolute right-[-0.5px] bottom-[-0.5px] left-[-0.5px] z-10 flex items-center gap-2 rounded-b-xl bg-linear-to-t from-components-panel-on-panel-item-bg-hover from-[60%] to-background-gradient-mask-transparent px-4 pt-8 pb-4 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100">
<Button
variant="primary"
variant={isInstalled ? 'secondary' : 'primary'}
className="min-w-0 flex-1 shadow-md"
onClick={showInstallFromMarketplace}
disabled={isInstalled}
onClick={isInstalled ? undefined : showInstallFromMarketplace}
>
{t(($) => $['detailPanel.operation.install'], { ns: 'plugin' })}
{isInstalled
? t(($) => $['task.installed'], { ns: 'plugin' })
: t(($) => $['detailPanel.operation.install'], { ns: 'plugin' })}
</Button>
<Button
className="min-w-0 flex-1 gap-0.5 shadow-xs backdrop-blur-[5px]"

View File

@ -2,7 +2,10 @@
import type { MarketplaceCollection, SearchParamsFromCollection } from '@dify/contracts/marketplace'
import type { Plugin } from '../../types'
import { cn } from '@langgenius/dify-ui/cn'
import { useMemo } from 'react'
import { PluginInstallPermissionProviderGuard } from '@/app/components/plugins/install-plugin/components/plugin-install-permission-provider'
import useCheckInstalled from '@/app/components/plugins/install-plugin/hooks/use-check-installed'
import { useOptionalPluginInstallPermission } from '@/app/components/plugins/install-plugin/hooks/use-plugin-install-permission'
import Empty from '../empty'
import CardWrapper from './card-wrapper'
import ListWithCollection from './list-with-collection'
@ -27,6 +30,31 @@ const List = ({
emptyClassName,
onCollectionMoreClick,
}: ListProps) => {
const { canInstallPlugin } = useOptionalPluginInstallPermission()
const pluginIds = useMemo(() => {
const ids = new Set<string>()
const addPluginId = (plugin: Plugin) => ids.add(plugin.plugin_id)
if (plugins) plugins.forEach(addPluginId)
else
Object.values(marketplaceCollectionPluginsMap).forEach((collectionPlugins) => {
collectionPlugins.forEach(addPluginId)
})
return [...ids].sort()
}, [marketplaceCollectionPluginsMap, plugins])
const shouldCheckInstalled =
!!showInstallButton && canInstallPlugin && !cardRender && pluginIds.length > 0
const { installedInfo } = useCheckInstalled({
pluginIds,
enabled: shouldCheckInstalled,
})
const installedPluginIds = useMemo(
() => new Set(Object.keys(installedInfo ?? {})),
[installedInfo],
)
return (
<PluginInstallPermissionProviderGuard canInstallPlugin={!!showInstallButton}>
{!plugins && (
@ -37,6 +65,7 @@ const List = ({
cardContainerClassName={cardContainerClassName}
cardRender={cardRender}
onCollectionMoreClick={onCollectionMoreClick}
installedPluginIds={installedPluginIds}
/>
)}
{plugins && !!plugins.length && (
@ -49,6 +78,7 @@ const List = ({
key={`${plugin.org}/${plugin.name}`}
plugin={plugin}
showInstallButton={showInstallButton}
isInstalled={installedPluginIds.has(plugin.plugin_id)}
/>
)
})}

View File

@ -38,18 +38,22 @@ type ListWithCollectionProps = {
cardContainerClassName?: string
cardRender?: (plugin: Plugin) => React.JSX.Element | null
onCollectionMoreClick?: (searchParams?: SearchParamsFromCollection) => void
installedPluginIds?: ReadonlySet<string>
}
type PluginCardProps = {
plugin: Plugin
showInstallButton?: boolean
cardRender?: (plugin: Plugin) => React.JSX.Element | null
isInstalled?: boolean
}
const PluginCard = ({ plugin, showInstallButton, cardRender }: PluginCardProps) => {
const PluginCard = ({ plugin, showInstallButton, cardRender, isInstalled }: PluginCardProps) => {
if (cardRender) return cardRender(plugin)
return <CardWrapper plugin={plugin} showInstallButton={showInstallButton} />
return (
<CardWrapper plugin={plugin} showInstallButton={showInstallButton} isInstalled={isInstalled} />
)
}
const ListWithCollection = ({
@ -59,6 +63,7 @@ const ListWithCollection = ({
cardContainerClassName,
cardRender,
onCollectionMoreClick,
installedPluginIds,
}: ListWithCollectionProps) => {
const { t } = useTranslation()
const locale = useLocale()
@ -143,6 +148,7 @@ const ListWithCollection = ({
plugin={plugin}
showInstallButton={showInstallButton}
cardRender={cardRender}
isInstalled={installedPluginIds?.has(plugin.plugin_id)}
/>
</div>
))}
@ -158,6 +164,7 @@ const ListWithCollection = ({
plugin={plugin}
showInstallButton={showInstallButton}
cardRender={cardRender}
isInstalled={installedPluginIds?.has(plugin.plugin_id)}
/>
))}
</div>