import { zodResolver } from '@hookform/resolvers/zod' import { useDebounce } from '@uidotdev/usehooks' import { Check, Github, Loader2 } from 'lucide-react' import Image from 'next/image' import { useRouter } from 'next/router' import { useCallback, useEffect, useState } from 'react' import { useForm, useWatch } from 'react-hook-form' import { toast } from 'sonner' import * as z from 'zod' import { InlineLink } from '@/components/ui/InlineLink' import { useParams } from 'common' import { useIsBranching2Enabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { AlertError } from 'components/ui/AlertError' import { useBranchUpdateMutation } from 'data/branches/branch-update-mutation' import { Branch, useBranchesQuery } from 'data/branches/branches-query' import { useCheckGithubBranchValidity } from 'data/integrations/github-branch-check-query' import { useGitHubConnectionsQuery } from 'data/integrations/github-connections-query' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { BASE_PATH } from 'lib/constants' import { Badge, Button, Dialog, DialogContent, DialogFooter, DialogHeader, DialogSection, DialogSectionSeparator, DialogTitle, FormControl_Shadcn_, FormField_Shadcn_, Form_Shadcn_, Input_Shadcn_, Label_Shadcn_ as Label, cn, } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' interface EditBranchModalProps { branch?: Branch visible: boolean onClose: () => void } export const EditBranchModal = ({ branch, visible, onClose }: EditBranchModalProps) => { const { ref } = useParams() const router = useRouter() const { data: projectDetails } = useSelectedProjectQuery() const { data: selectedOrg } = useSelectedOrganizationQuery() const gitlessBranching = useIsBranching2Enabled() const [isGitBranchValid, setIsGitBranchValid] = useState(false) const isBranch = projectDetails?.parent_project_ref !== undefined const projectRef = projectDetails !== undefined ? (isBranch ? projectDetails.parent_project_ref : ref) : undefined const { data: connections, error: connectionsError, isPending: isLoadingConnections, isSuccess: isSuccessConnections, isError: isErrorConnections, } = useGitHubConnectionsQuery({ organizationId: selectedOrg?.id, }) const { data: branches } = useBranchesQuery({ projectRef }) const { mutate: checkGithubBranchValidity, isPending: isChecking } = useCheckGithubBranchValidity( { onError: () => {} } ) const { mutate: updateBranch, isPending: isUpdating } = useBranchUpdateMutation({ onSuccess: (data) => { toast.success(`Successfully updated branch "${data.name}"`) onClose() }, onError: (error) => { toast.error(`Failed to update branch: ${error.message}`) }, }) const githubConnection = connections?.find((connection) => connection.project.ref === projectRef) const [repoOwner, repoName] = githubConnection?.repository.name.split('/') ?? [] const formId = 'edit-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) => // Allow the current branch name during edit val === branch?.name || (branches ?? []).every((b) => b.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' ), }) const form = useForm>({ mode: 'onChange', reValidateMode: 'onChange', resolver: zodResolver(FormSchema), defaultValues: { branchName: '', gitBranchName: '' }, }) const gitBranchName = useWatch({ control: form.control, name: 'gitBranchName' }) const debouncedGitBranchName = useDebounce(gitBranchName, 500) const isFormValid = form.formState.isValid && (!gitBranchName || isGitBranchValid) const canSubmit = isFormValid && !isUpdating && !isChecking const openLinkerPanel = () => { onClose() if (projectRef) { router.push(`/project/${projectRef}/settings/integrations`) } } const onSubmit = (data: z.infer) => { if (!projectRef) return console.error('Project ref is required') if (!branch?.project_ref) return console.error('Branch ref is required') const payload: { branchRef: string projectRef: string branchName: string gitBranch?: string } = { branchRef: branch.project_ref, projectRef, branchName: data.branchName, } // Only add gitBranch to the payload if it is present and valid // If gitBranchName is empty or invalid, gitBranch remains undefined in the payload if (data.gitBranchName && isGitBranchValid) { payload.gitBranch = data.gitBranchName } updateBranch(payload) } const validateGitBranchName = useCallback( (branchName: string) => { if (!githubConnection) return console.error( '[EditBranchModal > validateGitBranchName] GitHub Connection is missing' ) const repositoryId = githubConnection.repository.id const requested = branchName checkGithubBranchValidity( { repositoryId, branchName }, { onSuccess: () => { if (form.getValues('gitBranchName') !== requested) return // Check if another branch is already linked to this git branch const existingBranch = (branches ?? []).find( (b) => b.git_branch === branchName && b.id !== branch?.id ) 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') !== requested) return setIsGitBranchValid(false) form.setError('gitBranchName', { ...error, message: `Unable to find branch "${branchName}" in ${repoOwner}/${repoName}`, }) }, } ) }, [githubConnection, form, checkGithubBranchValidity, repoOwner, repoName, branches, branch] ) // Pre-fill form when the modal becomes visible and branch data is available useEffect(() => { if (visible && branch) { setIsGitBranchValid(!!branch.git_branch || gitlessBranching) // Initial validity based on existing link or gitless branching form.reset({ branchName: branch.name ?? '', gitBranchName: branch.git_branch ?? '', }) } }, [branch, visible, form, gitlessBranching]) useEffect(() => { if (!githubConnection || !debouncedGitBranchName) { setIsGitBranchValid(gitlessBranching) form.clearErrors('gitBranchName') return } form.clearErrors('gitBranchName') validateGitBranchName(debouncedGitBranchName) }, [debouncedGitBranchName, validateGitBranchName, form, githubConnection, gitlessBranching]) return ( !open && onClose()}> Edit branch "{branch?.name}"
( )} /> {githubConnection && ( ( Sync with Git branch {gitlessBranching ? '(optional)' : ''}
{`GitHub {repoOwner}/{repoName}
} description={ gitlessBranching ? 'Automatically deploy changes on every commit' : 'If linked, migrations from this Git branch will be automatically deployed.' } >
{ field.onChange(e) setIsGitBranchValid(false) }} />
{field.value ? ( isChecking ? ( ) : isGitBranchValid ? ( ) : null ) : null}
)} /> )} {isLoadingConnections && } {isErrorConnections && ( )} {isSuccessConnections && !githubConnection && (
{!gitlessBranching && Required}

{gitlessBranching ? 'Optionally connect to a GitHub repository to manage migrations automatically for this branch.' : 'Keep this preview branch in sync with a chosen GitHub branch'}

)}
) }