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, useInvalidDataSourceListAuth,
} from '@/service/use-datasource' } from '@/service/use-datasource'
import { useInvalidDataSourceList } from '@/service/use-pipeline' 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 { useDataSourceAuthUpdate, useMarketplaceAllPlugins } from '../hooks'
import DataSourcePage from '../index' import DataSourcePage from '../index'
@ -42,6 +46,7 @@ vi.mock('@/service/use-pipeline', () => ({
})) }))
vi.mock('@/service/use-plugins', () => ({ vi.mock('@/service/use-plugins', () => ({
useCheckInstalled: vi.fn(),
useInstalledPluginList: vi.fn(), useInstalledPluginList: vi.fn(),
useInvalidateInstalledPluginList: vi.fn(), useInvalidateInstalledPluginList: vi.fn(),
})) }))
@ -186,6 +191,11 @@ describe('DataSourcePage Component', () => {
vi.mocked(useInstalledPluginList).mockReturnValue({ vi.mocked(useInstalledPluginList).mockReturnValue({
data: { plugins: [], total: 0 }, data: { plugins: [], total: 0 },
} as unknown as ReturnType<typeof useInstalledPluginList>) } 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( vi.mocked(usePluginsWithLatestVersion).mockImplementation(
(plugins = []) => plugins as PluginDetail[], (plugins = []) => plugins as PluginDetail[],
) )

View File

@ -4,6 +4,7 @@ import { PluginCategoryEnum } from '../../../types'
// Mock invalidation / refresh functions // Mock invalidation / refresh functions
const mockInvalidateInstalledPluginList = vi.fn() const mockInvalidateInstalledPluginList = vi.fn()
const mockInvalidateCheckInstalled = vi.fn()
const mockRefetchLLMModelList = vi.fn() const mockRefetchLLMModelList = vi.fn()
const mockRefetchEmbeddingModelList = vi.fn() const mockRefetchEmbeddingModelList = vi.fn()
const mockRefetchRerankModelList = vi.fn() const mockRefetchRerankModelList = vi.fn()
@ -20,6 +21,7 @@ const mockInvalidateAllTriggerPlugins = vi.fn()
const mockInvalidateRAGRecommendedPlugins = vi.fn() const mockInvalidateRAGRecommendedPlugins = vi.fn()
vi.mock('@/service/use-plugins', () => ({ vi.mock('@/service/use-plugins', () => ({
useInvalidateCheckInstalled: () => mockInvalidateCheckInstalled,
useInvalidateInstalledPluginList: () => mockInvalidateInstalledPluginList, useInvalidateInstalledPluginList: () => mockInvalidateInstalledPluginList,
})) }))
@ -80,12 +82,13 @@ describe('useRefreshPluginList', () => {
vi.clearAllMocks() vi.clearAllMocks()
}) })
it('should always invalidate installed plugin list', () => { it('should always invalidate installed plugin list and installation checks', () => {
const { result } = renderHook(() => useRefreshPluginList()) const { result } = renderHook(() => useRefreshPluginList())
result.current.refreshPluginList() result.current.refreshPluginList()
expect(mockInvalidateInstalledPluginList).toHaveBeenCalledTimes(1) expect(mockInvalidateInstalledPluginList).toHaveBeenCalledTimes(1)
expect(mockInvalidateCheckInstalled).toHaveBeenCalledTimes(1)
}) })
it('should refresh tool providers for tool category manifest', () => { it('should refresh tool providers for tool category manifest', () => {

View File

@ -7,7 +7,10 @@ import {
import { useProviderContext } from '@/context/provider-context' import { useProviderContext } from '@/context/provider-context'
import { useInvalidDataSourceListAuth } from '@/service/use-datasource' import { useInvalidDataSourceListAuth } from '@/service/use-datasource'
import { useInvalidDataSourceList } from '@/service/use-pipeline' 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 { useInvalidateStrategyProviders } from '@/service/use-strategy'
import { import {
useInvalidateAllBuiltInTools, useInvalidateAllBuiltInTools,
@ -31,6 +34,7 @@ const SYSTEM_MODEL_TYPES = [
const useRefreshPluginList = () => { const useRefreshPluginList = () => {
const invalidateInstalledPluginList = useInvalidateInstalledPluginList() const invalidateInstalledPluginList = useInvalidateInstalledPluginList()
const invalidateCheckInstalled = useInvalidateCheckInstalled()
const { mutate: refetchLLMModelList } = useModelList(ModelTypeEnum.textGeneration) const { mutate: refetchLLMModelList } = useModelList(ModelTypeEnum.textGeneration)
const { mutate: refetchEmbeddingModelList } = useModelList(ModelTypeEnum.textEmbedding) const { mutate: refetchEmbeddingModelList } = useModelList(ModelTypeEnum.textEmbedding)
const { mutate: refetchRerankModelList } = useModelList(ModelTypeEnum.rerank) const { mutate: refetchRerankModelList } = useModelList(ModelTypeEnum.rerank)
@ -57,6 +61,7 @@ const useRefreshPluginList = () => {
) => { ) => {
// installed list // installed list
invalidateInstalledPluginList() invalidateInstalledPluginList()
invalidateCheckInstalled()
// tool page, tool select // tool page, tool select
if ((manifest && PluginCategoryEnum.tool.includes(manifest.category)) || refreshAllType) { if ((manifest && PluginCategoryEnum.tool.includes(manifest.category)) || refreshAllType) {

View File

@ -1,6 +1,7 @@
import type { ComponentProps } from 'react' import type { ComponentProps } from 'react'
import type { Plugin } from '@/app/components/plugins/types' import type { Plugin } from '@/app/components/plugins/types'
import { fireEvent, render, screen } from '@testing-library/react' import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ThemeProvider } from 'next-themes' import { ThemeProvider } from 'next-themes'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PluginCategoryEnum } from '@/app/components/plugins/types' import { PluginCategoryEnum } from '@/app/components/plugins/types'
@ -104,6 +105,17 @@ describe('CardWrapper', () => {
).toBeInTheDocument() ).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', () => { it('opens marketplace detail from the detail action', () => {
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null) 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.marketplace.noPluginFound': 'No plugins found',
'plugin.detailPanel.operation.install': 'Install', 'plugin.detailPanel.operation.install': 'Install',
'plugin.detailPanel.operation.detail': 'Detail', 'plugin.detailPanel.operation.detail': 'Detail',
'plugin.task.installed': 'Installed',
} }
return translations[fullKey] || key return translations[fullKey] || key
}), }),
@ -50,6 +51,19 @@ vi.mock('../../state', () => ({
useMarketplaceData: () => mockMarketplaceData, 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', () => ({ vi.mock('../../atoms', () => ({
useMarketplaceMoreClick: () => mockMoreClick, useMarketplaceMoreClick: () => mockMoreClick,
})) }))
@ -991,6 +1005,64 @@ describe('CardWrapper (via List integration)', () => {
expect(screen.getByText('Detail')).toBeInTheDocument() 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', () => { it('should call showInstallFromMarketplace when install button is clicked', () => {
const plugin = createMockPlugin({ name: 'click-test-plugin' }) const plugin = createMockPlugin({ name: 'click-test-plugin' })

View File

@ -16,8 +16,13 @@ import { getPluginLinkInMarketplace } from '../utils'
type CardWrapperProps = { type CardWrapperProps = {
plugin: Plugin plugin: Plugin
showInstallButton?: boolean showInstallButton?: boolean
isInstalled?: boolean
} }
const CardWrapperComponent = ({ plugin, showInstallButton }: CardWrapperProps) => { const CardWrapperComponent = ({
plugin,
showInstallButton,
isInstalled = false,
}: CardWrapperProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const { theme } = useTheme() const { theme } = useTheme()
const [ 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"> <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 <Button
variant="primary" variant={isInstalled ? 'secondary' : 'primary'}
className="min-w-0 flex-1 shadow-md" 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>
<Button <Button
className="min-w-0 flex-1 gap-0.5 shadow-xs backdrop-blur-[5px]" 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 { MarketplaceCollection, SearchParamsFromCollection } from '@dify/contracts/marketplace'
import type { Plugin } from '../../types' import type { Plugin } from '../../types'
import { cn } from '@langgenius/dify-ui/cn' 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 { 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 Empty from '../empty'
import CardWrapper from './card-wrapper' import CardWrapper from './card-wrapper'
import ListWithCollection from './list-with-collection' import ListWithCollection from './list-with-collection'
@ -27,6 +30,31 @@ const List = ({
emptyClassName, emptyClassName,
onCollectionMoreClick, onCollectionMoreClick,
}: ListProps) => { }: 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 ( return (
<PluginInstallPermissionProviderGuard canInstallPlugin={!!showInstallButton}> <PluginInstallPermissionProviderGuard canInstallPlugin={!!showInstallButton}>
{!plugins && ( {!plugins && (
@ -37,6 +65,7 @@ const List = ({
cardContainerClassName={cardContainerClassName} cardContainerClassName={cardContainerClassName}
cardRender={cardRender} cardRender={cardRender}
onCollectionMoreClick={onCollectionMoreClick} onCollectionMoreClick={onCollectionMoreClick}
installedPluginIds={installedPluginIds}
/> />
)} )}
{plugins && !!plugins.length && ( {plugins && !!plugins.length && (
@ -49,6 +78,7 @@ const List = ({
key={`${plugin.org}/${plugin.name}`} key={`${plugin.org}/${plugin.name}`}
plugin={plugin} plugin={plugin}
showInstallButton={showInstallButton} showInstallButton={showInstallButton}
isInstalled={installedPluginIds.has(plugin.plugin_id)}
/> />
) )
})} })}

View File

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