feat: refine integrations layout and controls

- add integrations headers, install action, permission quick settings, and update setting entry points

- centralize default vs compact content insets for integrations child pages

- cover provider, plugin, marketplace, MCP, and model provider behaviors with focused tests
This commit is contained in:
Jingyi-Dify 2026-05-12 14:47:27 -07:00
parent 150396d7f7
commit a917a49f66
19 changed files with 744 additions and 125 deletions

View File

@ -1,4 +1,4 @@
import { act, screen } from '@testing-library/react'
import { act, fireEvent, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import {
@ -8,6 +8,10 @@ import {
} from '../declarations'
import ModelProviderPage from '../index'
const { mockSetReferenceSettings } = vi.hoisted(() => ({
mockSetReferenceSettings: vi.fn(),
}))
const mockQuotaConfig = {
quota_type: CurrentSystemQuotaTypeEnum.free,
quota_unit: QuotaUnitEnum.times,
@ -83,6 +87,22 @@ vi.mock('../system-model-selector', () => ({
default: () => <div data-testid="system-model-selector" />,
}))
vi.mock('@/app/components/plugins/plugin-page/use-reference-setting', () => ({
default: () => ({
referenceSetting: { permission: {}, auto_upgrade: {} },
canSetPermissions: true,
setReferenceSettings: mockSetReferenceSettings,
}),
}))
vi.mock('@/app/components/plugins/reference-setting-modal', () => ({
default: ({ onHide }: { onHide: () => void }) => (
<div data-testid="reference-setting-modal">
<button type="button" onClick={onHide}>close</button>
</div>
),
}))
vi.mock('@/service/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/client')>()
const originalPlugins = actual.consoleQuery.plugins as unknown as Record<string, unknown>
@ -147,11 +167,20 @@ describe('ModelProviderPage', () => {
it('should render main elements', () => {
renderModelProviderPage()
expect(screen.getByText('common.modelProvider.models')).toBeInTheDocument()
expect(screen.getByPlaceholderText('common.modelProvider.searchModels')).toBeInTheDocument()
expect(screen.getByText('common.modelProvider.updateSetting')).toBeInTheDocument()
expect(screen.getByTestId('system-model-selector')).toBeInTheDocument()
expect(screen.getByTestId('install-from-marketplace')).toBeInTheDocument()
})
it('should open plugin reference settings from the update setting button', () => {
renderModelProviderPage()
fireEvent.click(screen.getByText('common.modelProvider.updateSetting'))
expect(screen.getByTestId('reference-setting-modal')).toBeInTheDocument()
})
it('should render configured and not configured providers sections', () => {
renderModelProviderPage()
expect(screen.getByText('openai')).toBeInTheDocument()

View File

@ -2,12 +2,16 @@ import type {
ModelProvider,
} from './declarations'
import type { PluginDetail } from '@/app/components/plugins/types'
import { cn } from '@langgenius/dify-ui/cn'
import { Button } from '@langgenius/dify-ui/button'
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
import { useDebounce } from 'ahooks'
import { useMemo } from 'react'
import { noop } from 'es-toolkit/function'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import SearchInput from '@/app/components/base/search-input'
import { usePluginsWithLatestVersion } from '@/app/components/plugins/hooks'
import useReferenceSetting from '@/app/components/plugins/plugin-page/use-reference-setting'
import ReferenceSettingModal from '@/app/components/plugins/reference-setting-modal'
import { IS_CLOUD_EDITION } from '@/config'
import { useProviderContext } from '@/context/provider-context'
import { consoleQuery } from '@/service/client'
@ -34,9 +38,19 @@ type Props = {
const FixedModelProvider = ['langgenius/openai/openai', 'langgenius/anthropic/anthropic']
const ModelProviderPage = ({ searchText }: Props) => {
const ModelProviderPage = ({
onSearchTextChange,
searchText,
}: Props) => {
const debouncedSearchText = useDebounce(searchText, { wait: 500 })
const { t } = useTranslation()
const {
referenceSetting,
canSetPermissions,
setReferenceSettings,
} = useReferenceSetting()
const [showPluginSettingModal, setShowPluginSettingModal] = useState(false)
const [warningDismissed, setWarningDismissed] = useState(false)
const { data: textGenerationDefaultModel, isLoading: isTextGenerationDefaultModelLoading } = useDefaultModel(ModelTypeEnum.textGeneration)
const { data: embeddingsDefaultModel, isLoading: isEmbeddingsDefaultModelLoading } = useDefaultModel(ModelTypeEnum.textEmbedding)
const { data: rerankDefaultModel, isLoading: isRerankDefaultModelLoading } = useDefaultModel(ModelTypeEnum.rerank)
@ -132,21 +146,30 @@ const ModelProviderPage = ({ searchText }: Props) => {
return (
<div className="relative -mt-2 pt-1">
<div className={cn('mb-2 flex items-center')}>
<div className="grow system-md-semibold text-text-primary">{t('modelProvider.models', { ns: 'common' })}</div>
<div className={cn(
'relative flex shrink-0 items-center justify-end gap-2 rounded-lg border border-transparent p-px',
showWarning && 'border-components-panel-border bg-components-panel-bg-blur pl-2 shadow-xs',
)}
>
{showWarning && <div className="absolute top-0 right-0 bottom-0 left-0 opacity-40" style={{ background: 'linear-gradient(92deg, rgba(247, 144, 9, 0.25) 0%, rgba(255, 255, 255, 0.00) 100%)' }} />}
{showWarning && (
<div className="flex items-center gap-1 system-xs-medium text-text-primary">
<span className="i-ri-alert-fill h-4 w-4 text-text-warning-secondary" />
<span className="max-w-[460px] truncate" title={t(warningTextKey, { ns: 'common' })}>{t(warningTextKey, { ns: 'common' })}</span>
</div>
<div className="mb-4 flex items-center justify-between gap-3">
<SearchInput
className="w-[200px] shrink-0"
placeholder={t('modelProvider.searchModels', { ns: 'common' })}
value={searchText}
onChange={onSearchTextChange ?? noop}
/>
<div className="flex shrink-0 items-center justify-end gap-2">
{canSetPermissions && referenceSetting && (
<Button
variant="secondary"
className="h-8 gap-0.5 px-3 system-sm-medium"
onClick={() => setShowPluginSettingModal(true)}
>
<span aria-hidden className="i-ri-flashlight-line size-4" />
<span className="px-0.5">{t('modelProvider.updateSetting', { ns: 'common' })}</span>
<span className="flex min-w-4 items-center justify-center rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1 py-0.5 system-2xs-medium-uppercase text-text-tertiary">
{t('autoUpdate.strategy.latest.name', { ns: 'plugin' })}
</span>
<span aria-hidden className="i-ri-arrow-down-s-line size-4" />
</Button>
)}
<SystemModelSelector
className="h-8 px-3 system-sm-medium"
notConfigured={showWarning}
textGenerationDefaultModel={textGenerationDefaultModel}
embeddingsDefaultModel={embeddingsDefaultModel}
@ -157,6 +180,24 @@ const ModelProviderPage = ({ searchText }: Props) => {
/>
</div>
</div>
{showWarning && !warningDismissed && (
<div className="fixed top-2 right-2 z-50 p-2">
<div className="flex items-center gap-2 rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3 py-2 shadow-xs backdrop-blur-[5px]">
<span aria-hidden className="i-ri-alert-fill size-4 shrink-0 text-text-warning-secondary" />
<span className="shrink-0 system-xs-medium whitespace-nowrap text-text-primary" title={t(warningTextKey, { ns: 'common' })}>
{t(warningTextKey, { ns: 'common' })}
</span>
<button
type="button"
className="flex size-4 shrink-0 items-center justify-center text-text-tertiary hover:text-text-secondary"
aria-label={t('operation.close', { ns: 'common' })}
onClick={() => setWarningDismissed(true)}
>
<span aria-hidden className="i-ri-close-line size-4" />
</button>
</div>
</div>
)}
{IS_CLOUD_EDITION && <QuotaPanel providers={providers} />}
{!filteredConfiguredProviders?.length && (
<div className="mb-2 rounded-[10px] bg-workflow-process-bg p-4">
@ -201,6 +242,13 @@ const ModelProviderPage = ({ searchText }: Props) => {
/>
)
}
{showPluginSettingModal && referenceSetting && (
<ReferenceSettingModal
payload={referenceSetting}
onHide={() => setShowPluginSettingModal(false)}
onSave={setReferenceSettings}
/>
)}
</div>
)
}

View File

@ -39,9 +39,13 @@ vi.mock('@/app/components/base/icons/src/vender/solid/mediaAndDevices', () => ({
MagicBox: () => <span data-testid="magic-box-icon">magic</span>,
}))
type MockButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: string
}
vi.mock('@langgenius/dify-ui/button', () => ({
Button: ({ children, onClick, className, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
<button type="button" data-testid="button-content" className={className} onClick={onClick} {...props}>{children}</button>
Button: ({ children, onClick, className, variant, ...props }: MockButtonProps) => (
<button type="button" data-testid="button-content" data-variant={variant} className={className} onClick={onClick} {...props}>{children}</button>
),
}))
@ -95,9 +99,11 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', async () => {
},
DropdownMenuContent: ({
children,
popupClassName,
}: {
children: React.ReactNode
}) => portalOpen ? <div data-testid="dropdown-content">{children}</div> : null,
popupClassName?: string
}) => portalOpen ? <div data-testid="dropdown-content" className={popupClassName}>{children}</div> : null,
DropdownMenuItem: ({
children,
onClick,
@ -164,6 +170,32 @@ describe('InstallPluginDropdown', () => {
expect(screen.getByText('plugin.source.local')).toBeInTheDocument()
})
it('applies custom trigger label and presentation props', () => {
const { container } = render(
<InstallPluginDropdown
onSwitchToMarketplaceTab={vi.fn()}
rootClassName="custom-root"
triggerClassName="custom-trigger"
triggerLabel="Install"
triggerOpenClassName="custom-open"
triggerVariant="primary"
popupClassName="custom-popup"
/>,
)
const trigger = screen.getByTestId('dropdown-trigger')
expect(container.querySelector('.custom-root')).toBeInTheDocument()
expect(trigger).toHaveTextContent('Install')
expect(trigger).toHaveClass('custom-trigger')
expect(trigger).toHaveAttribute('data-variant', 'primary')
fireEvent.click(trigger)
expect(trigger).toHaveClass('custom-open')
expect(screen.getByTestId('dropdown-content')).toHaveClass('custom-popup')
})
it('shows only marketplace when installation is restricted', () => {
mockSystemFeatures.plugin_installation_permission.restrict_to_marketplace_only = true

View File

@ -66,7 +66,7 @@ vi.mock('../filter-management', () => ({
}))
vi.mock('../empty', () => ({
default: () => <div data-testid="empty-state">empty</div>,
default: ({ contentInset }: { contentInset?: string }) => <div data-testid="empty-state" data-content-inset={contentInset}>empty</div>,
}))
vi.mock('../list', () => ({
@ -148,6 +148,20 @@ describe('PluginsPanel', () => {
expect(screen.getByRole('status')).toBeInTheDocument()
})
it('uses default content inset for the standalone plugins page', () => {
render(<PluginsPanel />)
expect(screen.getByTestId('filter-management').parentElement).toHaveClass('px-12')
expect(screen.getByTestId('empty-state')).toHaveAttribute('data-content-inset', 'default')
})
it('uses compact content inset for integrations plugin categories', () => {
render(<PluginsPanel contentInset="compact" />)
expect(screen.getByTestId('filter-management').parentElement).toHaveClass('px-6')
expect(screen.getByTestId('empty-state')).toHaveAttribute('data-content-inset', 'compact')
})
it('filters the list and exposes the load-more action', () => {
mockState.filters.searchQuery = 'alpha'
mockPluginListWithLatestVersion.mockReturnValue([

View File

@ -0,0 +1,6 @@
export type PluginPageContentInset = 'default' | 'compact'
export const pluginPageContentInsetClassNames: Record<PluginPageContentInset, string> = {
default: 'px-12',
compact: 'px-6',
}

View File

@ -1,9 +1,11 @@
'use client'
import type { PluginPageContentInset } from '../content-inset'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { useSuspenseQuery } from '@tanstack/react-query'
import { noop } from 'es-toolkit/function'
import * as React from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Group } from '@/app/components/base/icons/src/vender/other'
import { FileZip } from '@/app/components/base/icons/src/vender/solid/files'
@ -15,6 +17,7 @@ import { SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS } from '@/config'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { useInstalledPluginList } from '@/service/use-plugins'
import Line from '../../marketplace/empty/line'
import { pluginPageContentInsetClassNames } from '../content-inset'
import { usePluginPageContext } from '../context'
type InstallMethod = {
@ -23,7 +26,13 @@ type InstallMethod = {
action: string
}
const Empty = () => {
type EmptyProps = {
contentInset?: PluginPageContentInset
}
const Empty = ({
contentInset = 'default',
}: EmptyProps) => {
const { t } = useTranslation()
const fileInputRef = useRef<HTMLInputElement>(null)
const [selectedAction, setSelectedAction] = useState<string | null>(null)
@ -55,26 +64,24 @@ const Empty = () => {
return t('list.notFound', { ns: 'plugin' })
}, [pluginList?.plugins.length, t, filters.categories.length, filters.tags.length, filters.searchQuery])
const [installMethods, setInstallMethods] = useState<InstallMethod[]>([])
useEffect(() => {
const methods = []
const installMethods = useMemo<InstallMethod[]>(() => {
const methods: InstallMethod[] = []
if (enable_marketplace)
methods.push({ icon: MagicBox, text: t('source.marketplace', { ns: 'plugin' }), action: 'marketplace' })
if (plugin_installation_permission.restrict_to_marketplace_only) {
setInstallMethods(methods)
}
else {
methods.push({ icon: Github, text: t('source.github', { ns: 'plugin' }), action: 'github' })
methods.push({ icon: FileZip, text: t('source.local', { ns: 'plugin' }), action: 'local' })
setInstallMethods(methods)
}
if (plugin_installation_permission.restrict_to_marketplace_only)
return methods
methods.push({ icon: Github, text: t('source.github', { ns: 'plugin' }), action: 'github' })
methods.push({ icon: FileZip, text: t('source.local', { ns: 'plugin' }), action: 'local' })
return methods
}, [plugin_installation_permission, enable_marketplace, t])
const contentPaddingClassName = pluginPageContentInsetClassNames[contentInset]
return (
<div className="relative z-0 w-full grow">
{/* skeleton */}
<div className="absolute top-0 z-10 grid h-full w-full grid-cols-2 gap-2 overflow-hidden px-12">
<div className={cn('absolute top-0 z-10 grid h-full w-full grid-cols-2 gap-2 overflow-hidden', contentPaddingClassName)}>
{Array.from({ length: 20 }).fill(0).map((_, i) => (
<div key={i} className="h-24 rounded-xl bg-components-card-bg" />
))}

View File

@ -1,5 +1,6 @@
'use client'
import type { ButtonProps } from '@langgenius/dify-ui/button'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
@ -23,6 +24,12 @@ import { systemFeaturesQueryOptions } from '@/service/system-features'
type Props = {
onSwitchToMarketplaceTab: () => void
popupClassName?: string
rootClassName?: string
triggerClassName?: string
triggerLabel?: string
triggerOpenClassName?: string
triggerVariant?: ButtonProps['variant']
}
type InstallMethod = {
@ -33,6 +40,12 @@ type InstallMethod = {
const InstallPluginDropdown = ({
onSwitchToMarketplaceTab,
popupClassName,
rootClassName,
triggerClassName,
triggerLabel,
triggerOpenClassName = 'bg-state-base-hover',
triggerVariant,
}: Props) => {
const { t } = useTranslation()
const fileInputRef = useRef<HTMLInputElement>(null)
@ -112,7 +125,7 @@ const InstallPluginDropdown = ({
return (
<DropdownMenu open={isMenuOpen} onOpenChange={setIsMenuOpen}>
<div className="relative">
<div className={cn('relative', rootClassName)}>
<input
type="file"
ref={fileInputRef}
@ -123,20 +136,25 @@ const InstallPluginDropdown = ({
<DropdownMenuTrigger
render={(
<Button
className={cn('h-full w-full p-2 text-components-button-secondary-text', isMenuOpen && 'bg-state-base-hover')}
variant={triggerVariant}
className={cn(
'h-full w-full p-2',
triggerClassName,
isMenuOpen && triggerOpenClassName,
)}
/>
)}
>
<>
<RiAddLine className="h-4 w-4" />
<span className="pl-1">{t('installPlugin', { ns: 'plugin' })}</span>
<span className="pl-1">{triggerLabel ?? t('installPlugin', { ns: 'plugin' })}</span>
<RiArrowDownSLine className="ml-1 h-4 w-4" />
</>
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-start"
sideOffset={4}
popupClassName="w-[200px] pb-2"
popupClassName={cn('w-[200px] pb-2', popupClassName)}
>
<span className="flex items-start self-stretch pt-1 pr-3 pb-0.5 pl-3 system-xs-medium-uppercase text-text-tertiary">
{t('installFrom', { ns: 'plugin' })}

View File

@ -1,7 +1,9 @@
'use client'
import type { PluginDetail } from '../types'
import type { PluginPageContentInset } from './content-inset'
import type { FilterState } from './filter-management'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { useDebounceFn } from 'ahooks'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
@ -11,6 +13,7 @@ import { useGetLanguage } from '@/context/i18n'
import { renderI18nObject } from '@/i18n-config'
import { useInstalledPluginList, useInvalidateInstalledPluginList } from '@/service/use-plugins'
import { usePluginsWithLatestVersion } from '../hooks'
import { pluginPageContentInsetClassNames } from './content-inset'
import { usePluginPageContext } from './context'
import Empty from './empty'
import FilterManagement from './filter-management'
@ -41,7 +44,13 @@ const matchesSearchQuery = (plugin: PluginDetail & { latest_version: string }, q
return false
}
const PluginsPanel = () => {
type PluginsPanelProps = {
contentInset?: PluginPageContentInset
}
const PluginsPanel = ({
contentInset = 'default',
}: PluginsPanelProps) => {
const { t } = useTranslation()
const locale = useGetLanguage()
const filters = usePluginPageContext(v => v.filters) as FilterState
@ -74,10 +83,11 @@ const PluginsPanel = () => {
}, [currentPluginID, pluginListWithLatestVersion])
const handleHide = () => setCurrentPluginID(undefined)
const contentPaddingClassName = pluginPageContentInsetClassNames[contentInset]
return (
<>
<div className="flex flex-col items-start justify-center gap-3 self-stretch px-12 pt-1 pb-3">
<div className={cn('flex flex-col items-start justify-center gap-3 self-stretch pt-1 pb-3', contentPaddingClassName)}>
<div className="h-px self-stretch bg-divider-subtle"></div>
<FilterManagement
onFilterChange={handleFilterChange}
@ -88,7 +98,7 @@ const PluginsPanel = () => {
<>
{(filteredList?.length ?? 0) > 0
? (
<div className="flex grow flex-wrap content-start items-start justify-center gap-2 self-stretch overflow-y-auto px-12">
<div className={cn('flex grow flex-wrap content-start items-start justify-center gap-2 self-stretch overflow-y-auto', contentPaddingClassName)}>
<div className="w-full">
<List pluginList={filteredList || []} />
</div>
@ -106,7 +116,7 @@ const PluginsPanel = () => {
</div>
)
: (
<Empty />
<Empty contentInset={contentInset} />
)}
</>
)}

View File

@ -2,6 +2,58 @@ import { fireEvent, screen } from '@testing-library/react'
import { renderWithNuqs } from '@/test/nuqs-testing'
import IntegrationsPage from '../integrations-page'
const { mockRouterPush } = vi.hoisted(() => ({
mockRouterPush: vi.fn(),
}))
const {
mockCanSetPermissions,
mockReferenceSetting,
mockSetReferenceSettings,
} = vi.hoisted(() => ({
mockCanSetPermissions: vi.fn(() => true),
mockReferenceSetting: vi.fn(() => ({
permission: {
install_permission: 'everyone',
debug_permission: 'admins',
},
auto_upgrade: {},
})),
mockSetReferenceSettings: vi.fn(),
}))
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
push: mockRouterPush,
}),
}))
vi.mock('@/app/components/plugins/plugin-page/use-reference-setting', () => ({
default: () => ({
referenceSetting: mockReferenceSetting(),
canSetPermissions: mockCanSetPermissions(),
setReferenceSettings: mockSetReferenceSettings,
}),
}))
vi.mock('@/app/components/plugins/reference-setting-modal', () => ({
__esModule: true,
default: ({ onHide }: { onHide: () => void }) => (
<div data-testid="reference-setting-modal">
<button type="button" onClick={onHide}>close</button>
</div>
),
}))
vi.mock('@/app/components/plugins/plugin-page/install-plugin-dropdown', () => ({
__esModule: true,
default: ({ onSwitchToMarketplaceTab }: { onSwitchToMarketplaceTab: () => void }) => (
<button type="button" aria-label="plugin install" onClick={onSwitchToMarketplaceTab}>
install dropdown
</button>
),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page', () => ({
__esModule: true,
default: ({
@ -48,6 +100,14 @@ const renderIntegrationsPage = (searchParams?: Record<string, string>, section?:
describe('IntegrationsPage', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCanSetPermissions.mockReturnValue(true)
mockReferenceSetting.mockReturnValue({
permission: {
install_permission: 'everyone',
debug_permission: 'admins',
},
auto_upgrade: {},
})
})
it('defaults to the model provider section when no query is provided', () => {
@ -121,6 +181,97 @@ describe('IntegrationsPage', () => {
expect(screen.getByRole('link', { name: 'MCP' })).toHaveAttribute('href', '/integrations/tools/mcp')
})
it('renders the tools header for tool sections', () => {
renderIntegrationsPage({ section: 'builtin' })
expect(screen.getAllByText('common.menus.tools')).toHaveLength(2)
expect(screen.getByText('common.toolsPage.description')).toBeInTheDocument()
})
it('renders the mcp header for the mcp section', () => {
renderIntegrationsPage({ section: 'mcp' })
expect(screen.getAllByText('MCP')).toHaveLength(2)
expect(screen.getByText('common.mcpPage.description')).toBeInTheDocument()
expect(screen.queryByText('common.toolsPage.description')).not.toBeInTheDocument()
})
it('renders the swagger API header for the custom tool section', () => {
renderIntegrationsPage({ section: 'custom-tool' })
expect(screen.getAllByText('common.settings.swaggerAPIAsTool')).toHaveLength(2)
expect(screen.getByText('common.swaggerAPIAsToolPage.description')).toBeInTheDocument()
expect(screen.queryByText('common.toolsPage.description')).not.toBeInTheDocument()
})
it.each([
['workflow-tool', 'workflow.common.workflowAsTool', 'common.workflowAsToolPage.description'],
['api-based-extension', 'common.settings.apiBasedExtension', 'common.apiBasedExtensionPage.description'],
['data-source', 'common.settings.dataSource', 'common.dataSourcePage.description'],
['trigger', 'common.settings.trigger', 'common.triggerPage.description'],
['extension', 'common.settings.extension', 'common.extensionPage.description'],
['agent-strategy', 'common.settings.agentStrategy', 'common.agentStrategyPage.description'],
] as const)('renders the %s header', (section, title, description) => {
renderIntegrationsPage({ section })
expect(screen.getAllByText(title)).toHaveLength(2)
expect(screen.getByText(description)).toBeInTheDocument()
expect(screen.queryByText('common.toolsPage.description')).not.toBeInTheDocument()
})
it.each(['extension', 'agent-strategy'] as const)('renders plugin update settings action for %s', (section) => {
renderIntegrationsPage({ section })
expect(screen.getByText('common.modelProvider.updateSetting')).toBeInTheDocument()
expect(screen.getByText('plugin.autoUpdate.strategy.fixOnly.name')).toBeInTheDocument()
fireEvent.click(screen.getByText('common.modelProvider.updateSetting'))
expect(screen.getByTestId('reference-setting-modal')).toBeInTheDocument()
})
it('opens the original plugins marketplace path from the install dropdown marketplace action', () => {
renderIntegrationsPage({ section: 'builtin' })
fireEvent.click(screen.getByRole('button', { name: 'plugin install' }))
expect(mockRouterPush).toHaveBeenCalledWith('/plugins?tab=discover')
})
it('opens the sidebar plugin permissions quick settings and updates permissions', () => {
renderIntegrationsPage({ section: 'provider' })
fireEvent.click(screen.getByRole('button', { name: 'plugin.privilege.permissions' }))
expect(screen.getByText('plugin.privilege.permissions')).toBeInTheDocument()
expect(screen.getByText('plugin.privilege.quickWhoCanInstall')).toBeInTheDocument()
expect(screen.getByText('plugin.privilege.quickWhoCanDebug')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'plugin.privilege.quickWhoCanInstall: plugin.privilege.noone' }))
expect(mockSetReferenceSettings).toHaveBeenCalledWith({
permission: {
install_permission: 'noone',
debug_permission: 'admins',
},
auto_upgrade: {},
})
})
it('disables the sidebar plugin permissions quick settings when permission management is unavailable', () => {
mockCanSetPermissions.mockReturnValue(false)
renderIntegrationsPage({ section: 'provider' })
const trigger = screen.getByRole('button', { name: 'plugin.privilege.permissions' })
expect(trigger).toBeDisabled()
fireEvent.click(trigger)
expect(screen.queryByText('plugin.privilege.quickWhoCanInstall')).not.toBeInTheDocument()
expect(screen.queryByText('plugin.privilege.quickWhoCanDebug')).not.toBeInTheDocument()
})
it('collapses and expands the integrations sidebar', () => {
renderIntegrationsPage({ section: 'provider' })

View File

@ -7,16 +7,6 @@ import { ToolTypeEnum } from '../../workflow/block-selector/types'
import ProviderList from '../provider-list'
import { getToolType } from '../utils'
const { mockRouterPush } = vi.hoisted(() => ({
mockRouterPush: vi.fn(),
}))
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
push: mockRouterPush,
}),
}))
vi.mock('@/app/components/plugins/hooks', () => ({
useTags: () => ({
tags: [],
@ -169,11 +159,12 @@ vi.mock('@/app/components/plugins/marketplace/empty', () => ({
const mockHandleScroll = vi.fn()
vi.mock('../marketplace', () => ({
default: ({ showMarketplacePanel, isMarketplaceArrowVisible }: {
default: ({ showMarketplacePanel, isMarketplaceArrowVisible, contentInset }: {
showMarketplacePanel: () => void
isMarketplaceArrowVisible: boolean
contentInset?: string
}) => (
<div data-testid="marketplace">
<div data-testid="marketplace" data-content-inset={contentInset}>
<button data-testid="marketplace-arrow" onClick={showMarketplacePanel}>
{isMarketplaceArrowVisible ? 'arrow-visible' : 'arrow-hidden'}
</button>
@ -193,8 +184,8 @@ vi.mock('../marketplace/hooks', () => ({
}))
vi.mock('../mcp', () => ({
default: ({ searchText }: { searchText: string }) => (
<div data-testid="mcp-list">
default: ({ searchText, contentInset }: { searchText: string, contentInset?: string }) => (
<div data-testid="mcp-list" data-content-inset={contentInset}>
MCP List:
{searchText}
</div>
@ -213,7 +204,11 @@ describe('getToolType', () => {
})
})
const renderProviderList = (searchParams?: Record<string, string>, category?: ComponentProps<typeof ProviderList>['category']) => {
const renderProviderList = (
searchParams?: Record<string, string>,
category?: ComponentProps<typeof ProviderList>['category'],
contentInset?: ComponentProps<typeof ProviderList>['contentInset'],
) => {
const { wrapper: SystemFeaturesWrapper } = createSystemFeaturesWrapper({
systemFeatures: { enable_marketplace: mockEnableMarketplace },
})
@ -221,7 +216,7 @@ const renderProviderList = (searchParams?: Record<string, string>, category?: Co
<SystemFeaturesWrapper>{children}</SystemFeaturesWrapper>
)
return renderWithNuqs(
<Wrapped><ProviderList category={category} /></Wrapped>,
<Wrapped><ProviderList category={category} contentInset={contentInset} /></Wrapped>,
{ searchParams },
)
}
@ -254,14 +249,14 @@ describe('ProviderList', () => {
expect(screen.getByTestId('custom-create-card')).toBeInTheDocument()
})
it('uses canonical integration routes when controlled by route category', () => {
renderProviderList(undefined, 'mcp')
it('hides category tabs when controlled by route category', () => {
renderProviderList(undefined, 'builtin')
expect(screen.getByTestId('mcp-list')).toBeInTheDocument()
fireEvent.click(screen.getByText('tools.type.workflow'))
expect(mockRouterPush).toHaveBeenCalledWith('/integrations/tools/workflow')
expect(screen.getByTestId('card-google-search')).toBeInTheDocument()
expect(screen.queryByText('tools.type.builtIn')).not.toBeInTheDocument()
expect(screen.queryByText('tools.type.custom')).not.toBeInTheDocument()
expect(screen.queryByText('tools.type.workflow')).not.toBeInTheDocument()
expect(screen.queryByText('MCP')).not.toBeInTheDocument()
})
it('resets current provider when switching to a different tab', () => {
@ -281,6 +276,22 @@ describe('ProviderList', () => {
})
})
describe('Layout', () => {
it('uses default content inset outside compact integrations layout', () => {
const { container } = renderProviderList()
expect(container.querySelector('.sticky')).toHaveClass('px-12')
expect(screen.getByTestId('card-google-search').closest('.grid')).toHaveClass('px-12')
})
it('uses compact content inset when rendered by integrations layout', () => {
const { container } = renderProviderList(undefined, 'builtin', 'compact')
expect(container.querySelector('.sticky')).toHaveClass('px-6')
expect(screen.getByTestId('card-google-search').closest('.grid')).toHaveClass('px-6')
})
})
describe('Filtering', () => {
it('shows only builtin collections by default', () => {
renderProviderList()
@ -304,6 +315,14 @@ describe('ProviderList', () => {
expect(screen.queryByTestId('card-weather-tool')).not.toBeInTheDocument()
})
it('filters search within the current route category', () => {
renderProviderList(undefined, 'builtin')
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'My API' } })
expect(screen.queryByTestId('card-my-api')).not.toBeInTheDocument()
expect(screen.queryByTestId('card-google-search')).not.toBeInTheDocument()
})
it('filters collections by tag', () => {
renderProviderList()
fireEvent.click(screen.getByTestId('add-filter'))
@ -396,6 +415,12 @@ describe('ProviderList', () => {
renderProviderList({ category: 'mcp' })
expect(screen.getByTestId('mcp-list')).toBeInTheDocument()
})
it('passes compact content inset to MCPList when rendered by integrations layout', () => {
renderProviderList(undefined, 'mcp', 'compact')
expect(screen.getByTestId('mcp-list')).toHaveAttribute('data-content-inset', 'compact')
})
})
describe('Provider Detail', () => {
@ -455,6 +480,13 @@ describe('ProviderList', () => {
expect(screen.getByTestId('marketplace')).toBeInTheDocument()
})
it('passes compact content inset to marketplace when rendered by integrations layout', () => {
mockEnableMarketplace = true
renderProviderList(undefined, 'builtin', 'compact')
expect(screen.getByTestId('marketplace')).toHaveAttribute('data-content-inset', 'compact')
})
it('does not show marketplace when enable_marketplace is false', () => {
renderProviderList()
expect(screen.queryByTestId('marketplace')).not.toBeInTheDocument()

View File

@ -0,0 +1,6 @@
export type ToolsContentInset = 'default' | 'compact'
export const toolsContentInsetClassNames: Record<ToolsContentInset, string> = {
default: 'px-12',
compact: 'px-6',
}

View File

@ -9,11 +9,13 @@ import PluginCategoryPage from './plugin-category-page'
import ToolProviderList from './provider-list'
type IntegrationSectionRendererProps = {
onProviderSearchTextChange: (value: string) => void
providerSearchText: string
section: IntegrationSection
}
const IntegrationSectionRenderer = ({
onProviderSearchTextChange,
providerSearchText,
section,
}: IntegrationSectionRendererProps) => {
@ -21,17 +23,20 @@ const IntegrationSectionRenderer = ({
case 'provider':
return (
<div className="px-6 pt-6">
<ModelProviderPage searchText={providerSearchText} />
<ModelProviderPage
searchText={providerSearchText}
onSearchTextChange={onProviderSearchTextChange}
/>
</div>
)
case 'builtin':
return <ToolProviderList category="builtin" />
return <ToolProviderList category="builtin" contentInset="compact" />
case 'mcp':
return <ToolProviderList category="mcp" />
return <ToolProviderList category="mcp" contentInset="compact" />
case 'custom-tool':
return <ToolProviderList category="api" />
return <ToolProviderList category="api" contentInset="compact" />
case 'workflow-tool':
return <ToolProviderList category="workflow" />
return <ToolProviderList category="workflow" contentInset="compact" />
case 'data-source':
return (
<div className="px-6 pt-6">

View File

@ -1,11 +1,18 @@
'use client'
import type { Permissions, ReferenceSetting } from '@/app/components/plugins/types'
import type { IntegrationSection } from '@/app/components/tools/integration-routes'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { parseAsStringLiteral, useQueryState } from 'nuqs'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import DatasourceIcon from '@/app/components/base/icons/src/vender/workflow/Datasource'
import InstallPluginDropdown from '@/app/components/plugins/plugin-page/install-plugin-dropdown'
import useReferenceSetting from '@/app/components/plugins/plugin-page/use-reference-setting'
import ReferenceSettingModal from '@/app/components/plugins/reference-setting-modal'
import { PermissionType } from '@/app/components/plugins/types'
import {
buildIntegrationPath,
INTEGRATION_SECTION_VALUES,
@ -14,6 +21,7 @@ import {
toolCategoryBySection,
} from '@/app/components/tools/integration-routes'
import Link from '@/next/link'
import { useRouter } from '@/next/navigation'
import IntegrationSectionRenderer from './integration-section-renderer'
type IconComponent = typeof DatasourceIcon
@ -30,6 +38,85 @@ type NavItem = {
section?: IntegrationSection
}
type PermissionSettingKey = keyof Permissions
const permissionSettingOptions = [
PermissionType.everyone,
PermissionType.admin,
PermissionType.noOne,
] as const
const PermissionQuickPanel = ({
permission,
onChange,
}: {
permission: Permissions
onChange: (key: PermissionSettingKey, value: PermissionType) => void
}) => {
const { t } = useTranslation()
const rows: Array<{
key: PermissionSettingKey
label: string
value: PermissionType
}> = [
{
key: 'install_permission',
label: t('privilege.quickWhoCanInstall', { ns: 'plugin' }),
value: permission.install_permission || PermissionType.noOne,
},
{
key: 'debug_permission',
label: t('privilege.quickWhoCanDebug', { ns: 'plugin' }),
value: permission.debug_permission || PermissionType.noOne,
},
]
return (
<div className="w-[249px] overflow-hidden rounded-2xl border-t border-components-panel-border bg-components-panel-bg shadow-xl">
<div className="border-b-[0.5px] border-black/5 py-2">
<div className="flex flex-col gap-1 px-1 pt-0.5 pb-1">
<div className="px-3 pt-1 pb-0.5 system-sm-semibold-uppercase text-text-secondary">
{t('privilege.permissions', { ns: 'plugin' })}
</div>
{rows.map(row => (
<div key={row.key} className="flex flex-col gap-0.5 px-3 py-1">
<div className="flex min-h-6 items-center system-sm-semibold whitespace-nowrap text-text-secondary">
{row.label}
</div>
<div
role="group"
aria-label={row.label}
className="inline-flex w-fit items-center gap-px rounded-[10px] bg-components-segmented-control-bg-normal p-0.5"
>
{permissionSettingOptions.map((option) => {
const selected = row.value === option
const optionLabel = t(`privilege.${option}`, { ns: 'plugin' })
return (
<button
key={option}
type="button"
aria-pressed={selected}
aria-label={`${row.label}: ${optionLabel}`}
className={cn(
'flex h-7 shrink-0 items-center justify-center rounded-lg border-[0.5px] border-transparent px-2 py-1 system-sm-medium whitespace-nowrap text-text-secondary transition-colors hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-components-input-border-hover focus-visible:outline-hidden',
selected && 'border-components-segmented-control-item-active-border bg-components-segmented-control-item-active-bg text-text-accent-light-mode-only shadow-xs shadow-shadow-shadow-3 hover:bg-components-segmented-control-item-active-bg hover:text-text-accent-light-mode-only',
)}
onClick={() => onChange(row.key, option)}
>
<span className="px-0.5 py-0.5">{optionLabel}</span>
</button>
)
})}
</div>
</div>
))}
</div>
</div>
</div>
)
}
const navItemClassName = 'flex h-8 w-full items-center gap-2 rounded-lg py-1 pr-1 pl-3 text-left system-sm-medium transition-colors'
const activeNavItemClassName = 'bg-state-base-active system-sm-semibold text-components-menu-item-text-active'
const inactiveNavItemClassName = 'text-components-menu-item-text hover:bg-state-base-hover hover:text-components-menu-item-text-hover'
@ -106,11 +193,18 @@ export default function IntegrationsPage({
section: routeSection,
}: IntegrationsPageProps) {
const { t } = useTranslation()
const router = useRouter()
const {
referenceSetting,
canSetPermissions,
setReferenceSettings,
} = useReferenceSetting()
const [sectionParam] = useQueryState('section', parseAsIntegrationSection)
const [categoryParam] = useQueryState('category', parseAsToolCategory)
const section = routeSection ?? sectionParam ?? (categoryParam ? sectionByToolCategory[categoryParam] : 'provider')
const providerSearchText = ''
const [providerSearchText, setProviderSearchText] = useState('')
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
const [showPluginSettingModal, setShowPluginSettingModal] = useState(false)
const providerItem = useMemo<NavItem>(() => ({
section: 'provider',
label: t('settings.provider', { ns: 'common' }),
@ -173,6 +267,71 @@ export default function IntegrationsPage({
const isToolSection = Boolean(toolCategoryBySection[section])
const isPluginCategorySection = section === 'trigger' || section === 'agent-strategy' || section === 'extension'
const useFillLayout = isToolSection || isPluginCategorySection
const integrationHeader = useMemo(() => {
switch (section) {
case 'builtin':
return {
title: t('menus.tools', { ns: 'common' }),
description: t('toolsPage.description', { ns: 'common' }),
}
case 'mcp':
return {
title: 'MCP',
description: t('mcpPage.description', { ns: 'common' }),
}
case 'custom-tool':
return {
title: t('settings.swaggerAPIAsTool', { ns: 'common' }),
description: t('swaggerAPIAsToolPage.description', { ns: 'common' }),
}
case 'workflow-tool':
return {
title: t('common.workflowAsTool', { ns: 'workflow' }),
description: t('workflowAsToolPage.description', { ns: 'common' }),
}
case 'api-based-extension':
return {
title: t('settings.apiBasedExtension', { ns: 'common' }),
description: t('apiBasedExtensionPage.description', { ns: 'common' }),
}
case 'data-source':
return {
title: t('settings.dataSource', { ns: 'common' }),
description: t('dataSourcePage.description', { ns: 'common' }),
}
case 'trigger':
return {
title: t('settings.trigger', { ns: 'common' }),
description: t('triggerPage.description', { ns: 'common' }),
}
case 'extension':
return {
title: t('settings.extension', { ns: 'common' }),
description: t('extensionPage.description', { ns: 'common' }),
}
case 'agent-strategy':
return {
title: t('settings.agentStrategy', { ns: 'common' }),
description: t('agentStrategyPage.description', { ns: 'common' }),
}
default:
return null
}
}, [section, t])
const showHeaderPluginSetting = (section === 'extension' || section === 'agent-strategy') && canSetPermissions && !!referenceSetting
const showPermissionQuickPanel = canSetPermissions && !!referenceSetting
const handlePermissionChange = (key: PermissionSettingKey, value: PermissionType) => {
if (!referenceSetting)
return
setReferenceSettings({
...referenceSetting,
permission: {
...referenceSetting.permission,
[key]: value,
},
} satisfies ReferenceSetting)
}
return (
<div className="flex h-full min-h-0 bg-background-body">
@ -214,25 +373,45 @@ export default function IntegrationsPage({
</div>
{!sidebarCollapsed && (
<div className="mt-6 flex shrink-0 items-center gap-1">
<button
type="button"
disabled
className="flex h-8 min-w-0 flex-1 cursor-not-allowed items-center justify-center gap-0.5 rounded-lg border-[0.5px] border-components-button-primary-border bg-components-button-primary-bg px-2 system-sm-medium text-components-button-primary-text opacity-60 shadow-xs"
title={t('installAction', { ns: 'plugin' })}
>
<span aria-hidden className="i-ri-add-line size-4 shrink-0" />
<span className="min-w-0 truncate pl-1">{t('installAction', { ns: 'plugin' })}</span>
<span aria-hidden className="i-ri-arrow-down-s-line size-4 shrink-0" />
</button>
<button
type="button"
disabled
className="flex size-8 shrink-0 cursor-not-allowed items-center justify-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg text-components-button-secondary-text opacity-60 shadow-xs"
aria-label={t('settings.filter', { ns: 'common' })}
title={t('settings.filter', { ns: 'common' })}
>
<span aria-hidden className="i-ri-equalizer-2-line size-4" />
</button>
<InstallPluginDropdown
rootClassName="min-w-0 flex-1"
triggerVariant="primary"
triggerClassName="h-8 min-w-0 gap-0.5 p-2 system-sm-medium"
triggerLabel={t('installAction', { ns: 'plugin' })}
triggerOpenClassName="bg-components-button-primary-bg-hover"
popupClassName="w-[240px] rounded-2xl py-2 shadow-xl"
onSwitchToMarketplaceTab={() => router.push('/plugins?tab=discover')}
/>
<Popover>
<PopoverTrigger
render={(
<button
type="button"
disabled={!showPermissionQuickPanel}
className={cn(
'flex size-8 shrink-0 items-center justify-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg text-components-button-secondary-text shadow-xs transition-colors hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover focus-visible:ring-2 focus-visible:ring-components-input-border-hover focus-visible:outline-hidden',
!showPermissionQuickPanel && 'cursor-not-allowed opacity-60 hover:bg-components-button-secondary-bg hover:text-components-button-secondary-text',
)}
aria-label={t('privilege.permissions', { ns: 'plugin' })}
title={t('privilege.permissions', { ns: 'plugin' })}
>
<span aria-hidden className="i-ri-equalizer-2-line size-4" />
</button>
)}
/>
{showPermissionQuickPanel && (
<PopoverContent
placement="bottom-start"
sideOffset={4}
popupClassName="border-0 bg-transparent p-0 shadow-none"
>
<PermissionQuickPanel
permission={referenceSetting.permission}
onChange={handlePermissionChange}
/>
</PopoverContent>
)}
</Popover>
</div>
)}
<nav className="mt-6 shrink-0 space-y-0.5">
@ -254,7 +433,6 @@ export default function IntegrationsPage({
{!sidebarCollapsed && (
<>
<span className="min-w-0 flex-1 truncate">{t('menus.tools', { ns: 'common' })}</span>
<span aria-hidden className="i-ri-arrow-down-s-fill size-4 shrink-0" />
</>
)}
</Link>
@ -311,7 +489,35 @@ export default function IntegrationsPage({
)}
</aside>
<section className="flex min-w-0 flex-1 flex-col overflow-hidden">
{!isToolSection && (
{integrationHeader && (
<div className="flex min-h-14 shrink-0 items-start border-b border-divider-subtle px-6 pt-2 pb-2">
<div className="flex min-w-0 flex-1 items-end justify-between gap-3">
<div className="flex min-w-0 flex-col gap-0.5">
<div className="system-xl-semibold text-text-primary">
{integrationHeader.title}
</div>
<div className="system-sm-regular text-text-tertiary">
{integrationHeader.description}
</div>
</div>
{showHeaderPluginSetting && (
<Button
variant="secondary"
className="h-8 shrink-0 gap-0.5 px-3 system-sm-medium"
onClick={() => setShowPluginSettingModal(true)}
>
<span aria-hidden className="i-ri-flashlight-line size-4" />
<span className="px-0.5">{t('modelProvider.updateSetting', { ns: 'common' })}</span>
<span className="flex min-w-4 items-center justify-center rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1 py-0.5 system-2xs-medium-uppercase text-text-tertiary">
{t('autoUpdate.strategy.fixOnly.name', { ns: 'plugin' })}
</span>
<span aria-hidden className="i-ri-arrow-down-s-line size-4" />
</Button>
)}
</div>
</div>
)}
{!integrationHeader && !isToolSection && (
<div className="flex min-h-14 shrink-0 items-center justify-between border-b border-divider-subtle px-6 py-2">
<div>
<div className="system-xl-semibold text-text-primary">{activeItem?.label}</div>
@ -331,9 +537,17 @@ export default function IntegrationsPage({
<IntegrationSectionRenderer
section={section}
providerSearchText={providerSearchText}
onProviderSearchTextChange={setProviderSearchText}
/>
</div>
</section>
{showPluginSettingModal && referenceSetting && (
<ReferenceSettingModal
payload={referenceSetting}
onHide={() => setShowPluginSettingModal(false)}
onSave={setReferenceSettings}
/>
)}
</div>
)
}

View File

@ -174,6 +174,24 @@ describe('Marketplace', () => {
const marketplaceLink = screen.getByRole('link', { name: /plugin.marketplace.difyMarketplace/i })
expect(marketplaceLink).toHaveAttribute('href', 'https://marketplace.test/market')
})
it('should use compact content inset when requested by parent layout', () => {
const marketplaceContext = createMarketplaceContext()
const { container } = render(
<Marketplace
searchPluginText=""
filterPluginTags={[]}
isMarketplaceArrowVisible={false}
showMarketplacePanel={vi.fn()}
marketplaceContext={marketplaceContext}
contentInset="compact"
/>,
)
const sections = container.querySelectorAll('.bg-background-default-subtle')
expect(sections[0]).toHaveClass('px-6')
expect(sections[1]).toHaveClass('px-6')
})
})
})

View File

@ -1,5 +1,7 @@
import type { ToolsContentInset } from '../content-inset'
import type { useMarketplace } from './hooks'
import { useLocale } from '#i18n'
import { cn } from '@langgenius/dify-ui/cn'
import {
RiArrowRightUpLine,
RiArrowUpDoubleLine,
@ -9,6 +11,7 @@ import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import List from '@/app/components/plugins/marketplace/list'
import { getMarketplaceUrl } from '@/utils/var'
import { toolsContentInsetClassNames } from '../content-inset'
type MarketplaceProps = {
searchPluginText: string
@ -16,6 +19,7 @@ type MarketplaceProps = {
isMarketplaceArrowVisible: boolean
showMarketplacePanel: () => void
marketplaceContext: ReturnType<typeof useMarketplace>
contentInset?: ToolsContentInset
}
const Marketplace = ({
searchPluginText,
@ -23,6 +27,7 @@ const Marketplace = ({
isMarketplaceArrowVisible,
showMarketplacePanel,
marketplaceContext,
contentInset = 'default',
}: MarketplaceProps) => {
const locale = useLocale()
const { t } = useTranslation()
@ -34,10 +39,11 @@ const Marketplace = ({
plugins,
page,
} = marketplaceContext
const contentPaddingClassName = toolsContentInsetClassNames[contentInset]
return (
<>
<div className="sticky bottom-0 flex shrink-0 flex-col bg-background-default-subtle px-12 pt-2 pb-[14px]">
<div className={cn('sticky bottom-0 flex shrink-0 flex-col bg-background-default-subtle pt-2 pb-[14px]', contentPaddingClassName)}>
{isMarketplaceArrowVisible && (
<RiArrowUpDoubleLine
className="absolute top-2 left-1/2 z-10 h-4 w-4 -translate-x-1/2 cursor-pointer text-text-quaternary"
@ -89,7 +95,7 @@ const Marketplace = ({
</div>
</div>
</div>
<div className="mt-[-14px] shrink-0 grow bg-background-default-subtle px-12 pb-2">
<div className={cn('mt-[-14px] shrink-0 grow bg-background-default-subtle pb-2', contentPaddingClassName)}>
{
isLoading && page === 1 && (
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">

View File

@ -340,5 +340,13 @@ describe('MCPList', () => {
const grid = document.querySelector('.grid')
expect(grid).not.toHaveClass('overflow-hidden')
})
it('should use compact content inset when requested by parent layout', () => {
render(<MCPList searchText="" contentInset="compact" />)
const grid = document.querySelector('.grid')
expect(grid).toHaveClass('px-6')
expect(grid).not.toHaveClass('px-12')
})
})
})

View File

@ -1,16 +1,19 @@
'use client'
import type { ToolsContentInset } from '../content-inset'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
import { useMemo, useState } from 'react'
import {
useAllToolProviders,
} from '@/service/use-tools'
import { toolsContentInsetClassNames } from '../content-inset'
import NewMCPCard from './create-card'
import MCPDetailPanel from './detail/provider-detail'
import MCPCard from './provider-card'
type Props = {
searchText: string
contentInset?: ToolsContentInset
}
function renderDefaultCard() {
@ -34,6 +37,7 @@ function renderDefaultCard() {
const MCPList = ({
searchText,
contentInset = 'default',
}: Props) => {
const { data: list = [] as ToolWithProvider[], refetch } = useAllToolProviders()
const [isTriggerAuthorize, setIsTriggerAuthorize] = useState<boolean>(false)
@ -63,11 +67,13 @@ const MCPList = ({
setCurrentProviderID(providerID)
setIsTriggerAuthorize(true)
}
const contentPaddingClassName = toolsContentInsetClassNames[contentInset]
return (
<>
<div
className={cn(
'relative grid shrink-0 grid-cols-1 content-start gap-4 px-12 pt-2 pb-4 2k:grid-cols-6 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5',
'relative grid shrink-0 grid-cols-1 content-start gap-4 pt-2 pb-4 2k:grid-cols-6 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5',
contentPaddingClassName,
!list.length && 'h-[calc(100vh-136px)] overflow-hidden',
)}
>

View File

@ -21,7 +21,7 @@ const PluginCategoryPage = ({
return (
<PluginPageContextProvider key={category} initialFilters={initialFilters}>
<div className="flex h-0 grow flex-col overflow-hidden bg-background-body">
<PluginsPanel />
<PluginsPanel contentInset="compact" />
</div>
</PluginPageContextProvider>
)

View File

@ -1,5 +1,7 @@
'use client'
import type { ToolsContentInset } from './content-inset'
import type { Collection } from './types'
import type { Plugin } from '@/app/components/plugins/types'
import type { ToolCategory } from '@/app/components/tools/integration-routes'
import { cn } from '@langgenius/dify-ui/cn'
import { useSuspenseQuery } from '@tanstack/react-query'
@ -13,15 +15,15 @@ import CardMoreInfo from '@/app/components/plugins/card/card-more-info'
import { useTags } from '@/app/components/plugins/hooks'
import Empty from '@/app/components/plugins/marketplace/empty'
import PluginDetailPanel from '@/app/components/plugins/plugin-detail-panel'
import { buildIntegrationPath, sectionByToolCategory, TOOL_CATEGORY_VALUES } from '@/app/components/tools/integration-routes'
import { TOOL_CATEGORY_VALUES } from '@/app/components/tools/integration-routes'
import LabelFilter from '@/app/components/tools/labels/filter'
import CustomCreateCard from '@/app/components/tools/provider/custom-create-card'
import ProviderDetail from '@/app/components/tools/provider/detail'
import WorkflowToolEmpty from '@/app/components/tools/provider/empty'
import { useRouter } from '@/next/navigation'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { useCheckInstalled, useInvalidateInstalledPluginList } from '@/service/use-plugins'
import { useAllToolProviders } from '@/service/use-tools'
import { toolsContentInsetClassNames } from './content-inset'
import Marketplace from './marketplace'
import { useMarketplace } from './marketplace/hooks'
import MCPList from './mcp'
@ -38,15 +40,16 @@ const parseAsToolProviderCategory = parseAsStringLiteral(TOOL_CATEGORY_VALUES)
type ProviderListProps = {
category?: ToolCategory
contentInset?: ToolsContentInset
}
const ProviderList = ({
category,
contentInset = 'default',
}: ProviderListProps) => {
// const searchParams = useSearchParams()
// searchParams.get('category') === 'workflow'
const { t } = useTranslation()
const router = useRouter()
const { getTagLabel } = useTags()
const { data: enable_marketplace } = useSuspenseQuery({
...systemFeaturesQueryOptions(),
@ -56,6 +59,8 @@ const ProviderList = ({
const [categoryParam, setCategoryParam] = useQueryState('category', parseAsToolProviderCategory)
const activeTab = category ?? categoryParam
const isRouteCategory = !!category
const contentPaddingClassName = toolsContentInsetClassNames[contentInset]
const options = [
{ value: 'builtin', text: t('type.builtIn', { ns: 'tools' }) },
{ value: 'api', text: t('type.custom', { ns: 'tools' }) },
@ -138,26 +143,27 @@ const ProviderList = ({
ref={containerRef}
className="relative flex grow flex-col overflow-y-auto bg-background-body"
>
<div className={cn(
'sticky top-0 z-10 flex flex-wrap items-center justify-between gap-y-2 bg-background-body px-12 pt-4 pb-2 leading-[56px]',
currentProviderId && 'pr-6',
)}
<div
className={cn(
'sticky top-0 z-10 flex flex-wrap items-center justify-start gap-x-4 gap-y-2 bg-background-body pt-4 pb-2',
contentPaddingClassName,
currentProviderId && 'pr-6',
)}
>
<TabSliderNew
value={activeTab}
onChange={(state) => {
if (!isToolProviderCategory(state))
return
if (category)
router.push(buildIntegrationPath(sectionByToolCategory[state]))
else
{!isRouteCategory && (
<TabSliderNew
value={activeTab}
onChange={(state) => {
if (!isToolProviderCategory(state))
return
setCategoryParam(state)
if (state !== activeTab)
setCurrentProviderId(undefined)
}}
options={options}
/>
if (state !== activeTab)
setCurrentProviderId(undefined)
}}
options={options}
/>
)}
<div className="flex items-center gap-2">
{activeTab !== 'mcp' && (
<LabelFilter value={tagFilterValue} onChange={handleTagsChange} />
@ -173,10 +179,12 @@ const ProviderList = ({
</div>
</div>
{activeTab !== 'mcp' && (
<div className={cn(
'relative grid shrink-0 grid-cols-1 content-start gap-4 px-12 pt-2 pb-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4',
!filteredCollectionList.length && activeTab === 'workflow' && 'grow',
)}
<div
className={cn(
'relative grid shrink-0 grid-cols-1 content-start gap-4 pt-2 pb-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4',
contentPaddingClassName,
!filteredCollectionList.length && activeTab === 'workflow' && 'grow',
)}
>
{activeTab === 'api' && <CustomCreateCard onRefreshData={refetch} />}
{filteredCollectionList.map(collection => (
@ -195,7 +203,7 @@ const ProviderList = ({
brief: collection.description,
org: collection.plugin_id ? collection.plugin_id.split('/')[0] : '',
name: collection.plugin_id ? collection.plugin_id.split('/')[1] : collection.name,
} as any}
} as unknown as Plugin}
footer={(
<CardMoreInfo
tags={collection.labels?.map(label => getTagLabel(label)) || []}
@ -208,7 +216,7 @@ const ProviderList = ({
</div>
)}
{!filteredCollectionList.length && activeTab === 'builtin' && (
<Empty lightCard text={t('noTools', { ns: 'tools' })} className="h-[224px] shrink-0 px-12" />
<Empty lightCard text={t('noTools', { ns: 'tools' })} className={cn('h-[224px] shrink-0', contentPaddingClassName)} />
)}
<div ref={toolListTailRef} />
{enable_marketplace && activeTab === 'builtin' && (
@ -218,10 +226,11 @@ const ProviderList = ({
isMarketplaceArrowVisible={isMarketplaceArrowVisible}
showMarketplacePanel={showMarketplacePanel}
marketplaceContext={marketplaceContext}
contentInset={contentInset}
/>
)}
{activeTab === 'mcp' && (
<MCPList searchText={keywords} />
<MCPList searchText={keywords} contentInset={contentInset} />
)}
</div>
</div>