ResumeProjectButton.tsx263 lines · main
1import { zodResolver } from '@hookform/resolvers/zod'
2import { PermissionAction } from '@supabase/shared-types/out/constants'
3import { useFlag, useParams } from 'common'
4import { useRouter } from 'next/router'
5import { useMemo, useState, type ComponentPropsWithoutRef } from 'react'
6import { useForm } from 'react-hook-form'
7import { AWS_REGIONS, CloudProvider } from 'shared-data'
8import { toast } from 'sonner'
9import {
10 Button,
11 cn,
12 Dialog,
13 DialogContent,
14 DialogFooter,
15 DialogHeader,
16 DialogSection,
17 DialogTitle,
18 Form,
19 FormField,
20} from 'ui'
21import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
22import { z } from 'zod'
23
24import {
25 extractPostgresVersionDetails,
26 PostgresVersionSelector,
27} from '@/components/interfaces/ProjectCreation/PostgresVersionSelector'
28import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
29import { useFreeProjectLimitCheckQuery } from '@/data/organizations/free-project-limit-check-query'
30import { useSetProjectStatus } from '@/data/projects/project-detail-query'
31import { useProjectPauseStatusQuery } from '@/data/projects/project-pause-status-query'
32import { useProjectRestoreMutation } from '@/data/projects/project-restore-mutation'
33import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
34import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
35import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
36import { PROJECT_STATUS } from '@/lib/constants'
37
38const FormSchema = z.object({
39 postgresVersionSelection: z.string(),
40})
41
42type ResumeProjectButtonProps = Pick<
43 ComponentPropsWithoutRef<typeof ButtonTooltip>,
44 'className' | 'size' | 'type'
45> & {
46 label?: string
47}
48
49export 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}