DiskManagementReviewAndSubmitDialog.tsx 11 KB

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