| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642 |
- // @ts-nocheck
- import { zodResolver } from '@hookform/resolvers/zod'
- import { PermissionAction } from '@supabase/shared-types/out/constants'
- import { useQueryClient } from '@tanstack/react-query'
- import { useDebounce } from '@uidotdev/usehooks'
- import { useFlag, useParams } from 'common'
- import { Check, DatabaseZap, DollarSign, Github, GitMerge, Loader2 } from 'lucide-react'
- import Image from 'next/image'
- import Link from 'next/link'
- import { useRouter } from 'next/router'
- import { useCallback, useEffect, useState } from 'react'
- import { useForm } from 'react-hook-form'
- import { toast } from 'sonner'
- import {
- Badge,
- Button,
- cn,
- Dialog,
- DialogContent,
- DialogFooter,
- DialogHeader,
- DialogSection,
- DialogSectionSeparator,
- DialogTitle,
- Form,
- FormControl,
- FormField,
- Input,
- Label,
- Switch,
- Tooltip,
- TooltipContent,
- TooltipTrigger,
- } from 'ui'
- import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
- import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
- import * as z from 'zod'
- import {
- estimateComputeSize,
- estimateDiskCost,
- estimateRestoreTime,
- } from './BranchManagement.utils'
- import { TaxDisclaimer } from '@/components/interfaces/Billing/TaxDisclaimer'
- import { BranchingPITRNotice } from '@/components/layouts/AppLayout/EnableBranchingButton/BranchingPITRNotice'
- import AlertError from '@/components/ui/AlertError'
- import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
- import { InlineLink, InlineLinkClassName } from '@/components/ui/InlineLink'
- import { UpgradeToPro } from '@/components/ui/UpgradeToPro'
- import { useBranchCreateMutation } from '@/data/branches/branch-create-mutation'
- import { useBranchesQuery } from '@/data/branches/branches-query'
- import { DiskAttributesData, useDiskAttributesQuery } from '@/data/config/disk-attributes-query'
- import { useCheckGithubBranchValidity } from '@/data/integrations/github-branch-check-query'
- import { useGitHubConnectionsQuery } from '@/data/integrations/github-connections-query'
- import { projectKeys } from '@/data/projects/keys'
- import { DesiredInstanceSize, instanceSizeSpecs } from '@/data/projects/new-project.constants'
- import { useProjectAddonsQuery } from '@/data/subscriptions/project-addons-query'
- import { useSendEventMutation } from '@/data/telemetry/send-event-mutation'
- import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
- import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
- import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
- import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
- import { BASE_PATH, IS_PLATFORM } from '@/lib/constants'
- import { useAppStateSnapshot } from '@/state/app-state'
- export const CreateBranchModal = () => {
- const { ref } = useParams()
- const router = useRouter()
- const queryClient = useQueryClient()
- const { data: projectDetails } = useSelectedProjectQuery()
- const { data: selectedOrg } = useSelectedOrganizationQuery()
- const { showCreateBranchModal, setShowCreateBranchModal } = useAppStateSnapshot()
- const allowDataBranching = useFlag('allowDataBranching')
- const [isGitBranchValid, setIsGitBranchValid] = useState(false)
- const { can: canCreateBranch } = useAsyncCheckPermissions(
- PermissionAction.CREATE,
- 'preview_branches'
- )
- const { hasAccess: hasAccessToBranching, isLoading: isLoadingEntitlement } =
- useCheckEntitlements('branching_limit')
- const promptPlanUpgrade = IS_PLATFORM && !hasAccessToBranching
- const isBranch = projectDetails?.parent_project_ref !== undefined
- const projectRef =
- projectDetails !== undefined ? (isBranch ? projectDetails.parent_project_ref : ref) : undefined
- const formId = 'create-branch-form'
- const FormSchema = z.object({
- branchName: z
- .string()
- .min(1, 'Branch name cannot be empty')
- .refine(
- (val) => /^[a-zA-Z0-9\-_]+$/.test(val),
- 'Branch name can only contain alphanumeric characters, hyphens, and underscores.'
- )
- .refine(
- (val) => (branches ?? []).every((branch) => branch.name !== val),
- 'A branch with this name already exists'
- ),
- gitBranchName: z.string().optional(),
- withData: z.boolean().default(false).optional(),
- })
- const form = useForm<z.infer<typeof FormSchema>>({
- mode: 'onSubmit',
- reValidateMode: 'onBlur',
- resolver: zodResolver(FormSchema as any),
- defaultValues: { branchName: '', gitBranchName: '', withData: false },
- })
- const { withData, gitBranchName } = form.watch()
- const debouncedGitBranchName = useDebounce(gitBranchName, 500)
- const {
- data: connections,
- error: connectionsError,
- isPending: isLoadingConnections,
- isSuccess: isSuccessConnections,
- isError: isErrorConnections,
- } = useGitHubConnectionsQuery(
- { organizationId: selectedOrg?.id },
- { enabled: showCreateBranchModal }
- )
- const { data: branches } = useBranchesQuery({ projectRef })
- const { data: addons, isSuccess: isSuccessAddons } = useProjectAddonsQuery(
- { projectRef },
- { enabled: showCreateBranchModal }
- )
- const computeAddon = addons?.selected_addons.find((addon) => addon.type === 'compute_instance')
- const computeSize = computeAddon
- ? (computeAddon.variant.identifier.split('ci_')[1] as DesiredInstanceSize)
- : undefined
- const hasPitrEnabled = (addons?.selected_addons ?? []).some((addon) => addon.type === 'pitr')
- const {
- data: disk,
- isPending: isLoadingDiskAttr,
- isError: isErrorDiskAttr,
- } = useDiskAttributesQuery({ projectRef }, { enabled: showCreateBranchModal && withData })
- const projectDiskAttributes = disk?.attributes ?? {
- type: 'gp3',
- size_gb: 0,
- iops: 0,
- throughput_mbps: 0,
- }
- // Branch disk is oversized to include backup files, it should be scaled back eventually.
- const branchDiskAttributes = {
- ...projectDiskAttributes,
- // [Joshen] JFYI for Qiao - this multiplier may eventually be dropped
- size_gb: Math.round(projectDiskAttributes.size_gb * 1.5),
- } as DiskAttributesData['attributes']
- const branchComputeSize = estimateComputeSize(projectDiskAttributes.size_gb, computeSize)
- const estimatedDiskCost = estimateDiskCost(branchDiskAttributes)
- const { mutate: sendEvent } = useSendEventMutation()
- const { mutate: checkGithubBranchValidity, isPending: isCheckingGHBranchValidity } =
- useCheckGithubBranchValidity({
- onError: () => {},
- })
- const { mutate: createBranch, isPending: isCreatingBranch } = useBranchCreateMutation({
- onSuccess: async (data) => {
- toast.success(`Successfully created preview branch "${data.name}"`)
- if (projectRef) {
- await queryClient.invalidateQueries({ queryKey: projectKeys.detail(projectRef) })
- }
- sendEvent({
- action: 'branch_create_button_clicked',
- properties: {
- branchType: data.persistent ? 'persistent' : 'preview',
- gitlessBranching: !data.git_branch,
- },
- groups: {
- project: ref ?? 'Unknown',
- organization: selectedOrg?.slug ?? 'Unknown',
- },
- })
- setShowCreateBranchModal(false)
- router.push(`/project/${data.project_ref}`)
- },
- onError: (error) => {
- toast.error(`Failed to create branch: ${error.message}`)
- },
- })
- // Fetch production/default branch to inspect git_branch linkage
- const githubConnection = connections?.find((connection) => connection.project.ref === projectRef)
- const prodBranch = branches?.find((branch) => branch.is_default)
- const [repoOwner, repoName] = githubConnection?.repository.name.split('/') ?? []
- const isFormValid = form.formState.isValid && (!gitBranchName || isGitBranchValid)
- const isDisabled =
- !isFormValid ||
- !canCreateBranch ||
- !isSuccessAddons ||
- (!!gitBranchName && !isSuccessConnections) ||
- isLoadingEntitlement ||
- !hasAccessToBranching ||
- isCreatingBranch ||
- isCheckingGHBranchValidity
- const tooltipText = promptPlanUpgrade ? 'Upgrade to unlock branching' : undefined
- const validateGitBranchName = useCallback(
- (branchName: string) => {
- if (!githubConnection) {
- return console.error(
- '[CreateBranchModal > validateGitBranchName] GitHub Connection is missing'
- )
- }
- const repositoryId = githubConnection.repository.id
- checkGithubBranchValidity(
- { repositoryId, branchName },
- {
- onSuccess: () => {
- if (form.getValues('gitBranchName') !== branchName) return
- // Check if another branch is already linked to this git branch
- const existingBranch = (branches ?? []).find((b) => b.git_branch === branchName)
- if (existingBranch) {
- setIsGitBranchValid(false)
- form.setError('gitBranchName', {
- message: `Branch "${existingBranch.name}" is already linked to git branch "${branchName}"`,
- })
- return
- }
- setIsGitBranchValid(true)
- form.clearErrors('gitBranchName')
- },
- onError: (error) => {
- if (form.getValues('gitBranchName') !== branchName) return
- setIsGitBranchValid(false)
- form.setError('gitBranchName', {
- message:
- error?.message ??
- `Unable to find branch "${branchName}" in ${repoOwner}/${repoName}`,
- })
- },
- }
- )
- },
- [githubConnection, form, checkGithubBranchValidity, repoOwner, repoName, branches]
- )
- const onSubmit = (data: z.infer<typeof FormSchema>) => {
- if (!projectRef) return console.error('Project ref is required')
- createBranch({
- projectRef,
- branchName: data.branchName,
- is_default: false,
- ...(data.withData ? { desired_instance_size: computeSize } : {}),
- ...(data.gitBranchName ? { gitBranch: data.gitBranchName } : {}),
- ...(allowDataBranching ? { withData: data.withData } : {}),
- })
- }
- const handleGitHubClick = () => {
- setShowCreateBranchModal(false)
- router.push(`/project/${projectRef}/settings/integrations`)
- }
- useEffect(() => {
- if (showCreateBranchModal) form.reset()
- }, [form, showCreateBranchModal])
- useEffect(() => {
- form.clearErrors('gitBranchName')
- if (githubConnection && debouncedGitBranchName) validateGitBranchName(debouncedGitBranchName)
- }, [debouncedGitBranchName, validateGitBranchName, form, githubConnection])
- return (
- <Dialog open={showCreateBranchModal} onOpenChange={setShowCreateBranchModal}>
- <DialogContent
- size="large"
- hideClose
- onOpenAutoFocus={(e) => {
- if (promptPlanUpgrade) e.preventDefault()
- }}
- aria-describedby={undefined}
- >
- <DialogHeader padding="small">
- <DialogTitle>Create a new preview branch</DialogTitle>
- </DialogHeader>
- <DialogSectionSeparator />
- <Form {...form}>
- <form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
- {promptPlanUpgrade && (
- <UpgradeToPro
- fullWidth
- layout="vertical"
- source="create-branch"
- featureProposition="enable branching"
- primaryText="Upgrade to unlock branching"
- secondaryText="Create and test schema changes, functions, and more in a separate, temporary instance without affecting production."
- className="pb-5"
- />
- )}
- <DialogSection
- padding="medium"
- className={cn('space-y-4', promptPlanUpgrade && 'opacity-25 pointer-events-none')}
- >
- <FormField
- control={form.control}
- name="branchName"
- render={({ field }) => (
- <FormItemLayout label="Preview Branch Name">
- <FormControl>
- <Input
- {...field}
- placeholder="e.g. staging, dev-feature-x"
- autoComplete="off"
- />
- </FormControl>
- </FormItemLayout>
- )}
- />
- {isLoadingConnections && (
- <div className="flex flex-col gap-y-2">
- <ShimmeringLoader />
- <ShimmeringLoader className="w-1/2" />
- </div>
- )}
- {isErrorConnections && (
- <AlertError
- error={connectionsError}
- subject="Failed to retrieve GitHub connection information"
- />
- )}
- {isSuccessConnections &&
- (githubConnection ? (
- <FormField
- control={form.control}
- name="gitBranchName"
- render={({ field }) => (
- <FormItemLayout
- label={
- <div className="flex items-center justify-between w-full gap-4">
- <span className="flex-1">Sync with Git branch</span>
- <div className="flex items-center gap-2 text-sm">
- <Image
- className={cn('dark:invert')}
- src={`${BASE_PATH}/img/icons/github-icon.svg`}
- width={16}
- height={16}
- alt={`GitHub icon`}
- />
- <Link
- href={`https://github.com/${repoOwner}/${repoName}`}
- target="_blank"
- rel="noreferrer"
- className="text-foreground hover:underline"
- >
- {repoOwner}/{repoName}
- </Link>
- </div>
- </div>
- }
- labelOptional="Optional"
- description="Automatically deploy changes on every commit"
- >
- <div className="relative w-full">
- <FormControl>
- <Input
- {...field}
- placeholder="e.g. main, feat/some-feature"
- autoComplete="off"
- onChange={(e) => {
- field.onChange(e)
- setIsGitBranchValid(false)
- }}
- />
- </FormControl>
- <div className="absolute top-2.5 right-3 flex items-center gap-2">
- {field.value ? (
- isCheckingGHBranchValidity ? (
- <Loader2 size={14} className="animate-spin" />
- ) : isGitBranchValid ? (
- <Check size={14} className="text-brand" strokeWidth={2} />
- ) : null
- ) : null}
- </div>
- </div>
- </FormItemLayout>
- )}
- />
- ) : (
- <div className="flex items-center gap-2 justify-between">
- <div className="flex flex-col gap-1">
- <Label>Sync with a GitHub branch</Label>
- <p className="text-sm text-foreground-lighter">
- Keep this preview branch in sync with a chosen GitHub branch
- </p>
- </div>
- <Button type="default" icon={<Github />} onClick={handleGitHubClick}>
- Configure
- </Button>
- </div>
- ))}
- {allowDataBranching && (
- <FormField
- control={form.control}
- name="withData"
- render={({ field }) => (
- <FormItemLayout
- label={
- <>
- <Label className="mr-2">Include data</Label>
- {!hasPitrEnabled && <Badge variant="warning">Requires PITR</Badge>}
- </>
- }
- layout="flex-row-reverse"
- className="[&>div>label]:mb-1"
- description="Clone production data into this branch"
- >
- <FormControl>
- <Switch
- disabled={!hasPitrEnabled}
- checked={field.value}
- onCheckedChange={field.onChange}
- />
- </FormControl>
- </FormItemLayout>
- )}
- />
- )}
- </DialogSection>
- <DialogSectionSeparator />
- <DialogSection
- padding="medium"
- className={cn(
- 'flex flex-col gap-4',
- promptPlanUpgrade && 'opacity-25 pointer-events-none'
- )}
- >
- {withData && (
- <div className="flex flex-row gap-4">
- <div>
- <figure className="w-10 h-10 rounded-md bg-info-200 border border-info-400 flex items-center justify-center">
- <DatabaseZap className="text-info" size={20} strokeWidth={2} />
- </figure>
- </div>
- <div className="flex flex-col gap-y-1">
- {isLoadingDiskAttr ? (
- <>
- <ShimmeringLoader className="w-32 h-5 py-0" />
- <ShimmeringLoader className="w-72 h-8 py-0" />
- </>
- ) : (
- <>
- {isErrorDiskAttr ? (
- <>
- <p className="text-sm text-foreground">
- Branch disk size will incur additional cost per month
- </p>
- <p className="text-sm text-foreground-light">
- The additional cost and time taken to create a data branch is relative
- to the size of your database. We are unable to provide an estimate as
- we were unable to retrieve your project's disk configuration
- </p>
- </>
- ) : (
- <>
- <p className="text-sm text-foreground">
- Branch disk size is billed at ${estimatedDiskCost.total.toFixed(2)}{' '}
- per month
- </p>
- <p className="text-sm text-foreground-light">
- Creating a data branch will take about{' '}
- <span className="text-foreground">
- {estimateRestoreTime(branchDiskAttributes).toFixed()} minutes
- </span>{' '}
- and costs{' '}
- <span className="text-foreground">
- ${estimatedDiskCost.total.toFixed(2)}
- </span>{' '}
- per month based on your current target database volume size of{' '}
- {branchDiskAttributes.size_gb} GB and your{' '}
- <Tooltip>
- <TooltipTrigger>
- <span className={InlineLinkClassName}>
- project's disk configuration
- </span>
- </TooltipTrigger>
- <TooltipContent side="bottom">
- <div className="flex items-center gap-x-2">
- <p className="w-24">Disk type:</p>
- <p className="w-16">
- {branchDiskAttributes.type.toUpperCase()}
- </p>
- </div>
- <div className="flex items-center gap-x-2">
- <p className="w-24">Target disk size:</p>
- <p className="w-16">{branchDiskAttributes.size_gb} GB</p>
- <p>(${estimatedDiskCost.size.toFixed(2)})</p>
- </div>
- <div className="flex items-center gap-x-2">
- <p className="w-24">IOPs:</p>
- <p className="w-16">{branchDiskAttributes.iops} IOPS</p>
- <p>(${estimatedDiskCost.iops.toFixed(2)})</p>
- </div>
- {'throughput_mbps' in branchDiskAttributes && (
- <div className="flex items-center gap-x-2">
- <p className="w-24">Throughput:</p>
- <p className="w-16">
- {branchDiskAttributes.throughput_mbps} MB/s
- </p>
- <p>(${estimatedDiskCost.throughput.toFixed(2)})</p>
- </div>
- )}
- <p className="mt-2">
- More info in{' '}
- <InlineLink
- onClick={() => setShowCreateBranchModal(false)}
- className="pointer-events-auto"
- href={`/project/${ref}/settings/compute-and-disk`}
- >
- Compute and Disk
- </InlineLink>
- </p>
- </TooltipContent>
- </Tooltip>
- .
- </p>
- </>
- )}
- </>
- )}
- </div>
- </div>
- )}
- {githubConnection && (
- <div className="flex flex-row gap-4">
- <div>
- <figure className="w-10 h-10 rounded-md bg-info-200 border border-info-400 flex items-center justify-center">
- <GitMerge className="text-info" size={20} strokeWidth={2} />
- </figure>
- </div>
- <div className="flex flex-col gap-y-1">
- <p className="text-sm text-foreground">
- {prodBranch?.git_branch
- ? 'Merging to production enabled'
- : 'Merging to production disabled'}
- </p>
- <p className="text-sm text-foreground-light">
- {prodBranch?.git_branch ? (
- <>
- When this branch is merged to{' '}
- <span className="text-foreground">{prodBranch.git_branch}</span>,
- migrations will be deployed to production. Otherwise, migrations only run
- on preview branches.
- </>
- ) : (
- <>
- Merging this branch to production will not deploy migrations. To enable
- production deployment, enable "Deploy to production" in project
- integration settings.
- </>
- )}
- </p>
- </div>
- </div>
- )}
- <div className="flex flex-row gap-4">
- <div>
- <figure className="w-10 h-10 rounded-md bg-info-200 border border-info-400 flex items-center justify-center">
- <DollarSign className="text-info" size={20} strokeWidth={2} />
- </figure>
- </div>
- <div className="flex flex-col gap-y-1">
- <p className="text-sm text-foreground">
- Branch compute is billed at $
- {withData ? branchComputeSize.priceHourly : instanceSizeSpecs.micro.priceHourly}{' '}
- per hour
- </p>
- <p className="text-sm text-foreground-light">
- {withData ? (
- <>
- <code className="text-code-inline">{branchComputeSize.label}</code> compute
- size is automatically selected to match your production branch. You may
- downgrade after creation or pause the branch when not in use to save cost.
- </>
- ) : (
- <>This cost will continue for as long as the branch has not been removed.</>
- )}
- </p>
- </div>
- </div>
- {!hasPitrEnabled && <BranchingPITRNotice />}
- <TaxDisclaimer />
- </DialogSection>
- <DialogFooter className="justify-end gap-2" padding="medium">
- <Button
- type="default"
- disabled={isCreatingBranch}
- onClick={() => setShowCreateBranchModal(false)}
- >
- Cancel
- </Button>
- <ButtonTooltip
- form={formId}
- disabled={isDisabled}
- loading={isCreatingBranch}
- type={promptPlanUpgrade ? 'default' : 'primary'}
- htmlType="submit"
- tooltip={{
- content: {
- side: 'bottom',
- text: tooltipText,
- },
- }}
- >
- Create branch
- </ButtonTooltip>
- </DialogFooter>
- </form>
- </Form>
- </DialogContent>
- </Dialog>
- )
- }
|