import { zodResolver } from '@hookform/resolvers/zod' import { useQueryClient } from '@tanstack/react-query' import { DollarSign, GitMerge, Github, Loader2 } from 'lucide-react' import Image from 'next/image' import Link from 'next/link' import { useRouter } from 'next/router' import { useEffect } from 'react' import { useForm } from 'react-hook-form' import { toast } from 'sonner' import * as z from 'zod' import { PermissionAction } from '@supabase/shared-types/out/constants' import { useFlag, useParams } from 'common' import { useIsBranching2Enabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { BranchingPITRNotice } from 'components/layouts/AppLayout/EnableBranchingButton/BranchingPITRNotice' import AlertError from 'components/ui/AlertError' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import UpgradeToPro from 'components/ui/UpgradeToPro' import { useBranchCreateMutation } from 'data/branches/branch-create-mutation' import { useBranchesQuery } from 'data/branches/branches-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 { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { useAsyncCheckProjectPermissions } 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' import { Badge, Button, Dialog, DialogContent, DialogFooter, DialogHeader, DialogSection, DialogSectionSeparator, DialogTitle, FormControl_Shadcn_, FormField_Shadcn_, Form_Shadcn_, Input_Shadcn_, Label_Shadcn_ as Label, Switch, cn, } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' 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 gitlessBranching = useIsBranching2Enabled() const allowDataBranching = useFlag('allowDataBranching') const isProPlanAndUp = selectedOrg?.plan?.id !== 'free' const promptProPlanUpgrade = IS_PLATFORM && !isProPlanAndUp const isBranch = projectDetails?.parent_project_ref !== undefined const projectRef = projectDetails !== undefined ? (isBranch ? projectDetails.parent_project_ref : ref) : undefined const { data: connections, error: connectionsError, isLoading: isLoadingConnections, isSuccess: isSuccessConnections, isError: isErrorConnections, } = useGitHubConnectionsQuery({ organizationId: selectedOrg?.id, }) const { data: branches } = useBranchesQuery({ projectRef }) const { data: addons } = useProjectAddonsQuery({ projectRef }) const hasPitrEnabled = (addons?.selected_addons ?? []).find((addon) => addon.type === 'pitr') !== undefined const { mutateAsync: checkGithubBranchValidity, isLoading: isChecking } = useCheckGithubBranchValidity({ onError: () => {}, }) const { mutate: sendEvent } = useSendEventMutation() const { mutate: createBranch, isLoading: isCreating } = useBranchCreateMutation({ onSuccess: async (data) => { toast.success(`Successfully created preview branch "${data.name}"`) if (projectRef) { await Promise.all([queryClient.invalidateQueries(projectKeys.detail(projectRef))]) } sendEvent({ action: 'branch_create_button_clicked', properties: { branchType: data.persistent ? 'persistent' : 'preview', gitlessBranching, }, 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}`) }, }) const { can: canCreateBranch } = useAsyncCheckProjectPermissions( PermissionAction.CREATE, 'preview_branches' ) const githubConnection = connections?.find((connection) => connection.project.ref === projectRef) // Fetch production/default branch to inspect git_branch linkage const prodBranch = branches?.find((branch) => branch.is_default) const [repoOwner, repoName] = githubConnection?.repository.name.split('/') ?? [] 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() .refine( (val) => gitlessBranching || !githubConnection || (val && val.length > 0), 'Git branch name is required when GitHub is connected' ), withData: z.boolean().default(false).optional(), }) .superRefine(async (val, ctx) => { if (val.gitBranchName && val.gitBranchName.length > 0 && githubConnection?.repository.id) { try { await checkGithubBranchValidity({ repositoryId: githubConnection.repository.id, branchName: val.gitBranchName, }) // valid – no issues added } catch (error) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: `Unable to find branch "${val.gitBranchName}" in ${repoOwner}/${repoName}`, path: ['gitBranchName'], }) } } }) const form = useForm>({ mode: 'onSubmit', reValidateMode: 'onBlur', resolver: zodResolver(FormSchema), defaultValues: { branchName: '', gitBranchName: '', withData: false }, }) const canSubmit = !isCreating && !isChecking const isDisabled = !isSuccessConnections || isCreating || !canSubmit || isChecking || (!gitlessBranching && !githubConnection) || promptProPlanUpgrade || !canCreateBranch const onSubmit = (data: z.infer) => { if (!projectRef) return console.error('Project ref is required') createBranch({ projectRef, branchName: data.branchName, is_default: false, ...(data.gitBranchName ? { gitBranch: data.gitBranchName } : {}), ...(allowDataBranching ? { withData: data.withData } : {}), }) } const handleGitHubClick = () => { setShowCreateBranchModal(false) router.push(`/project/${projectRef}/settings/integrations`) } useEffect(() => { if (form && showCreateBranchModal) { form.reset() } }, [form, showCreateBranchModal]) return ( setShowCreateBranchModal(open)}> { if (promptProPlanUpgrade) { e.preventDefault() } }} > Create a new preview branch
{promptProPlanUpgrade && ( <> )} ( )} /> {githubConnection && ( ( Sync with Git branch {gitlessBranching ? '(optional)' : ''}
{`GitHub {repoOwner}/{repoName}
} description="Automatically deploy changes on every commit" >
{isChecking && }
)} /> )} {isLoadingConnections && } {isErrorConnections && ( )} {isSuccessConnections && ( <> {!githubConnection && (
{!gitlessBranching && ( Required )}

Keep this preview branch in sync with a chosen GitHub branch

)} )} {allowDataBranching && ( ( )} /> )}
{githubConnection && (

{prodBranch?.git_branch ? 'Merging to production enabled' : 'Merging to production disabled'}

{prodBranch?.git_branch ? ( <> When this branch is merged to{' '} {prodBranch.git_branch}, 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. )}

)}

Branches are billed $0.01344 per hour

This cost will continue for as long as the branch has not been removed.

{!hasPitrEnabled && }
Create branch
) }