DiskManagementReviewAndSubmitDialog.tsx301 lines · main
1import { PermissionAction } from '@supabase/shared-types/out/constants'
2import { ArrowRight } from 'lucide-react'
3import { UseFormReturn } from 'react-hook-form'
4import {
5 Alert,
6 AlertTitle,
7 Button,
8 ButtonProps,
9 Dialog,
10 DialogContent,
11 DialogDescription,
12 DialogFooter,
13 DialogHeader,
14 DialogSection,
15 DialogSectionSeparator,
16 DialogTitle,
17 DialogTrigger,
18 WarningIcon,
19} from 'ui'
20
21import { DiskStorageSchemaType } from '../DiskManagement.schema'
22import { DiskManagementMessage } from '../DiskManagement.types'
23import { DISK_AUTOSCALE_CONFIG_DEFAULTS } from '../ui/DiskManagement.constants'
24import {
25 BreakdownRow,
26 PriceDelta,
27 ValueChange,
28} from './DiskManagementReviewAndSubmitDialog.components'
29import { useDiskManagementReviewChanges } from './DiskManagementReviewAndSubmitDialog.hooks'
30import { TaxDisclaimer } from '@/components/interfaces/Billing/TaxDisclaimer'
31import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
32import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
33import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
34import { formatCurrency } from '@/lib/helpers'
35
36interface DiskManagementReviewAndSubmitDialogProps {
37 loading: boolean
38 form: UseFormReturn<DiskStorageSchemaType>
39 numReplicas: number
40 isDialogOpen: boolean
41 disabled?: boolean
42 setIsDialogOpen: (isOpen: boolean) => void
43 onSubmit: (values: DiskStorageSchemaType) => Promise<void>
44 buttonSize?: ButtonProps['size']
45 message?: DiskManagementMessage | null
46}
47
48export const DiskManagementReviewAndSubmitDialog = ({
49 isDialogOpen,
50 setIsDialogOpen,
51 disabled,
52 form,
53 numReplicas,
54 loading,
55 onSubmit,
56 message,
57 buttonSize = 'medium',
58}: DiskManagementReviewAndSubmitDialogProps) => {
59 const { data: project } = useSelectedProjectQuery()
60
61 const { can: canUpdateDiskConfiguration } = useAsyncCheckPermissions(
62 PermissionAction.UPDATE,
63 'projects',
64 { resource: { project_id: project?.id } }
65 )
66
67 const isDirty = !!Object.keys(form.formState.dirtyFields).length
68
69 const {
70 computeSizePrice,
71 diskSizePrice,
72 iopsPrice,
73 throughputPrice,
74 totalBeforePrice,
75 totalAfterPrice,
76 hasComputeChanges,
77 hasTotalSizeChanges,
78 hasStorageTypeChanges,
79 hasIOPSChanges,
80 hasGrowthPercentChanges,
81 hasMinIncrementChanges,
82 hasMaxSizeChanges,
83 anyBillableDiskChange,
84 anyDiskAttributeChange,
85 showThroughputRow,
86 hasAnyBreakdownRows,
87 oldComputeLabel,
88 newComputeLabel,
89 } = useDiskManagementReviewChanges(form, numReplicas)
90
91 return (
92 <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
93 <DialogTrigger asChild>
94 <ButtonTooltip
95 size={buttonSize}
96 htmlType="submit"
97 type="primary"
98 onClick={async (e) => {
99 e.preventDefault()
100 const isValid = await form.trigger()
101 if (isValid) setIsDialogOpen(true)
102 }}
103 disabled={disabled || !isDirty}
104 tooltip={{
105 content: {
106 side: 'bottom',
107 text: !canUpdateDiskConfiguration
108 ? 'You need additional permissions to update disk configuration'
109 : disabled
110 ? 'Current form values are invalid'
111 : undefined,
112 },
113 }}
114 >
115 Review changes
116 </ButtonTooltip>
117 </DialogTrigger>
118 <DialogContent className="min-w-[560px] p-0 gap-0 overflow-hidden">
119 <DialogHeader className="px-5 py-4">
120 <DialogTitle>Review changes</DialogTitle>
121 <DialogDescription>Changes will be applied shortly after confirmation.</DialogDescription>
122 </DialogHeader>
123 <DialogSectionSeparator />
124
125 {(hasComputeChanges || anyBillableDiskChange) && (
126 <>
127 <div className="relative flex border-b">
128 <div className="flex-1 flex flex-col items-center gap-2 py-6 px-4 border-r bg-linear-to-t from-[hsl(var(--background-surface-100))] to-transparent">
129 <span className="text-xs uppercase tracking-widest font-mono text-foreground-lighter">
130 Before
131 </span>
132 <span className="text-3xl text-foreground-light tabular-nums" translate="no">
133 {formatCurrency(totalBeforePrice)}
134 </span>
135 <span className="text-xs uppercase tracking-widest font-mono text-foreground-lighter">
136 per month
137 </span>
138 </div>
139
140 <div className="animate-badge-pulse absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-9 h-9 rounded-full bg-dash-sidebar border border-brand-500 flex items-center justify-center z-10 overflow-hidden">
141 <span className="absolute inset-0 bg-brand/10 rounded-full" />
142 <span className="animate-badge-shimmer pointer-events-none absolute inset-0 bg-linear-to-br from-transparent via-brand/20 to-transparent blur-md" />
143 <ArrowRight size={16} className="text-brand-600 relative z-10" strokeWidth={2.5} />
144 </div>
145
146 <div className="flex-1 flex flex-col items-center gap-2 py-6 px-4 bg-linear-to-t from-[hsl(var(--background-surface-100))] to-transparent">
147 <span className="text-xs uppercase tracking-widest font-mono text-foreground-lighter">
148 After
149 </span>
150 <span className="text-3xl text-foreground tabular-nums" translate="no">
151 {formatCurrency(totalAfterPrice)}
152 </span>
153 <span className="text-xs uppercase tracking-widest font-mono text-foreground-lighter">
154 per month
155 </span>
156 </div>
157 </div>
158 <TaxDisclaimer className="px-5 py-2 text-center border-b" />
159 </>
160 )}
161
162 {hasAnyBreakdownRows && (
163 <div className="py-0.5 px-5">
164 {hasComputeChanges && (
165 <BreakdownRow
166 label="Compute size"
167 description="Project will restart automatically on confirmation."
168 >
169 <div className="flex flex-col items-end gap-0.5">
170 <ValueChange from={oldComputeLabel} to={newComputeLabel} />
171 <PriceDelta
172 delta={Number(computeSizePrice.newPrice) - Number(computeSizePrice.oldPrice)}
173 />
174 </div>
175 </BreakdownRow>
176 )}
177 {hasStorageTypeChanges && (
178 <BreakdownRow label="Storage type">
179 <ValueChange
180 from={(form.formState.defaultValues?.storageType ?? '').toUpperCase()}
181 to={form.getValues('storageType').toUpperCase()}
182 />
183 </BreakdownRow>
184 )}
185 {(hasIOPSChanges || hasStorageTypeChanges) && (
186 <BreakdownRow
187 label="IOPS"
188 description={
189 anyDiskAttributeChange && !hasTotalSizeChanges && !hasStorageTypeChanges
190 ? 'Disk attributes, including IOPS and disk size, may only be modified 4 times in any 24-hour window, starting from the first modification.'
191 : undefined
192 }
193 >
194 <div className="flex flex-col items-end gap-0.5">
195 <ValueChange
196 from={(form.formState.defaultValues?.provisionedIOPS ?? 0).toLocaleString()}
197 to={(form.getValues('provisionedIOPS') ?? 0).toLocaleString()}
198 />
199 <PriceDelta delta={Number(iopsPrice.newPrice) - Number(iopsPrice.oldPrice)} />
200 </div>
201 </BreakdownRow>
202 )}
203 {showThroughputRow && (
204 <BreakdownRow label="Throughput">
205 <div className="flex flex-col items-end gap-0.5">
206 <ValueChange
207 from={`${(form.formState.defaultValues?.throughput ?? 0).toLocaleString()} MB/s`}
208 to={`${(form.getValues('throughput') ?? 0).toLocaleString()} MB/s`}
209 />
210 <PriceDelta
211 delta={Number(throughputPrice.newPrice) - Number(throughputPrice.oldPrice)}
212 />
213 </div>
214 </BreakdownRow>
215 )}
216 {(hasTotalSizeChanges || hasStorageTypeChanges) && (
217 <BreakdownRow
218 label="Disk size"
219 description="For 4 hours after changes you will not be able to modify disk attributes."
220 >
221 <div className="flex flex-col items-end gap-0.5">
222 <ValueChange
223 from={`${(form.formState.defaultValues?.totalSize ?? 0).toLocaleString()} GB`}
224 to={`${(form.getValues('totalSize') ?? 0).toLocaleString()} GB`}
225 />
226 <PriceDelta
227 delta={Number(diskSizePrice.newPrice) - Number(diskSizePrice.oldPrice)}
228 />
229 </div>
230 </BreakdownRow>
231 )}
232 {hasGrowthPercentChanges && (
233 <BreakdownRow label="Growth percent">
234 <ValueChange
235 from={`${form.formState.defaultValues?.growthPercent ?? DISK_AUTOSCALE_CONFIG_DEFAULTS.growthPercent}%`}
236 to={`${form.getValues('growthPercent') ?? DISK_AUTOSCALE_CONFIG_DEFAULTS.growthPercent}%`}
237 />
238 </BreakdownRow>
239 )}
240 {hasMinIncrementChanges && (
241 <BreakdownRow label="Min increment">
242 <ValueChange
243 from={`${form.formState.defaultValues?.minIncrementGb ?? DISK_AUTOSCALE_CONFIG_DEFAULTS.minIncrementSize} GB`}
244 to={`${form.getValues('minIncrementGb') ?? DISK_AUTOSCALE_CONFIG_DEFAULTS.minIncrementSize} GB`}
245 />
246 </BreakdownRow>
247 )}
248 {hasMaxSizeChanges && (
249 <BreakdownRow label="Max disk size">
250 <ValueChange
251 from={(
252 form.formState.defaultValues?.maxSizeGb ??
253 DISK_AUTOSCALE_CONFIG_DEFAULTS.maxSizeGb
254 ).toLocaleString()}
255 to={(
256 form.getValues('maxSizeGb') ?? DISK_AUTOSCALE_CONFIG_DEFAULTS.maxSizeGb
257 ).toLocaleString()}
258 />
259 </BreakdownRow>
260 )}
261 {numReplicas > 0 && (
262 <div className="py-2 text-xs text-foreground-muted">
263 Price change includes primary database and {numReplicas} replica
264 {numReplicas > 1 ? 's' : ''}
265 </div>
266 )}
267 </div>
268 )}
269
270 <DialogFooter className="px-5 py-4">
271 <Button block size="large" type="default" onClick={() => setIsDialogOpen(false)}>
272 Cancel
273 </Button>
274 <Button
275 block
276 type="primary"
277 size="large"
278 htmlType="submit"
279 loading={loading}
280 onClick={async () => {
281 await onSubmit(form.getValues())
282 }}
283 >
284 Confirm changes
285 </Button>
286 </DialogFooter>
287 {message && (
288 <>
289 <DialogSectionSeparator />
290 <DialogSection>
291 <Alert variant={message.type === 'error' ? 'destructive' : 'default'}>
292 <WarningIcon />
293 <AlertTitle>{message.message}</AlertTitle>
294 </Alert>
295 </DialogSection>
296 </>
297 )}
298 </DialogContent>
299 </Dialog>
300 )
301}