Files
supabase/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx
T
Brian-AnandDanny White f1082e28c7 chore: migrate Framer Motion imports to Motion (#51520)
## Problem

Closes #31221.
The applications and shared UI packages still import the former
`framer-motion` package instead of Motion's React entry point.

## Solution

Replace runtime imports, type imports, and three test mocks with
`motion/react` across the consuming workspaces.
Replace the seven direct dependency declarations with `motion:
^11.18.2`.
Preserve the existing animation engine version and all other dependency
resolutions, including the asynchronous `domMax` feature loader.
Historical blog examples remain as published; `framer-motion` remains a
transitive implementation dependency of Motion.

## Review instructions

1. Run `pnpm install --frozen-lockfile` and check the seven dependency
replacements in the lockfile.
2. Open the WWW Auth page and Docs homepage at desktop and mobile
widths, with normal and reduced motion preferences.
3. Open the mobile navigation, expand Product on WWW, and close the menu
using Escape and the close button.
4. Check the existing animation components, shared form validation, and
Studio tests for unchanged behavior.

Validation:

- Studio: 731 test files, 8,334 passing tests and one expected failure,
with `--maxWorkers=4`.
- Shared UI, UI patterns, WWW, and UI library: 615 passing tests.
- `pnpm run typecheck --concurrency=1`: all 16 repository tasks pass.
- All six application source lint scripts and lint ratchets pass;
generated Docs example copies are excluded from local lint.
- Repository Prettier source check, lockfile formatting, and case hazard
check pass.
- WWW: 10 existing end-to-end checks across nine pages.
- Docs: 5 existing guide link and accessibility checks across Database
and Auth.
- WWW and Docs mobile navigation pass at desktop/mobile sizes with
normal/reduced motion; SQL-to-REST translation tabs exercise the
asynchronous feature loader without browser errors.
- Frozen-lockfile installation passes; all existing package and snapshot
resolutions are unchanged.
- Learn and UI library command palettes open, filter results, and close
with Escape without browser errors.
- Independent code review finds no actionable defects.
- `npm run build -- --filter=www --filter=docs --filter=studio
--filter=learn --filter=library --concurrency=1 --continue=always`: all
five affected applications build successfully (9 tasks including
prerequisites); asset uploads disabled for local validation.

## Checklist

- [x] I have read
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
- [x] If I wrote a new docs topic or edited an existing topic, I used
the `/write-the-docs` or `/edit-the-docs` skill, which applies the docs
[style
guide](https://github.com/supabase/supabase/tree/master/apps/docs/style-guide)
(no docs topics changed)

---------

Co-authored-by: Danny White <3104761+dnywh@users.noreply.github.com>
2026-10-14 07:16:00 +00:00

611 lines
24 KiB
TypeScript

import { zodResolver } from '@hookform/resolvers/zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useParams } from 'common'
import { AnimatePresence, motion } from 'motion/react'
import { useEffect, useRef, useState, type ReactNode } from 'react'
import { useForm, useWatch } from 'react-hook-form'
import { CloudProvider } from 'shared-data'
import { toast } from 'sonner'
import { Button, cn, Form } from 'ui'
import { PageContainer } from 'ui-patterns/PageContainer'
import {
PageSection,
PageSectionContent,
PageSectionMeta,
PageSectionSummary,
PageSectionTitle,
} from 'ui-patterns/PageSection'
import { ComputeAndDiskUsageCharts } from './ComputeAndDiskUsageCharts'
import { CreateDiskStorageSchema, DiskStorageSchemaType } from './DiskManagement.schema'
import { DiskManagementMessage } from './DiskManagement.types'
import {
calculateDiskSizeRequiredForIopsWithGp3,
mapComputeSizeNameToAddonVariantId,
} from './DiskManagement.utils'
import { AdvancedSection, ComputeSection, DiskSection } from './DiskManagementForm.sections'
import { DiskMangementRestartRequiredSection } from './DiskManagementRestartRequiredSection'
import { DiskManagementReviewAndSubmitDialog } from './DiskManagementReviewAndSubmitDialog/DiskManagementReviewAndSubmitDialog'
import { useDiskManagementReviewChanges } from './DiskManagementReviewAndSubmitDialog/DiskManagementReviewAndSubmitDialog.hooks'
import { BillingChangeBadge } from './ui/BillingChangeBadge'
import {
DISK_LIMITS,
DiskType,
PLAN_DETAILS,
RESTRICTED_COMPUTE_FOR_THROUGHPUT_ON_GP3,
} from './ui/DiskManagement.constants'
import { NoticeBar } from './ui/NoticeBar'
import type { RecommendedComputeForReadReplicas } from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/recommendCompute'
import { useMainScrollContainer } from '@/components/layouts/MainScrollContainerContext'
import { PADDING_CLASSES } from '@/components/layouts/Scaffold'
import { UpgradeToPro } from '@/components/ui/UpgradeToPro'
import {
useDiskAttributesQuery,
useRemainingDurationForDiskAttributeUpdate,
} from '@/data/config/disk-attributes-query'
import { useUpdateDiskAttributesMutation } from '@/data/config/disk-attributes-update-mutation'
import { useDiskAutoscaleCustomConfigQuery } from '@/data/config/disk-autoscale-config-query'
import { useUpdateDiskAutoscaleConfigMutation } from '@/data/config/disk-autoscale-config-update-mutation'
import { useDiskUtilizationQuery } from '@/data/config/disk-utilization-query'
import { useSetProjectStatus } from '@/data/projects/project-detail-query'
import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
import { useProjectAddonUpdateMutation } from '@/data/subscriptions/project-addon-update-mutation'
import { useProjectAddonsQuery } from '@/data/subscriptions/project-addons-query'
import { AddonVariantId } from '@/data/subscriptions/types'
import { useResourceWarningsQuery } from '@/data/usage/resource-warnings-query'
import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import {
useIsAwsCloudProvider,
useIsAwsK8sCloudProvider,
useIsAwsNimbusCloudProvider,
useIsHighAvailability,
useSelectedProjectQuery,
} from '@/hooks/misc/useSelectedProject'
import { GB, PROJECT_STATUS } from '@/lib/constants'
export function DiskManagementForm({
chartsClassName,
overviewExtra,
beforeScaling,
recommendedCompute,
onRecommendedComputeApplied,
}: {
chartsClassName?: string
/** Rendered above usage charts in the overview block (for example topology). */
overviewExtra?: ReactNode
/** Rendered between overview and the Scaling section (for example read replicas). */
beforeScaling?: ReactNode
recommendedCompute?: RecommendedComputeForReadReplicas | null
onRecommendedComputeApplied?: () => void
} = {}) {
const { ref: projectRef } = useParams()
const { data: project, isPending: isProjectPending } = useSelectedProjectQuery()
const { data: org } = useSelectedOrganizationQuery()
const { setProjectStatus } = useSetProjectStatus()
const autoscaleSettingsRef = useRef<HTMLDivElement>(null)
const storageSettingsRef = useRef<HTMLDivElement>(null)
const computeSettingsRef = useRef<HTMLDivElement>(null)
const diskSizeSettingsRef = useRef<HTMLDivElement>(null)
const mainScrollContainer = useMainScrollContainer()
const isSpendCapEnabled = org?.plan.id !== 'free' && !org?.usage_billing_enabled
const { data: resourceWarnings } = useResourceWarningsQuery({ ref: projectRef })
// [Joshen Cleanup] JFYI this client side filtering can be cleaned up once BE changes are live which will only return the warnings based on the provided ref
const projectResourceWarnings = (resourceWarnings ?? [])?.find(
(warning) => warning.project === project?.ref
)
const isReadOnlyMode = projectResourceWarnings?.is_readonly_mode_enabled
const isAws = useIsAwsCloudProvider()
const isAwsK8s = useIsAwsK8sCloudProvider()
const isAwsNimbus = useIsAwsNimbusCloudProvider()
const isHighAvailability = useIsHighAvailability()
const { can: canUpdateDiskConfiguration, isSuccess: isPermissionsLoaded } =
useAsyncCheckPermissions(PermissionAction.UPDATE, 'projects', {
resource: {
project_id: project?.id,
},
})
const { hasAccess, isSuccess: isEntitlementsLoaded } = useCheckEntitlements(
'instances.compute_update_available_sizes'
)
const [isDialogOpen, setIsDialogOpen] = useState<boolean>(false)
const [refetchInterval, setRefetchInterval] = useState<number | false>(false)
const [message, setMessageState] = useState<DiskManagementMessage | null>(null)
const { data: databases, isSuccess: isReadReplicasSuccess } = useReadReplicasQuery({ projectRef })
const { data, isSuccess: isDiskAttributesSuccess } = useDiskAttributesQuery(
{ projectRef },
{
refetchInterval,
refetchOnWindowFocus: false,
enabled: project != null && isAws,
}
)
const { isSuccess: isAddonsSuccess } = useProjectAddonsQuery({ projectRef })
const { isWithinCooldownWindow, isSuccess: isCooldownSuccess } =
useRemainingDurationForDiskAttributeUpdate({
projectRef,
enabled: project != null && isAws,
})
const { data: diskUtil, isSuccess: isDiskUtilizationSuccess } = useDiskUtilizationQuery(
{
projectRef,
},
{ enabled: project != null && isAws }
)
const { data: diskAutoscaleConfig, isSuccess: isDiskAutoscaleConfigSuccess } =
useDiskAutoscaleCustomConfigQuery({ projectRef }, { enabled: project != null && isAws })
const computeSize = project?.infra_compute_size
? mapComputeSizeNameToAddonVariantId(project?.infra_compute_size)
: undefined
// @ts-ignore
const { type, iops, throughput_mbps, size_gb } = data?.attributes ?? { size_gb: 0, iops: 0 }
const { growth_percent, max_size_gb, min_increment_gb } = diskAutoscaleConfig ?? {}
const defaultValues = {
storageType: type ?? DiskType.GP3,
provisionedIOPS: iops,
throughput: throughput_mbps,
totalSize: size_gb,
computeSize: computeSize ?? 'ci_micro',
growthPercent: growth_percent,
minIncrementGb: min_increment_gb,
maxSizeGb: max_size_gb,
}
const form = useForm<DiskStorageSchemaType>({
resolver: zodResolver(
CreateDiskStorageSchema({
defaultTotalSize: defaultValues.totalSize,
cloudProvider: project?.cloud_provider as CloudProvider,
isSpendCapEnabled,
})
),
defaultValues,
mode: 'onBlur',
reValidateMode: 'onChange',
})
const modifiedComputeSize = useWatch({ control: form.control, name: 'computeSize' })
const isSuccess =
isAddonsSuccess &&
isDiskAttributesSuccess &&
isDiskUtilizationSuccess &&
isReadReplicasSuccess &&
isDiskAutoscaleConfigSuccess &&
isCooldownSuccess
const isRequestingChanges = data?.requested_modification !== undefined
const readReplicas = (databases ?? []).filter((db) => db.identifier !== projectRef)
const isPlanUpgradeRequired = !hasAccess
const {
computeSizePrice,
diskSizePrice,
totalBeforePrice,
totalAfterPrice,
advancedBeforePrice,
advancedAfterPrice,
showComputeBillingBadge,
showDiskBillingBadge,
showAdvancedBillingBadge,
} = useDiskManagementReviewChanges(form, readReplicas.length)
const { formState } = form
const errors = formState.errors
const usedSize = Math.round(((diskUtil?.metrics.fs_used_bytes ?? 0) / GB) * 100) / 100
const totalSize = formState.defaultValues?.totalSize || 0
const usedPercentage = (usedSize / totalSize) * 100
const disableIopsThroughputConfig =
isHighAvailability ||
(modifiedComputeSize &&
!isSpendCapEnabled &&
RESTRICTED_COMPUTE_FOR_THROUGHPUT_ON_GP3.includes(modifiedComputeSize))
const watchedTotalSize = useWatch({ control: form.control, name: 'totalSize' }) ?? 0
const watchedStorageType = useWatch({ control: form.control, name: 'storageType' })
// Minimum disk size where the platform API will accept an IOPS payload (500 IOPS/GB rule).
const minDiskSizeForCustomIops = calculateDiskSizeRequiredForIopsWithGp3(
DISK_LIMITS[DiskType.GP3].minIops
)
// Suggested target when prompting a resize, sits above the floor so users
// aren't pinned at the minimum during the 4-hour disk-config cooldown.
const suggestedDiskSizeForCustomIops = PLAN_DETAILS.pro.includedDiskGB.gp3
const isDiskTooSmallForCustomIops =
watchedStorageType === 'gp3' && watchedTotalSize < minDiskSizeForCustomIops
const isBranch = project?.parent_project_ref !== undefined
const disableDiskSizeInput =
isRequestingChanges ||
isPlanUpgradeRequired ||
isWithinCooldownWindow ||
isHighAvailability ||
!canUpdateDiskConfiguration ||
!isAws
const disableDiskInputs = disableDiskSizeInput || isSpendCapEnabled || isHighAvailability
// Compute resizing is not supported for High Availability projects during Alpha
const disableComputeInputs = isPlanUpgradeRequired || isHighAvailability
const isDirty = !!Object.keys(form.formState.dirtyFields).length
const isProjectResizing = project?.status === PROJECT_STATUS.RESIZING
const isProjectRequestingDiskChanges = isRequestingChanges && !isProjectResizing
const noPermissions = isPermissionsLoaded && !canUpdateDiskConfiguration
const isDiskNoticeVisible = !isProjectPending && !(isAws || isAwsNimbus)
const { mutateAsync: updateDiskConfiguration, isPending: isUpdatingDisk } =
useUpdateDiskAttributesMutation({
// this is to suppress to toast message
onError: () => {},
onSuccess: () => setRefetchInterval(2000),
})
const { mutateAsync: updateSubscriptionAddon, isPending: isUpdatingCompute } =
useProjectAddonUpdateMutation({
// this is to suppress to toast message
onError: () => {},
onSuccess: () => {
//Manually set project status to RESIZING, Project status should be RESIZING on next project status request.
if (projectRef) setProjectStatus({ ref: projectRef, status: PROJECT_STATUS.RESIZING })
},
})
const { mutateAsync: updateDiskAutoscaleConfig, isPending: isUpdatingDiskAutoscaleConfig } =
useUpdateDiskAutoscaleConfigMutation({
// this is to suppress to toast message
onError: () => {},
})
const isUpdatingConfig = isUpdatingDisk || isUpdatingCompute || isUpdatingDiskAutoscaleConfig
const onSubmit = async (data: DiskStorageSchemaType) => {
let payload = data
let willUpdateDiskConfiguration = false
setMessageState(null)
// [Joshen] Skip disk configuration related stuff for AWS Nimbus
try {
if (
!isAwsK8s &&
!isAwsNimbus &&
(payload.storageType !== form.formState.defaultValues?.storageType ||
payload.provisionedIOPS !== form.formState.defaultValues?.provisionedIOPS ||
payload.throughput !== form.formState.defaultValues?.throughput ||
payload.totalSize !== form.formState.defaultValues?.totalSize)
) {
willUpdateDiskConfiguration = true
await updateDiskConfiguration({
ref: projectRef,
provisionedIOPS: payload.provisionedIOPS!,
storageType: payload.storageType,
totalSize: payload.totalSize!,
throughput: payload.throughput,
})
}
if (
!isAwsK8s &&
!isAwsNimbus &&
(payload.growthPercent !== form.formState.defaultValues?.growthPercent ||
payload.minIncrementGb !== form.formState.defaultValues?.minIncrementGb ||
payload.maxSizeGb !== form.formState.defaultValues?.maxSizeGb)
) {
await updateDiskAutoscaleConfig({
projectRef,
growthPercent: payload.growthPercent,
minIncrementGb: payload.minIncrementGb,
maxSizeGb: payload.maxSizeGb,
})
}
if (
!isHighAvailability &&
payload.computeSize !== form.formState.defaultValues?.computeSize
) {
await updateSubscriptionAddon({
projectRef: projectRef,
// cast variant to AddonVariantId to satisfy type
variant: payload.computeSize as AddonVariantId,
type: 'compute_instance',
suppressToast: true,
})
}
setIsDialogOpen(false)
form.reset(data as DiskStorageSchemaType)
// Disk resizes get their own completion toast once polling confirms it's applied
if (!willUpdateDiskConfiguration) {
toast.success('Successfully updated disk settings!')
}
} catch (error: unknown) {
setMessageState({
message: error instanceof Error ? error.message : 'An unknown error occurred',
type: 'error',
})
}
}
useEffect(() => {
if (!isDiskAttributesSuccess) return
// @ts-ignore
const { type, iops, throughput_mbps, size_gb } = data?.attributes ?? { size_gb: 0 }
const formValues = {
storageType: type,
provisionedIOPS: iops,
throughput: throughput_mbps,
totalSize: size_gb,
computeSize: form.getValues('computeSize'),
}
if (!('requested_modification' in data)) {
if (refetchInterval !== false) {
form.reset(formValues)
setRefetchInterval(false)
toast.success('Disk configuration changes have been successfully applied!')
}
} else {
setRefetchInterval(2000)
}
}, [data, isDiskAttributesSuccess, form, refetchInterval])
// We only support disk configurations for >=Large instances
// If a customer downgrades back to <Large, we should reset the storage settings to avoid incurring unnecessary costs
useEffect(() => {
if (modifiedComputeSize && project?.infra_compute_size && isDialogOpen) {
if (RESTRICTED_COMPUTE_FOR_THROUGHPUT_ON_GP3.includes(modifiedComputeSize)) {
form.setValue('storageType', DiskType.GP3)
form.setValue('throughput', DISK_LIMITS['gp3'].minThroughput)
form.setValue('provisionedIOPS', DISK_LIMITS['gp3'].minIops)
}
}
}, [modifiedComputeSize, form, isDialogOpen, project])
useEffect(() => {
// Initialize field values properly when data has been loaded, preserving any user changes.
// Disk attribute queries never run for High Availability projects (non-AWS cloud provider),
// so the project loading is the initialization signal there.
if (isDiskAttributesSuccess || isSuccess || isHighAvailability) {
form.reset(defaultValues, {})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSuccess, isDiskAttributesSuccess, isHighAvailability])
// Apply the recommendation only after the sheet's close lifecycle has completed.
useEffect(() => {
// The compute add-on supplies the option. Keep the recommendation pending
// until disk attributes have initialised the form, so a later reset cannot
// overwrite it. Other infrastructure requests are unrelated to this handoff.
if (!recommendedCompute || !isAddonsSuccess || isHighAvailability) return
form.setValue('computeSize', recommendedCompute, {
shouldDirty: true,
shouldValidate: true,
})
void form.trigger(['provisionedIOPS', 'throughput'])
if (isDiskAttributesSuccess) onRecommendedComputeApplied?.()
const element = computeSettingsRef.current
if (!element) return
element
.querySelector<HTMLElement>(`[id="${recommendedCompute}"]`)
?.focus({ preventScroll: true })
if (!mainScrollContainer) {
element.scrollIntoView({ behavior: 'smooth', block: 'start' })
return
}
const scrollMarginTop = Number.parseFloat(getComputedStyle(element).scrollMarginTop) || 0
const top =
mainScrollContainer.scrollTop +
element.getBoundingClientRect().top -
mainScrollContainer.getBoundingClientRect().top -
scrollMarginTop
mainScrollContainer.scrollTo({ top: Math.max(0, top), behavior: 'smooth' })
}, [
form,
isAddonsSuccess,
isDiskAttributesSuccess,
isHighAvailability,
mainScrollContainer,
onRecommendedComputeApplied,
recommendedCompute,
])
// Deep links from billing / UpgradePlanButton (e.g. #compute).
useEffect(() => {
if (typeof window === 'undefined') return
if (window.location.hash !== '#compute') return
const timeoutId = setTimeout(() => {
computeSettingsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}, 100)
return () => clearTimeout(timeoutId)
}, [])
useEffect(() => {
const fieldErrors = Object.keys(errors)
if (fieldErrors.length === 0) return
const scrollTargets = [
{
hasError:
fieldErrors.includes('maxSizeGb') ||
fieldErrors.includes('growthPercent') ||
fieldErrors.includes('minIncrementGb'),
ref: autoscaleSettingsRef,
},
{
hasError: fieldErrors.includes('throughput') || fieldErrors.includes('provisionedIOPS'),
ref: storageSettingsRef,
},
{ hasError: fieldErrors.includes('totalSize'), ref: diskSizeSettingsRef },
{ hasError: fieldErrors.includes('computeSize'), ref: computeSettingsRef },
]
const scrollTarget = scrollTargets.find(({ hasError }) => hasError)?.ref ?? null
if (!scrollTarget) return
const timeoutId = setTimeout(() => {
scrollTarget.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}, 100)
return () => clearTimeout(timeoutId)
}, [errors])
return (
<Form {...form}>
<form id="disk-compute-form" onSubmit={form.handleSubmit(onSubmit)}>
<PageContainer size="default" className="pb-16">
<PageSection>
<PageSectionContent>
{overviewExtra}
<ComputeAndDiskUsageCharts className={cn(overviewExtra && 'mt-6', chartsClassName)} />
</PageSectionContent>
</PageSection>
{(isProjectResizing ||
isProjectRequestingDiskChanges ||
(isEntitlementsLoaded && !isPlanUpgradeRequired && noPermissions)) && (
<div className="relative flex flex-col gap-10">
<DiskMangementRestartRequiredSection
visible={isProjectResizing}
title="Your project will now automatically restart."
description="Your project will be unavailable for up to 2 mins."
/>
<NoticeBar
type="default"
visible={isProjectRequestingDiskChanges}
title="Disk configuration changes have been requested"
description="The requested changes will be applied to your disk shortly"
/>
<NoticeBar
type="default"
visible={isEntitlementsLoaded && !isPlanUpgradeRequired && noPermissions}
title="You do not have permission to update disk configuration"
description="Please contact your organization administrator to update your disk configuration"
/>
</div>
)}
{beforeScaling}
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Scaling</PageSectionTitle>
</PageSectionSummary>
</PageSectionMeta>
{isEntitlementsLoaded && isPlanUpgradeRequired && (
<UpgradeToPro
featureProposition="configure compute and disk"
primaryText="Only available on Pro Plan and above"
secondaryText="Upgrade to the Pro Plan to configure compute and disk settings."
/>
)}
<PageSectionContent>
<ComputeSection
form={form}
settingsRef={computeSettingsRef}
showBillingBadge={showComputeBillingBadge}
beforePrice={Number(computeSizePrice.oldPrice)}
afterPrice={Number(computeSizePrice.newPrice)}
disabled={disableComputeInputs}
/>
<DiskSection
form={form}
settingsRef={diskSizeSettingsRef}
showBillingBadge={showDiskBillingBadge}
beforePrice={Number(diskSizePrice.oldPrice)}
afterPrice={Number(diskSizePrice.newPrice)}
isAws={isAws}
isAwsK8s={isAwsK8s}
isBranch={isBranch}
isNoticeVisible={isDiskNoticeVisible}
isReadOnlyMode={!!isReadOnlyMode}
usedPercentage={usedPercentage}
isWithinCooldownWindow={isWithinCooldownWindow}
currentDiskSizeGb={defaultValues.totalSize}
disableDiskSizeInput={disableDiskSizeInput}
/>
{isAws && (
<AdvancedSection
form={form}
autoscaleSettingsRef={autoscaleSettingsRef}
storageSettingsRef={storageSettingsRef}
showBillingBadge={showAdvancedBillingBadge}
beforePrice={advancedBeforePrice}
afterPrice={advancedAfterPrice}
disableIopsThroughputConfig={disableIopsThroughputConfig}
canUpdateDiskConfiguration={canUpdateDiskConfiguration}
isDiskTooSmallForCustomIops={isDiskTooSmallForCustomIops}
disableDiskInputs={disableDiskInputs}
disableDiskSizeInput={disableDiskSizeInput}
suggestedDiskSizeForCustomIops={suggestedDiskSizeForCustomIops}
/>
)}
</PageSectionContent>
</PageSection>
</PageContainer>
<AnimatePresence>
{isDirty ? (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
transition={{ duration: 0.1, delay: 0.2 }}
className="z-10 w-full left-0 right-0 sticky bottom-0 bg-surface-100 border-t h-16 items-center flex"
>
<div
className={cn(
'mx-auto w-full max-w-[1200px]',
PADDING_CLASSES,
'flex items-center justify-end gap-3'
)}
>
<BillingChangeBadge
show={isDirty}
beforePrice={totalBeforePrice}
afterPrice={totalAfterPrice}
/>
<Button onClick={() => form.reset()} disabled={!isDirty} size="medium">
Cancel
</Button>
<DiskManagementReviewAndSubmitDialog
loading={isUpdatingConfig}
disabled={noPermissions}
form={form}
numReplicas={readReplicas.length}
isDialogOpen={isDialogOpen}
onSubmit={onSubmit}
setIsDialogOpen={setIsDialogOpen}
message={message}
/>
</div>
</motion.div>
) : null}
</AnimatePresence>
</form>
</Form>
)
}