diff --git a/apps/design-system/content/docs/ui-patterns/navigation.mdx b/apps/design-system/content/docs/ui-patterns/navigation.mdx index a0fc17e816f..4105c43e67b 100644 --- a/apps/design-system/content/docs/ui-patterns/navigation.mdx +++ b/apps/design-system/content/docs/ui-patterns/navigation.mdx @@ -12,3 +12,22 @@ Supabase has a necessarily complex navigation system to handle multiple products A horizontal list of related views within a consistent PageLayout context, allowing for clearer page-level organisation. Activating a NavMenu item should trigger a URL change. [NavMenu component guidelines](../components/nav-menu) + +## Page titles + +Browser page titles should follow a consistent most-specific-first structure so tabs and browser history are easier to scan: + +`Entity | Section | Surface | Project | Org | Supabase` + +Examples: + +- `users | Table Editor | My Project | My Org | Supabase` +- `Backups | Database | My Project | My Org | Supabase` + +Implementation notes (Studio): + +- Use the shared title formatter in `apps/studio/lib/page-title.ts` +- Prefer `ProjectLayout` for project-scoped pages +- Treat `title` as the section label and `product` as the surface label +- Use `browserTitle.entity` for the most specific resource (table/function/query) when available +- Avoid assembling `document.title` ad hoc in individual pages/layouts diff --git a/apps/studio/components/layouts/LogsLayout/LogsLayout.tsx b/apps/studio/components/layouts/LogsLayout/LogsLayout.tsx index 18eca1e0c14..c6e26798960 100644 --- a/apps/studio/components/layouts/LogsLayout/LogsLayout.tsx +++ b/apps/studio/components/layouts/LogsLayout/LogsLayout.tsx @@ -1,6 +1,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { PropsWithChildren } from 'react' +import { useRouter } from 'next/router' import NoPermission from 'components/ui/NoPermission' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' @@ -12,20 +13,29 @@ interface LogsLayoutProps { title?: string } +const LOGS_SECTION_TITLE_BY_ROUTE: Record = { + '/project/[ref]/logs/explorer': 'Explorer', + '/project/[ref]/logs/explorer/recent': 'Recent', + '/project/[ref]/logs/explorer/saved': 'Saved', + '/project/[ref]/logs/explorer/templates': 'Templates', +} + const LogsLayout = ({ title, children }: PropsWithChildren) => { + const router = useRouter() const { isLoading, can: canUseLogsExplorer } = useAsyncCheckPermissions( PermissionAction.ANALYTICS_READ, 'logflare' ) + const resolvedTitle = title ?? LOGS_SECTION_TITLE_BY_ROUTE[router.pathname] if (!canUseLogsExplorer) { if (isLoading) { - return + return } if (!isLoading && !canUseLogsExplorer) { return ( - + ) @@ -33,7 +43,11 @@ const LogsLayout = ({ title, children }: PropsWithChildren) => } return ( - }> + } + > {children} ) diff --git a/apps/studio/components/layouts/ObservabilityLayout/ObservabilityLayout.tsx b/apps/studio/components/layouts/ObservabilityLayout/ObservabilityLayout.tsx index f8d3692ca5f..0eefede125e 100644 --- a/apps/studio/components/layouts/ObservabilityLayout/ObservabilityLayout.tsx +++ b/apps/studio/components/layouts/ObservabilityLayout/ObservabilityLayout.tsx @@ -17,6 +17,29 @@ interface ObservabilityLayoutProps { title?: string } +const OBSERVABILITY_SECTION_TITLE_BY_ROUTE: Record = { + 'api-overview': 'API Gateway', + auth: 'Auth', + database: 'Database', + 'edge-functions': 'Edge Functions', + postgrest: 'PostgREST', + 'query-performance': 'Query Performance', + realtime: 'Realtime', + storage: 'Storage', +} + +const getObservabilitySectionTitle = (pathname: string | null, title?: string) => { + if (title !== undefined) return title + if (!pathname) return undefined + + const segments = pathname.split('/').filter(Boolean) + const page = segments[3] + + if (page === undefined) return 'Overview' + + return OBSERVABILITY_SECTION_TITLE_BY_ROUTE[page] ?? 'Report' +} + const ObservabilityLayoutContent = ({ title, children, @@ -81,11 +104,12 @@ const ObservabilityLayoutContent = ({ ]) const { reportsAll } = useIsFeatureEnabled(['reports:all']) + const resolvedTitle = getObservabilitySectionTitle(pathname, title) if (reportsAll) { return ( } isBlocking={false} diff --git a/apps/studio/components/layouts/ProjectLayout/index.test.tsx b/apps/studio/components/layouts/ProjectLayout/index.test.tsx new file mode 100644 index 00000000000..38c9879efe3 --- /dev/null +++ b/apps/studio/components/layouts/ProjectLayout/index.test.tsx @@ -0,0 +1,199 @@ +import { render, waitFor } from '@testing-library/react' +import type { ReactNode } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { STUDIO_PAGE_TITLE_SEPARATOR } from '@/lib/page-title' + +const { + mockRouter, + mockSetSelectedDatabaseId, + mockSetMobileMenuOpen, +} = vi.hoisted(() => ({ + mockRouter: { + pathname: '/project/[ref]/observability/query-performance', + asPath: '/project/default/observability/query-performance', + push: vi.fn(), + replace: vi.fn(), + }, + mockSetSelectedDatabaseId: vi.fn(), + mockSetMobileMenuOpen: vi.fn(), +})) + +vi.mock('next/router', () => ({ + useRouter: () => mockRouter, +})) + +vi.mock('next/head', async () => { + const React = await import('react') + + const Head = ({ children }: { children?: ReactNode }) => { + React.useEffect(() => { + const titleElement = React.Children.toArray(children).find( + (child) => React.isValidElement(child) && child.type === 'title' + ) + + if (!React.isValidElement(titleElement)) return + + const titleText = React.Children.toArray(titleElement.props.children).join('') + document.title = titleText + }, [children]) + + return null + } + + return { default: Head } +}) + +vi.mock('common', () => ({ + useParams: () => ({ ref: 'default' }), + mergeRefs: + (..._refs: any[]) => + (_value: unknown) => {}, +})) + +vi.mock('framer-motion', () => ({ + AnimatePresence: ({ children }: { children: ReactNode }) => <>{children}, + motion: { + div: ({ children, ...props }: any) =>
{children}
, + }, +})) + +vi.mock('ui', () => ({ + cn: (...classes: Array) => classes.filter(Boolean).join(' '), + LogoLoader: () =>
, + ResizableHandle: (props: any) =>
, + ResizablePanel: ({ children, ...props }: any) =>
{children}
, + ResizablePanelGroup: ({ children, ...props }: any) =>
{children}
, + useIsMobile: () => false, + usePanelRef: () => undefined, +})) + +vi.mock('ui-patterns/MobileSheetNav/MobileSheetNav', () => ({ + default: ({ children }: { children: ReactNode }) => <>{children}, +})) + +vi.mock('../editors/EditorsLayout.hooks', () => ({ + useEditorType: () => undefined, +})) + +vi.mock('../MainScrollContainerContext', () => ({ + useSetMainScrollContainer: () => () => {}, +})) + +vi.mock('./BuildingState', () => ({ default: () => null })) +vi.mock('./ConnectingState', () => ({ default: () => null })) +vi.mock('./LoadingState', () => ({ LoadingState: () => null })) +vi.mock('./PausedState/ProjectPausedState', () => ({ ProjectPausedState: () => null })) +vi.mock('./PauseFailedState', () => ({ default: () => null })) +vi.mock('./PausingState', () => ({ default: () => null })) +vi.mock('./ProductMenuBar', () => ({ + default: ({ children }: { children: ReactNode }) => <>{children}, +})) +vi.mock('./ResizingState', () => ({ default: () => null })) +vi.mock('./RestartingState', () => ({ default: () => null })) +vi.mock('./RestoreFailedState', () => ({ default: () => null })) +vi.mock('./RestoringState', () => ({ default: () => null })) +vi.mock('./UpgradingState', () => ({ UpgradingState: () => null })) + +vi.mock('@/components/interfaces/BranchManagement/CreateBranchModal', () => ({ + CreateBranchModal: () => null, +})) +vi.mock('@/components/interfaces/ProjectAPIDocs/ProjectAPIDocs', () => ({ + ProjectAPIDocs: () => null, +})) +vi.mock('@/components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner', () => ({ + ResourceExhaustionWarningBanner: () => null, +})) + +vi.mock('@/hooks/custom-content/useCustomContent', () => ({ + useCustomContent: () => ({ appTitle: 'Supabase' }), +})) + +vi.mock('@/hooks/misc/useSelectedOrganization', () => ({ + useSelectedOrganizationQuery: () => ({ + data: { name: 'Organization 1', slug: 'org-1' }, + }), +})) + +vi.mock('@/hooks/misc/useSelectedProject', () => ({ + useSelectedProjectQuery: () => ({ + data: { + ref: 'default', + name: 'Project 1', + status: 'ACTIVE_HEALTHY', + postgrestStatus: 'ONLINE', + }, + }), +})) + +vi.mock('@/hooks/misc/withAuth', () => ({ + withAuth: (Component: any) => Component, +})) + +vi.mock('@/hooks/ui/useFlag', () => ({ + usePHFlag: () => undefined, +})) + +vi.mock('@/state/app-state', () => ({ + useAppStateSnapshot: () => ({ + mobileMenuOpen: false, + showSidebar: false, + setMobileMenuOpen: mockSetMobileMenuOpen, + }), +})) + +vi.mock('@/state/database-selector', () => ({ + useDatabaseSelectorStateSnapshot: () => ({ + setSelectedDatabaseId: mockSetSelectedDatabaseId, + }), +})) + +import { ProjectLayout } from './index' + +describe('ProjectLayout title', () => { + beforeEach(() => { + mockRouter.pathname = '/project/[ref]/observability/query-performance' + mockRouter.asPath = '/project/default/observability/query-performance' + document.title = '' + }) + + afterEach(() => { + vi.clearAllMocks() + document.title = '' + }) + + it('sets a composed document title and deduplicates identical section/surface labels', async () => { + render( + +
Page Content
+
+ ) + + await waitFor(() => { + expect(document.title).toBe( + ['Settings', 'Project 1', 'Organization 1', 'Supabase'].join(STUDIO_PAGE_TITLE_SEPARATOR) + ) + }) + }) + + it('prefers entity-first browserTitle metadata when provided', async () => { + render( + +
Page Content
+
+ ) + + await waitFor(() => { + expect(document.title).toBe( + ['users', 'Tables', 'Database', 'Project 1', 'Organization 1', 'Supabase'].join( + STUDIO_PAGE_TITLE_SEPARATOR + ) + ) + }) + }) +}) diff --git a/apps/studio/pages/project/[ref]/logs/pg-upgrade-logs.tsx b/apps/studio/pages/project/[ref]/logs/pg-upgrade-logs.tsx index 9661aa7bdd9..97672dbfd66 100644 --- a/apps/studio/pages/project/[ref]/logs/pg-upgrade-logs.tsx +++ b/apps/studio/pages/project/[ref]/logs/pg-upgrade-logs.tsx @@ -11,7 +11,7 @@ export const LogPage: NextPageWithLayout = () => { LogPage.getLayout = (page) => ( - {page} + {page} )