DiskManagementReviewAndSubmitDialog.tsx301 lines · main
| 1 | import { PermissionAction } from '@supabase/shared-types/out/constants' |
| 2 | import { ArrowRight } from 'lucide-react' |
| 3 | import { UseFormReturn } from 'react-hook-form' |
| 4 | import { |
| 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 | |
| 21 | import { DiskStorageSchemaType } from '../DiskManagement.schema' |
| 22 | import { DiskManagementMessage } from '../DiskManagement.types' |
| 23 | import { DISK_AUTOSCALE_CONFIG_DEFAULTS } from '../ui/DiskManagement.constants' |
| 24 | import { |
| 25 | BreakdownRow, |
| 26 | PriceDelta, |
| 27 | ValueChange, |
| 28 | } from './DiskManagementReviewAndSubmitDialog.components' |
| 29 | import { useDiskManagementReviewChanges } from './DiskManagementReviewAndSubmitDialog.hooks' |
| 30 | import { TaxDisclaimer } from '@/components/interfaces/Billing/TaxDisclaimer' |
| 31 | import { ButtonTooltip } from '@/components/ui/ButtonTooltip' |
| 32 | import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' |
| 33 | import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' |
| 34 | import { formatCurrency } from '@/lib/helpers' |
| 35 | |
| 36 | interface 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 | |
| 48 | export 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 | } |