mirror of
https://github.com/langgenius/dify.git
synced 2026-07-21 18:58:35 +08:00
refactor(web): decouple detail sidebars from main nav (#38241)
This commit is contained in:
parent
93981cf75f
commit
edf21269b3
@ -0,0 +1,5 @@
|
||||
import { AppDetailSidebarSlot } from '../sidebar-page'
|
||||
|
||||
export default function AppAccessConfigDetailSidebarSlot() {
|
||||
return <AppDetailSidebarSlot />
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
import { AppDetailSidebarSlot } from '../sidebar-page'
|
||||
|
||||
export default function AppAnnotationsDetailSidebarSlot() {
|
||||
return <AppDetailSidebarSlot />
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
import { AppDetailSidebarSlot } from '../sidebar-page'
|
||||
|
||||
export default function AppConfigurationDetailSidebarSlot() {
|
||||
return <AppDetailSidebarSlot />
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
import { AppDetailSidebarSlot } from '../sidebar-page'
|
||||
|
||||
export default function AppDevelopDetailSidebarSlot() {
|
||||
return <AppDetailSidebarSlot />
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
import { AppDetailSidebarSlot } from '../sidebar-page'
|
||||
|
||||
export default function AppLogsDetailSidebarSlot() {
|
||||
return <AppDetailSidebarSlot />
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
import { AppDetailSidebarSlot } from '../sidebar-page'
|
||||
|
||||
export default function AppOverviewDetailSidebarSlot() {
|
||||
return <AppDetailSidebarSlot />
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
import { AppDetailSidebar } from '@/app/components/app-sidebar/app-detail-sidebar'
|
||||
|
||||
export function AppDetailSidebarSlot() {
|
||||
return <AppDetailSidebar />
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
import { AppDetailSidebarSlot } from '../sidebar-page'
|
||||
|
||||
export default function AppWorkflowDetailSidebarSlot() {
|
||||
return <AppDetailSidebarSlot />
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
import { DatasetDetailSidebarSlot } from '../sidebar-page'
|
||||
|
||||
export default function DatasetAccessConfigDetailSidebarSlot() {
|
||||
return <DatasetDetailSidebarSlot />
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
import { DatasetDetailSidebarSlot } from '../sidebar-page'
|
||||
|
||||
export default function DatasetApiDetailSidebarSlot() {
|
||||
return <DatasetDetailSidebarSlot />
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
import { DatasetDetailSidebarSlot } from '../../sidebar-page'
|
||||
|
||||
export default function DatasetDocumentDetailSidebarSlot() {
|
||||
return <DatasetDetailSidebarSlot />
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
import { DatasetDetailSidebarSlot } from '../../../sidebar-page'
|
||||
|
||||
export default function DatasetDocumentSettingsDetailSidebarSlot() {
|
||||
return <DatasetDetailSidebarSlot />
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
export default function DatasetDocumentCreateFromPipelineDetailSidebarSlot() {
|
||||
return null
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
export default function DatasetDocumentCreateDetailSidebarSlot() {
|
||||
return null
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
import { DatasetDetailSidebarSlot } from '../sidebar-page'
|
||||
|
||||
export default function DatasetDocumentsDetailSidebarSlot() {
|
||||
return <DatasetDetailSidebarSlot />
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
import { DatasetDetailSidebarSlot } from '../sidebar-page'
|
||||
|
||||
export default function DatasetHitTestingDetailSidebarSlot() {
|
||||
return <DatasetDetailSidebarSlot />
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
import { DatasetDetailSidebarSlot } from '../sidebar-page'
|
||||
|
||||
export default function DatasetPipelineDetailSidebarSlot() {
|
||||
return <DatasetDetailSidebarSlot />
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
import { DatasetDetailSidebarSlot } from '../sidebar-page'
|
||||
|
||||
export default function DatasetSettingsDetailSidebarSlot() {
|
||||
return <DatasetDetailSidebarSlot />
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
import { DatasetDetailSidebar } from '@/app/components/app-sidebar/dataset-detail-sidebar'
|
||||
|
||||
export function DatasetDetailSidebarSlot() {
|
||||
return <DatasetDetailSidebar />
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
export default function DatasetConnectDetailSidebarSlot() {
|
||||
return null
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
export default function DatasetCreateFromPipelineDetailSidebarSlot() {
|
||||
return null
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
export default function DatasetCreateDetailSidebarSlot() {
|
||||
return null
|
||||
}
|
||||
3
web/app/(commonLayout)/@detailSidebar/default.tsx
Normal file
3
web/app/(commonLayout)/@detailSidebar/default.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export default function DefaultDetailSidebarSlot() {
|
||||
return null
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
import { DeploymentDetailSidebarSlot } from '../sidebar-page'
|
||||
|
||||
export default function DeploymentAccessDetailSidebarSlot() {
|
||||
return <DeploymentDetailSidebarSlot />
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
import { DeploymentDetailSidebarSlot } from '../sidebar-page'
|
||||
|
||||
export default function DeploymentApiTokensDetailSidebarSlot() {
|
||||
return <DeploymentDetailSidebarSlot />
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
import { DeploymentDetailSidebarSlot } from '../sidebar-page'
|
||||
|
||||
export default function DeploymentInstancesDetailSidebarSlot() {
|
||||
return <DeploymentDetailSidebarSlot />
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
import { DeploymentDetailSidebarSlot } from '../sidebar-page'
|
||||
|
||||
export default function DeploymentOverviewDetailSidebarSlot() {
|
||||
return <DeploymentDetailSidebarSlot />
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
import { DeploymentDetailSidebarSlot } from './sidebar-page'
|
||||
|
||||
export default function DeploymentDetailPageSidebarSlot() {
|
||||
return <DeploymentDetailSidebarSlot />
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
import { DeploymentDetailSidebarSlot } from '../sidebar-page'
|
||||
|
||||
export default function DeploymentReleasesDetailSidebarSlot() {
|
||||
return <DeploymentDetailSidebarSlot />
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
import { DeploymentDetailSidebar } from '@/features/deployments/detail/detail-sidebar'
|
||||
|
||||
export function DeploymentDetailSidebarSlot() {
|
||||
return <DeploymentDetailSidebar />
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
export default function DeploymentCreateDetailSidebarSlot() {
|
||||
return null
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
import { AgentDetailSidebarSlot } from '../sidebar-page'
|
||||
|
||||
export default function AgentAccessDetailSidebarSlot() {
|
||||
return <AgentDetailSidebarSlot />
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
import { AgentDetailSidebarSlot } from '../sidebar-page'
|
||||
|
||||
export default function AgentConfigureDetailSidebarSlot() {
|
||||
return <AgentDetailSidebarSlot />
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
import { AgentDetailSidebarSlot } from '../sidebar-page'
|
||||
|
||||
export default function AgentLogsDetailSidebarSlot() {
|
||||
return <AgentDetailSidebarSlot />
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
import { AgentDetailSidebarSlot } from '../sidebar-page'
|
||||
|
||||
export default function AgentMonitoringDetailSidebarSlot() {
|
||||
return <AgentDetailSidebarSlot />
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
import { AgentDetailSidebarSlot } from './sidebar-page'
|
||||
|
||||
export default function AgentDetailPageSidebarSlot() {
|
||||
return <AgentDetailSidebarSlot />
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
import { AgentDetailSidebar } from '@/features/agent-v2/agent-detail/detail-sidebar'
|
||||
|
||||
export function AgentDetailSidebarSlot() {
|
||||
return <AgentDetailSidebar />
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
import { SnippetDetailSidebarSlot } from '../sidebar-page'
|
||||
|
||||
export default function SnippetOrchestrateDetailSidebarSlot() {
|
||||
return <SnippetDetailSidebarSlot />
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
import { SnippetDetailSidebarSlot } from './sidebar-page'
|
||||
|
||||
export default function SnippetDetailPageSidebarSlot() {
|
||||
return <SnippetDetailSidebarSlot />
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
import { SnippetDetailSidebar } from '@/app/components/snippets/components/snippet-detail-sidebar'
|
||||
|
||||
export function SnippetDetailSidebarSlot() {
|
||||
return <SnippetDetailSidebar />
|
||||
}
|
||||
@ -31,6 +31,7 @@ vi.mock('@/service/apps', () => ({
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
currentWorkspace: { id: 'workspace-1' },
|
||||
isCurrentWorkspaceDatasetOperator: false,
|
||||
isLoadingCurrentWorkspace: false,
|
||||
isLoadingWorkspacePermissionKeys: mockIsLoadingWorkspacePermissionKeys,
|
||||
userProfile: { id: 'user-1' },
|
||||
@ -111,6 +112,34 @@ describe('AppDetailLayout', () => {
|
||||
expect(useStore.getState().appDetail?.id).toBe('app-1')
|
||||
})
|
||||
|
||||
it('should render app detail content without owning the main skip target', async () => {
|
||||
render(
|
||||
<AppDetailLayout appId="app-1">
|
||||
<div>App page content</div>
|
||||
</AppDetailLayout>,
|
||||
)
|
||||
|
||||
await waitForAppContent()
|
||||
|
||||
expect(screen.queryByRole('main')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should preserve the column flex context for full-height workflow content', async () => {
|
||||
render(
|
||||
<AppDetailLayout appId="app-1">
|
||||
<div>App page content</div>
|
||||
</AppDetailLayout>,
|
||||
)
|
||||
|
||||
await waitForAppContent()
|
||||
|
||||
const contentSurface = screen.getByText('App page content').parentElement
|
||||
const appDetailContent = contentSurface?.parentElement
|
||||
const appDetailRoot = appDetailContent?.parentElement
|
||||
|
||||
expect(appDetailRoot).toHaveClass('flex-col')
|
||||
})
|
||||
|
||||
it('should redirect restricted app pages before exposing app detail content', async () => {
|
||||
mockPathname = '/app/app-1/logs'
|
||||
mockFetchAppDetailDirect.mockResolvedValue(createAppDetail({ permission_keys: [AppACLPermission.ViewLayout] }))
|
||||
|
||||
@ -133,29 +133,32 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
|
||||
setAppDetail({ ...appDetailRes, enable_sso: false })
|
||||
}, [appDetail?.id, appDetailRes, appId, currentWorkspace.id, isLoadingAppDetail, isLoadingCurrentWorkspace, isLoadingWorkspacePermissionKeys, isRbacEnabled, pathname, routeAppDetail, router, setAppDetail, userProfile?.id, workspacePermissionKeys])
|
||||
|
||||
if (!appDetail) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-background-body">
|
||||
<Loading />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isWorkflowPage = pathname.endsWith('/workflow')
|
||||
const content = !appDetail
|
||||
? (
|
||||
<div className="flex min-w-0 grow items-center justify-center bg-background-body">
|
||||
<Loading />
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className={cn(
|
||||
'relative flex h-0 min-h-0 min-w-0 grow overflow-hidden',
|
||||
!isWorkflowPage && 'pt-1 pr-1 pb-1',
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
'min-w-0 grow overflow-hidden bg-components-panel-bg',
|
||||
!isWorkflowPage && 'rounded-lg shadow-xs shadow-shadow-shadow-3',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'relative flex h-0 grow overflow-hidden',
|
||||
!isWorkflowPage && 'pt-1 pr-1 pb-1',
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
'grow overflow-hidden bg-components-panel-bg',
|
||||
!isWorkflowPage && 'rounded-lg shadow-xs shadow-shadow-shadow-3',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background-body">
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -186,6 +186,36 @@ describe('DatasetDetailLayout', () => {
|
||||
expect(screen.getByText('Pipeline content').parentElement).not.toHaveClass('rounded-lg')
|
||||
})
|
||||
|
||||
it('should preserve the column flex context for full-height pipeline content', () => {
|
||||
// Arrange
|
||||
mockUsePathname.mockReturnValue('/datasets/dataset-1/pipeline')
|
||||
mockUseDatasetDetail.mockReturnValue({
|
||||
data: {
|
||||
id: 'dataset-1',
|
||||
name: 'Dataset 1',
|
||||
provider: 'vendor',
|
||||
runtime_mode: 'rag_pipeline',
|
||||
is_published: false,
|
||||
},
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
} as unknown as ReturnType<typeof useDatasetDetail>)
|
||||
|
||||
// Act
|
||||
render(
|
||||
<DatasetDetailLayout datasetId="dataset-1">
|
||||
<div>Pipeline content</div>
|
||||
</DatasetDetailLayout>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const contentSurface = screen.getByText('Pipeline content').parentElement
|
||||
const datasetDetailContent = contentSurface?.parentElement
|
||||
const datasetDetailRoot = datasetDetailContent?.parentElement
|
||||
|
||||
expect(datasetDetailRoot).toHaveClass('flex-col')
|
||||
})
|
||||
|
||||
it('should keep create-from-pipeline pages unframed', () => {
|
||||
// Arrange
|
||||
mockUsePathname.mockReturnValue('/datasets/dataset-1/documents/create-from-pipeline')
|
||||
@ -211,6 +241,33 @@ describe('DatasetDetailLayout', () => {
|
||||
// Assert
|
||||
expect(screen.getByText('Create from pipeline content').parentElement).not.toHaveClass('rounded-lg')
|
||||
})
|
||||
|
||||
it('should render document creation route content without owning the main skip target', () => {
|
||||
// Arrange
|
||||
mockUsePathname.mockReturnValue('/datasets/dataset-1/documents/create')
|
||||
mockUseDatasetDetail.mockReturnValue({
|
||||
data: {
|
||||
id: 'dataset-1',
|
||||
name: 'Dataset 1',
|
||||
provider: 'vendor',
|
||||
runtime_mode: 'rag_pipeline',
|
||||
is_published: true,
|
||||
},
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
} as unknown as ReturnType<typeof useDatasetDetail>)
|
||||
|
||||
// Act
|
||||
render(
|
||||
<DatasetDetailLayout datasetId="dataset-1">
|
||||
<div>Create document content</div>
|
||||
</DatasetDetailLayout>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Create document content')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('main')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Permission Route Guards', () => {
|
||||
|
||||
@ -102,38 +102,37 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
|
||||
router.replace(getDatasetRedirectionPath(datasetRes, datasetACLCapabilities))
|
||||
}, [datasetACLCapabilities, datasetRes, router, shouldRedirectUnauthorizedRoute])
|
||||
|
||||
if (!datasetRes && !error)
|
||||
return <Loading type="app" />
|
||||
|
||||
if (shouldRedirect)
|
||||
return <Loading type="app" />
|
||||
|
||||
if (isCheckingRouteAccess || shouldRedirectUnauthorizedRoute)
|
||||
return <Loading type="app" />
|
||||
|
||||
const isPipelinePage = pathname.endsWith('/pipeline') || pathname.includes('/create-from-pipeline')
|
||||
const shouldShowLoading = (!datasetRes && !error) || shouldRedirect || isCheckingRouteAccess || shouldRedirectUnauthorizedRoute
|
||||
const content = shouldShowLoading
|
||||
? <Loading type="app" />
|
||||
: (
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex h-0 min-h-0 min-w-0 grow overflow-hidden',
|
||||
!isPipelinePage && 'pt-1 pr-1 pb-1',
|
||||
)}
|
||||
>
|
||||
<DatasetDetailContext.Provider value={{
|
||||
indexingTechnique: datasetRes?.indexing_technique,
|
||||
dataset: datasetRes,
|
||||
mutateDatasetRes,
|
||||
}}
|
||||
>
|
||||
<div className={cn(
|
||||
'min-w-0 grow overflow-hidden bg-components-panel-bg',
|
||||
!isPipelinePage && 'rounded-lg shadow-xs shadow-shadow-shadow-3',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</DatasetDetailContext.Provider>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex h-0 grow overflow-hidden',
|
||||
!isPipelinePage && 'pt-1 pr-1 pb-1',
|
||||
)}
|
||||
>
|
||||
<DatasetDetailContext.Provider value={{
|
||||
indexingTechnique: datasetRes?.indexing_technique,
|
||||
dataset: datasetRes,
|
||||
mutateDatasetRes,
|
||||
}}
|
||||
>
|
||||
<div className={cn(
|
||||
'grow overflow-hidden bg-components-panel-bg',
|
||||
!isPipelinePage && 'rounded-lg shadow-xs shadow-shadow-shadow-3',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</DatasetDetailContext.Provider>
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background-body">
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -13,7 +13,13 @@ import { ProviderContextProvider } from '@/context/provider-context-provider'
|
||||
import { CommonLayoutGlobalMounts } from './global-mounts'
|
||||
import { CommonLayoutHydrationBoundary } from './hydration-boundary'
|
||||
|
||||
export default async function Layout({ children }: { children: ReactNode }) {
|
||||
export default async function Layout({
|
||||
children,
|
||||
detailSidebar,
|
||||
}: {
|
||||
children: ReactNode
|
||||
detailSidebar: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<GoogleAnalyticsScripts />
|
||||
@ -26,7 +32,7 @@ export default async function Layout({ children }: { children: ReactNode }) {
|
||||
<EventEmitterContextProvider>
|
||||
<ProviderContextProvider>
|
||||
<ModalContextProvider>
|
||||
<MainNavLayout>
|
||||
<MainNavLayout detailSidebar={detailSidebar}>
|
||||
{children}
|
||||
</MainNavLayout>
|
||||
<CommonLayoutGlobalMounts />
|
||||
|
||||
19
web/app/components/app-sidebar/app-detail-sidebar.tsx
Normal file
19
web/app/components/app-sidebar/app-detail-sidebar.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
'use client'
|
||||
|
||||
import { DetailSidebarFrame } from '@/app/components/detail-sidebar'
|
||||
import AppDetailSection from './app-detail-section'
|
||||
import AppDetailTop from './app-detail-top'
|
||||
|
||||
export function AppDetailSidebar() {
|
||||
return (
|
||||
<DetailSidebarFrame
|
||||
renderTop={({ expand, onToggle }) => (
|
||||
<AppDetailTop
|
||||
expand={expand}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
)}
|
||||
renderSection={({ expand }) => <AppDetailSection expand={expand} />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
19
web/app/components/app-sidebar/dataset-detail-sidebar.tsx
Normal file
19
web/app/components/app-sidebar/dataset-detail-sidebar.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
'use client'
|
||||
|
||||
import { DetailSidebarFrame } from '@/app/components/detail-sidebar'
|
||||
import DatasetDetailSection from './dataset-detail-section'
|
||||
import DatasetDetailTop from './dataset-detail-top'
|
||||
|
||||
export function DatasetDetailSidebar() {
|
||||
return (
|
||||
<DetailSidebarFrame
|
||||
renderTop={({ expand, onToggle }) => (
|
||||
<DatasetDetailTop
|
||||
expand={expand}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
)}
|
||||
renderSection={({ expand }) => <DatasetDetailSection expand={expand} />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -90,7 +90,7 @@ vi.mock('@/app/components/app/store', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/main-nav/storage', () => ({
|
||||
vi.mock('@/app/components/detail-sidebar/storage', () => ({
|
||||
useSetDetailSidebarMode: () => mockSetDetailSidebarMode,
|
||||
}))
|
||||
|
||||
|
||||
@ -34,6 +34,7 @@ import {
|
||||
} from '@/app/components/app/configuration/debug/hooks'
|
||||
import useAdvancedPromptConfig from '@/app/components/app/configuration/hooks/use-advanced-prompt-config'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { useSetDetailSidebarMode } from '@/app/components/detail-sidebar/storage'
|
||||
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
||||
import { ModelFeatureEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import {
|
||||
@ -41,7 +42,6 @@ import {
|
||||
useTextGenerationCurrentProviderAndModelAndModelList,
|
||||
} from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import { useIntegrationsSetting } from '@/app/components/header/account-setting/use-integrations-setting'
|
||||
import { useSetDetailSidebarMode } from '@/app/components/main-nav/storage'
|
||||
import { ANNOTATION_DEFAULT, DATASET_DEFAULT, DEFAULT_AGENT_SETTING, DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
|
||||
125
web/app/components/detail-sidebar/__tests__/index.spec.tsx
Normal file
125
web/app/components/detail-sidebar/__tests__/index.spec.tsx
Normal file
@ -0,0 +1,125 @@
|
||||
import type { Mock } from 'vitest'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { DetailSidebarFrame } from '..'
|
||||
import { DETAIL_SIDEBAR_STORAGE_KEY } from '../storage'
|
||||
|
||||
const { hotkeyRegistrations } = vi.hoisted(() => ({
|
||||
hotkeyRegistrations: new Map<string, {
|
||||
handler: (event: { preventDefault: () => void }) => void
|
||||
options?: { ignoreInputs?: boolean }
|
||||
}>(),
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-hotkeys', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@tanstack/react-hotkeys')>()
|
||||
return {
|
||||
...actual,
|
||||
useHotkey: (
|
||||
hotkey: string,
|
||||
handler: (event: { preventDefault: () => void }) => void,
|
||||
options?: { ignoreInputs?: boolean },
|
||||
) => {
|
||||
hotkeyRegistrations.set(hotkey, { handler, options })
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/main-nav/components/account-section', () => ({
|
||||
default: ({ compact }: { compact?: boolean }) => (
|
||||
<button type="button" aria-label="account">{compact ? 'Compact account' : 'Expanded account'}</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/main-nav/components/help-menu', () => ({
|
||||
default: ({ triggerClassName }: { triggerClassName?: string }) => (
|
||||
<button type="button" aria-label="help" className={triggerClassName}>Help</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/env-nav', () => ({
|
||||
default: () => <div>Environment tag</div>,
|
||||
}))
|
||||
|
||||
function renderDetailSidebarFrame() {
|
||||
return render(
|
||||
<DetailSidebarFrame
|
||||
renderTop={({ expand, onToggle }) => (
|
||||
<div data-testid="detail-top" data-expand={expand}>
|
||||
<button type="button" data-testid="detail-toggle" onClick={onToggle}>Toggle</button>
|
||||
</div>
|
||||
)}
|
||||
renderSection={({ expand }) => (
|
||||
<div data-testid="detail-section" data-expand={expand}>Section</div>
|
||||
)}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('DetailSidebarFrame', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
hotkeyRegistrations.clear()
|
||||
;(useAppContext as Mock).mockReturnValue({
|
||||
langGeniusVersionInfo: {
|
||||
current_env: '',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('renders expanded detail content by default and registers the shortcut for focused inputs', () => {
|
||||
renderDetailSidebarFrame()
|
||||
|
||||
expect(screen.getByRole('complementary')).toHaveClass('w-62')
|
||||
expect(screen.getByTestId('detail-top')).toHaveAttribute('data-expand', 'true')
|
||||
expect(screen.getByTestId('detail-section')).toHaveAttribute('data-expand', 'true')
|
||||
expect(hotkeyRegistrations.get('Mod+B')?.options).toEqual(
|
||||
expect.objectContaining({ ignoreInputs: false }),
|
||||
)
|
||||
})
|
||||
|
||||
it('collapses detail content from the top toggle and hides environment metadata', () => {
|
||||
;(useAppContext as Mock).mockReturnValue({
|
||||
langGeniusVersionInfo: {
|
||||
current_env: 'TESTING',
|
||||
},
|
||||
})
|
||||
|
||||
renderDetailSidebarFrame()
|
||||
fireEvent.click(screen.getByTestId('detail-toggle'))
|
||||
|
||||
expect(screen.getByRole('complementary')).toHaveClass('w-16')
|
||||
expect(screen.getByTestId('detail-top')).toHaveAttribute('data-expand', 'false')
|
||||
expect(screen.getByTestId('detail-section')).toHaveAttribute('data-expand', 'false')
|
||||
expect(screen.queryByText('Environment tag')).not.toBeInTheDocument()
|
||||
expect(localStorage.getItem(DETAIL_SIDEBAR_STORAGE_KEY)).toBe('collapse')
|
||||
})
|
||||
|
||||
it('shows a floating preview on collapsed hover without changing persisted state', () => {
|
||||
renderDetailSidebarFrame()
|
||||
fireEvent.click(screen.getByTestId('detail-toggle'))
|
||||
fireEvent.mouseEnter(screen.getByTestId('detail-top').parentElement!)
|
||||
|
||||
expect(screen.getByRole('complementary')).toHaveClass('w-16', 'overflow-visible')
|
||||
expect(screen.getByTestId('detail-top')).toHaveAttribute('data-expand', 'true')
|
||||
expect(screen.getByTestId('detail-section')).toHaveAttribute('data-expand', 'true')
|
||||
expect(localStorage.getItem(DETAIL_SIDEBAR_STORAGE_KEY)).toBe('collapse')
|
||||
})
|
||||
|
||||
it('persists expansion without width animation when the hovered preview toggle is clicked', () => {
|
||||
renderDetailSidebarFrame()
|
||||
fireEvent.click(screen.getByTestId('detail-toggle'))
|
||||
fireEvent.mouseEnter(screen.getByTestId('detail-top').parentElement!)
|
||||
fireEvent.click(screen.getByTestId('detail-toggle'))
|
||||
|
||||
expect(screen.getByRole('complementary')).toHaveClass('w-62', 'transition-none')
|
||||
expect(screen.getByRole('complementary')).not.toHaveClass('overflow-visible')
|
||||
expect(screen.getByTestId('detail-top')).toHaveAttribute('data-expand', 'true')
|
||||
expect(screen.getByTestId('detail-section')).toHaveAttribute('data-expand', 'true')
|
||||
expect(localStorage.getItem(DETAIL_SIDEBAR_STORAGE_KEY)).toBe('expand')
|
||||
})
|
||||
})
|
||||
173
web/app/components/detail-sidebar/index.tsx
Normal file
173
web/app/components/detail-sidebar/index.tsx
Normal file
@ -0,0 +1,173 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useHotkey } from '@tanstack/react-hotkeys'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import EnvNav from '@/app/components/header/env-nav'
|
||||
import AccountSection from '@/app/components/main-nav/components/account-section'
|
||||
import HelpMenu from '@/app/components/main-nav/components/help-menu'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useDetailSidebarMode } from './storage'
|
||||
|
||||
type DetailSidebarRenderProps = {
|
||||
expand: boolean
|
||||
onToggle: () => void
|
||||
}
|
||||
|
||||
type DetailSidebarFrameProps = {
|
||||
className?: string
|
||||
renderTop: (props: DetailSidebarRenderProps) => ReactNode
|
||||
renderSection: (props: Pick<DetailSidebarRenderProps, 'expand'>) => ReactNode
|
||||
}
|
||||
|
||||
const secondarySidebarHelpTriggerIcon = <span aria-hidden className="i-ri-question-line size-4 shrink-0" />
|
||||
|
||||
function SecondarySidebarHelpMenu({
|
||||
triggerClassName,
|
||||
}: {
|
||||
triggerClassName?: string
|
||||
}) {
|
||||
return (
|
||||
<HelpMenu
|
||||
triggerIcon={secondarySidebarHelpTriggerIcon}
|
||||
triggerClassName={triggerClassName}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function DetailSidebarFrame({
|
||||
className,
|
||||
renderTop,
|
||||
renderSection,
|
||||
}: DetailSidebarFrameProps) {
|
||||
const { langGeniusVersionInfo } = useAppContext()
|
||||
const [storedDetailSidebarExpand, setStoredDetailSidebarExpand] = useDetailSidebarMode()
|
||||
const detailNavigationMode = storedDetailSidebarExpand === 'collapse' ? 'collapse' : 'expand'
|
||||
const detailNavigationExpanded = detailNavigationMode === 'expand'
|
||||
const [detailNavigationHoverPreviewOpen, setDetailNavigationHoverPreviewOpen] = useState(false)
|
||||
const [detailNavigationTransitionDisabled, setDetailNavigationTransitionDisabled] = useState(false)
|
||||
const closeDetailNavigationHoverPreviewTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const detailNavigationTransitionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const isDetailNavigationHoverPreviewOpen = !detailNavigationExpanded && detailNavigationHoverPreviewOpen
|
||||
const detailNavigationVisibleExpanded = detailNavigationExpanded || isDetailNavigationHoverPreviewOpen
|
||||
const bottomNavigationExpanded = detailNavigationVisibleExpanded
|
||||
const currentEnv = langGeniusVersionInfo?.current_env
|
||||
const showEnvTag = currentEnv === 'TESTING' || currentEnv === 'DEVELOPMENT'
|
||||
|
||||
const handleToggleDetailNavigation = useCallback(() => {
|
||||
if (isDetailNavigationHoverPreviewOpen) {
|
||||
if (detailNavigationTransitionTimerRef.current)
|
||||
clearTimeout(detailNavigationTransitionTimerRef.current)
|
||||
|
||||
setDetailNavigationTransitionDisabled(true)
|
||||
setDetailNavigationHoverPreviewOpen(false)
|
||||
setStoredDetailSidebarExpand('expand')
|
||||
detailNavigationTransitionTimerRef.current = setTimeout(() => {
|
||||
setDetailNavigationTransitionDisabled(false)
|
||||
}, 200)
|
||||
return
|
||||
}
|
||||
|
||||
const nextMode = detailNavigationExpanded ? 'collapse' : 'expand'
|
||||
setDetailNavigationHoverPreviewOpen(false)
|
||||
setStoredDetailSidebarExpand(nextMode)
|
||||
}, [detailNavigationExpanded, isDetailNavigationHoverPreviewOpen, setStoredDetailSidebarExpand])
|
||||
|
||||
const openDetailNavigationHoverPreview = useCallback(() => {
|
||||
if (detailNavigationExpanded)
|
||||
return
|
||||
|
||||
if (closeDetailNavigationHoverPreviewTimerRef.current)
|
||||
clearTimeout(closeDetailNavigationHoverPreviewTimerRef.current)
|
||||
|
||||
setDetailNavigationHoverPreviewOpen(true)
|
||||
}, [detailNavigationExpanded])
|
||||
|
||||
const closeDetailNavigationHoverPreview = useCallback(() => {
|
||||
if (closeDetailNavigationHoverPreviewTimerRef.current)
|
||||
clearTimeout(closeDetailNavigationHoverPreviewTimerRef.current)
|
||||
|
||||
closeDetailNavigationHoverPreviewTimerRef.current = setTimeout(() => {
|
||||
setDetailNavigationHoverPreviewOpen(false)
|
||||
}, 120)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (closeDetailNavigationHoverPreviewTimerRef.current)
|
||||
clearTimeout(closeDetailNavigationHoverPreviewTimerRef.current)
|
||||
if (detailNavigationTransitionTimerRef.current)
|
||||
clearTimeout(detailNavigationTransitionTimerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useHotkey('Mod+B', (e) => {
|
||||
e.preventDefault()
|
||||
handleToggleDetailNavigation()
|
||||
}, {
|
||||
ignoreInputs: false,
|
||||
})
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
'relative flex h-full shrink-0 bg-background-body p-1',
|
||||
detailNavigationTransitionDisabled ? 'transition-none' : 'transition-all',
|
||||
isDetailNavigationHoverPreviewOpen ? 'w-16 overflow-visible' : detailNavigationExpanded ? 'w-62 overflow-hidden' : 'w-16 overflow-hidden',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-h-0 flex-1 flex-col',
|
||||
isDetailNavigationHoverPreviewOpen
|
||||
? 'absolute top-1 bottom-1 left-1 z-40 w-60 overflow-hidden rounded-lg border border-divider-subtle bg-components-panel-bg shadow-lg'
|
||||
: 'overflow-hidden rounded-lg bg-components-panel-bg',
|
||||
detailNavigationVisibleExpanded ? 'w-60' : 'w-14',
|
||||
)}
|
||||
onMouseEnter={!detailNavigationExpanded ? openDetailNavigationHoverPreview : undefined}
|
||||
onMouseLeave={!detailNavigationExpanded ? closeDetailNavigationHoverPreview : undefined}
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{renderTop({
|
||||
expand: detailNavigationVisibleExpanded,
|
||||
onToggle: handleToggleDetailNavigation,
|
||||
})}
|
||||
{renderSection({
|
||||
expand: detailNavigationVisibleExpanded,
|
||||
})}
|
||||
{showEnvTag && detailNavigationVisibleExpanded && (
|
||||
<div className="relative z-30 mt-auto shrink-0 px-3 pb-2">
|
||||
<EnvNav />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={cn(
|
||||
!bottomNavigationExpanded
|
||||
? 'flex w-full shrink-0 flex-col items-center gap-0.5 rounded-lg px-2 pt-1 pb-3'
|
||||
: 'flex w-60 items-center justify-between bg-components-panel-bg py-3 pr-1 pl-3',
|
||||
)}
|
||||
>
|
||||
{!bottomNavigationExpanded
|
||||
? (
|
||||
<>
|
||||
<SecondarySidebarHelpMenu triggerClassName="mb-2" />
|
||||
<AccountSection compact />
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<div className="flex min-w-0 items-center gap-1 overflow-hidden">
|
||||
<AccountSection />
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center justify-center rounded-full p-1">
|
||||
<SecondarySidebarHelpMenu />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@ -5,52 +5,30 @@ 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'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import { DETAIL_SIDEBAR_STORAGE_KEY } from '@/app/components/detail-sidebar/storage'
|
||||
import { LEARN_DIFY_HIDDEN_STORAGE_KEY } from '@/app/components/explore/learn-dify/storage'
|
||||
import { useGotoAnythingOpen } from '@/app/components/goto-anything/atoms'
|
||||
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
||||
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'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { MainNav } from '../index'
|
||||
import { DETAIL_SIDEBAR_STORAGE_KEY } from '../storage'
|
||||
|
||||
const activeGradientMaskClassName = 'aria-[current=page]:dify-blue-glass-surface'
|
||||
const activeStackingClassName = 'aria-[current=page]:z-1'
|
||||
|
||||
type SnippetNavigationTestState = {
|
||||
onFieldsChange?: (fields: SnippetInputField[]) => void
|
||||
readonly: boolean
|
||||
snippet?: SnippetDetail
|
||||
}
|
||||
|
||||
const { mockIsAgentV2Enabled, mockSnippetFieldsChange, mockSwitchWorkspace, mockToastSuccess, hotkeyRegistrations, snippetDraftState, snippetNavigationState } = vi.hoisted(() => ({
|
||||
const { mockIsAgentV2Enabled, mockSwitchWorkspace, mockToastSuccess } = vi.hoisted(() => ({
|
||||
mockSwitchWorkspace: vi.fn(),
|
||||
mockSnippetFieldsChange: vi.fn(),
|
||||
mockToastSuccess: vi.fn(),
|
||||
mockIsAgentV2Enabled: vi.fn(() => true),
|
||||
hotkeyRegistrations: new Map<string, {
|
||||
handler: (event: { preventDefault: () => void }) => void
|
||||
options?: { ignoreInputs?: boolean }
|
||||
}>(),
|
||||
snippetDraftState: {
|
||||
inputFields: [],
|
||||
} as { inputFields: SnippetInputField[] },
|
||||
snippetNavigationState: {
|
||||
readonly: true,
|
||||
snippet: undefined,
|
||||
onFieldsChange: undefined,
|
||||
} as SnippetNavigationTestState,
|
||||
}))
|
||||
|
||||
vi.mock('@/features/agent-v2/feature-flag', () => ({
|
||||
@ -142,102 +120,10 @@ vi.mock('@langgenius/dify-ui/toast', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@tanstack/react-hotkeys', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@tanstack/react-hotkeys')>()
|
||||
return {
|
||||
...actual,
|
||||
useHotkey: (
|
||||
hotkey: string,
|
||||
handler: (event: { preventDefault: () => void }) => void,
|
||||
options?: { ignoreInputs?: boolean },
|
||||
) => {
|
||||
hotkeyRegistrations.set(hotkey, { handler, options })
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/header/github-star', () => ({
|
||||
default: ({ className }: { className?: string }) => <span className={className}>1,234</span>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app-sidebar/app-detail-section', () => ({
|
||||
default: ({ expand }: { expand: boolean }) => <div data-testid="app-detail-section" data-expand={expand} />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app-sidebar/app-detail-top', () => ({
|
||||
default: ({ expand, onToggle }: { expand: boolean, onToggle: () => void }) => (
|
||||
<div data-testid="app-detail-top" data-expand={expand}>
|
||||
<button type="button" data-testid="app-detail-toggle" onClick={onToggle}>Toggle</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app-sidebar/dataset-detail-section', () => ({
|
||||
default: ({ expand }: { expand: boolean }) => <div data-testid="dataset-detail-section" data-expand={expand} />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app-sidebar/dataset-detail-top', () => ({
|
||||
default: ({ expand, onToggle }: { expand: boolean, onToggle: () => void }) => (
|
||||
<div data-testid="dataset-detail-top" data-expand={expand}>
|
||||
<button type="button" data-testid="dataset-detail-toggle" onClick={onToggle}>Toggle</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/agent-v2/agent-detail/navigation', () => ({
|
||||
AgentDetailSection: ({ expand }: { expand: boolean }) => <div data-testid="agent-detail-section" data-expand={expand} />,
|
||||
AgentDetailTop: ({ expand, onToggle }: { expand: boolean, onToggle: () => void }) => (
|
||||
<div data-testid="agent-detail-top" data-expand={expand}>
|
||||
<button type="button" data-testid="agent-detail-toggle" onClick={onToggle}>Toggle</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/deployments/detail/deployment-sidebar', () => ({
|
||||
DeploymentDetailSection: ({ expand }: { expand: boolean }) => <div data-testid="deployment-detail-section" data-expand={expand} />,
|
||||
DeploymentDetailTop: ({ expand, onToggle }: { expand: boolean, onToggle: () => void }) => (
|
||||
<div data-testid="deployment-detail-top" data-expand={expand}>
|
||||
<button type="button" data-testid="deployment-detail-toggle" onClick={onToggle}>Toggle</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/snippets/store', () => ({
|
||||
useSnippetDetailStore: (selector: (state: SnippetNavigationTestState) => unknown) => selector(snippetNavigationState),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/snippets/draft-store', () => ({
|
||||
useSnippetDraftStore: (selector: (state: typeof snippetDraftState) => unknown) => selector(snippetDraftState),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/snippets/components/snippet-sidebar', () => ({
|
||||
SnippetSidebarContent: ({
|
||||
fields,
|
||||
onFieldsChange,
|
||||
readonly,
|
||||
snippet,
|
||||
}: {
|
||||
fields: SnippetInputField[]
|
||||
onFieldsChange: (fields: SnippetInputField[]) => void
|
||||
readonly: boolean
|
||||
snippet: SnippetDetail
|
||||
}) => (
|
||||
<div data-testid="snippet-sidebar-content" data-readonly={String(readonly)}>
|
||||
<span>{snippet.name}</span>
|
||||
<span>{fields.map(field => field.variable).join(',')}</span>
|
||||
<button type="button" onClick={() => onFieldsChange([])}>change snippet fields</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../components/snippet-detail-top', () => ({
|
||||
default: ({ expand, onToggle }: { expand: boolean, onToggle: () => void }) => (
|
||||
<div data-testid="snippet-detail-top" data-expand={expand}>
|
||||
<button type="button" data-testid="snippet-detail-toggle" onClick={onToggle}>Toggle</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useLocale: () => 'en-US',
|
||||
useDocLink: () => (path: string) => `https://docs.dify.ai${path}`,
|
||||
@ -297,24 +183,6 @@ const createInstalledApp = (overrides: Partial<InstalledApp> = {}): 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',
|
||||
@ -443,12 +311,6 @@ describe('MainNav', () => {
|
||||
mutateAsync: mockUpdatePinStatus,
|
||||
})
|
||||
mockSwitchWorkspace.mockReturnValue(new Promise(() => {}))
|
||||
hotkeyRegistrations.clear()
|
||||
snippetDraftState.inputFields = []
|
||||
snippetNavigationState.onFieldsChange = undefined
|
||||
snippetNavigationState.readonly = true
|
||||
snippetNavigationState.snippet = undefined
|
||||
useAppStore.getState().setAppDetail()
|
||||
})
|
||||
|
||||
it('renders primary navigation with the planned routes', () => {
|
||||
@ -551,23 +413,6 @@ describe('MainNav', () => {
|
||||
expect(container.querySelector('.relative.z-30')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the environment tag when app detail navigation is collapsed', async () => {
|
||||
mockPathname = '/app/app-1/overview'
|
||||
;(useAppContext as Mock).mockReturnValue({
|
||||
...appContextValue,
|
||||
langGeniusVersionInfo: {
|
||||
...appContextValue.langGeniusVersionInfo,
|
||||
current_env: 'TESTING',
|
||||
},
|
||||
})
|
||||
|
||||
const { container } = renderMainNav()
|
||||
fireEvent.click(await screen.findByTestId('app-detail-toggle'))
|
||||
|
||||
expect(screen.queryByText('common.environment.testing')).not.toBeInTheDocument()
|
||||
expect(container.querySelector('.relative.z-30')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the user education badge in the account popup without adding the workspace plan there', async () => {
|
||||
;(useProviderContext as Mock).mockReturnValue({
|
||||
enableBilling: true,
|
||||
@ -659,194 +504,6 @@ describe('MainNav', () => {
|
||||
expect(screen.getByRole('link', { name: /common.mainNav.home/ })).not.toHaveAttribute('aria-current')
|
||||
})
|
||||
|
||||
it('replaces global navigation with snippet detail navigation on snippet routes', async () => {
|
||||
mockPathname = '/snippets/snippet-1/orchestrate'
|
||||
snippetDraftState.inputFields = snippetFields
|
||||
snippetNavigationState.onFieldsChange = mockSnippetFieldsChange
|
||||
snippetNavigationState.readonly = false
|
||||
snippetNavigationState.snippet = snippet
|
||||
|
||||
renderMainNav()
|
||||
|
||||
expect(screen.getByRole('complementary')).toHaveClass('w-62')
|
||||
expect(screen.getByRole('complementary')).toHaveClass('p-1')
|
||||
expect(screen.getByRole('complementary')).toHaveClass('bg-background-body')
|
||||
expect(await screen.findByTestId('snippet-detail-top')).toHaveAttribute('data-expand', 'true')
|
||||
expect(await screen.findByTestId('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()
|
||||
expect(screen.queryByRole('link', { name: /common.menus.apps/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'explore.sidebar.webApps' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.account.account' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.mainNav.help.openMenu' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('collapses snippet detail navigation from the top-right toggle', async () => {
|
||||
mockPathname = '/snippets/snippet-1/orchestrate'
|
||||
snippetDraftState.inputFields = snippetFields
|
||||
snippetNavigationState.onFieldsChange = mockSnippetFieldsChange
|
||||
snippetNavigationState.snippet = snippet
|
||||
|
||||
renderMainNav()
|
||||
fireEvent.click(await screen.findByTestId('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(screen.getByLabelText('Snippet collapsed preview')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('1 input fields')).toBeInTheDocument()
|
||||
expect(localStorage.getItem(DETAIL_SIDEBAR_STORAGE_KEY)).toBe('collapse')
|
||||
})
|
||||
|
||||
it('replaces global navigation with app detail navigation on app routes', async () => {
|
||||
mockPathname = '/app/app-1/overview'
|
||||
|
||||
renderMainNav()
|
||||
|
||||
expect(await screen.findByTestId('app-detail-top')).toBeInTheDocument()
|
||||
expect(await screen.findByTestId('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-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()
|
||||
expect(screen.queryByRole('link', { name: /common.mainNav.home/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: /common.menus.apps/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'explore.sidebar.webApps' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps app detail store when rendering app detail navigation', () => {
|
||||
mockPathname = '/app/app-1/logs'
|
||||
useAppStore.getState().setAppDetail({ id: 'app-1' } as ReturnType<typeof useAppStore.getState>['appDetail'])
|
||||
|
||||
renderMainNav()
|
||||
|
||||
expect(useAppStore.getState().appDetail?.id).toBe('app-1')
|
||||
})
|
||||
|
||||
it('clears app detail store after leaving app routes', async () => {
|
||||
mockPathname = '/apps'
|
||||
useAppStore.getState().setAppDetail({ id: 'app-1' } as ReturnType<typeof useAppStore.getState>['appDetail'])
|
||||
|
||||
renderMainNav()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useAppStore.getState().appDetail).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
it('collapses app detail navigation from the top-right toggle', async () => {
|
||||
mockPathname = '/app/app-1/overview'
|
||||
|
||||
renderMainNav()
|
||||
fireEvent.click(await screen.findByTestId('app-detail-toggle'))
|
||||
|
||||
expect(screen.getByRole('complementary')).toHaveClass('w-16')
|
||||
expect(screen.getByRole('complementary')).not.toHaveClass('transition-none')
|
||||
expect(screen.getByRole('complementary')).toHaveClass('p-1')
|
||||
expect(screen.getByTestId('app-detail-top')).toHaveAttribute('data-expand', 'false')
|
||||
expect(await screen.findByTestId('app-detail-section')).toHaveAttribute('data-expand', 'false')
|
||||
expect(localStorage.getItem(DETAIL_SIDEBAR_STORAGE_KEY)).toBe('collapse')
|
||||
})
|
||||
|
||||
it('shows app detail navigation as a floating preview when hovering the collapsed top toggle', async () => {
|
||||
mockPathname = '/app/app-1/overview'
|
||||
|
||||
renderMainNav()
|
||||
fireEvent.click(await screen.findByTestId('app-detail-toggle'))
|
||||
fireEvent.mouseEnter(screen.getByTestId('app-detail-top').parentElement!)
|
||||
|
||||
expect(screen.getByRole('complementary')).toHaveClass('w-16', 'overflow-visible')
|
||||
expect(localStorage.getItem(DETAIL_SIDEBAR_STORAGE_KEY)).toBe('collapse')
|
||||
expect(screen.getAllByTestId('app-detail-top')).toHaveLength(1)
|
||||
expect(screen.getByTestId('app-detail-top')).toHaveAttribute('data-expand', 'true')
|
||||
expect(await screen.findByTestId('app-detail-section')).toHaveAttribute('data-expand', 'true')
|
||||
})
|
||||
|
||||
it('persists expanded app detail navigation without width animation when clicking the hovered toggle', async () => {
|
||||
mockPathname = '/app/app-1/overview'
|
||||
|
||||
renderMainNav()
|
||||
fireEvent.click(await screen.findByTestId('app-detail-toggle'))
|
||||
fireEvent.mouseEnter(screen.getByTestId('app-detail-top').parentElement!)
|
||||
fireEvent.click(screen.getByTestId('app-detail-toggle'))
|
||||
|
||||
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(await screen.findByTestId('app-detail-section')).toHaveAttribute('data-expand', 'true')
|
||||
expect(localStorage.getItem(DETAIL_SIDEBAR_STORAGE_KEY)).toBe('expand')
|
||||
})
|
||||
|
||||
it('replaces global navigation with dataset detail navigation on dataset routes', async () => {
|
||||
mockPathname = '/datasets/dataset-1/documents'
|
||||
|
||||
renderMainNav()
|
||||
|
||||
expect(await screen.findByTestId('dataset-detail-top')).toBeInTheDocument()
|
||||
expect(await screen.findByTestId('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-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()
|
||||
expect(screen.queryByRole('link', { name: /common.mainNav.home/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: /common.menus.datasets/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('collapses dataset detail navigation from the top-right toggle', async () => {
|
||||
mockPathname = '/datasets/dataset-1/documents'
|
||||
|
||||
renderMainNav()
|
||||
fireEvent.click(await screen.findByTestId('dataset-detail-toggle'))
|
||||
|
||||
expect(screen.getByRole('complementary')).toHaveClass('w-16')
|
||||
expect(screen.getByRole('complementary')).toHaveClass('p-1')
|
||||
expect(screen.getByTestId('dataset-detail-top')).toHaveAttribute('data-expand', 'false')
|
||||
expect(await screen.findByTestId('dataset-detail-section')).toHaveAttribute('data-expand', 'false')
|
||||
expect(localStorage.getItem(DETAIL_SIDEBAR_STORAGE_KEY)).toBe('collapse')
|
||||
})
|
||||
|
||||
it('shows dataset detail navigation as a floating preview when hovering the collapsed top toggle', async () => {
|
||||
mockPathname = '/datasets/dataset-1/documents'
|
||||
|
||||
renderMainNav()
|
||||
fireEvent.click(await screen.findByTestId('dataset-detail-toggle'))
|
||||
fireEvent.mouseEnter(screen.getByTestId('dataset-detail-top').parentElement!)
|
||||
|
||||
expect(screen.getByRole('complementary')).toHaveClass('w-16', 'overflow-visible')
|
||||
expect(localStorage.getItem(DETAIL_SIDEBAR_STORAGE_KEY)).toBe('collapse')
|
||||
expect(screen.getAllByTestId('dataset-detail-top')).toHaveLength(1)
|
||||
expect(screen.getByTestId('dataset-detail-top')).toHaveAttribute('data-expand', 'true')
|
||||
expect(await screen.findByTestId('dataset-detail-section')).toHaveAttribute('data-expand', 'true')
|
||||
})
|
||||
|
||||
it('replaces global navigation with agent detail navigation on roster detail routes', async () => {
|
||||
mockPathname = '/roster/agent/agent-1/configure'
|
||||
|
||||
renderMainNav()
|
||||
|
||||
expect(await screen.findByTestId('agent-detail-top')).toBeInTheDocument()
|
||||
const agentDetailNavigation = await screen.findByRole('navigation', { name: 'agentV2.agentDetail.navigationLabel' })
|
||||
expect(agentDetailNavigation).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /agentV2.agentDetail.sections.configure/ })).toHaveAttribute('href', '/roster/agent/agent-1/configure')
|
||||
expect(screen.getByTestId('agent-detail-top')).toHaveAttribute('data-expand', 'true')
|
||||
expect(agentDetailNavigation).toHaveClass('px-1')
|
||||
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()
|
||||
expect(screen.queryByRole('link', { name: /common.menus.roster/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps roster detail navigation hidden when Agent v2 is disabled', () => {
|
||||
mockIsAgentV2Enabled.mockReturnValue(false)
|
||||
mockPathname = '/roster/agent/agent-1/configure'
|
||||
@ -858,49 +515,6 @@ describe('MainNav', () => {
|
||||
expect(screen.queryByRole('link', { name: /common.menus.roster/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('replaces global navigation with deployment detail navigation on deployment routes', () => {
|
||||
mockPathname = '/deployments/app-instance-1/releases'
|
||||
|
||||
renderMainNav({ branding: { enabled: false }, enable_app_deploy: true })
|
||||
|
||||
expect(screen.getByTestId('deployment-detail-top')).toBeInTheDocument()
|
||||
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-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()
|
||||
expect(screen.queryByRole('link', { name: /common.mainNav.home/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: /common.menus.deployments/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('collapses agent detail navigation from the top-right toggle', async () => {
|
||||
mockPathname = '/roster/agent/agent-1/configure'
|
||||
|
||||
renderMainNav()
|
||||
fireEvent.click(await screen.findByTestId('agent-detail-toggle'))
|
||||
|
||||
expect(screen.getByRole('complementary')).toHaveClass('w-16')
|
||||
expect(screen.getByRole('complementary')).toHaveClass('p-1')
|
||||
expect(screen.getByTestId('agent-detail-top')).toHaveAttribute('data-expand', 'false')
|
||||
expect(await screen.findByRole('navigation', { name: 'agentV2.agentDetail.navigationLabel' })).toHaveClass('px-3')
|
||||
expect(localStorage.getItem(DETAIL_SIDEBAR_STORAGE_KEY)).toBe('collapse')
|
||||
})
|
||||
|
||||
it('collapses deployment detail navigation from the top-right toggle', () => {
|
||||
mockPathname = '/deployments/app-instance-1/releases'
|
||||
|
||||
renderMainNav({ branding: { enabled: false }, enable_app_deploy: true })
|
||||
fireEvent.click(screen.getByTestId('deployment-detail-toggle'))
|
||||
|
||||
expect(screen.getByRole('complementary')).toHaveClass('w-16')
|
||||
expect(screen.getByRole('complementary')).toHaveClass('p-1')
|
||||
expect(screen.getByTestId('deployment-detail-top')).toHaveAttribute('data-expand', 'false')
|
||||
expect(screen.getByTestId('deployment-detail-section')).toHaveAttribute('data-expand', 'false')
|
||||
expect(localStorage.getItem(DETAIL_SIDEBAR_STORAGE_KEY)).toBe('collapse')
|
||||
})
|
||||
|
||||
it.each([
|
||||
'/deployments',
|
||||
'/deployments/create',
|
||||
@ -915,30 +529,6 @@ describe('MainNav', () => {
|
||||
expect(screen.getByRole('link', { name: /common.menus.deployments/ })).toHaveAttribute('href', '/deployments')
|
||||
})
|
||||
|
||||
it('registers the detail navigation shortcut to run while inputs are focused', () => {
|
||||
mockPathname = '/app/app-1/overview'
|
||||
|
||||
renderMainNav()
|
||||
|
||||
expect(hotkeyRegistrations.get('Mod+B')?.options).toEqual(
|
||||
expect.objectContaining({ ignoreInputs: false }),
|
||||
)
|
||||
})
|
||||
|
||||
it('shows agent detail navigation as a floating preview when hovering the collapsed top toggle', async () => {
|
||||
mockPathname = '/roster/agent/agent-1/configure'
|
||||
|
||||
renderMainNav()
|
||||
fireEvent.click(await screen.findByTestId('agent-detail-toggle'))
|
||||
fireEvent.mouseEnter(screen.getByTestId('agent-detail-top').parentElement!)
|
||||
|
||||
expect(screen.getByRole('complementary')).toHaveClass('w-16', 'overflow-visible')
|
||||
expect(localStorage.getItem(DETAIL_SIDEBAR_STORAGE_KEY)).toBe('collapse')
|
||||
expect(screen.getAllByTestId('agent-detail-top')).toHaveLength(1)
|
||||
expect(screen.getByTestId('agent-detail-top')).toHaveAttribute('data-expand', 'true')
|
||||
expect(await screen.findByRole('navigation', { name: 'agentV2.agentDetail.navigationLabel' })).toHaveClass('px-1')
|
||||
})
|
||||
|
||||
it.each([
|
||||
'/datasets/create',
|
||||
'/datasets/create-from-pipeline',
|
||||
|
||||
@ -1,5 +1,11 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Mock } from 'vitest'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { isAgentV2Enabled } from '@/features/agent-v2/feature-flag'
|
||||
import { usePathname } from '@/next/navigation'
|
||||
import MainNavLayout from '../layout'
|
||||
|
||||
vi.mock('@/app/components/header', () => ({
|
||||
@ -16,13 +22,50 @@ vi.mock('react-i18next', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-query', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
|
||||
return {
|
||||
...actual,
|
||||
useSuspenseQuery: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/agent-v2/feature-flag', () => ({
|
||||
isAgentV2Enabled: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/next/navigation')>()
|
||||
return {
|
||||
...actual,
|
||||
usePathname: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../index', () => ({
|
||||
MainNav: ({ className }: { className?: string }) => <aside className={className} data-testid="main-nav">MainNav</aside>,
|
||||
}))
|
||||
|
||||
describe('MainNavLayout', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
useAppStore.getState().setAppDetail()
|
||||
;(usePathname as Mock).mockReturnValue('/apps')
|
||||
;(useAppContext as Mock).mockReturnValue({
|
||||
isCurrentWorkspaceDatasetOperator: false,
|
||||
isCurrentWorkspaceEditor: true,
|
||||
})
|
||||
;(useSuspenseQuery as Mock).mockReturnValue({
|
||||
data: {
|
||||
enable_app_deploy: true,
|
||||
},
|
||||
})
|
||||
;(isAgentV2Enabled as Mock).mockReturnValue(true)
|
||||
})
|
||||
|
||||
it('renders desktop main nav instead of the desktop header', () => {
|
||||
@ -73,4 +116,105 @@ describe('MainNavLayout', () => {
|
||||
|
||||
expect(main).toHaveFocus()
|
||||
})
|
||||
|
||||
it.each([
|
||||
'/datasets/dataset-1/documents',
|
||||
'/datasets/dataset-1/documents/document-1/settings',
|
||||
])('renders the detail sidebar slot outside the single skip navigation target on route %s', (pathname) => {
|
||||
;(usePathname as Mock).mockReturnValue(pathname)
|
||||
|
||||
render(
|
||||
<MainNavLayout detailSidebar={<aside aria-label="Detail sidebar">Detail sidebar</aside>}>
|
||||
<div>dataset detail</div>
|
||||
</MainNavLayout>,
|
||||
)
|
||||
|
||||
const main = screen.getByRole('main')
|
||||
const detailSidebar = screen.getByRole('complementary', { name: 'Detail sidebar' })
|
||||
|
||||
expect(screen.queryByTestId('main-nav')).not.toBeInTheDocument()
|
||||
expect(screen.getAllByRole('main')).toHaveLength(1)
|
||||
expect(main).toHaveAttribute('id', 'main-content')
|
||||
expect(main).toHaveTextContent('dataset detail')
|
||||
expect(main).not.toContainElement(detailSidebar)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'/datasets/create',
|
||||
'/datasets/dataset-1/documents/create',
|
||||
'/deployments/create',
|
||||
])('keeps the global main nav on collection and creation route %s', (pathname) => {
|
||||
;(usePathname as Mock).mockReturnValue(pathname)
|
||||
|
||||
render(
|
||||
<MainNavLayout detailSidebar={<aside aria-label="Detail sidebar">Detail sidebar</aside>}>
|
||||
<div>content</div>
|
||||
</MainNavLayout>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('main-nav')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('complementary', { name: 'Detail sidebar' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: 'agent detail route for dataset operators',
|
||||
pathname: '/roster/agent/agent-1/configure',
|
||||
appContext: {
|
||||
isCurrentWorkspaceDatasetOperator: true,
|
||||
isCurrentWorkspaceEditor: true,
|
||||
},
|
||||
systemFeatures: {
|
||||
enable_app_deploy: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'deployment detail route for non-editor workspaces',
|
||||
pathname: '/deployments/app-instance-1/overview',
|
||||
appContext: {
|
||||
isCurrentWorkspaceDatasetOperator: false,
|
||||
isCurrentWorkspaceEditor: false,
|
||||
},
|
||||
systemFeatures: {
|
||||
enable_app_deploy: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'deployment detail route when deployment is disabled',
|
||||
pathname: '/deployments/app-instance-1/overview',
|
||||
appContext: {
|
||||
isCurrentWorkspaceDatasetOperator: false,
|
||||
isCurrentWorkspaceEditor: true,
|
||||
},
|
||||
systemFeatures: {
|
||||
enable_app_deploy: false,
|
||||
},
|
||||
},
|
||||
])('keeps the global main nav on $label', ({ pathname, appContext, systemFeatures }) => {
|
||||
;(usePathname as Mock).mockReturnValue(pathname)
|
||||
;(useAppContext as Mock).mockReturnValue(appContext)
|
||||
;(useSuspenseQuery as Mock).mockReturnValue({
|
||||
data: systemFeatures,
|
||||
})
|
||||
|
||||
render(
|
||||
<MainNavLayout detailSidebar={<aside aria-label="Detail sidebar">Detail sidebar</aside>}>
|
||||
<div>detail route content</div>
|
||||
</MainNavLayout>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('main-nav')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('complementary', { name: 'Detail sidebar' })).not.toBeInTheDocument()
|
||||
expect(screen.getAllByRole('main')).toHaveLength(1)
|
||||
expect(screen.getByRole('main')).toHaveTextContent('detail route content')
|
||||
})
|
||||
|
||||
it('clears app detail state after leaving app routes', () => {
|
||||
useAppStore.getState().setAppDetail({ id: 'app-1' } as ReturnType<typeof useAppStore.getState>['appDetail'])
|
||||
;(usePathname as Mock).mockReturnValue('/datasets')
|
||||
|
||||
render(<MainNavLayout><div>content</div></MainNavLayout>)
|
||||
|
||||
expect(useAppStore.getState().appDetail).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@ -2,18 +2,14 @@
|
||||
|
||||
import type { MainNavItem, MainNavProps } from './types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useHotkey } from '@tanstack/react-hotkeys'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
import DifyLogo from '@/app/components/base/logo/dify-logo'
|
||||
import EnvNav from '@/app/components/header/env-nav'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { isAgentV2Enabled } from '@/features/agent-v2/feature-flag'
|
||||
import { DeploymentDetailSection, DeploymentDetailTop } from '@/features/deployments/detail/deployment-sidebar'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import dynamic from '@/next/dynamic'
|
||||
import Link from '@/next/link'
|
||||
@ -24,69 +20,9 @@ import MainNavLink from './components/nav-link'
|
||||
import { MainNavSearchButton } from './components/search-button'
|
||||
import { WorkspaceCard } from './components/workspace-card'
|
||||
import { isMainNavRouteVisible, MAIN_NAV_ROUTES } from './routes'
|
||||
import { useDetailSidebarMode } from './storage'
|
||||
|
||||
const DATASET_COLLECTION_ROUTES = new Set(['create', 'create-from-pipeline', 'connect'])
|
||||
const DATASET_DOCUMENT_CREATION_ROUTES = new Set(['create', 'create-from-pipeline'])
|
||||
const DEPLOYMENT_COLLECTION_ROUTES = new Set(['create'])
|
||||
const secondarySidebarHelpTriggerIcon = <span aria-hidden className="i-ri-question-line size-4 shrink-0" />
|
||||
|
||||
const AppDetailSection = dynamic(() => import('@/app/components/app-sidebar/app-detail-section'), { ssr: false })
|
||||
const AppDetailTop = dynamic(() => import('@/app/components/app-sidebar/app-detail-top'), { ssr: false })
|
||||
const DatasetDetailSection = dynamic(() => import('@/app/components/app-sidebar/dataset-detail-section'), { ssr: false })
|
||||
const DatasetDetailTop = dynamic(() => import('@/app/components/app-sidebar/dataset-detail-top'), { ssr: false })
|
||||
const AgentDetailSection = dynamic(() => import('@/features/agent-v2/agent-detail/navigation').then(mod => mod.AgentDetailSection), { ssr: false })
|
||||
const AgentDetailTop = dynamic(() => import('@/features/agent-v2/agent-detail/navigation').then(mod => mod.AgentDetailTop), { ssr: false })
|
||||
const SnippetDetailTop = dynamic(() => import('./components/snippet-detail-top'), { ssr: false })
|
||||
const SnippetDetailSection = dynamic(() => import('./components/snippet-detail-section').then(mod => mod.SnippetDetailSection), { ssr: false })
|
||||
const WebAppsSection = dynamic(() => import('./components/web-apps-section'), { ssr: false })
|
||||
|
||||
function SecondarySidebarHelpMenu({
|
||||
triggerClassName,
|
||||
}: {
|
||||
triggerClassName?: string
|
||||
}) {
|
||||
return (
|
||||
<HelpMenu
|
||||
triggerIcon={secondarySidebarHelpTriggerIcon}
|
||||
triggerClassName={triggerClassName}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const isDatasetDetailPathname = (pathname: string) => {
|
||||
const [section, datasetId, subSection, action] = pathname.split('/').filter(Boolean)
|
||||
|
||||
if (section !== 'datasets' || !datasetId)
|
||||
return false
|
||||
|
||||
if (DATASET_COLLECTION_ROUTES.has(datasetId))
|
||||
return false
|
||||
|
||||
if (subSection === 'documents' && action && DATASET_DOCUMENT_CREATION_ROUTES.has(action))
|
||||
return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const isAgentDetailPathname = (pathname: string) => {
|
||||
const [section, type, agentId] = pathname.split('/').filter(Boolean)
|
||||
|
||||
return section === 'roster' && type === 'agent' && !!agentId
|
||||
}
|
||||
|
||||
const isDeploymentDetailPathname = (pathname: string) => {
|
||||
const [section, appInstanceId] = pathname.split('/').filter(Boolean)
|
||||
|
||||
return section === 'deployments' && !!appInstanceId && !DEPLOYMENT_COLLECTION_ROUTES.has(appInstanceId)
|
||||
}
|
||||
|
||||
const isSnippetDetailPathname = (pathname: string) => {
|
||||
const [section, snippetId] = pathname.split('/').filter(Boolean)
|
||||
|
||||
return section === 'snippets' && !!snippetId
|
||||
}
|
||||
|
||||
export function MainNav({
|
||||
className,
|
||||
}: MainNavProps) {
|
||||
@ -97,88 +33,6 @@ export function MainNav({
|
||||
const agentV2Enabled = isAgentV2Enabled()
|
||||
const showEnvTag = langGeniusVersionInfo.current_env === 'TESTING' || langGeniusVersionInfo.current_env === 'DEVELOPMENT'
|
||||
const canUseAppDeploy = isCurrentWorkspaceEditor && systemFeatures.enable_app_deploy
|
||||
const showAppDetailNavigation = !isCurrentWorkspaceDatasetOperator && pathname.startsWith('/app/')
|
||||
const showDatasetDetailNavigation = isDatasetDetailPathname(pathname)
|
||||
const showAgentDetailNavigation = agentV2Enabled && !isCurrentWorkspaceDatasetOperator && isAgentDetailPathname(pathname)
|
||||
const showDeploymentDetailNavigation = canUseAppDeploy && !isCurrentWorkspaceDatasetOperator && isDeploymentDetailPathname(pathname)
|
||||
const showSnippetDetailNavigation = isSnippetDetailPathname(pathname)
|
||||
const showDetailNavigation = showAppDetailNavigation || showDatasetDetailNavigation || showAgentDetailNavigation || showDeploymentDetailNavigation || showSnippetDetailNavigation
|
||||
const { hasAppDetail, setAppDetail } = useAppStore(useShallow(state => ({
|
||||
hasAppDetail: !!state.appDetail,
|
||||
setAppDetail: state.setAppDetail,
|
||||
})))
|
||||
const [storedDetailSidebarExpand, setStoredDetailSidebarExpand] = useDetailSidebarMode()
|
||||
const detailNavigationMode = storedDetailSidebarExpand === 'collapse' ? 'collapse' : 'expand'
|
||||
const detailNavigationExpanded = detailNavigationMode === 'expand'
|
||||
const isCollapsedDetailNavigation = showDetailNavigation && !detailNavigationExpanded
|
||||
const [detailNavigationHoverPreviewOpen, setDetailNavigationHoverPreviewOpen] = useState(false)
|
||||
const [detailNavigationTransitionDisabled, setDetailNavigationTransitionDisabled] = useState(false)
|
||||
const closeDetailNavigationHoverPreviewTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const detailNavigationTransitionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const isDetailNavigationHoverPreviewOpen = isCollapsedDetailNavigation && detailNavigationHoverPreviewOpen
|
||||
const detailNavigationVisibleExpanded = detailNavigationExpanded || isDetailNavigationHoverPreviewOpen
|
||||
const bottomNavigationExpanded = !showDetailNavigation || detailNavigationVisibleExpanded
|
||||
const handleToggleDetailNavigation = useCallback(() => {
|
||||
if (isDetailNavigationHoverPreviewOpen) {
|
||||
if (detailNavigationTransitionTimerRef.current)
|
||||
clearTimeout(detailNavigationTransitionTimerRef.current)
|
||||
|
||||
setDetailNavigationTransitionDisabled(true)
|
||||
setDetailNavigationHoverPreviewOpen(false)
|
||||
setStoredDetailSidebarExpand('expand')
|
||||
detailNavigationTransitionTimerRef.current = setTimeout(() => {
|
||||
setDetailNavigationTransitionDisabled(false)
|
||||
}, 200)
|
||||
return
|
||||
}
|
||||
|
||||
const nextMode = detailNavigationExpanded ? 'collapse' : 'expand'
|
||||
setDetailNavigationHoverPreviewOpen(false)
|
||||
setStoredDetailSidebarExpand(nextMode)
|
||||
}, [detailNavigationExpanded, isDetailNavigationHoverPreviewOpen, setStoredDetailSidebarExpand])
|
||||
const openDetailNavigationHoverPreview = useCallback(() => {
|
||||
if (!isCollapsedDetailNavigation)
|
||||
return
|
||||
|
||||
if (closeDetailNavigationHoverPreviewTimerRef.current)
|
||||
clearTimeout(closeDetailNavigationHoverPreviewTimerRef.current)
|
||||
|
||||
setDetailNavigationHoverPreviewOpen(true)
|
||||
}, [isCollapsedDetailNavigation])
|
||||
const closeDetailNavigationHoverPreview = useCallback(() => {
|
||||
if (closeDetailNavigationHoverPreviewTimerRef.current)
|
||||
clearTimeout(closeDetailNavigationHoverPreviewTimerRef.current)
|
||||
|
||||
closeDetailNavigationHoverPreviewTimerRef.current = setTimeout(() => {
|
||||
setDetailNavigationHoverPreviewOpen(false)
|
||||
}, 120)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (closeDetailNavigationHoverPreviewTimerRef.current)
|
||||
clearTimeout(closeDetailNavigationHoverPreviewTimerRef.current)
|
||||
if (detailNavigationTransitionTimerRef.current)
|
||||
clearTimeout(detailNavigationTransitionTimerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (pathname.startsWith('/app/') || !hasAppDetail)
|
||||
return
|
||||
|
||||
setAppDetail()
|
||||
}, [hasAppDetail, pathname, setAppDetail])
|
||||
|
||||
useHotkey('Mod+B', (e) => {
|
||||
if (!showDetailNavigation)
|
||||
return
|
||||
|
||||
e.preventDefault()
|
||||
handleToggleDetailNavigation()
|
||||
}, {
|
||||
ignoreInputs: false,
|
||||
})
|
||||
|
||||
const navItems = useMemo<MainNavItem[]>(() => MAIN_NAV_ROUTES
|
||||
.filter(route => isMainNavRouteVisible(route, {
|
||||
@ -220,143 +74,45 @@ export function MainNav({
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
'relative flex h-full shrink-0',
|
||||
detailNavigationTransitionDisabled ? 'transition-none' : 'transition-all',
|
||||
isDetailNavigationHoverPreviewOpen ? 'overflow-visible' : 'overflow-hidden',
|
||||
showDetailNavigation
|
||||
? detailNavigationExpanded
|
||||
? 'w-62 bg-background-body p-1'
|
||||
: 'w-16 bg-background-body p-1'
|
||||
: 'w-60 flex-col',
|
||||
'bg-background-body',
|
||||
'relative flex h-full w-60 shrink-0 flex-col overflow-hidden bg-background-body transition-all',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-h-0 flex-1 flex-col',
|
||||
showDetailNavigation && (
|
||||
isDetailNavigationHoverPreviewOpen
|
||||
? 'absolute top-1 bottom-1 left-1 z-40 w-60 overflow-hidden rounded-lg border border-divider-subtle bg-components-panel-bg shadow-lg'
|
||||
: 'overflow-hidden rounded-lg bg-components-panel-bg'
|
||||
),
|
||||
showDetailNavigation && (detailNavigationVisibleExpanded ? 'w-60' : 'w-14'),
|
||||
)}
|
||||
onMouseEnter={isCollapsedDetailNavigation ? openDetailNavigationHoverPreview : undefined}
|
||||
onMouseLeave={isCollapsedDetailNavigation ? closeDetailNavigationHoverPreview : undefined}
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{showDetailNavigation
|
||||
? showAppDetailNavigation
|
||||
? (
|
||||
<AppDetailTop
|
||||
expand={detailNavigationVisibleExpanded}
|
||||
onToggle={handleToggleDetailNavigation}
|
||||
/>
|
||||
)
|
||||
: showDatasetDetailNavigation
|
||||
? (
|
||||
<DatasetDetailTop
|
||||
expand={detailNavigationVisibleExpanded}
|
||||
onToggle={handleToggleDetailNavigation}
|
||||
/>
|
||||
)
|
||||
: showAgentDetailNavigation
|
||||
? (
|
||||
<AgentDetailTop
|
||||
expand={detailNavigationVisibleExpanded}
|
||||
onToggle={handleToggleDetailNavigation}
|
||||
/>
|
||||
)
|
||||
: showDeploymentDetailNavigation
|
||||
? (
|
||||
<DeploymentDetailTop
|
||||
expand={detailNavigationVisibleExpanded}
|
||||
onToggle={handleToggleDetailNavigation}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<SnippetDetailTop
|
||||
expand={detailNavigationVisibleExpanded}
|
||||
onToggle={handleToggleDetailNavigation}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<div className="flex items-center justify-between pt-3 pr-2 pb-2 pl-4">
|
||||
{renderLogo()}
|
||||
<MainNavSearchButton />
|
||||
</div>
|
||||
<div className="p-2">
|
||||
<WorkspaceCard />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{showDetailNavigation
|
||||
? showAppDetailNavigation
|
||||
? <AppDetailSection expand={detailNavigationVisibleExpanded} />
|
||||
: showDatasetDetailNavigation
|
||||
? <DatasetDetailSection expand={detailNavigationVisibleExpanded} />
|
||||
: showAgentDetailNavigation
|
||||
? <AgentDetailSection expand={detailNavigationVisibleExpanded} />
|
||||
: showDeploymentDetailNavigation
|
||||
? <DeploymentDetailSection expand={detailNavigationVisibleExpanded} />
|
||||
: <SnippetDetailSection expand={detailNavigationVisibleExpanded} />
|
||||
: (
|
||||
<>
|
||||
<nav className="isolate flex flex-col gap-px p-2">
|
||||
{navItems.map(item => (
|
||||
<MainNavLink key={item.href} item={item} pathname={pathname}>
|
||||
{item.href === '/roster' && (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="dimm"
|
||||
text={t('menus.status', { ns: 'common' })}
|
||||
className="ml-auto shrink-0"
|
||||
/>
|
||||
)}
|
||||
</MainNavLink>
|
||||
))}
|
||||
</nav>
|
||||
{!isCurrentWorkspaceDatasetOperator && <WebAppsSection />}
|
||||
</>
|
||||
)}
|
||||
{showEnvTag && detailNavigationVisibleExpanded && (
|
||||
<div className="relative z-30 mt-auto shrink-0 px-3 pb-2">
|
||||
<EnvNav />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex items-center justify-between pt-3 pr-2 pb-2 pl-4">
|
||||
{renderLogo()}
|
||||
<MainNavSearchButton />
|
||||
</div>
|
||||
<div className={cn(
|
||||
!bottomNavigationExpanded
|
||||
? 'flex w-full shrink-0 flex-col items-center gap-0.5 rounded-lg px-2 pt-1 pb-3'
|
||||
: cn(
|
||||
'flex w-60 items-center justify-between py-3 pr-1 pl-3',
|
||||
showDetailNavigation
|
||||
? 'bg-components-panel-bg'
|
||||
: 'bg-gradient-to-b from-background-body-transparent to-background-body to-50% backdrop-blur-[2px]',
|
||||
),
|
||||
)}
|
||||
>
|
||||
{!bottomNavigationExpanded
|
||||
? (
|
||||
<>
|
||||
<SecondarySidebarHelpMenu triggerClassName="mb-2" />
|
||||
<AccountSection compact />
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<div className="flex min-w-0 items-center gap-1 overflow-hidden">
|
||||
<AccountSection />
|
||||
</div>
|
||||
{(!showDetailNavigation || detailNavigationVisibleExpanded) && (
|
||||
<div className="flex shrink-0 items-center justify-center rounded-full p-1">
|
||||
{showDetailNavigation ? <SecondarySidebarHelpMenu /> : <HelpMenu />}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
<div className="p-2">
|
||||
<WorkspaceCard />
|
||||
</div>
|
||||
<nav className="isolate flex flex-col gap-px p-2">
|
||||
{navItems.map(item => (
|
||||
<MainNavLink key={item.href} item={item} pathname={pathname}>
|
||||
{item.href === '/roster' && (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="dimm"
|
||||
text={t('menus.status', { ns: 'common' })}
|
||||
className="ml-auto shrink-0"
|
||||
/>
|
||||
)}
|
||||
</MainNavLink>
|
||||
))}
|
||||
</nav>
|
||||
{!isCurrentWorkspaceDatasetOperator && <WebAppsSection />}
|
||||
{showEnvTag && (
|
||||
<div className="relative z-30 mt-auto shrink-0 px-3 pb-2">
|
||||
<EnvNav />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex w-60 items-center justify-between bg-gradient-to-b from-background-body-transparent to-background-body to-50% py-3 pr-1 pl-3 backdrop-blur-[2px]">
|
||||
<div className="flex min-w-0 items-center gap-1 overflow-hidden">
|
||||
<AccountSection />
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center justify-center rounded-full p-1">
|
||||
<HelpMenu />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@ -1,27 +1,64 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { isAgentV2Enabled } from '@/features/agent-v2/feature-flag'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { usePathname } from '@/next/navigation'
|
||||
import { MainNav } from '.'
|
||||
import { shouldUseDetailSidebar } from './routes'
|
||||
import { MAIN_CONTENT_ID, SkipNav } from './skip-nav'
|
||||
|
||||
type MainNavLayoutProps = {
|
||||
children: ReactNode
|
||||
detailSidebar?: ReactNode
|
||||
}
|
||||
|
||||
function AppDetailStoreCleanup() {
|
||||
const pathname = usePathname()
|
||||
const { hasAppDetail, setAppDetail } = useAppStore(useShallow(state => ({
|
||||
hasAppDetail: !!state.appDetail,
|
||||
setAppDetail: state.setAppDetail,
|
||||
})))
|
||||
|
||||
useEffect(() => {
|
||||
if (pathname.startsWith('/app/') || !hasAppDetail)
|
||||
return
|
||||
|
||||
setAppDetail()
|
||||
}, [hasAppDetail, pathname, setAppDetail])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const MainNavLayout = ({
|
||||
children,
|
||||
detailSidebar,
|
||||
}: MainNavLayoutProps) => {
|
||||
const { t } = useTranslation('common')
|
||||
const pathname = usePathname()
|
||||
const { isCurrentWorkspaceDatasetOperator, isCurrentWorkspaceEditor } = useAppContext()
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const shouldHideMainNav = shouldUseDetailSidebar(pathname, {
|
||||
agentV2Enabled: isAgentV2Enabled(),
|
||||
canUseAppDeploy: isCurrentWorkspaceEditor && systemFeatures.enable_app_deploy,
|
||||
isCurrentWorkspaceDatasetOperator,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex h-0 min-h-0 grow overflow-hidden bg-background-body">
|
||||
<div className="flex h-0 min-h-0 min-w-0 grow overflow-hidden bg-background-body">
|
||||
<SkipNav>{t('navigation.skipToMain')}</SkipNav>
|
||||
<MainNav />
|
||||
<AppDetailStoreCleanup />
|
||||
{shouldHideMainNav ? detailSidebar : <MainNav />}
|
||||
<main
|
||||
id={MAIN_CONTENT_ID}
|
||||
tabIndex={-1}
|
||||
className="flex min-w-0 grow flex-col overflow-hidden outline-hidden focus:outline-hidden focus-visible:outline-hidden"
|
||||
className="flex min-h-0 min-w-0 grow flex-col overflow-hidden outline-hidden focus:outline-hidden focus-visible:outline-hidden"
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
|
||||
@ -2,6 +2,10 @@ import { buildIntegrationPath } from '@/app/components/integrations/routes'
|
||||
|
||||
type MainNavRouteVisibility = 'all' | 'notDatasetOperator' | 'appDeployEditor'
|
||||
|
||||
const DATASET_COLLECTION_ROUTES = new Set(['create', 'create-from-pipeline', 'connect'])
|
||||
const DATASET_DOCUMENT_CREATION_ROUTES = new Set(['create', 'create-from-pipeline'])
|
||||
const DEPLOYMENT_COLLECTION_ROUTES = new Set(['create'])
|
||||
|
||||
export type MainNavRouteConfig = {
|
||||
key: string
|
||||
href: string
|
||||
@ -20,6 +24,11 @@ export type MainNavRouteVisibilityOptions = {
|
||||
marketplaceEnabled: boolean
|
||||
}
|
||||
|
||||
export type DetailSidebarVisibilityOptions = Pick<
|
||||
MainNavRouteVisibilityOptions,
|
||||
'agentV2Enabled' | 'canUseAppDeploy' | 'isCurrentWorkspaceDatasetOperator'
|
||||
>
|
||||
|
||||
function isPathUnderRoute(pathname: string, route: string) {
|
||||
return pathname === route || pathname.startsWith(`${route}/`)
|
||||
}
|
||||
@ -107,3 +116,56 @@ export function isMainNavRouteVisible(route: MainNavRouteConfig, options: MainNa
|
||||
|
||||
return options.canUseAppDeploy
|
||||
}
|
||||
|
||||
function isAppDetailPathname(pathname: string) {
|
||||
return pathname.startsWith('/app/')
|
||||
}
|
||||
|
||||
function isDatasetDetailPathname(pathname: string) {
|
||||
const [section, datasetId, subSection, action] = pathname.split('/').filter(Boolean)
|
||||
|
||||
if (section !== 'datasets' || !datasetId)
|
||||
return false
|
||||
|
||||
if (DATASET_COLLECTION_ROUTES.has(datasetId))
|
||||
return false
|
||||
|
||||
if (subSection === 'documents' && action && DATASET_DOCUMENT_CREATION_ROUTES.has(action))
|
||||
return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function isAgentDetailPathname(pathname: string) {
|
||||
const [section, type, agentId] = pathname.split('/').filter(Boolean)
|
||||
|
||||
return section === 'roster' && type === 'agent' && !!agentId
|
||||
}
|
||||
|
||||
function isDeploymentDetailPathname(pathname: string) {
|
||||
const [section, appInstanceId] = pathname.split('/').filter(Boolean)
|
||||
|
||||
return section === 'deployments' && !!appInstanceId && !DEPLOYMENT_COLLECTION_ROUTES.has(appInstanceId)
|
||||
}
|
||||
|
||||
function isSnippetDetailPathname(pathname: string) {
|
||||
const [section, snippetId] = pathname.split('/').filter(Boolean)
|
||||
|
||||
return section === 'snippets' && !!snippetId
|
||||
}
|
||||
|
||||
export function shouldUseDetailSidebar(pathname: string, options: DetailSidebarVisibilityOptions) {
|
||||
if (isDatasetDetailPathname(pathname) || isSnippetDetailPathname(pathname))
|
||||
return true
|
||||
|
||||
if (options.isCurrentWorkspaceDatasetOperator)
|
||||
return false
|
||||
|
||||
if (isAppDetailPathname(pathname))
|
||||
return true
|
||||
|
||||
if (options.agentV2Enabled && isAgentDetailPathname(pathname))
|
||||
return true
|
||||
|
||||
return options.canUseAppDeploy && isDeploymentDetailPathname(pathname)
|
||||
}
|
||||
|
||||
@ -155,12 +155,13 @@ describe('SnippetPage', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should render the orchestrate route shell with independent main content', () => {
|
||||
it('should render the orchestrate route shell without owning the main landmark', () => {
|
||||
render(<SnippetPage snippetId="snippet-1" />)
|
||||
|
||||
expect(screen.getByTestId('workflow-context-provider')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('workflow-default-context')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('snippet-main')).toHaveTextContent('snippet-1')
|
||||
expect(screen.queryByRole('main')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should initialize workflow context with published graph data when the published workflow exists', () => {
|
||||
@ -203,4 +204,16 @@ describe('SnippetPage', () => {
|
||||
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep the detail route shell while orchestrate data is loading', () => {
|
||||
mockUseSnippetInit.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
})
|
||||
|
||||
render(<SnippetPage snippetId="snippet-1" />)
|
||||
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('main')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -42,7 +42,7 @@ describe('SnippetLayout', () => {
|
||||
})
|
||||
|
||||
describe('Layout', () => {
|
||||
it('should render the detail content without the app detail sidebar navigation', () => {
|
||||
it('should render the detail content without owning sidebar navigation', () => {
|
||||
render(
|
||||
<SnippetLayout
|
||||
snippetId="snippet-1"
|
||||
|
||||
@ -0,0 +1,19 @@
|
||||
'use client'
|
||||
|
||||
import { DetailSidebarFrame } from '@/app/components/detail-sidebar'
|
||||
import { SnippetDetailSection } from './snippet-detail-section'
|
||||
import SnippetDetailTop from './snippet-detail-top'
|
||||
|
||||
export function SnippetDetailSidebar() {
|
||||
return (
|
||||
<DetailSidebarFrame
|
||||
renderTop={({ expand, onToggle }) => (
|
||||
<SnippetDetailTop
|
||||
expand={expand}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
)}
|
||||
renderSection={({ expand }) => <SnippetDetailSection expand={expand} />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -8,7 +8,7 @@ import useDocumentTitle from '@/hooks/use-document-title'
|
||||
type SnippetLayoutProps = {
|
||||
children: ReactNode
|
||||
section: SnippetSection
|
||||
snippet: SnippetDetail
|
||||
snippet?: SnippetDetail
|
||||
snippetId: string
|
||||
}
|
||||
|
||||
@ -18,10 +18,10 @@ const SnippetLayout = ({
|
||||
}: SnippetLayoutProps) => {
|
||||
const { t } = useTranslation('snippet')
|
||||
|
||||
useDocumentTitle(snippet.name || t('typeLabel'))
|
||||
useDocumentTitle(snippet?.name || t('typeLabel'))
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full overflow-hidden bg-background-body">
|
||||
<div className="relative flex h-full min-h-0 min-w-0 overflow-hidden bg-background-body">
|
||||
<div className="relative min-h-0 min-w-0 grow overflow-hidden">
|
||||
<div className="absolute inset-0 min-h-0 min-w-0 overflow-hidden">
|
||||
{children}
|
||||
|
||||
@ -16,11 +16,16 @@ type SnippetPageProps = {
|
||||
snippetId: string
|
||||
}
|
||||
|
||||
const SnippetPageLoading = () => {
|
||||
const SnippetPageLoading = ({ snippetId }: SnippetPageProps) => {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-background-body">
|
||||
<Loading />
|
||||
</div>
|
||||
<SnippetLayout
|
||||
snippetId={snippetId}
|
||||
section="orchestrate"
|
||||
>
|
||||
<div className="flex h-full items-center justify-center bg-background-body">
|
||||
<Loading />
|
||||
</div>
|
||||
</SnippetLayout>
|
||||
)
|
||||
}
|
||||
|
||||
@ -52,7 +57,7 @@ const SnippetPage = ({ snippetId }: SnippetPageProps) => {
|
||||
}, [data])
|
||||
|
||||
if (!data || isLoading) {
|
||||
return <SnippetPageLoading />
|
||||
return <SnippetPageLoading snippetId={snippetId} />
|
||||
}
|
||||
|
||||
const hasPublishedWorkflow = !!data.publishedWorkflow
|
||||
|
||||
48
web/features/agent-v2/agent-detail/__tests__/layout.spec.tsx
Normal file
48
web/features/agent-v2/agent-detail/__tests__/layout.spec.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { AgentDetailLayout } from '../layout'
|
||||
|
||||
vi.mock('@tanstack/react-query', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
|
||||
return {
|
||||
...actual,
|
||||
useQuery: vi.fn(() => ({
|
||||
data: {
|
||||
name: 'Agent',
|
||||
},
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/hooks/use-document-title', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
agent: {
|
||||
byAgentId: {
|
||||
get: {
|
||||
queryOptions: vi.fn(input => input),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
describe('AgentDetailLayout', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render detail content without owning navigation landmarks', () => {
|
||||
render(
|
||||
<AgentDetailLayout agentId="agent-1">
|
||||
<div>Agent detail content</div>
|
||||
</AgentDetailLayout>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Agent detail content')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('main')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('complementary', { name: 'Detail sidebar' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -430,6 +430,28 @@ describe('AgentConfigurePage', () => {
|
||||
expect(screen.getByRole('status', { name: 'appApi.loading' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('region', { name: 'orchestrate-panel' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should expose a single configure workspace region after loading', () => {
|
||||
const queryClient = new QueryClient()
|
||||
mocks.queryState.composer = {
|
||||
data: {},
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
isPending: false,
|
||||
isSuccess: true,
|
||||
refetch: vi.fn(),
|
||||
}
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AgentConfigurePage agentId="agent-1" />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getAllByRole('region', { name: 'agentV2.agentDetail.sections.configure' })).toHaveLength(1)
|
||||
expect(screen.getByRole('region', { name: 'agentV2.agentDetail.sections.configure' })).toBeVisible()
|
||||
expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Right panel mode', () => {
|
||||
|
||||
@ -128,7 +128,6 @@ export function AgentOrchestratePanel({
|
||||
<ScrollArea
|
||||
className="min-h-0 flex-1 overflow-hidden"
|
||||
label={showHeader ? undefined : orchestrateLabel}
|
||||
labelledBy={showHeader ? orchestrateHeadingId : undefined}
|
||||
slotClassNames={{
|
||||
viewport: 'overscroll-contain',
|
||||
content: cn('min-h-full px-4 py-3', hasBottomAction && 'pb-20'),
|
||||
|
||||
18
web/features/agent-v2/agent-detail/detail-sidebar.tsx
Normal file
18
web/features/agent-v2/agent-detail/detail-sidebar.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
'use client'
|
||||
|
||||
import { DetailSidebarFrame } from '@/app/components/detail-sidebar'
|
||||
import { AgentDetailSection, AgentDetailTop } from './navigation'
|
||||
|
||||
export function AgentDetailSidebar() {
|
||||
return (
|
||||
<DetailSidebarFrame
|
||||
renderTop={({ expand, onToggle }) => (
|
||||
<AgentDetailTop
|
||||
expand={expand}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
)}
|
||||
renderSection={({ expand }) => <AgentDetailSection expand={expand} />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -27,7 +27,7 @@ export function AgentDetailLayout({
|
||||
useDocumentTitle(agentQuery.data?.name ?? t('agentDetail.documentTitle'))
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full min-w-0 flex-col overflow-hidden">
|
||||
<div className="relative flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="min-h-0 min-w-0 flex-1 overflow-auto">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
62
web/features/deployments/detail/__tests__/index.spec.tsx
Normal file
62
web/features/deployments/detail/__tests__/index.spec.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
import type { Mock } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { Provider as JotaiProvider } from 'jotai'
|
||||
import { NextRouteStateBridge } from '@/app/components/next-route-state'
|
||||
import { useParams, usePathname } from '@/next/navigation'
|
||||
import { InstanceDetail } from '..'
|
||||
|
||||
vi.mock('@/next/navigation', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/next/navigation')>()
|
||||
return {
|
||||
...actual,
|
||||
useParams: vi.fn(),
|
||||
usePathname: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/hooks/use-document-title', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../api-tokens/developer-api-header-switch', () => ({
|
||||
DeveloperApiHeaderSwitch: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('../instances/header-actions/new-deployment-button', () => ({
|
||||
NewDeploymentHeaderAction: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('../../create-release', () => ({
|
||||
CreateReleaseControl: () => null,
|
||||
}))
|
||||
|
||||
function renderInstanceDetail() {
|
||||
;(usePathname as Mock).mockReturnValue('/deployments/app-instance-1/overview')
|
||||
;(useParams as Mock).mockReturnValue({
|
||||
appInstanceId: 'app-instance-1',
|
||||
})
|
||||
|
||||
return render(
|
||||
<JotaiProvider>
|
||||
<NextRouteStateBridge>
|
||||
<InstanceDetail>
|
||||
<div>Deployment detail content</div>
|
||||
</InstanceDetail>
|
||||
</NextRouteStateBridge>
|
||||
</JotaiProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('InstanceDetail', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render detail content without owning navigation landmarks', () => {
|
||||
renderInstanceDetail()
|
||||
|
||||
expect(screen.getByText('Deployment detail content')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('main')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('complementary', { name: 'Detail sidebar' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
18
web/features/deployments/detail/detail-sidebar.tsx
Normal file
18
web/features/deployments/detail/detail-sidebar.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
'use client'
|
||||
|
||||
import { DetailSidebarFrame } from '@/app/components/detail-sidebar'
|
||||
import { DeploymentDetailSection, DeploymentDetailTop } from './deployment-sidebar'
|
||||
|
||||
export function DeploymentDetailSidebar() {
|
||||
return (
|
||||
<DetailSidebarFrame
|
||||
renderTop={({ expand, onToggle }) => (
|
||||
<DeploymentDetailTop
|
||||
expand={expand}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
)}
|
||||
renderSection={({ expand }) => <DeploymentDetailSection expand={expand} />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -57,7 +57,7 @@ export function InstanceDetail({ children }: {
|
||||
return null
|
||||
|
||||
return (
|
||||
<div className="relative m-1 ml-0 flex min-h-0 flex-1 overflow-hidden rounded-lg shadow-xs">
|
||||
<div className="relative m-1 ml-0 flex min-h-0 min-w-0 flex-1 overflow-hidden rounded-lg shadow-xs">
|
||||
<div className="min-w-0 grow overflow-hidden bg-components-panel-bg">
|
||||
<div className="h-full min-w-0 overflow-y-auto">
|
||||
<div className="flex min-h-full w-full flex-col">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user