ConnectStepsSection.tsx215 lines · main
| 1 | import { useParams } from 'common' |
| 2 | import dynamic from 'next/dynamic' |
| 3 | import Link from 'next/link' |
| 4 | import { useMemo, useRef } from 'react' |
| 5 | import { Button } from 'ui' |
| 6 | import { Admonition } from 'ui-patterns' |
| 7 | import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' |
| 8 | |
| 9 | import type { |
| 10 | ConnectionStringPooler, |
| 11 | ConnectState, |
| 12 | ProjectKeys, |
| 13 | ResolvedStep, |
| 14 | StepContentProps, |
| 15 | } from './Connect.types' |
| 16 | import { ConnectSheetStep } from './ConnectSheetStep' |
| 17 | import { CopyPromptAdmonition } from './CopyPromptAdmonition' |
| 18 | import { getConnectionStrings } from './DatabaseSettings.utils' |
| 19 | import { getAddons } from '@/components/interfaces/Billing/Subscription/Subscription.utils' |
| 20 | import { DocsButton } from '@/components/ui/DocsButton' |
| 21 | import { useProjectSettingsV2Query } from '@/data/config/project-settings-v2-query' |
| 22 | import { usePgbouncerConfigQuery } from '@/data/database/pgbouncer-config-query' |
| 23 | import { useSupavisorConfigurationQuery } from '@/data/database/supavisor-configuration-query' |
| 24 | import { useProjectAddonsQuery } from '@/data/subscriptions/project-addons-query' |
| 25 | import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' |
| 26 | import { DOCS_URL } from '@/lib/constants' |
| 27 | import { pluckObjectFields } from '@/lib/helpers' |
| 28 | |
| 29 | interface ConnectStepsSectionProps { |
| 30 | steps: ResolvedStep[] |
| 31 | state: ConnectState |
| 32 | projectKeys: ProjectKeys |
| 33 | } |
| 34 | |
| 35 | /** |
| 36 | * Resolves a content path template by replacing {{key}} placeholders with state values. |
| 37 | * Empty segments are filtered out to handle optional state values like frameworkVariant. |
| 38 | * |
| 39 | * Examples: |
| 40 | * - '{{framework}}/{{frameworkVariant}}/{{library}}' with state {framework: 'nextjs', frameworkVariant: 'app', library: 'brivenjs'} |
| 41 | * → 'nextjs/app/brivenjs' |
| 42 | * - '{{orm}}' with state {orm: 'prisma'} |
| 43 | * → 'prisma' |
| 44 | * - 'steps/install' (no templates) |
| 45 | * → 'steps/install' |
| 46 | */ |
| 47 | function resolveContentPath(template: string, state: ConnectState): string { |
| 48 | return template |
| 49 | .replace(/\{\{(\w+)\}\}/g, (_, key) => String(state[key] ?? '')) |
| 50 | .split('/') |
| 51 | .filter(Boolean) |
| 52 | .join('/') |
| 53 | } |
| 54 | |
| 55 | /** |
| 56 | * Hook to fetch and prepare connection strings for step content. |
| 57 | */ |
| 58 | function useConnectionStringPooler(): ConnectionStringPooler { |
| 59 | const { ref: projectRef } = useParams() |
| 60 | const { hasAccess: allowPgBouncerSelection } = useCheckEntitlements('dedicated_pooler') |
| 61 | |
| 62 | const { data: settings } = useProjectSettingsV2Query({ projectRef }) |
| 63 | const { data: pgbouncerConfig } = usePgbouncerConfigQuery({ projectRef }) |
| 64 | const { data: supavisorConfig } = useSupavisorConfigurationQuery({ projectRef }) |
| 65 | const { data: addons } = useProjectAddonsQuery({ projectRef }) |
| 66 | const { ipv4: ipv4Addon } = getAddons(addons?.selected_addons ?? []) |
| 67 | |
| 68 | const DB_FIELDS = ['db_host', 'db_name', 'db_port', 'db_user', 'inserted_at'] |
| 69 | const emptyState = { db_user: '', db_host: '', db_port: '', db_name: '' } |
| 70 | const connectionInfo = pluckObjectFields(settings || emptyState, DB_FIELDS) |
| 71 | const poolingConfigurationShared = supavisorConfig?.find((x) => x.database_type === 'PRIMARY') |
| 72 | const poolingConfigurationDedicated = allowPgBouncerSelection ? pgbouncerConfig : undefined |
| 73 | |
| 74 | const connectionStringsShared = getConnectionStrings({ |
| 75 | connectionInfo, |
| 76 | poolingInfo: { |
| 77 | connectionString: poolingConfigurationShared?.connection_string ?? '', |
| 78 | db_host: poolingConfigurationShared?.db_host ?? '', |
| 79 | db_name: poolingConfigurationShared?.db_name ?? '', |
| 80 | db_port: poolingConfigurationShared?.db_port ?? 0, |
| 81 | db_user: poolingConfigurationShared?.db_user ?? '', |
| 82 | }, |
| 83 | metadata: { projectRef }, |
| 84 | }) |
| 85 | |
| 86 | const connectionStringsDedicated = |
| 87 | poolingConfigurationDedicated !== undefined |
| 88 | ? getConnectionStrings({ |
| 89 | connectionInfo, |
| 90 | poolingInfo: { |
| 91 | connectionString: poolingConfigurationDedicated.connection_string, |
| 92 | db_host: poolingConfigurationDedicated.db_host, |
| 93 | db_name: poolingConfigurationDedicated.db_name, |
| 94 | db_port: poolingConfigurationDedicated.db_port, |
| 95 | db_user: poolingConfigurationDedicated.db_user, |
| 96 | }, |
| 97 | metadata: { projectRef }, |
| 98 | }) |
| 99 | : undefined |
| 100 | |
| 101 | return useMemo( |
| 102 | () => ({ |
| 103 | transactionShared: connectionStringsShared.pooler.uri, |
| 104 | sessionShared: connectionStringsShared.pooler.uri.replace('6543', '5432'), |
| 105 | transactionDedicated: connectionStringsDedicated?.pooler.uri, |
| 106 | sessionDedicated: connectionStringsDedicated?.pooler.uri.replace('6543', '5432'), |
| 107 | ipv4SupportedForDedicatedPooler: !!ipv4Addon, |
| 108 | direct: connectionStringsShared.direct.uri, |
| 109 | }), |
| 110 | [connectionStringsShared, connectionStringsDedicated, ipv4Addon] |
| 111 | ) |
| 112 | } |
| 113 | |
| 114 | /** |
| 115 | * Dynamically loads and renders a content component from the content directory. |
| 116 | * All step content uses this unified loader - no built-in component registry needed. |
| 117 | */ |
| 118 | function StepContent({ |
| 119 | contentId, |
| 120 | state, |
| 121 | projectKeys, |
| 122 | connectionStringPooler, |
| 123 | }: { |
| 124 | contentId: string |
| 125 | state: ConnectState |
| 126 | projectKeys: ProjectKeys |
| 127 | connectionStringPooler: ConnectionStringPooler |
| 128 | }) { |
| 129 | // Resolve any template placeholders in the content path |
| 130 | const filePath = useMemo(() => resolveContentPath(contentId, state), [contentId, state]) |
| 131 | |
| 132 | // Dynamically import the content component |
| 133 | const ContentComponent = useMemo(() => { |
| 134 | return dynamic<StepContentProps>(() => import(`./content/${filePath}/content`), { |
| 135 | loading: () => ( |
| 136 | <div className="p-4 min-h-[200px]"> |
| 137 | <GenericSkeletonLoader /> |
| 138 | </div> |
| 139 | ), |
| 140 | }) |
| 141 | }, [filePath]) |
| 142 | |
| 143 | return ( |
| 144 | <ContentComponent |
| 145 | state={state} |
| 146 | projectKeys={projectKeys} |
| 147 | connectionStringPooler={connectionStringPooler} |
| 148 | /> |
| 149 | ) |
| 150 | } |
| 151 | |
| 152 | export function ConnectStepsSection({ steps, state, projectKeys }: ConnectStepsSectionProps) { |
| 153 | const { ref } = useParams() |
| 154 | const stepsContainerRef = useRef<HTMLDivElement | null>(null) |
| 155 | const connectionStringPooler = useConnectionStringPooler() |
| 156 | |
| 157 | const { data: ipv4Addon } = useProjectAddonsQuery( |
| 158 | { projectRef: ref }, |
| 159 | { |
| 160 | select: (data) => { |
| 161 | const selectedAddons = data?.selected_addons ?? [] |
| 162 | return selectedAddons.find((addon) => addon.type === 'ipv4') |
| 163 | }, |
| 164 | } |
| 165 | ) |
| 166 | const showIpv4AddonNotice = |
| 167 | state.mode === 'direct' && |
| 168 | !ipv4Addon && |
| 169 | (state.connectionMethod === 'direct' || |
| 170 | (state.connectionMethod === 'transaction' && !state.useSharedPooler)) |
| 171 | |
| 172 | if (steps.length === 0) return null |
| 173 | |
| 174 | return ( |
| 175 | <div className="bg-muted/50 flex-1"> |
| 176 | <div className="p-8 flex flex-col gap-y-6"> |
| 177 | <h3>Connect your app</h3> |
| 178 | |
| 179 | <CopyPromptAdmonition stepsContainerRef={stepsContainerRef} /> |
| 180 | |
| 181 | {showIpv4AddonNotice && ( |
| 182 | <Admonition |
| 183 | type="default" |
| 184 | title={`${state.connectionMethod === 'direct' ? 'Direct connections use' : 'Transaction pooler uses'} IPv6 by default`} |
| 185 | description="Enable the dedicated IPv4 address add-on to connect from IPv4-only networks" |
| 186 | actions={[ |
| 187 | <Button asChild key="addon" type="default"> |
| 188 | <Link href={`/project/${ref}/settings/addons?panel=ipv4`}>Enable IPv4 add-on</Link> |
| 189 | </Button>, |
| 190 | <DocsButton key="docs" href={`${DOCS_URL}/guides/platform/ipv4-address`} />, |
| 191 | ]} |
| 192 | /> |
| 193 | )} |
| 194 | |
| 195 | <div className="mt-6" ref={stepsContainerRef}> |
| 196 | {steps.map((step, index) => ( |
| 197 | <ConnectSheetStep |
| 198 | key={step.id} |
| 199 | number={index + 1} |
| 200 | title={step.title} |
| 201 | description={step.description} |
| 202 | > |
| 203 | <StepContent |
| 204 | contentId={step.content} |
| 205 | state={state} |
| 206 | projectKeys={projectKeys} |
| 207 | connectionStringPooler={connectionStringPooler} |
| 208 | /> |
| 209 | </ConnectSheetStep> |
| 210 | ))} |
| 211 | </div> |
| 212 | </div> |
| 213 | </div> |
| 214 | ) |
| 215 | } |