From 10cc0ddb64f9996c0a340fe5b60d69448194f127 Mon Sep 17 00:00:00 2001 From: Jingyi-Dify Date: Mon, 11 May 2026 18:24:33 -0700 Subject: [PATCH] feat: add canonical integrations routes --- docs/integrations-route-contract.md | 140 ++++++++++++++++ .../integrations/[[...slug]]/page.tsx | 26 +++ web/app/(commonLayout)/plugins/page.tsx | 16 +- web/app/(commonLayout)/tools/page.tsx | 27 ++-- .../__tests__/constants.spec.ts | 12 +- .../header/account-setting/destinations.ts | 7 +- .../__tests__/index.non-cloud.spec.tsx | 2 +- .../__tests__/index.spec.tsx | 16 +- .../model-provider-page/index.tsx | 153 +++++------------- .../header/tools-nav/__tests__/index.spec.tsx | 11 +- web/app/components/header/tools-nav/index.tsx | 5 +- .../main-nav/__tests__/index.spec.tsx | 2 +- web/app/components/main-nav/index.tsx | 5 +- .../plugins/__tests__/plugin-routes.spec.ts | 26 +++ .../plugins/plugin-page/context-provider.tsx | 4 +- web/app/components/plugins/plugin-routes.ts | 32 ++++ .../__tests__/integration-routes.spec.ts | 64 ++++++++ .../__tests__/integrations-page.spec.tsx | 59 ++++++- .../tools/__tests__/provider-list.spec.tsx | 26 ++- .../components/tools/integration-routes.ts | 134 +++++++++++++++ .../tools/integration-section-renderer.tsx | 58 +++++++ .../components/tools/integrations-page.tsx | 100 ++++-------- .../components/tools/plugin-category-page.tsx | 30 ++++ web/app/components/tools/provider-list.tsx | 29 +++- .../tools/provider/__tests__/empty.spec.tsx | 8 +- web/app/components/tools/provider/empty.tsx | 7 +- .../__tests__/configure-button.spec.tsx | 2 +- .../tools/workflow-tool/configure-button.tsx | 3 +- web/i18n/ar-TN/common.json | 2 + web/i18n/de-DE/common.json | 2 + web/i18n/en-US/common.json | 1 + web/i18n/es-ES/common.json | 2 + web/i18n/fa-IR/common.json | 2 + web/i18n/fr-FR/common.json | 2 + web/i18n/hi-IN/common.json | 2 + web/i18n/id-ID/common.json | 2 + web/i18n/it-IT/common.json | 2 + web/i18n/ja-JP/common.json | 2 + web/i18n/ko-KR/common.json | 2 + web/i18n/nl-NL/common.json | 2 + web/i18n/pl-PL/common.json | 2 + web/i18n/pt-BR/common.json | 2 + web/i18n/ro-RO/common.json | 2 + web/i18n/ru-RU/common.json | 2 + web/i18n/sl-SI/common.json | 2 + web/i18n/th-TH/common.json | 2 + web/i18n/tr-TR/common.json | 2 + web/i18n/uk-UA/common.json | 2 + web/i18n/vi-VN/common.json | 2 + web/i18n/zh-Hans/common.json | 1 + web/i18n/zh-Hant/common.json | 2 + web/next/navigation.ts | 1 + 52 files changed, 799 insertions(+), 250 deletions(-) create mode 100644 docs/integrations-route-contract.md create mode 100644 web/app/(commonLayout)/integrations/[[...slug]]/page.tsx create mode 100644 web/app/components/plugins/__tests__/plugin-routes.spec.ts create mode 100644 web/app/components/plugins/plugin-routes.ts create mode 100644 web/app/components/tools/__tests__/integration-routes.spec.ts create mode 100644 web/app/components/tools/integration-routes.ts create mode 100644 web/app/components/tools/integration-section-renderer.tsx create mode 100644 web/app/components/tools/plugin-category-page.tsx diff --git a/docs/integrations-route-contract.md b/docs/integrations-route-contract.md new file mode 100644 index 0000000000..e4a4d3483b --- /dev/null +++ b/docs/integrations-route-contract.md @@ -0,0 +1,140 @@ +# Integrations Route Contract + +This document records the current canonical routes for the Integrations navigation, the legacy routes that redirect to them, and the remaining migration gaps. The first migration only moves existing pages onto the new routes; UI redesign work is out of scope. + +## Current Status + +Completed: + +| Area | Status | +| --- | --- | +| Canonical `/integrations/...` route adapter | Implemented in `web/app/(commonLayout)/integrations/[[...slug]]/page.tsx`. | +| Route contract utility | Implemented in `web/app/components/tools/integration-routes.ts`. | +| Existing page reuse | Implemented through `IntegrationSectionRenderer`; no duplicated UI copy. | +| Legacy `/tools?...` redirects | Implemented through `web/app/(commonLayout)/tools/page.tsx`. | +| Legacy `/plugins` installed redirects | Implemented through `web/app/(commonLayout)/plugins/page.tsx`. | +| Tools tab navigation under new URLs | Implemented; scoped tool tabs push canonical `/integrations/tools/...` URLs. | +| Singular-only canonical URLs | Implemented; plural and misplaced aliases are intentionally unsupported. | + +Not completed: + +| Area | Remaining work | +| --- | --- | +| Integrations overview page | Not introduced; `/integrations` currently redirects to `/integrations/model-provider`. | +| Tools overview page | Not introduced; `/integrations/tools` currently redirects to `/integrations/tools/built-in`. | +| Plugin route migration | `/plugins` still owns the old plugin management and marketplace surface; no `/integrations/plugin` route will be introduced. Non-marketplace plugin URLs should redirect to `/integrations`. | +| Marketplace route migration | `/marketplace/...` routes below are future recommendations only; they are not implemented here. | +| New onboarding UI redesign | Not started in this route migration; current pages intentionally reuse existing UI. | +| Marketplace plugin redirects | Not implemented; marketplace plugin URLs intentionally keep rendering the legacy plugin marketplace surface for now. | + +## Navigation Labels + +| Navigation item | Canonical label | +| --- | --- | +| Model Provider | Model Provider | +| Built-in tools | Built-in | +| Custom Tool | Swagger API as Tool | +| Workflow | Workflow as Tool | +| MCP | MCP | +| Data Source | Data Source | +| API Extension | API Extension | +| Plugins | Plugins | +| Marketplace | Marketplace | + +## Canonical Integrations Routes + +| Route | Destination | +| --- | --- | +| `/integrations` | Redirect to `/integrations/model-provider` unless an overview page is introduced. | +| `/integrations/model-provider` | Existing model provider management page. | +| `/integrations/tools` | Redirect to `/integrations/tools/built-in` unless a tools overview page is introduced. | +| `/integrations/tools/built-in` | Existing built-in tools list. | +| `/integrations/tools/swagger-api` | Existing custom API tool list, relabeled as Swagger API as Tool. | +| `/integrations/tools/workflow` | Existing Workflow as Tool management page. | +| `/integrations/tools/mcp` | Existing MCP tools management page. | +| `/integrations/trigger` | Existing plugin trigger list filtered from plugin management. | +| `/integrations/agent-strategy` | Existing agent strategy plugin list filtered from plugin management. | +| `/integrations/extension` | Existing extension plugin list filtered from plugin management. | +| `/integrations/data-source` | Existing data source page. | +| `/integrations/tools/api-extension` | Existing API extension page under the Tools group. | + +## Integration Plugin Category Routes + +These navigation items use plugin categories from the existing plugin management surface: + +| Navigation item | Plugin category | Route | +| --- | --- | --- | +| Trigger | `trigger` | `/integrations/trigger` | +| Agent Strategy | `agent-strategy` | `/integrations/agent-strategy` | +| Extension | `extension` | `/integrations/extension` | + +The install and filter controls in the Integrations sidebar are disabled actions, not route destinations. + +These routes reuse the installed plugin management list with an initial category filter. They are not marketplace category pages. + +Do not treat every plugin category as an Integrations navigation item automatically. `trigger`, `agent-strategy`, and `extension` are currently exposed under Integrations because they are explicit navigation items. Other plugin categories have different product meanings: + +| Plugin category | Integrations relationship | +| --- | --- | +| `tool` | Not equal to `/integrations/tools/...`; tool plugins can expose tool providers that appear in Tools, but the Tools page is provider-based. | +| `model` | Not equal to `/integrations/model-provider`; model providers are managed through the model provider page. | +| `datasource` | Not equal to the full Data Source page; data source integrations have their own existing page. | +| `trigger` | Reused as `/integrations/trigger`, installed plugins filtered by category. | +| `agent-strategy` | Reused as `/integrations/agent-strategy`, installed plugins filtered by category. | +| `extension` | Reused as `/integrations/extension`, installed plugins filtered by category. | + +## Legacy Tools Redirects + +| Legacy route | New route | +| --- | --- | +| `/tools` | `/integrations/tools/built-in` | +| `/tools?section=provider` | `/integrations/model-provider` | +| `/tools?section=builtin` | `/integrations/tools/built-in` | +| `/tools?section=builtin&category=builtin` | `/integrations/tools/built-in` | +| `/tools?category=builtin` | `/integrations/tools/built-in` | +| `/tools?section=custom-tool` | `/integrations/tools/swagger-api` | +| `/tools?section=custom-tool&category=api` | `/integrations/tools/swagger-api` | +| `/tools?category=api` | `/integrations/tools/swagger-api` | +| `/tools?section=workflow-tool` | `/integrations/tools/workflow` | +| `/tools?section=workflow-tool&category=workflow` | `/integrations/tools/workflow` | +| `/tools?category=workflow` | `/integrations/tools/workflow` | +| `/tools?section=mcp` | `/integrations/tools/mcp` | +| `/tools?section=mcp&category=mcp` | `/integrations/tools/mcp` | +| `/tools?category=mcp` | `/integrations/tools/mcp` | +| `/tools?section=data-source` | `/integrations/data-source` | +| `/tools?section=api-based-extension` | `/integrations/tools/api-extension` | +| `/tools?section=trigger` | `/integrations/trigger` | +| `/tools?section=agent-strategy` | `/integrations/agent-strategy` | +| `/tools?section=extension` | `/integrations/extension` | + +Preserve non-routing query parameters such as `q`, `tags`, and `sort`, but drop legacy routing parameters such as `section` and `category` during redirects. + +## Non-Canonical Integrations Routes + +Do not add plural or misplaced alias redirects for new Integrations URLs. Only the singular canonical routes above should resolve. For example, `/integrations/model-providers`, `/integrations/data-sources`, `/integrations/api-extensions`, `/integrations/tools/trigger`, `/integrations/tools/agent-strategy`, and `/integrations/tools/extension` should not be treated as supported URLs unless they are later confirmed to have shipped externally. + +## Legacy Plugin Redirects + +Plugins have two different product meanings today: installed plugin management and marketplace discovery. Only the non-marketplace plugin URLs should redirect into Integrations. There is no `/integrations/plugin` route. + +| Old Plugin URL | Recommended redirect | Reason | +| --- | --- | --- | +| `/plugins` | `/integrations` | Installed plugin management entry should move into the Integrations main entry. | +| `/plugins?tab=plugins` | `/integrations` | Explicit installed plugins tab; non-marketplace semantics. | +| `/plugins?tab=discover` | Do not redirect to Integrations | Marketplace discovery. | +| `/plugins?tab=all` | Do not redirect to Integrations | Marketplace category: all. | +| `/plugins?tab=tool` | Do not redirect to Integrations | Marketplace tool category, not installed tools management. | +| `/plugins?tab=model` | Do not redirect to Integrations | Marketplace model category. | +| `/plugins?tab=trigger` | Do not redirect to Integrations | Marketplace trigger category. | +| `/plugins?tab=agent-strategy` | Do not redirect to Integrations | Marketplace agent strategy category. | +| `/plugins?tab=extension` | Do not redirect to Integrations | Marketplace extension category. | +| `/plugins?tab=datasource` | Do not redirect to Integrations | Marketplace datasource category. | +| `/plugins?tab=bundle` | Do not redirect to Integrations | Marketplace bundle category. | + +## Migration Order + +1. Add the canonical route map and route tests. +2. Mount the existing pages under the new Integrations routes without UI redesign. +3. Update internal links to generate canonical URLs. +4. Add legacy redirects for `/tools` and `/plugins`. +5. Keep compatibility tests for each legacy route until old links can be removed. diff --git a/web/app/(commonLayout)/integrations/[[...slug]]/page.tsx b/web/app/(commonLayout)/integrations/[[...slug]]/page.tsx new file mode 100644 index 0000000000..44611ad020 --- /dev/null +++ b/web/app/(commonLayout)/integrations/[[...slug]]/page.tsx @@ -0,0 +1,26 @@ +import { getIntegrationRouteTargetBySlug } from '@/app/components/tools/integration-routes' +import IntegrationsPage from '@/app/components/tools/integrations-page' +import { notFound, redirect } from '@/next/navigation' + +type IntegrationsRoutePageProps = { + params: Promise<{ + slug?: string[] + }> +} + +const IntegrationsRoutePage = async ({ + params, +}: IntegrationsRoutePageProps) => { + const { slug } = await params + const target = getIntegrationRouteTargetBySlug(slug) + + if (target.type === 'redirect') + redirect(target.destination) + + if (target.type === 'not-found') + notFound() + + return +} + +export default IntegrationsRoutePage diff --git a/web/app/(commonLayout)/plugins/page.tsx b/web/app/(commonLayout)/plugins/page.tsx index f366200cf9..f62512295b 100644 --- a/web/app/(commonLayout)/plugins/page.tsx +++ b/web/app/(commonLayout)/plugins/page.tsx @@ -1,8 +1,22 @@ +import type { LegacyPluginsSearchParams } from '@/app/components/plugins/plugin-routes' import Marketplace from '@/app/components/plugins/marketplace' import PluginPage from '@/app/components/plugins/plugin-page' import PluginsPanel from '@/app/components/plugins/plugin-page/plugins-panel' +import { getLegacyPluginRedirectPath } from '@/app/components/plugins/plugin-routes' +import { redirect } from '@/next/navigation' + +type PluginListProps = { + searchParams?: Promise +} + +const PluginList = async ({ + searchParams, +}: PluginListProps) => { + const redirectPath = getLegacyPluginRedirectPath(await searchParams) + + if (redirectPath) + redirect(redirectPath) -const PluginList = () => { return ( } diff --git a/web/app/(commonLayout)/tools/page.tsx b/web/app/(commonLayout)/tools/page.tsx index c9ff11216f..952fc37965 100644 --- a/web/app/(commonLayout)/tools/page.tsx +++ b/web/app/(commonLayout)/tools/page.tsx @@ -1,14 +1,17 @@ -'use client' -import type { FC } from 'react' -import * as React from 'react' -import { useTranslation } from 'react-i18next' -import IntegrationsPage from '@/app/components/tools/integrations-page' -import useDocumentTitle from '@/hooks/use-document-title' +import type { LegacyToolsSearchParams } from '@/app/components/tools/integration-routes' +import { getIntegrationRedirectPathByLegacyToolsSearchParams } from '@/app/components/tools/integration-routes' +import { redirect } from '@/next/navigation' -const ToolsList: FC = () => { - const { t } = useTranslation() - useDocumentTitle(t('menus.tools', { ns: 'common' })) - - return +type ToolsPageProps = { + searchParams?: Promise } -export default React.memo(ToolsList) + +const ToolsPage = async ({ + searchParams, +}: ToolsPageProps) => { + const resolvedSearchParams = await searchParams + + redirect(getIntegrationRedirectPathByLegacyToolsSearchParams(resolvedSearchParams)) +} + +export default ToolsPage diff --git a/web/app/components/header/account-setting/__tests__/constants.spec.ts b/web/app/components/header/account-setting/__tests__/constants.spec.ts index ff58b5ef80..63c488dd29 100644 --- a/web/app/components/header/account-setting/__tests__/constants.spec.ts +++ b/web/app/components/header/account-setting/__tests__/constants.spec.ts @@ -47,12 +47,12 @@ describe('AccountSetting Constants', () => { it('should map migrated setting tabs to integrations sections', () => { expect(enableMovedAccountSettingDestinations).toBe(true) - expect(movedAccountSettingDestinations[ACCOUNT_SETTING_TAB.PROVIDER]).toBe('/tools?section=provider') - expect(movedAccountSettingDestinations[ACCOUNT_SETTING_TAB.DATA_SOURCE]).toBe('/tools?section=data-source') - expect(movedAccountSettingDestinations[ACCOUNT_SETTING_TAB.API_BASED_EXTENSION]).toBe('/tools?section=api-based-extension') - expect(getMovedAccountSettingDestination(ACCOUNT_SETTING_TAB.PROVIDER)).toBe('/tools?section=provider') - expect(getMovedAccountSettingDestination(ACCOUNT_SETTING_TAB.DATA_SOURCE)).toBe('/tools?section=data-source') - expect(getMovedAccountSettingDestination(ACCOUNT_SETTING_TAB.API_BASED_EXTENSION)).toBe('/tools?section=api-based-extension') + expect(movedAccountSettingDestinations[ACCOUNT_SETTING_TAB.PROVIDER]).toBe('/integrations/model-provider') + expect(movedAccountSettingDestinations[ACCOUNT_SETTING_TAB.DATA_SOURCE]).toBe('/integrations/data-source') + expect(movedAccountSettingDestinations[ACCOUNT_SETTING_TAB.API_BASED_EXTENSION]).toBe('/integrations/tools/api-extension') + expect(getMovedAccountSettingDestination(ACCOUNT_SETTING_TAB.PROVIDER)).toBe('/integrations/model-provider') + expect(getMovedAccountSettingDestination(ACCOUNT_SETTING_TAB.DATA_SOURCE)).toBe('/integrations/data-source') + expect(getMovedAccountSettingDestination(ACCOUNT_SETTING_TAB.API_BASED_EXTENSION)).toBe('/integrations/tools/api-extension') expect(getMovedAccountSettingDestination(ACCOUNT_SETTING_TAB.BILLING)).toBeUndefined() }) }) diff --git a/web/app/components/header/account-setting/destinations.ts b/web/app/components/header/account-setting/destinations.ts index c26e61ab35..7a97a42053 100644 --- a/web/app/components/header/account-setting/destinations.ts +++ b/web/app/components/header/account-setting/destinations.ts @@ -1,10 +1,11 @@ import type { AccountSettingTab } from './constants' +import { buildIntegrationPath } from '@/app/components/tools/integration-routes' import { ACCOUNT_SETTING_TAB } from './constants' export const movedAccountSettingDestinations: Partial> = { - [ACCOUNT_SETTING_TAB.PROVIDER]: '/tools?section=provider', - [ACCOUNT_SETTING_TAB.DATA_SOURCE]: '/tools?section=data-source', - [ACCOUNT_SETTING_TAB.API_BASED_EXTENSION]: '/tools?section=api-based-extension', + [ACCOUNT_SETTING_TAB.PROVIDER]: buildIntegrationPath('provider'), + [ACCOUNT_SETTING_TAB.DATA_SOURCE]: buildIntegrationPath('data-source'), + [ACCOUNT_SETTING_TAB.API_BASED_EXTENSION]: buildIntegrationPath('api-based-extension'), } export const enableMovedAccountSettingDestinations = true diff --git a/web/app/components/header/account-setting/model-provider-page/__tests__/index.non-cloud.spec.tsx b/web/app/components/header/account-setting/model-provider-page/__tests__/index.non-cloud.spec.tsx index 84aad65f68..de67b88dc1 100644 --- a/web/app/components/header/account-setting/model-provider-page/__tests__/index.non-cloud.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/__tests__/index.non-cloud.spec.tsx @@ -102,7 +102,7 @@ vi.mock('@/service/client', async (importOriginal) => { describe('ModelProviderPage non-cloud branch', () => { it('should skip the quota panel when cloud edition is disabled', () => { - renderWithSystemFeatures(, { + renderWithSystemFeatures(, { systemFeatures: { enable_marketplace: false }, }) 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 31d2e60fa1..6bf818a544 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 @@ -21,7 +21,7 @@ const renderModelProviderPage = ( props: { searchText?: string, enableMarketplace?: boolean } = {}, ) => { const { searchText = '', enableMarketplace = true } = props - return renderWithSystemFeatures(, { + return renderWithSystemFeatures(, { systemFeatures: { enable_marketplace: enableMarketplace }, }) } @@ -71,17 +71,6 @@ vi.mock('../install-from-marketplace', () => ({ default: () =>
, })) -vi.mock('@/app/components/plugins/plugin-page/use-reference-setting', () => ({ - default: () => ({ - referenceSetting: { permission: {}, auto_upgrade: {} }, - setReferenceSettings: vi.fn(), - }), -})) - -vi.mock('@/app/components/plugins/reference-setting-modal', () => ({ - default: () =>
, -})) - vi.mock('../provider-added-card', () => ({ default: ({ provider }: { provider: { provider: string } }) =>
{provider.provider}
, })) @@ -158,9 +147,8 @@ describe('ModelProviderPage', () => { it('should render main elements', () => { renderModelProviderPage() - expect(screen.getByPlaceholderText('common.operation.search')).toBeInTheDocument() + expect(screen.getByText('common.modelProvider.models')).toBeInTheDocument() expect(screen.getByTestId('system-model-selector')).toBeInTheDocument() - expect(screen.getByText('plugin.autoUpdate.updateSettings')).toBeInTheDocument() expect(screen.getByTestId('install-from-marketplace')).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 1b72d53bea..3b8874feab 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 @@ -5,12 +5,9 @@ import type { PluginDetail } from '@/app/components/plugins/types' import { cn } from '@langgenius/dify-ui/cn' import { useQuery, useSuspenseQuery } from '@tanstack/react-query' import { useDebounce } from 'ahooks' -import { useMemo, useState } from 'react' +import { useMemo } 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' @@ -25,23 +22,21 @@ import { import InstallFromMarketplace from './install-from-marketplace' import ProviderAddedCard from './provider-added-card' import QuotaPanel from './provider-added-card/quota-panel' -import { providerSupportsCredits } from './supports-credits' import SystemModelSelector from './system-model-selector' import { providerToPluginId } from './utils' type SystemModelConfigStatus = 'no-provider' | 'none-configured' | 'partially-configured' | 'fully-configured' type Props = { - onSearchTextChange: (value: string) => void + onSearchTextChange?: (value: string) => void searchText: string } const FixedModelProvider = ['langgenius/openai/openai', 'langgenius/anthropic/anthropic'] -const ModelProviderPage = ({ onSearchTextChange, searchText }: Props) => { +const ModelProviderPage = ({ searchText }: Props) => { const debouncedSearchText = useDebounce(searchText, { wait: 500 }) const { t } = useTranslation() - const [showUpdateSettingModal, setShowUpdateSettingModal] = 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) @@ -49,10 +44,6 @@ const ModelProviderPage = ({ onSearchTextChange, searchText }: Props) => { const { data: ttsDefaultModel, isLoading: isTTSDefaultModelLoading } = useDefaultModel(ModelTypeEnum.tts) const { modelProviders: providers } = useProviderContext() const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) - const { - referenceSetting, - setReferenceSettings, - } = useReferenceSetting() const allPluginIds = useMemo(() => { return [...new Set(providers.map(p => providerToPluginId(p.provider)).filter(Boolean))] @@ -138,70 +129,36 @@ const ModelProviderPage = ({ onSearchTextChange, searchText }: Props) => { return [filteredConfiguredProviders, filteredNotConfiguredProviders] }, [configuredProviders, debouncedSearchText, notConfiguredProviders]) - const [creditsBackedProviders, otherConfiguredProviders] = useMemo(() => { - const creditsBackedProviders: ModelProvider[] = [] - const otherConfiguredProviders: ModelProvider[] = [] - - filteredConfiguredProviders.forEach((provider) => { - if (providerSupportsCredits(provider, systemFeatures.trial_models)) - creditsBackedProviders.push(provider) - else - otherConfiguredProviders.push(provider) - }) - - return [creditsBackedProviders, otherConfiguredProviders] - }, [filteredConfiguredProviders, systemFeatures.trial_models]) - const hasConfiguredProviders = creditsBackedProviders.length > 0 || otherConfiguredProviders.length > 0 return (
-
- -
- -
+
{t('modelProvider.models', { ns: 'common' })}
+
+ {showWarning &&
} + {showWarning && ( +
+ + {t(warningTextKey, { ns: 'common' })} +
)} - > - {showWarning &&
} - {showWarning && ( -
- - {t(warningTextKey, { ns: 'common' })} -
- )} - -
+
{IS_CLOUD_EDITION && } - {!hasConfiguredProviders && ( + {!filteredConfiguredProviders?.length && (
@@ -210,46 +167,23 @@ const ModelProviderPage = ({ onSearchTextChange, searchText }: Props) => {
{t('modelProvider.emptyProviderTip', { ns: 'common' })}
)} - {!!creditsBackedProviders.length && ( -
-
-
{t('modelProvider.creditsBackedProviders', { ns: 'common' })}
-
{t('modelProvider.creditsBackedProvidersDesc', { ns: 'common' })}
-
-
- {creditsBackedProviders.map(provider => ( - - ))} -
-
- )} - {!!otherConfiguredProviders.length && ( -
-
{t('modelProvider.configuredProviders', { ns: 'common' })}
-
- {otherConfiguredProviders.map(provider => ( - - ))} -
-
+ {!!filteredConfiguredProviders?.length && ( +
+ {filteredConfiguredProviders?.map(provider => ( + + ))} +
)} {!!filteredNotConfiguredProviders?.length && ( -
-
{t('modelProvider.toBeConfigured', { ns: 'common' })}
-
+ <> +
{t('modelProvider.toBeConfigured', { ns: 'common' })}
+
{filteredNotConfiguredProviders?.map(provider => ( { /> ))}
-
+ )} { enableMarketplace && ( @@ -267,13 +201,6 @@ const ModelProviderPage = ({ onSearchTextChange, searchText }: Props) => { /> ) } - {showUpdateSettingModal && referenceSetting && ( - setShowUpdateSettingModal(false)} - onSave={setReferenceSettings} - /> - )}
) } diff --git a/web/app/components/header/tools-nav/__tests__/index.spec.tsx b/web/app/components/header/tools-nav/__tests__/index.spec.tsx index 361e6f8b84..d5646e09d7 100644 --- a/web/app/components/header/tools-nav/__tests__/index.spec.tsx +++ b/web/app/components/header/tools-nav/__tests__/index.spec.tsx @@ -28,7 +28,7 @@ describe('ToolsNav', () => { render() const link = screen.getByRole('link') - expect(link).toHaveAttribute('href', '/tools') + expect(link).toHaveAttribute('href', '/integrations/tools/built-in') expect(screen.getByText('common.menus.tools')).toBeInTheDocument() expect(screen.getByTestId('icon-hammer-line')).toBeInTheDocument() @@ -57,6 +57,15 @@ describe('ToolsNav', () => { expect(screen.getByTestId('icon-hammer-fill')).toBeInTheDocument() expect(screen.queryByTestId('icon-hammer-line')).not.toBeInTheDocument() }) + + it('should render active state for the integrations segment', () => { + mockUseSelectedLayoutSegment.mockReturnValue('integrations') + + render() + + expect(screen.getByRole('link')).toHaveClass('bg-components-main-nav-nav-button-bg-active') + expect(screen.getByTestId('icon-hammer-fill')).toBeInTheDocument() + }) }) describe('Props', () => { diff --git a/web/app/components/header/tools-nav/index.tsx b/web/app/components/header/tools-nav/index.tsx index 06d19382ac..8c7b73f163 100644 --- a/web/app/components/header/tools-nav/index.tsx +++ b/web/app/components/header/tools-nav/index.tsx @@ -6,6 +6,7 @@ import { RiHammerLine, } from '@remixicon/react' import { useTranslation } from 'react-i18next' +import { buildIntegrationPath } from '@/app/components/tools/integration-routes' import Link from '@/next/link' import { useSelectedLayoutSegment } from '@/next/navigation' @@ -18,11 +19,11 @@ const ToolsNav = ({ }: ToolsNavProps) => { const { t } = useTranslation() const selectedSegment = useSelectedLayoutSegment() - const activated = selectedSegment === 'tools' + const activated = selectedSegment === 'integrations' || selectedSegment === 'tools' return ( { diff --git a/web/app/components/main-nav/__tests__/index.spec.tsx b/web/app/components/main-nav/__tests__/index.spec.tsx index 71c1a00a2c..f6373a337e 100644 --- a/web/app/components/main-nav/__tests__/index.spec.tsx +++ b/web/app/components/main-nav/__tests__/index.spec.tsx @@ -214,7 +214,7 @@ describe('MainNav', () => { expect(screen.getByRole('link', { name: /common.mainNav.home/ })).toHaveAttribute('href', '/') expect(screen.getByRole('link', { name: /common.menus.apps/ })).toHaveAttribute('href', '/apps') expect(screen.getByRole('link', { name: /common.menus.datasets/ })).toHaveAttribute('href', '/datasets') - expect(screen.getByRole('link', { name: /common.mainNav.integrations/ })).toHaveAttribute('href', '/tools?section=provider') + expect(screen.getByRole('link', { name: /common.mainNav.integrations/ })).toHaveAttribute('href', '/integrations/model-provider') expect(screen.getByRole('link', { name: /common.mainNav.marketplace/ })).toHaveAttribute('href', '/plugins') }) diff --git a/web/app/components/main-nav/index.tsx b/web/app/components/main-nav/index.tsx index 975e9c98e2..63874347e8 100644 --- a/web/app/components/main-nav/index.tsx +++ b/web/app/components/main-nav/index.tsx @@ -7,6 +7,7 @@ import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import DifyLogo from '@/app/components/base/logo/dify-logo' import EnvNav from '@/app/components/header/env-nav' +import { buildIntegrationPath } from '@/app/components/tools/integration-routes' import { useAppContext } from '@/context/app-context' import Link from '@/next/link' import { usePathname } from '@/next/navigation' @@ -59,9 +60,9 @@ const MainNav = ({ ...(!isCurrentWorkspaceDatasetOperator ? [ { - href: '/tools?section=provider', + href: buildIntegrationPath('provider'), label: t('mainNav.integrations', { ns: 'common' }), - active: (path: string) => path.startsWith('/tools'), + active: (path: string) => path.startsWith('/integrations') || path.startsWith('/tools'), icon: 'i-custom-vender-main-nav-integrations', activeIcon: 'i-custom-vender-main-nav-integrations-active', }, diff --git a/web/app/components/plugins/__tests__/plugin-routes.spec.ts b/web/app/components/plugins/__tests__/plugin-routes.spec.ts new file mode 100644 index 0000000000..a329e7fec0 --- /dev/null +++ b/web/app/components/plugins/__tests__/plugin-routes.spec.ts @@ -0,0 +1,26 @@ +import { getLegacyPluginRedirectPath } from '../plugin-routes' + +describe('plugin routes', () => { + it.each([ + [{}, '/integrations'], + [{ tab: 'plugins' }, '/integrations'], + [{ tab: ['plugins', 'discover'] }, '/integrations'], + ])('redirects installed plugin URLs for search params %j', (searchParams, expected) => { + expect(getLegacyPluginRedirectPath(searchParams)).toBe(expected) + }) + + it.each([ + { tab: 'discover' }, + { tab: 'all' }, + { tab: 'tool' }, + { tab: 'model' }, + { tab: 'trigger' }, + { tab: 'agent-strategy' }, + { tab: 'extension' }, + { tab: 'datasource' }, + { tab: 'bundle' }, + { tab: 'unsupported' }, + ])('does not redirect marketplace or unsupported plugin URLs for search params %j', (searchParams) => { + expect(getLegacyPluginRedirectPath(searchParams)).toBeUndefined() + }) +}) diff --git a/web/app/components/plugins/plugin-page/context-provider.tsx b/web/app/components/plugins/plugin-page/context-provider.tsx index 6347fde86f..f789f162be 100644 --- a/web/app/components/plugins/plugin-page/context-provider.tsx +++ b/web/app/components/plugins/plugin-page/context-provider.tsx @@ -28,13 +28,15 @@ const parseAsPluginPageTab = parseAsStringEnum(PLUGIN_PAGE_TAB_VA type PluginPageContextProviderProps = { children: ReactNode + initialFilters?: FilterState } export const PluginPageContextProvider = ({ children, + initialFilters, }: PluginPageContextProviderProps) => { const containerRef = useRef(null) - const [filters, setFilters] = useState({ + const [filters, setFilters] = useState(initialFilters ?? { categories: [], tags: [], searchQuery: '', diff --git a/web/app/components/plugins/plugin-routes.ts b/web/app/components/plugins/plugin-routes.ts new file mode 100644 index 0000000000..f6156904b2 --- /dev/null +++ b/web/app/components/plugins/plugin-routes.ts @@ -0,0 +1,32 @@ +import { PLUGIN_TYPE_SEARCH_MAP } from './marketplace/constants' + +export type LegacyPluginsSearchParams = Record + +const INSTALLED_PLUGINS_TAB = 'plugins' +const MARKETPLACE_TAB = 'discover' + +const marketplacePluginTabs = new Set([ + MARKETPLACE_TAB, + ...Object.values(PLUGIN_TYPE_SEARCH_MAP), +]) + +const getFirstSearchParamValue = (value: string | string[] | undefined) => { + if (Array.isArray(value)) + return value[0] + + return value +} + +export const getLegacyPluginRedirectPath = ( + searchParams: LegacyPluginsSearchParams = {}, +) => { + const tab = getFirstSearchParamValue(searchParams.tab) + + if (!tab || tab === INSTALLED_PLUGINS_TAB) + return '/integrations' + + if (marketplacePluginTabs.has(tab)) + return undefined + + return undefined +} diff --git a/web/app/components/tools/__tests__/integration-routes.spec.ts b/web/app/components/tools/__tests__/integration-routes.spec.ts new file mode 100644 index 0000000000..c9c902382a --- /dev/null +++ b/web/app/components/tools/__tests__/integration-routes.spec.ts @@ -0,0 +1,64 @@ +import { + buildIntegrationPath, + getIntegrationRedirectPathByLegacyToolsSearchParams, + getIntegrationRouteTargetBySlug, + integrationPathBySection, +} from '../integration-routes' + +describe('integration routes', () => { + it('maps integration sections to canonical paths', () => { + expect(integrationPathBySection).toEqual({ + 'provider': '/integrations/model-provider', + 'builtin': '/integrations/tools/built-in', + 'custom-tool': '/integrations/tools/swagger-api', + 'workflow-tool': '/integrations/tools/workflow', + 'mcp': '/integrations/tools/mcp', + 'data-source': '/integrations/data-source', + 'api-based-extension': '/integrations/tools/api-extension', + 'trigger': '/integrations/trigger', + 'agent-strategy': '/integrations/agent-strategy', + 'extension': '/integrations/extension', + }) + expect(buildIntegrationPath('custom-tool')).toBe('/integrations/tools/swagger-api') + }) + + it.each([ + [undefined, { type: 'redirect', destination: '/integrations/model-provider' }], + [[], { type: 'redirect', destination: '/integrations/model-provider' }], + [['model-provider'], { type: 'section', section: 'provider' }], + [['tools'], { type: 'redirect', destination: '/integrations/tools/built-in' }], + [['tools', 'built-in'], { type: 'section', section: 'builtin' }], + [['tools', 'swagger-api'], { type: 'section', section: 'custom-tool' }], + [['tools', 'workflow'], { type: 'section', section: 'workflow-tool' }], + [['tools', 'mcp'], { type: 'section', section: 'mcp' }], + [['data-source'], { type: 'section', section: 'data-source' }], + [['tools', 'api-extension'], { type: 'section', section: 'api-based-extension' }], + [['trigger'], { type: 'section', section: 'trigger' }], + [['agent-strategy'], { type: 'section', section: 'agent-strategy' }], + [['extension'], { type: 'section', section: 'extension' }], + [['model-providers'], { type: 'not-found' }], + [['data-sources'], { type: 'not-found' }], + [['api-extensions'], { type: 'not-found' }], + [['tools', 'trigger'], { type: 'not-found' }], + [['tools', 'agent-strategy'], { type: 'not-found' }], + [['tools', 'extension'], { type: 'not-found' }], + [['missing'], { type: 'not-found' }], + ])('resolves slug %j', (slug, expected) => { + expect(getIntegrationRouteTargetBySlug(slug)).toEqual(expected) + }) + + it.each([ + [{}, '/integrations/tools/built-in'], + [{ section: 'provider' }, '/integrations/model-provider'], + [{ section: 'builtin' }, '/integrations/tools/built-in'], + [{ category: 'builtin' }, '/integrations/tools/built-in'], + [{ category: 'api' }, '/integrations/tools/swagger-api'], + [{ category: 'workflow' }, '/integrations/tools/workflow'], + [{ category: 'mcp' }, '/integrations/tools/mcp'], + [{ section: 'data-source' }, '/integrations/data-source'], + [{ section: 'api-based-extension' }, '/integrations/tools/api-extension'], + [{ section: 'custom-tool', category: 'api', q: 'slack', tags: ['a', 'b'] }, '/integrations/tools/swagger-api?q=slack&tags=a&tags=b'], + ])('builds legacy /tools redirect for search params %j', (searchParams, expected) => { + expect(getIntegrationRedirectPathByLegacyToolsSearchParams(searchParams)).toBe(expected) + }) +}) diff --git a/web/app/components/tools/__tests__/integrations-page.spec.tsx b/web/app/components/tools/__tests__/integrations-page.spec.tsx index 97bed53f85..9dc647786f 100644 --- a/web/app/components/tools/__tests__/integrations-page.spec.tsx +++ b/web/app/components/tools/__tests__/integrations-page.spec.tsx @@ -8,14 +8,14 @@ vi.mock('@/app/components/header/account-setting/model-provider-page', () => ({ onSearchTextChange, searchText, }: { - onSearchTextChange: (value: string) => void + onSearchTextChange?: (value: string) => void searchText: string }) => (
onSearchTextChange(event.target.value)} + onChange={event => onSearchTextChange?.(event.target.value)} />
), @@ -33,11 +33,16 @@ vi.mock('@/app/components/header/account-setting/api-based-extension-page', () = vi.mock('../provider-list', () => ({ __esModule: true, - default: () =>
, + default: ({ category }: { category?: string }) =>
{category}
, })) -const renderIntegrationsPage = (searchParams?: Record) => { - return renderWithNuqs(, { searchParams }) +vi.mock('../plugin-category-page', () => ({ + __esModule: true, + default: ({ category }: { category: string }) =>
, +})) + +const renderIntegrationsPage = (searchParams?: Record, section?: React.ComponentProps['section']) => { + return renderWithNuqs(, { searchParams }) } describe('IntegrationsPage', () => { @@ -60,6 +65,26 @@ describe('IntegrationsPage', () => { expect(screen.getByRole('textbox', { name: 'search' })).toBeInTheDocument() }) + it('renders plugin category sections from the section query', () => { + const triggerView = renderIntegrationsPage({ section: 'trigger' }) + + expect(screen.getByTestId('plugin-category-trigger')).toBeInTheDocument() + expect(screen.getByTestId('plugin-category-trigger').parentElement).toHaveClass('flex', 'flex-col', 'overflow-hidden') + expect(screen.getByRole('link', { name: 'common.settings.trigger' })).toHaveAttribute('href', '/integrations/trigger') + + triggerView.unmount() + const agentStrategyView = renderIntegrationsPage({ section: 'agent-strategy' }) + + expect(screen.getByTestId('plugin-category-agent-strategy')).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'common.settings.agentStrategy' })).toHaveAttribute('href', '/integrations/agent-strategy') + + agentStrategyView.unmount() + renderIntegrationsPage({ section: 'extension' }) + + expect(screen.getByTestId('plugin-category-extension')).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'common.settings.extension' })).toHaveAttribute('href', '/integrations/extension') + }) + it('renders migrated legacy setting sections', () => { const { unmount } = renderIntegrationsPage({ section: 'data-source' }) @@ -71,11 +96,29 @@ describe('IntegrationsPage', () => { expect(screen.getByTestId('api-extension-page')).toBeInTheDocument() }) + it('renders existing pages from route sections', () => { + const modelProviderView = renderIntegrationsPage(undefined, 'provider') + + expect(screen.getByTestId('model-provider-page')).toBeInTheDocument() + + modelProviderView.unmount() + const mcpView = renderIntegrationsPage(undefined, 'mcp') + + expect(screen.getByTestId('tool-provider-list')).toHaveTextContent('mcp') + expect(screen.getByTestId('tool-provider-list').parentElement).toHaveClass('flex', 'flex-col', 'overflow-hidden') + + mcpView.unmount() + renderIntegrationsPage(undefined, 'data-source') + + expect(screen.getByTestId('data-source-page')).toBeInTheDocument() + }) + it('keeps existing category-only tools URLs functional', () => { renderIntegrationsPage({ category: 'mcp' }) expect(screen.getByTestId('tool-provider-list')).toBeInTheDocument() expect(screen.getByRole('link', { name: 'MCP' })).toHaveClass('bg-state-base-active') + expect(screen.getByRole('link', { name: 'MCP' })).toHaveAttribute('href', '/integrations/tools/mcp') }) it('collapses and expands the integrations sidebar', () => { @@ -84,14 +127,14 @@ describe('IntegrationsPage', () => { fireEvent.click(screen.getByRole('button', { name: 'common.settings.collapse' })) expect(screen.queryByText('common.settings.integrations')).not.toBeInTheDocument() - expect(screen.queryByText('common.settings.customTool')).not.toBeInTheDocument() + expect(screen.queryByText('common.settings.swaggerAPIAsTool')).not.toBeInTheDocument() expect(screen.getByRole('link', { name: 'MCP' })).toBeInTheDocument() - expect(screen.getByLabelText('common.settings.trigger')).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'common.settings.trigger' })).toHaveAttribute('href', '/integrations/trigger') expect(screen.getByRole('button', { name: 'common.settings.expand' })).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'common.settings.expand' })) expect(screen.getByText('common.settings.integrations')).toBeInTheDocument() - expect(screen.getByText('common.settings.customTool')).toBeInTheDocument() + expect(screen.getByText('common.settings.swaggerAPIAsTool')).toBeInTheDocument() }) }) diff --git a/web/app/components/tools/__tests__/provider-list.spec.tsx b/web/app/components/tools/__tests__/provider-list.spec.tsx index 6661c26083..ffa8507a27 100644 --- a/web/app/components/tools/__tests__/provider-list.spec.tsx +++ b/web/app/components/tools/__tests__/provider-list.spec.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from 'react' +import type { ComponentProps, ReactNode } from 'react' import { cleanup, fireEvent, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features' @@ -7,6 +7,16 @@ 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: [], @@ -203,7 +213,7 @@ describe('getToolType', () => { }) }) -const renderProviderList = (searchParams?: Record) => { +const renderProviderList = (searchParams?: Record, category?: ComponentProps['category']) => { const { wrapper: SystemFeaturesWrapper } = createSystemFeaturesWrapper({ systemFeatures: { enable_marketplace: mockEnableMarketplace }, }) @@ -211,7 +221,7 @@ const renderProviderList = (searchParams?: Record) => { {children} ) return renderWithNuqs( - , + , { searchParams }, ) } @@ -244,6 +254,16 @@ describe('ProviderList', () => { expect(screen.getByTestId('custom-create-card')).toBeInTheDocument() }) + it('uses canonical integration routes when controlled by route category', () => { + renderProviderList(undefined, 'mcp') + + expect(screen.getByTestId('mcp-list')).toBeInTheDocument() + + fireEvent.click(screen.getByText('tools.type.workflow')) + + expect(mockRouterPush).toHaveBeenCalledWith('/integrations/tools/workflow') + }) + it('resets current provider when switching to a different tab', () => { renderProviderList() fireEvent.click(screen.getByTestId('card-google-search')) diff --git a/web/app/components/tools/integration-routes.ts b/web/app/components/tools/integration-routes.ts new file mode 100644 index 0000000000..28291ce3fd --- /dev/null +++ b/web/app/components/tools/integration-routes.ts @@ -0,0 +1,134 @@ +export const INTEGRATION_SECTION_VALUES = [ + 'provider', + 'builtin', + 'mcp', + 'custom-tool', + 'workflow-tool', + 'data-source', + 'api-based-extension', + 'trigger', + 'agent-strategy', + 'extension', +] as const + +export type IntegrationSection = typeof INTEGRATION_SECTION_VALUES[number] + +export const TOOL_CATEGORY_VALUES = ['builtin', 'api', 'workflow', 'mcp'] as const +export type ToolCategory = typeof TOOL_CATEGORY_VALUES[number] + +export type LegacyToolsSearchParams = Record + +const integrationSectionSet = new Set(INTEGRATION_SECTION_VALUES) +const toolCategorySet = new Set(TOOL_CATEGORY_VALUES) + +const isIntegrationSection = (value: string): value is IntegrationSection => { + return integrationSectionSet.has(value) +} + +const isToolCategory = (value: string): value is ToolCategory => { + return toolCategorySet.has(value) +} + +const getFirstSearchParamValue = (value: string | string[] | undefined) => { + if (Array.isArray(value)) + return value[0] + + return value +} + +export const toolCategoryBySection: Partial> = { + 'builtin': 'builtin', + 'mcp': 'mcp', + 'custom-tool': 'api', + 'workflow-tool': 'workflow', +} + +export const sectionByToolCategory: Record = { + builtin: 'builtin', + api: 'custom-tool', + workflow: 'workflow-tool', + mcp: 'mcp', +} + +export const integrationPathBySection: Record = { + 'provider': '/integrations/model-provider', + 'builtin': '/integrations/tools/built-in', + 'custom-tool': '/integrations/tools/swagger-api', + 'workflow-tool': '/integrations/tools/workflow', + 'mcp': '/integrations/tools/mcp', + 'data-source': '/integrations/data-source', + 'api-based-extension': '/integrations/tools/api-extension', + 'trigger': '/integrations/trigger', + 'agent-strategy': '/integrations/agent-strategy', + 'extension': '/integrations/extension', +} + +export const buildIntegrationPath = (section: IntegrationSection) => { + return integrationPathBySection[section] +} + +export const getIntegrationRedirectPathByLegacyToolsSearchParams = ( + searchParams: LegacyToolsSearchParams = {}, +) => { + const sectionParam = getFirstSearchParamValue(searchParams.section) + const categoryParam = getFirstSearchParamValue(searchParams.category) + const section = sectionParam && isIntegrationSection(sectionParam) + ? sectionParam + : categoryParam && isToolCategory(categoryParam) + ? sectionByToolCategory[categoryParam] + : 'builtin' + + const preservedSearchParams = new URLSearchParams() + Object.entries(searchParams).forEach(([key, value]) => { + if (key === 'section' || key === 'category' || value === undefined) + return + + if (Array.isArray(value)) { + value.forEach(item => preservedSearchParams.append(key, item)) + return + } + + preservedSearchParams.set(key, value) + }) + + const query = preservedSearchParams.toString() + return query ? `${buildIntegrationPath(section)}?${query}` : buildIntegrationPath(section) +} + +type IntegrationRouteTarget + = | { type: 'redirect', destination: string } + | { type: 'section', section: IntegrationSection } + | { type: 'not-found' } + +export const getIntegrationRouteTargetBySlug = (slug?: string[]): IntegrationRouteTarget => { + const path = slug?.join('/') ?? '' + + switch (path) { + case '': + return { type: 'redirect', destination: buildIntegrationPath('provider') } + case 'model-provider': + return { type: 'section', section: 'provider' } + case 'tools': + return { type: 'redirect', destination: buildIntegrationPath('builtin') } + case 'tools/built-in': + return { type: 'section', section: 'builtin' } + case 'tools/swagger-api': + return { type: 'section', section: 'custom-tool' } + case 'tools/workflow': + return { type: 'section', section: 'workflow-tool' } + case 'tools/mcp': + return { type: 'section', section: 'mcp' } + case 'data-source': + return { type: 'section', section: 'data-source' } + case 'tools/api-extension': + return { type: 'section', section: 'api-based-extension' } + case 'trigger': + return { type: 'section', section: 'trigger' } + case 'agent-strategy': + return { type: 'section', section: 'agent-strategy' } + case 'extension': + return { type: 'section', section: 'extension' } + default: + return { type: 'not-found' } + } +} diff --git a/web/app/components/tools/integration-section-renderer.tsx b/web/app/components/tools/integration-section-renderer.tsx new file mode 100644 index 0000000000..e79f5688a8 --- /dev/null +++ b/web/app/components/tools/integration-section-renderer.tsx @@ -0,0 +1,58 @@ +'use client' + +import type { IntegrationSection } from './integration-routes' +import ApiBasedExtensionPage from '@/app/components/header/account-setting/api-based-extension-page' +import DataSourcePage from '@/app/components/header/account-setting/data-source-page-new' +import ModelProviderPage from '@/app/components/header/account-setting/model-provider-page' +import { PluginCategoryEnum } from '@/app/components/plugins/types' +import PluginCategoryPage from './plugin-category-page' +import ToolProviderList from './provider-list' + +type IntegrationSectionRendererProps = { + providerSearchText: string + section: IntegrationSection +} + +const IntegrationSectionRenderer = ({ + providerSearchText, + section, +}: IntegrationSectionRendererProps) => { + switch (section) { + case 'provider': + return ( +
+ +
+ ) + case 'builtin': + return + case 'mcp': + return + case 'custom-tool': + return + case 'workflow-tool': + return + case 'data-source': + return ( +
+ +
+ ) + case 'api-based-extension': + return ( +
+ +
+ ) + case 'trigger': + return + case 'agent-strategy': + return + case 'extension': + return + default: + return null + } +} + +export default IntegrationSectionRenderer diff --git a/web/app/components/tools/integrations-page.tsx b/web/app/components/tools/integrations-page.tsx index 975b58bdd3..d5f1582b7c 100644 --- a/web/app/components/tools/integrations-page.tsx +++ b/web/app/components/tools/integrations-page.tsx @@ -1,48 +1,26 @@ 'use client' +import type { IntegrationSection } from '@/app/components/tools/integration-routes' import { cn } from '@langgenius/dify-ui/cn' 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 ApiBasedExtensionPage from '@/app/components/header/account-setting/api-based-extension-page' -import DataSourcePage from '@/app/components/header/account-setting/data-source-page-new' -import ModelProviderPage from '@/app/components/header/account-setting/model-provider-page' +import { + buildIntegrationPath, + INTEGRATION_SECTION_VALUES, + sectionByToolCategory, + TOOL_CATEGORY_VALUES, + toolCategoryBySection, +} from '@/app/components/tools/integration-routes' import Link from '@/next/link' -import ToolProviderList from './provider-list' +import IntegrationSectionRenderer from './integration-section-renderer' -const INTEGRATION_SECTION_VALUES = [ - 'provider', - 'builtin', - 'mcp', - 'custom-tool', - 'workflow-tool', - 'data-source', - 'api-based-extension', -] as const - -type IntegrationSection = typeof INTEGRATION_SECTION_VALUES[number] - -const TOOL_CATEGORY_VALUES = ['builtin', 'api', 'workflow', 'mcp'] as const -type ToolCategory = typeof TOOL_CATEGORY_VALUES[number] type IconComponent = typeof DatasourceIcon const parseAsIntegrationSection = parseAsStringLiteral(INTEGRATION_SECTION_VALUES) const parseAsToolCategory = parseAsStringLiteral(TOOL_CATEGORY_VALUES) -const toolCategoryBySection: Partial> = { - 'builtin': 'builtin', - 'mcp': 'mcp', - 'custom-tool': 'api', - 'workflow-tool': 'workflow', -} -const sectionByToolCategory: Record = { - builtin: 'builtin', - api: 'custom-tool', - workflow: 'workflow-tool', - mcp: 'mcp', -} - type NavItem = { activeIcon?: IconComponent | string disabled?: boolean @@ -58,12 +36,7 @@ const inactiveNavItemClassName = 'text-components-menu-item-text hover:bg-state- const disabledNavItemClassName = 'cursor-not-allowed text-components-menu-item-text-disabled' const buildSectionHref = (section: IntegrationSection) => { - const category = toolCategoryBySection[section] - const params = new URLSearchParams({ section }) - if (category) - params.set('category', category) - - return `/tools?${params.toString()}` + return buildIntegrationPath(section) } type NavLinkItemProps = { @@ -125,12 +98,18 @@ const NavLinkItem = ({ collapsed, item, section }: NavLinkItemProps) => { ) } -export default function IntegrationsPage() { +type IntegrationsPageProps = { + section?: IntegrationSection +} + +export default function IntegrationsPage({ + section: routeSection, +}: IntegrationsPageProps) { const { t } = useTranslation() const [sectionParam] = useQueryState('section', parseAsIntegrationSection) const [categoryParam] = useQueryState('category', parseAsToolCategory) - const section = sectionParam ?? (categoryParam ? sectionByToolCategory[categoryParam] : 'provider') - const [providerSearchText, setProviderSearchText] = useState('') + const section = routeSection ?? sectionParam ?? (categoryParam ? sectionByToolCategory[categoryParam] : 'provider') + const providerSearchText = '' const [sidebarCollapsed, setSidebarCollapsed] = useState(false) const providerItem = useMemo(() => ({ section: 'provider', @@ -147,13 +126,13 @@ export default function IntegrationsPage() { }, { section: 'custom-tool', - label: t('settings.customTool', { ns: 'common' }), + label: t('settings.swaggerAPIAsTool', { ns: 'common' }), icon: 'i-custom-vender-integrations-custom-tool', iconClassName: 'h-[14.5px] w-[12.5px]', }, { section: 'workflow-tool', - label: t('type.workflow', { ns: 'tools' }), + label: t('common.workflowAsTool', { ns: 'workflow' }), icon: 'i-custom-vender-integrations-workflow-as-tool', iconClassName: 'h-3 w-[12.5px]', }, @@ -172,26 +151,28 @@ export default function IntegrationsPage() { iconClassName: 'size-4', }, { + section: 'trigger', label: t('settings.trigger', { ns: 'common' }), icon: 'i-custom-vender-integrations-trigger', iconClassName: 'h-[13.5px] w-[13.5px]', - disabled: true, }, { + section: 'agent-strategy', label: t('settings.agentStrategy', { ns: 'common' }), icon: 'i-custom-vender-integrations-agent-strategy', iconClassName: 'h-[14.5px] w-[15.5px]', - disabled: true, }, { + section: 'extension', label: t('settings.extension', { ns: 'common' }), icon: 'i-custom-vender-integrations-extension', iconClassName: 'h-[13.5px] w-3', - disabled: true, }, ], [t]) const activeItem = [providerItem, ...toolItems, ...secondaryItems].find(item => item.section === section) const isToolSection = Boolean(toolCategoryBySection[section]) + const isPluginCategorySection = section === 'trigger' || section === 'agent-strategy' || section === 'extension' + const useFillLayout = isToolSection || isPluginCategorySection return (
@@ -342,26 +323,15 @@ export default function IntegrationsPage() {
)} -
- {section === 'provider' && ( -
- -
- )} - {section === 'data-source' && ( -
- -
- )} - {section === 'api-based-extension' && ( -
- -
- )} - {isToolSection && } +
+
diff --git a/web/app/components/tools/plugin-category-page.tsx b/web/app/components/tools/plugin-category-page.tsx new file mode 100644 index 0000000000..2323ce8ad0 --- /dev/null +++ b/web/app/components/tools/plugin-category-page.tsx @@ -0,0 +1,30 @@ +'use client' + +import type { PluginCategoryEnum } from '@/app/components/plugins/types' +import { useMemo } from 'react' +import { PluginPageContextProvider } from '@/app/components/plugins/plugin-page/context-provider' +import PluginsPanel from '@/app/components/plugins/plugin-page/plugins-panel' + +type PluginCategoryPageProps = { + category: PluginCategoryEnum +} + +const PluginCategoryPage = ({ + category, +}: PluginCategoryPageProps) => { + const initialFilters = useMemo(() => ({ + categories: [category], + tags: [], + searchQuery: '', + }), [category]) + + return ( + +
+ +
+
+ ) +} + +export default PluginCategoryPage diff --git a/web/app/components/tools/provider-list.tsx b/web/app/components/tools/provider-list.tsx index 74fc47a833..e4210f94b2 100644 --- a/web/app/components/tools/provider-list.tsx +++ b/web/app/components/tools/provider-list.tsx @@ -1,5 +1,6 @@ 'use client' import type { Collection } from './types' +import type { ToolCategory } from '@/app/components/tools/integration-routes' import { cn } from '@langgenius/dify-ui/cn' import { useSuspenseQuery } from '@tanstack/react-query' import { parseAsStringLiteral, useQueryState } from 'nuqs' @@ -12,10 +13,12 @@ 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 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' @@ -24,21 +27,26 @@ import { useMarketplace } from './marketplace/hooks' import MCPList from './mcp' import { getToolType } from './utils' -const TOOL_PROVIDER_CATEGORY_VALUES = ['builtin', 'api', 'workflow', 'mcp'] as const -type ToolProviderCategory = typeof TOOL_PROVIDER_CATEGORY_VALUES[number] -const toolProviderCategorySet = new Set(TOOL_PROVIDER_CATEGORY_VALUES) +const toolProviderCategorySet = new Set(TOOL_CATEGORY_VALUES) -const isToolProviderCategory = (value: string): value is ToolProviderCategory => { +const isToolProviderCategory = (value: string): value is ToolCategory => { return toolProviderCategorySet.has(value) } -const parseAsToolProviderCategory = parseAsStringLiteral(TOOL_PROVIDER_CATEGORY_VALUES) +const parseAsToolProviderCategory = parseAsStringLiteral(TOOL_CATEGORY_VALUES) .withDefault('builtin') -const ProviderList = () => { +type ProviderListProps = { + category?: ToolCategory +} + +const ProviderList = ({ + category, +}: ProviderListProps) => { // const searchParams = useSearchParams() // searchParams.get('category') === 'workflow' const { t } = useTranslation() + const router = useRouter() const { getTagLabel } = useTags() const { data: enable_marketplace } = useSuspenseQuery({ ...systemFeaturesQueryOptions(), @@ -46,7 +54,8 @@ const ProviderList = () => { }) const containerRef = useRef(null) - const [activeTab, setActiveTab] = useQueryState('category', parseAsToolProviderCategory) + const [categoryParam, setCategoryParam] = useQueryState('category', parseAsToolProviderCategory) + const activeTab = category ?? categoryParam const options = [ { value: 'builtin', text: t('type.builtIn', { ns: 'tools' }) }, { value: 'api', text: t('type.custom', { ns: 'tools' }) }, @@ -139,7 +148,11 @@ const ProviderList = () => { onChange={(state) => { if (!isToolProviderCategory(state)) return - setActiveTab(state) + if (category) + router.push(buildIntegrationPath(sectionByToolCategory[state])) + else + setCategoryParam(state) + if (state !== activeTab) setCurrentProviderId(undefined) }} diff --git a/web/app/components/tools/provider/__tests__/empty.spec.tsx b/web/app/components/tools/provider/__tests__/empty.spec.tsx index 7484f99895..0a4e852e7f 100644 --- a/web/app/components/tools/provider/__tests__/empty.spec.tsx +++ b/web/app/components/tools/provider/__tests__/empty.spec.tsx @@ -42,18 +42,18 @@ describe('Empty', () => { // Tests for different type prop values describe('Type Props', () => { - it('should render with Custom type and include link to /tools?category=api', () => { + it('should render with Custom type and include link to Swagger API as Tool', () => { render() - const link = document.querySelector('a[href="/tools?category=api"]') + const link = document.querySelector('a[href="/integrations/tools/swagger-api"]') expect(link).toBeInTheDocument() expect(link).toHaveAttribute('target', '_blank') }) - it('should render with MCP type and include link to /tools?category=mcp', () => { + it('should render with MCP type and include link to the MCP route', () => { render() - const link = document.querySelector('a[href="/tools?category=mcp"]') + const link = document.querySelector('a[href="/integrations/tools/mcp"]') expect(link).toBeInTheDocument() expect(link).toHaveAttribute('target', '_blank') }) diff --git a/web/app/components/tools/provider/empty.tsx b/web/app/components/tools/provider/empty.tsx index ca64cf0da0..e74beb5c63 100644 --- a/web/app/components/tools/provider/empty.tsx +++ b/web/app/components/tools/provider/empty.tsx @@ -2,6 +2,7 @@ import { cn } from '@langgenius/dify-ui/cn' import { RiArrowRightUpLine } from '@remixicon/react' import { useTranslation } from 'react-i18next' +import { buildIntegrationPath } from '@/app/components/tools/integration-routes' import useTheme from '@/hooks/use-theme' import Link from '@/next/link' import { NoToolPlaceholder } from '../../base/icons/src/vender/other' @@ -15,11 +16,11 @@ type Props = { const getLink = (type?: ToolTypeEnum) => { switch (type) { case ToolTypeEnum.Custom: - return '/tools?category=api' + return buildIntegrationPath('custom-tool') case ToolTypeEnum.MCP: - return '/tools?category=mcp' + return buildIntegrationPath('mcp') default: - return '/tools?category=api' + return buildIntegrationPath('custom-tool') } } const Empty = ({ diff --git a/web/app/components/tools/workflow-tool/__tests__/configure-button.spec.tsx b/web/app/components/tools/workflow-tool/__tests__/configure-button.spec.tsx index d8babd2955..27f6788ce7 100644 --- a/web/app/components/tools/workflow-tool/__tests__/configure-button.spec.tsx +++ b/web/app/components/tools/workflow-tool/__tests__/configure-button.spec.tsx @@ -399,7 +399,7 @@ describe('WorkflowToolConfigureButton', () => { await user.click(screen.getByText('workflow.common.manageInTools')) // Assert - expect(mockPush).toHaveBeenCalledWith('/tools?category=workflow') + expect(mockPush).toHaveBeenCalledWith('/integrations/tools/workflow') }) }) diff --git a/web/app/components/tools/workflow-tool/configure-button.tsx b/web/app/components/tools/workflow-tool/configure-button.tsx index 370154aa57..04a39046f8 100644 --- a/web/app/components/tools/workflow-tool/configure-button.tsx +++ b/web/app/components/tools/workflow-tool/configure-button.tsx @@ -4,6 +4,7 @@ import { cn } from '@langgenius/dify-ui/cn' import { useTranslation } from 'react-i18next' import Loading from '@/app/components/base/loading' import Indicator from '@/app/components/header/indicator' +import { buildIntegrationPath } from '@/app/components/tools/integration-routes' import { useRouter } from '@/next/navigation' import Divider from '../../base/divider' @@ -95,7 +96,7 @@ const WorkflowToolConfigureButton = ({