mirror of
https://github.com/supabase/supabase.git
synced 2026-05-09 10:19:50 -04:00
be0d05fb08
## Context Have Branching 2.0 as the default behaviour + remove it from feature preview Behaviour should match staging / prod if branching 2.0 feature preview is toggled on ## To test - [ ] Test branching flow in general for any oddities <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Chores** * Removed the Branching 2.0 preview and cleared its persisted preview setting; branching UI and branch editing are now available without opt‑in. * Simplified branch management flows and empty states by removing preview-dependent conditions and tooltips. * Made GitHub branch sync optional in create/edit forms and simplified validation and submit behavior. * "Create merge request" and related branch actions now render consistently across the UI. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
566 lines
20 KiB
TypeScript
566 lines
20 KiB
TypeScript
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
|
import { useQueryClient } from '@tanstack/react-query'
|
|
import { useParams } from 'common'
|
|
import { partition } from 'lodash'
|
|
import {
|
|
Clock,
|
|
ExternalLink,
|
|
Infinity,
|
|
MoreVertical,
|
|
Pencil,
|
|
RefreshCw,
|
|
Shield,
|
|
Trash2,
|
|
} from 'lucide-react'
|
|
import Link from 'next/link'
|
|
import { useState } from 'react'
|
|
import { toast } from 'sonner'
|
|
import {
|
|
Button,
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from 'ui'
|
|
import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal'
|
|
|
|
import { BranchLoader, BranchManagementSection, BranchRow, BranchRowLoader } from './BranchPanels'
|
|
import { EditBranchModal } from './EditBranchModal'
|
|
import { PreviewBranchesEmptyState } from './EmptyStates'
|
|
import { DropdownMenuItemTooltip } from '@/components/ui/DropdownMenuItemTooltip'
|
|
import { TextConfirmModal } from '@/components/ui/TextConfirmModalWrapper'
|
|
import { useBranchQuery } from '@/data/branches/branch-query'
|
|
import { useBranchResetMutation } from '@/data/branches/branch-reset-mutation'
|
|
import { useBranchRestoreMutation } from '@/data/branches/branch-restore-mutation'
|
|
import { useBranchUpdateMutation } from '@/data/branches/branch-update-mutation'
|
|
import type { Branch } from '@/data/branches/branches-query'
|
|
import { branchKeys } from '@/data/branches/keys'
|
|
import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
|
|
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
|
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
|
|
import { IS_PLATFORM } from '@/lib/constants'
|
|
|
|
interface OverviewProps {
|
|
isGithubConnected: boolean
|
|
isLoading: boolean
|
|
isSuccess: boolean
|
|
repo: string
|
|
mainBranch: Branch
|
|
previewBranches: Branch[]
|
|
onSelectCreateBranch: () => void
|
|
onSelectDeleteBranch: (branch: Branch) => void
|
|
generateCreatePullRequestURL: (branchName?: string) => string
|
|
}
|
|
|
|
export const Overview = ({
|
|
isGithubConnected,
|
|
isLoading,
|
|
isSuccess,
|
|
repo,
|
|
mainBranch,
|
|
previewBranches,
|
|
onSelectCreateBranch,
|
|
onSelectDeleteBranch,
|
|
generateCreatePullRequestURL,
|
|
}: OverviewProps) => {
|
|
const [scheduledForDeletionBranches, aliveBranches] = partition(
|
|
previewBranches,
|
|
(branch) => branch.deletion_scheduled_at !== undefined
|
|
)
|
|
const [persistentBranches, ephemeralBranches] = partition(
|
|
aliveBranches,
|
|
(branch) => branch.persistent
|
|
)
|
|
const { ref: projectRef } = useParams()
|
|
const { data: selectedOrg } = useSelectedOrganizationQuery()
|
|
|
|
const { hasAccess: hasAccessToPersistentBranching, isLoading: isLoadingEntitlement } =
|
|
useCheckEntitlements('branching_persistent')
|
|
|
|
return (
|
|
<>
|
|
<BranchManagementSection header="Production branch">
|
|
{isLoading && <BranchRowLoader />}
|
|
{isSuccess && mainBranch !== undefined && (
|
|
<BranchRow
|
|
branch={mainBranch}
|
|
isGithubConnected={isGithubConnected}
|
|
label={
|
|
<div className="flex items-center gap-x-2">
|
|
<Shield size={14} strokeWidth={1.5} className="text-warning" />
|
|
{mainBranch.name}
|
|
</div>
|
|
}
|
|
repo={repo}
|
|
rowActions={<MainBranchActions branch={mainBranch} repo={repo} />}
|
|
/>
|
|
)}
|
|
{isSuccess && mainBranch === undefined && (
|
|
<div className="w-full flex items-center justify-between px-4 py-2.5 hover:bg-surface-100">
|
|
<Link href={`/project/${projectRef}`} className="text-foreground block w-full">
|
|
<div className="flex items-center gap-x-3">
|
|
<Shield size={14} strokeWidth={1.5} className="text-warning" />
|
|
main
|
|
</div>
|
|
</Link>
|
|
</div>
|
|
)}
|
|
</BranchManagementSection>
|
|
|
|
{/* Persistent Branches Section */}
|
|
<BranchManagementSection header="Persistent branches">
|
|
{(isLoading || isLoadingEntitlement) && <BranchLoader />}
|
|
{isSuccess &&
|
|
!isLoadingEntitlement &&
|
|
!hasAccessToPersistentBranching &&
|
|
IS_PLATFORM &&
|
|
persistentBranches.length === 0 && (
|
|
<div className="px-6 py-10 flex items-center justify-between">
|
|
<div className="flex flex-col gap-0.5">
|
|
<p className="text-sm">Upgrade to unlock persistent branches</p>
|
|
<p className="text-sm text-foreground-lighter text-balance">
|
|
Persistent branches are long-lived, cannot be reset, and are ideal for staging
|
|
environments.
|
|
</p>
|
|
</div>
|
|
<Button type="primary" asChild>
|
|
<Link href={`/org/${selectedOrg?.slug}/billing?panel=subscriptionPlan`}>
|
|
Upgrade
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
)}
|
|
{isSuccess &&
|
|
!isLoadingEntitlement &&
|
|
hasAccessToPersistentBranching &&
|
|
persistentBranches.length === 0 && (
|
|
<div className="flex items-center flex-col gap-0.5 justify-center w-full py-10">
|
|
<p>No persistent branches</p>
|
|
<p className="text-foreground-lighter text-center text-balance">
|
|
Persistent branches are long-lived, cannot be reset, and are ideal for staging
|
|
environments.
|
|
</p>
|
|
</div>
|
|
)}
|
|
{isSuccess &&
|
|
!isLoadingEntitlement &&
|
|
persistentBranches.map((branch) => {
|
|
return (
|
|
<BranchRow
|
|
isGithubConnected={isGithubConnected}
|
|
key={branch.id}
|
|
repo={repo}
|
|
branch={branch}
|
|
rowActions={
|
|
<PreviewBranchActions
|
|
branch={branch}
|
|
repo={repo}
|
|
onSelectDeleteBranch={() => onSelectDeleteBranch(branch)}
|
|
generateCreatePullRequestURL={generateCreatePullRequestURL}
|
|
/>
|
|
}
|
|
/>
|
|
)
|
|
})}
|
|
</BranchManagementSection>
|
|
|
|
{/* Ephemeral/Preview Branches Section */}
|
|
<BranchManagementSection header="Preview branches">
|
|
{isLoading && <BranchLoader />}
|
|
{isSuccess && ephemeralBranches.length === 0 && (
|
|
<PreviewBranchesEmptyState onSelectCreateBranch={onSelectCreateBranch} />
|
|
)}
|
|
{isSuccess &&
|
|
ephemeralBranches.map((branch) => {
|
|
return (
|
|
<BranchRow
|
|
isGithubConnected={isGithubConnected}
|
|
key={branch.id}
|
|
repo={repo}
|
|
branch={branch}
|
|
rowActions={
|
|
<PreviewBranchActions
|
|
branch={branch}
|
|
repo={repo}
|
|
onSelectDeleteBranch={() => onSelectDeleteBranch(branch)}
|
|
generateCreatePullRequestURL={generateCreatePullRequestURL}
|
|
/>
|
|
}
|
|
/>
|
|
)
|
|
})}
|
|
</BranchManagementSection>
|
|
{/* Scheduled for deletion branches section */}
|
|
<BranchManagementSection header="Scheduled for deletion branches">
|
|
{isLoading && <BranchLoader />}
|
|
{isSuccess && scheduledForDeletionBranches.length === 0 && (
|
|
<div className="flex items-center flex-col gap-0.5 justify-center w-full py-10">
|
|
<p className="text-foreground-lighter">No branches scheduled for deletion</p>
|
|
</div>
|
|
)}
|
|
{isSuccess &&
|
|
scheduledForDeletionBranches.map((branch) => {
|
|
return (
|
|
<BranchRow
|
|
isGithubConnected={isGithubConnected}
|
|
key={branch.id}
|
|
repo={repo}
|
|
branch={branch}
|
|
rowActions={
|
|
<PreviewBranchActions
|
|
branch={branch}
|
|
repo={repo}
|
|
// If a scheduled for deletion branch is deleted, we force the deletion
|
|
onSelectDeleteBranch={() => onSelectDeleteBranch(branch)}
|
|
generateCreatePullRequestURL={generateCreatePullRequestURL}
|
|
/>
|
|
}
|
|
/>
|
|
)
|
|
})}
|
|
</BranchManagementSection>
|
|
</>
|
|
)
|
|
}
|
|
|
|
// Row actions for preview branches (non-main)
|
|
const PreviewBranchActions = ({
|
|
branch,
|
|
onSelectDeleteBranch,
|
|
generateCreatePullRequestURL,
|
|
}: {
|
|
branch: Branch
|
|
repo: string
|
|
onSelectDeleteBranch: () => void
|
|
generateCreatePullRequestURL: (branchName?: string) => string
|
|
}) => {
|
|
const queryClient = useQueryClient()
|
|
const { project_ref: branchRef, parent_project_ref: projectRef } = branch
|
|
|
|
const { can: canDeleteBranches } = useAsyncCheckPermissions(
|
|
PermissionAction.DELETE,
|
|
'preview_branches'
|
|
)
|
|
const { can: canUpdateBranches } = useAsyncCheckPermissions(
|
|
PermissionAction.UPDATE,
|
|
'preview_branches'
|
|
)
|
|
// If user can update branches, they can restore branches
|
|
const canRestoreBranches = canUpdateBranches
|
|
|
|
const { data } = useBranchQuery({ projectRef, branchRef })
|
|
const isBranchActiveHealthy = data?.status === 'ACTIVE_HEALTHY'
|
|
const isPersistentBranch = branch.persistent
|
|
|
|
const { hasAccess: hasAccessToPersistentBranching } = useCheckEntitlements('branching_persistent')
|
|
|
|
const [showConfirmResetModal, setShowConfirmResetModal] = useState(false)
|
|
const [showBranchModeSwitch, setShowBranchModeSwitch] = useState(false)
|
|
const [
|
|
showPersistentBranchDeleteConfirmationModal,
|
|
setShowPersistentBranchDeleteConfirmationModal,
|
|
] = useState(false)
|
|
const [showEditBranchModal, setShowEditBranchModal] = useState(false)
|
|
|
|
const { mutate: resetBranch, isPending: isResetting } = useBranchResetMutation({
|
|
onSuccess() {
|
|
toast.success('Success! Please allow a few seconds for the branch to reset.')
|
|
setShowConfirmResetModal(false)
|
|
},
|
|
})
|
|
|
|
const { mutate: updateBranch, isPending: isUpdatingBranch } = useBranchUpdateMutation({
|
|
onSuccess() {
|
|
toast.success('Successfully updated branch')
|
|
setShowBranchModeSwitch(false)
|
|
if (projectRef) {
|
|
queryClient.invalidateQueries({ queryKey: branchKeys.list(projectRef) })
|
|
}
|
|
},
|
|
})
|
|
const { mutate: restoreBranch } = useBranchRestoreMutation({
|
|
onSuccess() {
|
|
toast.success('Success! Please allow a few minutes for the branch to restore.')
|
|
setShowBranchModeSwitch(false)
|
|
},
|
|
})
|
|
|
|
const onRestoreBranch = () => {
|
|
restoreBranch({ branchRef, projectRef })
|
|
}
|
|
|
|
const onConfirmReset = () => {
|
|
resetBranch({ branchRef, projectRef })
|
|
}
|
|
|
|
const onTogglePersistent = () => {
|
|
updateBranch({ branchRef, projectRef, persistent: !branch.persistent })
|
|
}
|
|
|
|
const onDeleteBranch = (e: Event | React.MouseEvent<HTMLDivElement>) => {
|
|
if (isPersistentBranch) {
|
|
setShowPersistentBranchDeleteConfirmationModal(true)
|
|
} else {
|
|
e.stopPropagation()
|
|
onSelectDeleteBranch()
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
type="text"
|
|
icon={<MoreVertical />}
|
|
className="px-1"
|
|
onClick={(e) => e.stopPropagation()}
|
|
/>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent className="w-56" side="bottom" align="end">
|
|
<DropdownMenuItemTooltip
|
|
className="gap-x-2"
|
|
disabled={!canUpdateBranches || !isBranchActiveHealthy || isUpdatingBranch}
|
|
onSelect={(e) => {
|
|
e.stopPropagation()
|
|
setShowEditBranchModal(true)
|
|
}}
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
setShowEditBranchModal(true)
|
|
}}
|
|
tooltip={{
|
|
content: {
|
|
side: 'left',
|
|
text: !canUpdateBranches
|
|
? 'You need additional permissions to edit branches'
|
|
: !isBranchActiveHealthy
|
|
? 'Branch is still initializing. Please wait for it to become healthy before editing.'
|
|
: undefined,
|
|
},
|
|
}}
|
|
>
|
|
<Pencil size={14} /> Edit branch
|
|
</DropdownMenuItemTooltip>
|
|
|
|
{!branch.deletion_scheduled_at && (
|
|
<DropdownMenuItemTooltip
|
|
className="gap-x-2"
|
|
disabled={isResetting || !isBranchActiveHealthy}
|
|
onSelect={(e) => {
|
|
e.stopPropagation()
|
|
setShowConfirmResetModal(true)
|
|
}}
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
setShowConfirmResetModal(true)
|
|
}}
|
|
tooltip={{
|
|
content: {
|
|
side: 'left',
|
|
text: !isBranchActiveHealthy
|
|
? 'Branch is still initializing. Please wait for it to become healthy before resetting.'
|
|
: undefined,
|
|
},
|
|
}}
|
|
>
|
|
<RefreshCw size={14} /> Reset branch
|
|
</DropdownMenuItemTooltip>
|
|
)}
|
|
|
|
{!branch.deletion_scheduled_at && (
|
|
<DropdownMenuItemTooltip
|
|
className="gap-x-2"
|
|
disabled={
|
|
!isBranchActiveHealthy || (!branch.persistent && !hasAccessToPersistentBranching)
|
|
}
|
|
onSelect={(e) => {
|
|
e.stopPropagation()
|
|
setShowBranchModeSwitch(true)
|
|
}}
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
setShowBranchModeSwitch(true)
|
|
}}
|
|
tooltip={{
|
|
content: {
|
|
side: 'left',
|
|
text: !isBranchActiveHealthy
|
|
? 'Branch is still initializing. Please wait for it to become healthy before switching.'
|
|
: !branch.persistent && !hasAccessToPersistentBranching
|
|
? 'Upgrade your plan to access persistent branches'
|
|
: undefined,
|
|
},
|
|
}}
|
|
>
|
|
{branch.persistent ? (
|
|
<>
|
|
<Clock size={14} /> Switch to preview
|
|
</>
|
|
) : (
|
|
<>
|
|
<Infinity size={14} className="scale-110" /> Switch to persistent
|
|
</>
|
|
)}
|
|
</DropdownMenuItemTooltip>
|
|
)}
|
|
|
|
{/* Create PR if applicable */}
|
|
{branch.git_branch && branch.pr_number === undefined && (
|
|
<DropdownMenuItem asChild className="gap-x-2">
|
|
<a
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
href={generateCreatePullRequestURL(branch.git_branch)}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<ExternalLink size={14} /> Create pull request
|
|
</a>
|
|
</DropdownMenuItem>
|
|
)}
|
|
{branch.deletion_scheduled_at && (
|
|
<DropdownMenuItemTooltip
|
|
className="gap-x-2"
|
|
disabled={!canRestoreBranches || branch.preview_project_status !== 'INACTIVE'}
|
|
onSelect={(e) => {
|
|
e.stopPropagation()
|
|
onRestoreBranch()
|
|
}}
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
onRestoreBranch()
|
|
}}
|
|
tooltip={{
|
|
content: {
|
|
side: 'left',
|
|
text: !canRestoreBranches
|
|
? 'You need additional permissions to restore branches'
|
|
: branch.preview_project_status !== 'INACTIVE'
|
|
? 'Preview project is not fully paused or already coming up. Please wait for it to become fully paused before restoring.'
|
|
: undefined,
|
|
},
|
|
}}
|
|
>
|
|
<Clock size={14} /> Restore branch
|
|
</DropdownMenuItemTooltip>
|
|
)}
|
|
|
|
<DropdownMenuSeparator />
|
|
|
|
<DropdownMenuItemTooltip
|
|
className="gap-x-2"
|
|
disabled={!canDeleteBranches}
|
|
onSelect={onDeleteBranch}
|
|
onClick={onDeleteBranch}
|
|
tooltip={{
|
|
content: {
|
|
side: 'left',
|
|
text: !canDeleteBranches
|
|
? 'You need additional permissions to delete branches'
|
|
: undefined,
|
|
},
|
|
}}
|
|
>
|
|
<Trash2 size={14} /> Delete branch
|
|
</DropdownMenuItemTooltip>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
<TextConfirmModal
|
|
variant="warning"
|
|
visible={showConfirmResetModal}
|
|
onCancel={() => setShowConfirmResetModal(false)}
|
|
onConfirm={onConfirmReset}
|
|
loading={isResetting}
|
|
title="Reset branch"
|
|
confirmLabel="Reset branch"
|
|
confirmPlaceholder="Type in name of branch"
|
|
confirmString={branch?.name ?? ''}
|
|
alert={{
|
|
title: `Are you sure you want to reset the "${branch.name}" branch? All data will be deleted.`,
|
|
}}
|
|
/>
|
|
|
|
<ConfirmationModal
|
|
variant="default"
|
|
visible={showBranchModeSwitch}
|
|
confirmLabel={branch.persistent ? 'Switch to preview' : 'Switch to persistent'}
|
|
title="Confirm branch mode switch"
|
|
loading={isUpdatingBranch}
|
|
onCancel={() => setShowBranchModeSwitch(false)}
|
|
onConfirm={onTogglePersistent}
|
|
>
|
|
<p className="text-sm text-foreground-light">
|
|
Are you sure you want to switch the branch "{branch.name}" to{' '}
|
|
{branch.persistent ? 'preview' : 'persistent'}?
|
|
</p>
|
|
</ConfirmationModal>
|
|
|
|
<ConfirmationModal
|
|
variant="warning"
|
|
visible={showPersistentBranchDeleteConfirmationModal}
|
|
confirmLabel={'Switch to preview'}
|
|
title="Branch must be switched to preview before deletion"
|
|
loading={isUpdatingBranch}
|
|
onCancel={() => setShowPersistentBranchDeleteConfirmationModal(false)}
|
|
onConfirm={onTogglePersistent}
|
|
>
|
|
<p className="text-sm text-foreground-light">
|
|
You must switch the branch "{branch.name}" to preview before deleting it.
|
|
</p>
|
|
</ConfirmationModal>
|
|
|
|
<EditBranchModal
|
|
branch={branch}
|
|
visible={showEditBranchModal}
|
|
onClose={() => setShowEditBranchModal(false)}
|
|
/>
|
|
</>
|
|
)
|
|
}
|
|
|
|
// Actions for main (production) branch
|
|
const MainBranchActions = ({ branch, repo }: { branch: Branch; repo: string }) => {
|
|
const { ref: projectRef } = useParams()
|
|
const { can: canUpdateBranches } = useAsyncCheckPermissions(
|
|
PermissionAction.UPDATE,
|
|
'preview_branches'
|
|
)
|
|
const [showEditBranchModal, setShowEditBranchModal] = useState(false)
|
|
|
|
return (
|
|
<>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button type="text" icon={<MoreVertical />} className="px-1" />
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent className="w-56" side="bottom" align="end">
|
|
{repo ? (
|
|
<Link passHref href={`/project/${projectRef}/settings/integrations`}>
|
|
<DropdownMenuItem asChild className="gap-x-2">
|
|
<a>Change production branch</a>
|
|
</DropdownMenuItem>
|
|
</Link>
|
|
) : (
|
|
<DropdownMenuItem
|
|
className="gap-x-2"
|
|
disabled={!canUpdateBranches}
|
|
onSelect={() => setShowEditBranchModal(true)}
|
|
onClick={() => setShowEditBranchModal(true)}
|
|
>
|
|
<Pencil size={14} /> Edit Branch
|
|
</DropdownMenuItem>
|
|
)}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
<EditBranchModal
|
|
branch={branch}
|
|
visible={showEditBranchModal}
|
|
onClose={() => setShowEditBranchModal(false)}
|
|
/>
|
|
</>
|
|
)
|
|
}
|