mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
feat(studio): setup soft-delete and easy branch restore (#40353)
* wip: branch deletion grace period * fix: allow to force delete pending * fix: update for staging * fix: typo * fix: revert soft-delete by default * chore: revert branch-delete-mutation changes * fix: revert platform.d.ts changes * fix: use INVALID status as distinguisher * fix: hide unusable options * fix: apply review suggestion Co-authored-by: Ali Waseem <waseema393@gmail.com> * updated formatting * fix: lint * fix: react v5 update --------- Co-authored-by: Ali Waseem <waseema393@gmail.com>
This commit is contained in:
1 parent
2fbb6cb9a7
commit
38176b82a3
5 files changed
+212
-68
No files matched your search
@@ -81,6 +81,10 @@ export const BranchRow = ({
|
||||
const page = router.pathname.split('/').pop()
|
||||
|
||||
const daysFromNow = dayjs().diff(dayjs(branch.updated_at), 'day')
|
||||
const willBeDeletedIn = branch.deletion_scheduled_at
|
||||
? dayjs(branch.deletion_scheduled_at).diff(dayjs(), 'minutes')
|
||||
: null
|
||||
const isDeletionPending = willBeDeletedIn !== null && willBeDeletedIn < 0
|
||||
const formattedTimeFromNow = dayjs(branch.updated_at).fromNow()
|
||||
const formattedUpdatedAt = dayjs(branch.updated_at).format('DD MMM YYYY, HH:mm:ss (ZZ)')
|
||||
|
||||
@@ -125,9 +129,19 @@ export const BranchRow = ({
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-4">
|
||||
<p className="text-xs text-foreground-lighter">
|
||||
{daysFromNow > 1 ? `Updated on ${formattedUpdatedAt}` : `Updated ${formattedTimeFromNow}`}
|
||||
</p>
|
||||
{branch.deletion_scheduled_at ? (
|
||||
<p className="text-xs text-foreground-lighter">
|
||||
{isDeletionPending
|
||||
? 'Deletion pending...'
|
||||
: `Will be deleted in ${willBeDeletedIn} minutes`}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-foreground-lighter">
|
||||
{daysFromNow > 1
|
||||
? `Updated on ${formattedUpdatedAt}`
|
||||
: `Updated ${formattedTimeFromNow}`}
|
||||
</p>
|
||||
)}
|
||||
<WorkflowLogs projectRef={branch.project_ref} status={branch.status} />
|
||||
{rowActions}
|
||||
</div>
|
||||
|
||||
@@ -39,6 +39,7 @@ import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
import { BranchLoader, BranchManagementSection, BranchRow, BranchRowLoader } from './BranchPanels'
|
||||
import { EditBranchModal } from './EditBranchModal'
|
||||
import { PreviewBranchesEmptyState } from './EmptyStates'
|
||||
import { useBranchRestoreMutation } from 'data/branches/branch-restore-mutation'
|
||||
|
||||
interface OverviewProps {
|
||||
isGithubConnected: boolean
|
||||
@@ -63,8 +64,12 @@ export const Overview = ({
|
||||
onSelectDeleteBranch,
|
||||
generateCreatePullRequestURL,
|
||||
}: OverviewProps) => {
|
||||
const [persistentBranches, ephemeralBranches] = partition(
|
||||
const [scheduledForDeletionBranches, aliveBranches] = partition(
|
||||
previewBranches,
|
||||
(branch) => branch.deletion_scheduled_at !== undefined
|
||||
)
|
||||
const [persistentBranches, ephemeralBranches] = partition(
|
||||
aliveBranches,
|
||||
(branch) => branch.persistent
|
||||
)
|
||||
const { ref: projectRef } = useParams()
|
||||
@@ -186,6 +191,35 @@ export const Overview = ({
|
||||
)
|
||||
})}
|
||||
</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 justify-center w-full py-10">
|
||||
<p>No scheduled for deletion branches</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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -213,13 +247,14 @@ const PreviewBranchActions = ({
|
||||
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, isLoading: isLoadingEntitlement } =
|
||||
useCheckEntitlements('branching_persistent')
|
||||
const { hasAccess: hasAccessToPersistentBranching } = useCheckEntitlements('branching_persistent')
|
||||
|
||||
const [showConfirmResetModal, setShowConfirmResetModal] = useState(false)
|
||||
const [showBranchModeSwitch, setShowBranchModeSwitch] = useState(false)
|
||||
@@ -245,6 +280,16 @@ const PreviewBranchActions = ({
|
||||
}
|
||||
},
|
||||
})
|
||||
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 })
|
||||
@@ -275,64 +320,66 @@ const PreviewBranchActions = ({
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56" side="bottom" align="end">
|
||||
<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>
|
||||
|
||||
<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'
|
||||
{!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,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{branch.persistent ? (
|
||||
<>
|
||||
<Clock size={14} /> Switch to preview
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Infinity size={14} className="scale-110" /> Switch to persistent
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuItemTooltip>
|
||||
|
||||
},
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
{/* Edit Branch (gitless) */}
|
||||
{gitlessBranching && (
|
||||
<DropdownMenuItemTooltip
|
||||
@@ -391,6 +438,32 @@ const PreviewBranchActions = ({
|
||||
</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>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
} from 'ui'
|
||||
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
import { sanitizeRoute } from './ProjectDropdown'
|
||||
import { partition } from 'lodash'
|
||||
|
||||
const BranchLink = ({
|
||||
branch,
|
||||
@@ -101,7 +102,7 @@ export const BranchDropdown = () => {
|
||||
const mainBranch = branches?.find((branch) => branch.is_default)
|
||||
const restOfBranches = branches
|
||||
?.filter((branch) => !branch.is_default)
|
||||
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
||||
?.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
||||
|
||||
const sortedBranches =
|
||||
branches && branches.length > 0
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { type UseMutationOptions, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { handleError, post } from 'data/fetchers'
|
||||
import { toast } from 'sonner'
|
||||
import type { ResponseError } from 'types'
|
||||
import { branchKeys } from './keys'
|
||||
|
||||
export type BranchRestoreVariables = {
|
||||
branchRef: string
|
||||
projectRef: string
|
||||
}
|
||||
|
||||
export async function restoreBranch({ branchRef }: BranchRestoreVariables) {
|
||||
const { data, error } = await post('/v1/branches/{branch_id_or_ref}/restore', {
|
||||
params: {
|
||||
path: { branch_id_or_ref: branchRef },
|
||||
},
|
||||
})
|
||||
|
||||
if (error) handleError(error)
|
||||
return data
|
||||
}
|
||||
|
||||
type BranchRestoreData = Awaited<ReturnType<typeof restoreBranch>>
|
||||
|
||||
export const useBranchRestoreMutation = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
...options
|
||||
}: Omit<
|
||||
UseMutationOptions<BranchRestoreData, ResponseError, BranchRestoreVariables>,
|
||||
'mutationFn'
|
||||
> = {}) => {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation<BranchRestoreData, ResponseError, BranchRestoreVariables>({
|
||||
mutationFn: (vars) => restoreBranch(vars),
|
||||
async onSuccess(data, variables, context) {
|
||||
const { projectRef } = variables
|
||||
await queryClient.invalidateQueries({ queryKey: branchKeys.list(projectRef) })
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
async onError(data, variables, context) {
|
||||
if (onError === undefined) {
|
||||
toast.error(`Failed to restore branch: ${data.message}`)
|
||||
} else {
|
||||
onError(data, variables, context)
|
||||
}
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
@@ -95,8 +95,12 @@ const BranchesPage: NextPageWithLayout = () => {
|
||||
}
|
||||
|
||||
const onConfirmDeleteBranch = () => {
|
||||
if (selectedBranchToDelete == undefined) return console.error('No branch selected')
|
||||
const { project_ref: branchRef, parent_project_ref: projectRef } = selectedBranchToDelete
|
||||
if (selectedBranchToDelete === undefined) return console.error('No branch selected')
|
||||
const {
|
||||
project_ref: branchRef,
|
||||
parent_project_ref: projectRef,
|
||||
persistent,
|
||||
} = selectedBranchToDelete
|
||||
deleteBranch(
|
||||
{ branchRef, projectRef },
|
||||
{
|
||||
@@ -108,7 +112,7 @@ const BranchesPage: NextPageWithLayout = () => {
|
||||
sendEvent({
|
||||
action: 'branch_delete_button_clicked',
|
||||
properties: {
|
||||
branchType: selectedBranchToDelete.persistent ? 'persistent' : 'preview',
|
||||
branchType: persistent ? 'persistent' : 'preview',
|
||||
origin: 'branches_page',
|
||||
},
|
||||
groups: {
|
||||
@@ -175,7 +179,9 @@ const BranchesPage: NextPageWithLayout = () => {
|
||||
confirmLabel="Delete branch"
|
||||
confirmPlaceholder="Type in name of branch"
|
||||
confirmString={selectedBranchToDelete?.name ?? ''}
|
||||
alert={{ title: 'You cannot recover this branch once deleted' }}
|
||||
alert={{
|
||||
title: 'You cannot recover this branch once deleted',
|
||||
}}
|
||||
text={
|
||||
<>
|
||||
This will delete your database preview branch{' '}
|
||||
|
||||
Reference in new issue
Block a user