mirror of
https://github.com/langgenius/dify.git
synced 2026-05-13 08:57:28 +08:00
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:
parent
150396d7f7
commit
a917a49f66
@ -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()
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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([
|
||||
|
||||
6
web/app/components/plugins/plugin-page/content-inset.ts
Normal file
6
web/app/components/plugins/plugin-page/content-inset.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export type PluginPageContentInset = 'default' | 'compact'
|
||||
|
||||
export const pluginPageContentInsetClassNames: Record<PluginPageContentInset, string> = {
|
||||
default: 'px-12',
|
||||
compact: 'px-6',
|
||||
}
|
||||
@ -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" />
|
||||
))}
|
||||
|
||||
@ -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' })}
|
||||
|
||||
@ -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} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -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' })
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
6
web/app/components/tools/content-inset.ts
Normal file
6
web/app/components/tools/content-inset.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export type ToolsContentInset = 'default' | 'compact'
|
||||
|
||||
export const toolsContentInsetClassNames: Record<ToolsContentInset, string> = {
|
||||
default: 'px-12',
|
||||
compact: 'px-6',
|
||||
}
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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',
|
||||
)}
|
||||
>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user