CreateBranchModal.tsx 26 KB

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