feat: nested mobile nav menu (#43333)

- consolidate `top level menu` and `contextual menu` into nested menu on
mobile
- remove legacy mobile submenu
This commit is contained in:
Francesco Sansalvadore authored and GitHub committed 2026-03-11 13:55:20 +01:00
1 parent 7848dafdd9
commit 23c827bdda
59 files changed
+2066 -180

No files matched your search

@@ -1,12 +1,12 @@
import Link from 'next/link'
import { IS_PLATFORM, useParams } from 'common'
import {
useIsAnalyticsBucketsEnabled,
useIsVectorBucketsEnabled,
} from 'data/config/project-storage-config-query'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import Link from 'next/link'
import { Badge, Menu } from 'ui'
import { BUCKET_TYPES } from './Storage.constants'
import { useStorageV2Page } from './Storage.utils'
@@ -29,9 +29,9 @@ export const StorageMenuV2 = () => {
})
return (
<Menu type="pills" className="my-6 flex flex-grow flex-col">
<div className="space-y-6">
<div className="mx-3">
<Menu type="pills" className="my-2 md:my-4 flex flex-grow flex-col">
<div className="space-y-4">
<div className="md:mx-3">
<Menu.Group title={<span className="uppercase font-mono">Manage</span>} />
{bucketTypes.map(([type, config]) => {
@@ -55,8 +55,8 @@ export const StorageMenuV2 = () => {
{IS_PLATFORM && (
<>
<div className="h-px w-full bg-border" />
<div className="mx-3">
<div className="h-px w-[calc(100%-1.5rem)] mx-auto md:w-full bg-border" />
<div className="md:mx-3">
<Menu.Group title={<span className="uppercase font-mono">Configuration</span>} />
<Link href={`/project/${ref}/storage/s3`}>
@@ -1,15 +1,18 @@
import Head from 'next/head'
import { useRouter } from 'next/router'
import { PropsWithChildren, useEffect } from 'react'
import { LOCAL_STORAGE_KEYS } from 'common'
import { useCustomContent } from 'hooks/custom-content/useCustomContent'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
import { withAuth } from 'hooks/misc/withAuth'
import { IS_PLATFORM } from 'lib/constants'
import Head from 'next/head'
import { useRouter } from 'next/router'
import type { PropsWithChildren } from 'react'
import { useEffect, useLayoutEffect, useMemo } from 'react'
import { useAppStateSnapshot } from 'state/app-state'
import { cn } from 'ui'
import { useMobileSheet } from '../ProjectLayout/NavigationBar/MobileSheetContext'
import { AccountMenuContent } from './AccountMenuContent'
import { WithSidebar } from './WithSidebar'
export interface AccountLayoutProps {
@@ -19,6 +22,7 @@ export interface AccountLayoutProps {
const AccountLayout = ({ children, title }: PropsWithChildren<AccountLayoutProps>) => {
const router = useRouter()
const appSnap = useAppStateSnapshot()
const { setContent: setMobileSheetContent, registerOpenMenu } = useMobileSheet()
const showSecuritySettings = useIsFeatureEnabled('account:show_security_settings')
@@ -39,6 +43,61 @@ const AccountLayout = ({ children, title }: PropsWithChildren<AccountLayoutProps
const currentPath = router.pathname
const sections = useMemo(
() => [
{
key: 'account-settings',
heading: 'Account Settings',
links: [
{
key: 'preferences',
label: 'Preferences',
href: '/account/me',
isActive: currentPath === '/account/me',
},
{
key: 'access-tokens',
label: 'Access Tokens',
href: '/account/tokens',
isActive: currentPath === '/account/tokens' || currentPath === '/account/tokens/scoped',
},
...(showSecuritySettings
? [
{
key: 'security',
label: 'Security',
href: '/account/security',
isActive: currentPath === '/account/security',
},
]
: []),
],
},
{
key: 'logs',
heading: 'Logs',
links: [
{
key: 'audit-logs',
label: 'Audit Logs',
href: '/account/audit',
isActive: currentPath === '/account/audit',
},
],
},
],
[currentPath, showSecuritySettings]
)
useLayoutEffect(() => {
const unregister = registerOpenMenu(() => {
setMobileSheetContent(
<AccountMenuContent sections={sections} onCloseSheet={() => setMobileSheetContent(null)} />
)
})
return unregister
}, [registerOpenMenu, setMobileSheetContent, sections])
useEffect(() => {
if (!IS_PLATFORM) {
router.push('/project/default')
@@ -56,49 +115,7 @@ const AccountLayout = ({ children, title }: PropsWithChildren<AccountLayoutProps
title=""
breadcrumbs={[]}
backToDashboardURL={backToDashboardURL}
sections={[
{
key: 'account-settings',
heading: 'Account Settings',
links: [
{
key: 'preferences',
label: 'Preferences',
href: '/account/me',
isActive: currentPath === '/account/me',
},
{
key: 'access-tokens',
label: 'Access Tokens',
href: '/account/tokens',
isActive:
currentPath === '/account/tokens' || currentPath === '/account/tokens/scoped',
},
...(showSecuritySettings
? [
{
key: 'security',
label: 'Security',
href: '/account/security',
isActive: currentPath === '/account/security',
},
]
: []),
],
},
{
key: 'logs',
heading: 'Logs',
links: [
{
key: 'audit-logs',
label: 'Audit Logs',
href: '/account/audit',
isActive: currentPath === '/account/audit',
},
],
},
]}
sections={sections}
>
{children}
</WithSidebar>
@@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest'
import { getActiveKey, toSubMenuSections } from './AccountLayout.utils'
describe('toSubMenuSections', () => {
it('converts sections to SubMenuSection format', () => {
const sections = [
{
key: 'account-settings',
heading: 'Account Settings',
links: [
{ key: 'preferences', label: 'Preferences', href: '/account/me', isActive: true },
{
key: 'access-tokens',
label: 'Access Tokens',
href: '/account/tokens',
isActive: false,
},
],
},
]
const result = toSubMenuSections(sections)
expect(result).toHaveLength(1)
expect(result[0]).toEqual({
key: 'account-settings',
heading: 'Account Settings',
links: [
{ key: 'preferences', label: 'Preferences', href: '/account/me' },
{ key: 'access-tokens', label: 'Access Tokens', href: '/account/tokens' },
],
})
})
it('returns empty array for non-array input', () => {
expect(toSubMenuSections(null as any)).toEqual([])
expect(toSubMenuSections(undefined as any)).toEqual([])
})
it('filters out invalid links', () => {
const sections = [
{
key: 's1',
links: [
{ key: 'a', label: 'A', href: '/a' },
null,
{ key: '', label: 'B', href: '/b' },
{ key: 'c', label: null, href: '/c' } as any,
],
},
]
const result = toSubMenuSections(sections)
expect(result[0].links).toHaveLength(1)
expect(result[0].links[0]).toEqual({ key: 'a', label: 'A', href: '/a' })
})
it('handles missing optional fields', () => {
const sections = [{ key: 's1', links: [{ key: 'a', label: 'A' }] }]
const result = toSubMenuSections(sections)
expect(result[0].links[0].href).toBeUndefined()
expect(result[0].heading).toBeUndefined()
})
})
describe('getActiveKey', () => {
it('returns key of first active link', () => {
const sections = [
{
key: 's1',
links: [
{ key: 'a', label: 'A', isActive: false },
{ key: 'b', label: 'B', isActive: true },
],
},
]
expect(getActiveKey(sections)).toBe('b')
})
it('returns first active across sections', () => {
const sections = [
{ key: 's1', links: [{ key: 'a', label: 'A', isActive: false }] },
{ key: 's2', links: [{ key: 'b', label: 'B', isActive: true }] },
]
expect(getActiveKey(sections)).toBe('b')
})
it('returns undefined when no active link', () => {
const sections = [{ key: 's1', links: [{ key: 'a', label: 'A', isActive: false }] }]
expect(getActiveKey(sections)).toBeUndefined()
})
it('returns undefined for invalid input', () => {
expect(getActiveKey(null as any)).toBeUndefined()
expect(getActiveKey(undefined as any)).toBeUndefined()
expect(getActiveKey([])).toBeUndefined()
})
it('handles missing links array', () => {
const sections = [{ key: 's1', links: undefined }]
expect(getActiveKey(sections as any)).toBeUndefined()
})
})
@@ -0,0 +1,39 @@
import type { SubMenuSection } from 'components/ui/ProductMenu/ProductMenu.types'
import type { SidebarSection } from './AccountLayout.types'
/**
* Converts AccountLayout SidebarSection[] to SubMenuSection[] for SubMenu/ProductMenu.
* Defensive: handles missing or malformed sections/links.
*/
export function toSubMenuSections(sections: SidebarSection[]): SubMenuSection[] {
if (!Array.isArray(sections)) return []
return sections
.filter((s): s is SidebarSection => s != null && typeof s === 'object')
.map((s) => ({
key: s.key ?? '',
heading: s.heading,
links: (s.links ?? [])
.filter((l) => l != null && typeof l === 'object' && l.key && l.label != null)
.map((l) => ({
key: l.key,
label: l.label,
href: l.href,
})),
}))
.filter((s) => s.key || s.heading)
}
/**
* Returns the key of the first active link across all sections.
* Used to highlight the current page in SubMenu.
*/
export function getActiveKey(sections: SidebarSection[]): string | undefined {
if (!Array.isArray(sections)) return undefined
for (const section of sections) {
if (!section?.links || !Array.isArray(section.links)) continue
const active = section.links.find((l) => l?.isActive === true)
if (active?.key) return active.key
}
return undefined
}
@@ -0,0 +1,24 @@
'use client'
import { SubMenu } from 'components/ui/ProductMenu/SubMenu'
import { getActiveKey, toSubMenuSections } from './AccountLayout.utils'
import type { SidebarSection } from './AccountLayout.types'
export interface AccountMenuContentProps {
sections: SidebarSection[]
onCloseSheet?: () => void
}
export function AccountMenuContent({ sections, onCloseSheet }: AccountMenuContentProps) {
const subMenuSections = toSubMenuSections(sections)
const page = getActiveKey(sections)
return (
<div className="flex flex-col h-full overflow-y-auto">
<nav className="px-2 py-2" aria-label="Account menu">
<SubMenu sections={subMenuSections} page={page} onItemClick={onCloseSheet} />
</nav>
</div>
)
}
@@ -3,7 +3,7 @@ import Link from 'next/link'
import { PropsWithChildren, ReactNode } from 'react'
import { useAppStateSnapshot } from 'state/app-state'
import { cn, Menu } from 'ui'
import MobileSheetNav from 'ui-patterns/MobileSheetNav/MobileSheetNav'
import type { SidebarSection } from './AccountLayout.types'
interface WithSidebarProps {
@@ -50,17 +50,6 @@ export const WithSidebar = ({
<div className="flex flex-1 flex-col">
<div className="flex-1 flex-grow overflow-y-auto">{children}</div>
</div>
<MobileSheetNav open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}>
<SidebarContent
title={title}
header={header}
sections={sections}
subitems={subitems}
subitemsParentKey={subitemsParentKey}
customSidebarContent={customSidebarContent}
backToDashboardURL={backToDashboardURL}
/>
</MobileSheetNav>
</div>
)
}
@@ -1,14 +1,14 @@
import { useParams } from 'common'
import { useFlag, useParams } from 'common'
import { ProductMenu } from 'components/ui/ProductMenu'
import { useAuthConfigPrefetch } from 'data/auth/auth-config-query'
import { withAuth } from 'hooks/misc/withAuth'
import { useRouter } from 'next/router'
import { PropsWithChildren } from 'react'
import type { PropsWithChildren } from 'react'
import { ProjectLayout } from '../ProjectLayout'
import { useGenerateAuthMenu } from './AuthLayout.utils'
const AuthProductMenu = () => {
export const AuthProductMenu = () => {
const router = useRouter()
const { ref: projectRef = 'default' } = useParams()
@@ -1,7 +1,7 @@
import { ProductMenu } from 'components/ui/ProductMenu'
import { withAuth } from 'hooks/misc/withAuth'
import { useRouter } from 'next/router'
import { PropsWithChildren } from 'react'
import type { PropsWithChildren } from 'react'
import { ProjectLayout } from '../ProjectLayout'
import { useGenerateDatabaseMenu } from './DatabaseMenu.utils'
@@ -10,7 +10,7 @@ export interface DatabaseLayoutProps {
title: string
}
const DatabaseProductMenu = () => {
export const DatabaseProductMenu = () => {
const router = useRouter()
const page = router.pathname.split('/')[4]
const menu = useGenerateDatabaseMenu()
@@ -15,6 +15,7 @@ import { LayoutSidebar } from './ProjectLayout/LayoutSidebar'
import { LayoutSidebarProvider } from './ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
import MobileNavigationBar from './ProjectLayout/NavigationBar/MobileNavigationBar'
import { MobileSheetProvider } from './ProjectLayout/NavigationBar/MobileSheetContext'
import { StudioMobileSheetNav } from './ProjectLayout/NavigationBar/StudioMobileSheetNav'
import { ProjectContextProvider } from './ProjectLayout/ProjectContext'
export interface DefaultLayoutProps {
@@ -119,6 +120,7 @@ export const DefaultLayout = ({
</div>
<BannerStack />
<StudioMobileSheetNav />
</BannerStackProvider>
</MobileSheetProvider>
</ProjectContextProvider>
@@ -2,11 +2,11 @@ import { useParams } from 'common'
import { ProductMenu } from 'components/ui/ProductMenu'
import { withAuth } from 'hooks/misc/withAuth'
import { useRouter } from 'next/router'
import { ComponentProps, PropsWithChildren } from 'react'
import type { ComponentProps, PropsWithChildren } from 'react'
import { ProjectLayout } from '../ProjectLayout'
const EdgeFunctionsProductMenu = () => {
export const EdgeFunctionsProductMenu = () => {
const { ref: projectRef = 'default' } = useParams()
const router = useRouter()
const page = router.pathname.split('/')[4]
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import { getCategoryParamFromAsPath, getIntegrationsPageFromPathname } from './Integrations.utils'
describe('getIntegrationsPageFromPathname', () => {
it('returns section only when no subsection', () => {
expect(getIntegrationsPageFromPathname('/project/abc123/integrations')).toBe('integrations')
})
it('returns section and subsection when both present', () => {
expect(getIntegrationsPageFromPathname('/project/abc123/integrations/xyz-456')).toBe(
'integrations/xyz-456'
)
})
it('returns empty string when path too short', () => {
expect(getIntegrationsPageFromPathname('/project')).toBe('')
expect(getIntegrationsPageFromPathname('/project/abc123')).toBe('')
expect(getIntegrationsPageFromPathname('/')).toBe('')
expect(getIntegrationsPageFromPathname('')).toBe('')
})
it('returns empty string when project segment not found', () => {
expect(getIntegrationsPageFromPathname('/org/my-org')).toBe('')
})
it('handles trailing slash', () => {
expect(getIntegrationsPageFromPathname('/project/abc123/integrations/')).toBe('integrations')
})
})
describe('getCategoryParamFromAsPath', () => {
it('returns category value when present', () => {
expect(getCategoryParamFromAsPath('/project/ref/integrations?category=wrapper')).toBe('wrapper')
expect(getCategoryParamFromAsPath('/path?category=postgres_extension')).toBe(
'postgres_extension'
)
})
it('returns null when category absent', () => {
expect(getCategoryParamFromAsPath('/project/ref/integrations')).toBeNull()
expect(getCategoryParamFromAsPath('/path?foo=bar')).toBeNull()
})
it('returns null for invalid input', () => {
expect(getCategoryParamFromAsPath(undefined)).toBeNull()
expect(getCategoryParamFromAsPath('')).toBeNull()
})
})
@@ -0,0 +1,31 @@
/**
* Builds the integrations page key from pathname segments.
* e.g. /project/ref/integrations → 'integrations'
* /project/ref/integrations/abc-123 → 'integrations/abc-123'
* Returns empty string if path is too short.
*/
export function getIntegrationsPageFromPathname(pathname: string): string {
const segments = pathname.split('/').filter(Boolean)
const projectIndex = segments.indexOf('project')
if (projectIndex === -1 || segments.length <= projectIndex + 2) return ''
const section = segments[projectIndex + 2]
const subSection = segments[projectIndex + 3]
if (!section) return ''
return subSection ? `${section}/${subSection}` : section
}
/**
* Extracts the 'category' query parameter from a full URL (asPath).
* Returns null if not present or asPath is invalid.
*/
export function getCategoryParamFromAsPath(asPath: string | undefined): string | null {
if (!asPath || typeof asPath !== 'string') return null
const queryPart = asPath.split('?')[1]
if (!queryPart) return null
const params = new URLSearchParams(queryPart)
return params.get('category')
}
@@ -0,0 +1,44 @@
import type { ProductMenuGroup } from 'components/ui/ProductMenu/ProductMenu.types'
export function generateIntegrationsMenu({
projectRef,
flags,
}: {
projectRef?: string
flags?: { showWrappers: boolean }
}): ProductMenuGroup[] {
const { showWrappers } = flags ?? {}
return [
{
title: 'Explore',
items: [
{
name: 'All',
key: 'integrations',
url: `/project/${projectRef}/integrations`,
pages: ['integrations'],
items: [],
},
...(showWrappers
? [
{
name: 'Wrappers',
key: 'integrations-wrapper',
url: `/project/${projectRef}/integrations?category=wrapper`,
pages: ['integrations?category=wrapper'],
items: [],
},
]
: []),
{
name: 'Postgres Modules',
key: 'integrations-postgres_extension',
url: `/project/${projectRef}/integrations?category=postgres_extension`,
pages: ['integrations?category=postgres_extension'],
items: [],
},
],
},
]
}
@@ -0,0 +1,88 @@
import { useParams } from 'common'
import { useInstalledIntegrations } from 'components/interfaces/Integrations/Landing/useInstalledIntegrations'
import AlertError from 'components/ui/AlertError'
import { ProductMenu } from 'components/ui/ProductMenu'
import { ProductMenuItem } from 'components/ui/ProductMenu/ProductMenuItem'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { getPathnameWithoutQuery } from 'lib/pathname.utils'
import { useRouter } from 'next/router'
import { Menu, Separator } from 'ui'
import { GenericSkeletonLoader } from 'ui-patterns'
import { getCategoryParamFromAsPath, getIntegrationsPageFromPathname } from './Integrations.utils'
import { generateIntegrationsMenu } from './IntegrationsMenu.utils'
export function IntegrationsProductMenu() {
const router = useRouter()
const { ref: projectRef } = useParams()
const { data: project } = useSelectedProjectQuery()
const { integrationsWrappers: showWrappers } = useIsFeatureEnabled(['integrations:wrappers'])
const resolvedProjectRef = projectRef ?? project?.ref
const pathname = getPathnameWithoutQuery(router.asPath, router.pathname)
const page = getIntegrationsPageFromPathname(pathname)
const categoryParam = getCategoryParamFromAsPath(router.asPath)
const {
installedIntegrations: integrations,
error,
isLoading,
isSuccess,
isError,
} = useInstalledIntegrations()
const resolvedPage =
page === 'integrations'
? categoryParam
? `integrations-${categoryParam}`
: 'integrations'
: page
return (
<>
<ProductMenu
page={resolvedPage}
menu={generateIntegrationsMenu({ projectRef, flags: { showWrappers } })}
/>
<Separator />
<div className="px-4 py-6 md:px-6">
<Menu.Group
title={
<div className="flex flex-col space-y-2 uppercase font-mono">
<span>Installed</span>
</div>
}
/>
{isLoading && <GenericSkeletonLoader />}
{isError && (
<AlertError
showIcon={false}
error={error}
subject="Failed to retrieve installed integrations"
/>
)}
{isSuccess &&
resolvedProjectRef &&
integrations.map((integration) => (
<ProductMenuItem
key={`integrations/${integration.id}`}
isActive={page === `integrations/${integration.id}`}
item={{
name: integration.name,
label: integration.status,
key: `integrations/${integration.id}`,
url: `/project/${resolvedProjectRef}/integrations/${integration.id}/overview`,
icon: (
<div className="relative w-6 h-6 bg-white border rounded flex items-center justify-center">
{integration.icon({ className: 'p-1' })}
</div>
),
items: [],
}}
/>
))}
</div>
</>
)
}
@@ -1,6 +1,3 @@
import { ExternalLink } from 'lucide-react'
import { type PropsWithChildren } from 'react'
import PartnerIcon from 'components/ui/PartnerIcon'
import { PARTNER_TO_NAME } from 'components/ui/PartnerManagedResource'
import { useAwsRedirectQuery } from 'data/integrations/aws-redirect-query'
@@ -8,8 +5,12 @@ import { useVercelRedirectQuery } from 'data/integrations/vercel-redirect-query'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { withAuth } from 'hooks/misc/withAuth'
import { MANAGED_BY } from 'lib/constants/infrastructure'
import { ExternalLink } from 'lucide-react'
import type { PropsWithChildren } from 'react'
import { Alert_Shadcn_, AlertTitle_Shadcn_, Button, cn } from 'ui'
import { useRegisterOrgMenu } from './OrganizationLayout/useRegisterOrgMenu'
const OrganizationLayoutContent = ({ children }: PropsWithChildren) => {
const { data: selectedOrganization } = useSelectedOrganizationQuery()
@@ -59,6 +60,7 @@ const OrganizationLayoutContent = ({ children }: PropsWithChildren) => {
}
const OrganizationLayout = ({ children }: PropsWithChildren) => {
useRegisterOrgMenu()
return <OrganizationLayoutContent>{children}</OrganizationLayoutContent>
}
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import { getPathnameWithoutQuery, isOrgMenuScope } from './OrganizationLayout.utils'
describe('getPathnameWithoutQuery', () => {
it('strips query string from path', () => {
expect(getPathnameWithoutQuery('/org/my-org?foo=bar', undefined)).toBe('/org/my-org')
})
it('returns asPath when no query', () => {
expect(getPathnameWithoutQuery('/org/my-org', undefined)).toBe('/org/my-org')
})
it('uses fallback when asPath is undefined', () => {
expect(getPathnameWithoutQuery(undefined, '/account/me')).toBe('/account/me')
})
it('returns empty string for invalid input', () => {
expect(getPathnameWithoutQuery(undefined, undefined)).toBe('')
expect(getPathnameWithoutQuery('', '')).toBe('')
})
})
describe('isOrgMenuScope', () => {
it('returns true for /org/ routes', () => {
expect(isOrgMenuScope('/org/my-org')).toBe(true)
expect(isOrgMenuScope('/org/my-org/team')).toBe(true)
})
it('returns false for non-org routes', () => {
expect(isOrgMenuScope('/account/me')).toBe(false)
expect(isOrgMenuScope('/project/ref/editor')).toBe(false)
expect(isOrgMenuScope('/organizations')).toBe(false)
})
it('returns false for invalid input', () => {
expect(isOrgMenuScope('')).toBe(false)
expect(isOrgMenuScope(null as any)).toBe(false)
expect(isOrgMenuScope(undefined as any)).toBe(false)
})
it('handles trimmed paths', () => {
expect(isOrgMenuScope(' /org/xyz ')).toBe(true)
})
})
@@ -0,0 +1,13 @@
export function getPathnameWithoutQuery(asPath?: string, fallback?: string): string {
const path = asPath ?? fallback ?? ''
if (typeof path !== 'string' || path.length === 0) return ''
const withoutQuery = path.split('?')[0]
return withoutQuery ?? path
}
export function isOrgMenuScope(pathname: string): boolean {
if (!pathname || typeof pathname !== 'string') return false
const trimmed = pathname.trim()
return trimmed.startsWith('/org/')
}
@@ -0,0 +1,27 @@
'use client'
import { useRouter } from 'next/router'
import { useLayoutEffect } from 'react'
import { OrgMenuContent } from '../ProjectLayout/LayoutHeader/MobileMenuContent/OrgMenuContent'
import { useMobileSheet } from '../ProjectLayout/NavigationBar/MobileSheetContext'
import { getPathnameWithoutQuery, isOrgMenuScope } from './OrganizationLayout.utils'
/**
* Registers the org menu with the mobile sheet when in org scope (/org/...).
* Unregisters when navigating away. Call from OrganizationLayout.
*/
export function useRegisterOrgMenu() {
const router = useRouter()
const { setContent: setMobileSheetContent, registerOpenMenu } = useMobileSheet()
useLayoutEffect(() => {
const pathname = getPathnameWithoutQuery(router.asPath, router.pathname)
if (!isOrgMenuScope(pathname)) return
const unregister = registerOpenMenu(() => {
setMobileSheetContent(<OrgMenuContent onCloseSheet={() => setMobileSheetContent(null)} />)
})
return unregister
}, [router.asPath, router.pathname, registerOpenMenu, setMobileSheetContent])
}
@@ -72,7 +72,6 @@ export const LayoutHeader = ({
const { ref: projectRef, slug } = useParams()
const { data: selectedProject } = useSelectedProjectQuery()
const { data: selectedOrganization } = useSelectedOrganizationQuery()
const { setMobileMenuOpen } = useAppStateSnapshot()
const gitlessBranching = useIsBranching2Enabled()
const connectSheetFlag = usePHFlag<string | boolean>('connectSheet')
@@ -113,20 +112,6 @@ export const LayoutHeader = ({
</Link>
</div>
)}
{(showProductMenu || isAccountPage) && (
<div className="flex items-center justify-center border-r flex-0 md:hidden h-full aspect-square">
<button
title="Menu dropdown button"
className={cn(
'group/view-toggle ml-4 flex justify-center flex-col border-none space-x-0 items-start gap-1 !bg-transparent rounded-md min-w-[30px] w-[30px] h-[30px]'
)}
onClick={() => setMobileMenuOpen(true)}
>
<div className="h-px inline-block left-0 w-4 transition-all ease-out bg-foreground-lighter group-hover/view-toggle:bg-foreground p-0 m-0" />
<div className="h-px inline-block left-0 w-3 transition-all ease-out bg-foreground-lighter group-hover/view-toggle:bg-foreground p-0 m-0" />
</button>
</div>
)}
<div
className={cn(
'flex items-center justify-between h-full pr-3 flex-1 overflow-x-auto gap-x-8 pl-4'
@@ -0,0 +1,208 @@
'use client'
import { useFlag, useParams } from 'common'
import {
useIsAPIDocsSidePanelEnabled,
useUnifiedLogsPreview,
} from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { ICON_SIZE, ICON_STROKE_WIDTH } from 'components/interfaces/Sidebar'
import {
generateOtherRoutes,
generateProductRoutes,
generateSettingsRoutes,
generateToolRoutes,
} from 'components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils'
import type { Route } from 'components/ui/ui.types'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { Home } from 'icons'
import { getPathnameWithoutQuery, getPathSegment } from 'lib/pathname.utils'
import { ChevronLeft } from 'lucide-react'
import { useRouter } from 'next/router'
import React, { useMemo } from 'react'
import { Button, cn, Separator, SidebarGroup, SidebarMenu } from 'ui'
import { GenericSkeletonLoader } from 'ui-patterns'
import { resolveSectionDisplay } from './MobileMenuContent.utils'
import { getProductMenuComponent } from './mobileProductMenuRegistry'
import { TopLevelRouteItem } from './TopLevelRouteItem'
import { routeHasSubmenu, useMobileMenuNavigation } from './useMobileMenuNavigation'
export interface MobileMenuContentProps {
currentProductMenu: React.ReactNode
currentProduct: string
currentSectionKey: string | null
onCloseSheet?: () => void
}
export function MobileMenuContent({
currentProductMenu,
currentProduct,
currentSectionKey,
onCloseSheet,
}: MobileMenuContentProps) {
const router = useRouter()
const { ref } = useParams()
const { data: project } = useSelectedProjectQuery()
const pathname = getPathnameWithoutQuery(router.asPath, router.pathname)
const activeRoute = getPathSegment(pathname, 3)
const { viewLevel, selectedSectionKey, handleTopLevelClick, handleBackToTop } =
useMobileMenuNavigation({
currentSectionKey,
hasCurrentProductMenu: !!currentProductMenu,
onCloseSheet,
})
const {
projectAuthAll: authEnabled,
projectEdgeFunctionAll: edgeFunctionsEnabled,
projectStorageAll: storageEnabled,
realtimeAll: realtimeEnabled,
} = useIsFeatureEnabled([
'project_auth:all',
'project_edge_function:all',
'project_storage:all',
'realtime:all',
])
const authOverviewPageEnabled = useFlag('authOverviewPage')
const showReports = useIsFeatureEnabled('reports:all')
const isNewAPIDocsEnabled = useIsAPIDocsSidePanelEnabled()
const { isEnabled: isUnifiedLogsEnabled } = useUnifiedLogsPreview()
const toolRoutes = useMemo(() => generateToolRoutes(ref, project), [ref, project])
const productRoutes = useMemo(
() =>
generateProductRoutes(ref, project, {
auth: authEnabled,
edgeFunctions: edgeFunctionsEnabled,
storage: storageEnabled,
realtime: realtimeEnabled,
authOverviewPage: authOverviewPageEnabled,
}),
[
ref,
project,
authEnabled,
edgeFunctionsEnabled,
storageEnabled,
realtimeEnabled,
authOverviewPageEnabled,
]
)
const otherRoutes = useMemo(
() =>
generateOtherRoutes(ref, project, {
unifiedLogs: isUnifiedLogsEnabled,
showReports,
apiDocsSidePanel: isNewAPIDocsEnabled,
}),
[ref, project, isUnifiedLogsEnabled, showReports, isNewAPIDocsEnabled]
)
const settingsRoutes = useMemo(() => generateSettingsRoutes(ref, project), [ref, project])
const homeRoute: Route = useMemo(
() => ({
key: 'HOME',
label: 'Project Overview',
icon: <Home size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />,
link: ref ? `/project/${ref}` : undefined,
}),
[ref]
)
const allTopLevelRoutes = useMemo(
() => [homeRoute, ...toolRoutes, ...productRoutes, ...otherRoutes, ...settingsRoutes],
[homeRoute, toolRoutes, productRoutes, otherRoutes, settingsRoutes]
)
const { sectionKey: sectionKeyToShow, sectionLabel } = resolveSectionDisplay({
viewLevel,
selectedSectionKey,
currentSectionKey,
currentProduct,
routes: allTopLevelRoutes,
})
const SectionMenuContent = sectionKeyToShow ? getProductMenuComponent(sectionKeyToShow) : null
const pageSegment = getPathSegment(pathname, 4)
const renderRoute = (route: Route, isActive: boolean) => (
<TopLevelRouteItem
key={route.key}
route={route}
isActive={isActive}
hasSubmenu={routeHasSubmenu(route)}
onTopLevelClick={handleTopLevelClick}
onCloseSheet={onCloseSheet}
/>
)
return (
<div className="flex flex-col h-full bg-background">
{viewLevel === 'section' && sectionLabel && (
<div
className={cn(
'flex-shrink-0 flex items-center gap-2 border-b border-default px-3 min-h-[var(--header-height)]'
)}
>
<Button
type="text"
className="!p-1 justify-start"
icon={<ChevronLeft size={20} />}
onClick={handleBackToTop}
aria-label="Back to menu"
block
>
<span className="font-medium truncate text-sm">{sectionLabel}</span>
</Button>
</div>
)}
<div className="flex-1 overflow-y-auto pb-8 text-sidebar-foreground">
{viewLevel === 'top' && (
<nav className="flex flex-col gap-2 p-1" aria-label="Project menu">
<SidebarMenu>
<SidebarGroup className="gap-0.5">
{[homeRoute, ...toolRoutes].map((route) =>
renderRoute(
route,
activeRoute === route.key || (route.key === 'HOME' && activeRoute === undefined)
)
)}
</SidebarGroup>
<Separator className="mx-2 w-auto bg-sidebar-border" />
<SidebarGroup className="gap-0.5">
{productRoutes.map((route) => renderRoute(route, activeRoute === route.key))}
</SidebarGroup>
<Separator className="mx-2 w-auto bg-sidebar-border" />
<SidebarGroup className="gap-0.5">
{otherRoutes.map((route) => renderRoute(route, activeRoute === route.key))}
</SidebarGroup>
<Separator className="mx-2 w-auto bg-sidebar-border" />
<SidebarGroup className="gap-0.5">
{settingsRoutes.map((route) => renderRoute(route, activeRoute === route.key))}
</SidebarGroup>
</SidebarMenu>
</nav>
)}
{viewLevel === 'section' && sectionKeyToShow && (
<div className="p-1">
{sectionKeyToShow === currentSectionKey && currentProductMenu ? (
currentProductMenu
) : SectionMenuContent ? (
<React.Suspense fallback={<GenericSkeletonLoader className="p-4" />}>
{sectionKeyToShow === 'advisors' ? (
<SectionMenuContent
{...({ page: pageSegment } as React.ComponentProps<typeof SectionMenuContent>)}
/>
) : (
<SectionMenuContent />
)}
</React.Suspense>
) : null}
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,112 @@
import { describe, expect, it } from 'vitest'
import { getSectionKeyFromPathname, resolveSectionDisplay } from './MobileMenuContent.utils'
describe('getSectionKeyFromPathname', () => {
it('returns section key for project section paths', () => {
expect(getSectionKeyFromPathname('/project/abc123/database/schemas')).toBe('database')
expect(getSectionKeyFromPathname('/project/abc123/auth/users')).toBe('auth')
expect(getSectionKeyFromPathname('/project/abc123/settings/general')).toBe('settings')
expect(getSectionKeyFromPathname('/project/abc123/integrations')).toBe('integrations')
expect(getSectionKeyFromPathname('/project/abc123/realtime/inspector')).toBe('realtime')
expect(getSectionKeyFromPathname('/project/abc123/functions')).toBe('functions')
expect(getSectionKeyFromPathname('/project/abc123/logs/explorer')).toBe('logs')
expect(getSectionKeyFromPathname('/project/abc123/advisors/security')).toBe('advisors')
})
it('returns null for project home (no section segment)', () => {
expect(getSectionKeyFromPathname('/project/abc123')).toBeNull()
expect(getSectionKeyFromPathname('/project/abc123/')).toBeNull()
})
it('returns null when pathname does not contain "project"', () => {
expect(getSectionKeyFromPathname('/org/my-org')).toBeNull()
expect(getSectionKeyFromPathname('/account/me')).toBeNull()
expect(getSectionKeyFromPathname('/')).toBeNull()
expect(getSectionKeyFromPathname('')).toBeNull()
})
it('returns null when project segment is missing or dynamic', () => {
expect(getSectionKeyFromPathname('/project')).toBeNull()
expect(getSectionKeyFromPathname('/project/')).toBeNull()
expect(getSectionKeyFromPathname('/project/[ref]/database/schemas')).toBeNull()
})
it('returns first segment after project ref as section key', () => {
expect(getSectionKeyFromPathname('/project/foo/bar/baz')).toBe('bar')
})
})
describe('resolveSectionDisplay', () => {
const routes = [
{ key: 'database', label: 'Database' },
{ key: 'auth', label: 'Authentication' },
{ key: 'settings', label: 'Settings' },
]
it('returns nulls when viewLevel is top', () => {
const result = resolveSectionDisplay({
viewLevel: 'top',
selectedSectionKey: 'database',
currentSectionKey: 'auth',
currentProduct: 'Authentication',
routes,
})
expect(result).toEqual({ sectionKey: null, sectionLabel: null })
})
it('uses selectedSectionKey when set', () => {
const result = resolveSectionDisplay({
viewLevel: 'section',
selectedSectionKey: 'database',
currentSectionKey: 'auth',
currentProduct: 'Authentication',
routes,
})
expect(result).toEqual({ sectionKey: 'database', sectionLabel: 'Database' })
})
it('falls back to currentSectionKey when selectedSectionKey is null', () => {
const result = resolveSectionDisplay({
viewLevel: 'section',
selectedSectionKey: null,
currentSectionKey: 'auth',
currentProduct: 'Authentication',
routes,
})
expect(result).toEqual({ sectionKey: 'auth', sectionLabel: 'Authentication' })
})
it('uses currentProduct as label when sectionKey matches currentSectionKey', () => {
const result = resolveSectionDisplay({
viewLevel: 'section',
selectedSectionKey: 'auth',
currentSectionKey: 'auth',
currentProduct: 'Auth Users',
routes,
})
expect(result).toEqual({ sectionKey: 'auth', sectionLabel: 'Auth Users' })
})
it('falls back to sectionKey itself when no matching route', () => {
const result = resolveSectionDisplay({
viewLevel: 'section',
selectedSectionKey: 'unknown',
currentSectionKey: null,
currentProduct: '',
routes,
})
expect(result).toEqual({ sectionKey: 'unknown', sectionLabel: 'unknown' })
})
it('returns nulls when in section view but both keys are null', () => {
const result = resolveSectionDisplay({
viewLevel: 'section',
selectedSectionKey: null,
currentSectionKey: null,
currentProduct: '',
routes,
})
expect(result).toEqual({ sectionKey: null, sectionLabel: null })
})
})
@@ -0,0 +1,64 @@
/**
* Derives the current section key from the project pathname.
* e.g. /project/[ref]/database/schemas → 'database', /project/[ref] → null (home).
*/
export function getSectionKeyFromPathname(pathname: string): string | null {
const segments = pathname.split('/').filter(Boolean)
const projectIndex = segments.indexOf('project')
if (projectIndex === -1 || segments.length <= projectIndex + 1) return null
const refSegment = segments[projectIndex + 1]
if (!refSegment || refSegment.startsWith('[')) return null
const sectionSegment = segments[projectIndex + 2]
if (!sectionSegment) return null
return sectionSegment
}
export interface ResolveSectionDisplayParams {
viewLevel: 'top' | 'section'
selectedSectionKey: string | null
currentSectionKey: string | null
currentProduct: string
routes: Array<{ key: string; label: string }>
}
export interface SectionDisplay {
sectionKey: string | null
sectionLabel: string | null
}
/**
* Resolves which section to show and its label for the mobile menu.
* When in section view: uses selectedSectionKey (user clicked) or falls back to currentSectionKey.
* Label resolves from currentProduct when matching the current section, otherwise from route labels.
*/
export function resolveSectionDisplay({
viewLevel,
selectedSectionKey,
currentSectionKey,
currentProduct,
routes,
}: ResolveSectionDisplayParams): SectionDisplay {
if (viewLevel !== 'section') {
return { sectionKey: null, sectionLabel: null }
}
const sectionKey = selectedSectionKey ?? currentSectionKey
if (!sectionKey) {
return { sectionKey: null, sectionLabel: null }
}
const isCurrentSection = sectionKey === currentSectionKey
if (isCurrentSection) {
return { sectionKey, sectionLabel: currentProduct }
}
const matchingRoute = routes.find((r) => r.key === sectionKey)
const sectionLabel = matchingRoute?.label ?? sectionKey
return { sectionKey, sectionLabel }
}
@@ -0,0 +1,171 @@
'use client'
import { useIsMFAEnabled, useParams } from 'common'
import { ICON_SIZE, ICON_STROKE_WIDTH } from 'components/interfaces/Sidebar'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { getPathnameWithoutQuery } from 'lib/pathname.utils'
import { useTrack } from 'lib/telemetry/track'
import { Blocks, Boxes, ChartArea, ChevronLeft, Receipt, Settings, Users } from 'lucide-react'
import { useRouter } from 'next/router'
import React, { useMemo } from 'react'
import { Button, cn, SidebarGroup, SidebarMenu } from 'ui'
import { GenericSkeletonLoader } from 'ui-patterns'
import { getOrgMenuComponent } from './mobileOrgMenuRegistry'
import type { OrgNavItem } from './OrgMenuContent.utils'
import {
getOrgActiveRoute,
getOrgSectionKeyFromPathname,
isOrgMenuActive,
} from './OrgMenuContent.utils'
import { OrgMenuItem } from './OrgMenuItem'
import { orgItemHasSubmenu, useOrgMenuNavigation } from './useOrgMenuNavigation'
export interface OrgMenuContentProps {
onCloseSheet?: () => void
}
export function OrgMenuContent({ onCloseSheet }: OrgMenuContentProps) {
const router = useRouter()
const { slug } = useParams()
const organizationSlug: string = slug ?? (router.query.orgSlug as string) ?? ''
const { data: org } = useSelectedOrganizationQuery()
const isUserMFAEnabled = useIsMFAEnabled()
const disableAccessMfa = org?.organization_requires_mfa && !isUserMFAEnabled
const showBilling = useIsFeatureEnabled('billing:all')
const pathname = getPathnameWithoutQuery(router.asPath, router.pathname)
const activeRoute = getOrgActiveRoute(pathname)
const initialSectionKey = getOrgSectionKeyFromPathname(activeRoute)
const track = useTrack()
const {
viewLevel,
selectedSectionKey,
handleSubmenuClick: navigateToSubmenu,
handleBackToTop: navigateBackToTop,
} = useOrgMenuNavigation({ initialSectionKey })
const handleSubmenuClick = (item: OrgNavItem) => {
track('org_submenu_opened', { itemKey: item.key, itemLabel: item.label })
navigateToSubmenu(item)
}
const handleBackToTop = () => {
track('org_menu_back_clicked')
navigateBackToTop()
}
const navMenuItems: OrgNavItem[] = useMemo(
() => [
{
label: 'Projects',
href: `/org/${organizationSlug}`,
key: 'projects',
icon: <Boxes size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />,
},
{
label: 'Team',
href: `/org/${organizationSlug}/team`,
key: 'team',
icon: <Users size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />,
},
{
label: 'Integrations',
href: `/org/${organizationSlug}/integrations`,
key: 'integrations',
icon: <Blocks size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />,
},
{
label: 'Usage',
href: `/org/${organizationSlug}/usage`,
key: 'usage',
icon: <ChartArea size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />,
},
...(showBilling
? [
{
label: 'Billing',
href: `/org/${organizationSlug}/billing`,
key: 'billing',
icon: <Receipt size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />,
},
]
: []),
{
label: 'Organization settings',
href: `/org/${organizationSlug}/general`,
key: 'settings',
icon: <Settings size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />,
},
],
[organizationSlug, showBilling]
)
const sectionKeyToShow = viewLevel === 'section' ? selectedSectionKey : null
const sectionLabel =
sectionKeyToShow && navMenuItems.find((item) => item.key === sectionKeyToShow)?.label
const SectionMenuContent = sectionKeyToShow ? getOrgMenuComponent(sectionKeyToShow) : null
if (!organizationSlug) return null
if (viewLevel === 'section' && sectionKeyToShow && SectionMenuContent) {
return (
<div className="flex flex-col h-full">
<div
className={cn(
'flex-shrink-0 flex items-center gap-2 border-b border-default px-3 min-h-[var(--header-height)]'
)}
>
<Button
type="text"
className="!p-1 justify-start"
icon={<ChevronLeft size={20} />}
onClick={handleBackToTop}
aria-label="Back to menu"
block
>
<span className="font-medium truncate text-sm">{sectionLabel ?? sectionKeyToShow}</span>
</Button>
</div>
<div className="flex-1 overflow-y-auto text-sidebar-foreground px-2">
<React.Suspense fallback={<GenericSkeletonLoader className="p-4" />}>
<SectionMenuContent onCloseSheet={onCloseSheet} />
</React.Suspense>
</div>
</div>
)
}
return (
<div className="flex flex-col h-full">
<div className="flex-1 overflow-y-auto text-sidebar-foreground">
<nav className="flex flex-col gap-2 p-1" aria-label="Organization menu">
<SidebarMenu>
<SidebarGroup className="gap-0.5">
{navMenuItems.map((item, i) => (
<OrgMenuItem
key={item.key}
item={item}
isActive={isOrgMenuActive(item, i, pathname, activeRoute)}
disabled={disableAccessMfa}
onCloseSheet={onCloseSheet}
onSubmenuClick={orgItemHasSubmenu(item) ? handleSubmenuClick : undefined}
onSelect={() =>
track('org_menu_item_clicked', {
itemKey: item.key,
itemHref: item.href,
})
}
/>
))}
</SidebarGroup>
</SidebarMenu>
</nav>
</div>
</div>
)
}
@@ -0,0 +1,97 @@
import { describe, expect, it } from 'vitest'
import {
getOrgActiveRoute,
getOrgSectionKeyFromPathname,
isOrgMenuActive,
type OrgNavItem,
} from './OrgMenuContent.utils'
const mockItem = (key: string): OrgNavItem => ({
key,
label: key,
href: `/org/foo/${key}`,
icon: null,
})
describe('getOrgActiveRoute', () => {
it('returns segment after org slug', () => {
expect(getOrgActiveRoute('/org/my-org/team')).toBe('team')
expect(getOrgActiveRoute('/org/my-org/integrations')).toBe('integrations')
expect(getOrgActiveRoute('/org/my-org/settings/general')).toBe('settings')
})
it('returns undefined for org home', () => {
expect(getOrgActiveRoute('/org/my-org')).toBeUndefined()
expect(getOrgActiveRoute('/org/my-org/')).toBeUndefined()
})
it('returns undefined when path too short', () => {
expect(getOrgActiveRoute('/org')).toBeUndefined()
expect(getOrgActiveRoute('/')).toBeUndefined()
expect(getOrgActiveRoute('')).toBeUndefined()
})
it('returns undefined when org segment not found', () => {
expect(getOrgActiveRoute('/project/ref/database')).toBeUndefined()
})
})
describe('isOrgMenuActive', () => {
it('first item (index 0) is active when activeRoute is undefined', () => {
expect(isOrgMenuActive(mockItem('projects'), 0, '/org/foo', undefined)).toBe(true)
})
it('first item is not active when activeRoute is defined', () => {
expect(isOrgMenuActive(mockItem('projects'), 0, '/org/foo/team', 'team')).toBe(false)
})
it('item is active when activeRoute matches item key', () => {
expect(isOrgMenuActive(mockItem('team'), 1, '/org/foo/team', 'team')).toBe(true)
expect(
isOrgMenuActive(mockItem('integrations'), 2, '/org/foo/integrations', 'integrations')
).toBe(true)
})
it('settings item is active when pathname includes settings sub-routes', () => {
expect(isOrgMenuActive(mockItem('settings'), 5, '/org/foo/settings/general', 'settings')).toBe(
true
)
expect(isOrgMenuActive(mockItem('settings'), 5, '/org/foo/general', undefined)).toBe(true)
expect(isOrgMenuActive(mockItem('settings'), 5, '/org/foo/apps', undefined)).toBe(true)
expect(isOrgMenuActive(mockItem('settings'), 5, '/org/foo/audit', undefined)).toBe(true)
expect(isOrgMenuActive(mockItem('settings'), 5, '/org/foo/documents', undefined)).toBe(true)
expect(isOrgMenuActive(mockItem('settings'), 5, '/org/foo/security', undefined)).toBe(true)
expect(isOrgMenuActive(mockItem('settings'), 5, '/org/foo/sso', undefined)).toBe(true)
})
it('settings item is not active when pathname has no settings sub-route', () => {
expect(isOrgMenuActive(mockItem('settings'), 5, '/org/foo/team', 'team')).toBe(false)
})
it('item is not active when activeRoute does not match', () => {
expect(isOrgMenuActive(mockItem('team'), 1, '/org/foo/team', 'usage')).toBe(false)
})
})
describe('getOrgSectionKeyFromPathname', () => {
it('returns settings for org settings sub-routes', () => {
expect(getOrgSectionKeyFromPathname('general')).toBe('settings')
expect(getOrgSectionKeyFromPathname('security')).toBe('settings')
expect(getOrgSectionKeyFromPathname('sso')).toBe('settings')
expect(getOrgSectionKeyFromPathname('apps')).toBe('settings')
expect(getOrgSectionKeyFromPathname('audit')).toBe('settings')
expect(getOrgSectionKeyFromPathname('documents')).toBe('settings')
})
it('returns null for non-settings routes', () => {
expect(getOrgSectionKeyFromPathname('team')).toBeNull()
expect(getOrgSectionKeyFromPathname('integrations')).toBeNull()
expect(getOrgSectionKeyFromPathname('billing')).toBeNull()
expect(getOrgSectionKeyFromPathname('usage')).toBeNull()
})
it('returns null when activeRoute is undefined', () => {
expect(getOrgSectionKeyFromPathname(undefined)).toBeNull()
})
})
@@ -0,0 +1,51 @@
import type { ReactNode } from 'react'
export interface OrgNavItem {
label: string
href: string
key: string
icon: ReactNode
}
export function getOrgActiveRoute(pathname: string): string | undefined {
const segments = pathname.split('/').filter(Boolean)
const orgIndex = segments.indexOf('org')
if (orgIndex === -1 || segments.length <= orgIndex + 1) return undefined
return segments[orgIndex + 2]
}
const ORG_SETTINGS_ROUTES: string[] = [
'general',
'apps',
'audit',
'documents',
'security',
'sso',
] as const
export function getOrgSectionKeyFromPathname(activeRoute: string | undefined): string | null {
if (activeRoute && ORG_SETTINGS_ROUTES.includes(activeRoute)) {
return 'settings'
}
return null
}
export function isOrgMenuActive(
item: OrgNavItem,
index: number,
pathname: string,
activeRoute: string | undefined
): boolean {
if (index === 0) {
return activeRoute === undefined
}
if (item.key === 'settings') {
const route = activeRoute ?? getOrgActiveRoute(pathname)
if (route === undefined) return false
return (
route === 'settings' ||
ORG_SETTINGS_ROUTES.includes(route as (typeof ORG_SETTINGS_ROUTES)[number])
)
}
return activeRoute === item.key
}
@@ -0,0 +1,64 @@
import Link from 'next/link'
import { cn, SidebarMenuItem, sidebarMenuButtonVariants } from 'ui'
import type { OrgNavItem } from './OrgMenuContent.utils'
export interface OrgMenuItemProps {
item: OrgNavItem
isActive: boolean
disabled?: boolean
onCloseSheet?: () => void
onSubmenuClick?: (item: OrgNavItem) => void
onSelect?: () => void
}
export function OrgMenuItem({
item,
isActive,
disabled = false,
onCloseSheet,
onSubmenuClick,
onSelect,
}: OrgMenuItemProps) {
const menuButtonClass = cn(
sidebarMenuButtonVariants({ size: 'default', hasIcon: true }),
disabled && 'opacity-50 pointer-events-none'
)
const content = (
<>
<span className="flex size-5 shrink-0 items-center justify-center [&>svg]:size-5 [&>svg]:shrink-0">
{item.icon}
</span>
<span className="truncate">{item.label}</span>
</>
)
return (
<SidebarMenuItem key={item.key}>
{onSubmenuClick ? (
<button
type="button"
data-active={isActive}
onClick={() => onSubmenuClick?.(item)}
disabled={disabled}
className={menuButtonClass}
>
{content}
</button>
) : (
<Link
href={item.href}
onClick={() => {
onSelect?.()
onCloseSheet?.()
}}
data-active={isActive}
className={menuButtonClass}
>
{content}
</Link>
)}
</SidebarMenuItem>
)
}
@@ -0,0 +1,66 @@
import type { Route } from 'components/ui/ui.types'
import Link from 'next/link'
import { cn, sidebarMenuButtonVariants, SidebarMenuItem } from 'ui'
import { isDirectLinkAtTopLevel } from './useMobileMenuNavigation'
export interface TopLevelRouteItemProps {
route: Route
isActive: boolean
hasSubmenu: boolean
onTopLevelClick: (route: Route) => void
onCloseSheet?: () => void
}
export function TopLevelRouteItem({
route,
isActive,
hasSubmenu,
onTopLevelClick,
onCloseSheet,
}: TopLevelRouteItemProps) {
const hasItems = hasSubmenu && !isDirectLinkAtTopLevel(route)
const content = (
<>
{route.icon && (
<span className="flex size-5 shrink-0 items-center justify-center [&>svg]:size-5 [&>svg]:shrink-0">
{route.icon}
</span>
)}
<span className="truncate">{route.label}</span>
</>
)
const menuButtonClass = cn(
sidebarMenuButtonVariants({ size: 'default', hasIcon: !!route.icon }),
route.disabled && 'opacity-50 pointer-events-none'
)
return (
<SidebarMenuItem key={route.key}>
{hasItems ? (
<button
type="button"
data-active={isActive}
onClick={() => onTopLevelClick(route)}
disabled={route.disabled}
className={menuButtonClass}
>
{content}
</button>
) : route.link ? (
<Link
href={route.link}
onClick={onCloseSheet}
data-active={isActive}
className={menuButtonClass}
>
{content}
</Link>
) : (
<span data-active={false} className={cn(menuButtonClass, 'cursor-default')}>
{content}
</span>
)}
</SidebarMenuItem>
)
}
@@ -0,0 +1,4 @@
export { MobileMenuContent } from './MobileMenuContent'
export type { MobileMenuContentProps } from './MobileMenuContent'
export { getSectionKeyFromPathname } from './MobileMenuContent.utils'
export { getProductMenuComponent } from './mobileProductMenuRegistry'
@@ -0,0 +1,22 @@
import React, { type ComponentType } from 'react'
/**
* Lazy-loaded org menu components for the mobile org sheet submenu.
* Sections without a dedicated submenu map to null (they navigate directly).
*/
export const MOBILE_ORG_MENU_REGISTRY: Record<string, ComponentType<any> | null> = {
projects: null,
team: null,
integrations: null,
usage: null,
billing: null,
settings: React.lazy(() =>
import('@/components/layouts/ProjectLayout/OrganizationSettingsMenu').then((m) => ({
default: m.OrganizationSettingsMenu,
}))
),
}
export function getOrgMenuComponent(sectionKey: string): ComponentType<any> | null {
return MOBILE_ORG_MENU_REGISTRY[sectionKey] ?? null
}
@@ -0,0 +1,75 @@
import React, { type ComponentType } from 'react'
/**
* Lazy-loaded product menu components to avoid circular dependencies
* (registry -> Layout -> ProjectLayout -> MobileMenuContent -> registry).
* Sections without a dedicated product menu map to null.
*/
export const MOBILE_PRODUCT_MENU_REGISTRY: Record<string, ComponentType | null> = {
HOME: null,
editor: React.lazy(() =>
import('components/layouts/TableEditorLayout/TableEditorMenu').then((m) => ({
default: m.TableEditorMenu,
}))
),
sql: React.lazy(() =>
import('components/layouts/SQLEditorLayout/SQLEditorMenu').then((m) => ({
default: m.SQLEditorMenu,
}))
),
database: React.lazy(() =>
import('components/layouts/DatabaseLayout/DatabaseLayout').then((m) => ({
default: m.DatabaseProductMenu,
}))
),
auth: React.lazy(() =>
import('components/layouts/AuthLayout/AuthLayout').then((m) => ({
default: m.AuthProductMenu,
}))
),
storage: React.lazy(() =>
import('components/interfaces/Storage/StorageMenuV2').then((m) => ({
default: m.StorageMenuV2,
}))
),
functions: React.lazy(() =>
import('components/layouts/EdgeFunctionsLayout/EdgeFunctionsLayout').then((m) => ({
default: m.EdgeFunctionsProductMenu,
}))
),
realtime: React.lazy(() =>
import('components/layouts/RealtimeLayout/RealtimeLayout').then((m) => ({
default: m.RealtimeProductMenu,
}))
),
advisors: React.lazy(() =>
import('components/layouts/AdvisorsLayout/AdvisorsSidebarMenu').then((m) => ({
default: m.AdvisorsSidebarMenu,
}))
),
observability: React.lazy(() =>
import('components/layouts/ObservabilityLayout/ObservabilityMenu').then((m) => ({
default: m.default,
}))
),
logs: React.lazy(() =>
import('components/layouts/LogsLayout/LogsSidebarMenuV2').then((m) => ({
default: m.LogsSidebarMenuV2,
}))
),
api: null,
integrations: React.lazy(() =>
import('components/layouts/Integrations/IntegrationsProductMenu').then((m) => ({
default: m.IntegrationsProductMenu,
}))
),
settings: React.lazy(() =>
import('components/layouts/ProjectSettingsLayout/SettingsLayout').then((m) => ({
default: m.SettingsProductMenu,
}))
),
}
export function getProductMenuComponent(sectionKey: string): ComponentType | null {
return MOBILE_PRODUCT_MENU_REGISTRY[sectionKey] ?? null
}
@@ -0,0 +1,75 @@
'use client'
import type { Route } from 'components/ui/ui.types'
import { useRouter } from 'next/router'
import { useCallback, useState } from 'react'
import { getProductMenuComponent } from './mobileProductMenuRegistry'
const TOP_LEVEL_DIRECT_LINK_KEYS = ['editor', 'sql'] as const
export function isDirectLinkAtTopLevel(route: Route): boolean {
return TOP_LEVEL_DIRECT_LINK_KEYS.includes(
route.key as (typeof TOP_LEVEL_DIRECT_LINK_KEYS)[number]
)
}
export function routeHasSubmenu(route: Route): boolean {
if (route.items && Array.isArray(route.items) && route.items.length > 0) return true
return getProductMenuComponent(route.key) !== null
}
interface UseMobileMenuNavigationParams {
currentSectionKey: string | null
hasCurrentProductMenu: boolean
onCloseSheet?: () => void
}
export function useMobileMenuNavigation({
currentSectionKey,
hasCurrentProductMenu,
onCloseSheet,
}: UseMobileMenuNavigationParams) {
const router = useRouter()
const [viewLevel, setViewLevel] = useState<'top' | 'section'>(
hasCurrentProductMenu && currentSectionKey ? 'section' : 'top'
)
const [selectedSectionKey, setSelectedSectionKey] = useState<string | null>(null)
const handleTopLevelClick = useCallback(
(route: Route) => {
if (route.disabled) return
if (isDirectLinkAtTopLevel(route) && route.link) {
router.push(route.link)
onCloseSheet?.()
return
}
if (routeHasSubmenu(route)) {
setSelectedSectionKey(route.key)
setViewLevel('section')
return
}
if (route.link) {
router.push(route.link)
onCloseSheet?.()
}
},
[router, onCloseSheet]
)
const handleBackToTop = useCallback(() => {
setViewLevel('top')
setSelectedSectionKey(null)
}, [])
return {
viewLevel,
selectedSectionKey,
handleTopLevelClick,
handleBackToTop,
}
}
@@ -0,0 +1,38 @@
'use client'
import { useCallback, useState } from 'react'
import { getOrgMenuComponent } from './mobileOrgMenuRegistry'
import type { OrgNavItem } from './OrgMenuContent.utils'
export function orgItemHasSubmenu(item: OrgNavItem): boolean {
return getOrgMenuComponent(item.key) !== null
}
interface UseOrgMenuNavigationParams {
initialSectionKey: string | null
}
export function useOrgMenuNavigation({ initialSectionKey }: UseOrgMenuNavigationParams) {
const [viewLevel, setViewLevel] = useState<'top' | 'section'>(
initialSectionKey ? 'section' : 'top'
)
const [selectedSectionKey, setSelectedSectionKey] = useState<string | null>(initialSectionKey)
const handleSubmenuClick = useCallback((item: OrgNavItem) => {
setSelectedSectionKey(item.key)
setViewLevel('section')
}, [])
const handleBackToTop = useCallback(() => {
setViewLevel('top')
setSelectedSectionKey(null)
}, [])
return {
viewLevel,
selectedSectionKey,
handleSubmenuClick,
handleBackToTop,
}
}
@@ -34,6 +34,8 @@ export const SIDEBAR_KEYS = {
HELP_PANEL: 'help-panel',
} as const
export type TYPEOF_SIDEBAR_KEYS = (typeof SIDEBAR_KEYS)[keyof typeof SIDEBAR_KEYS]
export const LayoutSidebarProvider = ({ children }: PropsWithChildren) => {
const router = useRouter()
const { data: project } = useSelectedProjectQuery()
@@ -2,7 +2,6 @@ import { useBreakpoint } from 'common'
import { useEffect } from 'react'
import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state'
import { cn, ResizableHandle, ResizablePanel } from 'ui'
import { MobileSheetNav } from 'ui-patterns'
import { useMobileSheet } from '../NavigationBar/MobileSheetContext'
@@ -19,13 +18,17 @@ export const LayoutSidebar = ({
maxSize = '50',
defaultSize = '30',
}: LayoutSidebarProps) => {
const { activeSidebar, closeActive } = useSidebarManagerSnapshot()
const { activeSidebar } = useSidebarManagerSnapshot()
const isMobile = useBreakpoint('md')
const { content: mobileSheetContent, setContent: setMobileSheetContent } = useMobileSheet()
const { setContent: setMobileSheetContent } = useMobileSheet()
// On mobile the sidebar content is rendered in MobileSheetNav
// On mobile, sidebar content is shown in the sheet. Sync sheet content with active sidebar; clear when none or when switching to desktop.
useEffect(() => {
if (isMobile && activeSidebar?.component) {
if (!isMobile) {
setMobileSheetContent(null)
return
}
if (activeSidebar?.component) {
setMobileSheetContent(activeSidebar.id)
} else {
setMobileSheetContent(null)
@@ -33,23 +36,7 @@ export const LayoutSidebar = ({
}, [isMobile, activeSidebar, setMobileSheetContent])
if (!activeSidebar?.component) return null
if (isMobile)
return (
<MobileSheetNav
shouldCloseOnRouteChange={false}
shouldCloseOnViewportResize={false}
open={mobileSheetContent !== null}
onOpenChange={(open: boolean) => {
if (!open) {
setMobileSheetContent(null)
closeActive()
}
}}
>
{activeSidebar?.component?.()}
</MobileSheetNav>
)
if (isMobile) return null
return (
<>
@@ -1,19 +1,19 @@
import { useParams } from 'common'
import { SidebarContent } from 'components/interfaces/Sidebar'
import { IS_PLATFORM } from 'lib/constants'
import { Menu, Search } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useState } from 'react'
import { Button, cn } from 'ui'
import { CommandMenuTrigger, MobileSheetNav } from 'ui-patterns'
import { CommandMenuTrigger } from 'ui-patterns'
import { useMobileSheet } from './MobileSheetContext'
export const ICON_SIZE = 20
export const ICON_STROKE_WIDTH = 1.5
const MobileNavigationBar = ({ hideMobileMenu }: { hideMobileMenu?: boolean }) => {
const router = useRouter()
const [isSheetOpen, setIsSheetOpen] = useState(false)
const { openMenu } = useMobileSheet()
const { ref: projectRef } = useParams()
return (
@@ -39,6 +39,7 @@ const MobileNavigationBar = ({ hideMobileMenu }: { hideMobileMenu?: boolean }) =
<div className="flex gap-2">
<CommandMenuTrigger>
<button
type="button"
className={cn(
'group',
'flex-grow h-[30px] rounded-md',
@@ -50,9 +51,7 @@ const MobileNavigationBar = ({ hideMobileMenu }: { hideMobileMenu?: boolean }) =
'transition'
)}
>
<div className="flex items-center space-x-2">
<Search size={18} strokeWidth={2} />
</div>
<Search size={18} strokeWidth={2} />
</button>
</CommandMenuTrigger>
{!hideMobileMenu && (
@@ -61,14 +60,11 @@ const MobileNavigationBar = ({ hideMobileMenu }: { hideMobileMenu?: boolean }) =
type="default"
className="flex lg:hidden border-default bg-surface-100/75 text-foreground-light rounded-md min-w-[30px] w-[30px] h-[30px] data-[state=open]:bg-overlay-hover/30"
icon={<Menu />}
onClick={() => setIsSheetOpen(true)}
onClick={() => openMenu()}
/>
)}
</div>
</nav>
<MobileSheetNav open={isSheetOpen} onOpenChange={setIsSheetOpen} data-state="expanded">
<SidebarContent />
</MobileSheetNav>
</div>
)
}
@@ -1,24 +1,50 @@
import type { PropsWithChildren } from 'react'
import { createContext, useCallback, useContext, useState } from 'react'
import type { PropsWithChildren, ReactNode } from 'react'
import { createContext, useCallback, useContext, useRef, useState } from 'react'
export type MobileSheetContentType = null | string
import type { TYPEOF_SIDEBAR_KEYS } from '../LayoutSidebar/LayoutSidebarProvider'
/**
* Sheet content: null = closed; sidebar id = one of SIDEBAR_KEYS; ReactNode = custom content (menu, search, etc.).
*/
export type MobileSheetContentType = null | TYPEOF_SIDEBAR_KEYS | ReactNode
type MobileSheetContextValue = {
content: MobileSheetContentType
setContent: (content: MobileSheetContentType) => void
isOpen: boolean
/** Open the sheet with the current menu content. Registered by ProjectLayout (project menu) or OrganizationLayout (org menu). */
openMenu: () => void
/** Register the callback run when openMenu() is called (e.g. from MobileNavigationBar). Returns an unregister function. */
registerOpenMenu: (fn: () => void) => () => void
}
const MobileSheetContext = createContext<MobileSheetContextValue | null>(null)
export function MobileSheetProvider({ children }: PropsWithChildren) {
const [content, setContentState] = useState<MobileSheetContentType>(null)
const openMenuRef = useRef<() => void>(() => {})
const isOpen = content !== null
const setContent = useCallback((next: MobileSheetContentType) => {
setContentState(next)
}, [])
const openMenu = useCallback(() => {
openMenuRef.current()
}, [])
const registerOpenMenu = useCallback((fn: () => void) => {
openMenuRef.current = fn
return () => {
openMenuRef.current = () => {}
}
}, [])
return (
<MobileSheetContext.Provider value={{ content, setContent }}>
<MobileSheetContext.Provider
value={{ content, setContent, isOpen, openMenu, registerOpenMenu }}
>
{children}
</MobileSheetContext.Provider>
)
@@ -0,0 +1,48 @@
import type { ReactNode } from 'react'
import { sidebarManagerState, useSidebarManagerSnapshot } from 'state/sidebar-manager-state'
import { MobileSheetNav } from 'ui-patterns'
import { SIDEBAR_KEYS } from '../LayoutSidebar/LayoutSidebarProvider'
import type { TYPEOF_SIDEBAR_KEYS } from '../LayoutSidebar/LayoutSidebarProvider'
import type { MobileSheetContentType } from './MobileSheetContext'
import { useMobileSheet } from './MobileSheetContext'
function isSidebarId(content: unknown): content is TYPEOF_SIDEBAR_KEYS {
return (
typeof content === 'string' &&
Object.values(SIDEBAR_KEYS).includes(content as TYPEOF_SIDEBAR_KEYS)
)
}
function getSheetChildren(
content: MobileSheetContentType,
activeSidebar: { id: string; component?: () => ReactNode } | null
): ReactNode {
if (content === null) return null
if (isSidebarId(content) && activeSidebar?.id === content) {
return activeSidebar.component?.() ?? null
}
if (!isSidebarId(content)) return content
return null
}
const StudioMobileSheetNav = () => {
const { content, setContent } = useMobileSheet()
const { activeSidebar } = useSidebarManagerSnapshot()
const sheetChildren = getSheetChildren(content, activeSidebar ?? null)
const handleOpenChange = (open: boolean) => {
if (!open) {
setContent(null)
sidebarManagerState.closeActive()
}
}
return (
<MobileSheetNav open={content !== null} onOpenChange={handleOpenChange}>
{sheetChildren}
</MobileSheetNav>
)
}
export { StudioMobileSheetNav }
@@ -0,0 +1,48 @@
import { useParams } from 'common'
import { useIsPlatformWebhooksEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { SubMenu } from 'components/ui/ProductMenu/SubMenu'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { getPathnameWithoutQuery } from 'lib/pathname.utils'
import { useRouter } from 'next/router'
import {
generateOrganizationSettingsSections,
normalizeOrganizationSettingsPath,
} from './OrganizationSettingsLayout'
export interface OrganizationSettingsMenuProps {
onCloseSheet?: () => void
}
export function OrganizationSettingsMenu({ onCloseSheet }: OrganizationSettingsMenuProps) {
const router = useRouter()
const { slug } = useParams()
const organizationSlug = slug ?? (router.query.orgSlug as string) ?? ''
const pathname = getPathnameWithoutQuery(router.asPath, router.pathname)
const currentPath = normalizeOrganizationSettingsPath(pathname)
const showPlatformWebhooks = useIsPlatformWebhooksEnabled()
const {
organizationShowSsoSettings: showSsoSettings,
organizationShowSecuritySettings: showSecuritySettings,
organizationShowLegalDocuments: showLegalDocuments,
} = useIsFeatureEnabled([
'organization:show_sso_settings',
'organization:show_security_settings',
'organization:show_legal_documents',
])
const sections = generateOrganizationSettingsSections({
slug: organizationSlug,
currentPath,
showSecuritySettings: showSecuritySettings,
showSsoSettings,
showLegalDocuments,
showPlatformWebhooks,
})
const page = currentPath.split('/').filter(Boolean).pop()
return <SubMenu sections={sections} page={page} onItemClick={onCloseSheet} />
}
@@ -51,17 +51,31 @@ vi.mock('framer-motion', () => ({
AnimatePresence: ({ children }: { children: ReactNode }) => <>{children}</>,
motion: {
div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
create: (Component: any) => Component,
},
}))
vi.mock('ui', () => ({
cn: (...classes: Array<string | false | null | undefined>) => classes.filter(Boolean).join(' '),
CommandInput_Shadcn_: { displayName: 'CommandInput' },
Command_Shadcn_: { displayName: 'Command' },
CommandGroup_Shadcn_: { displayName: 'CommandGroup' },
CommandItem_Shadcn_: { displayName: 'CommandItem' },
CommandList_Shadcn_: { displayName: 'CommandList' },
LogoLoader: () => <div data-testid="logo-loader" />,
ResizableHandle: (props: any) => <div {...props} />,
ResizablePanel: ({ children, ...props }: any) => <div {...props}>{children}</div>,
ResizablePanelGroup: ({ children, ...props }: any) => <div {...props}>{children}</div>,
Sidebar: ({ children, ...props }: any) => <div {...props}>{children}</div>,
SidebarContent: ({ children, ...props }: any) => <div {...props}>{children}</div>,
SidebarFooter: ({ children, ...props }: any) => <div {...props}>{children}</div>,
SidebarGroup: ({ children, ...props }: any) => <div {...props}>{children}</div>,
SidebarMenu: ({ children, ...props }: any) => <div {...props}>{children}</div>,
SidebarMenuButton: (props: any) => <div {...props} />,
SidebarMenuItem: (props: any) => <div {...props} />,
useIsMobile: () => false,
usePanelRef: () => undefined,
useSidebar: () => ({ setOpen: vi.fn() }),
}))
vi.mock('ui-patterns/MobileSheetNav/MobileSheetNav', () => ({
@@ -144,6 +158,7 @@ vi.mock('@/state/database-selector', () => ({
}),
}))
import { MobileSheetProvider } from './NavigationBar/MobileSheetContext'
import { ProjectLayout } from './index'
describe('ProjectLayout title', () => {
@@ -160,9 +175,11 @@ describe('ProjectLayout title', () => {
it('sets a composed document title and deduplicates identical section/surface labels', async () => {
render(
<ProjectLayout title="Settings" product="Settings" isBlocking={false}>
<div>Page Content</div>
</ProjectLayout>
<MobileSheetProvider>
<ProjectLayout title="Settings" product="Settings" isBlocking={false}>
<div>Page Content</div>
</ProjectLayout>
</MobileSheetProvider>
)
await waitFor(() => {
@@ -174,14 +191,16 @@ describe('ProjectLayout title', () => {
it('prefers entity-first browserTitle metadata when provided', async () => {
render(
<ProjectLayout
title="Database"
product="Database"
browserTitle={{ entity: 'users', section: 'Tables' }}
isBlocking={false}
>
<div>Page Content</div>
</ProjectLayout>
<MobileSheetProvider>
<ProjectLayout
title="Database"
product="Database"
browserTitle={{ entity: 'users', section: 'Tables' }}
isBlocking={false}
>
<div>Page Content</div>
</ProjectLayout>
</MobileSheetProvider>
)
await waitFor(() => {
@@ -2,7 +2,14 @@ import { mergeRefs, useParams } from 'common'
import { AnimatePresence, motion } from 'framer-motion'
import Head from 'next/head'
import { useRouter } from 'next/router'
import { forwardRef, Fragment, PropsWithChildren, ReactNode, useEffect } from 'react'
import {
forwardRef,
Fragment,
useEffect,
useLayoutEffect,
type PropsWithChildren,
type ReactNode,
} from 'react'
import {
cn,
LogoLoader,
@@ -12,13 +19,16 @@ import {
useIsMobile,
usePanelRef,
} from 'ui'
import MobileSheetNav from 'ui-patterns/MobileSheetNav/MobileSheetNav'
import { useEditorType } from '../editors/EditorsLayout.hooks'
import { useSetMainScrollContainer } from '../MainScrollContainerContext'
import BuildingState from './BuildingState'
import ConnectingState from './ConnectingState'
import { getPathnameWithoutQuery } from '@/lib/pathname.utils'
import { getSectionKeyFromPathname, MobileMenuContent } from './LayoutHeader/MobileMenuContent'
import { LoadingState } from './LoadingState'
import { useMobileSheet } from './NavigationBar/MobileSheetContext'
import { ProjectPausedState } from './PausedState/ProjectPausedState'
import { PauseFailedState } from './PauseFailedState'
import { PausingState } from './PausingState'
@@ -105,7 +115,11 @@ export const ProjectLayout = forwardRef<HTMLDivElement, PropsWithChildren<Projec
const router = useRouter()
const { data: selectedOrganization } = useSelectedOrganizationQuery()
const { data: selectedProject } = useSelectedProjectQuery()
const { mobileMenuOpen, showSidebar, setMobileMenuOpen } = useAppStateSnapshot()
const { showSidebar } = useAppStateSnapshot()
const { setContent: setMobileSheetContent, registerOpenMenu } = useMobileSheet()
const pathname = getPathnameWithoutQuery(router.asPath, router.pathname)
const currentSectionKey = getSectionKeyFromPathname(pathname)
const setMainScrollContainer = useSetMainScrollContainer()
const combinedRef = mergeRefs(ref, setMainScrollContainer)
@@ -144,6 +158,20 @@ export const ProjectLayout = forwardRef<HTMLDivElement, PropsWithChildren<Projec
router.pathname.includes('/project/[ref]/logs')
const showPausedState = isPaused && !ignorePausedState
useLayoutEffect(() => {
const unregister = registerOpenMenu(() => {
setMobileSheetContent(
<MobileMenuContent
currentProductMenu={productMenu ?? null}
currentProduct={product}
currentSectionKey={currentSectionKey}
onCloseSheet={() => setMobileSheetContent(null)}
/>
)
})
return unregister
}, [registerOpenMenu, productMenu, product, currentSectionKey, setMobileSheetContent])
return (
<>
<Head>
@@ -215,9 +243,6 @@ export const ProjectLayout = forwardRef<HTMLDivElement, PropsWithChildren<Projec
</div>
<CreateBranchModal />
<ProjectAPIDocs />
<MobileSheetNav open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}>
{productMenu}
</MobileSheetNav>
</>
)
}
@@ -1,6 +1,3 @@
import { useRouter } from 'next/router'
import { PropsWithChildren } from 'react'
import { useParams } from 'common'
import { useIsPlatformWebhooksEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { ProductMenu } from 'components/ui/ProductMenu'
@@ -8,22 +5,24 @@ import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { withAuth } from 'hooks/misc/withAuth'
import { useRouter } from 'next/router'
import type { PropsWithChildren } from 'react'
import { ProjectLayout } from '../ProjectLayout'
import { generateSettingsMenu } from './SettingsMenu.utils'
interface SettingsLayoutProps {
title?: string
}
const SettingsLayout = ({ title, children }: PropsWithChildren<SettingsLayoutProps>) => {
/**
* Menu-only component for the settings section. Used by the desktop sidebar and by the
* mobile sheet submenu. Must not wrap ProjectLayout so that opening the settings submenu
* in the mobile sheet does not overwrite registerOpenMenu and break the menu button.
*/
export const SettingsProductMenu = () => {
const router = useRouter()
const { ref } = useParams()
const { data: project } = useSelectedProjectQuery()
const { data: organization } = useSelectedOrganizationQuery()
const platformWebhooksEnabled = useIsPlatformWebhooksEnabled()
// billing pages live under /billing/invoices and /billing/subscription, etc
// so we need to pass the [5]th part of the url to the menu
const page = router.pathname.includes('billing')
? router.pathname.split('/')[5]
: router.pathname.split('/')[4]
@@ -60,12 +59,20 @@ const SettingsLayout = ({ title, children }: PropsWithChildren<SettingsLayoutPro
platformWebhooks: platformWebhooksEnabled,
})
return <ProductMenu page={page} menu={menuRoutes} />
}
interface SettingsLayoutProps {
title?: string
}
export const SettingsLayout = ({ title, children }: PropsWithChildren<SettingsLayoutProps>) => {
return (
<ProjectLayout
isBlocking={false}
title={title || 'Settings'}
product="Settings"
productMenu={<ProductMenu page={page} menu={menuRoutes} />}
productMenu={<SettingsProductMenu />}
>
{children}
</ProjectLayout>
@@ -1,19 +1,31 @@
import { useRouter } from 'next/router'
import { PropsWithChildren } from 'react'
import { ProductMenu } from 'components/ui/ProductMenu'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { withAuth } from 'hooks/misc/withAuth'
import { useRouter } from 'next/router'
import type { PropsWithChildren } from 'react'
import { ProjectLayout } from '../ProjectLayout'
import { generateRealtimeMenu } from './RealtimeMenu.utils'
/**
* Menu-only component for the Realtime section. Used by the desktop sidebar and by the
* mobile sheet submenu. Must not wrap ProjectLayout so that opening the realtime submenu
* in the mobile sheet does not overwrite registerOpenMenu and break the menu button.
*/
export const RealtimeProductMenu = () => {
const router = useRouter()
const { data: project } = useSelectedProjectQuery()
const page = router.pathname.split('/')[4]
return <ProductMenu page={page} menu={generateRealtimeMenu(project ?? undefined)} />
}
export interface RealtimeLayoutProps {
title: string
}
const RealtimeLayout = ({ title, children }: PropsWithChildren<RealtimeLayoutProps>) => {
export const RealtimeLayout = ({ title, children }: PropsWithChildren<RealtimeLayoutProps>) => {
const { data: project } = useSelectedProjectQuery()
const router = useRouter()
const page = router.pathname.split('/')[4]
@@ -21,7 +33,7 @@ const RealtimeLayout = ({ title, children }: PropsWithChildren<RealtimeLayoutPro
<ProjectLayout
title={title}
product="Realtime"
productMenu={<ProductMenu page={page} menu={generateRealtimeMenu(project!)} />}
productMenu={<ProductMenu page={page} menu={generateRealtimeMenu(project)} />}
>
{children}
</ProjectLayout>
@@ -2,7 +2,7 @@ import type { ProductMenuGroup } from 'components/ui/ProductMenu/ProductMenu.typ
import type { Project } from 'data/projects/project-detail-query'
import { IS_PLATFORM } from 'lib/constants'
export const generateRealtimeMenu = (project: Project): ProductMenuGroup[] => {
export const generateRealtimeMenu = (project: Project | undefined): ProductMenuGroup[] => {
const ref = project?.ref ?? 'default'
const showRealtimeSettings = IS_PLATFORM
@@ -26,3 +26,18 @@ export interface ProductMenuGroupItem {
childItems?: ProductMenuGroupItem[]
pages?: string[]
}
/**
* Generic section format for SubMenu. Compatible with SidebarSection from AccountLayout.
*/
export interface SubMenuSection {
key: string
heading?: string
links: Array<{ key: string; label: string; href?: string }>
}
export interface SubMenuProps {
sections: SubMenuSection[]
page?: string
onItemClick?: () => void
}
@@ -57,7 +57,7 @@ export const ProductMenuItem = ({
}
return (
<Link href={url} className="block" target={target}>
<Link href={url} className="block" target={target} onClick={onClick}>
{menuItem}
</Link>
)
@@ -0,0 +1,10 @@
import { useMemo } from 'react'
import { ProductMenu } from './index'
import type { SubMenuProps } from './ProductMenu.types'
import { convertSectionsToProductMenu } from './SubMenu.utils'
export function SubMenu({ sections, page, onItemClick }: SubMenuProps) {
const menu = useMemo(() => convertSectionsToProductMenu(sections), [sections])
return <ProductMenu page={page} menu={menu} onItemClick={onItemClick} />
}
@@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest'
import { convertSectionsToProductMenu } from './SubMenu.utils'
describe('convertSectionsToProductMenu', () => {
it('converts sections with heading and links to ProductMenuGroup format', () => {
const sections = [
{
key: 'configuration',
heading: 'Configuration',
links: [
{ key: 'general', label: 'General', href: '/org/foo/general' },
{ key: 'security', label: 'Security', href: '/org/foo/security' },
],
},
]
const result = convertSectionsToProductMenu(sections)
expect(result).toEqual([
{
key: 'configuration',
title: 'Configuration',
items: [
{ key: 'general', name: 'General', url: '/org/foo/general' },
{ key: 'security', name: 'Security', url: '/org/foo/security' },
],
},
])
})
it('uses # for undefined href', () => {
const sections = [
{
key: 'config',
links: [{ key: 'item', label: 'Item' }],
},
]
const result = convertSectionsToProductMenu(sections)
expect(result[0].items[0].url).toBe('#')
})
it('handles empty sections array', () => {
const result = convertSectionsToProductMenu([])
expect(result).toEqual([])
})
it('handles section with empty links', () => {
const sections = [
{
key: 'empty',
heading: 'Empty',
links: [],
},
]
const result = convertSectionsToProductMenu(sections)
expect(result).toEqual([{ key: 'empty', title: 'Empty', items: [] }])
})
it('handles section without heading', () => {
const sections = [
{
key: 'no-heading',
links: [{ key: 'a', label: 'A', href: '/a' }],
},
]
const result = convertSectionsToProductMenu(sections)
expect(result[0].title).toBeUndefined()
expect(result[0].items).toHaveLength(1)
})
it('converts multiple sections', () => {
const sections = [
{ key: 'a', heading: 'A', links: [{ key: 'a1', label: 'A1', href: '/a1' }] },
{ key: 'b', heading: 'B', links: [{ key: 'b1', label: 'B1', href: '/b1' }] },
]
const result = convertSectionsToProductMenu(sections)
expect(result).toHaveLength(2)
expect(result[0].key).toBe('a')
expect(result[1].key).toBe('b')
})
})
@@ -0,0 +1,13 @@
import type { ProductMenuGroup, SubMenuSection } from './ProductMenu.types'
export function convertSectionsToProductMenu(sections: SubMenuSection[]): ProductMenuGroup[] {
return sections.map((section) => ({
key: section.key,
title: section.heading,
items: section.links.map((link) => ({
key: link.key,
name: link.label,
url: link.href ?? '#',
})),
}))
}
@@ -6,16 +6,18 @@ import { ProductMenuItem } from './ProductMenuItem'
interface ProductMenuProps {
page?: string
menu: ProductMenuGroup[]
/** Called when a menu item link is clicked (e.g. to close a sheet on navigation) */
onItemClick?: () => void
}
export const ProductMenu = ({ page, menu }: ProductMenuProps) => {
export const ProductMenu = ({ page, menu, onItemClick }: ProductMenuProps) => {
return (
<div className="flex flex-col space-y-8">
<div className="flex flex-col space-y-4">
<Menu type="pills">
{menu.map((group, idx) => (
<div key={group.key || group.title}>
<div className="my-6 space-y-8">
<div className="mx-3">
<div className="my-4 space-y-4">
<div className="md:mx-3">
<Menu.Group
title={
group.title ? (
@@ -38,13 +40,16 @@ export const ProductMenu = ({ page, menu }: ProductMenuProps) => {
item={item}
isActive={isActive}
target={item.isExternal ? '_blank' : '_self'}
onClick={onItemClick}
/>
)
})}
</div>
</div>
</div>
{idx !== menu.length - 1 && <div className="h-px w-full bg-border-overlay" />}
{idx !== menu.length - 1 && (
<div className="h-px w-[calc(100%-1.5rem)] mx-auto md:w-full bg-border-overlay" />
)}
</div>
))}
</Menu>
+28
View File
@@ -0,0 +1,28 @@
/**
* Pathname utilities for safe URL/path parsing.
* Use these instead of direct array indexing (e.g. pathname.split('/')[3]) to avoid undefined access.
*/
/**
* Extracts the pathname without query string or hash.
* Use with Next.js router: getPathnameWithoutQuery(router.asPath, router.pathname)
*/
export function getPathnameWithoutQuery(
asPath: string | undefined,
fallbackPathname: string
): string {
if (asPath === undefined || asPath === null) return fallbackPathname
const withoutQuery = asPath.split(/[?#]/)[0]
return withoutQuery ?? fallbackPathname
}
/**
* Returns the path segment at the given index, or undefined if out of bounds.
* Segments are from splitting on '/', e.g. '/org/my-org/team' → ['', 'org', 'my-org', 'team']
* Index 0 = '', 1 = 'org', 2 = 'my-org', 3 = 'team'
*/
export function getPathSegment(pathname: string, index: number): string | undefined {
const segments = pathname.split('/')
const segment = segments[index]
return segment
}
+1 -1
View File
@@ -35,7 +35,7 @@ const Audit: NextPageWithLayout = () => {
Audit.getLayout = (page) => (
<AppLayout>
<DefaultLayout hideMobileMenu headerTitle="Account">
<DefaultLayout headerTitle="Account">
<OrganizationLayout>
<AccountLayout title="Audit Logs">{page}</AccountLayout>
</OrganizationLayout>
+1 -1
View File
@@ -31,7 +31,7 @@ const User: NextPageWithLayout = () => {
User.getLayout = (page) => (
<AppLayout>
<DefaultLayout hideMobileMenu headerTitle="Account">
<DefaultLayout headerTitle="Account">
<OrganizationLayout>
<AccountLayout title="Account Settings">{page}</AccountLayout>
</OrganizationLayout>
+1 -1
View File
@@ -89,7 +89,7 @@ const Security: NextPageWithLayout = () => {
Security.getLayout = (page) => (
<AppLayout>
<DefaultLayout hideMobileMenu headerTitle="Account">
<DefaultLayout headerTitle="Account">
<OrganizationLayout>
<AccountLayout title="Security">{page}</AccountLayout>
</OrganizationLayout>
+1 -1
View File
@@ -66,7 +66,7 @@ const UserAccessTokens: NextPageWithLayout = () => {
UserAccessTokens.getLayout = (page) => (
<AppLayout>
<DefaultLayout hideMobileMenu headerTitle="Account">
<DefaultLayout headerTitle="Account">
<OrganizationLayout>
<AccountLayout title="Access Tokens">{page}</AccountLayout>
</OrganizationLayout>
+1 -1
View File
@@ -545,7 +545,7 @@ const PageLayout = withAuth(({ children }: PropsWithChildren) => {
})
Wizard.getLayout = (page) => (
<DefaultLayout headerTitle="New project">
<DefaultLayout hideMobileMenu headerTitle="New project">
<PageLayout>{page}</PageLayout>
</DefaultLayout>
)
+2 -3
View File
@@ -1,11 +1,10 @@
import HCaptcha from '@hcaptcha/react-hcaptcha'
import { useCallback, useEffect, useState } from 'react'
import { NewOrgForm } from 'components/interfaces/Organization/NewOrg/NewOrgForm'
import AppLayout from 'components/layouts/AppLayout/AppLayout'
import DefaultLayout from 'components/layouts/DefaultLayout'
import WizardLayout from 'components/layouts/WizardLayout'
import { SetupIntentResponse, useSetupIntent } from 'data/stripe/setup-intent-mutation'
import { useCallback, useEffect, useState } from 'react'
import type { NextPageWithLayout } from 'types'
/**
@@ -99,7 +98,7 @@ const Wizard: NextPageWithLayout = () => {
Wizard.getLayout = (page) => (
<AppLayout>
<DefaultLayout headerTitle="New organization">
<DefaultLayout hideMobileMenu headerTitle="New organization">
<WizardLayout>{page}</WizardLayout>
</DefaultLayout>
</AppLayout>
+1 -1
View File
@@ -10,7 +10,7 @@ const SupportPage: NextPageWithLayout = () => {
SupportPage.getLayout = (page) => (
<AppLayout>
<DefaultLayout>{page}</DefaultLayout>
<DefaultLayout hideMobileMenu>{page}</DefaultLayout>
</AppLayout>
)
+51
View File
@@ -2325,6 +2325,54 @@ export interface SidebarOpenedEvent {
groups: TelemetryGroups
}
/**
* User opened an org menu submenu in the mobile navigation sheet.
*
* @group Events
* @source studio
* @page Organization pages (mobile)
*/
export interface OrgSubmenuOpenedEvent {
action: 'org_submenu_opened'
properties: {
/** The key of the submenu item that was opened */
itemKey: string
/** The display label of the submenu item */
itemLabel: string
}
groups: TelemetryGroups
}
/**
* User clicked the back button in the mobile org menu to return to the top-level menu.
*
* @group Events
* @source studio
* @page Organization pages (mobile)
*/
export interface OrgMenuBackClickedEvent {
action: 'org_menu_back_clicked'
groups: TelemetryGroups
}
/**
* User clicked a menu item in the mobile org navigation sheet.
*
* @group Events
* @source studio
* @page Organization pages (mobile)
*/
export interface OrgMenuItemClickedEvent {
action: 'org_menu_item_clicked'
properties: {
/** The key identifying the menu item */
itemKey: string
/** The navigation href of the menu item */
itemHref: string
}
groups: TelemetryGroups
}
/**
* User toggled the inline editor setting in account preferences.
*
@@ -2863,4 +2911,7 @@ export type TelemetryEvent =
| IntegrationInstallFailedEvent
| IntegrationUninstallCompletedEvent
| RlsEventTriggerBannerCreateButtonClickedEvent
| OrgSubmenuOpenedEvent
| OrgMenuBackClickedEvent
| OrgMenuItemClickedEvent
| PricingCalculatorExperimentExposedEvent
@@ -25,12 +25,14 @@ const MobileSheetNav: React.FC<{
const router = useRouter()
const { width } = useWindowSize()
const pathWithoutQuery = router?.asPath?.split('?')?.[0]
// Use full asPath (including query) so the sheet closes when navigating to the same path with
// different query params (e.g. Integrations submenu: All vs Wrappers vs Postgres Modules).
const fullPath = router?.asPath ?? ''
useEffect(() => {
if (shouldCloseOnRouteChange) {
onOpenChange(false)
}
}, [pathWithoutQuery])
}, [fullPath])
useEffect(() => {
if (shouldCloseOnViewportResize) {