useConnectState.ts355 lines · main
| 1 | import { useParams } from 'common' |
| 2 | import { useCallback, useMemo, useState } from 'react' |
| 3 | import { FEATURE_GROUPS_PLATFORM, MCP_CLIENTS } from 'ui-patterns/McpUrlBuilder' |
| 4 | |
| 5 | import { |
| 6 | connectionStringMethodOptions, |
| 7 | DATABASE_CONNECTION_TYPES, |
| 8 | FRAMEWORKS, |
| 9 | MOBILES, |
| 10 | ORMS, |
| 11 | } from './Connect.constants' |
| 12 | import { |
| 13 | getActiveFields, |
| 14 | getDefaultState, |
| 15 | resetDependentFields, |
| 16 | resolveSteps, |
| 17 | } from './connect.resolver' |
| 18 | import { connectSchema } from './connect.schema' |
| 19 | import type { |
| 20 | ConnectMode, |
| 21 | ConnectSchema, |
| 22 | ConnectState, |
| 23 | FieldOption, |
| 24 | ResolvedField, |
| 25 | ResolvedStep, |
| 26 | } from './Connect.types' |
| 27 | import { resolveFrameworkLibraryKey } from './Connect.utils' |
| 28 | import { Database, useReadReplicasQuery } from '@/data/read-replicas/replicas-query' |
| 29 | import { formatDatabaseID, formatDatabaseRegion } from '@/data/read-replicas/replicas.utils' |
| 30 | import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' |
| 31 | import { useIsHighAvailability } from '@/hooks/misc/useSelectedProject' |
| 32 | |
| 33 | // ============================================================================ |
| 34 | // Data Source Helpers |
| 35 | // ============================================================================ |
| 36 | |
| 37 | /** |
| 38 | * Get field options from a data source reference. |
| 39 | * This maps source names to actual data. |
| 40 | */ |
| 41 | function getFieldOptionsFromSource({ |
| 42 | source, |
| 43 | state, |
| 44 | databases, |
| 45 | }: { |
| 46 | source: string |
| 47 | state: ConnectState |
| 48 | databases: Database[] |
| 49 | }): FieldOption[] { |
| 50 | switch (source) { |
| 51 | case 'frameworks': |
| 52 | return [...FRAMEWORKS, ...MOBILES].map((f) => ({ |
| 53 | value: f.key, |
| 54 | label: f.label, |
| 55 | icon: f.icon, |
| 56 | })) |
| 57 | |
| 58 | case 'frameworkVariants': { |
| 59 | // Get variants for the selected framework |
| 60 | const allFrameworks = [...FRAMEWORKS, ...MOBILES] |
| 61 | const selected = allFrameworks.find((f) => f.key === state.framework) |
| 62 | if (!selected?.children?.length) return [] |
| 63 | // Only return if there are multiple children (variants) |
| 64 | if (selected.children.length <= 1) return [] |
| 65 | return selected.children.map((c) => ({ |
| 66 | value: c.key, |
| 67 | label: c.label, |
| 68 | icon: c.icon, |
| 69 | })) |
| 70 | } |
| 71 | |
| 72 | case 'libraries': { |
| 73 | // Get libraries for the selected framework and variant |
| 74 | const allFrameworks = [...FRAMEWORKS, ...MOBILES] |
| 75 | const selectedFramework = allFrameworks.find((f) => f.key === state.framework) |
| 76 | if (!selectedFramework) return [] |
| 77 | |
| 78 | // If framework has variants, look in the variant |
| 79 | if (selectedFramework.children?.length > 1 && state.frameworkVariant) { |
| 80 | const variant = selectedFramework.children.find((c) => c.key === state.frameworkVariant) |
| 81 | if (variant?.children?.length) { |
| 82 | return variant.children.map((c) => ({ |
| 83 | value: c.key, |
| 84 | label: c.label, |
| 85 | icon: c.icon, |
| 86 | })) |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | // Otherwise look directly in framework children |
| 91 | if (selectedFramework.children?.length === 1) { |
| 92 | const child = selectedFramework.children[0] |
| 93 | if (child.children?.length) { |
| 94 | return child.children.map((c) => ({ |
| 95 | value: c.key, |
| 96 | label: c.label, |
| 97 | icon: c.icon, |
| 98 | })) |
| 99 | } |
| 100 | // The child itself is the library |
| 101 | return [{ value: child.key, label: child.label, icon: child.icon }] |
| 102 | } |
| 103 | |
| 104 | return [] |
| 105 | } |
| 106 | |
| 107 | case 'connectionMethods': |
| 108 | return Object.values(connectionStringMethodOptions).map((m) => ({ |
| 109 | value: m.value, |
| 110 | label: m.label, |
| 111 | description: m.description, |
| 112 | })) |
| 113 | |
| 114 | case 'connectionSources': |
| 115 | return databases.map((db) => { |
| 116 | const region = formatDatabaseRegion(db?.region ?? '') |
| 117 | const id = formatDatabaseID(db.identifier ?? '') |
| 118 | const label = db.identifier.includes('-rr-') |
| 119 | ? `Read Replica (${region} - ${id}}` |
| 120 | : 'Primary Database' |
| 121 | return { value: db.identifier, label } |
| 122 | }) |
| 123 | |
| 124 | case 'connectionTypes': |
| 125 | return DATABASE_CONNECTION_TYPES.map((t) => ({ |
| 126 | value: t.id, |
| 127 | label: t.label, |
| 128 | })) |
| 129 | |
| 130 | case 'orms': |
| 131 | return ORMS.map((o) => ({ |
| 132 | value: o.key, |
| 133 | label: o.label, |
| 134 | icon: o.icon, |
| 135 | })) |
| 136 | |
| 137 | case 'mcpClients': |
| 138 | return MCP_CLIENTS.map((c) => ({ |
| 139 | value: c.key, |
| 140 | label: c.label, |
| 141 | icon: c.icon, |
| 142 | })) |
| 143 | |
| 144 | case 'mcpFeatures': |
| 145 | return FEATURE_GROUPS_PLATFORM.map((f) => ({ |
| 146 | value: f.id, |
| 147 | label: f.name, |
| 148 | description: f.description, |
| 149 | })) |
| 150 | |
| 151 | default: |
| 152 | return [] |
| 153 | } |
| 154 | } |
| 155 | |
| 156 | /** |
| 157 | * Resolve field options, handling both static options and data source references. |
| 158 | */ |
| 159 | function resolveFieldOptionsWithSource({ |
| 160 | field, |
| 161 | state, |
| 162 | databases, |
| 163 | }: { |
| 164 | field: ResolvedField |
| 165 | state: ConnectState |
| 166 | databases: Database[] |
| 167 | }): FieldOption[] { |
| 168 | // If already resolved (from conditional resolution) |
| 169 | if (field.resolvedOptions.length > 0) { |
| 170 | return field.resolvedOptions |
| 171 | } |
| 172 | |
| 173 | // Check if it's a source reference |
| 174 | const options = connectSchema.fields[field.id]?.options |
| 175 | if (options && typeof options === 'object' && 'source' in options) { |
| 176 | return getFieldOptionsFromSource({ source: options.source as string, state, databases }) |
| 177 | } |
| 178 | |
| 179 | return [] |
| 180 | } |
| 181 | |
| 182 | // ============================================================================ |
| 183 | // Hook |
| 184 | // ============================================================================ |
| 185 | |
| 186 | export interface UseConnectStateReturn { |
| 187 | state: ConnectState |
| 188 | updateField: (fieldId: string, value: string | boolean | string[]) => void |
| 189 | setMode: (mode: ConnectMode) => void |
| 190 | activeFields: ResolvedField[] |
| 191 | resolvedSteps: ResolvedStep[] |
| 192 | getFieldOptions: (fieldId: string) => FieldOption[] |
| 193 | schema: ConnectSchema |
| 194 | } |
| 195 | |
| 196 | export function useConnectState(initialState?: Partial<ConnectState>): UseConnectStateReturn { |
| 197 | const { ref: projectRef } = useParams() |
| 198 | const { data: databases = [] } = useReadReplicasQuery({ projectRef }) |
| 199 | const { hasAccess: hasDedicatedPooler } = useCheckEntitlements('dedicated_pooler') |
| 200 | const isHighAvailability = useIsHighAvailability() |
| 201 | |
| 202 | const [state, setState] = useState<ConnectState>(() => { |
| 203 | const defaults = getDefaultState({ schema: connectSchema }) |
| 204 | |
| 205 | // Set initial framework if mode is framework |
| 206 | if (defaults.mode === 'framework' && !defaults.framework) { |
| 207 | const firstFramework = FRAMEWORKS[0] |
| 208 | defaults.framework = firstFramework?.key ?? '' |
| 209 | |
| 210 | // Set initial variant if framework has variants |
| 211 | if (firstFramework?.children?.length > 1) { |
| 212 | defaults.frameworkVariant = firstFramework.children[0]?.key ?? '' |
| 213 | } |
| 214 | |
| 215 | // Set initial library |
| 216 | const libraryKey = resolveFrameworkLibraryKey({ |
| 217 | framework: defaults.framework, |
| 218 | frameworkVariant: defaults.frameworkVariant, |
| 219 | library: defaults.library, |
| 220 | }) |
| 221 | if (libraryKey) defaults.library = libraryKey |
| 222 | } |
| 223 | |
| 224 | // Set initial ORM if mode is orm |
| 225 | if (defaults.mode === 'orm' && !defaults.orm) { |
| 226 | defaults.orm = ORMS[0]?.key ?? '' |
| 227 | } |
| 228 | |
| 229 | // Set initial MCP client if mode is mcp |
| 230 | if (defaults.mode === 'mcp' && !defaults.mcpClient) { |
| 231 | defaults.mcpClient = MCP_CLIENTS[0]?.key ?? '' |
| 232 | } |
| 233 | |
| 234 | return { ...defaults, ...initialState } as ConnectState |
| 235 | }) |
| 236 | |
| 237 | const updateField = useCallback((fieldId: string, value: string | boolean | string[]) => { |
| 238 | setState((prev) => { |
| 239 | const next = { ...prev, [fieldId]: value } |
| 240 | |
| 241 | // Handle cascading updates for framework selection |
| 242 | if (fieldId === 'framework') { |
| 243 | const allFrameworks = [...FRAMEWORKS, ...MOBILES] |
| 244 | const selected = allFrameworks.find((f) => f.key === value) |
| 245 | |
| 246 | // Reset variant if framework changed |
| 247 | if (selected?.children && selected.children.length > 1) { |
| 248 | next.frameworkVariant = selected.children[0]?.key ?? '' |
| 249 | } else { |
| 250 | delete next.frameworkVariant |
| 251 | } |
| 252 | |
| 253 | // Reset library |
| 254 | const libraryKey = resolveFrameworkLibraryKey({ |
| 255 | framework: next.framework, |
| 256 | frameworkVariant: next.frameworkVariant, |
| 257 | }) |
| 258 | if (libraryKey) { |
| 259 | next.library = libraryKey |
| 260 | } else { |
| 261 | delete next.library |
| 262 | } |
| 263 | } |
| 264 | |
| 265 | // Handle cascading updates for variant selection |
| 266 | if (fieldId === 'frameworkVariant') { |
| 267 | const libraryKey = resolveFrameworkLibraryKey({ |
| 268 | framework: prev.framework, |
| 269 | frameworkVariant: String(value), |
| 270 | }) |
| 271 | if (libraryKey) next.library = libraryKey |
| 272 | } |
| 273 | |
| 274 | // Reset useSharedPooler when connectionMethod changes to 'direct' |
| 275 | if (fieldId === 'connectionMethod' && value === 'direct') { |
| 276 | next.useSharedPooler = false |
| 277 | } |
| 278 | |
| 279 | return resetDependentFields(next, fieldId, connectSchema) |
| 280 | }) |
| 281 | }, []) |
| 282 | |
| 283 | const setMode = useCallback( |
| 284 | (mode: ConnectMode) => { |
| 285 | setState((prev) => { |
| 286 | const next: ConnectState = { ...prev, mode } |
| 287 | |
| 288 | // Initialize mode-specific defaults |
| 289 | if (mode === 'framework' && !next.framework) { |
| 290 | const firstFramework = FRAMEWORKS[0] |
| 291 | next.framework = firstFramework?.key ?? '' |
| 292 | if (firstFramework?.children?.length > 1) { |
| 293 | next.frameworkVariant = firstFramework.children[0]?.key ?? '' |
| 294 | } |
| 295 | const libraryKey = resolveFrameworkLibraryKey({ |
| 296 | framework: next.framework, |
| 297 | frameworkVariant: next.frameworkVariant, |
| 298 | }) |
| 299 | if (libraryKey) next.library = libraryKey |
| 300 | } |
| 301 | |
| 302 | if (mode === 'direct') { |
| 303 | next.connectionMethod = next.connectionMethod ?? 'direct' |
| 304 | next.connectionType = next.connectionType ?? 'uri' |
| 305 | next.connectionSource = projectRef ?? '_' |
| 306 | } |
| 307 | |
| 308 | if (mode === 'orm' && !next.orm) { |
| 309 | next.orm = ORMS[0]?.key ?? '' |
| 310 | } |
| 311 | |
| 312 | if (mode === 'mcp' && !next.mcpClient) { |
| 313 | next.mcpClient = MCP_CLIENTS[0]?.key ?? '' |
| 314 | } |
| 315 | |
| 316 | return next |
| 317 | }) |
| 318 | }, |
| 319 | [projectRef] |
| 320 | ) |
| 321 | |
| 322 | const activeFields = useMemo(() => { |
| 323 | let fields = getActiveFields(connectSchema, state) |
| 324 | if (!hasDedicatedPooler) { |
| 325 | fields = fields.filter((f) => f.id !== 'useSharedPooler') |
| 326 | } |
| 327 | if (isHighAvailability) { |
| 328 | fields = fields |
| 329 | .filter((f) => f.id !== 'connectionMethod' && f.id !== 'useSharedPooler') |
| 330 | .map((f) => (f.id === 'connectionType' ? { ...f, label: 'Connection Type' } : f)) |
| 331 | } |
| 332 | return fields |
| 333 | }, [state, hasDedicatedPooler, isHighAvailability]) |
| 334 | |
| 335 | const resolvedSteps = useMemo(() => resolveSteps(connectSchema, state), [state]) |
| 336 | |
| 337 | const getFieldOptions = useCallback( |
| 338 | (fieldId: string): FieldOption[] => { |
| 339 | const field = activeFields.find((f) => f.id === fieldId) |
| 340 | if (!field) return [] |
| 341 | return resolveFieldOptionsWithSource({ field, state, databases }) |
| 342 | }, |
| 343 | [activeFields, state, databases] |
| 344 | ) |
| 345 | |
| 346 | return { |
| 347 | state, |
| 348 | updateField, |
| 349 | setMode, |
| 350 | activeFields, |
| 351 | resolvedSteps, |
| 352 | getFieldOptions, |
| 353 | schema: connectSchema, |
| 354 | } |
| 355 | } |