diff --git a/web/app/(commonLayout)/__tests__/role-route-guard.spec.tsx b/web/app/(commonLayout)/__tests__/role-route-guard.spec.tsx deleted file mode 100644 index 8fa969fa899..00000000000 --- a/web/app/(commonLayout)/__tests__/role-route-guard.spec.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import type { ReactNode } from 'react' -import { useQuery } from '@tanstack/react-query' -import { screen } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' -import { RoleRouteGuard } from '../role-route-guard' - -const mocks = vi.hoisted(() => ({ - redirect: vi.fn((url: string) => { - throw new Error(`NEXT_REDIRECT:${url}`) - }), - currentWorkspaceQueryOptions: vi.fn(() => ({ queryKey: ['console', 'workspaces', 'current', 'post'] })), - systemFeaturesQueryKey: vi.fn(() => ['console', 'systemFeatures', 'get']), -})) - -let mockPathname = '/apps' - -vi.mock('@/next/navigation', () => ({ - redirect: (url: string) => mocks.redirect(url), - usePathname: () => mockPathname, -})) - -vi.mock('@tanstack/react-query', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useQuery: vi.fn(), - } -}) - -vi.mock('@/service/client', () => ({ - consoleQuery: { - systemFeatures: { - get: { - queryKey: mocks.systemFeaturesQueryKey, - }, - }, - workspaces: { - current: { - post: { - queryOptions: mocks.currentWorkspaceQueryOptions, - }, - }, - }, - }, -})) - -const mockUseQuery = vi.mocked(useQuery) - -function renderGuard(children: ReactNode) { - return renderWithSystemFeatures( - - {children} - , - { - systemFeatures: { - enable_app_deploy: true, - }, - }, - ) -} - -const setCurrentWorkspaceQuery = (overrides: { role?: string, isPending?: boolean } = {}) => { - mockUseQuery.mockReturnValue({ - data: overrides.role, - isPending: overrides.isPending ?? false, - } as ReturnType) -} - -describe('RoleRouteGuard', () => { - beforeEach(() => { - vi.clearAllMocks() - mockPathname = '/app/app-1' - setCurrentWorkspaceQuery() - }) - - it.each(['/', '/apps', '/app/app-1', '/deployments/create', '/snippets', '/explore/apps', '/tools', '/integrations', '/datasets'])('should allow %s without workspace role checks', (pathname) => { - mockPathname = pathname - - renderGuard(
content
) - - expect(screen.getByText('content')).toBeInTheDocument() - expect(mocks.redirect).not.toHaveBeenCalled() - expect(mockUseQuery).not.toHaveBeenCalled() - expect(mocks.currentWorkspaceQueryOptions).not.toHaveBeenCalled() - }) - - it('should redirect deployments route when app deploy is disabled', () => { - mockPathname = '/deployments/create' - - expect(() => renderWithSystemFeatures( - -
content
-
, - { - systemFeatures: { - enable_app_deploy: false, - }, - }, - )).toThrow('NEXT_REDIRECT:/apps') - - expect(mocks.redirect).toHaveBeenCalledWith('/apps') - expect(mockUseQuery).not.toHaveBeenCalled() - expect(mocks.currentWorkspaceQueryOptions).not.toHaveBeenCalled() - }) -}) diff --git a/web/app/(commonLayout)/deployments/__tests__/layout.spec.tsx b/web/app/(commonLayout)/deployments/__tests__/layout.spec.tsx new file mode 100644 index 00000000000..2b86761699e --- /dev/null +++ b/web/app/(commonLayout)/deployments/__tests__/layout.spec.tsx @@ -0,0 +1,61 @@ +import type { ReactElement, ReactNode } from 'react' +import { render, screen } from '@testing-library/react' + +const mocks = vi.hoisted(() => ({ + ensureQueryData: vi.fn(), + systemFeaturesQueryOptions: { queryKey: ['console', 'system-features'] }, + notFound: vi.fn(() => { + throw new Error('NEXT_NOT_FOUND') + }), +})) + +vi.mock('@/context/query-client-server', () => ({ + getQueryClientServer: () => ({ + ensureQueryData: mocks.ensureQueryData, + }), +})) + +vi.mock('@/features/system-features/server', () => ({ + serverSystemFeaturesQueryOptions: () => mocks.systemFeaturesQueryOptions, +})) + +vi.mock('@/features/deployments/deploy-drawer', () => ({ + DeployDrawer: () =>
Deploy drawer
, +})) + +vi.mock('@/next/navigation', () => ({ + notFound: () => mocks.notFound(), +})) + +const renderDeploymentsLayout = async (children: ReactNode) => { + const { default: DeploymentsLayout } = await import('../layout') + const element = await DeploymentsLayout({ children }) + render(element as ReactElement) +} + +describe('DeploymentsLayout', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.ensureQueryData.mockResolvedValue({ enable_app_deploy: true }) + }) + + it('should render deployments content and drawer when app deploy is enabled', async () => { + await renderDeploymentsLayout(
Deployments content
) + + expect(mocks.ensureQueryData).toHaveBeenCalledWith(mocks.systemFeaturesQueryOptions) + expect(screen.getByText('Deployments content')).toBeInTheDocument() + expect(screen.getByText('Deploy drawer')).toBeInTheDocument() + expect(mocks.notFound).not.toHaveBeenCalled() + }) + + it('should trigger notFound when app deploy is disabled', async () => { + mocks.ensureQueryData.mockResolvedValue({ enable_app_deploy: false }) + const { default: DeploymentsLayout } = await import('../layout') + + await expect(DeploymentsLayout({ + children:
Deployments content
, + })).rejects.toThrow('NEXT_NOT_FOUND') + + expect(mocks.notFound).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/app/(commonLayout)/deployments/layout.tsx b/web/app/(commonLayout)/deployments/layout.tsx index eb522444778..f1ab03c323e 100644 --- a/web/app/(commonLayout)/deployments/layout.tsx +++ b/web/app/(commonLayout)/deployments/layout.tsx @@ -1,9 +1,17 @@ import type { ReactNode } from 'react' +import { getQueryClientServer } from '@/context/query-client-server' import { DeployDrawer } from '@/features/deployments/deploy-drawer' +import { serverSystemFeaturesQueryOptions } from '@/features/system-features/server' +import { notFound } from '@/next/navigation' -export default function DeploymentsLayout({ children }: { +export default async function DeploymentsLayout({ children }: { children: ReactNode }) { + const systemFeatures = await getQueryClientServer().ensureQueryData(serverSystemFeaturesQueryOptions()) + + if (!systemFeatures.enable_app_deploy) + notFound() + return ( <> {children} diff --git a/web/app/(commonLayout)/layout.tsx b/web/app/(commonLayout)/layout.tsx index 71baf83c795..69a72bdb3ee 100644 --- a/web/app/(commonLayout)/layout.tsx +++ b/web/app/(commonLayout)/layout.tsx @@ -1,5 +1,4 @@ import type { ReactNode } from 'react' -import * as React from 'react' import InSiteMessageNotification from '@/app/components/app/in-site-message/notification' import AmplitudeProvider from '@/app/components/base/amplitude' import { GoogleAnalyticsScripts } from '@/app/components/base/ga' @@ -17,7 +16,6 @@ import { ModalContextProvider } from '@/context/modal-context-provider' import { ProviderContextProvider } from '@/context/provider-context-provider' import PartnerStack from '../components/billing/partner-stack' import { CommonLayoutHydrationBoundary } from './hydration-boundary' -import { RoleRouteGuard } from './role-route-guard' export default async function Layout({ children }: { children: ReactNode }) { return ( @@ -33,9 +31,7 @@ export default async function Layout({ children }: { children: ReactNode }) { - - {children} - + {children} diff --git a/web/app/(commonLayout)/role-route-guard.tsx b/web/app/(commonLayout)/role-route-guard.tsx deleted file mode 100644 index 90526cbb3ca..00000000000 --- a/web/app/(commonLayout)/role-route-guard.tsx +++ /dev/null @@ -1,21 +0,0 @@ -'use client' - -import type { ReactNode } from 'react' -import { useSuspenseQuery } from '@tanstack/react-query' -import { systemFeaturesQueryOptions } from '@/features/system-features/client' -import { redirect, usePathname } from '@/next/navigation' - -function isPathUnderRoute(pathname: string, route: string) { - return pathname === route || pathname.startsWith(`${route}/`) -} - -export function RoleRouteGuard({ children }: { children: ReactNode }) { - const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) - const pathname = usePathname() - const shouldRedirectAppDeploy = isPathUnderRoute(pathname, '/deployments') && !systemFeatures.enable_app_deploy - - if (shouldRedirectAppDeploy) - redirect('/apps') - - return <>{children} -} diff --git a/web/app/components/main-nav/__tests__/index.spec.tsx b/web/app/components/main-nav/__tests__/index.spec.tsx index 9e528f6d1cc..6082f4a4acd 100644 --- a/web/app/components/main-nav/__tests__/index.spec.tsx +++ b/web/app/components/main-nav/__tests__/index.spec.tsx @@ -20,7 +20,7 @@ import { usePathname, useRouter } from '@/next/navigation' import { consoleQuery } from '@/service/client' import { useGetInstalledApps, useUninstallApp, useUpdateAppPinStatus } from '@/service/use-explore' import { AppModeEnum } from '@/types/app' -import MainNav from '../index' +import { MainNav } from '../index' import { DETAIL_SIDEBAR_STORAGE_KEY } from '../storage' const activeGradientMaskClassName = 'aria-[current=page]:dify-blue-glass-surface' @@ -608,7 +608,7 @@ describe('MainNav', () => { expect(screen.getByTestId('app-detail-section')).toBeInTheDocument() expect(screen.getByTestId('app-detail-top')).toHaveAttribute('data-expand', 'true') expect(screen.getByTestId('app-detail-section')).toHaveAttribute('data-expand', 'true') - expect(screen.getByRole('complementary')).toHaveClass('w-[248px]') + expect(screen.getByRole('complementary')).toHaveClass('w-62') expect(screen.getByRole('complementary')).toHaveClass('p-1') expect(screen.getByRole('complementary')).toHaveClass('bg-background-body') expect(screen.queryByRole('button', { name: 'common.mainNav.workspace.openMenu' })).not.toBeInTheDocument() @@ -673,7 +673,7 @@ describe('MainNav', () => { fireEvent.mouseEnter(screen.getByTestId('app-detail-top').parentElement!) fireEvent.click(screen.getByTestId('app-detail-toggle')) - expect(screen.getByRole('complementary')).toHaveClass('w-[248px]', 'transition-none') + expect(screen.getByRole('complementary')).toHaveClass('w-62', 'transition-none') expect(screen.getByRole('complementary')).not.toHaveClass('overflow-visible') expect(screen.getByTestId('app-detail-top')).toHaveAttribute('data-expand', 'true') expect(screen.getByTestId('app-detail-section')).toHaveAttribute('data-expand', 'true') @@ -689,7 +689,7 @@ describe('MainNav', () => { expect(screen.getByTestId('dataset-detail-section')).toBeInTheDocument() expect(screen.getByTestId('dataset-detail-top')).toHaveAttribute('data-expand', 'true') expect(screen.getByTestId('dataset-detail-section')).toHaveAttribute('data-expand', 'true') - expect(screen.getByRole('complementary')).toHaveClass('w-[248px]') + expect(screen.getByRole('complementary')).toHaveClass('w-62') expect(screen.getByRole('complementary')).toHaveClass('p-1') expect(screen.getByRole('complementary')).toHaveClass('bg-background-body') expect(screen.queryByRole('button', { name: 'common.mainNav.workspace.openMenu' })).not.toBeInTheDocument() @@ -733,7 +733,7 @@ describe('MainNav', () => { expect(screen.getByTestId('agent-detail-section')).toBeInTheDocument() expect(screen.getByTestId('agent-detail-top')).toHaveAttribute('data-expand', 'true') expect(screen.getByTestId('agent-detail-section')).toHaveAttribute('data-expand', 'true') - expect(screen.getByRole('complementary')).toHaveClass('w-[248px]') + expect(screen.getByRole('complementary')).toHaveClass('w-62') expect(screen.getByRole('complementary')).toHaveClass('p-1') expect(screen.getByRole('complementary')).toHaveClass('bg-background-body') expect(screen.queryByRole('button', { name: 'common.mainNav.workspace.openMenu' })).not.toBeInTheDocument() @@ -760,7 +760,7 @@ describe('MainNav', () => { expect(screen.getByTestId('deployment-detail-section')).toBeInTheDocument() expect(screen.getByTestId('deployment-detail-top')).toHaveAttribute('data-expand', 'true') expect(screen.getByTestId('deployment-detail-section')).toHaveAttribute('data-expand', 'true') - expect(screen.getByRole('complementary')).toHaveClass('w-[248px]') + expect(screen.getByRole('complementary')).toHaveClass('w-62') expect(screen.getByRole('complementary')).toHaveClass('p-1') expect(screen.getByRole('complementary')).toHaveClass('bg-background-body') expect(screen.queryByRole('button', { name: 'common.mainNav.workspace.openMenu' })).not.toBeInTheDocument() diff --git a/web/app/components/main-nav/__tests__/layout.spec.tsx b/web/app/components/main-nav/__tests__/layout.spec.tsx index 02d2da91c90..3e22a3ff190 100644 --- a/web/app/components/main-nav/__tests__/layout.spec.tsx +++ b/web/app/components/main-nav/__tests__/layout.spec.tsx @@ -17,7 +17,7 @@ vi.mock('react-i18next', () => ({ })) vi.mock('../index', () => ({ - default: ({ className }: { className?: string }) => , + MainNav: ({ className }: { className?: string }) => , })) describe('MainNavLayout', () => { diff --git a/web/app/components/main-nav/index.tsx b/web/app/components/main-nav/index.tsx index fd304813280..4dcb09b835d 100644 --- a/web/app/components/main-nav/index.tsx +++ b/web/app/components/main-nav/index.tsx @@ -82,9 +82,9 @@ const isSnippetDetailPathname = (pathname: string) => { return section === 'snippets' && !!snippetId } -const MainNav = ({ +export function MainNav({ className, -}: MainNavProps) => { +}: MainNavProps) { const { t } = useTranslation() const pathname = usePathname() const { langGeniusVersionInfo, isCurrentWorkspaceDatasetOperator, isCurrentWorkspaceEditor } = useAppContext() @@ -222,7 +222,7 @@ const MainNav = ({ isDetailNavigationHoverPreviewOpen ? 'overflow-visible' : 'overflow-hidden', showDetailNavigation ? detailNavigationExpanded - ? 'w-[248px] bg-background-body p-1' + ? 'w-62 bg-background-body p-1' : 'w-16 bg-background-body p-1' : showSnippetDetailBottomNavigation ? 'w-16 bg-background-body p-1' @@ -356,5 +356,3 @@ const MainNav = ({ ) } - -export default MainNav diff --git a/web/app/components/main-nav/layout.tsx b/web/app/components/main-nav/layout.tsx index 44730fe7468..e1688b860e1 100644 --- a/web/app/components/main-nav/layout.tsx +++ b/web/app/components/main-nav/layout.tsx @@ -2,7 +2,7 @@ import type { ReactNode } from 'react' import { useTranslation } from 'react-i18next' -import MainNav from './index' +import { MainNav } from '.' import { MAIN_CONTENT_ID, SkipNav } from './skip-nav' type MainNavLayoutProps = {