mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
Reinstate source param in new connect sheet to select read replicas (#44018)
## Context Adds a "source" param in the new Connect sheet to allow selecting read replicas - this was present in the old Connect dialog <img width="962" height="713" alt="image" src="https://github.com/user-attachments/assets/3c805e41-e8df-431b-83d0-e5f227d68475" /> Selecting sources will update the connect steps at the bottom - connection string, host etc
This commit is contained in:
1 parent
b7847b74db
commit
bf54231c82
7 files changed
+180
-44
No files matched your search
@@ -44,8 +44,6 @@ export function ConnectConfigSection({
|
||||
}: ConnectConfigSectionProps) {
|
||||
if (activeFields.length === 0) return null
|
||||
|
||||
const formLayoutClassName = 'md:[&>div:first-child]:!w-1/3 xl:[&>div:first-child]:!w-2/5'
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-4">
|
||||
{activeFields.map((field) => {
|
||||
|
||||
@@ -471,7 +471,7 @@ describe('connect.resolver:getDefaultState', () => {
|
||||
steps: [],
|
||||
}
|
||||
|
||||
const state = getDefaultState(schema)
|
||||
const state = getDefaultState({ schema })
|
||||
expect(state.mode).toBe('framework')
|
||||
})
|
||||
|
||||
@@ -501,7 +501,7 @@ describe('connect.resolver:getDefaultState', () => {
|
||||
steps: [],
|
||||
}
|
||||
|
||||
const state = getDefaultState(schema)
|
||||
const state = getDefaultState({ schema })
|
||||
expect(state.framework).toBe('nextjs')
|
||||
expect(state.library).toBe('supabasejs')
|
||||
expect(state.mcpReadonly).toBe(false)
|
||||
@@ -514,7 +514,7 @@ describe('connect.resolver:getDefaultState', () => {
|
||||
steps: [],
|
||||
}
|
||||
|
||||
const state = getDefaultState(schema)
|
||||
const state = getDefaultState({ schema })
|
||||
expect(state.mode).toBe('direct')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -196,7 +196,7 @@ function resolveFieldOptions(field: { options?: unknown }, state: ConnectState):
|
||||
/**
|
||||
* Gets default state for the schema, using first mode and default field values.
|
||||
*/
|
||||
export function getDefaultState(schema: ConnectSchema): ConnectState {
|
||||
export function getDefaultState({ schema }: { schema: ConnectSchema }): ConnectState {
|
||||
const defaultMode = schema.modes[0]?.id ?? 'direct'
|
||||
|
||||
const state: ConnectState = { mode: defaultMode }
|
||||
|
||||
@@ -180,7 +180,7 @@ export const connectSchema: ConnectSchema = {
|
||||
id: 'direct',
|
||||
label: 'Direct',
|
||||
description: 'Connection string',
|
||||
fields: ['connectionMethod', 'useSharedPooler', 'connectionType'],
|
||||
fields: ['connectionSource', 'connectionMethod', 'useSharedPooler', 'connectionType'],
|
||||
},
|
||||
{
|
||||
id: 'orm',
|
||||
@@ -233,6 +233,13 @@ export const connectSchema: ConnectSchema = {
|
||||
},
|
||||
|
||||
// Direct connection fields
|
||||
connectionSource: {
|
||||
id: 'connectionSource',
|
||||
type: 'select',
|
||||
label: 'Source',
|
||||
options: { source: 'connectionSources' },
|
||||
defaultValue: undefined,
|
||||
},
|
||||
connectionMethod: {
|
||||
id: 'connectionMethod',
|
||||
type: 'radio-list',
|
||||
|
||||
+88
-1
@@ -1,7 +1,10 @@
|
||||
import { useParams } from 'common'
|
||||
import { useMemo } from 'react'
|
||||
import { CodeBlock } from 'ui'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import { getConnectionStrings } from '../../../DatabaseSettings.utils'
|
||||
import { getAddons } from '@/components/interfaces/Billing/Subscription/Subscription.utils'
|
||||
import {
|
||||
type ConnectionStringMethod,
|
||||
type DatabaseConnectionType,
|
||||
@@ -15,6 +18,12 @@ import {
|
||||
PASSWORD_PLACEHOLDER,
|
||||
resolveConnectionString,
|
||||
} from '@/components/interfaces/ConnectSheet/ConnectionString.utils'
|
||||
import { usePgbouncerConfigQuery } from '@/data/database/pgbouncer-config-query'
|
||||
import { useSupavisorConfigurationQuery } from '@/data/database/supavisor-configuration-query'
|
||||
import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
|
||||
import { useProjectAddonsQuery } from '@/data/subscriptions/project-addons-query'
|
||||
import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
|
||||
import { pluckObjectFields } from '@/lib/helpers'
|
||||
|
||||
const buildPsqlCommand = (params: { host: string; port: string; database: string; user: string }) =>
|
||||
`psql -h ${params.host} -p ${params.port} -d ${params.database} -U ${params.user}`
|
||||
@@ -22,15 +31,93 @@ const buildPsqlCommand = (params: { host: string; port: string; database: string
|
||||
const buildJdbcString = (params: { host: string; port: string; database: string; user: string }) =>
|
||||
`jdbc:postgresql://${params.host}:${params.port}/${params.database}?user=${params.user}&password=${PASSWORD_PLACEHOLDER}`
|
||||
|
||||
/**
|
||||
* [Joshen] ConnectStepsSection does something similar but since only this page needs to consider connection strings
|
||||
* from all databases (including read replicas), am opting to separate the logic for retrieving connection strings here
|
||||
*
|
||||
* We can however, consider to shift this logic into ConnectStepsSection, such that we can consider read replicas for
|
||||
* the other tabs like "Framework" and "ORM" too. However, leaving them out for now and only updating "Direct"
|
||||
*/
|
||||
const useConnectionStringDatabases = () => {
|
||||
const { ref: projectRef } = useParams()
|
||||
const { hasAccess: allowPgBouncerSelection } = useCheckEntitlements('dedicated_pooler')
|
||||
|
||||
const { data: databases = [] } = useReadReplicasQuery({ projectRef })
|
||||
const { data: pgbouncerConfig } = usePgbouncerConfigQuery({ projectRef })
|
||||
const { data: supavisorConfig } = useSupavisorConfigurationQuery({ projectRef })
|
||||
const { data: addons } = useProjectAddonsQuery({ projectRef })
|
||||
const { ipv4: ipv4Addon } = getAddons(addons?.selected_addons ?? [])
|
||||
|
||||
const DB_FIELDS = ['db_host', 'db_name', 'db_port', 'db_user', 'inserted_at']
|
||||
const emptyState = { db_user: '', db_host: '', db_port: '', db_name: '' }
|
||||
|
||||
return Object.fromEntries(
|
||||
databases.map((db) => {
|
||||
const connectionInfo = pluckObjectFields(db || emptyState, DB_FIELDS)
|
||||
const poolingConfigurationShared = supavisorConfig?.find(
|
||||
(x) => x.identifier === db.identifier
|
||||
)
|
||||
const poolingConfigurationDedicated = allowPgBouncerSelection ? pgbouncerConfig : undefined
|
||||
|
||||
const connectionStringsShared = getConnectionStrings({
|
||||
connectionInfo,
|
||||
poolingInfo: {
|
||||
connectionString: poolingConfigurationShared?.connection_string ?? '',
|
||||
db_host: poolingConfigurationShared?.db_host ?? '',
|
||||
db_name: poolingConfigurationShared?.db_name ?? '',
|
||||
db_port: poolingConfigurationShared?.db_port ?? 0,
|
||||
db_user: poolingConfigurationShared?.db_user ?? '',
|
||||
},
|
||||
metadata: { projectRef: db.identifier },
|
||||
})
|
||||
|
||||
const connectionStringsDedicated =
|
||||
poolingConfigurationDedicated !== undefined
|
||||
? getConnectionStrings({
|
||||
connectionInfo,
|
||||
poolingInfo: {
|
||||
connectionString: poolingConfigurationDedicated.connection_string.replace(
|
||||
projectRef ?? '_',
|
||||
db.identifier
|
||||
),
|
||||
db_host: poolingConfigurationDedicated.db_host,
|
||||
db_name: poolingConfigurationDedicated.db_name,
|
||||
db_port: poolingConfigurationDedicated.db_port,
|
||||
db_user: poolingConfigurationDedicated.db_user,
|
||||
},
|
||||
metadata: { projectRef: db.identifier },
|
||||
})
|
||||
: undefined
|
||||
|
||||
return [
|
||||
db.identifier,
|
||||
{
|
||||
transactionShared: connectionStringsShared.pooler.uri,
|
||||
sessionShared: connectionStringsShared.pooler.uri.replace('6543', '5432'),
|
||||
transactionDedicated: connectionStringsDedicated?.pooler.uri,
|
||||
sessionDedicated: connectionStringsDedicated?.pooler.uri.replace('6543', '5432'),
|
||||
ipv4SupportedForDedicatedPooler: !!ipv4Addon,
|
||||
direct: connectionStringsShared.direct.uri,
|
||||
},
|
||||
]
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Step component for direct database connections.
|
||||
* Uses state to determine which connection string to show.
|
||||
*/
|
||||
function DirectConnectionContent({ state, connectionStringPooler }: StepContentProps) {
|
||||
function DirectConnectionContent({ state }: StepContentProps) {
|
||||
const connectionSource = state.connectionSource
|
||||
const connectionType = (state.connectionType as DatabaseConnectionType) ?? 'uri'
|
||||
const connectionMethod = (state.connectionMethod as ConnectionStringMethod) ?? 'direct'
|
||||
const useSharedPooler = Boolean(state.useSharedPooler)
|
||||
|
||||
const connectionStrings = useConnectionStringDatabases()
|
||||
const connectionStringPooler =
|
||||
connectionStrings[connectionSource as keyof typeof connectionStrings]
|
||||
|
||||
// Determine which connection string to use
|
||||
const resolvedConnectionString = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import { useConnectState } from './useConnectState'
|
||||
|
||||
vi.mock('common', () => ({
|
||||
useParams: () => ({ ref: 'test-ref' }),
|
||||
}))
|
||||
|
||||
vi.mock('@/data/read-replicas/replicas-query', () => ({
|
||||
useReadReplicasQuery: () => ({ data: [] }),
|
||||
}))
|
||||
|
||||
describe('useConnectState', () => {
|
||||
// ============================================================================
|
||||
// Initial State Tests
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useParams } from 'common'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { FEATURE_GROUPS_PLATFORM, MCP_CLIENTS } from 'ui-patterns/McpUrlBuilder'
|
||||
|
||||
@@ -24,6 +25,8 @@ import type {
|
||||
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'
|
||||
|
||||
// ============================================================================
|
||||
// Data Source Helpers
|
||||
@@ -33,7 +36,15 @@ import { resolveFrameworkLibraryKey } from './Connect.utils'
|
||||
* Get field options from a data source reference.
|
||||
* This maps source names to actual data.
|
||||
*/
|
||||
function getFieldOptionsFromSource(source: string, state: ConnectState): FieldOption[] {
|
||||
function getFieldOptionsFromSource({
|
||||
source,
|
||||
state,
|
||||
databases,
|
||||
}: {
|
||||
source: string
|
||||
state: ConnectState
|
||||
databases: Database[]
|
||||
}): FieldOption[] {
|
||||
switch (source) {
|
||||
case 'frameworks':
|
||||
return [...FRAMEWORKS, ...MOBILES].map((f) => ({
|
||||
@@ -98,6 +109,16 @@ function getFieldOptionsFromSource(source: string, state: ConnectState): FieldOp
|
||||
description: 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,
|
||||
@@ -133,7 +154,15 @@ function getFieldOptionsFromSource(source: string, state: ConnectState): FieldOp
|
||||
/**
|
||||
* Resolve field options, handling both static options and data source references.
|
||||
*/
|
||||
function resolveFieldOptionsWithSource(field: ResolvedField, state: ConnectState): FieldOption[] {
|
||||
function resolveFieldOptionsWithSource({
|
||||
field,
|
||||
state,
|
||||
databases,
|
||||
}: {
|
||||
field: ResolvedField
|
||||
state: ConnectState
|
||||
databases: Database[]
|
||||
}): FieldOption[] {
|
||||
// If already resolved (from conditional resolution)
|
||||
if (field.resolvedOptions.length > 0) {
|
||||
return field.resolvedOptions
|
||||
@@ -142,7 +171,7 @@ function resolveFieldOptionsWithSource(field: ResolvedField, state: ConnectState
|
||||
// Check if it's a source reference
|
||||
const options = connectSchema.fields[field.id]?.options
|
||||
if (options && typeof options === 'object' && 'source' in options) {
|
||||
return getFieldOptionsFromSource(options.source as string, state)
|
||||
return getFieldOptionsFromSource({ source: options.source as string, state, databases })
|
||||
}
|
||||
|
||||
return []
|
||||
@@ -163,8 +192,11 @@ export interface UseConnectStateReturn {
|
||||
}
|
||||
|
||||
export function useConnectState(initialState?: Partial<ConnectState>): UseConnectStateReturn {
|
||||
const { ref: projectRef } = useParams()
|
||||
const { data: databases = [] } = useReadReplicasQuery({ projectRef })
|
||||
|
||||
const [state, setState] = useState<ConnectState>(() => {
|
||||
const defaults = getDefaultState(connectSchema)
|
||||
const defaults = getDefaultState({ schema: connectSchema })
|
||||
|
||||
// Set initial framework if mode is framework
|
||||
if (defaults.mode === 'framework' && !defaults.framework) {
|
||||
@@ -246,41 +278,45 @@ export function useConnectState(initialState?: Partial<ConnectState>): UseConnec
|
||||
})
|
||||
}, [])
|
||||
|
||||
const setMode = useCallback((mode: ConnectMode) => {
|
||||
setState((prev) => {
|
||||
const next: ConnectState = { ...prev, mode }
|
||||
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 ?? ''
|
||||
// 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,
|
||||
library: next.library,
|
||||
})
|
||||
if (libraryKey) next.library = libraryKey
|
||||
}
|
||||
const libraryKey = resolveFrameworkLibraryKey({
|
||||
framework: next.framework,
|
||||
frameworkVariant: next.frameworkVariant,
|
||||
library: next.library,
|
||||
})
|
||||
if (libraryKey) next.library = libraryKey
|
||||
}
|
||||
|
||||
if (mode === 'direct') {
|
||||
next.connectionMethod = next.connectionMethod ?? 'direct'
|
||||
next.connectionType = next.connectionType ?? 'uri'
|
||||
}
|
||||
if (mode === 'direct') {
|
||||
next.connectionMethod = next.connectionMethod ?? 'direct'
|
||||
next.connectionType = next.connectionType ?? 'uri'
|
||||
next.connectionSource = projectRef ?? '_'
|
||||
}
|
||||
|
||||
if (mode === 'orm' && !next.orm) {
|
||||
next.orm = ORMS[0]?.key ?? ''
|
||||
}
|
||||
if (mode === 'orm' && !next.orm) {
|
||||
next.orm = ORMS[0]?.key ?? ''
|
||||
}
|
||||
|
||||
if (mode === 'mcp' && !next.mcpClient) {
|
||||
next.mcpClient = MCP_CLIENTS[0]?.key ?? ''
|
||||
}
|
||||
if (mode === 'mcp' && !next.mcpClient) {
|
||||
next.mcpClient = MCP_CLIENTS[0]?.key ?? ''
|
||||
}
|
||||
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
return next
|
||||
})
|
||||
},
|
||||
[projectRef]
|
||||
)
|
||||
|
||||
const activeFields = useMemo(() => getActiveFields(connectSchema, state), [state])
|
||||
|
||||
@@ -290,9 +326,9 @@ export function useConnectState(initialState?: Partial<ConnectState>): UseConnec
|
||||
(fieldId: string): FieldOption[] => {
|
||||
const field = activeFields.find((f) => f.id === fieldId)
|
||||
if (!field) return []
|
||||
return resolveFieldOptionsWithSource(field, state)
|
||||
return resolveFieldOptionsWithSource({ field, state, databases })
|
||||
},
|
||||
[activeFields, state]
|
||||
[activeFields, state, databases]
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
Reference in new issue
Block a user