mirror of
https://github.com/supabase/supabase.git
synced 2026-10-09 03:15:06 +03:00
## Context PR here mainly breaks up the files under `ConnectSheet` to separate the functional logic so that we can write unit tests. No behavior changes intended beyond the bug fixes ## Changes involved - **Test organization:** moved all root-level `ConnectSheet` test files into `ConnectSheet/__tests__/` for consistency with other parts of the codebase that use this convention. - **Bug fix:** read replica label had a stray `}` / missing `)`, rendering as e.g. `Read Replica (us-east-1 - abc123})` instead of `Read Replica (us-east-1 - abc123)`. - **`ConnectSheet.tsx`:** extracted the "hydrate sheet state on open" `useEffect` logic (mode/field/URL param resolution from URL vs. localStorage) into a new `ConnectSheet.utils.ts`, with unit tests - **`useConnectServerEnv.ts`:** fixed two race conditions in the secret reveal/hide flow: - `toggle()` and `getValue()` could each fire a separate reveal request if triggered close together — now deduped to share one in-flight request. - `getValue()` could hide a secret that had just been explicitly revealed by a concurrent `toggle()`, due to reading a stale closure value — now reads the live state via `useLatest`. - Also stopped swallowing the original error on reveal failure (now attached via `cause`). - Added tests for the above, plus the 10s auto-hide timer (previously untested). - **`ConnectStepsSection.tsx`:** extracted `resolveContentPath` and the three inline "show notice" booleans (IPv4 addon, session pooler, self-hosted MCP) into `ConnectStepsSection.utils.ts`, matching the existing pattern for the Data API notice. Added unit tests for all of them. ## To test - [ ] Just a basic smoke test of the Connect sheet should do <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Improved connect setup hydration so saved preferences and URL values are applied more consistently when opening the sheet, including automatic URL backfilling where needed. * Refreshed connection guidance notices (IPv4 add-on, session pooler, and self-hosted MCP) with more consistent logic. * **Bug Fixes** * Fixed secret reveal behavior to keep concurrent reveal actions in sync, handle failures more safely, and ensure auto-hide works reliably. * Corrected the read-replica option label formatting. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
386 lines
12 KiB
TypeScript
386 lines
12 KiB
TypeScript
import { useParams } from 'common'
|
|
import { useCallback, useMemo, useState } from 'react'
|
|
import { FEATURE_GROUPS_PLATFORM, MCP_CLIENTS } from 'ui-patterns/McpUrlBuilder'
|
|
|
|
import {
|
|
connectionStringMethodOptions,
|
|
DATABASE_CONNECTION_TYPES,
|
|
FRAMEWORKS,
|
|
MOBILES,
|
|
ORMS,
|
|
} from './Connect.constants'
|
|
import {
|
|
getActiveFields,
|
|
getDefaultState,
|
|
resetDependentFields,
|
|
resolveSteps,
|
|
} from './connect.resolver'
|
|
import { connectSchema } from './connect.schema'
|
|
import type {
|
|
ConnectMode,
|
|
ConnectSchema,
|
|
ConnectState,
|
|
DeploymentMode,
|
|
FieldOption,
|
|
ResolvedField,
|
|
ResolvedStep,
|
|
} from './Connect.types'
|
|
import { resolveFrameworkLibraryKey } from './Connect.utils'
|
|
import { Database, useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
|
|
import { formatDatabaseID, formatDatabaseRegion } from '@/data/read-replicas/replicas.utils'
|
|
import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
|
|
import { useDeploymentMode } from '@/hooks/misc/useDeploymentMode'
|
|
import { useIsHighAvailability } from '@/hooks/misc/useSelectedProject'
|
|
|
|
// ============================================================================
|
|
// Data Source Helpers
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Get field options from a data source reference.
|
|
* This maps source names to actual data.
|
|
*/
|
|
function getFieldOptionsFromSource({
|
|
source,
|
|
state,
|
|
databases,
|
|
deploymentMode,
|
|
}: {
|
|
source: string
|
|
state: ConnectState
|
|
databases: Database[]
|
|
deploymentMode: DeploymentMode
|
|
}): FieldOption[] {
|
|
switch (source) {
|
|
case 'frameworks':
|
|
return [...FRAMEWORKS, ...MOBILES].map((f) => ({
|
|
value: f.key,
|
|
label: f.label,
|
|
icon: f.icon,
|
|
}))
|
|
|
|
case 'frameworkVariants': {
|
|
// Get variants for the selected framework
|
|
const allFrameworks = [...FRAMEWORKS, ...MOBILES]
|
|
const selected = allFrameworks.find((f) => f.key === state.framework)
|
|
if (!selected?.children?.length) return []
|
|
// Only return if there are multiple children (variants)
|
|
if (selected.children.length <= 1) return []
|
|
return selected.children.map((c) => ({
|
|
value: c.key,
|
|
label: c.label,
|
|
icon: c.icon,
|
|
}))
|
|
}
|
|
|
|
case 'libraries': {
|
|
// Get libraries for the selected framework and variant
|
|
const allFrameworks = [...FRAMEWORKS, ...MOBILES]
|
|
const selectedFramework = allFrameworks.find((f) => f.key === state.framework)
|
|
if (!selectedFramework) return []
|
|
|
|
// If framework has variants, look in the variant
|
|
if (selectedFramework.children?.length > 1 && state.frameworkVariant) {
|
|
const variant = selectedFramework.children.find((c) => c.key === state.frameworkVariant)
|
|
if (variant?.children?.length) {
|
|
return variant.children.map((c) => ({
|
|
value: c.key,
|
|
label: c.label,
|
|
icon: c.icon,
|
|
}))
|
|
}
|
|
}
|
|
|
|
// Otherwise look directly in framework children
|
|
if (selectedFramework.children?.length === 1) {
|
|
const child = selectedFramework.children[0]
|
|
if (child.children?.length) {
|
|
return child.children.map((c) => ({
|
|
value: c.key,
|
|
label: c.label,
|
|
icon: c.icon,
|
|
}))
|
|
}
|
|
// The child itself is the library
|
|
return [{ value: child.key, label: child.label, icon: child.icon }]
|
|
}
|
|
|
|
return []
|
|
}
|
|
|
|
case 'connectionMethods': {
|
|
const all = Object.values(connectionStringMethodOptions)
|
|
const allowed: string[] = deploymentMode.isCli
|
|
? ['direct']
|
|
: deploymentMode.isSelfHosted
|
|
? ['session', 'transaction', 'direct']
|
|
: ['direct', 'transaction', 'session']
|
|
const filtered = allowed
|
|
.map((value) => all.find((m) => m.value === value))
|
|
.filter((m): m is (typeof all)[number] => !!m)
|
|
return filtered.map((m) => ({
|
|
value: m.value,
|
|
label: m.label,
|
|
description:
|
|
deploymentMode.isSelfHosted && m.value === 'direct'
|
|
? 'Manually configurable for self-hosted Supabase.'
|
|
: deploymentMode.isSelfHosted && m.value === 'session'
|
|
? 'Supavisor (default pooler for self-hosted Supabase).'
|
|
: m.description,
|
|
}))
|
|
}
|
|
|
|
case 'connectionSources':
|
|
return databases.map((db) => {
|
|
const region = formatDatabaseRegion(db?.region ?? '')
|
|
const id = formatDatabaseID(db.identifier ?? '')
|
|
const label = db.identifier.includes('-rr-')
|
|
? `Read Replica (${region} - ${id})`
|
|
: 'Primary Database'
|
|
return { value: db.identifier, label }
|
|
})
|
|
|
|
case 'connectionTypes':
|
|
return DATABASE_CONNECTION_TYPES.map((t) => ({
|
|
value: t.id,
|
|
label: t.label,
|
|
}))
|
|
|
|
case 'orms':
|
|
return ORMS.map((o) => ({
|
|
value: o.key,
|
|
label: o.label,
|
|
icon: o.icon,
|
|
}))
|
|
|
|
case 'mcpClients':
|
|
return MCP_CLIENTS.map((c) => ({
|
|
value: c.key,
|
|
label: c.label,
|
|
icon: c.icon,
|
|
}))
|
|
|
|
case 'mcpFeatures':
|
|
return FEATURE_GROUPS_PLATFORM.map((f) => ({
|
|
value: f.id,
|
|
label: f.name,
|
|
description: f.description,
|
|
}))
|
|
|
|
default:
|
|
return []
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve field options, handling both static options and data source references.
|
|
*/
|
|
function resolveFieldOptionsWithSource({
|
|
field,
|
|
state,
|
|
databases,
|
|
deploymentMode,
|
|
}: {
|
|
field: ResolvedField
|
|
state: ConnectState
|
|
databases: Database[]
|
|
deploymentMode: DeploymentMode
|
|
}): FieldOption[] {
|
|
// If already resolved (from conditional resolution)
|
|
if (field.resolvedOptions.length > 0) {
|
|
return field.resolvedOptions
|
|
}
|
|
|
|
// Check if it's a source reference
|
|
const options = connectSchema.fields[field.id]?.options
|
|
if (options && typeof options === 'object' && 'source' in options) {
|
|
return getFieldOptionsFromSource({
|
|
source: options.source as string,
|
|
state,
|
|
databases,
|
|
deploymentMode,
|
|
})
|
|
}
|
|
|
|
return []
|
|
}
|
|
|
|
// ============================================================================
|
|
// Hook
|
|
// ============================================================================
|
|
|
|
export interface UseConnectStateReturn {
|
|
state: ConnectState
|
|
updateField: (fieldId: string, value: string | boolean | string[]) => void
|
|
setMode: (mode: ConnectMode) => void
|
|
activeFields: ResolvedField[]
|
|
resolvedSteps: ResolvedStep[]
|
|
getFieldOptions: (fieldId: string) => FieldOption[]
|
|
schema: ConnectSchema
|
|
}
|
|
|
|
export function useConnectState(initialState?: Partial<ConnectState>): UseConnectStateReturn {
|
|
const { ref: projectRef } = useParams()
|
|
const { data: databases = [] } = useReadReplicasQuery({ projectRef })
|
|
const { hasAccess: hasDedicatedPooler } = useCheckEntitlements('dedicated_pooler')
|
|
const isHighAvailability = useIsHighAvailability()
|
|
const deploymentMode = useDeploymentMode()
|
|
|
|
const [state, setState] = useState<ConnectState>(() => {
|
|
const defaults = getDefaultState({ schema: connectSchema })
|
|
|
|
// Set initial framework if mode is framework
|
|
if (defaults.mode === 'framework' && !defaults.framework) {
|
|
const firstFramework = FRAMEWORKS[0]
|
|
defaults.framework = firstFramework?.key ?? ''
|
|
|
|
// Set initial variant if framework has variants
|
|
if (firstFramework?.children?.length > 1) {
|
|
defaults.frameworkVariant = firstFramework.children[0]?.key ?? ''
|
|
}
|
|
|
|
// Set initial library
|
|
const libraryKey = resolveFrameworkLibraryKey({
|
|
framework: defaults.framework,
|
|
frameworkVariant: defaults.frameworkVariant,
|
|
library: defaults.library,
|
|
})
|
|
if (libraryKey) defaults.library = libraryKey
|
|
}
|
|
|
|
// Set initial ORM if mode is orm
|
|
if (defaults.mode === 'orm' && !defaults.orm) {
|
|
defaults.orm = ORMS[0]?.key ?? ''
|
|
}
|
|
|
|
// Set initial MCP client if mode is mcp
|
|
if (defaults.mode === 'mcp' && !defaults.mcpClient) {
|
|
defaults.mcpClient = MCP_CLIENTS[0]?.key ?? ''
|
|
}
|
|
|
|
return { ...defaults, ...initialState } as ConnectState
|
|
})
|
|
|
|
const updateField = useCallback((fieldId: string, value: string | boolean | string[]) => {
|
|
setState((prev) => {
|
|
const next = { ...prev, [fieldId]: value }
|
|
|
|
// Handle cascading updates for framework selection
|
|
if (fieldId === 'framework') {
|
|
const allFrameworks = [...FRAMEWORKS, ...MOBILES]
|
|
const selected = allFrameworks.find((f) => f.key === value)
|
|
|
|
// Reset variant if framework changed
|
|
if (selected?.children && selected.children.length > 1) {
|
|
next.frameworkVariant = selected.children[0]?.key ?? ''
|
|
} else {
|
|
delete next.frameworkVariant
|
|
}
|
|
|
|
// Reset library
|
|
const libraryKey = resolveFrameworkLibraryKey({
|
|
framework: next.framework,
|
|
frameworkVariant: next.frameworkVariant,
|
|
})
|
|
if (libraryKey) {
|
|
next.library = libraryKey
|
|
} else {
|
|
delete next.library
|
|
}
|
|
}
|
|
|
|
// Handle cascading updates for variant selection
|
|
if (fieldId === 'frameworkVariant') {
|
|
const libraryKey = resolveFrameworkLibraryKey({
|
|
framework: prev.framework,
|
|
frameworkVariant: String(value),
|
|
})
|
|
if (libraryKey) next.library = libraryKey
|
|
}
|
|
|
|
// Reset useSharedPooler when connectionMethod changes to 'direct'
|
|
if (fieldId === 'connectionMethod' && value === 'direct') {
|
|
next.useSharedPooler = false
|
|
}
|
|
|
|
return resetDependentFields(next, fieldId, connectSchema)
|
|
})
|
|
}, [])
|
|
|
|
const setMode = useCallback(
|
|
(mode: ConnectMode) => {
|
|
setState((prev) => {
|
|
const next: ConnectState = { ...prev, mode }
|
|
|
|
// Initialize mode-specific defaults
|
|
if (mode === 'framework' && !next.framework) {
|
|
const firstFramework = FRAMEWORKS[0]
|
|
next.framework = firstFramework?.key ?? ''
|
|
if (firstFramework?.children?.length > 1) {
|
|
next.frameworkVariant = firstFramework.children[0]?.key ?? ''
|
|
}
|
|
const libraryKey = resolveFrameworkLibraryKey({
|
|
framework: next.framework,
|
|
frameworkVariant: next.frameworkVariant,
|
|
})
|
|
if (libraryKey) next.library = libraryKey
|
|
}
|
|
|
|
if (mode === 'direct') {
|
|
const defaultMethod = deploymentMode.isSelfHosted ? 'session' : 'direct'
|
|
next.connectionMethod = next.connectionMethod ?? defaultMethod
|
|
next.connectionType = next.connectionType ?? 'uri'
|
|
next.connectionSource = projectRef ?? '_'
|
|
}
|
|
|
|
if (mode === 'orm' && !next.orm) {
|
|
next.orm = ORMS[0]?.key ?? ''
|
|
}
|
|
|
|
if (mode === 'mcp' && !next.mcpClient) {
|
|
next.mcpClient = MCP_CLIENTS[0]?.key ?? ''
|
|
}
|
|
|
|
return next
|
|
})
|
|
},
|
|
[projectRef, deploymentMode.isSelfHosted]
|
|
)
|
|
|
|
const activeFields = useMemo(() => {
|
|
let fields = getActiveFields(connectSchema, state)
|
|
if (!hasDedicatedPooler || !deploymentMode.isPlatform) {
|
|
// useSharedPooler is a platform-only toggle (CLI has no pooler; self-hosted
|
|
// already uses Supavisor shared)
|
|
fields = fields.filter((f) => f.id !== 'useSharedPooler')
|
|
}
|
|
if (isHighAvailability) {
|
|
fields = fields
|
|
.filter((f) => f.id !== 'connectionMethod' && f.id !== 'useSharedPooler')
|
|
.map((f) => (f.id === 'connectionType' ? { ...f, label: 'Connection Type' } : f))
|
|
}
|
|
return fields
|
|
}, [state, hasDedicatedPooler, isHighAvailability, deploymentMode.isPlatform])
|
|
|
|
const resolvedSteps = useMemo(() => resolveSteps(connectSchema, state), [state])
|
|
|
|
const getFieldOptions = useCallback(
|
|
(fieldId: string): FieldOption[] => {
|
|
const field = activeFields.find((f) => f.id === fieldId)
|
|
if (!field) return []
|
|
return resolveFieldOptionsWithSource({ field, state, databases, deploymentMode })
|
|
},
|
|
[activeFields, state, databases, deploymentMode]
|
|
)
|
|
|
|
return {
|
|
state,
|
|
updateField,
|
|
setMode,
|
|
activeFields,
|
|
resolvedSteps,
|
|
getFieldOptions,
|
|
schema: connectSchema,
|
|
}
|
|
}
|