From 23c827bddaf2f4fe7ab25738b953dd8420bae0a5 Mon Sep 17 00:00:00 2001 From: Francesco Sansalvadore Date: Wed, 11 Mar 2026 13:55:20 +0100 Subject: [PATCH] feat: nested mobile nav menu (#43333) - consolidate `top level menu` and `contextual menu` into nested menu on mobile - remove legacy mobile submenu --- .../interfaces/Storage/StorageMenuV2.tsx | 14 +- .../layouts/AccountLayout/AccountLayout.tsx | 111 ++++++---- .../AccountLayout/AccountLayout.utils.test.ts | 101 +++++++++ .../AccountLayout/AccountLayout.utils.ts | 39 ++++ .../AccountLayout/AccountMenuContent.tsx | 24 ++ .../layouts/AccountLayout/WithSidebar.tsx | 13 +- .../layouts/AuthLayout/AuthLayout.tsx | 6 +- .../layouts/DatabaseLayout/DatabaseLayout.tsx | 4 +- .../components/layouts/DefaultLayout.tsx | 2 + .../EdgeFunctionsLayout.tsx | 4 +- .../Integrations/Integrations.utils.test.ts | 49 +++++ .../Integrations/Integrations.utils.ts | 31 +++ .../Integrations/IntegrationsMenu.utils.ts | 44 ++++ .../Integrations/IntegrationsProductMenu.tsx | 88 ++++++++ .../components/layouts/OrganizationLayout.tsx | 8 +- .../OrganizationLayout.utils.test.ts | 45 ++++ .../OrganizationLayout.utils.ts | 13 ++ .../OrganizationLayout/useRegisterOrgMenu.tsx | 27 +++ .../LayoutHeader/LayoutHeader.tsx | 15 -- .../MobileMenuContent/MobileMenuContent.tsx | 208 ++++++++++++++++++ .../MobileMenuContent.utils.test.ts | 112 ++++++++++ .../MobileMenuContent.utils.ts | 64 ++++++ .../MobileMenuContent/OrgMenuContent.tsx | 171 ++++++++++++++ .../OrgMenuContent.utils.test.ts | 97 ++++++++ .../MobileMenuContent/OrgMenuContent.utils.ts | 51 +++++ .../MobileMenuContent/OrgMenuItem.tsx | 64 ++++++ .../MobileMenuContent/TopLevelRouteItem.tsx | 66 ++++++ .../LayoutHeader/MobileMenuContent/index.ts | 4 + .../mobileOrgMenuRegistry.tsx | 22 ++ .../mobileProductMenuRegistry.tsx | 75 +++++++ .../useMobileMenuNavigation.ts | 75 +++++++ .../MobileMenuContent/useOrgMenuNavigation.ts | 38 ++++ .../LayoutSidebar/LayoutSidebarProvider.tsx | 2 + .../ProjectLayout/LayoutSidebar/index.tsx | 31 +-- .../NavigationBar/MobileNavigationBar.tsx | 18 +- .../NavigationBar/MobileSheetContext.tsx | 34 ++- .../NavigationBar/StudioMobileSheetNav.tsx | 48 ++++ .../OrganizationSettingsMenu.tsx | 48 ++++ .../layouts/ProjectLayout/index.test.tsx | 41 +++- .../layouts/ProjectLayout/index.tsx | 37 +++- .../ProjectSettingsLayout/SettingsLayout.tsx | 29 ++- .../layouts/RealtimeLayout/RealtimeLayout.tsx | 24 +- .../RealtimeLayout/RealtimeMenu.utils.ts | 2 +- .../ui/ProductMenu/ProductMenu.types.ts | 15 ++ .../ui/ProductMenu/ProductMenuItem.tsx | 2 +- .../components/ui/ProductMenu/SubMenu.tsx | 10 + .../ui/ProductMenu/SubMenu.utils.test.ts | 90 ++++++++ .../ui/ProductMenu/SubMenu.utils.ts | 13 ++ .../components/ui/ProductMenu/index.tsx | 15 +- apps/studio/lib/pathname.utils.ts | 28 +++ apps/studio/pages/account/audit.tsx | 2 +- apps/studio/pages/account/me.tsx | 2 +- apps/studio/pages/account/security.tsx | 2 +- apps/studio/pages/account/tokens.tsx | 2 +- apps/studio/pages/new/[slug].tsx | 2 +- apps/studio/pages/new/index.tsx | 5 +- apps/studio/pages/support/new.tsx | 2 +- packages/common/telemetry-constants.ts | 51 +++++ .../src/MobileSheetNav/MobileSheetNav.tsx | 6 +- 59 files changed, 2066 insertions(+), 180 deletions(-) create mode 100644 apps/studio/components/layouts/AccountLayout/AccountLayout.utils.test.ts create mode 100644 apps/studio/components/layouts/AccountLayout/AccountLayout.utils.ts create mode 100644 apps/studio/components/layouts/AccountLayout/AccountMenuContent.tsx create mode 100644 apps/studio/components/layouts/Integrations/Integrations.utils.test.ts create mode 100644 apps/studio/components/layouts/Integrations/Integrations.utils.ts create mode 100644 apps/studio/components/layouts/Integrations/IntegrationsMenu.utils.ts create mode 100644 apps/studio/components/layouts/Integrations/IntegrationsProductMenu.tsx create mode 100644 apps/studio/components/layouts/OrganizationLayout/OrganizationLayout.utils.test.ts create mode 100644 apps/studio/components/layouts/OrganizationLayout/OrganizationLayout.utils.ts create mode 100644 apps/studio/components/layouts/OrganizationLayout/useRegisterOrgMenu.tsx create mode 100644 apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/MobileMenuContent.tsx create mode 100644 apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/MobileMenuContent.utils.test.ts create mode 100644 apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/MobileMenuContent.utils.ts create mode 100644 apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/OrgMenuContent.tsx create mode 100644 apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/OrgMenuContent.utils.test.ts create mode 100644 apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/OrgMenuContent.utils.ts create mode 100644 apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/OrgMenuItem.tsx create mode 100644 apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/TopLevelRouteItem.tsx create mode 100644 apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/index.ts create mode 100644 apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/mobileOrgMenuRegistry.tsx create mode 100644 apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/mobileProductMenuRegistry.tsx create mode 100644 apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/useMobileMenuNavigation.ts create mode 100644 apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/useOrgMenuNavigation.ts create mode 100644 apps/studio/components/layouts/ProjectLayout/NavigationBar/StudioMobileSheetNav.tsx create mode 100644 apps/studio/components/layouts/ProjectLayout/OrganizationSettingsMenu.tsx create mode 100644 apps/studio/components/ui/ProductMenu/SubMenu.tsx create mode 100644 apps/studio/components/ui/ProductMenu/SubMenu.utils.test.ts create mode 100644 apps/studio/components/ui/ProductMenu/SubMenu.utils.ts create mode 100644 apps/studio/lib/pathname.utils.ts diff --git a/apps/studio/components/interfaces/Storage/StorageMenuV2.tsx b/apps/studio/components/interfaces/Storage/StorageMenuV2.tsx index ebbf86f9768..41bfba31ec8 100644 --- a/apps/studio/components/interfaces/Storage/StorageMenuV2.tsx +++ b/apps/studio/components/interfaces/Storage/StorageMenuV2.tsx @@ -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 ( - -
-
+ +
+
Manage} /> {bucketTypes.map(([type, config]) => { @@ -55,8 +55,8 @@ export const StorageMenuV2 = () => { {IS_PLATFORM && ( <> -
-
+
+
Configuration} /> diff --git a/apps/studio/components/layouts/AccountLayout/AccountLayout.tsx b/apps/studio/components/layouts/AccountLayout/AccountLayout.tsx index 84640554271..5a15698b947 100644 --- a/apps/studio/components/layouts/AccountLayout/AccountLayout.tsx +++ b/apps/studio/components/layouts/AccountLayout/AccountLayout.tsx @@ -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) => { 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 [ + { + 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( + setMobileSheetContent(null)} /> + ) + }) + return unregister + }, [registerOpenMenu, setMobileSheetContent, sections]) + useEffect(() => { if (!IS_PLATFORM) { router.push('/project/default') @@ -56,49 +115,7 @@ const AccountLayout = ({ children, title }: PropsWithChildren {children} diff --git a/apps/studio/components/layouts/AccountLayout/AccountLayout.utils.test.ts b/apps/studio/components/layouts/AccountLayout/AccountLayout.utils.test.ts new file mode 100644 index 00000000000..d412a2a9d7c --- /dev/null +++ b/apps/studio/components/layouts/AccountLayout/AccountLayout.utils.test.ts @@ -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() + }) +}) diff --git a/apps/studio/components/layouts/AccountLayout/AccountLayout.utils.ts b/apps/studio/components/layouts/AccountLayout/AccountLayout.utils.ts new file mode 100644 index 00000000000..947761b29d7 --- /dev/null +++ b/apps/studio/components/layouts/AccountLayout/AccountLayout.utils.ts @@ -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 +} diff --git a/apps/studio/components/layouts/AccountLayout/AccountMenuContent.tsx b/apps/studio/components/layouts/AccountLayout/AccountMenuContent.tsx new file mode 100644 index 00000000000..caa7aad8ddd --- /dev/null +++ b/apps/studio/components/layouts/AccountLayout/AccountMenuContent.tsx @@ -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 ( +
+ +
+ ) +} diff --git a/apps/studio/components/layouts/AccountLayout/WithSidebar.tsx b/apps/studio/components/layouts/AccountLayout/WithSidebar.tsx index d2d237b7966..804ffe5033f 100644 --- a/apps/studio/components/layouts/AccountLayout/WithSidebar.tsx +++ b/apps/studio/components/layouts/AccountLayout/WithSidebar.tsx @@ -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 = ({
{children}
- - -
) } diff --git a/apps/studio/components/layouts/AuthLayout/AuthLayout.tsx b/apps/studio/components/layouts/AuthLayout/AuthLayout.tsx index bc13cbea34d..746d2206103 100644 --- a/apps/studio/components/layouts/AuthLayout/AuthLayout.tsx +++ b/apps/studio/components/layouts/AuthLayout/AuthLayout.tsx @@ -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() diff --git a/apps/studio/components/layouts/DatabaseLayout/DatabaseLayout.tsx b/apps/studio/components/layouts/DatabaseLayout/DatabaseLayout.tsx index 182cc86f678..8210ff34b90 100644 --- a/apps/studio/components/layouts/DatabaseLayout/DatabaseLayout.tsx +++ b/apps/studio/components/layouts/DatabaseLayout/DatabaseLayout.tsx @@ -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() diff --git a/apps/studio/components/layouts/DefaultLayout.tsx b/apps/studio/components/layouts/DefaultLayout.tsx index 88d6852d3d4..e56dc27ac0b 100644 --- a/apps/studio/components/layouts/DefaultLayout.tsx +++ b/apps/studio/components/layouts/DefaultLayout.tsx @@ -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 = ({
+ diff --git a/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionsLayout.tsx b/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionsLayout.tsx index dd2f03aa7ba..fe17e65341f 100644 --- a/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionsLayout.tsx +++ b/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionsLayout.tsx @@ -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] diff --git a/apps/studio/components/layouts/Integrations/Integrations.utils.test.ts b/apps/studio/components/layouts/Integrations/Integrations.utils.test.ts new file mode 100644 index 00000000000..0ccb5f6494b --- /dev/null +++ b/apps/studio/components/layouts/Integrations/Integrations.utils.test.ts @@ -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() + }) +}) diff --git a/apps/studio/components/layouts/Integrations/Integrations.utils.ts b/apps/studio/components/layouts/Integrations/Integrations.utils.ts new file mode 100644 index 00000000000..1e0efff99e6 --- /dev/null +++ b/apps/studio/components/layouts/Integrations/Integrations.utils.ts @@ -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') +} diff --git a/apps/studio/components/layouts/Integrations/IntegrationsMenu.utils.ts b/apps/studio/components/layouts/Integrations/IntegrationsMenu.utils.ts new file mode 100644 index 00000000000..995a7164f3a --- /dev/null +++ b/apps/studio/components/layouts/Integrations/IntegrationsMenu.utils.ts @@ -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: [], + }, + ], + }, + ] +} diff --git a/apps/studio/components/layouts/Integrations/IntegrationsProductMenu.tsx b/apps/studio/components/layouts/Integrations/IntegrationsProductMenu.tsx new file mode 100644 index 00000000000..91ec564fada --- /dev/null +++ b/apps/studio/components/layouts/Integrations/IntegrationsProductMenu.tsx @@ -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 ( + <> + + +
+ + Installed +
+ } + /> + {isLoading && } + {isError && ( + + )} + {isSuccess && + resolvedProjectRef && + integrations.map((integration) => ( + + {integration.icon({ className: 'p-1' })} +
+ ), + items: [], + }} + /> + ))} +
+ + ) +} diff --git a/apps/studio/components/layouts/OrganizationLayout.tsx b/apps/studio/components/layouts/OrganizationLayout.tsx index 427749ca007..2823ca16caf 100644 --- a/apps/studio/components/layouts/OrganizationLayout.tsx +++ b/apps/studio/components/layouts/OrganizationLayout.tsx @@ -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 {children} } diff --git a/apps/studio/components/layouts/OrganizationLayout/OrganizationLayout.utils.test.ts b/apps/studio/components/layouts/OrganizationLayout/OrganizationLayout.utils.test.ts new file mode 100644 index 00000000000..95e48956e1c --- /dev/null +++ b/apps/studio/components/layouts/OrganizationLayout/OrganizationLayout.utils.test.ts @@ -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) + }) +}) diff --git a/apps/studio/components/layouts/OrganizationLayout/OrganizationLayout.utils.ts b/apps/studio/components/layouts/OrganizationLayout/OrganizationLayout.utils.ts new file mode 100644 index 00000000000..793eef072a1 --- /dev/null +++ b/apps/studio/components/layouts/OrganizationLayout/OrganizationLayout.utils.ts @@ -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/') +} diff --git a/apps/studio/components/layouts/OrganizationLayout/useRegisterOrgMenu.tsx b/apps/studio/components/layouts/OrganizationLayout/useRegisterOrgMenu.tsx new file mode 100644 index 00000000000..4650986ed6b --- /dev/null +++ b/apps/studio/components/layouts/OrganizationLayout/useRegisterOrgMenu.tsx @@ -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( setMobileSheetContent(null)} />) + }) + return unregister + }, [router.asPath, router.pathname, registerOpenMenu, setMobileSheetContent]) +} diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx index ce5d2e78aeb..7bfb9f88c7b 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx @@ -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('connectSheet') @@ -113,20 +112,6 @@ export const LayoutHeader = ({
)} - {(showProductMenu || isAccountPage) && ( -
- -
- )}
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: , + 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) => ( + + ) + + return ( +
+ {viewLevel === 'section' && sectionLabel && ( +
+ +
+ )} +
+ {viewLevel === 'top' && ( + + )} + {viewLevel === 'section' && sectionKeyToShow && ( +
+ {sectionKeyToShow === currentSectionKey && currentProductMenu ? ( + currentProductMenu + ) : SectionMenuContent ? ( + }> + {sectionKeyToShow === 'advisors' ? ( + )} + /> + ) : ( + + )} + + ) : null} +
+ )} +
+
+ ) +} diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/MobileMenuContent.utils.test.ts b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/MobileMenuContent.utils.test.ts new file mode 100644 index 00000000000..fd81be51610 --- /dev/null +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/MobileMenuContent.utils.test.ts @@ -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 }) + }) +}) diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/MobileMenuContent.utils.ts b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/MobileMenuContent.utils.ts new file mode 100644 index 00000000000..e3adc212db4 --- /dev/null +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/MobileMenuContent.utils.ts @@ -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 } +} diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/OrgMenuContent.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/OrgMenuContent.tsx new file mode 100644 index 00000000000..2beaa1f3cb8 --- /dev/null +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/OrgMenuContent.tsx @@ -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: , + }, + { + label: 'Team', + href: `/org/${organizationSlug}/team`, + key: 'team', + icon: , + }, + { + label: 'Integrations', + href: `/org/${organizationSlug}/integrations`, + key: 'integrations', + icon: , + }, + { + label: 'Usage', + href: `/org/${organizationSlug}/usage`, + key: 'usage', + icon: , + }, + ...(showBilling + ? [ + { + label: 'Billing', + href: `/org/${organizationSlug}/billing`, + key: 'billing', + icon: , + }, + ] + : []), + { + label: 'Organization settings', + href: `/org/${organizationSlug}/general`, + key: 'settings', + icon: , + }, + ], + [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 ( +
+
+ +
+
+ }> + + +
+
+ ) + } + + return ( +
+
+ +
+
+ ) +} diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/OrgMenuContent.utils.test.ts b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/OrgMenuContent.utils.test.ts new file mode 100644 index 00000000000..c8555408764 --- /dev/null +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/OrgMenuContent.utils.test.ts @@ -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() + }) +}) diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/OrgMenuContent.utils.ts b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/OrgMenuContent.utils.ts new file mode 100644 index 00000000000..04e7f6aa607 --- /dev/null +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/OrgMenuContent.utils.ts @@ -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 +} diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/OrgMenuItem.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/OrgMenuItem.tsx new file mode 100644 index 00000000000..1c68ac67172 --- /dev/null +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/OrgMenuItem.tsx @@ -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 = ( + <> + + {item.icon} + + {item.label} + + ) + + return ( + + {onSubmenuClick ? ( + + ) : ( + { + onSelect?.() + onCloseSheet?.() + }} + data-active={isActive} + className={menuButtonClass} + > + {content} + + )} + + ) +} diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/TopLevelRouteItem.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/TopLevelRouteItem.tsx new file mode 100644 index 00000000000..5c95ddc9cdc --- /dev/null +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/TopLevelRouteItem.tsx @@ -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 && ( + + {route.icon} + + )} + {route.label} + + ) + const menuButtonClass = cn( + sidebarMenuButtonVariants({ size: 'default', hasIcon: !!route.icon }), + route.disabled && 'opacity-50 pointer-events-none' + ) + + return ( + + {hasItems ? ( + + ) : route.link ? ( + + {content} + + ) : ( + + {content} + + )} + + ) +} diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/index.ts b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/index.ts new file mode 100644 index 00000000000..fec0e2d4249 --- /dev/null +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/index.ts @@ -0,0 +1,4 @@ +export { MobileMenuContent } from './MobileMenuContent' +export type { MobileMenuContentProps } from './MobileMenuContent' +export { getSectionKeyFromPathname } from './MobileMenuContent.utils' +export { getProductMenuComponent } from './mobileProductMenuRegistry' diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/mobileOrgMenuRegistry.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/mobileOrgMenuRegistry.tsx new file mode 100644 index 00000000000..74932451c94 --- /dev/null +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/mobileOrgMenuRegistry.tsx @@ -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 | 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 | null { + return MOBILE_ORG_MENU_REGISTRY[sectionKey] ?? null +} diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/mobileProductMenuRegistry.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/mobileProductMenuRegistry.tsx new file mode 100644 index 00000000000..aa882e0a170 --- /dev/null +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/mobileProductMenuRegistry.tsx @@ -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 = { + 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 +} diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/useMobileMenuNavigation.ts b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/useMobileMenuNavigation.ts new file mode 100644 index 00000000000..2a92d079bd8 --- /dev/null +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/useMobileMenuNavigation.ts @@ -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(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, + } +} diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/useOrgMenuNavigation.ts b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/useOrgMenuNavigation.ts new file mode 100644 index 00000000000..4af4d87bef9 --- /dev/null +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/useOrgMenuNavigation.ts @@ -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(initialSectionKey) + + const handleSubmenuClick = useCallback((item: OrgNavItem) => { + setSelectedSectionKey(item.key) + setViewLevel('section') + }, []) + + const handleBackToTop = useCallback(() => { + setViewLevel('top') + setSelectedSectionKey(null) + }, []) + + return { + viewLevel, + selectedSectionKey, + handleSubmenuClick, + handleBackToTop, + } +} diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider.tsx index 74aa05af224..a77795d82c4 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider.tsx @@ -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() diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.tsx index ac76bf48164..2f9ad556ac6 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.tsx @@ -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 ( - { - if (!open) { - setMobileSheetContent(null) - closeActive() - } - }} - > - {activeSidebar?.component?.()} - - ) + if (isMobile) return null return ( <> diff --git a/apps/studio/components/layouts/ProjectLayout/NavigationBar/MobileNavigationBar.tsx b/apps/studio/components/layouts/ProjectLayout/NavigationBar/MobileNavigationBar.tsx index 15873138927..58031a0440f 100644 --- a/apps/studio/components/layouts/ProjectLayout/NavigationBar/MobileNavigationBar.tsx +++ b/apps/studio/components/layouts/ProjectLayout/NavigationBar/MobileNavigationBar.tsx @@ -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 }) =
{!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={} - onClick={() => setIsSheetOpen(true)} + onClick={() => openMenu()} /> )}
- - -
) } diff --git a/apps/studio/components/layouts/ProjectLayout/NavigationBar/MobileSheetContext.tsx b/apps/studio/components/layouts/ProjectLayout/NavigationBar/MobileSheetContext.tsx index 72a6f76ff50..336ae4b5a4a 100644 --- a/apps/studio/components/layouts/ProjectLayout/NavigationBar/MobileSheetContext.tsx +++ b/apps/studio/components/layouts/ProjectLayout/NavigationBar/MobileSheetContext.tsx @@ -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(null) export function MobileSheetProvider({ children }: PropsWithChildren) { const [content, setContentState] = useState(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 ( - + {children} ) diff --git a/apps/studio/components/layouts/ProjectLayout/NavigationBar/StudioMobileSheetNav.tsx b/apps/studio/components/layouts/ProjectLayout/NavigationBar/StudioMobileSheetNav.tsx new file mode 100644 index 00000000000..d2f8f07e399 --- /dev/null +++ b/apps/studio/components/layouts/ProjectLayout/NavigationBar/StudioMobileSheetNav.tsx @@ -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 ( + + {sheetChildren} + + ) +} + +export { StudioMobileSheetNav } diff --git a/apps/studio/components/layouts/ProjectLayout/OrganizationSettingsMenu.tsx b/apps/studio/components/layouts/ProjectLayout/OrganizationSettingsMenu.tsx new file mode 100644 index 00000000000..ed3efcf288f --- /dev/null +++ b/apps/studio/components/layouts/ProjectLayout/OrganizationSettingsMenu.tsx @@ -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 +} diff --git a/apps/studio/components/layouts/ProjectLayout/index.test.tsx b/apps/studio/components/layouts/ProjectLayout/index.test.tsx index 7bec870580d..588514e8b4f 100644 --- a/apps/studio/components/layouts/ProjectLayout/index.test.tsx +++ b/apps/studio/components/layouts/ProjectLayout/index.test.tsx @@ -51,17 +51,31 @@ vi.mock('framer-motion', () => ({ AnimatePresence: ({ children }: { children: ReactNode }) => <>{children}, motion: { div: ({ children, ...props }: any) =>
{children}
, + create: (Component: any) => Component, }, })) vi.mock('ui', () => ({ cn: (...classes: Array) => 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: () =>
, ResizableHandle: (props: any) =>
, ResizablePanel: ({ children, ...props }: any) =>
{children}
, ResizablePanelGroup: ({ children, ...props }: any) =>
{children}
, + Sidebar: ({ children, ...props }: any) =>
{children}
, + SidebarContent: ({ children, ...props }: any) =>
{children}
, + SidebarFooter: ({ children, ...props }: any) =>
{children}
, + SidebarGroup: ({ children, ...props }: any) =>
{children}
, + SidebarMenu: ({ children, ...props }: any) =>
{children}
, + SidebarMenuButton: (props: any) =>
, + SidebarMenuItem: (props: any) =>
, 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( - -
Page Content
-
+ + +
Page Content
+
+
) await waitFor(() => { @@ -174,14 +191,16 @@ describe('ProjectLayout title', () => { it('prefers entity-first browserTitle metadata when provided', async () => { render( - -
Page Content
-
+ + +
Page Content
+
+
) await waitFor(() => { diff --git a/apps/studio/components/layouts/ProjectLayout/index.tsx b/apps/studio/components/layouts/ProjectLayout/index.tsx index a12b3310441..adc3819fb7a 100644 --- a/apps/studio/components/layouts/ProjectLayout/index.tsx +++ b/apps/studio/components/layouts/ProjectLayout/index.tsx @@ -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 { + const unregister = registerOpenMenu(() => { + setMobileSheetContent( + setMobileSheetContent(null)} + /> + ) + }) + return unregister + }, [registerOpenMenu, productMenu, product, currentSectionKey, setMobileSheetContent]) + return ( <> @@ -215,9 +243,6 @@ export const ProjectLayout = forwardRef - - {productMenu} - ) } diff --git a/apps/studio/components/layouts/ProjectSettingsLayout/SettingsLayout.tsx b/apps/studio/components/layouts/ProjectSettingsLayout/SettingsLayout.tsx index 6d0feda1d88..03b8c1ccce3 100644 --- a/apps/studio/components/layouts/ProjectSettingsLayout/SettingsLayout.tsx +++ b/apps/studio/components/layouts/ProjectSettingsLayout/SettingsLayout.tsx @@ -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) => { +/** + * 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 +} + +interface SettingsLayoutProps { + title?: string +} + +export const SettingsLayout = ({ title, children }: PropsWithChildren) => { return ( } + productMenu={} > {children} diff --git a/apps/studio/components/layouts/RealtimeLayout/RealtimeLayout.tsx b/apps/studio/components/layouts/RealtimeLayout/RealtimeLayout.tsx index 3c0f8d515c0..3c9e57fdcf3 100644 --- a/apps/studio/components/layouts/RealtimeLayout/RealtimeLayout.tsx +++ b/apps/studio/components/layouts/RealtimeLayout/RealtimeLayout.tsx @@ -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 +} + export interface RealtimeLayoutProps { title: string } -const RealtimeLayout = ({ title, children }: PropsWithChildren) => { +export const RealtimeLayout = ({ title, children }: PropsWithChildren) => { const { data: project } = useSelectedProjectQuery() - const router = useRouter() const page = router.pathname.split('/')[4] @@ -21,7 +33,7 @@ const RealtimeLayout = ({ title, children }: PropsWithChildren} + productMenu={} > {children} diff --git a/apps/studio/components/layouts/RealtimeLayout/RealtimeMenu.utils.ts b/apps/studio/components/layouts/RealtimeLayout/RealtimeMenu.utils.ts index 2c961d27709..387e3d9f28e 100644 --- a/apps/studio/components/layouts/RealtimeLayout/RealtimeMenu.utils.ts +++ b/apps/studio/components/layouts/RealtimeLayout/RealtimeMenu.utils.ts @@ -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 diff --git a/apps/studio/components/ui/ProductMenu/ProductMenu.types.ts b/apps/studio/components/ui/ProductMenu/ProductMenu.types.ts index 5179cc1fe36..477022c9b23 100644 --- a/apps/studio/components/ui/ProductMenu/ProductMenu.types.ts +++ b/apps/studio/components/ui/ProductMenu/ProductMenu.types.ts @@ -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 +} diff --git a/apps/studio/components/ui/ProductMenu/ProductMenuItem.tsx b/apps/studio/components/ui/ProductMenu/ProductMenuItem.tsx index 2c56aaae93e..f4222de82d6 100644 --- a/apps/studio/components/ui/ProductMenu/ProductMenuItem.tsx +++ b/apps/studio/components/ui/ProductMenu/ProductMenuItem.tsx @@ -57,7 +57,7 @@ export const ProductMenuItem = ({ } return ( - + {menuItem} ) diff --git a/apps/studio/components/ui/ProductMenu/SubMenu.tsx b/apps/studio/components/ui/ProductMenu/SubMenu.tsx new file mode 100644 index 00000000000..f169a0b189f --- /dev/null +++ b/apps/studio/components/ui/ProductMenu/SubMenu.tsx @@ -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 +} diff --git a/apps/studio/components/ui/ProductMenu/SubMenu.utils.test.ts b/apps/studio/components/ui/ProductMenu/SubMenu.utils.test.ts new file mode 100644 index 00000000000..4e214205173 --- /dev/null +++ b/apps/studio/components/ui/ProductMenu/SubMenu.utils.test.ts @@ -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') + }) +}) diff --git a/apps/studio/components/ui/ProductMenu/SubMenu.utils.ts b/apps/studio/components/ui/ProductMenu/SubMenu.utils.ts new file mode 100644 index 00000000000..2557c9091df --- /dev/null +++ b/apps/studio/components/ui/ProductMenu/SubMenu.utils.ts @@ -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 ?? '#', + })), + })) +} diff --git a/apps/studio/components/ui/ProductMenu/index.tsx b/apps/studio/components/ui/ProductMenu/index.tsx index 3ffec73783d..a2e4d463bb4 100644 --- a/apps/studio/components/ui/ProductMenu/index.tsx +++ b/apps/studio/components/ui/ProductMenu/index.tsx @@ -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 ( -
+
{menu.map((group, idx) => (
-
-
+
+
{ item={item} isActive={isActive} target={item.isExternal ? '_blank' : '_self'} + onClick={onItemClick} /> ) })}
- {idx !== menu.length - 1 &&
} + {idx !== menu.length - 1 && ( +
+ )}
))}
diff --git a/apps/studio/lib/pathname.utils.ts b/apps/studio/lib/pathname.utils.ts new file mode 100644 index 00000000000..9ec5eaa9bb5 --- /dev/null +++ b/apps/studio/lib/pathname.utils.ts @@ -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 +} diff --git a/apps/studio/pages/account/audit.tsx b/apps/studio/pages/account/audit.tsx index d7cec7e7621..c54102ac594 100644 --- a/apps/studio/pages/account/audit.tsx +++ b/apps/studio/pages/account/audit.tsx @@ -35,7 +35,7 @@ const Audit: NextPageWithLayout = () => { Audit.getLayout = (page) => ( - + {page} diff --git a/apps/studio/pages/account/me.tsx b/apps/studio/pages/account/me.tsx index d00ddf59c5b..0dfc31b0cc8 100644 --- a/apps/studio/pages/account/me.tsx +++ b/apps/studio/pages/account/me.tsx @@ -31,7 +31,7 @@ const User: NextPageWithLayout = () => { User.getLayout = (page) => ( - + {page} diff --git a/apps/studio/pages/account/security.tsx b/apps/studio/pages/account/security.tsx index 2388a568970..9a6ff5ff17e 100644 --- a/apps/studio/pages/account/security.tsx +++ b/apps/studio/pages/account/security.tsx @@ -89,7 +89,7 @@ const Security: NextPageWithLayout = () => { Security.getLayout = (page) => ( - + {page} diff --git a/apps/studio/pages/account/tokens.tsx b/apps/studio/pages/account/tokens.tsx index f875a7b55a4..deb965d81e2 100644 --- a/apps/studio/pages/account/tokens.tsx +++ b/apps/studio/pages/account/tokens.tsx @@ -66,7 +66,7 @@ const UserAccessTokens: NextPageWithLayout = () => { UserAccessTokens.getLayout = (page) => ( - + {page} diff --git a/apps/studio/pages/new/[slug].tsx b/apps/studio/pages/new/[slug].tsx index 7a7322ea882..54ba1e101ff 100644 --- a/apps/studio/pages/new/[slug].tsx +++ b/apps/studio/pages/new/[slug].tsx @@ -545,7 +545,7 @@ const PageLayout = withAuth(({ children }: PropsWithChildren) => { }) Wizard.getLayout = (page) => ( - + {page} ) diff --git a/apps/studio/pages/new/index.tsx b/apps/studio/pages/new/index.tsx index 467c1410f62..b4668efdc7b 100644 --- a/apps/studio/pages/new/index.tsx +++ b/apps/studio/pages/new/index.tsx @@ -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) => ( - + {page} diff --git a/apps/studio/pages/support/new.tsx b/apps/studio/pages/support/new.tsx index 7330213b826..62952687ac5 100644 --- a/apps/studio/pages/support/new.tsx +++ b/apps/studio/pages/support/new.tsx @@ -10,7 +10,7 @@ const SupportPage: NextPageWithLayout = () => { SupportPage.getLayout = (page) => ( - {page} + {page} ) diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts index fffc6a4eede..12bbc76bfad 100644 --- a/packages/common/telemetry-constants.ts +++ b/packages/common/telemetry-constants.ts @@ -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 diff --git a/packages/ui-patterns/src/MobileSheetNav/MobileSheetNav.tsx b/packages/ui-patterns/src/MobileSheetNav/MobileSheetNav.tsx index 064035ac607..45f2caafef9 100644 --- a/packages/ui-patterns/src/MobileSheetNav/MobileSheetNav.tsx +++ b/packages/ui-patterns/src/MobileSheetNav/MobileSheetNav.tsx @@ -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) {