CreateBranchModal.tsx642 lines · main
1// @ts-nocheck
2import { zodResolver } from '@hookform/resolvers/zod'
3import { PermissionAction } from '@supabase/shared-types/out/constants'
4import { useQueryClient } from '@tanstack/react-query'
5import { useDebounce } from '@uidotdev/usehooks'
6import { useFlag, useParams } from 'common'
7import { Check, DatabaseZap, DollarSign, Github, GitMerge, Loader2 } from 'lucide-react'
8import Image from 'next/image'
9import Link from 'next/link'
10import { useRouter } from 'next/router'
11import { useCallback, useEffect, useState } from 'react'
12import { useForm } from 'react-hook-form'
13import { toast } from 'sonner'
14import {
15 Badge,
16 Button,
17 cn,
18 Dialog,
19 DialogContent,
20 DialogFooter,
21 DialogHeader,
22 DialogSection,
23 DialogSectionSeparator,
24 DialogTitle,
25 Form,
26 FormControl,
27 FormField,
28 Input,
29 Label,
30 Switch,
31 Tooltip,
32 TooltipContent,
33 TooltipTrigger,
34} from 'ui'
35import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
36import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
37import * as z from 'zod'
38
39import {
40 estimateComputeSize,
41 estimateDiskCost,
42 estimateRestoreTime,
43} from './BranchManagement.utils'
44import { TaxDisclaimer } from '@/components/interfaces/Billing/TaxDisclaimer'
45import { BranchingPITRNotice } from '@/components/layouts/AppLayout/EnableBranchingButton/BranchingPITRNotice'
46import AlertError from '@/components/ui/AlertError'
47import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
48import { InlineLink, InlineLinkClassName } from '@/components/ui/InlineLink'
49import { UpgradeToPro } from '@/components/ui/UpgradeToPro'
50import { useBranchCreateMutation } from '@/data/branches/branch-create-mutation'
51import { useBranchesQuery } from '@/data/branches/branches-query'
52import { DiskAttributesData, useDiskAttributesQuery } from '@/data/config/disk-attributes-query'
53import { useCheckGithubBranchValidity } from '@/data/integrations/github-branch-check-query'
54import { useGitHubConnectionsQuery } from '@/data/integrations/github-connections-query'
55import { projectKeys } from '@/data/projects/keys'
56import { DesiredInstanceSize, instanceSizeSpecs } from '@/data/projects/new-project.constants'
57import { useProjectAddonsQuery } from '@/data/subscriptions/project-addons-query'
58import { useSendEventMutation } from '@/data/telemetry/send-event-mutation'
59import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
60import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
61import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
62import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
63import { BASE_PATH, IS_PLATFORM } from '@/lib/constants'
64import { useAppStateSnapshot } from '@/state/app-state'
65
66export const CreateBranchModal = () => {
67 const { ref } = useParams()
68 const router = useRouter()
69 const queryClient = useQueryClient()
70 const { data: projectDetails } = useSelectedProjectQuery()
71 const { data: selectedOrg } = useSelectedOrganizationQuery()
72 const { showCreateBranchModal, setShowCreateBranchModal } = useAppStateSnapshot()
73
74 const allowDataBranching = useFlag('allowDataBranching')
75
76 const [isGitBranchValid, setIsGitBranchValid] = useState(false)
77
78 const { can: canCreateBranch } = useAsyncCheckPermissions(
79 PermissionAction.CREATE,
80 'preview_branches'
81 )
82
83 const { hasAccess: hasAccessToBranching, isLoading: isLoadingEntitlement } =
84 useCheckEntitlements('branching_limit')
85 const promptPlanUpgrade = IS_PLATFORM && !hasAccessToBranching
86
87 const isBranch = projectDetails?.parent_project_ref !== undefined
88 const projectRef =
89 projectDetails !== undefined ? (isBranch ? projectDetails.parent_project_ref : ref) : undefined
90
91 const formId = 'create-branch-form'
92 const FormSchema = z.object({
93 branchName: z
94 .string()
95 .min(1, 'Branch name cannot be empty')
96 .refine(
97 (val) => /^[a-zA-Z0-9\-_]+$/.test(val),
98 'Branch name can only contain alphanumeric characters, hyphens, and underscores.'
99 )
100 .refine(
101 (val) => (branches ?? []).every((branch) => branch.name !== val),
102 'A branch with this name already exists'
103 ),
104 gitBranchName: z.string().optional(),
105 withData: z.boolean().default(false).optional(),
106 })
107
108 const form = useForm<z.infer<typeof FormSchema>>({
109 mode: 'onSubmit',
110 reValidateMode: 'onBlur',
111 resolver: zodResolver(FormSchema as any),
112 defaultValues: { branchName: '', gitBranchName: '', withData: false },
113 })
114
115 const { withData, gitBranchName } = form.watch()
116 const debouncedGitBranchName = useDebounce(gitBranchName, 500)
117
118 const {
119 data: connections,
120 error: connectionsError,
121 isPending: isLoadingConnections,
122 isSuccess: isSuccessConnections,
123 isError: isErrorConnections,
124 } = useGitHubConnectionsQuery(
125 { organizationId: selectedOrg?.id },
126 { enabled: showCreateBranchModal }
127 )
128
129 const { data: branches } = useBranchesQuery({ projectRef })
130 const { data: addons, isSuccess: isSuccessAddons } = useProjectAddonsQuery(
131 { projectRef },
132 { enabled: showCreateBranchModal }
133 )
134 const computeAddon = addons?.selected_addons.find((addon) => addon.type === 'compute_instance')
135 const computeSize = computeAddon
136 ? (computeAddon.variant.identifier.split('ci_')[1] as DesiredInstanceSize)
137 : undefined
138 const hasPitrEnabled = (addons?.selected_addons ?? []).some((addon) => addon.type === 'pitr')
139
140 const {
141 data: disk,
142 isPending: isLoadingDiskAttr,
143 isError: isErrorDiskAttr,
144 } = useDiskAttributesQuery({ projectRef }, { enabled: showCreateBranchModal && withData })
145 const projectDiskAttributes = disk?.attributes ?? {
146 type: 'gp3',
147 size_gb: 0,
148 iops: 0,
149 throughput_mbps: 0,
150 }
151 // Branch disk is oversized to include backup files, it should be scaled back eventually.
152 const branchDiskAttributes = {
153 ...projectDiskAttributes,
154 // [Joshen] JFYI for Qiao - this multiplier may eventually be dropped
155 size_gb: Math.round(projectDiskAttributes.size_gb * 1.5),
156 } as DiskAttributesData['attributes']
157 const branchComputeSize = estimateComputeSize(projectDiskAttributes.size_gb, computeSize)
158 const estimatedDiskCost = estimateDiskCost(branchDiskAttributes)
159
160 const { mutate: sendEvent } = useSendEventMutation()
161
162 const { mutate: checkGithubBranchValidity, isPending: isCheckingGHBranchValidity } =
163 useCheckGithubBranchValidity({
164 onError: () => {},
165 })
166
167 const { mutate: createBranch, isPending: isCreatingBranch } = useBranchCreateMutation({
168 onSuccess: async (data) => {
169 toast.success(`Successfully created preview branch "${data.name}"`)
170 if (projectRef) {
171 await queryClient.invalidateQueries({ queryKey: projectKeys.detail(projectRef) })
172 }
173 sendEvent({
174 action: 'branch_create_button_clicked',
175 properties: {
176 branchType: data.persistent ? 'persistent' : 'preview',
177 gitlessBranching: !data.git_branch,
178 },
179 groups: {
180 project: ref ?? 'Unknown',
181 organization: selectedOrg?.slug ?? 'Unknown',
182 },
183 })
184
185 setShowCreateBranchModal(false)
186 router.push(`/project/${data.project_ref}`)
187 },
188 onError: (error) => {
189 toast.error(`Failed to create branch: ${error.message}`)
190 },
191 })
192
193 // Fetch production/default branch to inspect git_branch linkage
194 const githubConnection = connections?.find((connection) => connection.project.ref === projectRef)
195 const prodBranch = branches?.find((branch) => branch.is_default)
196 const [repoOwner, repoName] = githubConnection?.repository.name.split('/') ?? []
197 const isFormValid = form.formState.isValid && (!gitBranchName || isGitBranchValid)
198
199 const isDisabled =
200 !isFormValid ||
201 !canCreateBranch ||
202 !isSuccessAddons ||
203 (!!gitBranchName && !isSuccessConnections) ||
204 isLoadingEntitlement ||
205 !hasAccessToBranching ||
206 isCreatingBranch ||
207 isCheckingGHBranchValidity
208
209 const tooltipText = promptPlanUpgrade ? 'Upgrade to unlock branching' : undefined
210
211 const validateGitBranchName = useCallback(
212 (branchName: string) => {
213 if (!githubConnection) {
214 return console.error(
215 '[CreateBranchModal > validateGitBranchName] GitHub Connection is missing'
216 )
217 }
218
219 const repositoryId = githubConnection.repository.id
220
221 checkGithubBranchValidity(
222 { repositoryId, branchName },
223 {
224 onSuccess: () => {
225 if (form.getValues('gitBranchName') !== branchName) return
226
227 // Check if another branch is already linked to this git branch
228 const existingBranch = (branches ?? []).find((b) => b.git_branch === branchName)
229 if (existingBranch) {
230 setIsGitBranchValid(false)
231 form.setError('gitBranchName', {
232 message: `Branch "${existingBranch.name}" is already linked to git branch "${branchName}"`,
233 })
234 return
235 }
236
237 setIsGitBranchValid(true)
238 form.clearErrors('gitBranchName')
239 },
240 onError: (error) => {
241 if (form.getValues('gitBranchName') !== branchName) return
242 setIsGitBranchValid(false)
243 form.setError('gitBranchName', {
244 message:
245 error?.message ??
246 `Unable to find branch "${branchName}" in ${repoOwner}/${repoName}`,
247 })
248 },
249 }
250 )
251 },
252 [githubConnection, form, checkGithubBranchValidity, repoOwner, repoName, branches]
253 )
254
255 const onSubmit = (data: z.infer<typeof FormSchema>) => {
256 if (!projectRef) return console.error('Project ref is required')
257 createBranch({
258 projectRef,
259 branchName: data.branchName,
260 is_default: false,
261 ...(data.withData ? { desired_instance_size: computeSize } : {}),
262 ...(data.gitBranchName ? { gitBranch: data.gitBranchName } : {}),
263 ...(allowDataBranching ? { withData: data.withData } : {}),
264 })
265 }
266
267 const handleGitHubClick = () => {
268 setShowCreateBranchModal(false)
269 router.push(`/project/${projectRef}/settings/integrations`)
270 }
271
272 useEffect(() => {
273 if (showCreateBranchModal) form.reset()
274 }, [form, showCreateBranchModal])
275
276 useEffect(() => {
277 form.clearErrors('gitBranchName')
278 if (githubConnection && debouncedGitBranchName) validateGitBranchName(debouncedGitBranchName)
279 }, [debouncedGitBranchName, validateGitBranchName, form, githubConnection])
280
281 return (
282 <Dialog open={showCreateBranchModal} onOpenChange={setShowCreateBranchModal}>
283 <DialogContent
284 size="large"
285 hideClose
286 onOpenAutoFocus={(e) => {
287 if (promptPlanUpgrade) e.preventDefault()
288 }}
289 aria-describedby={undefined}
290 >
291 <DialogHeader padding="small">
292 <DialogTitle>Create a new preview branch</DialogTitle>
293 </DialogHeader>
294 <DialogSectionSeparator />
295
296 <Form {...form}>
297 <form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
298 {promptPlanUpgrade && (
299 <UpgradeToPro
300 fullWidth
301 layout="vertical"
302 source="create-branch"
303 featureProposition="enable branching"
304 primaryText="Upgrade to unlock branching"
305 secondaryText="Create and test schema changes, functions, and more in a separate, temporary instance without affecting production."
306 className="pb-5"
307 />
308 )}
309
310 <DialogSection
311 padding="medium"
312 className={cn('space-y-4', promptPlanUpgrade && 'opacity-25 pointer-events-none')}
313 >
314 <FormField
315 control={form.control}
316 name="branchName"
317 render={({ field }) => (
318 <FormItemLayout label="Preview Branch Name">
319 <FormControl>
320 <Input
321 {...field}
322 placeholder="e.g. staging, dev-feature-x"
323 autoComplete="off"
324 />
325 </FormControl>
326 </FormItemLayout>
327 )}
328 />
329
330 {isLoadingConnections && (
331 <div className="flex flex-col gap-y-2">
332 <ShimmeringLoader />
333 <ShimmeringLoader className="w-1/2" />
334 </div>
335 )}
336
337 {isErrorConnections && (
338 <AlertError
339 error={connectionsError}
340 subject="Failed to retrieve GitHub connection information"
341 />
342 )}
343
344 {isSuccessConnections &&
345 (githubConnection ? (
346 <FormField
347 control={form.control}
348 name="gitBranchName"
349 render={({ field }) => (
350 <FormItemLayout
351 label={
352 <div className="flex items-center justify-between w-full gap-4">
353 <span className="flex-1">Sync with Git branch</span>
354 <div className="flex items-center gap-2 text-sm">
355 <Image
356 className={cn('dark:invert')}
357 src={`${BASE_PATH}/img/icons/github-icon.svg`}
358 width={16}
359 height={16}
360 alt={`GitHub icon`}
361 />
362 <Link
363 href={`https://github.com/${repoOwner}/${repoName}`}
364 target="_blank"
365 rel="noreferrer"
366 className="text-foreground hover:underline"
367 >
368 {repoOwner}/{repoName}
369 </Link>
370 </div>
371 </div>
372 }
373 labelOptional="Optional"
374 description="Automatically deploy changes on every commit"
375 >
376 <div className="relative w-full">
377 <FormControl>
378 <Input
379 {...field}
380 placeholder="e.g. main, feat/some-feature"
381 autoComplete="off"
382 onChange={(e) => {
383 field.onChange(e)
384 setIsGitBranchValid(false)
385 }}
386 />
387 </FormControl>
388 <div className="absolute top-2.5 right-3 flex items-center gap-2">
389 {field.value ? (
390 isCheckingGHBranchValidity ? (
391 <Loader2 size={14} className="animate-spin" />
392 ) : isGitBranchValid ? (
393 <Check size={14} className="text-brand" strokeWidth={2} />
394 ) : null
395 ) : null}
396 </div>
397 </div>
398 </FormItemLayout>
399 )}
400 />
401 ) : (
402 <div className="flex items-center gap-2 justify-between">
403 <div className="flex flex-col gap-1">
404 <Label>Sync with a GitHub branch</Label>
405 <p className="text-sm text-foreground-lighter">
406 Keep this preview branch in sync with a chosen GitHub branch
407 </p>
408 </div>
409 <Button type="default" icon={<Github />} onClick={handleGitHubClick}>
410 Configure
411 </Button>
412 </div>
413 ))}
414
415 {allowDataBranching && (
416 <FormField
417 control={form.control}
418 name="withData"
419 render={({ field }) => (
420 <FormItemLayout
421 label={
422 <>
423 <Label className="mr-2">Include data</Label>
424 {!hasPitrEnabled && <Badge variant="warning">Requires PITR</Badge>}
425 </>
426 }
427 layout="flex-row-reverse"
428 className="[&>div>label]:mb-1"
429 description="Clone production data into this branch"
430 >
431 <FormControl>
432 <Switch
433 disabled={!hasPitrEnabled}
434 checked={field.value}
435 onCheckedChange={field.onChange}
436 />
437 </FormControl>
438 </FormItemLayout>
439 )}
440 />
441 )}
442 </DialogSection>
443
444 <DialogSectionSeparator />
445
446 <DialogSection
447 padding="medium"
448 className={cn(
449 'flex flex-col gap-4',
450 promptPlanUpgrade && 'opacity-25 pointer-events-none'
451 )}
452 >
453 {withData && (
454 <div className="flex flex-row gap-4">
455 <div>
456 <figure className="w-10 h-10 rounded-md bg-info-200 border border-info-400 flex items-center justify-center">
457 <DatabaseZap className="text-info" size={20} strokeWidth={2} />
458 </figure>
459 </div>
460 <div className="flex flex-col gap-y-1">
461 {isLoadingDiskAttr ? (
462 <>
463 <ShimmeringLoader className="w-32 h-5 py-0" />
464 <ShimmeringLoader className="w-72 h-8 py-0" />
465 </>
466 ) : (
467 <>
468 {isErrorDiskAttr ? (
469 <>
470 <p className="text-sm text-foreground">
471 Branch disk size will incur additional cost per month
472 </p>
473 <p className="text-sm text-foreground-light">
474 The additional cost and time taken to create a data branch is relative
475 to the size of your database. We are unable to provide an estimate as
476 we were unable to retrieve your project's disk configuration
477 </p>
478 </>
479 ) : (
480 <>
481 <p className="text-sm text-foreground">
482 Branch disk size is billed at ${estimatedDiskCost.total.toFixed(2)}{' '}
483 per month
484 </p>
485 <p className="text-sm text-foreground-light">
486 Creating a data branch will take about{' '}
487 <span className="text-foreground">
488 {estimateRestoreTime(branchDiskAttributes).toFixed()} minutes
489 </span>{' '}
490 and costs{' '}
491 <span className="text-foreground">
492 ${estimatedDiskCost.total.toFixed(2)}
493 </span>{' '}
494 per month based on your current target database volume size of{' '}
495 {branchDiskAttributes.size_gb} GB and your{' '}
496 <Tooltip>
497 <TooltipTrigger>
498 <span className={InlineLinkClassName}>
499 project's disk configuration
500 </span>
501 </TooltipTrigger>
502 <TooltipContent side="bottom">
503 <div className="flex items-center gap-x-2">
504 <p className="w-24">Disk type:</p>
505 <p className="w-16">
506 {branchDiskAttributes.type.toUpperCase()}
507 </p>
508 </div>
509 <div className="flex items-center gap-x-2">
510 <p className="w-24">Target disk size:</p>
511 <p className="w-16">{branchDiskAttributes.size_gb} GB</p>
512 <p>(${estimatedDiskCost.size.toFixed(2)})</p>
513 </div>
514 <div className="flex items-center gap-x-2">
515 <p className="w-24">IOPs:</p>
516 <p className="w-16">{branchDiskAttributes.iops} IOPS</p>
517 <p>(${estimatedDiskCost.iops.toFixed(2)})</p>
518 </div>
519 {'throughput_mbps' in branchDiskAttributes && (
520 <div className="flex items-center gap-x-2">
521 <p className="w-24">Throughput:</p>
522 <p className="w-16">
523 {branchDiskAttributes.throughput_mbps} MB/s
524 </p>
525 <p>(${estimatedDiskCost.throughput.toFixed(2)})</p>
526 </div>
527 )}
528 <p className="mt-2">
529 More info in{' '}
530 <InlineLink
531 onClick={() => setShowCreateBranchModal(false)}
532 className="pointer-events-auto"
533 href={`/project/${ref}/settings/compute-and-disk`}
534 >
535 Compute and Disk
536 </InlineLink>
537 </p>
538 </TooltipContent>
539 </Tooltip>
540 .
541 </p>
542 </>
543 )}
544 </>
545 )}
546 </div>
547 </div>
548 )}
549
550 {githubConnection && (
551 <div className="flex flex-row gap-4">
552 <div>
553 <figure className="w-10 h-10 rounded-md bg-info-200 border border-info-400 flex items-center justify-center">
554 <GitMerge className="text-info" size={20} strokeWidth={2} />
555 </figure>
556 </div>
557 <div className="flex flex-col gap-y-1">
558 <p className="text-sm text-foreground">
559 {prodBranch?.git_branch
560 ? 'Merging to production enabled'
561 : 'Merging to production disabled'}
562 </p>
563 <p className="text-sm text-foreground-light">
564 {prodBranch?.git_branch ? (
565 <>
566 When this branch is merged to{' '}
567 <span className="text-foreground">{prodBranch.git_branch}</span>,
568 migrations will be deployed to production. Otherwise, migrations only run
569 on preview branches.
570 </>
571 ) : (
572 <>
573 Merging this branch to production will not deploy migrations. To enable
574 production deployment, enable "Deploy to production" in project
575 integration settings.
576 </>
577 )}
578 </p>
579 </div>
580 </div>
581 )}
582
583 <div className="flex flex-row gap-4">
584 <div>
585 <figure className="w-10 h-10 rounded-md bg-info-200 border border-info-400 flex items-center justify-center">
586 <DollarSign className="text-info" size={20} strokeWidth={2} />
587 </figure>
588 </div>
589 <div className="flex flex-col gap-y-1">
590 <p className="text-sm text-foreground">
591 Branch compute is billed at $
592 {withData ? branchComputeSize.priceHourly : instanceSizeSpecs.micro.priceHourly}{' '}
593 per hour
594 </p>
595 <p className="text-sm text-foreground-light">
596 {withData ? (
597 <>
598 <code className="text-code-inline">{branchComputeSize.label}</code> compute
599 size is automatically selected to match your production branch. You may
600 downgrade after creation or pause the branch when not in use to save cost.
601 </>
602 ) : (
603 <>This cost will continue for as long as the branch has not been removed.</>
604 )}
605 </p>
606 </div>
607 </div>
608
609 {!hasPitrEnabled && <BranchingPITRNotice />}
610 <TaxDisclaimer />
611 </DialogSection>
612
613 <DialogFooter className="justify-end gap-2" padding="medium">
614 <Button
615 type="default"
616 disabled={isCreatingBranch}
617 onClick={() => setShowCreateBranchModal(false)}
618 >
619 Cancel
620 </Button>
621 <ButtonTooltip
622 form={formId}
623 disabled={isDisabled}
624 loading={isCreatingBranch}
625 type={promptPlanUpgrade ? 'default' : 'primary'}
626 htmlType="submit"
627 tooltip={{
628 content: {
629 side: 'bottom',
630 text: tooltipText,
631 },
632 }}
633 >
634 Create branch
635 </ButtonTooltip>
636 </DialogFooter>
637 </form>
638 </Form>
639 </DialogContent>
640 </Dialog>
641 )
642}