Overview.tsx565 lines · main
1import { PermissionAction } from '@supabase/shared-types/out/constants'
2import { useQueryClient } from '@tanstack/react-query'
3import { useParams } from 'common'
4import { partition } from 'lodash'
5import {
6 Clock,
7 ExternalLink,
8 Infinity,
9 MoreVertical,
10 Pencil,
11 RefreshCw,
12 Shield,
13 Trash2,
14} from 'lucide-react'
15import Link from 'next/link'
16import { useState } from 'react'
17import { toast } from 'sonner'
18import {
19 Button,
20 DropdownMenu,
21 DropdownMenuContent,
22 DropdownMenuItem,
23 DropdownMenuSeparator,
24 DropdownMenuTrigger,
25} from 'ui'
26import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal'
27
28import { BranchLoader, BranchManagementSection, BranchRow, BranchRowLoader } from './BranchPanels'
29import { EditBranchModal } from './EditBranchModal'
30import { PreviewBranchesEmptyState } from './EmptyStates'
31import { DropdownMenuItemTooltip } from '@/components/ui/DropdownMenuItemTooltip'
32import { TextConfirmModal } from '@/components/ui/TextConfirmModalWrapper'
33import { useBranchQuery } from '@/data/branches/branch-query'
34import { useBranchResetMutation } from '@/data/branches/branch-reset-mutation'
35import { useBranchRestoreMutation } from '@/data/branches/branch-restore-mutation'
36import { useBranchUpdateMutation } from '@/data/branches/branch-update-mutation'
37import type { Branch } from '@/data/branches/branches-query'
38import { branchKeys } from '@/data/branches/keys'
39import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
40import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
41import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
42import { IS_PLATFORM } from '@/lib/constants'
43
44interface OverviewProps {
45 isGithubConnected: boolean
46 isLoading: boolean
47 isSuccess: boolean
48 repo: string
49 mainBranch: Branch
50 previewBranches: Branch[]
51 onSelectCreateBranch: () => void
52 onSelectDeleteBranch: (branch: Branch) => void
53 generateCreatePullRequestURL: (branchName?: string) => string
54}
55
56export const Overview = ({
57 isGithubConnected,
58 isLoading,
59 isSuccess,
60 repo,
61 mainBranch,
62 previewBranches,
63 onSelectCreateBranch,
64 onSelectDeleteBranch,
65 generateCreatePullRequestURL,
66}: OverviewProps) => {
67 const [scheduledForDeletionBranches, aliveBranches] = partition(
68 previewBranches,
69 (branch) => branch.deletion_scheduled_at !== undefined
70 )
71 const [persistentBranches, ephemeralBranches] = partition(
72 aliveBranches,
73 (branch) => branch.persistent
74 )
75 const { ref: projectRef } = useParams()
76 const { data: selectedOrg } = useSelectedOrganizationQuery()
77
78 const { hasAccess: hasAccessToPersistentBranching, isLoading: isLoadingEntitlement } =
79 useCheckEntitlements('branching_persistent')
80
81 return (
82 <>
83 <BranchManagementSection header="Production branch">
84 {isLoading && <BranchRowLoader />}
85 {isSuccess && mainBranch !== undefined && (
86 <BranchRow
87 branch={mainBranch}
88 isGithubConnected={isGithubConnected}
89 label={
90 <div className="flex items-center gap-x-2">
91 <Shield size={14} strokeWidth={1.5} className="text-warning" />
92 {mainBranch.name}
93 </div>
94 }
95 repo={repo}
96 rowActions={<MainBranchActions branch={mainBranch} repo={repo} />}
97 />
98 )}
99 {isSuccess && mainBranch === undefined && (
100 <div className="w-full flex items-center justify-between px-4 py-2.5 hover:bg-surface-100">
101 <Link href={`/project/${projectRef}`} className="text-foreground block w-full">
102 <div className="flex items-center gap-x-3">
103 <Shield size={14} strokeWidth={1.5} className="text-warning" />
104 main
105 </div>
106 </Link>
107 </div>
108 )}
109 </BranchManagementSection>
110
111 {/* Persistent Branches Section */}
112 <BranchManagementSection header="Persistent branches">
113 {(isLoading || isLoadingEntitlement) && <BranchLoader />}
114 {isSuccess &&
115 !isLoadingEntitlement &&
116 !hasAccessToPersistentBranching &&
117 IS_PLATFORM &&
118 persistentBranches.length === 0 && (
119 <div className="px-6 py-10 flex items-center justify-between">
120 <div className="flex flex-col gap-0.5">
121 <p className="text-sm">Upgrade to unlock persistent branches</p>
122 <p className="text-sm text-foreground-lighter text-balance">
123 Persistent branches are long-lived, cannot be reset, and are ideal for staging
124 environments.
125 </p>
126 </div>
127 <Button type="primary" asChild>
128 <Link href={`/org/${selectedOrg?.slug}/billing?panel=subscriptionPlan`}>
129 Upgrade
130 </Link>
131 </Button>
132 </div>
133 )}
134 {isSuccess &&
135 !isLoadingEntitlement &&
136 hasAccessToPersistentBranching &&
137 persistentBranches.length === 0 && (
138 <div className="flex items-center flex-col gap-0.5 justify-center w-full py-10">
139 <p>No persistent branches</p>
140 <p className="text-foreground-lighter text-center text-balance">
141 Persistent branches are long-lived, cannot be reset, and are ideal for staging
142 environments.
143 </p>
144 </div>
145 )}
146 {isSuccess &&
147 !isLoadingEntitlement &&
148 persistentBranches.map((branch) => {
149 return (
150 <BranchRow
151 isGithubConnected={isGithubConnected}
152 key={branch.id}
153 repo={repo}
154 branch={branch}
155 rowActions={
156 <PreviewBranchActions
157 branch={branch}
158 repo={repo}
159 onSelectDeleteBranch={() => onSelectDeleteBranch(branch)}
160 generateCreatePullRequestURL={generateCreatePullRequestURL}
161 />
162 }
163 />
164 )
165 })}
166 </BranchManagementSection>
167
168 {/* Ephemeral/Preview Branches Section */}
169 <BranchManagementSection header="Preview branches">
170 {isLoading && <BranchLoader />}
171 {isSuccess && ephemeralBranches.length === 0 && (
172 <PreviewBranchesEmptyState onSelectCreateBranch={onSelectCreateBranch} />
173 )}
174 {isSuccess &&
175 ephemeralBranches.map((branch) => {
176 return (
177 <BranchRow
178 isGithubConnected={isGithubConnected}
179 key={branch.id}
180 repo={repo}
181 branch={branch}
182 rowActions={
183 <PreviewBranchActions
184 branch={branch}
185 repo={repo}
186 onSelectDeleteBranch={() => onSelectDeleteBranch(branch)}
187 generateCreatePullRequestURL={generateCreatePullRequestURL}
188 />
189 }
190 />
191 )
192 })}
193 </BranchManagementSection>
194 {/* Scheduled for deletion branches section */}
195 <BranchManagementSection header="Scheduled for deletion branches">
196 {isLoading && <BranchLoader />}
197 {isSuccess && scheduledForDeletionBranches.length === 0 && (
198 <div className="flex items-center flex-col gap-0.5 justify-center w-full py-10">
199 <p className="text-foreground-lighter">No branches scheduled for deletion</p>
200 </div>
201 )}
202 {isSuccess &&
203 scheduledForDeletionBranches.map((branch) => {
204 return (
205 <BranchRow
206 isGithubConnected={isGithubConnected}
207 key={branch.id}
208 repo={repo}
209 branch={branch}
210 rowActions={
211 <PreviewBranchActions
212 branch={branch}
213 repo={repo}
214 // If a scheduled for deletion branch is deleted, we force the deletion
215 onSelectDeleteBranch={() => onSelectDeleteBranch(branch)}
216 generateCreatePullRequestURL={generateCreatePullRequestURL}
217 />
218 }
219 />
220 )
221 })}
222 </BranchManagementSection>
223 </>
224 )
225}
226
227// Row actions for preview branches (non-main)
228const PreviewBranchActions = ({
229 branch,
230 onSelectDeleteBranch,
231 generateCreatePullRequestURL,
232}: {
233 branch: Branch
234 repo: string
235 onSelectDeleteBranch: () => void
236 generateCreatePullRequestURL: (branchName?: string) => string
237}) => {
238 const queryClient = useQueryClient()
239 const { project_ref: branchRef, parent_project_ref: projectRef } = branch
240
241 const { can: canDeleteBranches } = useAsyncCheckPermissions(
242 PermissionAction.DELETE,
243 'preview_branches'
244 )
245 const { can: canUpdateBranches } = useAsyncCheckPermissions(
246 PermissionAction.UPDATE,
247 'preview_branches'
248 )
249 // If user can update branches, they can restore branches
250 const canRestoreBranches = canUpdateBranches
251
252 const { data } = useBranchQuery({ projectRef, branchRef })
253 const isBranchActiveHealthy = data?.status === 'ACTIVE_HEALTHY'
254 const isPersistentBranch = branch.persistent
255
256 const { hasAccess: hasAccessToPersistentBranching } = useCheckEntitlements('branching_persistent')
257
258 const [showConfirmResetModal, setShowConfirmResetModal] = useState(false)
259 const [showBranchModeSwitch, setShowBranchModeSwitch] = useState(false)
260 const [
261 showPersistentBranchDeleteConfirmationModal,
262 setShowPersistentBranchDeleteConfirmationModal,
263 ] = useState(false)
264 const [showEditBranchModal, setShowEditBranchModal] = useState(false)
265
266 const { mutate: resetBranch, isPending: isResetting } = useBranchResetMutation({
267 onSuccess() {
268 toast.success('Success! Please allow a few seconds for the branch to reset.')
269 setShowConfirmResetModal(false)
270 },
271 })
272
273 const { mutate: updateBranch, isPending: isUpdatingBranch } = useBranchUpdateMutation({
274 onSuccess() {
275 toast.success('Successfully updated branch')
276 setShowBranchModeSwitch(false)
277 if (projectRef) {
278 queryClient.invalidateQueries({ queryKey: branchKeys.list(projectRef) })
279 }
280 },
281 })
282 const { mutate: restoreBranch } = useBranchRestoreMutation({
283 onSuccess() {
284 toast.success('Success! Please allow a few minutes for the branch to restore.')
285 setShowBranchModeSwitch(false)
286 },
287 })
288
289 const onRestoreBranch = () => {
290 restoreBranch({ branchRef, projectRef })
291 }
292
293 const onConfirmReset = () => {
294 resetBranch({ branchRef, projectRef })
295 }
296
297 const onTogglePersistent = () => {
298 updateBranch({ branchRef, projectRef, persistent: !branch.persistent })
299 }
300
301 const onDeleteBranch = (e: Event | React.MouseEvent<HTMLDivElement>) => {
302 if (isPersistentBranch) {
303 setShowPersistentBranchDeleteConfirmationModal(true)
304 } else {
305 e.stopPropagation()
306 onSelectDeleteBranch()
307 }
308 }
309
310 return (
311 <>
312 <DropdownMenu>
313 <DropdownMenuTrigger asChild>
314 <Button
315 type="text"
316 icon={<MoreVertical />}
317 className="px-1"
318 onClick={(e) => e.stopPropagation()}
319 />
320 </DropdownMenuTrigger>
321 <DropdownMenuContent className="w-56" side="bottom" align="end">
322 <DropdownMenuItemTooltip
323 className="gap-x-2"
324 disabled={!canUpdateBranches || !isBranchActiveHealthy || isUpdatingBranch}
325 onSelect={(e) => {
326 e.stopPropagation()
327 setShowEditBranchModal(true)
328 }}
329 onClick={(e) => {
330 e.stopPropagation()
331 setShowEditBranchModal(true)
332 }}
333 tooltip={{
334 content: {
335 side: 'left',
336 text: !canUpdateBranches
337 ? 'You need additional permissions to edit branches'
338 : !isBranchActiveHealthy
339 ? 'Branch is still initializing. Please wait for it to become healthy before editing.'
340 : undefined,
341 },
342 }}
343 >
344 <Pencil size={14} /> Edit branch
345 </DropdownMenuItemTooltip>
346
347 {!branch.deletion_scheduled_at && (
348 <DropdownMenuItemTooltip
349 className="gap-x-2"
350 disabled={isResetting || !isBranchActiveHealthy}
351 onSelect={(e) => {
352 e.stopPropagation()
353 setShowConfirmResetModal(true)
354 }}
355 onClick={(e) => {
356 e.stopPropagation()
357 setShowConfirmResetModal(true)
358 }}
359 tooltip={{
360 content: {
361 side: 'left',
362 text: !isBranchActiveHealthy
363 ? 'Branch is still initializing. Please wait for it to become healthy before resetting.'
364 : undefined,
365 },
366 }}
367 >
368 <RefreshCw size={14} /> Reset branch
369 </DropdownMenuItemTooltip>
370 )}
371
372 {!branch.deletion_scheduled_at && (
373 <DropdownMenuItemTooltip
374 className="gap-x-2"
375 disabled={
376 !isBranchActiveHealthy || (!branch.persistent && !hasAccessToPersistentBranching)
377 }
378 onSelect={(e) => {
379 e.stopPropagation()
380 setShowBranchModeSwitch(true)
381 }}
382 onClick={(e) => {
383 e.stopPropagation()
384 setShowBranchModeSwitch(true)
385 }}
386 tooltip={{
387 content: {
388 side: 'left',
389 text: !isBranchActiveHealthy
390 ? 'Branch is still initializing. Please wait for it to become healthy before switching.'
391 : !branch.persistent && !hasAccessToPersistentBranching
392 ? 'Upgrade your plan to access persistent branches'
393 : undefined,
394 },
395 }}
396 >
397 {branch.persistent ? (
398 <>
399 <Clock size={14} /> Switch to preview
400 </>
401 ) : (
402 <>
403 <Infinity size={14} className="scale-110" /> Switch to persistent
404 </>
405 )}
406 </DropdownMenuItemTooltip>
407 )}
408
409 {/* Create PR if applicable */}
410 {branch.git_branch && branch.pr_number === undefined && (
411 <DropdownMenuItem asChild className="gap-x-2">
412 <a
413 target="_blank"
414 rel="noreferrer"
415 href={generateCreatePullRequestURL(branch.git_branch)}
416 onClick={(e) => e.stopPropagation()}
417 >
418 <ExternalLink size={14} /> Create pull request
419 </a>
420 </DropdownMenuItem>
421 )}
422 {branch.deletion_scheduled_at && (
423 <DropdownMenuItemTooltip
424 className="gap-x-2"
425 disabled={!canRestoreBranches || branch.preview_project_status !== 'INACTIVE'}
426 onSelect={(e) => {
427 e.stopPropagation()
428 onRestoreBranch()
429 }}
430 onClick={(e) => {
431 e.stopPropagation()
432 onRestoreBranch()
433 }}
434 tooltip={{
435 content: {
436 side: 'left',
437 text: !canRestoreBranches
438 ? 'You need additional permissions to restore branches'
439 : branch.preview_project_status !== 'INACTIVE'
440 ? 'Preview project is not fully paused or already coming up. Please wait for it to become fully paused before restoring.'
441 : undefined,
442 },
443 }}
444 >
445 <Clock size={14} /> Restore branch
446 </DropdownMenuItemTooltip>
447 )}
448
449 <DropdownMenuSeparator />
450
451 <DropdownMenuItemTooltip
452 className="gap-x-2"
453 disabled={!canDeleteBranches}
454 onSelect={onDeleteBranch}
455 onClick={onDeleteBranch}
456 tooltip={{
457 content: {
458 side: 'left',
459 text: !canDeleteBranches
460 ? 'You need additional permissions to delete branches'
461 : undefined,
462 },
463 }}
464 >
465 <Trash2 size={14} /> Delete branch
466 </DropdownMenuItemTooltip>
467 </DropdownMenuContent>
468 </DropdownMenu>
469
470 <TextConfirmModal
471 variant="warning"
472 visible={showConfirmResetModal}
473 onCancel={() => setShowConfirmResetModal(false)}
474 onConfirm={onConfirmReset}
475 loading={isResetting}
476 title="Reset branch"
477 confirmLabel="Reset branch"
478 confirmPlaceholder="Type in name of branch"
479 confirmString={branch?.name ?? ''}
480 alert={{
481 title: `Are you sure you want to reset the "${branch.name}" branch? All data will be deleted.`,
482 }}
483 />
484
485 <ConfirmationModal
486 variant="default"
487 visible={showBranchModeSwitch}
488 confirmLabel={branch.persistent ? 'Switch to preview' : 'Switch to persistent'}
489 title="Confirm branch mode switch"
490 loading={isUpdatingBranch}
491 onCancel={() => setShowBranchModeSwitch(false)}
492 onConfirm={onTogglePersistent}
493 >
494 <p className="text-sm text-foreground-light">
495 Are you sure you want to switch the branch "{branch.name}" to{' '}
496 {branch.persistent ? 'preview' : 'persistent'}?
497 </p>
498 </ConfirmationModal>
499
500 <ConfirmationModal
501 variant="warning"
502 visible={showPersistentBranchDeleteConfirmationModal}
503 confirmLabel={'Switch to preview'}
504 title="Branch must be switched to preview before deletion"
505 loading={isUpdatingBranch}
506 onCancel={() => setShowPersistentBranchDeleteConfirmationModal(false)}
507 onConfirm={onTogglePersistent}
508 >
509 <p className="text-sm text-foreground-light">
510 You must switch the branch "{branch.name}" to preview before deleting it.
511 </p>
512 </ConfirmationModal>
513
514 <EditBranchModal
515 branch={branch}
516 visible={showEditBranchModal}
517 onClose={() => setShowEditBranchModal(false)}
518 />
519 </>
520 )
521}
522
523// Actions for main (production) branch
524const MainBranchActions = ({ branch, repo }: { branch: Branch; repo: string }) => {
525 const { ref: projectRef } = useParams()
526 const { can: canUpdateBranches } = useAsyncCheckPermissions(
527 PermissionAction.UPDATE,
528 'preview_branches'
529 )
530 const [showEditBranchModal, setShowEditBranchModal] = useState(false)
531
532 return (
533 <>
534 <DropdownMenu>
535 <DropdownMenuTrigger asChild>
536 <Button type="text" icon={<MoreVertical />} className="px-1" />
537 </DropdownMenuTrigger>
538 <DropdownMenuContent className="w-56" side="bottom" align="end">
539 {repo ? (
540 <Link passHref href={`/project/${projectRef}/settings/integrations`}>
541 <DropdownMenuItem asChild className="gap-x-2">
542 <a>Change production branch</a>
543 </DropdownMenuItem>
544 </Link>
545 ) : (
546 <DropdownMenuItem
547 className="gap-x-2"
548 disabled={!canUpdateBranches}
549 onSelect={() => setShowEditBranchModal(true)}
550 onClick={() => setShowEditBranchModal(true)}
551 >
552 <Pencil size={14} /> Edit Branch
553 </DropdownMenuItem>
554 )}
555 </DropdownMenuContent>
556 </DropdownMenu>
557
558 <EditBranchModal
559 branch={branch}
560 visible={showEditBranchModal}
561 onClose={() => setShowEditBranchModal(false)}
562 />
563 </>
564 )
565}