EditBranchModal.tsx345 lines · main
1import { zodResolver } from '@hookform/resolvers/zod'
2import { useDebounce } from '@uidotdev/usehooks'
3import { useParams } from 'common'
4import { Check, Github, Loader2 } from 'lucide-react'
5import Image from 'next/image'
6import { useRouter } from 'next/router'
7import { useCallback, useEffect, useState } from 'react'
8import { useForm, useWatch } from 'react-hook-form'
9import { toast } from 'sonner'
10import {
11 Button,
12 cn,
13 Dialog,
14 DialogContent,
15 DialogFooter,
16 DialogHeader,
17 DialogSection,
18 DialogSectionSeparator,
19 DialogTitle,
20 Form,
21 FormControl,
22 FormField,
23 Input,
24 Label,
25} from 'ui'
26import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
27import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
28import * as z from 'zod'
29
30import { AlertError } from '@/components/ui/AlertError'
31import { InlineLink } from '@/components/ui/InlineLink'
32import { useBranchUpdateMutation } from '@/data/branches/branch-update-mutation'
33import { Branch, useBranchesQuery } from '@/data/branches/branches-query'
34import { useCheckGithubBranchValidity } from '@/data/integrations/github-branch-check-query'
35import { useGitHubConnectionsQuery } from '@/data/integrations/github-connections-query'
36import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
37import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
38import { BASE_PATH } from '@/lib/constants'
39
40interface EditBranchModalProps {
41 branch?: Branch
42 visible: boolean
43 onClose: () => void
44}
45
46export const EditBranchModal = ({ branch, visible, onClose }: EditBranchModalProps) => {
47 const { ref } = useParams()
48 const router = useRouter()
49 const { data: projectDetails } = useSelectedProjectQuery()
50 const { data: selectedOrg } = useSelectedOrganizationQuery()
51
52 const [isGitBranchValid, setIsGitBranchValid] = useState(true)
53
54 const isBranch = projectDetails?.parent_project_ref !== undefined
55 const projectRef =
56 projectDetails !== undefined ? (isBranch ? projectDetails.parent_project_ref : ref) : undefined
57
58 const {
59 data: connections,
60 error: connectionsError,
61 isPending: isLoadingConnections,
62 isSuccess: isSuccessConnections,
63 isError: isErrorConnections,
64 } = useGitHubConnectionsQuery({
65 organizationId: selectedOrg?.id,
66 })
67
68 const { data: branches } = useBranchesQuery({ projectRef })
69 const { mutate: checkGithubBranchValidity, isPending: isChecking } = useCheckGithubBranchValidity(
70 { onError: () => {} }
71 )
72
73 const { mutate: updateBranch, isPending: isUpdating } = useBranchUpdateMutation({
74 onSuccess: (data) => {
75 toast.success(`Successfully updated branch "${data.name}"`)
76 onClose()
77 },
78 onError: (error) => {
79 toast.error(`Failed to update branch: ${error.message}`)
80 },
81 })
82
83 const githubConnection = connections?.find((connection) => connection.project.ref === projectRef)
84 const [repoOwner, repoName] = githubConnection?.repository.name.split('/') ?? []
85
86 const formId = 'edit-branch-form'
87 const FormSchema = z.object({
88 branchName: z
89 .string()
90 .min(1, 'Branch name cannot be empty')
91 .refine(
92 (val) => /^[a-zA-Z0-9\-_]+$/.test(val),
93 'Branch name can only contain alphanumeric characters, hyphens, and underscores.'
94 )
95 .refine(
96 (val) =>
97 // Allow the current branch name during edit
98 val === branch?.name || (branches ?? []).every((b) => b.name !== val),
99 'A branch with this name already exists'
100 ),
101 gitBranchName: z.string().optional(),
102 })
103
104 const form = useForm<z.infer<typeof FormSchema>>({
105 mode: 'onChange',
106 reValidateMode: 'onChange',
107 resolver: zodResolver(FormSchema as any),
108 defaultValues: { branchName: '', gitBranchName: '' },
109 })
110 const gitBranchName = useWatch({ control: form.control, name: 'gitBranchName' })
111 const debouncedGitBranchName = useDebounce(gitBranchName, 500)
112
113 const isFormValid = form.formState.isValid && (!gitBranchName || isGitBranchValid)
114 const canSubmit = isFormValid && !isUpdating && !isChecking
115
116 const openLinkerPanel = () => {
117 onClose()
118
119 if (projectRef) {
120 router.push(`/project/${projectRef}/settings/integrations`)
121 }
122 }
123
124 const onSubmit = (data: z.infer<typeof FormSchema>) => {
125 if (!projectRef) return console.error('Project ref is required')
126 if (!branch?.project_ref) return console.error('Branch ref is required')
127
128 const payload: {
129 branchRef: string
130 projectRef: string
131 branchName: string
132 gitBranch?: string
133 } = {
134 branchRef: branch.project_ref,
135 projectRef,
136 branchName: data.branchName,
137 }
138
139 // Only add gitBranch to the payload if it is present and valid
140 // If gitBranchName is empty or invalid, gitBranch remains undefined in the payload
141 if (data.gitBranchName && isGitBranchValid) {
142 payload.gitBranch = data.gitBranchName
143 }
144
145 updateBranch(payload)
146 }
147
148 const validateGitBranchName = useCallback(
149 (branchName: string) => {
150 if (!githubConnection)
151 return console.error(
152 '[EditBranchModal > validateGitBranchName] GitHub Connection is missing'
153 )
154
155 const repositoryId = githubConnection.repository.id
156 const requested = branchName
157 checkGithubBranchValidity(
158 { repositoryId, branchName },
159 {
160 onSuccess: () => {
161 if (form.getValues('gitBranchName') !== requested) return
162
163 // Check if another branch is already linked to this git branch
164 const existingBranch = (branches ?? []).find(
165 (b) => b.git_branch === branchName && b.id !== branch?.id
166 )
167 if (existingBranch) {
168 setIsGitBranchValid(false)
169 form.setError('gitBranchName', {
170 message: `Branch "${existingBranch.name}" is already linked to git branch "${branchName}"`,
171 })
172 return
173 }
174
175 setIsGitBranchValid(true)
176 form.clearErrors('gitBranchName')
177 },
178 onError: (error) => {
179 if (form.getValues('gitBranchName') !== requested) return
180 setIsGitBranchValid(false)
181 form.setError('gitBranchName', {
182 ...error,
183 message: `Unable to find branch "${branchName}" in ${repoOwner}/${repoName}`,
184 })
185 },
186 }
187 )
188 },
189 [githubConnection, form, checkGithubBranchValidity, repoOwner, repoName, branches, branch]
190 )
191
192 // Pre-fill form when the modal becomes visible and branch data is available
193 useEffect(() => {
194 if (visible && branch) {
195 form.reset({
196 branchName: branch.name ?? '',
197 gitBranchName: branch.git_branch ?? '',
198 })
199 }
200 }, [branch, visible, form])
201
202 useEffect(() => {
203 if (!githubConnection || !debouncedGitBranchName) {
204 return form.clearErrors('gitBranchName')
205 }
206
207 form.clearErrors('gitBranchName')
208 validateGitBranchName(debouncedGitBranchName)
209 }, [debouncedGitBranchName, validateGitBranchName, form, githubConnection])
210
211 return (
212 <Dialog open={visible} onOpenChange={(open) => !open && onClose()}>
213 <DialogContent size="large" hideClose>
214 <DialogHeader padding="small">
215 <DialogTitle>Edit branch "{branch?.name}"</DialogTitle>
216 </DialogHeader>
217 <DialogSectionSeparator />
218 <Form {...form}>
219 <form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
220 <DialogSection padding="medium" className="space-y-4">
221 <FormField
222 control={form.control}
223 name="branchName"
224 render={({ field }) => (
225 <FormItemLayout label="Preview branch name">
226 <FormControl>
227 <Input
228 {...field}
229 placeholder="e.g. staging, dev-feature-x"
230 autoComplete="off"
231 />
232 </FormControl>
233 </FormItemLayout>
234 )}
235 />
236
237 {isLoadingConnections && (
238 <div className="flex flex-col gap-y-2">
239 <ShimmeringLoader />
240 <ShimmeringLoader className="w-1/2" />
241 </div>
242 )}
243
244 {isErrorConnections && (
245 <AlertError
246 error={connectionsError}
247 subject="Failed to retrieve GitHub connection information"
248 />
249 )}
250
251 {isSuccessConnections &&
252 (githubConnection ? (
253 <FormField
254 control={form.control}
255 name="gitBranchName"
256 render={({ field }) => (
257 <FormItemLayout
258 label={
259 <div className="flex items-center justify-between w-full gap-4">
260 <span className="flex-1">Sync with Git branch</span>
261 <div className="flex items-center gap-2 text-sm">
262 <Image
263 className={cn('dark:invert')}
264 src={`${BASE_PATH}/img/icons/github-icon.svg`}
265 width={16}
266 height={16}
267 alt={`GitHub icon`}
268 />
269 <InlineLink href={`https://github.com/${repoOwner}/${repoName}`}>
270 {repoOwner}/{repoName}
271 </InlineLink>
272 </div>
273 </div>
274 }
275 labelOptional="Optional"
276 description="Automatically deploy changes on every commit"
277 >
278 <div className="relative">
279 <FormControl>
280 <Input
281 {...field}
282 placeholder="e.g. main, feat/some-feature"
283 autoComplete="off"
284 onChange={(e) => {
285 field.onChange(e)
286 setIsGitBranchValid(false)
287 }}
288 />
289 </FormControl>
290 <div className="absolute top-2.5 right-3 flex items-center gap-2">
291 {field.value ? (
292 isChecking ? (
293 <Loader2 size={14} className="animate-spin" />
294 ) : isGitBranchValid ? (
295 <Check size={14} className="text-brand" strokeWidth={2} />
296 ) : null
297 ) : null}
298 </div>
299 </div>
300 </FormItemLayout>
301 )}
302 />
303 ) : (
304 <div className="flex items-center gap-2 justify-between">
305 <div className="flex flex-col gap-1">
306 <div className="flex items-center gap-2">
307 <Label>Sync with a GitHub branch</Label>
308 </div>
309 <p className="text-sm text-foreground-light">
310 Optionally connect to a GitHub repository to manage migrations automatically
311 for this branch.
312 </p>
313 </div>
314 <Button type="default" icon={<Github />} onClick={openLinkerPanel}>
315 Connect to GitHub
316 </Button>
317 </div>
318 ))}
319 </DialogSection>
320
321 <DialogFooter padding="medium">
322 <Button disabled={isUpdating} type="default" onClick={onClose}>
323 Cancel
324 </Button>
325 <Button
326 form={formId}
327 disabled={
328 (!!gitBranchName && !isSuccessConnections) ||
329 isUpdating ||
330 !canSubmit ||
331 isChecking
332 }
333 loading={isUpdating}
334 type="primary"
335 htmlType="submit"
336 >
337 Update branch
338 </Button>
339 </DialogFooter>
340 </form>
341 </Form>
342 </DialogContent>
343 </Dialog>
344 )
345}