Overview.tsx 20 KB

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