diff --git a/web/app/components/tools/__tests__/provider-list.spec.tsx b/web/app/components/tools/__tests__/provider-list.spec.tsx index 30f60dc637..991e63f317 100644 --- a/web/app/components/tools/__tests__/provider-list.spec.tsx +++ b/web/app/components/tools/__tests__/provider-list.spec.tsx @@ -104,6 +104,39 @@ vi.mock('@/service/use-plugins', () => ({ useInvalidateInstalledPluginList: () => mockInvalidateInstalledPluginList, })) +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('@/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/card', () => ({ default: ({ payload, className }: { payload: { name: string }, className?: string }) => (
{payload.name}
@@ -227,6 +260,14 @@ describe('ProviderList', () => { mockEnableMarketplace = false mockCollectionData = createDefaultCollections() mockCheckedInstalledData = null + mockCanSetPermissions.mockReturnValue(true) + mockReferenceSetting.mockReturnValue({ + permission: { + install_permission: 'everyone', + debug_permission: 'admins', + }, + auto_upgrade: {}, + }) Element.prototype.scrollTo = vi.fn() }) @@ -280,20 +321,27 @@ describe('ProviderList', () => { it('uses default content inset outside compact integrations layout', () => { const { container } = renderProviderList() - expect(container.querySelector('.sticky')).toHaveClass('px-12') - expect(container.querySelector('.sticky')).not.toHaveClass('max-w-[1600px]') - expect(screen.getByTestId('card-google-search').closest('.grid')).toHaveClass('px-12') - expect(screen.getByTestId('card-google-search').closest('.grid')).not.toHaveClass('max-w-[1600px]') + expect(container.querySelector('.sticky')).toHaveClass('px-12', 'pt-2', 'pb-0') + expect(container.querySelector('.sticky')).toHaveClass('max-w-[1600px]') + expect(screen.getByTestId('card-google-search').closest('.grid')).toHaveClass('px-12', 'gap-2', 'pt-2') + expect(screen.getByTestId('card-google-search').closest('.grid')).toHaveClass('max-w-[1600px]') }) it('uses compact content inset when rendered by integrations layout', () => { const { container } = renderProviderList(undefined, 'builtin', 'compact') - expect(container.querySelector('.sticky')).toHaveClass('px-6') + expect(container.querySelector('.sticky')).toHaveClass('px-6', 'pt-2', 'pb-0') expect(container.querySelector('.sticky')).toHaveClass('max-w-[1600px]') - expect(screen.getByTestId('card-google-search').closest('.grid')).toHaveClass('px-6') + expect(screen.getByTestId('card-google-search').closest('.grid')).toHaveClass('px-6', 'gap-2', 'pt-2') expect(screen.getByTestId('card-google-search').closest('.grid')).toHaveClass('max-w-[1600px]') }) + + it('keeps tool cards at three columns on desktop and wider screens', () => { + renderProviderList(undefined, 'builtin', 'compact') + + expect(screen.getByTestId('card-google-search').closest('.grid')).toHaveClass('grid-cols-1', 'sm:grid-cols-2', 'md:grid-cols-3') + expect(screen.getByTestId('card-google-search').closest('.grid')).not.toHaveClass('lg:grid-cols-4') + }) }) describe('Filtering', () => { @@ -344,6 +392,16 @@ describe('ProviderList', () => { expect(screen.getByTestId('card-weather-tool')).toBeInTheDocument() }) + it('does not apply hidden tag filters on non-tools tabs', () => { + renderProviderList() + + fireEvent.click(screen.getByTestId('add-filter')) + fireEvent.click(screen.getByText('tools.type.custom')) + + expect(screen.queryByTestId('label-filter')).not.toBeInTheDocument() + expect(screen.getByTestId('card-my-api')).toBeInTheDocument() + }) + it('clears search with clear button', () => { renderProviderList() const input = screen.getByRole('textbox') @@ -353,13 +411,17 @@ describe('ProviderList', () => { expect(screen.getByTestId('card-weather-tool')).toBeInTheDocument() }) - it('shows label filter for non-MCP tabs', () => { + it('shows label filter for the built-in tools page', () => { renderProviderList() expect(screen.getByTestId('label-filter')).toBeInTheDocument() }) - it('hides label filter for MCP tab', () => { - renderProviderList({ category: 'mcp' }) + it.each([ + ['api'], + ['workflow'], + ['mcp'], + ] as const)('hides label filter for the %s tool page', (category) => { + renderProviderList({ category }) expect(screen.queryByTestId('label-filter')).not.toBeInTheDocument() }) @@ -367,6 +429,28 @@ describe('ProviderList', () => { renderProviderList() expect(screen.getByRole('textbox')).toBeInTheDocument() }) + + it('opens plugin update settings from the tools toolbar', () => { + renderProviderList(undefined, 'builtin') + + expect(screen.getByText('common.modelProvider.updateSetting')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.strategy.latest.name')).toBeInTheDocument() + + fireEvent.click(screen.getByText('common.modelProvider.updateSetting')) + + expect(screen.getByTestId('reference-setting-modal')).toBeInTheDocument() + }) + + it.each([ + ['mcp'], + ['api'], + ['workflow'], + ] as const)('does not show plugin update settings on the %s tool page', (category) => { + renderProviderList({ category }) + + expect(screen.queryByText('common.modelProvider.updateSetting')).not.toBeInTheDocument() + expect(screen.queryByText('plugin.autoUpdate.strategy.latest.name')).not.toBeInTheDocument() + }) }) describe('Custom Tab', () => { diff --git a/web/app/components/tools/marketplace/__tests__/index.spec.tsx b/web/app/components/tools/marketplace/__tests__/index.spec.tsx index 822359eece..673bd55879 100644 --- a/web/app/components/tools/marketplace/__tests__/index.spec.tsx +++ b/web/app/components/tools/marketplace/__tests__/index.spec.tsx @@ -15,6 +15,7 @@ vi.mock('@/app/components/plugins/marketplace/list', () => ({ default: (props: { marketplaceCollections: unknown[] marketplaceCollectionPluginsMap: Record + cardContainerClassName?: string plugins?: unknown[] showInstallButton?: boolean }) => { @@ -136,6 +137,7 @@ describe('Marketplace', () => { // Assert expect(screen.getByTestId('marketplace-list')).toBeInTheDocument() expect(listRenderSpy).toHaveBeenCalledWith(expect.objectContaining({ + cardContainerClassName: 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3', showInstallButton: true, })) }) diff --git a/web/app/components/tools/marketplace/index.tsx b/web/app/components/tools/marketplace/index.tsx index e2606771d2..a1dbbb37fe 100644 --- a/web/app/components/tools/marketplace/index.tsx +++ b/web/app/components/tools/marketplace/index.tsx @@ -11,7 +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 { toolsContentFrameClassNames, toolsContentInsetClassNames } from '../content-inset' +import { toolsContentInsetClassNames, toolsUnifiedContentFrameClassName } from '../content-inset' type MarketplaceProps = { searchPluginText: string @@ -40,7 +40,8 @@ const Marketplace = ({ page, } = marketplaceContext const contentPaddingClassName = toolsContentInsetClassNames[contentInset] - const contentFrameClassName = cn(toolsContentFrameClassNames[contentInset], contentPaddingClassName) + const marketplaceFrameClassName = cn(contentPaddingClassName, toolsUnifiedContentFrameClassName) + const cardContainerClassName = 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3' return ( <> @@ -51,7 +52,7 @@ const Marketplace = ({ onClick={showMarketplacePanel} /> )} -
+
{t('marketplace.moreFrom', { ns: 'plugin' })}
@@ -106,12 +107,13 @@ const Marketplace = ({ } { (!isLoading || page > 1) && ( -
+
) diff --git a/web/app/components/tools/mcp/__tests__/create-card.spec.tsx b/web/app/components/tools/mcp/__tests__/create-card.spec.tsx index 6e5b4038f4..d343be23be 100644 --- a/web/app/components/tools/mcp/__tests__/create-card.spec.tsx +++ b/web/app/components/tools/mcp/__tests__/create-card.spec.tsx @@ -149,11 +149,25 @@ describe('NewMCPCard', () => { }) describe('Styling', () => { - it('should have correct card structure', () => { + it('should match the Figma card shell and section sizing', () => { render(, { wrapper: createWrapper() }) - const card = document.querySelector('.rounded-xl') - expect(card).toBeInTheDocument() + const card = screen.getByText('tools.mcp.create.cardTitle').closest('.col-span-1') + expect(card).toHaveClass( + 'h-[120px]', + 'overflow-hidden', + 'rounded-xl', + 'border-[0.5px]', + 'border-components-panel-border', + 'bg-components-panel-on-panel-item-bg', + 'shadow-md', + ) + + const header = screen.getByRole('button', { name: 'tools.mcp.create.cardTitle' }) + expect(header).toHaveClass('h-[84px]', 'gap-3', 'p-4') + + const docLink = screen.getByText('tools.mcp.create.cardLink').closest('a') + expect(docLink).toHaveClass('h-8', 'border-t', 'border-divider-subtle', 'px-3', 'py-2') }) it('should have clickable cursor style', () => { diff --git a/web/app/components/tools/mcp/__tests__/index.spec.tsx b/web/app/components/tools/mcp/__tests__/index.spec.tsx index 4a07d3f82a..f5b26af2c2 100644 --- a/web/app/components/tools/mcp/__tests__/index.spec.tsx +++ b/web/app/components/tools/mcp/__tests__/index.spec.tsx @@ -167,6 +167,7 @@ describe('MCPList', () => { mockProviders = [ { id: '1', name: { 'en-US': 'Search Tool' }, type: 'mcp' }, { id: '2', name: { 'en-US': 'Another Provider' }, type: 'mcp' }, + { id: '3', name: { 'en-US': 'Search API Tool' }, type: 'api' }, ] }) @@ -175,6 +176,7 @@ describe('MCPList', () => { expect(screen.getByTestId('provider-card-1')).toBeInTheDocument() expect(screen.queryByTestId('provider-card-2')).not.toBeInTheDocument() + expect(screen.queryByTestId('provider-card-3')).not.toBeInTheDocument() }) it('should filter case-insensitively', () => { @@ -316,13 +318,14 @@ describe('MCPList', () => { }) describe('Grid Layout', () => { - it('should have responsive grid layout', () => { + it('should keep MCP cards to three columns at desktop width and above', () => { render() const grid = document.querySelector('.grid') - expect(grid).toHaveClass('grid-cols-1') - expect(grid).toHaveClass('md:grid-cols-2') - expect(grid).toHaveClass('xl:grid-cols-4') + expect(grid).toHaveClass('grid-cols-1', 'sm:grid-cols-2', 'md:grid-cols-3') + expect(grid).not.toHaveClass('xl:grid-cols-4') + expect(grid).not.toHaveClass('2xl:grid-cols-5') + expect(grid).not.toHaveClass('2k:grid-cols-6') }) it('should have overflow hidden when list is empty', () => { diff --git a/web/app/components/tools/mcp/__tests__/provider-card.spec.tsx b/web/app/components/tools/mcp/__tests__/provider-card.spec.tsx index fc4c07275a..8b66bb1c6e 100644 --- a/web/app/components/tools/mcp/__tests__/provider-card.spec.tsx +++ b/web/app/components/tools/mcp/__tests__/provider-card.spec.tsx @@ -183,6 +183,20 @@ describe('MCPCard', () => { render(, { wrapper: createWrapper() }) expect(screen.getByText(/tools.mcp.updateTime/)).toBeInTheDocument() }) + + it('should use the Figma card shell', () => { + render(, { wrapper: createWrapper() }) + + const card = screen.getByText('Test MCP Server').closest('.group') + expect(card).toHaveClass( + 'overflow-hidden', + 'rounded-xl', + 'border-[0.5px]', + 'border-components-panel-border', + 'bg-components-panel-on-panel-item-bg', + 'shadow-md', + ) + }) }) describe('No Tools State', () => { diff --git a/web/app/components/tools/mcp/create-card.tsx b/web/app/components/tools/mcp/create-card.tsx index d96365b2db..b2fb040da8 100644 --- a/web/app/components/tools/mcp/create-card.tsx +++ b/web/app/components/tools/mcp/create-card.tsx @@ -1,9 +1,8 @@ 'use client' import type { ToolWithProvider } from '@/app/components/workflow/types' import { - RiAddCircleFill, + RiAddLine, RiArrowRightUpLine, - RiBookOpenLine, } from '@remixicon/react' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -35,22 +34,34 @@ const NewMCPCard = ({ handleCreate }: Props) => { return ( <> {isCurrentWorkspaceManager && ( -
-
setShowModal(true)}> -
-
- +
+
- +
+
+ {t('mcp.create.cardTitle', { ns: 'tools' })} +
+
+ + +
+
{t('mcp.create.cardLink', { ns: 'tools' })}
+
+ +
)} {showModal && ( diff --git a/web/app/components/tools/mcp/detail/__tests__/provider-detail.spec.tsx b/web/app/components/tools/mcp/detail/__tests__/provider-detail.spec.tsx index 4d69d89516..c32398070b 100644 --- a/web/app/components/tools/mcp/detail/__tests__/provider-detail.spec.tsx +++ b/web/app/components/tools/mcp/detail/__tests__/provider-detail.spec.tsx @@ -62,7 +62,16 @@ describe('MCPDetailPanel', () => { , { wrapper: createWrapper() }, ) - expect(screen.getByRole('dialog')).toBeInTheDocument() + const dialog = screen.getByRole('dialog') + + expect(dialog).toBeInTheDocument() + expect(dialog).toHaveClass( + 'data-[swipe-direction=right]:top-2', + 'data-[swipe-direction=right]:bottom-2', + 'data-[swipe-direction=right]:h-[calc(100dvh-16px)]', + 'data-[swipe-direction=right]:w-[400px]', + 'data-[swipe-direction=right]:max-w-[calc(100vw-1rem)]', + ) }) it('should render content when detail is provided', () => { diff --git a/web/app/components/tools/mcp/detail/provider-detail.tsx b/web/app/components/tools/mcp/detail/provider-detail.tsx index e704e6d262..54906246f4 100644 --- a/web/app/components/tools/mcp/detail/provider-detail.tsx +++ b/web/app/components/tools/mcp/detail/provider-detail.tsx @@ -50,7 +50,7 @@ const MCPDetailPanel: FC = ({ - + {detail && ( { return list.filter((collection) => { + if (collection.type !== 'mcp') + return false if (searchText) return Object.values(collection.name).some(value => (value as string).toLowerCase().includes(searchText.toLowerCase())) - return collection.type === 'mcp' + return true }) as ToolWithProvider[] }, [list, searchText]) @@ -68,12 +70,12 @@ const MCPList = ({ setIsTriggerAuthorize(true) } const contentPaddingClassName = toolsContentInsetClassNames[contentInset] - const contentFrameClassName = cn(toolsContentFrameClassNames[contentInset], contentPaddingClassName) + const contentFrameClassName = cn(contentPaddingClassName, toolsUnifiedContentFrameClassName) return ( <>
handleSelect(data.id)} className={cn( - 'group relative flex cursor-pointer flex-col rounded-xl border-[1.5px] border-transparent bg-components-card-bg shadow-xs hover:bg-components-card-bg-alt hover:shadow-md', - currentProvider?.id === data.id && 'border-components-option-card-option-selected-border bg-components-card-bg-alt', + 'group relative flex cursor-pointer flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg shadow-md hover:bg-components-panel-on-panel-item-bg-hover', + currentProvider?.id === data.id && 'border-components-option-card-option-selected-border bg-components-panel-on-panel-item-bg-hover', )} >
diff --git a/web/app/components/tools/provider-list.tsx b/web/app/components/tools/provider-list.tsx index 1dac25bc64..6c2551c2bc 100644 --- a/web/app/components/tools/provider-list.tsx +++ b/web/app/components/tools/provider-list.tsx @@ -3,6 +3,7 @@ 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 { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { useSuspenseQuery } from '@tanstack/react-query' import { parseAsStringLiteral, useQueryState } from 'nuqs' @@ -15,6 +16,8 @@ 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 useReferenceSetting from '@/app/components/plugins/plugin-page/use-reference-setting' +import ReferenceSettingModal from '@/app/components/plugins/reference-setting-modal' 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' @@ -23,7 +26,7 @@ import WorkflowToolEmpty from '@/app/components/tools/provider/empty' import { systemFeaturesQueryOptions } from '@/service/system-features' import { useCheckInstalled, useInvalidateInstalledPluginList } from '@/service/use-plugins' import { useAllToolProviders } from '@/service/use-tools' -import { toolsContentFrameClassNames, toolsContentInsetClassNames } from './content-inset' +import { toolsContentInsetClassNames, toolsUnifiedContentFrameClassName } from './content-inset' import Marketplace from './marketplace' import { useMarketplace } from './marketplace/hooks' import MCPList from './mcp' @@ -51,6 +54,11 @@ const ProviderList = ({ // searchParams.get('category') === 'workflow' const { t } = useTranslation() const { getTagLabel } = useTags() + const { + referenceSetting, + canSetPermissions, + setReferenceSettings, + } = useReferenceSetting() const { data: enable_marketplace } = useSuspenseQuery({ ...systemFeaturesQueryOptions(), select: s => s.enable_marketplace, @@ -61,7 +69,9 @@ const ProviderList = ({ const activeTab = category ?? categoryParam const isRouteCategory = !!category const contentPaddingClassName = toolsContentInsetClassNames[contentInset] - const contentFrameClassName = cn(toolsContentFrameClassNames[contentInset], contentPaddingClassName) + const toolListFrameClassName = cn(contentPaddingClassName, toolsUnifiedContentFrameClassName) + const showToolsUpdateSetting = activeTab === 'builtin' && canSetPermissions && !!referenceSetting + const showLabelFilter = activeTab === 'builtin' const options = [ { value: 'builtin', text: t('type.builtIn', { ns: 'tools' }) }, { value: 'api', text: t('type.custom', { ns: 'tools' }) }, @@ -69,6 +79,7 @@ const ProviderList = ({ { value: 'mcp', text: 'MCP' }, ] const [tagFilterValue, setTagFilterValue] = useState([]) + const [showPluginSettingModal, setShowPluginSettingModal] = useState(false) const handleTagsChange = (value: string[]) => { setTagFilterValue(value) } @@ -81,13 +92,13 @@ const ProviderList = ({ return collectionList.filter((collection) => { if (collection.type !== activeTab) return false - if (tagFilterValue.length > 0 && (!collection.labels || collection.labels.every(label => !tagFilterValue.includes(label)))) + if (showLabelFilter && tagFilterValue.length > 0 && (!collection.labels || collection.labels.every(label => !tagFilterValue.includes(label)))) return false if (keywords) return Object.values(collection.label).some(value => value.toLowerCase().includes(keywords.toLowerCase())) return true }) - }, [activeTab, tagFilterValue, keywords, collectionList]) + }, [activeTab, showLabelFilter, tagFilterValue, keywords, collectionList]) const [currentProviderId, setCurrentProviderId] = useState() const currentProvider = useMemo(() => { @@ -146,8 +157,8 @@ const ProviderList = ({ >
@@ -165,25 +176,41 @@ const ProviderList = ({ options={options} /> )} -
- {activeTab !== 'mcp' && ( - +
+
+ {showLabelFilter && ( + + )} + handleKeywordsChange(e.target.value)} + onClear={() => handleKeywordsChange('')} + /> +
+ {showToolsUpdateSetting && ( + )} - handleKeywordsChange(e.target.value)} - onClear={() => handleKeywordsChange('')} - />
{activeTab !== 'mcp' && (
@@ -217,7 +244,7 @@ const ProviderList = ({
)} {!filteredCollectionList.length && activeTab === 'builtin' && ( - + )}
{enable_marketplace && activeTab === 'builtin' && ( @@ -247,6 +274,13 @@ const ProviderList = ({ onUpdate={() => invalidateInstalledPluginList()} onHide={() => setCurrentProviderId(undefined)} /> + {showPluginSettingModal && referenceSetting && ( + setShowPluginSettingModal(false)} + onSave={setReferenceSettings} + /> + )} ) } diff --git a/web/app/components/tools/provider/__tests__/detail.spec.tsx b/web/app/components/tools/provider/__tests__/detail.spec.tsx index 5a26589e11..bcaec02f34 100644 --- a/web/app/components/tools/provider/__tests__/detail.spec.tsx +++ b/web/app/components/tools/provider/__tests__/detail.spec.tsx @@ -174,6 +174,32 @@ describe('ProviderDetail', () => { }) describe('Rendering', () => { + it('uses the full-height right drawer layout from the design', () => { + render( + , + ) + + const dialog = screen.getByRole('dialog') + + expect(dialog).toHaveClass( + 'data-[swipe-direction=right]:top-2', + 'data-[swipe-direction=right]:right-2', + 'data-[swipe-direction=right]:bottom-2', + 'data-[swipe-direction=right]:h-[calc(100dvh-16px)]', + 'data-[swipe-direction=right]:w-[400px]', + 'data-[swipe-direction=right]:max-w-[calc(100vw-1rem)]', + ) + expect(dialog).not.toHaveClass( + 'data-[swipe-direction=right]:top-16', + 'data-[swipe-direction=right]:w-[420px]', + 'data-[swipe-direction=right]:max-w-[420px]', + ) + }) + it('renders title, org info and description for a builtIn collection', async () => { render( - +