mirror of
https://github.com/supabase/supabase.git
synced 2026-09-08 19:08:44 +08:00
Closes DOCS-1261 _WAVE plugin shows headers creating jumps in hierarchy. Preview on the left:_ <img width="1022" height="417" alt="Screenshot 2026-07-29 at 2 42 07 PM" src="https://github.com/user-attachments/assets/f5e3bd09-8dbf-45e3-8a7f-70d7334fd25b" /> <img width="408" height="663" alt="Screenshot 2026-07-29 at 2 44 33 PM" src="https://github.com/user-attachments/assets/6b952a81-40e5-4a9d-b08a-190c71575cac" /> ## Problem Four shared components in `apps/docs` render a hardcoded heading tag no matter where they're used: - `NamedCodeBlock` renders a code block's filename as an `<h6>` - `ProjectConfigVariables` renders a variable label as an `<h6>` - `StepHikeCompact.Details` renders a step title as an `<h3>` - `IconPanel` renders its title as an `<h5>` Since these are fixed, they often land in the wrong spot in a page's heading order (like an h6 right after an h2), which breaks navigation for screen reader users. This showed up in the [header hierarchy triage report](https://app.notion.com/p/supabase/Playwright-E2E-Triage-Reports-3ab5004b775f81e3bc60d058fa5a02c1) — fixing these 4 components alone resolves 72 of the 159 heading-order violations found. ## Solution Swapped the heading tag in each component for a `<span>` with the same classes. None of these are really "headings" for the content that follows, so they shouldn't be in the tag tree at all. The one wrinkle: this codebase applies heading font weight/family through a global CSS rule keyed off the tag name (h1-h6), not something the tag gives you for free. So each span now sets that styling explicitly, plus a margin to match what was there before. Nothing else changed — same classes, same layout. ## Manual testing Staging preview: https://docs-git-ui-header-hierarchy-supabase.vercel.app Check that each one still looks right: - [NamedCodeBlock](https://docs-git-ui-header-hierarchy-supabase.vercel.app/docs/guides/self-hosting/docker) — filenames above the code blocks - [ProjectConfigVariables](https://docs-git-ui-header-hierarchy-supabase.vercel.app/docs/guides/auth/quickstarts/react-native) — the "Project URL" / "Publishable key" labels (this page also has a `NamedCodeBlock` inside the numbered steps) - [StepHikeCompact](https://docs-git-ui-header-hierarchy-supabase.vercel.app/docs/guides/database/beekeeper-studio) — the step titles ("Create a new connection", etc.) - [IconPanel](https://docs-git-ui-header-hierarchy-supabase.vercel.app/docs/guides/resources) — the "Auth0" / "Firebase Auth" panel titles under "Migrate to Supabase" I also ran typecheck, lint, and the docs test suite locally — all green. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Style** * Updated headings and labels across documentation and UI components for more consistent typography. * Improved spacing, font weight, and block-level layout for project variables, step details, code tabs, and icon panels. * Preserved existing text content and conditional display behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai --> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
505 lines
16 KiB
TypeScript
505 lines
16 KiB
TypeScript
'use client'
|
|
|
|
import {
|
|
ComboBox,
|
|
ComboBoxOption,
|
|
} from '~/components/ProjectConfigVariables/ProjectConfigVariables.ComboBox'
|
|
import type {
|
|
Branch,
|
|
Org,
|
|
Variable,
|
|
} from '~/components/ProjectConfigVariables/ProjectConfigVariables.utils'
|
|
import {
|
|
fromBranchValue,
|
|
fromOrgProjectValue,
|
|
prettyFormatVariable,
|
|
toBranchValue,
|
|
toDisplayNameOrgProject,
|
|
toOrgProjectValue,
|
|
} from '~/components/ProjectConfigVariables/ProjectConfigVariables.utils'
|
|
import { useCopy } from '~/hooks/useCopy'
|
|
import { useDebounce } from '~/hooks/useDebounce'
|
|
import { useBranchesQuery } from '~/lib/fetch/branches'
|
|
import { useOrganizationsQuery } from '~/lib/fetch/organizations'
|
|
import { useSupavisorConfigQuery, type SupavisorConfigData } from '~/lib/fetch/pooler'
|
|
import { useProjectKeysQuery, useProjectSettingsQuery } from '~/lib/fetch/projectApi'
|
|
import {
|
|
isProjectPaused,
|
|
ProjectInfoInfinite,
|
|
useProjectsInfiniteQuery,
|
|
} from '~/lib/fetch/projects-infinite'
|
|
import { retrieve, storeOrRemoveNull } from '~/lib/storage'
|
|
import { useSendTelemetryEvent } from '~/lib/telemetry'
|
|
import { useOnLogout } from '~/lib/userAuth'
|
|
import { LOCAL_STORAGE_KEYS, useIsLoggedIn, useIsUserLoading } from 'common'
|
|
import { Check, Copy } from 'lucide-react'
|
|
import Link from 'next/link'
|
|
import { useEffect, useMemo, useState } from 'react'
|
|
import CopyToClipboard from 'react-copy-to-clipboard'
|
|
import { withErrorBoundary } from 'react-error-boundary'
|
|
import { Button_Shadcn_ as Button, cn, Input } from 'ui'
|
|
import { proxy, useSnapshot } from 'valtio'
|
|
|
|
type ProjectOrgDataState =
|
|
| 'userLoading'
|
|
| 'loggedOut'
|
|
| 'loggedIn.dataPending'
|
|
| 'loggedIn.dataError'
|
|
| 'loggedIn.dataSuccess.hasData'
|
|
| 'loggedIn.dataSuccess.hasNoData'
|
|
|
|
type BranchesDataState =
|
|
| 'userLoading'
|
|
| 'loggedOut'
|
|
| 'loggedIn.noBranches'
|
|
| 'loggedIn.branches.dataPending'
|
|
| 'loggedIn.branches.dataError'
|
|
| 'loggedIn.branches.dataSuccess.hasData'
|
|
| 'loggedIn.branches.dataSuccess.noData'
|
|
|
|
type VariableDataState =
|
|
| 'userLoading'
|
|
| 'loggedOut'
|
|
| 'loggedIn.noSelectedProject'
|
|
| 'loggedIn.selectedProject.projectPaused'
|
|
| 'loggedIn.selectedProject.dataPending'
|
|
| 'loggedIn.selectedProject.dataError'
|
|
| 'loggedIn.selectedProject.dataSuccess'
|
|
|
|
const projectsStore = proxy({
|
|
selectedOrg: null as Org | null,
|
|
selectedProject: null as ProjectInfoInfinite | null,
|
|
setSelectedOrgProject: (org: Org | null, project: ProjectInfoInfinite | null) => {
|
|
projectsStore.selectedOrg = org
|
|
storeOrRemoveNull('local', LOCAL_STORAGE_KEYS.SAVED_ORG, org?.id.toString())
|
|
|
|
projectsStore.selectedProject = project
|
|
storeOrRemoveNull('local', LOCAL_STORAGE_KEYS.SAVED_PROJECT, project?.ref)
|
|
},
|
|
selectedBranch: null as Branch | null,
|
|
setSelectedBranch: (branch: Branch | null) => {
|
|
projectsStore.selectedBranch = branch
|
|
storeOrRemoveNull('local', LOCAL_STORAGE_KEYS.SAVED_BRANCH, branch?.id)
|
|
},
|
|
clear: () => {
|
|
projectsStore.setSelectedOrgProject(null, null)
|
|
projectsStore.setSelectedBranch(null)
|
|
},
|
|
})
|
|
|
|
function OrgProjectSelector() {
|
|
const isUserLoading = useIsUserLoading()
|
|
const isLoggedIn = useIsLoggedIn()
|
|
|
|
const [search, setSearch] = useState('')
|
|
const debouncedSearch = useDebounce(search, 500)
|
|
|
|
const { selectedOrg, selectedProject, setSelectedOrgProject } = useSnapshot(projectsStore)
|
|
|
|
const {
|
|
data: organizations,
|
|
isPending: organizationsIsPending,
|
|
isError: organizationsIsError,
|
|
} = useOrganizationsQuery({ enabled: isLoggedIn })
|
|
|
|
const {
|
|
data: projectsData,
|
|
isPending: projectsIsPending,
|
|
isError: projectsIsError,
|
|
isFetching,
|
|
isFetchingNextPage,
|
|
hasNextPage,
|
|
fetchNextPage,
|
|
} = useProjectsInfiniteQuery(
|
|
{ search: search.length === 0 ? search : debouncedSearch },
|
|
{ enabled: isLoggedIn }
|
|
)
|
|
const projects =
|
|
useMemo(() => projectsData?.pages.flatMap((page) => page.projects), [projectsData?.pages]) || []
|
|
|
|
const anyIsPending = organizationsIsPending || projectsIsPending
|
|
const anyIsError = organizationsIsError || projectsIsError
|
|
|
|
const stateSummary: ProjectOrgDataState = isUserLoading
|
|
? 'userLoading'
|
|
: !isLoggedIn
|
|
? 'loggedOut'
|
|
: anyIsPending
|
|
? 'loggedIn.dataPending'
|
|
: anyIsError
|
|
? 'loggedIn.dataError'
|
|
: projects?.length === 0
|
|
? 'loggedIn.dataSuccess.hasNoData'
|
|
: 'loggedIn.dataSuccess.hasData'
|
|
|
|
const formattedData: ComboBoxOption[] = useMemo(
|
|
() =>
|
|
stateSummary !== 'loggedIn.dataSuccess.hasData'
|
|
? []
|
|
: (projects!
|
|
.map((project) => {
|
|
const organization = organizations!.find((org) => org.id === project.organization_id)!
|
|
const paused = isProjectPaused(project)
|
|
return {
|
|
id: project.ref,
|
|
value: toOrgProjectValue(organization, project),
|
|
displayName: paused
|
|
? `${toDisplayNameOrgProject(organization, project)} (paused)`
|
|
: toDisplayNameOrgProject(organization, project),
|
|
disabled: paused,
|
|
}
|
|
})
|
|
.filter(Boolean) as ComboBoxOption[]),
|
|
[organizations, projects, stateSummary]
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (stateSummary === 'loggedIn.dataSuccess.hasData' && (!selectedOrg || !selectedProject)) {
|
|
const storedMaybeOrgId = retrieve('local', LOCAL_STORAGE_KEYS.SAVED_ORG)
|
|
const storedMaybeProjectRef = retrieve('local', LOCAL_STORAGE_KEYS.SAVED_PROJECT)
|
|
|
|
let storedOrg: Org | undefined
|
|
let storedProject: ProjectInfoInfinite | undefined
|
|
if (storedMaybeOrgId && storedMaybeProjectRef) {
|
|
storedOrg = organizations!.find((org) => org.id === Number(storedMaybeOrgId))
|
|
storedProject = projects!.find((project) => project.ref === storedMaybeProjectRef)
|
|
}
|
|
|
|
if (storedOrg && storedProject && storedProject.organization_id === storedOrg.id) {
|
|
setSelectedOrgProject(storedOrg, storedProject)
|
|
} else {
|
|
const firstActiveProject = projects!.find((project) => !isProjectPaused(project))
|
|
if (firstActiveProject) {
|
|
const matchingOrg = organizations!.find(
|
|
(org) => org.id === firstActiveProject.organization_id
|
|
)
|
|
if (matchingOrg) setSelectedOrgProject(matchingOrg, firstActiveProject)
|
|
}
|
|
}
|
|
}
|
|
}, [organizations, projects, selectedOrg, selectedProject, setSelectedOrgProject, stateSummary])
|
|
|
|
return (
|
|
<ComboBox
|
|
name="project"
|
|
isLoading={stateSummary === 'userLoading' || stateSummary === 'loggedIn.dataPending'}
|
|
disabled={
|
|
stateSummary === 'loggedOut' ||
|
|
stateSummary === 'loggedIn.dataError' ||
|
|
stateSummary === 'loggedIn.dataSuccess.hasNoData'
|
|
}
|
|
options={formattedData}
|
|
selectedDisplayName={
|
|
selectedOrg && selectedProject
|
|
? toDisplayNameOrgProject(selectedOrg, selectedProject)
|
|
: undefined
|
|
}
|
|
selectedOption={
|
|
selectedOrg && selectedProject ? toOrgProjectValue(selectedOrg, selectedProject) : undefined
|
|
}
|
|
onSelectOption={(optionValue) => {
|
|
const [orgId, projectRef] = fromOrgProjectValue(optionValue)
|
|
if (!orgId || !projectRef) return
|
|
|
|
const org = organizations?.find((org) => org.id === orgId)
|
|
const project = projects?.find((project) => project.ref === projectRef)
|
|
|
|
if (org && project && project.organization_id === org.id) {
|
|
setSelectedOrgProject(org, project)
|
|
}
|
|
}}
|
|
search={search}
|
|
isFetching={isFetching}
|
|
isFetchingNextPage={isFetchingNextPage}
|
|
hasNextPage={hasNextPage}
|
|
fetchNextPage={fetchNextPage}
|
|
setSearch={setSearch}
|
|
useCommandSearch={false}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function BranchSelector() {
|
|
const userLoading = useIsUserLoading()
|
|
const isLoggedIn = useIsLoggedIn()
|
|
|
|
const { selectedProject, selectedBranch, setSelectedBranch } = useSnapshot(projectsStore)
|
|
const [branchSearch, setBranchSearch] = useState('')
|
|
|
|
const projectPaused = isProjectPaused(selectedProject)
|
|
const hasBranches = selectedProject?.is_branch_enabled ?? false
|
|
|
|
const { data, isPending, isError } = useBranchesQuery(
|
|
{ projectRef: selectedProject?.ref },
|
|
{ enabled: isLoggedIn && !projectPaused && hasBranches }
|
|
)
|
|
|
|
const stateSummary: BranchesDataState = userLoading
|
|
? 'userLoading'
|
|
: !isLoggedIn
|
|
? 'loggedOut'
|
|
: !hasBranches || projectPaused
|
|
? 'loggedIn.noBranches'
|
|
: isPending
|
|
? 'loggedIn.branches.dataPending'
|
|
: isError
|
|
? 'loggedIn.branches.dataError'
|
|
: data.length === 0
|
|
? 'loggedIn.branches.dataSuccess.noData'
|
|
: 'loggedIn.branches.dataSuccess.hasData'
|
|
|
|
const formattedData: ComboBoxOption[] =
|
|
stateSummary !== 'loggedIn.branches.dataSuccess.hasData'
|
|
? []
|
|
: data!.map((branch) => ({
|
|
id: branch.id,
|
|
displayName: branch.name,
|
|
value: toBranchValue(branch),
|
|
}))
|
|
|
|
useEffect(() => {
|
|
if (stateSummary === 'loggedIn.branches.dataSuccess.hasData' && !selectedBranch) {
|
|
const storedMaybeBranchId = retrieve('local', LOCAL_STORAGE_KEYS.SAVED_BRANCH)
|
|
|
|
let storedBranch: Branch | undefined
|
|
if (storedMaybeBranchId) {
|
|
storedBranch = data!.find((branch) => branch.id === storedMaybeBranchId)
|
|
}
|
|
|
|
if (storedBranch) {
|
|
setSelectedBranch(storedBranch)
|
|
} else {
|
|
const productionBranch = data!.find(
|
|
(branch) => branch.project_ref === branch.parent_project_ref
|
|
)
|
|
setSelectedBranch(productionBranch ?? data![0])
|
|
}
|
|
}
|
|
}, [data, selectedBranch, setSelectedBranch, stateSummary])
|
|
|
|
return hasBranches ? (
|
|
<ComboBox
|
|
name="branch"
|
|
isLoading={stateSummary === 'userLoading' || stateSummary === 'loggedIn.branches.dataPending'}
|
|
disabled={
|
|
stateSummary === 'loggedOut' ||
|
|
stateSummary === 'loggedIn.noBranches' ||
|
|
stateSummary === 'loggedIn.branches.dataError' ||
|
|
stateSummary === 'loggedIn.branches.dataSuccess.noData'
|
|
}
|
|
options={formattedData}
|
|
selectedDisplayName={selectedBranch?.name}
|
|
selectedOption={selectedBranch ? toBranchValue(selectedBranch) : undefined}
|
|
search={branchSearch}
|
|
setSearch={setBranchSearch}
|
|
onSelectOption={(option) => {
|
|
const [branchId] = fromBranchValue(option)
|
|
if (branchId) {
|
|
const branch = data?.find((branch) => branch.id === branchId)
|
|
if (branch) setSelectedBranch(branch)
|
|
}
|
|
}}
|
|
/>
|
|
) : null
|
|
}
|
|
|
|
function VariableView({ variable, className }: { variable: Variable; className?: string }) {
|
|
const isUserLoading = useIsUserLoading()
|
|
const isLoggedIn = useIsLoggedIn()
|
|
|
|
const { selectedProject, selectedBranch } = useSnapshot(projectsStore)
|
|
const projectPaused = isProjectPaused(selectedProject)
|
|
const hasBranches = selectedProject?.is_branch_enabled ?? false
|
|
const ref = hasBranches ? selectedBranch?.project_ref : selectedProject?.ref
|
|
|
|
const needsApiQuery = variable === 'publishable' || variable === 'anon' || variable === 'url'
|
|
const needsSupavisorQuery = variable === 'sessionPooler'
|
|
|
|
const {
|
|
data: apiSettingsData,
|
|
isPending: isApiSettingsPending,
|
|
isError: isApiSettingsError,
|
|
} = useProjectSettingsQuery(
|
|
{
|
|
projectRef: ref,
|
|
},
|
|
{ enabled: isLoggedIn && !!ref && !projectPaused && needsApiQuery }
|
|
)
|
|
|
|
const {
|
|
data: apiKeysData,
|
|
isPending: isApiKeysPending,
|
|
isError: isApiKeysError,
|
|
} = useProjectKeysQuery(
|
|
{
|
|
projectRef: ref,
|
|
},
|
|
{ enabled: isLoggedIn && !!ref && !projectPaused && needsApiQuery }
|
|
)
|
|
|
|
const {
|
|
data: supavisorConfig,
|
|
isPending: isSupavisorPending,
|
|
isError: isSupavisorError,
|
|
} = useSupavisorConfigQuery(
|
|
{
|
|
projectRef: ref,
|
|
},
|
|
{ enabled: isLoggedIn && !!ref && !projectPaused && needsSupavisorQuery }
|
|
)
|
|
|
|
function isInvalidSupavisorData(supavisorData: SupavisorConfigData) {
|
|
return supavisorData.length === 0
|
|
}
|
|
|
|
const stateSummary: VariableDataState = isUserLoading
|
|
? 'userLoading'
|
|
: !isLoggedIn
|
|
? 'loggedOut'
|
|
: !ref
|
|
? 'loggedIn.noSelectedProject'
|
|
: projectPaused
|
|
? 'loggedIn.selectedProject.projectPaused'
|
|
: (needsApiQuery ? isApiSettingsPending || isApiKeysPending : isSupavisorPending)
|
|
? 'loggedIn.selectedProject.dataPending'
|
|
: (
|
|
needsApiQuery
|
|
? isApiSettingsError || isApiKeysError
|
|
: isSupavisorError || isInvalidSupavisorData(supavisorConfig!)
|
|
)
|
|
? 'loggedIn.selectedProject.dataError'
|
|
: 'loggedIn.selectedProject.dataSuccess'
|
|
|
|
let variableValue: string = ''
|
|
|
|
if (stateSummary === 'loggedIn.selectedProject.dataSuccess') {
|
|
switch (variable) {
|
|
case 'url':
|
|
variableValue = `https://${apiSettingsData?.app_config?.endpoint}`
|
|
break
|
|
case 'anon':
|
|
variableValue =
|
|
apiKeysData?.find((key) => key.type === 'legacy' && key.id === 'anon')?.api_key || ''
|
|
break
|
|
case 'publishable':
|
|
variableValue = apiKeysData?.find((key) => key.type === 'publishable')?.api_key || ''
|
|
break
|
|
case 'sessionPooler':
|
|
variableValue = supavisorConfig?.[0]?.connection_string || ''
|
|
}
|
|
}
|
|
|
|
const { copied, handleCopy } = useCopy()
|
|
const sendTelemetryEvent = useSendTelemetryEvent()
|
|
|
|
return (
|
|
<>
|
|
<div className={cn('flex items-center gap-2', className)}>
|
|
<Input
|
|
readOnly
|
|
type="text"
|
|
className="font-mono"
|
|
value={
|
|
stateSummary === 'userLoading' ||
|
|
stateSummary === 'loggedIn.selectedProject.dataPending'
|
|
? 'Loading...'
|
|
: stateSummary === 'loggedIn.selectedProject.projectPaused'
|
|
? 'PROJECT PAUSED'
|
|
: stateSummary === 'loggedIn.selectedProject.dataSuccess'
|
|
? variableValue
|
|
: `YOUR ${prettyFormatVariable[variable].toUpperCase()}`
|
|
}
|
|
/>
|
|
<CopyToClipboard text={variableValue ?? ''}>
|
|
<Button
|
|
disabled={!variableValue}
|
|
variant="ghost"
|
|
className="px-0"
|
|
onClick={() => {
|
|
handleCopy()
|
|
sendTelemetryEvent({
|
|
action: 'docs_project_config_variables_copy_button_clicked',
|
|
properties: { variable },
|
|
})
|
|
}}
|
|
aria-label="Copy"
|
|
>
|
|
{copied ? <Check size="18" /> : <Copy size="18" />}
|
|
</Button>
|
|
</CopyToClipboard>
|
|
</div>
|
|
{stateSummary === 'loggedIn.selectedProject.dataError' && (
|
|
<p className="text-foreground-muted text-sm mt-2 mb-0 ml-1">
|
|
You can also copy your {prettyFormatVariable[variable]} from the{' '}
|
|
<Link
|
|
className="text-foreground-muted"
|
|
href="https://supabase.com/dashboard/project/_/settings/api"
|
|
rel="noreferrer noopener"
|
|
target="_blank"
|
|
>
|
|
dashboard
|
|
</Link>
|
|
.
|
|
</p>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
function LoginHint({ variable }: { variable: Variable }) {
|
|
const isUserLoading = useIsUserLoading()
|
|
const isLoggedIn = useIsLoggedIn()
|
|
|
|
if (isUserLoading || isLoggedIn) return null
|
|
|
|
return (
|
|
<p className="not-prose text-foreground-muted text-sm mt-2 mb-0 ml-1">
|
|
To get your {prettyFormatVariable[variable]},{' '}
|
|
<Link
|
|
className="text-foreground-muted"
|
|
href="https://supabase.com/dashboard"
|
|
rel="noreferrer noopener"
|
|
target="_blank"
|
|
>
|
|
log in
|
|
</Link>
|
|
.
|
|
</p>
|
|
)
|
|
}
|
|
|
|
function ProjectConfigVariablesInternal({ variable }: { variable: Variable }) {
|
|
const { clear: clearSharedStoreData } = useSnapshot(projectsStore)
|
|
useOnLogout(clearSharedStoreData)
|
|
|
|
return (
|
|
<div className="max-w-[min(100%, 500px)] my-6">
|
|
<span className={cn('block mt-0 mb-1 font-heading font-semibold', 'text-foreground')}>
|
|
{prettyFormatVariable[variable]}
|
|
</span>
|
|
<div className="flex flex-wrap gap-x-6">
|
|
<OrgProjectSelector />
|
|
<BranchSelector />
|
|
</div>
|
|
<VariableView variable={variable} className="mt-1" />
|
|
<LoginHint variable={variable} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export const ProjectConfigVariables = withErrorBoundary(ProjectConfigVariablesInternal, {
|
|
fallback: (
|
|
<p>
|
|
Couldn't display your API settings. You can get them from the{' '}
|
|
<Link
|
|
href="https://supabase.com/dashboard/project/_/settings/api"
|
|
rel="noreferrer noopener"
|
|
target="_blank"
|
|
>
|
|
dashboard
|
|
</Link>
|
|
.
|
|
</p>
|
|
),
|
|
})
|