fix: handle integration marketplace install callbacks (#38236)

Co-authored-by: hjlarry <hjlarry@163.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Jingyi 2026-06-30 20:14:40 -07:00 committed by GitHub
parent 4f1030b94d
commit f816ae2e95
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 859 additions and 139 deletions

View File

@ -3583,11 +3583,6 @@
"count": 2
}
},
"web/app/components/header/account-setting/model-provider-page/model-provider-page-body.tsx": {
"jsx-a11y/anchor-has-content": {
"count": 1
}
},
"web/app/components/header/account-setting/model-provider-page/provider-added-card/cooldown-timer.tsx": {
"react/set-state-in-effect": {
"count": 1

View File

@ -6,6 +6,9 @@ import PluginPage from '@/app/components/plugins/plugin-page'
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
const mockFetchManifestFromMarketPlace = vi.fn()
const { mockRouterReplace } = vi.hoisted(() => ({
mockRouterReplace: vi.fn(),
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
@ -26,6 +29,12 @@ vi.mock('@/hooks/use-document-title', () => ({
default: vi.fn(),
}))
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
replace: mockRouterReplace,
}),
}))
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.example.com${path}`,
}))

View File

@ -2,17 +2,65 @@ import type { LegacyPluginsSearchParams } from '@/app/components/plugins/plugin-
import Marketplace from '@/app/components/plugins/marketplace'
import PluginPage from '@/app/components/plugins/plugin-page'
import PluginsPanel from '@/app/components/plugins/plugin-page/plugins-panel'
import { getLegacyPluginRedirectPath } from '@/app/components/plugins/plugin-routes'
import {
getFirstPackageIdFromSearchParams,
getInstallRedirectPathByPluginCategory,
getInstallRedirectPathFromSearchParams,
getLegacyPluginRedirectPath,
shouldResolveInstallCategoryRedirect,
} from '@/app/components/plugins/plugin-routes'
import { MARKETPLACE_API_PREFIX } from '@/config'
import { redirect } from '@/next/navigation'
type PluginListProps = {
searchParams?: Promise<LegacyPluginsSearchParams>
}
type MarketplaceManifestCategoryResponse = {
data?: {
plugin?: {
category?: string
}
}
}
const fetchPluginCategoryFromMarketplace = async (packageId: string) => {
try {
const response = await fetch(
`${MARKETPLACE_API_PREFIX}/plugins/identifier?unique_identifier=${encodeURIComponent(packageId)}`,
{ cache: 'no-store' },
)
if (!response.ok)
return undefined
const payload = await response.json() as MarketplaceManifestCategoryResponse
return payload.data?.plugin?.category
}
catch {
return undefined
}
}
const PluginList = async ({
searchParams,
}: PluginListProps) => {
const redirectPath = getLegacyPluginRedirectPath(await searchParams)
const resolvedSearchParams = await searchParams ?? {}
const installRedirectPathFromSearchParams = getInstallRedirectPathFromSearchParams(resolvedSearchParams)
if (installRedirectPathFromSearchParams)
redirect(installRedirectPathFromSearchParams)
if (shouldResolveInstallCategoryRedirect(resolvedSearchParams)) {
const packageId = getFirstPackageIdFromSearchParams(resolvedSearchParams)
const category = packageId ? await fetchPluginCategoryFromMarketplace(packageId) : undefined
const installRedirectPath = getInstallRedirectPathByPluginCategory(category, resolvedSearchParams)
if (installRedirectPath)
redirect(installRedirectPath)
}
const redirectPath = getLegacyPluginRedirectPath(resolvedSearchParams)
if (redirectPath)
redirect(redirectPath)

View File

@ -180,5 +180,22 @@ describe('InstallFromMarketplace Component', () => {
// Assert
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
it('should use the marketplace callback action when provided', () => {
// Arrange
vi.mocked(useMarketplaceAllPlugins).mockReturnValue({
plugins: mockPlugins,
isLoading: false,
})
const onOpenMarketplace = vi.fn()
render(<InstallFromMarketplace providers={mockProviders} searchText="" onOpenMarketplace={onOpenMarketplace} />)
// Act
fireEvent.click(screen.getByRole('button', { name: 'plugin.marketplace.difyMarketplace' }))
// Assert
expect(onOpenMarketplace).toHaveBeenCalledTimes(1)
expect(screen.queryByRole('link', { name: 'plugin.marketplace.difyMarketplace' })).not.toBeInTheDocument()
})
})
})

View File

@ -18,6 +18,7 @@ import InstallFromMarketplace from './install-from-marketplace'
type DataSourcePageProps = {
layout?: (parts: { body: ReactNode, toolbar: ReactNode }) => ReactNode
onOpenMarketplace?: () => void
stickyToolbar?: boolean
}
@ -53,6 +54,7 @@ function DataSourceListSkeleton() {
const DataSourcePage = ({
layout,
onOpenMarketplace,
stickyToolbar,
}: DataSourcePageProps) => {
const { t } = useTranslation()
@ -164,6 +166,7 @@ const DataSourcePage = ({
<InstallFromMarketplace
providers={dataSources}
searchText={searchText}
onOpenMarketplace={onOpenMarketplace}
/>
)
}

View File

@ -1,10 +1,6 @@
import type { DataSourceAuth } from './types'
import type { Plugin } from '@/app/components/plugins/types'
import { cn } from '@langgenius/dify-ui/cn'
import {
RiArrowDownSLine,
RiArrowRightUpLine,
} from '@remixicon/react'
import { useTheme } from 'next-themes'
import {
memo,
@ -25,10 +21,12 @@ import {
} from './hooks'
type InstallFromMarketplaceProps = {
onOpenMarketplace?: () => void
providers: DataSourceAuth[]
searchText: string
}
const InstallFromMarketplace = ({
onOpenMarketplace,
providers,
searchText,
}: InstallFromMarketplaceProps) => {
@ -58,15 +56,28 @@ const InstallFromMarketplace = ({
className="flex cursor-pointer items-center gap-1 border-none bg-transparent p-0 text-left system-md-semibold text-text-primary"
onClick={() => setCollapse(!collapse)}
>
<RiArrowDownSLine className={cn('size-4', collapse && '-rotate-90')} aria-hidden="true" />
<span className={cn('i-ri-arrow-down-s-line size-4', collapse && '-rotate-90')} aria-hidden="true" />
{t('modelProvider.installDataSource', { ns: 'common' })}
</button>
<div className="mb-2 flex items-center pt-2">
<span className="pr-1 system-sm-regular text-text-tertiary">{t('modelProvider.discoverMore', { ns: 'common' })}</span>
<Link target="_blank" href={getMarketplaceCategoryUrl(PluginCategoryEnum.datasource, { theme })} className="inline-flex items-center system-sm-medium text-text-accent">
{t('marketplace.difyMarketplace', { ns: 'plugin' })}
<RiArrowRightUpLine className="size-4" />
</Link>
{onOpenMarketplace
? (
<button
type="button"
className="inline-flex items-center border-0 bg-transparent p-0 system-sm-medium text-text-accent"
onClick={onOpenMarketplace}
>
{t('marketplace.difyMarketplace', { ns: 'plugin' })}
<span className="i-ri-arrow-right-up-line size-4" aria-hidden="true" />
</button>
)
: (
<Link target="_blank" rel="noopener noreferrer" href={getMarketplaceCategoryUrl(PluginCategoryEnum.datasource, { theme })} className="inline-flex items-center system-sm-medium text-text-accent">
{t('marketplace.difyMarketplace', { ns: 'plugin' })}
<span className="i-ri-arrow-right-up-line size-4" aria-hidden="true" />
</Link>
)}
</div>
</div>
{!collapse && isAllPluginsLoading && <Loading type="area" />}

View File

@ -132,4 +132,15 @@ describe('InstallFromMarketplace', () => {
render(<InstallFromMarketplace providers={mockProviders} searchText="" />)
expect(screen.getByText('plugin.marketplace.difyMarketplace')).toHaveAttribute('href', 'https://marketplace.test/plugins/model?theme=light')
})
it('should use the marketplace callback action when provided', () => {
const onOpenMarketplace = vi.fn()
render(<InstallFromMarketplace providers={mockProviders} searchText="" onOpenMarketplace={onOpenMarketplace} />)
fireEvent.click(screen.getByRole('button', { name: 'plugin.marketplace.difyMarketplace' }))
expect(onOpenMarketplace).toHaveBeenCalledTimes(1)
expect(screen.queryByRole('link', { name: 'plugin.marketplace.difyMarketplace' })).not.toBeInTheDocument()
})
})

View File

@ -31,6 +31,7 @@ type SystemModelConfigStatus = 'no-provider' | 'none-configured' | 'partially-co
type Props = Readonly<{
layout?: (parts: { body: ReactNode, toolbar: ReactNode }) => ReactNode
onOpenMarketplace?: () => void
onSearchTextChange?: (value: string) => void
searchText: string
stickyToolbar?: boolean
@ -41,6 +42,7 @@ const FixedModelProvider = ['langgenius/openai/openai', 'langgenius/anthropic/an
const ModelProviderPage = ({
layout,
onOpenMarketplace,
onSearchTextChange,
searchText,
stickyToolbar,
@ -139,6 +141,7 @@ const ModelProviderPage = ({
ttsDefaultModel={ttsDefaultModel}
isLoading={isDefaultModelLoading}
hideProviderSettingsFooter={hideSystemModelSelectorProviderSettingsFooter}
onOpenMarketplace={onOpenMarketplace}
/>
)
@ -210,6 +213,7 @@ const ModelProviderPage = ({
enableMarketplace={enableMarketplace}
searchText={searchText}
pluginDetailMap={pluginDetailMap}
onOpenMarketplace={onOpenMarketplace}
/>
)

View File

@ -19,10 +19,12 @@ import {
} from './hooks'
type InstallFromMarketplaceProps = {
onOpenMarketplace?: () => void
providers: ModelProvider[]
searchText: string
}
const InstallFromMarketplace = ({
onOpenMarketplace,
providers,
searchText,
}: InstallFromMarketplaceProps) => {
@ -57,15 +59,28 @@ const InstallFromMarketplace = ({
</button>
<div className="flex items-center gap-1">
<span className="system-sm-regular text-text-tertiary">{t('modelProvider.discoverMore', { ns: 'common' })}</span>
<Link
target="_blank"
rel="noopener noreferrer"
href={getMarketplaceCategoryUrl(PluginCategoryEnum.model, { theme })}
className="inline-flex items-center system-sm-medium text-text-accent"
>
{t('marketplace.difyMarketplace', { ns: 'plugin' })}
<span className="i-ri-arrow-right-up-line size-4" />
</Link>
{onOpenMarketplace
? (
<button
type="button"
className="inline-flex items-center border-0 bg-transparent p-0 system-sm-medium text-text-accent"
onClick={onOpenMarketplace}
>
{t('marketplace.difyMarketplace', { ns: 'plugin' })}
<span className="i-ri-arrow-right-up-line size-4" aria-hidden="true" />
</button>
)
: (
<Link
target="_blank"
rel="noopener noreferrer"
href={getMarketplaceCategoryUrl(PluginCategoryEnum.model, { theme })}
className="inline-flex items-center system-sm-medium text-text-accent"
>
{t('marketplace.difyMarketplace', { ns: 'plugin' })}
<span className="i-ri-arrow-right-up-line size-4" aria-hidden="true" />
</Link>
)}
</div>
</div>
{!collapse && isAllPluginsLoading && <Loading type="area" />}

View File

@ -21,6 +21,7 @@ type ModelProviderPageBodyProps = {
enableMarketplace: boolean
searchText: string
pluginDetailMap: Map<string, PluginDetail>
onOpenMarketplace?: () => void
}
function ModelProviderCardSkeleton() {
@ -79,6 +80,7 @@ function EmptyProviderState({
marketplace: (
<a
href="#model-provider-marketplace"
aria-label={t('marketplace.difyMarketplace', { ns: 'plugin' })}
className="system-xs-medium text-text-accent hover:underline"
/>
),
@ -128,6 +130,7 @@ const ModelProviderPageBody: FC<ModelProviderPageBodyProps> = ({
enableMarketplace,
searchText,
pluginDetailMap,
onOpenMarketplace,
}) => {
const { t } = useTranslation()
@ -165,6 +168,7 @@ const ModelProviderPageBody: FC<ModelProviderPageBodyProps> = ({
<InstallFromMarketplace
providers={providers}
searchText={searchText}
onOpenMarketplace={onOpenMarketplace}
/>
</div>
)}

View File

@ -33,6 +33,7 @@ type ModelSelectorProps = {
showDeprecatedWarnIcon?: boolean
hideProviderSettingsFooter?: boolean
onConfigureEmptyState?: () => void
onOpenMarketplace?: () => void
providerSettingsSource?: 'agent'
showModelMeta?: boolean
}
@ -49,6 +50,7 @@ function ModelSelector({
showDeprecatedWarnIcon = true,
hideProviderSettingsFooter,
onConfigureEmptyState,
onOpenMarketplace,
providerSettingsSource,
showModelMeta,
}: ModelSelectorProps) {
@ -168,6 +170,7 @@ function ModelSelector({
hideProviderSettingsFooter={hideProviderSettingsFooter}
providerSettingsSource={providerSettingsSource}
onConfigureEmptyState={onConfigureEmptyState ? handleConfigureEmptyState : undefined}
onOpenMarketplace={onOpenMarketplace}
onInputValueChange={setInputValue}
onHide={handleHide}
/>

View File

@ -15,6 +15,7 @@ type MarketplaceSectionProps = {
theme?: string
onMarketplaceCollapsedChange: (collapsed: boolean) => void
onInstallPlugin: (key: ModelProviderQuotaGetPaid) => void | Promise<void>
onOpenMarketplace?: () => void
}
function MarketplaceSection({
@ -26,6 +27,7 @@ function MarketplaceSection({
theme,
onMarketplaceCollapsedChange,
onInstallPlugin,
onOpenMarketplace,
}: MarketplaceSectionProps) {
const { t } = useTranslation()
@ -82,17 +84,32 @@ function MarketplaceSection({
</div>
)
})}
<a
className="flex cursor-pointer items-center gap-0.5 px-3 py-1.5"
href={getMarketplaceCategoryUrl(PluginCategoryEnum.model, { theme })}
target="_blank"
rel="noopener noreferrer"
>
<span className="flex-1 system-xs-regular text-text-accent">
{t('modelProvider.selector.discoverMoreInMarketplace', { ns: 'common' })}
</span>
<span className="i-ri-arrow-right-up-line size-3! text-text-accent" />
</a>
{onOpenMarketplace
? (
<button
type="button"
className="flex w-full cursor-pointer items-center gap-0.5 border-0 bg-transparent px-3 py-1.5 text-left"
onClick={onOpenMarketplace}
>
<span className="flex-1 system-xs-regular text-text-accent">
{t('modelProvider.selector.discoverMoreInMarketplace', { ns: 'common' })}
</span>
<span className="i-ri-arrow-right-up-line size-3! text-text-accent" aria-hidden="true" />
</button>
)
: (
<a
className="flex cursor-pointer items-center gap-0.5 px-3 py-1.5"
href={getMarketplaceCategoryUrl(PluginCategoryEnum.model, { theme })}
target="_blank"
rel="noopener noreferrer"
>
<span className="flex-1 system-xs-regular text-text-accent">
{t('modelProvider.selector.discoverMoreInMarketplace', { ns: 'common' })}
</span>
<span className="i-ri-arrow-right-up-line size-3! text-text-accent" aria-hidden="true" />
</a>
)}
</div>
)}
</div>

View File

@ -42,6 +42,7 @@ export type PopupProps = {
providerSettingsSource?: 'agent'
onConfigureEmptyState?: () => void
onInputValueChange: (value: string) => void
onOpenMarketplace?: () => void
onHide: () => void
}
function Popup({
@ -53,6 +54,7 @@ function Popup({
providerSettingsSource,
onConfigureEmptyState,
onInputValueChange,
onOpenMarketplace,
onHide,
}: PopupProps) {
const { t } = useTranslation()
@ -236,6 +238,7 @@ function Popup({
theme={theme}
onMarketplaceCollapsedChange={setMarketplaceCollapsed}
onInstallPlugin={handleInstallPlugin}
onOpenMarketplace={onOpenMarketplace}
/>
)}
</div>

View File

@ -38,6 +38,7 @@ type SystemModelSelectorProps = {
notConfigured: boolean
isLoading?: boolean
hideProviderSettingsFooter?: boolean
onOpenMarketplace?: () => void
}
type SystemModelLabelKey
@ -64,6 +65,7 @@ const SystemModel: FC<SystemModelSelectorProps> = ({
notConfigured,
isLoading,
hideProviderSettingsFooter,
onOpenMarketplace,
}) => {
const { t } = useTranslation()
const { workspacePermissionKeys } = useAppContext()
@ -189,6 +191,7 @@ const SystemModel: FC<SystemModelSelectorProps> = ({
defaultModel={currentTextGenerationDefaultModel}
modelList={textGenerationModelList}
hideProviderSettingsFooter={hideProviderSettingsFooter}
onOpenMarketplace={onOpenMarketplace}
onConfigureEmptyState={() => setOpen(false)}
showModelMeta={false}
onSelect={model => handleChangeDefaultModel(ModelTypeEnum.textGeneration, model)}
@ -202,6 +205,7 @@ const SystemModel: FC<SystemModelSelectorProps> = ({
defaultModel={currentEmbeddingsDefaultModel}
modelList={embeddingModelList}
hideProviderSettingsFooter={hideProviderSettingsFooter}
onOpenMarketplace={onOpenMarketplace}
onConfigureEmptyState={() => setOpen(false)}
showModelMeta={false}
onSelect={model => handleChangeDefaultModel(ModelTypeEnum.textEmbedding, model)}
@ -215,6 +219,7 @@ const SystemModel: FC<SystemModelSelectorProps> = ({
defaultModel={currentRerankDefaultModel}
modelList={rerankModelList}
hideProviderSettingsFooter={hideProviderSettingsFooter}
onOpenMarketplace={onOpenMarketplace}
onConfigureEmptyState={() => setOpen(false)}
showModelMeta={false}
onSelect={model => handleChangeDefaultModel(ModelTypeEnum.rerank, model)}
@ -228,6 +233,7 @@ const SystemModel: FC<SystemModelSelectorProps> = ({
defaultModel={currentSpeech2textDefaultModel}
modelList={speech2textModelList}
hideProviderSettingsFooter={hideProviderSettingsFooter}
onOpenMarketplace={onOpenMarketplace}
onConfigureEmptyState={() => setOpen(false)}
showModelMeta={false}
onSelect={model => handleChangeDefaultModel(ModelTypeEnum.speech2text, model)}
@ -241,6 +247,7 @@ const SystemModel: FC<SystemModelSelectorProps> = ({
defaultModel={currentTTSDefaultModel}
modelList={ttsModelList}
hideProviderSettingsFooter={hideProviderSettingsFooter}
onOpenMarketplace={onOpenMarketplace}
onConfigureEmptyState={() => setOpen(false)}
showModelMeta={false}
onSelect={model => handleChangeDefaultModel(ModelTypeEnum.tts, model)}

View File

@ -140,14 +140,23 @@ vi.mock('@/app/components/plugins/plugin-page/plugin-tasks', () => ({
default: () => <button type="button" aria-label="plugin tasks">tasks</button>,
}))
vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace-query', () => ({
__esModule: true,
default: ({ installContextCategory }: { installContextCategory?: string }) => (
<div data-testid="install-from-marketplace-query" data-install-context-category={installContextCategory} />
),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page', () => ({
__esModule: true,
default: ({
layout,
onOpenMarketplace,
onSearchTextChange,
searchText,
}: {
layout?: (parts: { body: React.ReactNode, toolbar: React.ReactNode }) => React.ReactNode
onOpenMarketplace?: () => void
onSearchTextChange?: (value: string) => void
searchText: string
}) => {
@ -160,7 +169,11 @@ vi.mock('@/app/components/header/account-setting/model-provider-page', () => ({
/>
</div>
)
const body = <div data-testid="model-provider-page" />
const body = (
<div data-testid="model-provider-page">
<button type="button" aria-label="model provider marketplace" onClick={onOpenMarketplace}>marketplace</button>
</div>
)
if (layout)
return layout({ body, toolbar })
@ -179,9 +192,13 @@ vi.mock('@/app/components/header/account-setting/model-provider-page', () => ({
vi.mock('@/app/components/header/account-setting/data-source-page-new', () => ({
__esModule: true,
default: ({ layout }: { layout?: (parts: { body: React.ReactNode, toolbar: React.ReactNode }) => React.ReactNode }) => {
default: ({ layout, onOpenMarketplace }: { layout?: (parts: { body: React.ReactNode, toolbar: React.ReactNode }) => React.ReactNode, onOpenMarketplace?: () => void }) => {
const toolbar = <div data-testid="data-source-toolbar" />
const body = <div data-testid="data-source-page" />
const body = (
<div data-testid="data-source-page">
<button type="button" aria-label="data source marketplace" onClick={onOpenMarketplace}>marketplace</button>
</div>
)
if (layout)
return layout({ body, toolbar })
@ -292,6 +309,7 @@ describe('IntegrationsPage', () => {
renderIntegrationsPage({ section: 'provider' })
expect(screen.getByTestId('model-provider-page')).toBeInTheDocument()
expect(screen.getByTestId('install-from-marketplace-query')).toHaveAttribute('data-install-context-category', 'model')
expect(screen.getByTestId('model-provider-toolbar').closest('[class*="max-w-[1600px]"]')).toHaveClass('px-6', 'pt-3', 'pb-2')
expect(within(screen.getByTestId('model-provider-toolbar').closest('section')!).getByText('common.settings.provider')).toHaveClass('title-2xl-semi-bold')
expect(screen.getByTestId('model-provider-page').parentElement).toHaveClass('max-w-[1600px]', 'px-6')
@ -353,13 +371,17 @@ describe('IntegrationsPage', () => {
expect(screen.getByRole('link', { name: 'plugin.categorySingle.extension' })).toHaveAttribute('href', '/integrations/extension')
})
it('opens the integrations marketplace path from plugin category empty states', () => {
renderIntegrationsPage({ section: 'extension' })
it.each([
['provider', 'model provider marketplace', '/plugins/model'],
['data-source', 'data source marketplace', '/plugins/datasource'],
['extension', 'empty marketplace', '/plugins/extension'],
] as const)('opens the %s marketplace path from integrations', (section, buttonName, marketplacePath) => {
renderIntegrationsPage({ section })
fireEvent.click(screen.getByRole('button', { name: 'empty marketplace' }))
fireEvent.click(screen.getByRole('button', { name: buttonName }))
expect(mockWindowOpen).toHaveBeenCalledWith(
expect.stringContaining('/plugins/extension?source='),
expect.stringContaining(`${marketplacePath}?source=`),
'_blank',
'noopener,noreferrer',
)
@ -380,6 +402,7 @@ describe('IntegrationsPage', () => {
const { unmount } = renderIntegrationsPage({ section: 'data-source' })
expect(screen.getByTestId('data-source-page')).toBeInTheDocument()
expect(screen.getByTestId('install-from-marketplace-query')).toHaveAttribute('data-install-context-category', 'datasource')
expect(screen.getByRole('button', { name: 'plugin debug' })).toHaveTextContent('plugin.debugInfo.title')
unmount()

View File

@ -1,21 +1,27 @@
import type { ReactNode } from 'react'
import { act, render, screen } from '@testing-library/react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import PluginCategoryPage from '../plugin-category-page'
const {
mockContainerRef,
mockFetchManifestFromMarketPlace,
mockSetInstallState,
mockUseUploader,
mockUsePluginInstallation,
mockPluginInstallationPermission,
} = vi.hoisted(() => ({
mockContainerRef: { current: null },
mockFetchManifestFromMarketPlace: vi.fn(),
mockSetInstallState: vi.fn(),
mockUseUploader: vi.fn((_: unknown) => ({
dragging: false,
fileUploader: { current: null },
fileChangeHandle: undefined,
removeFile: undefined,
})),
mockUsePluginInstallation: vi.fn(),
mockPluginInstallationPermission: {
restrict_to_marketplace_only: false,
},
@ -56,6 +62,34 @@ vi.mock('@/app/components/plugins/install-plugin/install-from-local-package', ()
),
}))
vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () => ({
default: ({
installContextCategory,
onClose,
uniqueIdentifier,
}: {
installContextCategory?: PluginCategoryEnum
onClose: () => void
uniqueIdentifier: string
}) => (
<div
data-testid="install-from-marketplace"
data-install-context-category={installContextCategory}
data-unique-identifier={uniqueIdentifier}
>
<button type="button" onClick={onClose}>close</button>
</div>
),
}))
vi.mock('@/hooks/use-query-params', () => ({
usePluginInstallation: () => mockUsePluginInstallation(),
}))
vi.mock('@/service/plugins', () => ({
fetchManifestFromMarketPlace: (...args: unknown[]) => mockFetchManifestFromMarketPlace(...args),
}))
type UploaderOptions = {
onFileChange: (file: File | null) => void
}
@ -64,6 +98,7 @@ describe('PluginCategoryPage', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPluginInstallationPermission.restrict_to_marketplace_only = false
mockUsePluginInstallation.mockReturnValue([{ packageId: null, bundleInfo: null }, mockSetInstallState])
})
it.each([
@ -112,6 +147,33 @@ describe('PluginCategoryPage', () => {
}))
})
it('keeps marketplace install params while install permission is loading', async () => {
const packageId = 'junjiem/mcp_see_agent:0.2.4@82caf96890992e9dec2c43c3fac82bfce8bd18a41de7c2b6948151b2d7f7b7a2'
mockUsePluginInstallation.mockReturnValue([{ packageId, bundleInfo: null }, mockSetInstallState])
render(<PluginCategoryPage canInstall={false} isInstallPermissionLoading category={PluginCategoryEnum.agent} />)
await waitFor(() => {
expect(mockUseUploader).toHaveBeenCalled()
})
expect(mockFetchManifestFromMarketPlace).not.toHaveBeenCalled()
expect(mockSetInstallState).not.toHaveBeenCalled()
expect(screen.queryByTestId('install-from-marketplace')).not.toBeInTheDocument()
})
it('clears marketplace install params when install permission is unavailable after loading', async () => {
const packageId = 'junjiem/mcp_see_agent:0.2.4@82caf96890992e9dec2c43c3fac82bfce8bd18a41de7c2b6948151b2d7f7b7a2'
mockUsePluginInstallation.mockReturnValue([{ packageId, bundleInfo: null }, mockSetInstallState])
render(<PluginCategoryPage canInstall={false} category={PluginCategoryEnum.agent} />)
await waitFor(() => {
expect(mockSetInstallState).toHaveBeenCalledWith(null)
})
expect(mockFetchManifestFromMarketPlace).not.toHaveBeenCalled()
expect(screen.queryByTestId('install-from-marketplace')).not.toBeInTheDocument()
})
it('opens the local package installer for supported dropped files', () => {
render(<PluginCategoryPage category={PluginCategoryEnum.tool} />)
@ -124,6 +186,33 @@ describe('PluginCategoryPage', () => {
expect(screen.getByTestId('install-from-local-package')).toHaveAttribute('data-install-context-category', PluginCategoryEnum.tool)
})
it('opens the marketplace installer from package id query params', async () => {
const packageId = 'langgenius/telegram_trigger:0.0.6@923a18de89d8cdb7f419d0dff60bf08a8b81b65fef6bf606cf0ce4b0ee56a9ca'
mockUsePluginInstallation.mockReturnValue([{ packageId, bundleInfo: null }, mockSetInstallState])
mockFetchManifestFromMarketPlace.mockResolvedValue({
data: {
plugin: {
org: 'langgenius',
name: 'telegram_trigger',
category: PluginCategoryEnum.trigger,
},
version: { version: '0.0.6' },
},
})
render(<PluginCategoryPage category={PluginCategoryEnum.trigger} />)
await waitFor(() => {
expect(mockFetchManifestFromMarketPlace).toHaveBeenCalledWith(encodeURIComponent(packageId))
expect(screen.getByTestId('install-from-marketplace')).toHaveAttribute('data-unique-identifier', packageId)
})
expect(screen.getByTestId('install-from-marketplace')).toHaveAttribute('data-install-context-category', PluginCategoryEnum.trigger)
fireEvent.click(screen.getByRole('button', { name: 'close' }))
expect(mockSetInstallState).toHaveBeenCalledWith(null)
})
it('ignores dropped files when install permission is unavailable', () => {
render(<PluginCategoryPage canInstall={false} category={PluginCategoryEnum.agent} />)

View File

@ -46,6 +46,7 @@ describe('integration routes', () => {
[undefined, { type: 'redirect', destination: '/integrations/model-provider' }],
[[], { type: 'redirect', destination: '/integrations/model-provider' }],
[['model-provider'], { type: 'section', section: 'provider' }],
[['model-provider', 'plugins'], { type: 'redirect', destination: '/integrations/model-provider' }],
[['tools'], { type: 'redirect', destination: '/integrations/tools/built-in' }],
[['tools', 'built-in'], { type: 'section', section: 'builtin' }],
[['tool', 'api'], { type: 'redirect', destination: '/integrations/tools/api' }],
@ -57,6 +58,10 @@ describe('integration routes', () => {
[['trigger'], { type: 'section', section: 'trigger' }],
[['agent-strategy'], { type: 'section', section: 'agent-strategy' }],
[['extension'], { type: 'section', section: 'extension' }],
[['agent-strategy', 'plugins'], { type: 'redirect', destination: '/integrations/agent-strategy' }],
[['trigger', 'plugins'], { type: 'redirect', destination: '/integrations/trigger' }],
[['tools', 'built-in', 'plugins'], { type: 'redirect', destination: '/integrations/tools/built-in' }],
[['data-source', 'plugins'], { type: 'redirect', destination: '/integrations/data-source' }],
[['model-providers'], { type: 'not-found' }],
[['data-sources'], { type: 'not-found' }],
[['api-extensions'], { type: 'not-found' }],
@ -81,6 +86,30 @@ describe('integration routes', () => {
})
})
it('preserves marketplace install query params when redirecting nested marketplace callbacks', () => {
expect(getIntegrationRouteTargetBySlug(['agent-strategy', 'plugins'], {
'package-ids': '["junjiem/mcp_see_agent"]',
})).toEqual({
type: 'redirect',
destination: '/integrations/agent-strategy?package-ids=%5B%22junjiem%2Fmcp_see_agent%22%5D',
})
})
it('preserves marketplace install query params when redirecting model and data source callbacks', () => {
expect(getIntegrationRouteTargetBySlug(['model-provider', 'plugins'], {
'package-ids': '["langgenius/openai"]',
})).toEqual({
type: 'redirect',
destination: '/integrations/model-provider?package-ids=%5B%22langgenius%2Fopenai%22%5D',
})
expect(getIntegrationRouteTargetBySlug(['data-source', 'plugins'], {
'package-ids': '["langgenius/notion_datasource"]',
})).toEqual({
type: 'redirect',
destination: '/integrations/data-source?package-ids=%5B%22langgenius%2Fnotion_datasource%22%5D',
})
})
it.each([
[{}, '/integrations/tools/built-in'],
[{ section: 'provider' }, '/integrations/model-provider'],

View File

@ -111,6 +111,7 @@ export default function IntegrationsPage({
canUpdatePlugin,
handlePermissionChange,
isPluginCategory,
isReferenceSettingLoading,
permission,
showPermissionQuickPanel,
showPluginCategorySetting,
@ -285,6 +286,7 @@ export default function IntegrationsPage({
onSwitchToMarketplace={handleSwitchToMarketplace}
canInstallPlugin={canInstallPlugin}
canDeletePlugin={canDeletePlugin}
isInstallPermissionLoading={isReferenceSettingLoading}
canUpdatePlugin={canUpdatePlugin}
pluginCategoryToolbarAction={pluginSettingAction}
/>
@ -309,6 +311,7 @@ export default function IntegrationsPage({
onSwitchToMarketplace={handleSwitchToMarketplace}
canInstallPlugin={canInstallPlugin}
canDeletePlugin={canDeletePlugin}
isInstallPermissionLoading={isReferenceSettingLoading}
canUpdatePlugin={canUpdatePlugin}
pluginCategoryToolbarAction={pluginSettingAction}
/>

View File

@ -5,6 +5,7 @@ import { useSuspenseQuery } from '@tanstack/react-query'
import { noop } from 'es-toolkit/function'
import { useMemo, useState } from 'react'
import InstallFromLocalPackage from '@/app/components/plugins/install-plugin/install-from-local-package'
import InstallFromMarketplaceQuery from '@/app/components/plugins/install-plugin/install-from-marketplace-query'
import { usePluginPageContext } from '@/app/components/plugins/plugin-page/context'
import { PluginPageContextProvider } from '@/app/components/plugins/plugin-page/context-provider'
import PluginsPanel from '@/app/components/plugins/plugin-page/plugins-panel'
@ -16,6 +17,7 @@ import { systemFeaturesQueryOptions } from '@/features/system-features/client'
type PluginCategoryPageProps = {
canInstall?: boolean
canDeletePlugin?: boolean
isInstallPermissionLoading?: boolean
canUpdatePlugin?: boolean
category: PluginCategoryEnum
layout?: (parts: { body: ReactNode, toolbar: ReactNode }) => ReactNode
@ -28,6 +30,7 @@ const supportedLocalPackageExtensions = SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS.sp
const PluginCategoryPageContent = ({
canInstall = true,
canDeletePlugin = true,
isInstallPermissionLoading = false,
canUpdatePlugin = true,
category,
layout,
@ -93,6 +96,11 @@ const PluginCategoryPageContent = ({
onSuccess={noop}
/>
)}
<InstallFromMarketplaceQuery
canInstallPlugin={canInstall}
isPermissionLoading={isInstallPermissionLoading}
installContextCategory={category}
/>
<input
ref={fileUploader}
className="hidden"
@ -108,6 +116,7 @@ const PluginCategoryPageContent = ({
const PluginCategoryPage = ({
canInstall = true,
canDeletePlugin = true,
isInstallPermissionLoading = false,
canUpdatePlugin = true,
category,
layout,
@ -125,6 +134,7 @@ const PluginCategoryPage = ({
<PluginCategoryPageContent
canInstall={canInstall}
canDeletePlugin={canDeletePlugin}
isInstallPermissionLoading={isInstallPermissionLoading}
canUpdatePlugin={canUpdatePlugin}
category={category}
layout={layout}

View File

@ -136,8 +136,29 @@ type IntegrationRouteTarget
| { type: 'section', section: IntegrationSection }
| { type: 'not-found' }
const getSectionByCanonicalPath = (path: string) => {
const entry = Object.entries(integrationPathBySection).find(([, sectionPath]) => {
return sectionPath.replace('/integrations/', '') === path
})
return entry?.[0] as IntegrationSection | undefined
}
export const getIntegrationRouteTargetBySlug = (slug?: string[], searchParams?: IntegrationRouteSearchParams): IntegrationRouteTarget => {
const path = slug?.join('/') ?? ''
const nestedMarketplaceCallbackPath = path.endsWith('/plugins')
? path.slice(0, -'/plugins'.length)
: undefined
const nestedMarketplaceCallbackSection = nestedMarketplaceCallbackPath
? getSectionByCanonicalPath(nestedMarketplaceCallbackPath)
: undefined
if (nestedMarketplaceCallbackSection) {
return {
type: 'redirect',
destination: appendSearchParams(buildIntegrationPath(nestedMarketplaceCallbackSection), searchParams),
}
}
switch (path) {
case '':

View File

@ -5,6 +5,7 @@ import type { IntegrationSection } from './routes'
import { ApiBasedExtensionPage } from '@/app/components/header/account-setting/api-based-extension-page'
import DataSourcePage from '@/app/components/header/account-setting/data-source-page-new'
import ModelProviderPage from '@/app/components/header/account-setting/model-provider-page'
import InstallFromMarketplaceQuery from '@/app/components/plugins/install-plugin/install-from-marketplace-query'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { toolsContentFrameClassNames, toolsContentInsetClassNames } from '@/app/components/tools/content-inset'
import { IntegrationPageHeader } from './page-header'
@ -15,6 +16,7 @@ import ToolProviderList from './tool-provider-list'
type IntegrationSectionRendererProps = {
canInstallPlugin?: boolean
canDeletePlugin?: boolean
isInstallPermissionLoading?: boolean
canUpdatePlugin?: boolean
description?: ReactNode
onProviderSearchTextChange: (value: string) => void
@ -29,6 +31,7 @@ type IntegrationSectionRendererProps = {
const IntegrationSectionRenderer = ({
canInstallPlugin = true,
canDeletePlugin = true,
isInstallPermissionLoading = false,
canUpdatePlugin = true,
description,
onProviderSearchTextChange,
@ -74,6 +77,7 @@ const IntegrationSectionRenderer = ({
<PluginCategoryPage
canInstall={canInstallPlugin}
canDeletePlugin={canDeletePlugin}
isInstallPermissionLoading={isInstallPermissionLoading}
canUpdatePlugin={canUpdatePlugin}
category={category}
layout={renderDirectLayout}
@ -81,17 +85,28 @@ const IntegrationSectionRenderer = ({
toolbarAction={pluginCategoryToolbarAction}
/>
)
const renderMarketplaceInstallQuery = (category: PluginCategoryEnum) => (
<InstallFromMarketplaceQuery
canInstallPlugin={canInstallPlugin}
isPermissionLoading={isInstallPermissionLoading}
installContextCategory={category}
/>
)
switch (section) {
case 'provider':
return (
<ModelProviderPage
hideSystemModelSelectorProviderSettingsFooter
layout={renderScrollableLayout}
searchText={providerSearchText}
stickyToolbar
onSearchTextChange={onProviderSearchTextChange}
/>
<>
<ModelProviderPage
hideSystemModelSelectorProviderSettingsFooter
layout={renderScrollableLayout}
onOpenMarketplace={onSwitchToMarketplace}
searchText={providerSearchText}
stickyToolbar
onSearchTextChange={onProviderSearchTextChange}
/>
{renderMarketplaceInstallQuery(PluginCategoryEnum.model)}
</>
)
case 'builtin':
return renderPluginCategoryPage(PluginCategoryEnum.tool)
@ -103,7 +118,10 @@ const IntegrationSectionRenderer = ({
return <ToolProviderList category="workflow" contentInset="compact" layout={renderDirectLayout} />
case 'data-source':
return (
<DataSourcePage stickyToolbar layout={renderScrollableLayout} />
<>
<DataSourcePage stickyToolbar layout={renderScrollableLayout} onOpenMarketplace={onSwitchToMarketplace} />
{renderMarketplaceInstallQuery(PluginCategoryEnum.datasource)}
</>
)
case 'custom-endpoint':
return <ApiBasedExtensionPage layout={renderScrollableLayout} />

View File

@ -1,4 +1,4 @@
import { getLegacyPluginRedirectPath } from '../plugin-routes'
import { getFirstPackageIdFromSearchParams, getInstallRedirectPathByPluginCategory, getInstallRedirectPathFromSearchParams, getLegacyPluginRedirectPath, shouldResolveInstallCategoryRedirect } from '../plugin-routes'
describe('plugin routes', () => {
it.each([
@ -13,10 +13,91 @@ describe('plugin routes', () => {
[{ tab: 'trigger' }, '/integrations/trigger'],
[{ tab: 'agent-strategy' }, '/integrations/agent-strategy'],
[{ tab: 'extension' }, '/integrations/extension'],
[
{ 'tab': 'trigger', 'package-ids': '["langgenius/telegram_trigger"]', 'source': 'https://marketplace.dify.ai' },
'/integrations/trigger?package-ids=%5B%22langgenius%2Ftelegram_trigger%22%5D',
],
])('redirects legacy plugin category URLs for search params %j', (searchParams, expected) => {
expect(getLegacyPluginRedirectPath(searchParams)).toBe(expected)
})
it.each([
{ 'package-ids': '["langgenius/telegram_trigger"]' },
{ 'tab': 'plugins', 'package-ids': '["langgenius/telegram_trigger"]' },
{ 'bundle-info': '{"org":"langgenius","name":"bundle","version":"1.0.0"}' },
])('keeps install deep links on the legacy plugin page for search params %j', (searchParams) => {
expect(getLegacyPluginRedirectPath(searchParams)).toBeUndefined()
})
it('parses the first package id from marketplace install search params', () => {
expect(getFirstPackageIdFromSearchParams({
'package-ids': '["junjiem/mcp_see_agent:0.2.4@82caf96890992e9dec2c43c3fac82bfce8bd18a41de7c2b6948151b2d7f7b7a2"]',
})).toBe('junjiem/mcp_see_agent:0.2.4@82caf96890992e9dec2c43c3fac82bfce8bd18a41de7c2b6948151b2d7f7b7a2')
})
it.each([
[{ 'package-ids': '["junjiem/mcp_see_agent"]' }, true],
[{ 'tab': 'plugins', 'package-ids': '["junjiem/mcp_see_agent"]' }, true],
[{ 'tab': 'trigger', 'package-ids': '["langgenius/telegram_trigger"]' }, false],
[{ 'bundle-info': '{"org":"langgenius","name":"bundle","version":"1.0.0"}' }, false],
])('detects package install deep links that need category resolution for search params %j', (searchParams, expected) => {
expect(shouldResolveInstallCategoryRedirect(searchParams)).toBe(expected)
})
it.each([
[
'model',
{ 'package-ids': '["langgenius/openai"]' },
'/integrations/model-provider?package-ids=%5B%22langgenius%2Fopenai%22%5D',
],
[
'agent-strategy',
{ 'package-ids': '["junjiem/mcp_see_agent"]' },
'/integrations/agent-strategy?package-ids=%5B%22junjiem%2Fmcp_see_agent%22%5D',
],
[
'trigger',
{ 'tab': 'plugins', 'package-ids': '["langgenius/telegram_trigger"]', 'source': 'https://marketplace.dify.ai' },
'/integrations/trigger?package-ids=%5B%22langgenius%2Ftelegram_trigger%22%5D',
],
[
'datasource',
{ 'package-ids': '["langgenius/notion_datasource"]' },
'/integrations/data-source?package-ids=%5B%22langgenius%2Fnotion_datasource%22%5D',
],
])('builds install redirect paths from marketplace plugin category %s', (category, searchParams, expected) => {
expect(getInstallRedirectPathByPluginCategory(category, searchParams)).toBe(expected)
})
it.each([
[
{ 'category': 'agent-strategy', 'package-ids': '["junjiem/mcp_see_agent"]', 'source': 'https://marketplace.dify.ai' },
'/integrations/agent-strategy?package-ids=%5B%22junjiem%2Fmcp_see_agent%22%5D',
],
[
{ 'tab': 'trigger', 'package-ids': '["langgenius/telegram_trigger"]', 'source': 'https://marketplace.dify.ai' },
'/integrations/trigger?package-ids=%5B%22langgenius%2Ftelegram_trigger%22%5D',
],
[
{ 'category': 'model', 'package-ids': '["langgenius/openai"]', 'source': 'https://marketplace.dify.ai' },
'/integrations/model-provider?package-ids=%5B%22langgenius%2Fopenai%22%5D',
],
[
{ 'category': 'datasource', 'package-ids': '["langgenius/notion_datasource"]', 'source': 'https://marketplace.dify.ai' },
'/integrations/data-source?package-ids=%5B%22langgenius%2Fnotion_datasource%22%5D',
],
[
{ 'category': 'bundle', 'package-ids': '["langgenius/bundle"]' },
undefined,
],
[
{ category: 'agent-strategy' },
undefined,
],
])('builds install redirect paths directly from install search params %j', (searchParams, expected) => {
expect(getInstallRedirectPathFromSearchParams(searchParams)).toBe(expected)
})
it.each([
[{ tab: 'discover' }, '/marketplace'],
[{ tab: 'discover', category: 'extension' }, '/marketplace?category=extension'],

View File

@ -0,0 +1,110 @@
'use client'
import type { Dependency, PluginDeclaration, PluginManifestInMarket } from '../../types'
import { useEffect, useState } from 'react'
import { MARKETPLACE_API_PREFIX } from '@/config'
import { usePluginInstallation } from '@/hooks/use-query-params'
import { fetchBundleInfoFromMarketPlace, fetchManifestFromMarketPlace } from '@/service/plugins'
type MarketplaceInstall = {
manifest: PluginDeclaration | PluginManifestInMarket
uniqueIdentifier: string
}
export type UseInstallFromMarketplaceQueryOptions = {
canInstallPlugin: boolean
isPermissionLoading?: boolean
onPackageCategoryResolved?: (category: string | undefined, packageId: string) => boolean
}
export const useInstallFromMarketplaceQuery = ({
canInstallPlugin,
isPermissionLoading = false,
onPackageCategoryResolved,
}: UseInstallFromMarketplaceQueryOptions) => {
const [{ packageId, bundleInfo }, setInstallState] = usePluginInstallation()
const [marketplaceInstall, setMarketplaceInstall] = useState<MarketplaceInstall | null>(null)
const [dependencies, setDependencies] = useState<Dependency[]>([])
const [isShowInstallFromMarketplace, setIsShowInstallFromMarketplace] = useState(false)
useEffect(() => {
if (!packageId && !bundleInfo)
return
if (isPermissionLoading)
return
if (!canInstallPlugin) {
setInstallState(null)
return
}
let ignore = false
const loadMarketplaceInstall = async () => {
if (packageId) {
try {
const { data } = await fetchManifestFromMarketPlace(encodeURIComponent(packageId))
if (ignore)
return
const { plugin, version } = data
const redirected = onPackageCategoryResolved?.(plugin.category, packageId)
if (redirected)
return
setMarketplaceInstall({
uniqueIdentifier: packageId,
manifest: {
...plugin,
version: version.version,
icon: `${MARKETPLACE_API_PREFIX}/plugins/${plugin.org}/${plugin.name}/icon`,
},
})
setIsShowInstallFromMarketplace(true)
}
catch (error) {
if (!ignore)
console.error('Failed to load marketplace plugin manifest:', error)
}
return
}
if (bundleInfo) {
try {
const { data } = await fetchBundleInfoFromMarketPlace(bundleInfo)
if (ignore)
return
setDependencies(data.version.dependencies)
setIsShowInstallFromMarketplace(true)
}
catch (error) {
if (!ignore)
console.error('Failed to load bundle info:', error)
}
}
}
loadMarketplaceInstall()
return () => {
ignore = true
}
}, [bundleInfo, canInstallPlugin, isPermissionLoading, onPackageCategoryResolved, packageId, setInstallState])
const hideInstallFromMarketplace = () => {
setMarketplaceInstall(null)
setDependencies([])
setIsShowInstallFromMarketplace(false)
setInstallState(null)
}
return {
bundleInfo,
dependencies,
hideInstallFromMarketplace,
isShowInstallFromMarketplace,
marketplaceInstall: marketplaceInstall?.uniqueIdentifier === packageId ? marketplaceInstall : null,
}
}

View File

@ -0,0 +1,43 @@
'use client'
import type { PluginCategoryEnum, PluginManifestInMarket } from '../types'
import type { UseInstallFromMarketplaceQueryOptions } from './hooks/use-install-from-marketplace-query'
import { useInstallFromMarketplaceQuery as useInstallFromMarketplaceQueryHook } from './hooks/use-install-from-marketplace-query'
import InstallFromMarketplace from './install-from-marketplace'
type InstallFromMarketplaceQueryProps = UseInstallFromMarketplaceQueryOptions & {
installContextCategory?: PluginCategoryEnum
}
const InstallFromMarketplaceQuery = ({
installContextCategory,
...options
}: InstallFromMarketplaceQueryProps) => {
const {
bundleInfo,
dependencies,
hideInstallFromMarketplace,
isShowInstallFromMarketplace,
marketplaceInstall,
} = useInstallFromMarketplaceQueryHook(options)
if (!isShowInstallFromMarketplace)
return null
if (!marketplaceInstall && !bundleInfo)
return null
return (
<InstallFromMarketplace
manifest={marketplaceInstall?.manifest as PluginManifestInMarket}
uniqueIdentifier={marketplaceInstall?.uniqueIdentifier ?? ''}
isBundle={!!bundleInfo}
dependencies={dependencies}
installContextCategory={installContextCategory}
onClose={hideInstallFromMarketplace}
onSuccess={hideInstallFromMarketplace}
/>
)
}
export default InstallFromMarketplaceQuery

View File

@ -12,6 +12,9 @@ import { fetchBundleInfoFromMarketPlace, fetchManifestFromMarketPlace } from '@/
import PluginPageWithContext from '../index'
let mockEnableMarketplace = true
const { mockRouterReplace } = vi.hoisted(() => ({
mockRouterReplace: vi.fn(),
}))
const render = (ui: ReactElement, options: Parameters<typeof renderWithSystemFeatures>[1] = {}) =>
renderWithSystemFeatures(ui, {
@ -33,6 +36,12 @@ vi.mock('@/hooks/use-document-title', () => ({
default: vi.fn(),
}))
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
replace: mockRouterReplace,
}),
}))
vi.mock('@/context/i18n', () => ({
useLocale: () => 'en-US',
useDocLink: () => (path: string) => `https://docs.example.com${path}`,
@ -466,7 +475,7 @@ describe('PluginPage Component', () => {
plugin: { org: 'test-org', name: 'test-plugin', category: 'tool' },
version: { version: '1.0.0' },
},
} as Awaited<ReturnType<typeof fetchManifestFromMarketPlace>>)
} as unknown as Awaited<ReturnType<typeof fetchManifestFromMarketPlace>>)
render(<PluginPageWithContext {...createDefaultProps()} />)
@ -502,10 +511,10 @@ describe('PluginPage Component', () => {
vi.mocked(fetchManifestFromMarketPlace).mockResolvedValue({
data: {
plugin: { org: 'test-org', name: 'test-plugin', category: 'tool' },
plugin: { org: 'test-org', name: 'test-plugin', category: 'unknown' },
version: { version: '1.0.0' },
},
} as Awaited<ReturnType<typeof fetchManifestFromMarketPlace>>)
} as unknown as Awaited<ReturnType<typeof fetchManifestFromMarketPlace>>)
render(<PluginPageWithContext {...createDefaultProps()} />)
@ -514,6 +523,28 @@ describe('PluginPage Component', () => {
}, { timeout: 3000 })
})
it('should redirect supported plugin categories to integrations before opening the modal', async () => {
const mockSetInstallState = vi.fn()
vi.mocked(usePluginInstallation).mockReturnValue([
{ packageId: 'junjiem/mcp_see_agent:0.2.4@test', bundleInfo: null },
mockSetInstallState,
])
vi.mocked(fetchManifestFromMarketPlace).mockResolvedValue({
data: {
plugin: { org: 'junjiem', name: 'mcp_see_agent', category: 'agent-strategy' },
version: { version: '0.2.4' },
},
} as unknown as Awaited<ReturnType<typeof fetchManifestFromMarketPlace>>)
render(<PluginPageWithContext {...createDefaultProps()} />)
await waitFor(() => {
expect(mockRouterReplace).toHaveBeenCalledWith('/integrations/agent-strategy?package-ids=%5B%22junjiem%2Fmcp_see_agent%3A0.2.4%40test%22%5D')
})
expect(screen.queryByTestId('install-marketplace-modal')).not.toBeInTheDocument()
})
it('should handle fetch error gracefully', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
@ -764,10 +795,10 @@ describe('PluginPage Component', () => {
vi.mocked(fetchManifestFromMarketPlace).mockResolvedValue({
data: {
plugin: { org: 'test-org', name: 'test-plugin', category: 'tool' },
plugin: { org: 'test-org', name: 'test-plugin', category: 'unknown' },
version: { version: '1.0.0' },
},
} as Awaited<ReturnType<typeof fetchManifestFromMarketPlace>>)
} as unknown as Awaited<ReturnType<typeof fetchManifestFromMarketPlace>>)
render(<PluginPageWithContext {...createDefaultProps()} />)
@ -1044,10 +1075,10 @@ describe('PluginPage Integration', () => {
vi.mocked(fetchManifestFromMarketPlace).mockResolvedValue({
data: {
plugin: { org: 'langgenius', name: 'test-plugin', category: 'tool' },
plugin: { org: 'langgenius', name: 'test-plugin', category: 'unknown' },
version: { version: '1.0.0' },
},
} as Awaited<ReturnType<typeof fetchManifestFromMarketPlace>>)
} as unknown as Awaited<ReturnType<typeof fetchManifestFromMarketPlace>>)
render(<PluginPageWithContext {...createDefaultProps()} />)

View File

@ -369,6 +369,35 @@ describe('useReferenceSetting Hook', () => {
expect(result.current.canManagement).toBe(true)
expect(result.current.canDebugger).toBe(true)
})
it('should keep permission state loading while workspace permission keys are loading', () => {
vi.mocked(useAppContext).mockReturnValue({
isCurrentWorkspaceManager: false,
isCurrentWorkspaceOwner: false,
isLoadingWorkspacePermissionKeys: true,
langGeniusVersionInfo: { current_version: '1.0.0', latest_version: '', version: '' },
workspacePermissionKeys: [] as string[],
} as ReturnType<typeof useAppContext>)
const { result } = renderHook(() => useReferenceSetting(PluginCategoryEnum.tool))
expect(result.current.isPermissionLoading).toBe(true)
expect(result.current.canInstallPlugin).toBe(false)
})
it('should keep permission state loading while current workspace is loading', () => {
vi.mocked(useAppContext).mockReturnValue({
isCurrentWorkspaceManager: false,
isCurrentWorkspaceOwner: false,
isLoadingCurrentWorkspace: true,
langGeniusVersionInfo: { current_version: '1.0.0', latest_version: '', version: '' },
workspacePermissionKeys: ['plugin.install'],
} as ReturnType<typeof useAppContext>)
const { result } = renderHook(() => useReferenceSetting(PluginCategoryEnum.tool))
expect(result.current.isPermissionLoading).toBe(true)
})
})
describe('RBAC permissions', () => {

View File

@ -1,6 +1,5 @@
'use client'
import type { Dependency, PluginDeclaration, PluginManifestInMarket } from '../types'
import type { PluginPageTab } from './context'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
@ -14,23 +13,22 @@ import {
import { useSuspenseQuery } from '@tanstack/react-query'
import { useBoolean } from 'ahooks'
import { noop } from 'es-toolkit/function'
import { cloneElement, isValidElement, useEffect, useMemo, useState } from 'react'
import { cloneElement, isValidElement, useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import TabSlider from '@/app/components/base/tab-slider'
import ReferenceSettingModal from '@/app/components/plugins/reference-setting-modal'
import { MARKETPLACE_API_PREFIX, SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS } from '@/config'
import { SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS } from '@/config'
import { useDocLink } from '@/context/i18n'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import useDocumentTitle from '@/hooks/use-document-title'
import { usePluginInstallation } from '@/hooks/use-query-params'
import Link from '@/next/link'
import { fetchBundleInfoFromMarketPlace, fetchManifestFromMarketPlace } from '@/service/plugins'
import { sleep } from '@/utils'
import { useRouter } from '@/next/navigation'
import { PLUGIN_PAGE_TABS_MAP } from '../hooks'
import { PluginInstallPermissionProvider } from '../install-plugin/components/plugin-install-permission-provider'
import InstallFromLocalPackage from '../install-plugin/install-from-local-package'
import InstallFromMarketplace from '../install-plugin/install-from-marketplace'
import InstallFromMarketplaceQuery from '../install-plugin/install-from-marketplace-query'
import { PLUGIN_TYPE_SEARCH_MAP } from '../marketplace/constants'
import { getInstallRedirectPathByPluginCategory } from '../plugin-routes'
import { PluginCategoryEnum } from '../types'
import { usePluginPageContext } from './context'
import { PluginPageContextProvider } from './context-provider'
@ -50,6 +48,29 @@ const isPluginPageTab = (value: string): value is PluginPageTab => {
return pluginPageTabSet.has(value)
}
const getCurrentInstallSearchParams = (packageId: string) => {
const searchParams: Record<string, string | string[]> = {}
new URLSearchParams(window.location.search).forEach((value, key) => {
const existing = searchParams[key]
if (existing === undefined) {
searchParams[key] = value
return
}
if (Array.isArray(existing)) {
existing.push(value)
return
}
searchParams[key] = [existing, value]
})
searchParams['package-ids'] ??= JSON.stringify([packageId])
return searchParams
}
export type PluginPageProps = {
plugins: React.ReactNode
marketplace: React.ReactNode
@ -65,24 +86,7 @@ const PluginPage = ({
}: PluginPageProps) => {
const { t } = useTranslation()
const docLink = useDocLink()
// Use nuqs hook for installation state
const [{ packageId, bundleInfo }, setInstallState] = usePluginInstallation()
const [uniqueIdentifier, setUniqueIdentifier] = useState<string | null>(null)
const [dependencies, setDependencies] = useState<Dependency[]>([])
const [isShowInstallFromMarketplace, {
setTrue: showInstallFromMarketplace,
setFalse: doHideInstallFromMarketplace,
}] = useBoolean(false)
const hideInstallFromMarketplace = () => {
doHideInstallFromMarketplace()
setInstallState(null)
}
const [manifest, setManifest] = useState<PluginDeclaration | PluginManifestInMarket | null>(null)
const { replace } = useRouter()
const {
referenceSetting,
@ -98,41 +102,15 @@ const PluginPage = ({
setReferenceSettings,
} = useReferenceSetting(PluginCategoryEnum.tool)
useEffect(() => {
(async () => {
setUniqueIdentifier(null)
await sleep(100)
if (isPermissionLoading)
return
const handlePackageCategoryResolved = useCallback((category: string | undefined, packageId: string) => {
const installRedirectPath = getInstallRedirectPathByPluginCategory(category, getCurrentInstallSearchParams(packageId))
if (!installRedirectPath)
return false
replace(installRedirectPath)
return true
}, [replace])
if (!canInstallPlugin) {
setInstallState(null)
return
}
if (packageId) {
const { data } = await fetchManifestFromMarketPlace(encodeURIComponent(packageId))
const { plugin, version } = data
setManifest({
...plugin,
version: version.version,
icon: `${MARKETPLACE_API_PREFIX}/plugins/${plugin.org}/${plugin.name}/icon`,
})
setUniqueIdentifier(packageId)
showInstallFromMarketplace()
return
}
if (bundleInfo) {
try {
const { data } = await fetchBundleInfoFromMarketPlace(bundleInfo)
setDependencies(data.version.dependencies)
showInstallFromMarketplace()
}
catch (error) {
console.error('Failed to load bundle info:', error)
}
}
})()
}, [packageId, bundleInfo, canInstallPlugin, isPermissionLoading, setInstallState, showInstallFromMarketplace])
const [showPluginSettingModal, {
setTrue: setShowPluginSettingModal,
setFalse: setHidePluginSettingModal,
@ -351,18 +329,11 @@ const PluginPage = ({
/>
)}
{
isShowInstallFromMarketplace && uniqueIdentifier && (
<InstallFromMarketplace
manifest={manifest! as PluginManifestInMarket}
uniqueIdentifier={uniqueIdentifier}
isBundle={!!bundleInfo}
dependencies={dependencies}
onClose={hideInstallFromMarketplace}
onSuccess={hideInstallFromMarketplace}
/>
)
}
<InstallFromMarketplaceQuery
canInstallPlugin={canInstallPlugin}
isPermissionLoading={isPermissionLoading}
onPackageCategoryResolved={handlePackageCategoryResolved}
/>
</div>
)
}

View File

@ -25,7 +25,14 @@ const useCanSetPluginSettings = () => {
export const usePluginSettingsAccess = () => {
const { t } = useTranslation()
const { isCurrentWorkspaceManager, isCurrentWorkspaceOwner, workspacePermissionKeys, langGeniusVersionInfo } = useAppContext()
const {
isCurrentWorkspaceManager,
isCurrentWorkspaceOwner,
isLoadingCurrentWorkspace,
isLoadingWorkspacePermissionKeys,
workspacePermissionKeys,
langGeniusVersionInfo,
} = useAppContext()
const { data: rbacEnabled } = useSuspenseQuery({
...systemFeaturesQueryOptions(),
select: s => s.rbac_enabled,
@ -68,7 +75,7 @@ export const usePluginSettingsAccess = () => {
canDebugger: canDebugPlugin,
canSetPermissions,
currentDifyVersion: langGeniusVersionInfo?.current_version,
isPermissionLoading: permissionQuery.isLoading || permissionQuery.isFetching,
isPermissionLoading: permissionQuery.isLoading || permissionQuery.isFetching || !!isLoadingCurrentWorkspace || !!isLoadingWorkspacePermissionKeys,
permissionError: permissionQuery.error,
isPermissionUpdatePending,
}

View File

@ -4,6 +4,8 @@ export type LegacyPluginsSearchParams = Record<string, string | string[] | undef
const INSTALLED_PLUGINS_TAB = 'plugins'
const MARKETPLACE_TAB = 'discover'
const installSearchParamKeys = new Set(['package-ids', 'bundle-info'])
const PACKAGE_IDS_SEARCH_PARAM = 'package-ids'
const integrationPluginPathByTab = new Map<string, string>([
['trigger', '/integrations/trigger'],
@ -11,6 +13,15 @@ const integrationPluginPathByTab = new Map<string, string>([
['extension', '/integrations/extension'],
])
const integrationPluginPathByInstallCategory = new Map<string, string>([
['model', '/integrations/model-provider'],
['tool', '/integrations/tools/built-in'],
['datasource', '/integrations/data-source'],
['trigger', '/integrations/trigger'],
['agent-strategy', '/integrations/agent-strategy'],
['extension', '/integrations/extension'],
])
const getIntegrationPluginPathByTab = (tab: string) => {
return integrationPluginPathByTab.get(tab)
}
@ -27,14 +38,42 @@ const getFirstSearchParamValue = (value: string | string[] | undefined) => {
return value
}
const buildMarketplaceRedirectPath = (
const hasInstallSearchParams = (searchParams: LegacyPluginsSearchParams) => {
return Object.keys(searchParams).some(key => installSearchParamKeys.has(key))
}
export const getFirstPackageIdFromSearchParams = (searchParams: LegacyPluginsSearchParams) => {
const value = getFirstSearchParamValue(searchParams[PACKAGE_IDS_SEARCH_PARAM])
if (!value)
return null
try {
const parsed: unknown = JSON.parse(value)
if (Array.isArray(parsed)) {
const first = parsed[0]
return typeof first === 'string' ? first : null
}
}
catch {
return value
}
return value
}
export const shouldResolveInstallCategoryRedirect = (searchParams: LegacyPluginsSearchParams) => {
const tab = getFirstSearchParamValue(searchParams.tab)
return (!tab || tab === INSTALLED_PLUGINS_TAB) && !!getFirstPackageIdFromSearchParams(searchParams)
}
const buildPreservedSearchParams = (
searchParams: LegacyPluginsSearchParams,
tab: string,
ignoredKeys: Set<string>,
) => {
const preservedSearchParams = new URLSearchParams()
Object.entries(searchParams).forEach(([key, value]) => {
if (key === 'tab' || value === undefined)
if (ignoredKeys.has(key) || value === undefined)
return
if (Array.isArray(value)) {
@ -45,11 +84,74 @@ const buildMarketplaceRedirectPath = (
preservedSearchParams.set(key, value)
})
return preservedSearchParams
}
const buildRedirectPath = (
path: string,
searchParams: LegacyPluginsSearchParams,
ignoredKeys: Set<string>,
) => {
const query = buildPreservedSearchParams(searchParams, ignoredKeys).toString()
return query ? `${path}?${query}` : path
}
const buildInstallRedirectPath = (
path: string,
searchParams: LegacyPluginsSearchParams,
) => {
const installSearchParams: LegacyPluginsSearchParams = {}
Object.entries(searchParams).forEach(([key, value]) => {
if (installSearchParamKeys.has(key))
installSearchParams[key] = value
})
return buildRedirectPath(path, installSearchParams, new Set())
}
export const getInstallRedirectPathByPluginCategory = (
category: string | undefined,
searchParams: LegacyPluginsSearchParams,
) => {
if (!category)
return undefined
const path = integrationPluginPathByInstallCategory.get(category)
if (!path)
return undefined
return buildInstallRedirectPath(path, searchParams)
}
export const getInstallRedirectPathFromSearchParams = (
searchParams: LegacyPluginsSearchParams,
) => {
if (!hasInstallSearchParams(searchParams))
return undefined
const category = getFirstSearchParamValue(searchParams.category)
const pathByCategory = getInstallRedirectPathByPluginCategory(category, searchParams)
if (pathByCategory)
return pathByCategory
const tab = getFirstSearchParamValue(searchParams.tab)
return getInstallRedirectPathByPluginCategory(tab, searchParams)
}
const buildMarketplaceRedirectPath = (
searchParams: LegacyPluginsSearchParams,
tab: string,
) => {
const path = '/marketplace'
const ignoredKeys = new Set(['tab'])
const preservedSearchParams = buildPreservedSearchParams(searchParams, ignoredKeys)
if (tab !== MARKETPLACE_TAB && !preservedSearchParams.has('category'))
preservedSearchParams.set('category', tab)
const query = preservedSearchParams.toString()
return query ? `/marketplace?${query}` : '/marketplace'
return query ? `${path}?${query}` : path
}
export const getLegacyPluginRedirectPath = (
@ -57,12 +159,18 @@ export const getLegacyPluginRedirectPath = (
) => {
const tab = getFirstSearchParamValue(searchParams.tab)
if ((!tab || tab === INSTALLED_PLUGINS_TAB) && hasInstallSearchParams(searchParams))
return undefined
if (!tab || tab === INSTALLED_PLUGINS_TAB)
return '/integrations'
const integrationPluginPath = getIntegrationPluginPathByTab(tab)
if (integrationPluginPath)
return integrationPluginPath
if (integrationPluginPath) {
return hasInstallSearchParams(searchParams)
? buildInstallRedirectPath(integrationPluginPath, searchParams)
: buildRedirectPath(integrationPluginPath, searchParams, new Set(['tab']))
}
if (marketplacePluginTabs.has(tab))
return buildMarketplaceRedirectPath(searchParams, tab)