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..7bec870580d --- /dev/null +++ b/apps/studio/components/layouts/ProjectLayout/index.test.tsx @@ -0,0 +1,195 @@ +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/components/layouts/ProjectLayout/index.tsx b/apps/studio/components/layouts/ProjectLayout/index.tsx index b54a2584a5a..8cb791c9d6d 100644 --- a/apps/studio/components/layouts/ProjectLayout/index.tsx +++ b/apps/studio/components/layouts/ProjectLayout/index.tsx @@ -37,6 +37,7 @@ import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { withAuth } from '@/hooks/misc/withAuth' import { usePHFlag } from '@/hooks/ui/useFlag' import { PROJECT_STATUS } from '@/lib/constants' +import { buildStudioPageTitle } from '@/lib/page-title' import { useAppStateSnapshot } from '@/state/app-state' import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector' @@ -72,6 +73,13 @@ export interface ProjectLayoutProps { isBlocking?: boolean product?: string productMenu?: ReactNode + browserTitle?: { + entity?: string + section?: string + surface?: string + override?: string + } + // Deprecated: use browserTitle.entity instead. Kept for backwards compatibility. selectedTable?: string resizableSidebar?: boolean productMenuClassName?: string @@ -85,6 +93,7 @@ export const ProjectLayout = forwardRef - - {title - ? `${title} | ${titleSuffix}` - : selectedTable - ? `${selectedTable} | ${projectName} | ${organizationName} | ${titleSuffix}` - : projectName - ? `${projectName} | ${organizationName} | ${titleSuffix}` - : organizationName - ? `${organizationName} | ${titleSuffix}` - : titleSuffix} - + {pageTitle}
diff --git a/apps/studio/lib/page-title.test.ts b/apps/studio/lib/page-title.test.ts new file mode 100644 index 00000000000..a05f31c32c2 --- /dev/null +++ b/apps/studio/lib/page-title.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest' + +import { buildStudioPageTitle, STUDIO_PAGE_TITLE_SEPARATOR } from './page-title' + +describe('buildStudioPageTitle', () => { + it('builds a project-scoped title in most-specific-first order', () => { + expect( + buildStudioPageTitle({ + surface: 'Database', + project: 'Acme Project', + org: 'Acme Org', + brand: 'Supabase', + }) + ).toBe( + `Database${STUDIO_PAGE_TITLE_SEPARATOR}Acme Project${STUDIO_PAGE_TITLE_SEPARATOR}Acme Org${STUDIO_PAGE_TITLE_SEPARATOR}Supabase` + ) + }) + + it('includes entity and section when provided', () => { + expect( + buildStudioPageTitle({ + entity: 'users', + section: 'Tables', + surface: 'Database', + project: 'Acme Project', + org: 'Acme Org', + brand: 'Supabase', + }) + ).toBe( + `users${STUDIO_PAGE_TITLE_SEPARATOR}Tables${STUDIO_PAGE_TITLE_SEPARATOR}Database${STUDIO_PAGE_TITLE_SEPARATOR}Acme Project${STUDIO_PAGE_TITLE_SEPARATOR}Acme Org${STUDIO_PAGE_TITLE_SEPARATOR}Supabase` + ) + }) + + it('omits missing segments', () => { + expect( + buildStudioPageTitle({ + section: 'Authentication', + project: 'Acme Project', + brand: 'Supabase', + }) + ).toBe( + `Authentication${STUDIO_PAGE_TITLE_SEPARATOR}Acme Project${STUDIO_PAGE_TITLE_SEPARATOR}Supabase` + ) + }) + + it('deduplicates adjacent segments case-insensitively', () => { + expect( + buildStudioPageTitle({ + section: 'Database', + surface: 'database', + project: 'Acme Project', + org: 'Acme Org', + brand: 'Supabase', + }) + ).toBe( + `Database${STUDIO_PAGE_TITLE_SEPARATOR}Acme Project${STUDIO_PAGE_TITLE_SEPARATOR}Acme Org${STUDIO_PAGE_TITLE_SEPARATOR}Supabase` + ) + }) + + it('normalizes whitespace in each segment', () => { + expect( + buildStudioPageTitle({ + entity: ' hello world ', + surface: ' Edge Functions ', + brand: ' Supabase ', + }) + ).toBe( + `hello world${STUDIO_PAGE_TITLE_SEPARATOR}Edge Functions${STUDIO_PAGE_TITLE_SEPARATOR}Supabase` + ) + }) + + it('truncates very long segments', () => { + const longName = 'x'.repeat(80) + + expect( + buildStudioPageTitle({ + entity: longName, + surface: 'Table Editor', + brand: 'Supabase', + }) + ).toBe( + `${'x'.repeat(59)}…${STUDIO_PAGE_TITLE_SEPARATOR}Table Editor${STUDIO_PAGE_TITLE_SEPARATOR}Supabase` + ) + }) + + it('supports custom brand titles', () => { + expect( + buildStudioPageTitle({ + surface: 'Settings', + brand: 'Supabase Studio', + }) + ).toBe(`Settings${STUDIO_PAGE_TITLE_SEPARATOR}Supabase Studio`) + }) +}) diff --git a/apps/studio/lib/page-title.ts b/apps/studio/lib/page-title.ts new file mode 100644 index 00000000000..30918e48b38 --- /dev/null +++ b/apps/studio/lib/page-title.ts @@ -0,0 +1,46 @@ +export interface StudioPageTitleParts { + entity?: string + section?: string + surface?: string + project?: string + org?: string + brand?: string +} + +export const STUDIO_PAGE_TITLE_SEPARATOR = ' | ' +const MAX_SEGMENT_LENGTH = 60 + +const normalizeTitleSegment = (value?: string) => { + if (value === undefined) return undefined + + const normalized = value.trim().replace(/\s+/g, ' ') + if (normalized.length === 0) return undefined + + if (normalized.length <= MAX_SEGMENT_LENGTH) return normalized + return `${normalized.slice(0, MAX_SEGMENT_LENGTH - 1).trimEnd()}…` +} + +export const buildStudioPageTitle = (parts: StudioPageTitleParts) => { + const orderedParts = [ + parts.entity, + parts.section, + parts.surface, + parts.project, + parts.org, + parts.brand, + ] + + const segments: string[] = [] + + orderedParts.forEach((part) => { + const segment = normalizeTitleSegment(part) + if (!segment) return + + const lastSegment = segments[segments.length - 1] + if (lastSegment !== undefined && lastSegment.toLowerCase() === segment.toLowerCase()) return + + segments.push(segment) + }) + + return segments.join(STUDIO_PAGE_TITLE_SEPARATOR) +}