diff --git a/web/app/components/main-nav/__tests__/index.spec.tsx b/web/app/components/main-nav/__tests__/index.spec.tsx index cf68d4cac8a..0494d4de1bf 100644 --- a/web/app/components/main-nav/__tests__/index.spec.tsx +++ b/web/app/components/main-nav/__tests__/index.spec.tsx @@ -5,6 +5,7 @@ import type { ModalContextState } from '@/context/modal-context' import type { ProviderContextState } from '@/context/provider-context' import type { IWorkspace } from '@/models/common' import type { InstalledApp } from '@/models/explore' +import type { SnippetDetail, SnippetInputField } from '@/models/snippet' import { fireEvent, screen, waitFor } from '@testing-library/react' import { createStore, Provider as JotaiProvider } from 'jotai' import { createTestQueryClient, renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' @@ -16,6 +17,7 @@ import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/con import { useAppContext, useSelector as useAppContextSelector } from '@/context/app-context' import { useModalContext } from '@/context/modal-context' import { useProviderContext } from '@/context/provider-context' +import { PipelineInputVarType } from '@/models/pipeline' import { usePathname, useRouter } from '@/next/navigation' import { consoleQuery } from '@/service/client' import { useGetInstalledApps, useUninstallApp, useUpdateAppPinStatus } from '@/service/use-explore' @@ -25,14 +27,28 @@ import { DETAIL_SIDEBAR_STORAGE_KEY } from '../storage' const activeEdgeClassName = 'before:pointer-events-none' -const { mockIsAgentV2Enabled, mockSwitchWorkspace, mockToastSuccess, hotkeyRegistrations } = vi.hoisted(() => ({ +type SnippetNavigationTestState = { + fields: SnippetInputField[] + onFieldsChange?: (fields: SnippetInputField[]) => void + readonly: boolean + snippet?: SnippetDetail +} + +const { mockIsAgentV2Enabled, mockSnippetFieldsChange, mockSwitchWorkspace, mockToastSuccess, hotkeyRegistrations, snippetNavigationState } = vi.hoisted(() => ({ mockSwitchWorkspace: vi.fn(), + mockSnippetFieldsChange: vi.fn(), mockToastSuccess: vi.fn(), mockIsAgentV2Enabled: vi.fn(() => true), hotkeyRegistrations: new Map void }) => void options?: { ignoreInputs?: boolean } }>(), + snippetNavigationState: { + fields: [], + readonly: true, + snippet: undefined, + onFieldsChange: undefined, + } as SnippetNavigationTestState, })) vi.mock('@/features/agent-v2/feature-flag', () => ({ @@ -184,6 +200,38 @@ vi.mock('@/features/deployments/detail/deployment-sidebar', () => ({ ), })) +vi.mock('@/app/components/snippets/store', () => ({ + useSnippetDetailStore: (selector: (state: SnippetNavigationTestState) => unknown) => selector(snippetNavigationState), +})) + +vi.mock('@/app/components/snippets/components/snippet-sidebar', () => ({ + SnippetSidebarContent: ({ + fields, + onFieldsChange, + readonly, + snippet, + }: { + fields: SnippetInputField[] + onFieldsChange: (fields: SnippetInputField[]) => void + readonly: boolean + snippet: SnippetDetail + }) => ( +
+ {snippet.name} + {fields.map(field => field.variable).join(',')} + +
+ ), +})) + +vi.mock('../components/snippet-detail-top', () => ({ + default: ({ expand, onToggle }: { expand: boolean, onToggle: () => void }) => ( +
+ +
+ ), +})) + vi.mock('@/context/i18n', () => ({ useLocale: () => 'en-US', useDocLink: () => (path: string) => `https://docs.dify.ai${path}`, @@ -241,6 +289,24 @@ const createInstalledApp = (overrides: Partial = {}): InstalledApp }, }) +const snippet: SnippetDetail = { + id: 'snippet-1', + name: 'Snippet', + description: 'Description', + updatedAt: '2026-03-29 10:00', + usage: '0', + tags: [], +} + +const snippetFields: SnippetInputField[] = [ + { + label: 'Query', + variable: 'query', + type: PipelineInputVarType.textInput, + required: true, + }, +] + const appContextValue: AppContextValue = { userProfile: { id: 'user-1', @@ -357,6 +423,10 @@ describe('MainNav', () => { }) mockSwitchWorkspace.mockReturnValue(new Promise(() => {})) hotkeyRegistrations.clear() + snippetNavigationState.fields = [] + snippetNavigationState.onFieldsChange = undefined + snippetNavigationState.readonly = true + snippetNavigationState.snippet = undefined useAppStore.getState().setAppDetail() }) @@ -562,12 +632,24 @@ describe('MainNav', () => { expect(screen.getByRole('link', { name: /common.mainNav.home/ })).not.toHaveAttribute('aria-current') }) - it('hides the main menu on snippet detail routes while keeping account settings available', () => { + it('replaces global navigation with snippet detail navigation on snippet routes', () => { mockPathname = '/snippets/snippet-1/orchestrate' + snippetNavigationState.fields = snippetFields + snippetNavigationState.onFieldsChange = mockSnippetFieldsChange + snippetNavigationState.readonly = false + snippetNavigationState.snippet = snippet renderMainNav() - expect(screen.getByRole('complementary')).toHaveClass('w-16') + expect(screen.getByRole('complementary')).toHaveClass('w-[248px]') + expect(screen.getByRole('complementary')).toHaveClass('p-1') + expect(screen.getByRole('complementary')).toHaveClass('bg-background-body') + expect(screen.getByTestId('snippet-detail-top')).toHaveAttribute('data-expand', 'true') + expect(screen.getByTestId('snippet-sidebar-content')).toHaveAttribute('data-readonly', 'false') + expect(screen.getByText(snippet.name)).toBeInTheDocument() + expect(screen.getByText('query')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'change snippet fields' })) + expect(mockSnippetFieldsChange).toHaveBeenCalledWith([]) expect(screen.queryByLabelText('Dify')).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'common.mainNav.workspace.openMenu' })).not.toBeInTheDocument() expect(screen.queryByRole('link', { name: /common.mainNav.home/ })).not.toBeInTheDocument() @@ -577,6 +659,22 @@ describe('MainNav', () => { expect(screen.getByRole('button', { name: 'common.mainNav.help.openMenu' })).toBeInTheDocument() }) + it('collapses snippet detail navigation from the top-right toggle', () => { + mockPathname = '/snippets/snippet-1/orchestrate' + snippetNavigationState.fields = snippetFields + snippetNavigationState.onFieldsChange = mockSnippetFieldsChange + snippetNavigationState.snippet = snippet + + renderMainNav() + fireEvent.click(screen.getByTestId('snippet-detail-toggle')) + + expect(screen.getByRole('complementary')).toHaveClass('w-16') + expect(screen.getByRole('complementary')).toHaveClass('p-1') + expect(screen.getByTestId('snippet-detail-top')).toHaveAttribute('data-expand', 'false') + expect(screen.queryByTestId('snippet-sidebar-content')).not.toBeInTheDocument() + expect(localStorage.getItem(DETAIL_SIDEBAR_STORAGE_KEY)).toBe('collapse') + }) + it('replaces global navigation with app detail navigation on app routes', () => { mockPathname = '/app/app-1/overview' diff --git a/web/app/components/main-nav/index.tsx b/web/app/components/main-nav/index.tsx index e5bf47f5258..11934d8605d 100644 --- a/web/app/components/main-nav/index.tsx +++ b/web/app/components/main-nav/index.tsx @@ -14,9 +14,8 @@ 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 { SnippetSidebarContent } from '@/app/components/snippets/components/snippet-sidebar' -// import { useSnippetDetailStore } from '@/app/components/snippets/store' +import { SnippetSidebarContent } from '@/app/components/snippets/components/snippet-sidebar' +import { useSnippetDetailStore } from '@/app/components/snippets/store' import { useAppContext } from '@/context/app-context' import { AgentDetailSection, AgentDetailTop } from '@/features/agent-v2/agent-detail/navigation' import { isAgentV2Enabled } from '@/features/agent-v2/feature-flag' @@ -28,7 +27,7 @@ import AccountSection from './components/account-section' import HelpMenu from './components/help-menu' import MainNavLink from './components/nav-link' import { MainNavSearchButton } from './components/search-button' -// import SnippetDetailTop from './components/snippet-detail-top' +import SnippetDetailTop from './components/snippet-detail-top' import WebAppsSection from './components/web-apps-section' import { WorkspaceCard } from './components/workspace-card' import { isMainNavRouteVisible, MAIN_NAV_ROUTES } from './routes' @@ -99,8 +98,14 @@ const MainNav = ({ const showDatasetDetailNavigation = isDatasetDetailPathname(pathname) const showAgentDetailNavigation = agentV2Enabled && !isCurrentWorkspaceDatasetOperator && isAgentDetailPathname(pathname) const showDeploymentDetailNavigation = canUseAppDeploy && !isCurrentWorkspaceDatasetOperator && isDeploymentDetailPathname(pathname) - const showSnippetDetailBottomNavigation = isSnippetDetailPathname(pathname) - const showDetailNavigation = showAppDetailNavigation || showDatasetDetailNavigation || showAgentDetailNavigation || showDeploymentDetailNavigation + const showSnippetDetailNavigation = isSnippetDetailPathname(pathname) + const showDetailNavigation = showAppDetailNavigation || showDatasetDetailNavigation || showAgentDetailNavigation || showDeploymentDetailNavigation || showSnippetDetailNavigation + const snippetNavigation = useSnippetDetailStore(useShallow(state => ({ + fields: state.fields, + onFieldsChange: state.onFieldsChange, + readonly: state.readonly, + snippet: state.snippet, + }))) const { hasAppDetail, setAppDetail } = useAppStore(useShallow(state => ({ hasAppDetail: !!state.appDetail, setAppDetail: state.setAppDetail, @@ -265,25 +270,30 @@ const MainNav = ({ onToggle={handleToggleDetailNavigation} /> ) - : ( - - ) - : showSnippetDetailBottomNavigation - ? null - : ( - <> -
- {renderLogo()} - -
-
- -
- - )} + : showDeploymentDetailNavigation + ? ( + + ) + : ( + + ) + : ( + <> +
+ {renderLogo()} + +
+
+ +
+ + )} {showDetailNavigation ? showAppDetailNavigation ? @@ -291,20 +301,29 @@ const MainNav = ({ ? : showAgentDetailNavigation ? - : - : showSnippetDetailBottomNavigation - ? null - : ( - <> - - {!isCurrentWorkspaceDatasetOperator && } - - )} - {showEnvTag && !showSnippetDetailBottomNavigation && detailNavigationVisibleExpanded && ( + : showDeploymentDetailNavigation + ? + : detailNavigationVisibleExpanded && snippetNavigation.snippet && snippetNavigation.onFieldsChange + ? ( + + ) + : null + : ( + <> + + {!isCurrentWorkspaceDatasetOperator && } + + )} + {showEnvTag && detailNavigationVisibleExpanded && (