From 762321751ca30d628a7aaa5696f2d9de414fa545 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Thu, 18 Jun 2026 14:47:22 +0800 Subject: [PATCH] refactor(web): centralize main nav route access (#37612) --- .../__tests__/role-route-guard.spec.tsx | 32 +++++- web/app/(commonLayout)/role-route-guard.tsx | 2 +- web/app/components/main-nav/index.tsx | 83 +++----------- web/app/components/main-nav/routes.ts | 108 ++++++++++++++++++ 4 files changed, 154 insertions(+), 71 deletions(-) create mode 100644 web/app/components/main-nav/routes.ts diff --git a/web/app/(commonLayout)/__tests__/role-route-guard.spec.tsx b/web/app/(commonLayout)/__tests__/role-route-guard.spec.tsx index f26723d0199..761d8c1c8bf 100644 --- a/web/app/(commonLayout)/__tests__/role-route-guard.spec.tsx +++ b/web/app/(commonLayout)/__tests__/role-route-guard.spec.tsx @@ -47,14 +47,14 @@ vi.mock('@/service/client', () => ({ const mockUseQuery = vi.mocked(useQuery) -function renderGuard(children: ReactNode) { +function renderGuard(children: ReactNode, systemFeatures: { enable_app_deploy?: boolean } = {}) { return renderWithSystemFeatures( {children} , { systemFeatures: { - enable_app_deploy: true, + enable_app_deploy: systemFeatures.enable_app_deploy ?? true, }, }, ) @@ -95,6 +95,16 @@ describe('RoleRouteGuard', () => { expect(mocks.redirect).toHaveBeenCalledWith('/datasets') }) + it('should allow dataset operator on routes outside the guarded list', () => { + mockPathname = '/new-route' + setCurrentWorkspaceQuery({ role: 'dataset_operator' }) + + renderGuard(
content
) + + expect(screen.getByText('content')).toBeInTheDocument() + expect(mocks.redirect).not.toHaveBeenCalled() + }) + it('should redirect dataset operator on deployments routes', () => { mockPathname = '/deployments/create' setCurrentWorkspaceQuery({ role: 'dataset_operator' }) @@ -104,6 +114,24 @@ describe('RoleRouteGuard', () => { expect(mocks.redirect).toHaveBeenCalledWith('/datasets') }) + it('should prefer app deploy redirect when app deploy is disabled', () => { + mockPathname = '/deployments/create' + setCurrentWorkspaceQuery({ role: 'dataset_operator' }) + + expect(() => renderGuard(
content
, { enable_app_deploy: false })).toThrow('NEXT_REDIRECT:/') + + expect(mocks.redirect).toHaveBeenCalledWith('/') + }) + + it('should redirect app deploy routes when app deploy is disabled', () => { + mockPathname = '/deployments/create' + setCurrentWorkspaceQuery({ role: 'editor' }) + + expect(() => renderGuard(
content
, { enable_app_deploy: false })).toThrow('NEXT_REDIRECT:/') + + expect(mocks.redirect).toHaveBeenCalledWith('/') + }) + it('should allow dataset operator on non-guarded routes', () => { mockPathname = '/plugins' setCurrentWorkspaceQuery({ role: 'dataset_operator' }) diff --git a/web/app/(commonLayout)/role-route-guard.tsx b/web/app/(commonLayout)/role-route-guard.tsx index 0fee1d72f08..fc81c091716 100644 --- a/web/app/(commonLayout)/role-route-guard.tsx +++ b/web/app/(commonLayout)/role-route-guard.tsx @@ -30,7 +30,7 @@ export function RoleRouteGuard({ children }: { children: ReactNode }) { return if (shouldRedirectAppDeploy) - redirect('/apps') + redirect('/') if (shouldRedirectDatasetOperator) redirect('/datasets') diff --git a/web/app/components/main-nav/index.tsx b/web/app/components/main-nav/index.tsx index 07d66c3a2d8..aa1495b08cc 100644 --- a/web/app/components/main-nav/index.tsx +++ b/web/app/components/main-nav/index.tsx @@ -15,7 +15,6 @@ import DatasetDetailTop from '@/app/components/app-sidebar/dataset-detail-top' import { useStore as useAppStore } from '@/app/components/app/store' import DifyLogo from '@/app/components/base/logo/dify-logo' import EnvNav from '@/app/components/header/env-nav' -import { buildIntegrationPath } from '@/app/components/integrations/routes' import { useAppContext } from '@/context/app-context' import { AgentDetailSection, AgentDetailTop } from '@/features/agent-v2/agent-detail/navigation' import { isAgentV2Enabled } from '@/features/agent-v2/feature-flag' @@ -29,6 +28,7 @@ import MainNavLink from './components/nav-link' import { MainNavSearchButton } from './components/search-button' import WebAppsSection from './components/web-apps-section' import { WorkspaceCard } from './components/workspace-card' +import { isMainNavRouteVisible, MAIN_NAV_ROUTES } from './routes' const DATASET_COLLECTION_ROUTES = new Set(['create', 'create-from-pipeline', 'connect']) const DATASET_DOCUMENT_CREATION_ROUTES = new Set(['create', 'create-from-pipeline']) @@ -185,73 +185,20 @@ const MainNav = ({ ignoreInputs: false, }) - const navItems = useMemo(() => [ - ...(!isCurrentWorkspaceDatasetOperator - ? [ - { - href: '/', - label: t('mainNav.home', { ns: 'common' }), - active: (path: string) => path === '/' || path === '/explore/apps', - icon: 'i-custom-vender-main-nav-home', - activeIcon: 'i-custom-vender-main-nav-home-active', - }, - { - href: '/apps', - label: t('menus.apps', { ns: 'common' }), - active: (path: string) => path.startsWith('/apps') || path.startsWith('/app/') || path.startsWith('/snippets'), - icon: 'i-custom-vender-main-nav-studio', - activeIcon: 'i-custom-vender-main-nav-studio-active', - }, - ...(agentV2Enabled - ? [{ - href: '/roster', - label: t('menus.roster', { ns: 'common' }), - active: (path: string) => path.startsWith('/roster'), - icon: 'i-custom-vender-main-nav-roster', - activeIcon: 'i-custom-vender-main-nav-roster-active', - }] - : []), - ] - : []), - ...((isCurrentWorkspaceEditor || isCurrentWorkspaceDatasetOperator) - ? [ - { - href: '/datasets', - label: t('menus.datasets', { ns: 'common' }), - active: (path: string) => path.startsWith('/datasets'), - icon: 'i-custom-vender-main-nav-knowledge', - activeIcon: 'i-custom-vender-main-nav-knowledge-active', - }, - ] - : []), - ...(!isCurrentWorkspaceDatasetOperator - ? [ - { - href: buildIntegrationPath('provider'), - label: t('mainNav.integrations', { ns: 'common' }), - active: (path: string) => path.startsWith('/integrations') || path.startsWith('/tools'), - icon: 'i-custom-vender-main-nav-integrations', - activeIcon: 'i-custom-vender-main-nav-integrations-active', - }, - ] - : []), - { - href: '/marketplace', - label: t('mainNav.marketplace', { ns: 'common' }), - active: path => path.startsWith('/marketplace') || path.startsWith('/plugins'), - icon: 'i-custom-vender-main-nav-marketplace', - activeIcon: 'i-custom-vender-main-nav-marketplace-active', - }, - ...(canUseAppDeploy - ? [{ - href: '/deployments', - label: t('menus.deployments', { ns: 'common' }), - active: (path: string) => path.startsWith('/deployments'), - icon: 'i-ri-rocket-line', - activeIcon: 'i-ri-rocket-fill', - }] - : []), - ], [agentV2Enabled, canUseAppDeploy, isCurrentWorkspaceDatasetOperator, isCurrentWorkspaceEditor, t]) + const navItems = useMemo(() => MAIN_NAV_ROUTES + .filter(route => isMainNavRouteVisible(route, { + agentV2Enabled, + canUseAppDeploy, + isCurrentWorkspaceDatasetOperator, + isCurrentWorkspaceEditor, + })) + .map(route => ({ + href: route.href, + label: t(route.labelKey, { ns: 'common' }), + active: route.active, + icon: route.icon, + activeIcon: route.activeIcon, + })), [agentV2Enabled, canUseAppDeploy, isCurrentWorkspaceDatasetOperator, isCurrentWorkspaceEditor, t]) const renderLogo = () => { const appTitle = systemFeatures.branding.enabled && systemFeatures.branding.application_title ? systemFeatures.branding.application_title : 'Dify' diff --git a/web/app/components/main-nav/routes.ts b/web/app/components/main-nav/routes.ts new file mode 100644 index 00000000000..e63419cf226 --- /dev/null +++ b/web/app/components/main-nav/routes.ts @@ -0,0 +1,108 @@ +import { buildIntegrationPath } from '@/app/components/integrations/routes' + +type MainNavRouteVisibility = 'all' | 'notDatasetOperator' | 'editorOrDatasetOperator' | 'appDeployEditor' + +export type MainNavRouteConfig = { + key: string + href: string + labelKey: string + active: (pathname: string) => boolean + icon: string + activeIcon: string + visibility: MainNavRouteVisibility + feature?: 'agentV2' +} + +export type MainNavRouteVisibilityOptions = { + agentV2Enabled: boolean + canUseAppDeploy: boolean + isCurrentWorkspaceDatasetOperator: boolean + isCurrentWorkspaceEditor: boolean +} + +function isPathUnderRoute(pathname: string, route: string) { + return pathname === route || pathname.startsWith(`${route}/`) +} + +export const MAIN_NAV_ROUTES = [ + { + key: 'home', + href: '/', + labelKey: 'mainNav.home', + active: (path: string) => path === '/' || path === '/explore/apps', + icon: 'i-custom-vender-main-nav-home', + activeIcon: 'i-custom-vender-main-nav-home-active', + visibility: 'notDatasetOperator', + }, + { + key: 'apps', + href: '/apps', + labelKey: 'menus.apps', + active: (path: string) => isPathUnderRoute(path, '/apps') || isPathUnderRoute(path, '/app') || isPathUnderRoute(path, '/snippets'), + icon: 'i-custom-vender-main-nav-studio', + activeIcon: 'i-custom-vender-main-nav-studio-active', + visibility: 'notDatasetOperator', + }, + { + key: 'roster', + href: '/roster', + labelKey: 'menus.roster', + active: (path: string) => isPathUnderRoute(path, '/roster'), + icon: 'i-custom-vender-main-nav-roster', + activeIcon: 'i-custom-vender-main-nav-roster-active', + visibility: 'notDatasetOperator', + feature: 'agentV2', + }, + { + key: 'datasets', + href: '/datasets', + labelKey: 'menus.datasets', + active: (path: string) => isPathUnderRoute(path, '/datasets'), + icon: 'i-custom-vender-main-nav-knowledge', + activeIcon: 'i-custom-vender-main-nav-knowledge-active', + visibility: 'editorOrDatasetOperator', + }, + { + key: 'integrations', + href: buildIntegrationPath('provider'), + labelKey: 'mainNav.integrations', + active: (path: string) => isPathUnderRoute(path, '/integrations') || isPathUnderRoute(path, '/tools'), + icon: 'i-custom-vender-main-nav-integrations', + activeIcon: 'i-custom-vender-main-nav-integrations-active', + visibility: 'notDatasetOperator', + }, + { + key: 'marketplace', + href: '/marketplace', + labelKey: 'mainNav.marketplace', + active: (path: string) => isPathUnderRoute(path, '/marketplace') || isPathUnderRoute(path, '/plugins'), + icon: 'i-custom-vender-main-nav-marketplace', + activeIcon: 'i-custom-vender-main-nav-marketplace-active', + visibility: 'all', + }, + { + key: 'deployments', + href: '/deployments', + labelKey: 'menus.deployments', + active: (path: string) => isPathUnderRoute(path, '/deployments'), + icon: 'i-ri-rocket-line', + activeIcon: 'i-ri-rocket-fill', + visibility: 'appDeployEditor', + }, +] as const satisfies readonly MainNavRouteConfig[] + +export function isMainNavRouteVisible(route: MainNavRouteConfig, options: MainNavRouteVisibilityOptions) { + if (route.feature === 'agentV2' && !options.agentV2Enabled) + return false + + if (route.visibility === 'all') + return true + + if (route.visibility === 'notDatasetOperator') + return !options.isCurrentWorkspaceDatasetOperator + + if (route.visibility === 'editorOrDatasetOperator') + return options.isCurrentWorkspaceEditor || options.isCurrentWorkspaceDatasetOperator + + return options.canUseAppDeploy +}