diff --git a/web/app/components/header/account-setting/model-provider-page/__tests__/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/__tests__/index.spec.tsx index 6bf818a544..0f2eaf9c60 100644 --- a/web/app/components/header/account-setting/model-provider-page/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/__tests__/index.spec.tsx @@ -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: () =>
, })) +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 }) => ( +
+ +
+ ), +})) + vi.mock('@/service/client', async (importOriginal) => { const actual = await importOriginal() const originalPlugins = actual.consoleQuery.plugins as unknown as Record @@ -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() diff --git a/web/app/components/header/account-setting/model-provider-page/index.tsx b/web/app/components/header/account-setting/model-provider-page/index.tsx index 3b8874feab..94d2667f3b 100644 --- a/web/app/components/header/account-setting/model-provider-page/index.tsx +++ b/web/app/components/header/account-setting/model-provider-page/index.tsx @@ -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 (
-
-
{t('modelProvider.models', { ns: 'common' })}
-
- {showWarning &&
} - {showWarning && ( -
- - {t(warningTextKey, { ns: 'common' })} -
+
+ +
+ {canSetPermissions && referenceSetting && ( + )} { />
+ {showWarning && !warningDismissed && ( +
+
+ + + {t(warningTextKey, { ns: 'common' })} + + +
+
+ )} {IS_CLOUD_EDITION && } {!filteredConfiguredProviders?.length && (
@@ -201,6 +242,13 @@ const ModelProviderPage = ({ searchText }: Props) => { /> ) } + {showPluginSettingModal && referenceSetting && ( + setShowPluginSettingModal(false)} + onSave={setReferenceSettings} + /> + )}
) } diff --git a/web/app/components/plugins/plugin-page/__tests__/install-plugin-dropdown.spec.tsx b/web/app/components/plugins/plugin-page/__tests__/install-plugin-dropdown.spec.tsx index 9f883af1b2..434de5117d 100644 --- a/web/app/components/plugins/plugin-page/__tests__/install-plugin-dropdown.spec.tsx +++ b/web/app/components/plugins/plugin-page/__tests__/install-plugin-dropdown.spec.tsx @@ -39,9 +39,13 @@ vi.mock('@/app/components/base/icons/src/vender/solid/mediaAndDevices', () => ({ MagicBox: () => magic, })) +type MockButtonProps = React.ButtonHTMLAttributes & { + variant?: string +} + vi.mock('@langgenius/dify-ui/button', () => ({ - Button: ({ children, onClick, className, ...props }: React.ButtonHTMLAttributes) => ( - + Button: ({ children, onClick, className, variant, ...props }: MockButtonProps) => ( + ), })) @@ -95,9 +99,11 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', async () => { }, DropdownMenuContent: ({ children, + popupClassName, }: { children: React.ReactNode - }) => portalOpen ?
{children}
: null, + popupClassName?: string + }) => portalOpen ?
{children}
: 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( + , + ) + + 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 diff --git a/web/app/components/plugins/plugin-page/__tests__/plugins-panel.spec.tsx b/web/app/components/plugins/plugin-page/__tests__/plugins-panel.spec.tsx index bad857077a..a86af2942f 100644 --- a/web/app/components/plugins/plugin-page/__tests__/plugins-panel.spec.tsx +++ b/web/app/components/plugins/plugin-page/__tests__/plugins-panel.spec.tsx @@ -66,7 +66,7 @@ vi.mock('../filter-management', () => ({ })) vi.mock('../empty', () => ({ - default: () =>
empty
, + default: ({ contentInset }: { contentInset?: string }) =>
empty
, })) 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() + + 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() + + 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([ diff --git a/web/app/components/plugins/plugin-page/content-inset.ts b/web/app/components/plugins/plugin-page/content-inset.ts new file mode 100644 index 0000000000..beaceac729 --- /dev/null +++ b/web/app/components/plugins/plugin-page/content-inset.ts @@ -0,0 +1,6 @@ +export type PluginPageContentInset = 'default' | 'compact' + +export const pluginPageContentInsetClassNames: Record = { + default: 'px-12', + compact: 'px-6', +} diff --git a/web/app/components/plugins/plugin-page/empty/index.tsx b/web/app/components/plugins/plugin-page/empty/index.tsx index 7b9fab6f3e..7deae2265a 100644 --- a/web/app/components/plugins/plugin-page/empty/index.tsx +++ b/web/app/components/plugins/plugin-page/empty/index.tsx @@ -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(null) const [selectedAction, setSelectedAction] = useState(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([]) - useEffect(() => { - const methods = [] + const installMethods = useMemo(() => { + 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 (
{/* skeleton */} -
+
{Array.from({ length: 20 }).fill(0).map((_, i) => (
))} diff --git a/web/app/components/plugins/plugin-page/install-plugin-dropdown.tsx b/web/app/components/plugins/plugin-page/install-plugin-dropdown.tsx index a972c9891c..2f863e0f17 100644 --- a/web/app/components/plugins/plugin-page/install-plugin-dropdown.tsx +++ b/web/app/components/plugins/plugin-page/install-plugin-dropdown.tsx @@ -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(null) @@ -112,7 +125,7 @@ const InstallPluginDropdown = ({ return ( -
+
)} > <> - {t('installPlugin', { ns: 'plugin' })} + {triggerLabel ?? t('installPlugin', { ns: 'plugin' })} {t('installFrom', { ns: 'plugin' })} diff --git a/web/app/components/plugins/plugin-page/plugins-panel.tsx b/web/app/components/plugins/plugin-page/plugins-panel.tsx index a824d55dda..4a09301c9c 100644 --- a/web/app/components/plugins/plugin-page/plugins-panel.tsx +++ b/web/app/components/plugins/plugin-page/plugins-panel.tsx @@ -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 ( <> -
+
{ <> {(filteredList?.length ?? 0) > 0 ? ( -
+
@@ -106,7 +116,7 @@ const PluginsPanel = () => {
) : ( - + )} )} diff --git a/web/app/components/tools/__tests__/integrations-page.spec.tsx b/web/app/components/tools/__tests__/integrations-page.spec.tsx index 9dc647786f..06967682b4 100644 --- a/web/app/components/tools/__tests__/integrations-page.spec.tsx +++ b/web/app/components/tools/__tests__/integrations-page.spec.tsx @@ -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 }) => ( +
+ +
+ ), +})) + +vi.mock('@/app/components/plugins/plugin-page/install-plugin-dropdown', () => ({ + __esModule: true, + default: ({ onSwitchToMarketplaceTab }: { onSwitchToMarketplaceTab: () => void }) => ( + + ), +})) + vi.mock('@/app/components/header/account-setting/model-provider-page', () => ({ __esModule: true, default: ({ @@ -48,6 +100,14 @@ const renderIntegrationsPage = (searchParams?: Record, 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' }) diff --git a/web/app/components/tools/__tests__/provider-list.spec.tsx b/web/app/components/tools/__tests__/provider-list.spec.tsx index c093af70b7..8a205d4428 100644 --- a/web/app/components/tools/__tests__/provider-list.spec.tsx +++ b/web/app/components/tools/__tests__/provider-list.spec.tsx @@ -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 }) => ( -
+
@@ -193,8 +184,8 @@ vi.mock('../marketplace/hooks', () => ({ })) vi.mock('../mcp', () => ({ - default: ({ searchText }: { searchText: string }) => ( -
+ default: ({ searchText, contentInset }: { searchText: string, contentInset?: string }) => ( +
MCP List: {searchText}
@@ -213,7 +204,11 @@ describe('getToolType', () => { }) }) -const renderProviderList = (searchParams?: Record, category?: ComponentProps['category']) => { +const renderProviderList = ( + searchParams?: Record, + category?: ComponentProps['category'], + contentInset?: ComponentProps['contentInset'], +) => { const { wrapper: SystemFeaturesWrapper } = createSystemFeaturesWrapper({ systemFeatures: { enable_marketplace: mockEnableMarketplace }, }) @@ -221,7 +216,7 @@ const renderProviderList = (searchParams?: Record, category?: Co {children} ) return renderWithNuqs( - , + , { 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() diff --git a/web/app/components/tools/content-inset.ts b/web/app/components/tools/content-inset.ts new file mode 100644 index 0000000000..f713da0b3f --- /dev/null +++ b/web/app/components/tools/content-inset.ts @@ -0,0 +1,6 @@ +export type ToolsContentInset = 'default' | 'compact' + +export const toolsContentInsetClassNames: Record = { + default: 'px-12', + compact: 'px-6', +} diff --git a/web/app/components/tools/integration-section-renderer.tsx b/web/app/components/tools/integration-section-renderer.tsx index e79f5688a8..45b17feada 100644 --- a/web/app/components/tools/integration-section-renderer.tsx +++ b/web/app/components/tools/integration-section-renderer.tsx @@ -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 (
- +
) case 'builtin': - return + return case 'mcp': - return + return case 'custom-tool': - return + return case 'workflow-tool': - return + return case 'data-source': return (
diff --git a/web/app/components/tools/integrations-page.tsx b/web/app/components/tools/integrations-page.tsx index d5f1582b7c..995fa7cdd8 100644 --- a/web/app/components/tools/integrations-page.tsx +++ b/web/app/components/tools/integrations-page.tsx @@ -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 ( +
+
+
+
+ {t('privilege.permissions', { ns: 'plugin' })} +
+ {rows.map(row => ( +
+
+ {row.label} +
+
+ {permissionSettingOptions.map((option) => { + const selected = row.value === option + const optionLabel = t(`privilege.${option}`, { ns: 'plugin' }) + + return ( + + ) + })} +
+
+ ))} +
+
+
+ ) +} + 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(() => ({ 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 (
@@ -214,25 +373,45 @@ export default function IntegrationsPage({
{!sidebarCollapsed && (
- - + router.push('/plugins?tab=discover')} + /> + + + + + )} + /> + {showPermissionQuickPanel && ( + + + + )} +
)}
) } diff --git a/web/app/components/tools/marketplace/__tests__/index.spec.tsx b/web/app/components/tools/marketplace/__tests__/index.spec.tsx index 43c303b075..ad2c36510d 100644 --- a/web/app/components/tools/marketplace/__tests__/index.spec.tsx +++ b/web/app/components/tools/marketplace/__tests__/index.spec.tsx @@ -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( + , + ) + + const sections = container.querySelectorAll('.bg-background-default-subtle') + expect(sections[0]).toHaveClass('px-6') + expect(sections[1]).toHaveClass('px-6') + }) }) }) diff --git a/web/app/components/tools/marketplace/index.tsx b/web/app/components/tools/marketplace/index.tsx index e7d6a22d42..1c4e994e4e 100644 --- a/web/app/components/tools/marketplace/index.tsx +++ b/web/app/components/tools/marketplace/index.tsx @@ -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 + 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 ( <> -
+
{isMarketplaceArrowVisible && (
-
+
{ isLoading && page === 1 && (
diff --git a/web/app/components/tools/mcp/__tests__/index.spec.tsx b/web/app/components/tools/mcp/__tests__/index.spec.tsx index 58510dab4c..d04e26b116 100644 --- a/web/app/components/tools/mcp/__tests__/index.spec.tsx +++ b/web/app/components/tools/mcp/__tests__/index.spec.tsx @@ -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() + + const grid = document.querySelector('.grid') + expect(grid).toHaveClass('px-6') + expect(grid).not.toHaveClass('px-12') + }) }) }) diff --git a/web/app/components/tools/mcp/index.tsx b/web/app/components/tools/mcp/index.tsx index f4b4b44c88..e2b1953c7c 100644 --- a/web/app/components/tools/mcp/index.tsx +++ b/web/app/components/tools/mcp/index.tsx @@ -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(false) @@ -63,11 +67,13 @@ const MCPList = ({ setCurrentProviderID(providerID) setIsTriggerAuthorize(true) } + const contentPaddingClassName = toolsContentInsetClassNames[contentInset] return ( <>
diff --git a/web/app/components/tools/plugin-category-page.tsx b/web/app/components/tools/plugin-category-page.tsx index 2323ce8ad0..c7a9b39397 100644 --- a/web/app/components/tools/plugin-category-page.tsx +++ b/web/app/components/tools/plugin-category-page.tsx @@ -21,7 +21,7 @@ const PluginCategoryPage = ({ return (
- +
) diff --git a/web/app/components/tools/provider-list.tsx b/web/app/components/tools/provider-list.tsx index e4210f94b2..0cb37dae08 100644 --- a/web/app/components/tools/provider-list.tsx +++ b/web/app/components/tools/provider-list.tsx @@ -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" > -
- { - if (!isToolProviderCategory(state)) - return - if (category) - router.push(buildIntegrationPath(sectionByToolCategory[state])) - else + {!isRouteCategory && ( + { + if (!isToolProviderCategory(state)) + return setCategoryParam(state) - if (state !== activeTab) - setCurrentProviderId(undefined) - }} - options={options} - /> + if (state !== activeTab) + setCurrentProviderId(undefined) + }} + options={options} + /> + )}
{activeTab !== 'mcp' && ( @@ -173,10 +179,12 @@ const ProviderList = ({
{activeTab !== 'mcp' && ( -
{activeTab === 'api' && } {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={( getTagLabel(label)) || []} @@ -208,7 +216,7 @@ const ProviderList = ({
)} {!filteredCollectionList.length && activeTab === 'builtin' && ( - + )}
{enable_marketplace && activeTab === 'builtin' && ( @@ -218,10 +226,11 @@ const ProviderList = ({ isMarketplaceArrowVisible={isMarketplaceArrowVisible} showMarketplacePanel={showMarketplacePanel} marketplaceContext={marketplaceContext} + contentInset={contentInset} /> )} {activeTab === 'mcp' && ( - + )}