Files
supabase/apps/studio/components/interfaces/Project/ResumeProjectButton.tsx
Danny White 2d92563b57 fix(studio): add resume project flow to project settings (#45078)
## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Bug fix. Resolves DEPR-511.

## What is the current behavior?

Paused projects in `Project Settings > General > Project availability`
still present restart/pause maintenance controls, but no resume
affordance. That makes the resume path hard to discover from Settings
and pushes users back to the project dashboard to find the correct
action.

The paused state also keeps showing a redundant disabled `Pause project`
row, and the pause confirmation uses a more flexible modal than this
flow needs.

DEPR-519 already covered the unhealthy-project restart guard, but not
this paused-project discoverability path.

## What is the new behavior?

Project Settings is now paused-project aware. It shows a shared `Resume
project` action when the project can still be restored, falls back to
the project dashboard when the restore window has expired or pause
status cannot be confirmed, and reuses the same resume flow, permission
checks, and free-tier guardrails as the paused dashboard.

While a project is already paused, the redundant `Pause project` row is
hidden so the section stays focused on the real next action. For active
projects, the pause row remains in place, including the useful disabled
tooltip states for plans that cannot pause.

The pause confirmation now uses `AlertDialog` with shorter, more
accurate copy about the restore window, and the restart controls now
behave more consistently on smaller breakpoints. The Project Settings
command-menu entry is also searchable via `resume project`.

| Before | After |
| --- | --- |
| <img width="1602" height="566" alt="CleanShot 2026-04-24 at 18 05
25@2x"
src="https://github.com/user-attachments/assets/bd8f4095-0360-443c-a179-185da69eb9e8"
/> | <img width="1538" height="408" alt="CleanShot 2026-04-24 at 18 06
12@2x"
src="https://github.com/user-attachments/assets/7ac26529-4b54-460e-89c3-927891d873d8"
/> |
| <img width="1524" height="524" alt="CleanShot 2026-04-24 at 18 08
53@2x"
src="https://github.com/user-attachments/assets/f3c49c46-b389-4324-b982-f557b159623e"
/> | <img width="1528" height="550" alt="CleanShot 2026-04-24 at 18 08
30@2x"
src="https://github.com/user-attachments/assets/4021e2bb-f22f-40db-be43-de6d0fb571b3"
/> |
| <img width="896" height="558" alt="CleanShot 2026-04-24 at 17 41
40@2x"
src="https://github.com/user-attachments/assets/31569aec-89a6-4984-8011-39d8b102c90f"
/> | <img width="912" height="502" alt="CleanShot 2026-04-24 at 18 10
34@2x"
src="https://github.com/user-attachments/assets/f19dcd27-12e6-4a2f-8eed-ca709e77dfa1"
/> |

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added a tooltip-enabled "Resume project" button that handles
permissions, free-plan member gating, optional Postgres version
selection, and navigates to the project after restore.
* **UX**
* Pause confirmation migrated to an alert-style dialog with updated copy
and disabled controls during pausing.
* Restart controls updated for improved responsive layout and refreshed
button visuals.
* Project settings now show appropriate resume/dashboard actions based
on pause/restore eligibility.
* **Tests**
* Added tests for active, resumable-paused, and non-resumable-paused
states.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
2026-04-28 09:30:54 +00:00

264 lines
9.1 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { zodResolver } from '@hookform/resolvers/zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useFlag, useParams } from 'common'
import { useRouter } from 'next/router'
import { useMemo, useState, type ComponentPropsWithoutRef } from 'react'
import { useForm } from 'react-hook-form'
import { AWS_REGIONS, CloudProvider } from 'shared-data'
import { toast } from 'sonner'
import {
Button,
cn,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogSection,
DialogTitle,
Form,
FormField,
} from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { z } from 'zod'
import {
extractPostgresVersionDetails,
PostgresVersionSelector,
} from '@/components/interfaces/ProjectCreation/PostgresVersionSelector'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import { useFreeProjectLimitCheckQuery } from '@/data/organizations/free-project-limit-check-query'
import { useSetProjectStatus } from '@/data/projects/project-detail-query'
import { useProjectPauseStatusQuery } from '@/data/projects/project-pause-status-query'
import { useProjectRestoreMutation } from '@/data/projects/project-restore-mutation'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { PROJECT_STATUS } from '@/lib/constants'
const FormSchema = z.object({
postgresVersionSelection: z.string(),
})
type ResumeProjectButtonProps = Pick<
ComponentPropsWithoutRef<typeof ButtonTooltip>,
'className' | 'size' | 'type'
> & {
label?: string
}
export const ResumeProjectButton = ({
className,
label = 'Resume project',
size,
type = 'default',
}: ResumeProjectButtonProps) => {
const router = useRouter()
const { ref } = useParams()
const { data: project } = useSelectedProjectQuery()
const { data: selectedOrganization } = useSelectedOrganizationQuery()
const { setProjectStatus } = useSetProjectStatus()
const showPostgresVersionSelector = useFlag('showPostgresVersionSelector')
const region = Object.values(AWS_REGIONS).find((x) => x.code === project?.region)
const orgSlug = selectedOrganization?.slug
const isFreePlan = selectedOrganization?.plan?.id === 'free'
const {
data: pauseStatus,
isPending: isPauseStatusPending,
isSuccess: isPauseStatusSuccess,
} = useProjectPauseStatusQuery({ ref }, { enabled: project?.status === PROJECT_STATUS.INACTIVE })
const isRestoreDisabled = isPauseStatusSuccess && !pauseStatus.can_restore
const { data: membersExceededLimit } = useFreeProjectLimitCheckQuery(
{ slug: orgSlug },
{ enabled: isFreePlan }
)
const hasMembersExceedingFreeTierLimit = (membersExceededLimit ?? []).length > 0
const [showConfirmRestore, setShowConfirmRestore] = useState(false)
const [showFreeProjectLimitWarning, setShowFreeProjectLimitWarning] = useState(false)
const { can: canResumeProject } = useAsyncCheckPermissions(
PermissionAction.INFRA_EXECUTE,
'queue_jobs.projects.initialize_or_resume'
)
const { mutate: restoreProject, isPending: isRestoring } = useProjectRestoreMutation({
onSuccess: async (_, variables) => {
setProjectStatus({ ref: variables.ref, status: PROJECT_STATUS.RESTORING })
toast.success('Restoring project, project will be ready in a few minutes')
await router.push(`/project/${variables.ref}`)
},
})
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
mode: 'onChange',
defaultValues: { postgresVersionSelection: '' },
})
const onSelectRestore = () => {
if (project?.status !== PROJECT_STATUS.INACTIVE) {
return toast.error('Unable to resume: project is not paused')
}
if (isRestoreDisabled) {
return toast.error('This project can no longer be resumed from the dashboard')
}
if (!canResumeProject) {
return toast.error('You do not have the required permissions to restore this project')
}
if (hasMembersExceedingFreeTierLimit) {
return setShowFreeProjectLimitWarning(true)
}
setShowConfirmRestore(true)
}
const onConfirmRestore = async (values: z.infer<typeof FormSchema>) => {
if (!project) {
return toast.error('Unable to restore: project is required')
}
if (!showPostgresVersionSelector) {
return restoreProject({ ref: project.ref })
}
const postgresVersionDetails = extractPostgresVersionDetails(values.postgresVersionSelection)
restoreProject({
ref: project.ref,
releaseChannel: postgresVersionDetails.releaseChannel,
postgresEngine: postgresVersionDetails.postgresEngine,
})
}
const buttonDisabled =
project?.status !== PROJECT_STATUS.INACTIVE ||
project === undefined ||
isPauseStatusPending ||
isRestoring ||
isRestoreDisabled ||
!canResumeProject
const tooltipText = useMemo(() => {
if (isPauseStatusPending) return 'Checking whether this project can be resumed'
if (project?.status !== PROJECT_STATUS.INACTIVE) {
return 'Project must be paused before it can be resumed'
}
if (isRestoreDisabled) return 'This project can no longer be resumed from the dashboard'
if (!canResumeProject) return 'You need additional permissions to resume this project'
return undefined
}, [canResumeProject, isPauseStatusPending, isRestoreDisabled, project?.status])
return (
<>
<ButtonTooltip
className={className}
size={size}
type={type}
disabled={buttonDisabled}
loading={isRestoring}
onClick={onSelectRestore}
tooltip={{
content: {
side: 'bottom',
text: tooltipText,
},
}}
>
{label}
</ButtonTooltip>
<ConfirmationModal
visible={showConfirmRestore}
size="small"
title="Resume this project"
onCancel={() => setShowConfirmRestore(false)}
onConfirm={() => form.handleSubmit(onConfirmRestore)()}
loading={isRestoring}
confirmLabel="Resume"
confirmLabelLoading="Resuming"
cancelLabel="Cancel"
>
<div className={cn(showPostgresVersionSelector && 'flex flex-col gap-y-4')}>
<p className="text-sm">
{isFreePlan
? 'Your projects data will be restored to when it was initially paused.'
: 'Your projects data will be restored and billing will resume based on compute size and hours active.'}
</p>
<Form {...form}>
<form onSubmit={form.handleSubmit(onConfirmRestore)}>
{showPostgresVersionSelector && (
<div className="space-y-2">
<FormField
control={form.control}
name="postgresVersionSelection"
render={({ field }) => (
<PostgresVersionSelector
field={field}
form={form}
type="unpause"
label="Postgres version"
layout="vertical"
dbRegion={region?.displayName ?? ''}
cloudProvider={(project?.cloud_provider ?? 'AWS') as CloudProvider}
organizationSlug={selectedOrganization?.slug}
/>
)}
/>
</div>
)}
</form>
</Form>
</div>
</ConfirmationModal>
<Dialog
open={showFreeProjectLimitWarning}
onOpenChange={() => setShowFreeProjectLimitWarning(false)}
>
<DialogContent size="medium" className="gap-0 pb-0">
<DialogHeader className="border-b">
<DialogTitle className="leading-normal">
Your organization has members who have exceeded their free project limits
</DialogTitle>
</DialogHeader>
<DialogSection className="text-sm">
<p className="text-foreground-light">
The following members have reached their maximum limits for the number of active free
plan projects within organizations where they are an administrator or owner:
</p>
<ul className="my-4 list-disc list-inside">
{(membersExceededLimit ?? []).map((member, idx: number) => (
<li key={`member-${idx}`}>
{member.username || member.primary_email} (Limit: {member.free_project_limit} free
projects)
</li>
))}
</ul>
<p className="text-foreground-light">
These members will need to either delete, pause, or upgrade one or more of these
projects before you're able to resume this project.
</p>
</DialogSection>
<DialogFooter>
<Button
htmlType="button"
type="default"
onClick={() => setShowFreeProjectLimitWarning(false)}
>
Understood
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}