ResumeProjectButton.tsx263 lines · main
| 1 | import { zodResolver } from '@hookform/resolvers/zod' |
| 2 | import { PermissionAction } from '@supabase/shared-types/out/constants' |
| 3 | import { useFlag, useParams } from 'common' |
| 4 | import { useRouter } from 'next/router' |
| 5 | import { useMemo, useState, type ComponentPropsWithoutRef } from 'react' |
| 6 | import { useForm } from 'react-hook-form' |
| 7 | import { AWS_REGIONS, CloudProvider } from 'shared-data' |
| 8 | import { toast } from 'sonner' |
| 9 | import { |
| 10 | Button, |
| 11 | cn, |
| 12 | Dialog, |
| 13 | DialogContent, |
| 14 | DialogFooter, |
| 15 | DialogHeader, |
| 16 | DialogSection, |
| 17 | DialogTitle, |
| 18 | Form, |
| 19 | FormField, |
| 20 | } from 'ui' |
| 21 | import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' |
| 22 | import { z } from 'zod' |
| 23 | |
| 24 | import { |
| 25 | extractPostgresVersionDetails, |
| 26 | PostgresVersionSelector, |
| 27 | } from '@/components/interfaces/ProjectCreation/PostgresVersionSelector' |
| 28 | import { ButtonTooltip } from '@/components/ui/ButtonTooltip' |
| 29 | import { useFreeProjectLimitCheckQuery } from '@/data/organizations/free-project-limit-check-query' |
| 30 | import { useSetProjectStatus } from '@/data/projects/project-detail-query' |
| 31 | import { useProjectPauseStatusQuery } from '@/data/projects/project-pause-status-query' |
| 32 | import { useProjectRestoreMutation } from '@/data/projects/project-restore-mutation' |
| 33 | import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' |
| 34 | import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' |
| 35 | import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' |
| 36 | import { PROJECT_STATUS } from '@/lib/constants' |
| 37 | |
| 38 | const FormSchema = z.object({ |
| 39 | postgresVersionSelection: z.string(), |
| 40 | }) |
| 41 | |
| 42 | type ResumeProjectButtonProps = Pick< |
| 43 | ComponentPropsWithoutRef<typeof ButtonTooltip>, |
| 44 | 'className' | 'size' | 'type' |
| 45 | > & { |
| 46 | label?: string |
| 47 | } |
| 48 | |
| 49 | export const ResumeProjectButton = ({ |
| 50 | className, |
| 51 | label = 'Resume project', |
| 52 | size, |
| 53 | type = 'default', |
| 54 | }: ResumeProjectButtonProps) => { |
| 55 | const router = useRouter() |
| 56 | const { ref } = useParams() |
| 57 | const { data: project } = useSelectedProjectQuery() |
| 58 | const { data: selectedOrganization } = useSelectedOrganizationQuery() |
| 59 | const { setProjectStatus } = useSetProjectStatus() |
| 60 | |
| 61 | const newProjectInternalOnlyConfiguration = useFlag('newProjectInternalOnlyConfiguration') |
| 62 | const region = Object.values(AWS_REGIONS).find((x) => x.code === project?.region) |
| 63 | const orgSlug = selectedOrganization?.slug |
| 64 | const isFreePlan = selectedOrganization?.plan?.id === 'free' |
| 65 | |
| 66 | const { |
| 67 | data: pauseStatus, |
| 68 | isPending: isPauseStatusPending, |
| 69 | isSuccess: isPauseStatusSuccess, |
| 70 | } = useProjectPauseStatusQuery({ ref }, { enabled: project?.status === PROJECT_STATUS.INACTIVE }) |
| 71 | |
| 72 | const isRestoreDisabled = isPauseStatusSuccess && !pauseStatus.can_restore |
| 73 | |
| 74 | const { data: membersExceededLimit } = useFreeProjectLimitCheckQuery( |
| 75 | { slug: orgSlug }, |
| 76 | { enabled: isFreePlan } |
| 77 | ) |
| 78 | |
| 79 | const hasMembersExceedingFreeTierLimit = (membersExceededLimit ?? []).length > 0 |
| 80 | |
| 81 | const [showConfirmRestore, setShowConfirmRestore] = useState(false) |
| 82 | const [showFreeProjectLimitWarning, setShowFreeProjectLimitWarning] = useState(false) |
| 83 | |
| 84 | const { can: canResumeProject } = useAsyncCheckPermissions( |
| 85 | PermissionAction.INFRA_EXECUTE, |
| 86 | 'queue_jobs.projects.initialize_or_resume' |
| 87 | ) |
| 88 | |
| 89 | const { mutate: restoreProject, isPending: isRestoring } = useProjectRestoreMutation({ |
| 90 | onSuccess: async (_, variables) => { |
| 91 | setProjectStatus({ ref: variables.ref, status: PROJECT_STATUS.RESTORING }) |
| 92 | toast.success('Restoring project, project will be ready in a few minutes') |
| 93 | await router.push(`/project/${variables.ref}`) |
| 94 | }, |
| 95 | }) |
| 96 | |
| 97 | const form = useForm<z.infer<typeof FormSchema>>({ |
| 98 | resolver: zodResolver(FormSchema as any), |
| 99 | mode: 'onChange', |
| 100 | defaultValues: { postgresVersionSelection: '' }, |
| 101 | }) |
| 102 | |
| 103 | const onSelectRestore = () => { |
| 104 | if (project?.status !== PROJECT_STATUS.INACTIVE) { |
| 105 | return toast.error('Unable to resume: project is not paused') |
| 106 | } |
| 107 | |
| 108 | if (isRestoreDisabled) { |
| 109 | return toast.error('This project can no longer be resumed from the dashboard') |
| 110 | } |
| 111 | |
| 112 | if (!canResumeProject) { |
| 113 | return toast.error('You do not have the required permissions to restore this project') |
| 114 | } |
| 115 | |
| 116 | if (hasMembersExceedingFreeTierLimit) { |
| 117 | return setShowFreeProjectLimitWarning(true) |
| 118 | } |
| 119 | |
| 120 | setShowConfirmRestore(true) |
| 121 | } |
| 122 | |
| 123 | const onConfirmRestore = async (values: z.infer<typeof FormSchema>) => { |
| 124 | if (!project) { |
| 125 | return toast.error('Unable to restore: project is required') |
| 126 | } |
| 127 | |
| 128 | if (!newProjectInternalOnlyConfiguration) { |
| 129 | return restoreProject({ ref: project.ref }) |
| 130 | } |
| 131 | |
| 132 | const postgresVersionDetails = extractPostgresVersionDetails(values.postgresVersionSelection) |
| 133 | |
| 134 | restoreProject({ |
| 135 | ref: project.ref, |
| 136 | releaseChannel: postgresVersionDetails.releaseChannel, |
| 137 | postgresEngine: postgresVersionDetails.postgresEngine, |
| 138 | }) |
| 139 | } |
| 140 | |
| 141 | const buttonDisabled = |
| 142 | project?.status !== PROJECT_STATUS.INACTIVE || |
| 143 | project === undefined || |
| 144 | isPauseStatusPending || |
| 145 | isRestoring || |
| 146 | isRestoreDisabled || |
| 147 | !canResumeProject |
| 148 | |
| 149 | const tooltipText = useMemo(() => { |
| 150 | if (isPauseStatusPending) return 'Checking whether this project can be resumed' |
| 151 | if (project?.status !== PROJECT_STATUS.INACTIVE) { |
| 152 | return 'Project must be paused before it can be resumed' |
| 153 | } |
| 154 | if (isRestoreDisabled) return 'This project can no longer be resumed from the dashboard' |
| 155 | if (!canResumeProject) return 'You need additional permissions to resume this project' |
| 156 | return undefined |
| 157 | }, [canResumeProject, isPauseStatusPending, isRestoreDisabled, project?.status]) |
| 158 | |
| 159 | return ( |
| 160 | <> |
| 161 | <ButtonTooltip |
| 162 | className={className} |
| 163 | size={size} |
| 164 | type={type} |
| 165 | disabled={buttonDisabled} |
| 166 | loading={isRestoring} |
| 167 | onClick={onSelectRestore} |
| 168 | tooltip={{ |
| 169 | content: { |
| 170 | side: 'bottom', |
| 171 | text: tooltipText, |
| 172 | }, |
| 173 | }} |
| 174 | > |
| 175 | {label} |
| 176 | </ButtonTooltip> |
| 177 | |
| 178 | <ConfirmationModal |
| 179 | visible={showConfirmRestore} |
| 180 | size="small" |
| 181 | title="Resume this project" |
| 182 | onCancel={() => setShowConfirmRestore(false)} |
| 183 | onConfirm={() => form.handleSubmit(onConfirmRestore)()} |
| 184 | loading={isRestoring} |
| 185 | confirmLabel="Resume" |
| 186 | confirmLabelLoading="Resuming" |
| 187 | cancelLabel="Cancel" |
| 188 | > |
| 189 | <div className={cn(newProjectInternalOnlyConfiguration && 'flex flex-col gap-y-4')}> |
| 190 | <p className="text-sm"> |
| 191 | {isFreePlan |
| 192 | ? 'Your project’s data will be restored to when it was initially paused.' |
| 193 | : 'Your project’s data will be restored and billing will resume based on compute size and hours active.'} |
| 194 | </p> |
| 195 | <Form {...form}> |
| 196 | <form onSubmit={form.handleSubmit(onConfirmRestore)}> |
| 197 | {newProjectInternalOnlyConfiguration && ( |
| 198 | <div className="space-y-2"> |
| 199 | <FormField |
| 200 | control={form.control} |
| 201 | name="postgresVersionSelection" |
| 202 | render={({ field }) => ( |
| 203 | <PostgresVersionSelector |
| 204 | field={field} |
| 205 | form={form} |
| 206 | type="unpause" |
| 207 | label="Postgres version" |
| 208 | layout="vertical" |
| 209 | dbRegion={region?.displayName ?? ''} |
| 210 | cloudProvider={(project?.cloud_provider ?? 'AWS') as CloudProvider} |
| 211 | organizationSlug={selectedOrganization?.slug} |
| 212 | /> |
| 213 | )} |
| 214 | /> |
| 215 | </div> |
| 216 | )} |
| 217 | </form> |
| 218 | </Form> |
| 219 | </div> |
| 220 | </ConfirmationModal> |
| 221 | |
| 222 | <Dialog |
| 223 | open={showFreeProjectLimitWarning} |
| 224 | onOpenChange={() => setShowFreeProjectLimitWarning(false)} |
| 225 | > |
| 226 | <DialogContent size="medium" className="gap-0 pb-0"> |
| 227 | <DialogHeader className="border-b"> |
| 228 | <DialogTitle className="leading-normal"> |
| 229 | Your organization has members who have exceeded their free project limits |
| 230 | </DialogTitle> |
| 231 | </DialogHeader> |
| 232 | <DialogSection className="text-sm"> |
| 233 | <p className="text-foreground-light"> |
| 234 | The following members have reached their maximum limits for the number of active free |
| 235 | plan projects within organizations where they are an administrator or owner: |
| 236 | </p> |
| 237 | <ul className="my-4 list-disc list-inside"> |
| 238 | {(membersExceededLimit ?? []).map((member, idx: number) => ( |
| 239 | <li key={`member-${idx}`}> |
| 240 | {member.username || member.primary_email} (Limit: {member.free_project_limit} free |
| 241 | projects) |
| 242 | </li> |
| 243 | ))} |
| 244 | </ul> |
| 245 | <p className="text-foreground-light"> |
| 246 | These members will need to either delete, pause, or upgrade one or more of these |
| 247 | projects before you're able to resume this project. |
| 248 | </p> |
| 249 | </DialogSection> |
| 250 | <DialogFooter> |
| 251 | <Button |
| 252 | htmlType="button" |
| 253 | type="default" |
| 254 | onClick={() => setShowFreeProjectLimitWarning(false)} |
| 255 | > |
| 256 | Understood |
| 257 | </Button> |
| 258 | </DialogFooter> |
| 259 | </DialogContent> |
| 260 | </Dialog> |
| 261 | </> |
| 262 | ) |
| 263 | } |