mirror of
https://github.com/langgenius/dify.git
synced 2026-07-23 20:18:40 +08:00
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:
parent
4f1030b94d
commit
f816ae2e95
@ -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
|
||||
|
||||
@ -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}`,
|
||||
}))
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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" />}
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
|
||||
@ -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" />}
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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} />)
|
||||
|
||||
|
||||
@ -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'],
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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 '':
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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'],
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
@ -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()} />)
|
||||
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user