ConnectStepsSection.tsx215 lines · main
1import { useParams } from 'common'
2import dynamic from 'next/dynamic'
3import Link from 'next/link'
4import { useMemo, useRef } from 'react'
5import { Button } from 'ui'
6import { Admonition } from 'ui-patterns'
7import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
8
9import type {
10 ConnectionStringPooler,
11 ConnectState,
12 ProjectKeys,
13 ResolvedStep,
14 StepContentProps,
15} from './Connect.types'
16import { ConnectSheetStep } from './ConnectSheetStep'
17import { CopyPromptAdmonition } from './CopyPromptAdmonition'
18import { getConnectionStrings } from './DatabaseSettings.utils'
19import { getAddons } from '@/components/interfaces/Billing/Subscription/Subscription.utils'
20import { DocsButton } from '@/components/ui/DocsButton'
21import { useProjectSettingsV2Query } from '@/data/config/project-settings-v2-query'
22import { usePgbouncerConfigQuery } from '@/data/database/pgbouncer-config-query'
23import { useSupavisorConfigurationQuery } from '@/data/database/supavisor-configuration-query'
24import { useProjectAddonsQuery } from '@/data/subscriptions/project-addons-query'
25import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
26import { DOCS_URL } from '@/lib/constants'
27import { pluckObjectFields } from '@/lib/helpers'
28
29interface 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 */
47function 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 */
58function 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 */
118function 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
152export 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}