mirror of
https://github.com/supabase/supabase.git
synced 2026-05-08 09:50:33 -04:00
f2122b64f2
* Use paginated projects endpoint for docs * Deprecate old projects query * Fix * Fix * fix(docs branch selector) * refactor(docs project selector): simplify dom --------- Co-authored-by: Charis Lam <26616127+charislam@users.noreply.github.com>
490 lines
16 KiB
TypeScript
490 lines
16 KiB
TypeScript
'use client'
|
|
|
|
import type {
|
|
Branch,
|
|
Org,
|
|
Variable,
|
|
} from '~/components/ProjectConfigVariables/ProjectConfigVariables.utils'
|
|
|
|
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 { proxy, useSnapshot } from 'valtio'
|
|
|
|
import { LOCAL_STORAGE_KEYS, useIsLoggedIn, useIsUserLoading } from 'common'
|
|
import { Button_Shadcn_ as Button, cn, Input_Shadcn_ as Input } from 'ui'
|
|
|
|
import {
|
|
ComboBox,
|
|
ComboBoxOption,
|
|
} from '~/components/ProjectConfigVariables/ProjectConfigVariables.ComboBox'
|
|
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 { useOnLogout } from '~/lib/userAuth'
|
|
|
|
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)!
|
|
return {
|
|
id: project.ref,
|
|
value: toOrgProjectValue(organization, project),
|
|
displayName: toDisplayNameOrgProject(organization, project),
|
|
}
|
|
})
|
|
.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 if (projects!.length > 0) {
|
|
const firstProject = projects![0]
|
|
const matchingOrg = organizations!.find((org) => org.id === firstProject.organization_id)
|
|
if (matchingOrg) setSelectedOrgProject(matchingOrg, firstProject)
|
|
}
|
|
}
|
|
}, [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()
|
|
|
|
return (
|
|
<>
|
|
<div className={cn('flex items-center gap-2', className)}>
|
|
<Input
|
|
disabled
|
|
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}
|
|
aria-label="Copy"
|
|
>
|
|
{copied ? <Check /> : <Copy />}
|
|
</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="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">
|
|
<h6 className={cn('mt-0 mb-1', 'text-foreground')}>{prettyFormatVariable[variable]}</h6>
|
|
<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>
|
|
),
|
|
})
|