mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 01:15:03 +03:00
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:
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'
|
||||
|
||||
+208
@@ -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>
|
||||
)
|
||||
}
|
||||
+112
@@ -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 })
|
||||
})
|
||||
})
|
||||
+64
@@ -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 }
|
||||
}
|
||||
+171
@@ -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>
|
||||
)
|
||||
}
|
||||
+97
@@ -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()
|
||||
})
|
||||
})
|
||||
+51
@@ -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
|
||||
}
|
||||
+64
@@ -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>
|
||||
)
|
||||
}
|
||||
+66
@@ -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'
|
||||
+22
@@ -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
|
||||
}
|
||||
+75
@@ -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
|
||||
}
|
||||
+75
@@ -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,
|
||||
}
|
||||
}
|
||||
+38
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -10,7 +10,7 @@ const SupportPage: NextPageWithLayout = () => {
|
||||
|
||||
SupportPage.getLayout = (page) => (
|
||||
<AppLayout>
|
||||
<DefaultLayout>{page}</DefaultLayout>
|
||||
<DefaultLayout hideMobileMenu>{page}</DefaultLayout>
|
||||
</AppLayout>
|
||||
)
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user