Files
supabase/apps/studio/components/interfaces/DiskManagement/DiskManagementReviewAndSubmitDialog/DiskManagementReviewAndSubmitDialog.hooks.ts
Saxon Fletcher 618e003136 Modernize compute and disk configuration (#48368)
## Summary

This is a precursor to unifying "compute and disk" and "infrastructure
pages". First step is just updating the existing compute and disk page
to make use of standard page and form patterns.

- Reorganizes the existing Compute and Disk form into the established
settings layout pattern, with Scaling, Compute, Disk, and Advanced
sections.
- Moves billing deltas to section headers while retaining the sticky
review footer.
- Adds animated notices, validation-error scrolling, and a responsive
compute-size selector (2 columns by default, 3 from 680px, and 4 from
900px).
- Preserves permissions, entitlements, cooldowns, read-only warnings,
replica pricing, GP3 validation, Nano/PITR locks, and the free Micro
upgrade treatment.
- Keeps the redesigned experience on `/settings/compute-and-disk` so it
can be reviewed independently of the route cutover.

## Stack

1. #48368 (this PR)
2. #48369
3. #48370

## How to test

1. Check out `chore/infra-compute-1-config` and start Studio with `pnpm
dev:studio`.
2. Open `/project/<ref>/settings/compute-and-disk`.
3. Confirm the page header and Scaling, Compute, Disk, and Advanced
sections follow the standard settings-page spacing, with the plan notice
directly below the Scaling header.
4. Change the compute size and disk configuration. Confirm billing
deltas appear in the relevant section headers and the sticky review
footer summarizes and applies the pending changes.
5. Enter invalid GP3 IOPS or throughput values and submit. Confirm the
validation message is shown and the first invalid field is scrolled into
view.
6. Exercise representative project states: insufficient permissions,
read-only mode, resize cooldown, Nano with PITR, a project with read
replicas, and a free Micro upgrade. Confirm the existing locks,
warnings, pricing, and upgrade treatment remain intact.
7. Resize the viewport and confirm the compute cards use 2 columns by
default, 3 columns from 680px, and no more than 4 columns from 900px.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Reworked the Compute and Disk settings UI into clearer sectioned
pages, including smoother navigation to the first validation error.
- Added a transitional in-form NoticeBar for consistent alert rendering.
- Improved billing change badges to reflect accurate before/after
totals.
- **Bug Fixes**
  - Fixed disk IOPS/throughput pricing to include replica-based charges.
- Strengthened disk sizing validation (GP3 limits, legacy/size edge
cases, spend-cap behavior, and provider-specific constraints).
- **Refactor**
  - Updated key form field layouts for improved readability.
- **Tests**
  - Expanded schema validation and pricing/billing badge test coverage.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
2026-07-29 10:56:19 +10:00

252 lines
7.8 KiB
TypeScript

import { useMemo } from 'react'
import { useFormState, useWatch, type UseFormReturn } from 'react-hook-form'
import { DiskStorageSchemaType } from '../DiskManagement.schema'
import { ComputeInstanceAddonVariantId } from '../DiskManagement.types'
import {
calculateComputeSizePrice,
calculateDiskSizePrice,
calculateIOPSPrice,
calculateThroughputPrice,
getAvailableComputeOptions,
mapAddOnVariantIdToComputeSize,
} from '../DiskManagement.utils'
import { DiskType } from '../ui/DiskManagement.constants'
import { useProjectAddonsQuery } from '@/data/subscriptions/project-addons-query'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import {
useIsAwsNimbusCloudProvider,
useSelectedProjectQuery,
} from '@/hooks/misc/useSelectedProject'
const COMPUTE_SIZES_BELOW_LARGE: Array<ComputeInstanceAddonVariantId> = [
'ci_nano',
'ci_micro',
'ci_small',
'ci_medium',
]
export function shouldShowComputeBillingBadge({
isDirty,
hasComputeSizeError,
oldPrice,
newPrice,
}: {
isDirty: boolean
hasComputeSizeError: boolean
oldPrice: string | number
newPrice: string | number
}) {
return isDirty && !hasComputeSizeError && Number(oldPrice) !== Number(newPrice)
}
export function useDiskManagementReviewChanges(
form: UseFormReturn<DiskStorageSchemaType>,
numReplicas: number
) {
const [
computeSize,
totalSize,
storageType,
provisionedIOPS,
throughput,
growthPercent,
minIncrementGb,
maxSizeGb,
] = useWatch({
control: form.control,
name: [
'computeSize',
'totalSize',
'storageType',
'provisionedIOPS',
'throughput',
'growthPercent',
'minIncrementGb',
'maxSizeGb',
],
})
const { isDirty, errors, defaultValues } = useFormState({ control: form.control })
const { data: project } = useSelectedProjectQuery()
const { data: org } = useSelectedOrganizationQuery()
const isAwsNimbus = useIsAwsNimbusCloudProvider()
const { data: addons } = useProjectAddonsQuery({ projectRef: project?.ref })
const isAwsK8sProject = project?.cloud_provider === 'AWS_K8S'
const planId = org?.plan.id ?? 'free'
const availableAddons = useMemo(() => addons?.available_addons ?? [], [addons])
const availableOptions = useMemo(
() => getAvailableComputeOptions(availableAddons, project?.cloud_provider),
[availableAddons, project?.cloud_provider]
)
// --- Prices ---
const computeSizePrice = calculateComputeSizePrice({
availableOptions,
oldComputeSize: defaultValues?.computeSize || 'ci_micro',
newComputeSize: computeSize,
plan: planId,
})
const diskSizePrice = calculateDiskSizePrice({
planId,
oldSize: defaultValues?.totalSize || 0,
oldStorageType: defaultValues?.storageType as DiskType,
newSize: totalSize,
newStorageType: storageType as DiskType,
numReplicas,
})
const iopsPrice = calculateIOPSPrice({
oldStorageType: defaultValues?.storageType as DiskType,
oldProvisionedIOPS: defaultValues?.provisionedIOPS || 0,
newStorageType: storageType as DiskType,
newProvisionedIOPS: provisionedIOPS,
numReplicas,
})
const throughputPrice = calculateThroughputPrice({
storageType: storageType as DiskType,
newThroughput: throughput || 0,
oldThroughput: defaultValues?.throughput || 0,
numReplicas,
})
const totalBeforePrice =
Number(computeSizePrice.oldPrice) +
Number(diskSizePrice.oldPrice) +
Number(iopsPrice.oldPrice) +
Number(throughputPrice.oldPrice)
const totalAfterPrice =
Number(computeSizePrice.newPrice) +
Number(diskSizePrice.newPrice) +
Number(iopsPrice.newPrice) +
Number(throughputPrice.newPrice)
const advancedBeforePrice = Number(iopsPrice.oldPrice) + Number(throughputPrice.oldPrice)
const advancedAfterPrice = Number(iopsPrice.newPrice) + Number(throughputPrice.newPrice)
const showComputeBillingBadge = shouldShowComputeBillingBadge({
isDirty,
hasComputeSizeError: !!errors.computeSize,
oldPrice: computeSizePrice.oldPrice,
newPrice: computeSizePrice.newPrice,
})
const showDiskBillingBadge =
isDirty &&
!errors.totalSize &&
Number(diskSizePrice.oldPrice) !== Number(diskSizePrice.newPrice)
const showAdvancedBillingBadge =
isDirty &&
advancedBeforePrice !== advancedAfterPrice &&
!errors.provisionedIOPS &&
!errors.throughput
// --- Change flags ---
const hasComputeChanges = defaultValues?.computeSize !== computeSize
const hasTotalSizeChanges =
!isAwsK8sProject && !isAwsNimbus && defaultValues?.totalSize !== totalSize
const hasStorageTypeChanges =
!isAwsK8sProject && !isAwsNimbus && defaultValues?.storageType !== storageType
const hasThroughputChanges =
!isAwsK8sProject && !isAwsNimbus && defaultValues?.throughput !== throughput
const hasIOPSChanges =
!isAwsK8sProject && !isAwsNimbus && defaultValues?.provisionedIOPS !== provisionedIOPS
const hasGrowthPercentChanges =
!isAwsK8sProject && !isAwsNimbus && defaultValues?.growthPercent !== growthPercent
const hasMinIncrementChanges =
!isAwsK8sProject && !isAwsNimbus && defaultValues?.minIncrementGb !== minIncrementGb
const hasMaxSizeChanges =
!isAwsK8sProject && !isAwsNimbus && defaultValues?.maxSizeGb !== maxSizeGb
// --- Derived predicates ---
const storageTypeAfter = storageType as DiskType
// Show hero whenever any line-item price actually changes, not just compute
const anyBillableDiskChange =
Number(diskSizePrice.newPrice) !== Number(diskSizePrice.oldPrice) ||
Number(iopsPrice.newPrice) !== Number(iopsPrice.oldPrice) ||
Number(throughputPrice.newPrice) !== Number(throughputPrice.oldPrice)
// Show cooldown warning whenever any disk attribute that counts toward the 24-hour modification limit changes
const anyDiskAttributeChange = hasIOPSChanges || hasStorageTypeChanges || hasTotalSizeChanges
// Show extended downtime warning when resizing to/from a size below large
const hasExtendedDowntimeRisk =
hasComputeChanges &&
(COMPUTE_SIZES_BELOW_LARGE.includes(
(defaultValues?.computeSize ?? 'ci_nano') as ComputeInstanceAddonVariantId
) ||
COMPUTE_SIZES_BELOW_LARGE.includes(computeSize as ComputeInstanceAddonVariantId))
// Throughput is only a user-configurable, separately-billed attribute for GP3. For IO2 it is
// derived from provisioned IOPS (0.256 MiB/s per IOPS) and isn't surfaced as its own value, so
// the form clears it to 0 — rendering a misleading "→ 0 MB/s". Only show the row when the
// resulting storage type is GP3; any GP3→IO2 throughput price delta still lands in the total.
const showThroughputRow =
!isAwsK8sProject &&
!isAwsNimbus &&
storageTypeAfter === 'gp3' &&
(hasThroughputChanges || hasStorageTypeChanges)
const hasAnyBreakdownRows =
hasComputeChanges ||
hasStorageTypeChanges ||
hasIOPSChanges ||
showThroughputRow ||
hasTotalSizeChanges ||
hasGrowthPercentChanges ||
hasMinIncrementChanges ||
hasMaxSizeChanges
// --- Labels ---
const oldComputeLabel = mapAddOnVariantIdToComputeSize(defaultValues?.computeSize ?? 'ci_nano')
const newComputeLabel = mapAddOnVariantIdToComputeSize(computeSize)
return {
// prices
computeSizePrice,
diskSizePrice,
iopsPrice,
throughputPrice,
totalBeforePrice,
totalAfterPrice,
advancedBeforePrice,
advancedAfterPrice,
showComputeBillingBadge,
showDiskBillingBadge,
showAdvancedBillingBadge,
// change flags
hasComputeChanges,
hasTotalSizeChanges,
hasStorageTypeChanges,
hasThroughputChanges,
hasIOPSChanges,
hasGrowthPercentChanges,
hasMinIncrementChanges,
hasMaxSizeChanges,
// derived predicates
anyBillableDiskChange,
anyDiskAttributeChange,
showThroughputRow,
hasAnyBreakdownRows,
hasExtendedDowntimeRisk,
// labels
oldComputeLabel,
newComputeLabel,
}
}