feat(studio): point replica deep links at Infrastructure and recommend compute

Finish the move: selectors open the Infrastructure add-replica sheet, and eligibility CTAs pre-select compute with a visible selected state.
This commit is contained in:
Danny White committed 2026-08-21 11:32:17 +10:00
1 parent 3738faadd4
commit 3ff593b8b6
12 files changed
+203 -33

No files matched your search

@@ -2,6 +2,7 @@ import { zodResolver } from '@hookform/resolvers/zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useParams } from 'common'
import { AnimatePresence, motion } from 'framer-motion'
import { useQueryState } from 'nuqs'
import { useEffect, useRef, useState, type ReactNode } from 'react'
import { useForm, useWatch } from 'react-hook-form'
import { CloudProvider } from 'shared-data'
@@ -35,6 +36,10 @@ import {
RESTRICTED_COMPUTE_FOR_THROUGHPUT_ON_GP3,
} from './ui/DiskManagement.constants'
import { NoticeBar } from './ui/NoticeBar'
import {
recommendComputeParser,
subscribeRecommendCompute,
} from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/recommendCompute'
import { PADDING_CLASSES } from '@/components/layouts/Scaffold'
import { UpgradeToPro } from '@/components/ui/UpgradeToPro'
import {
@@ -167,6 +172,11 @@ export function DiskManagementForm({
reValidateMode: 'onChange',
})
const [recommendCompute, setRecommendCompute] = useQueryState(
'recommendCompute',
recommendComputeParser
)
const modifiedComputeSize = useWatch({ control: form.control, name: 'computeSize' })
const isSuccess =
@@ -366,6 +376,51 @@ export function DiskManagementForm({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSuccess, isDiskAttributesSuccess])
// From Add read replica eligibility: pre-select compute and scroll to Scaling.
// Declared after form.reset so a cold-load with ?recommendCompute= does not get wiped.
useEffect(() => {
if (!recommendCompute || !isSuccess) return
form.setValue('computeSize', recommendCompute, {
shouldDirty: true,
shouldValidate: true,
})
void form.trigger(['provisionedIOPS', 'throughput'])
setRecommendCompute(null)
const timeoutId = setTimeout(() => {
computeSettingsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}, 150)
return () => clearTimeout(timeoutId)
}, [form, isSuccess, recommendCompute, setRecommendCompute])
// In-page handoff from the Add read replica sheet (avoids URL write races on unmount).
useEffect(() => {
return subscribeRecommendCompute((size) => {
form.setValue('computeSize', size, {
shouldDirty: true,
shouldValidate: true,
})
void form.trigger(['provisionedIOPS', 'throughput'])
window.setTimeout(() => {
computeSettingsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}, 150)
})
}, [form])
// 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
@@ -30,8 +30,8 @@ export function DiskManagementPanelForm() {
<Admonition
type="default"
layout="responsive"
title="Disk Management has moved"
description="Disk configuration is now managed alongside Project Compute on the Infrastructure page."
title="Disk management has moved"
description="Disk configuration is now managed alongside project compute on the Infrastructure page."
actions={
<Button variant="default" asChild>
<Link href={getInfrastructurePath(projectRef)}>Go to Infrastructure</Link>
@@ -113,7 +113,7 @@ export function ComputeSizeField({ form, disabled }: ComputeSizeFieldProps) {
render={({ field }) => (
<div className="@container">
<RadioGroupCard
{...field}
value={field.value}
onValueChange={(value: ComputeInstanceAddonVariantId) => {
setValue('computeSize', value, {
shouldDirty: true,
@@ -122,7 +122,6 @@ export function ComputeSizeField({ form, disabled }: ComputeSizeFieldProps) {
trigger('provisionedIOPS')
trigger('throughput')
}}
defaultValue={field.value}
disabled={disabled}
className={cn(
!addonsError && 'grid grid-cols-2 gap-4 @[680px]:grid-cols-3 @[900px]:grid-cols-4'
@@ -215,7 +214,7 @@ export function ComputeSizeField({ form, disabled }: ComputeSizeFieldProps) {
</HoverCard>
)}
<div className="w-full flex flex-col gap-3 justify-between">
<div className="relative px-3 opacity-50 group-data-checked:opacity-100 flex justify-between">
<div className="relative px-3 opacity-50 group-data-[state=checked]:opacity-100 flex justify-between">
<ComputeBadge
className="inline-flex font-semibold"
infraComputeSize={compute.name as InfraInstanceSize}
@@ -94,7 +94,7 @@ const Subscription = () => {
</Button>
{currentPlan && currentPlan.id !== 'free' && (
<InitiateCancellationFlowButton variant="danger">
Cancel Subscription
Cancel subscription
</InitiateCancellationFlowButton>
)}
</div>
@@ -9,6 +9,7 @@ import {
DropdownMenuSubTrigger,
} from 'ui'
import { getAddReadReplicaPath } from '@/components/interfaces/Settings/Infrastructure/Infrastructure.utils'
import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
import { formatDatabaseID, formatDatabaseRegion } from '@/data/read-replicas/replicas.utils'
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
@@ -38,7 +39,7 @@ export const DatabaseParametersSubMenu = ({
const selectedDatabaseId = identifier ?? projectRef
const selectedDatabase = databases.find((db) => db.identifier === selectedDatabaseId)
const newReplicaURL = `/project/${projectRef}/database/replication?destinationType=Read+Replica`
const newReplicaURL = getAddReadReplicaPath(projectRef)
return (
<DropdownMenuSub>
@@ -1,11 +1,17 @@
import { SupportCategories } from '@supabase/shared-types/out/constants'
import { useParams } from 'common'
import Link from 'next/link'
import { parseAsBoolean, useQueryState } from 'nuqs'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Button } from 'ui'
import { Admonition } from 'ui-patterns/Admonition'
import {
RECOMMENDED_COMPUTE_FOR_READ_REPLICAS,
requestRecommendCompute,
type RecommendedComputeForReadReplicas,
} from '../recommendCompute'
import { useCheckEligibilityDeployReplica } from './useCheckEligibilityDeployReplica'
import { SupportLink } from '@/components/interfaces/Support/SupportLink'
import { DocsButton } from '@/components/ui/DocsButton'
@@ -21,6 +27,15 @@ export const ReadReplicaEligibilityWarnings = () => {
const { ref: projectRef } = useParams()
const { data: org } = useSelectedOrganizationQuery()
const { data: project } = useSelectedProjectQuery()
const isFreePlan = org?.plan?.id === 'free'
const [, setAddReplica] = useQueryState(
'addReplica',
parseAsBoolean.withDefault(false).withOptions({
history: 'push',
clearOnDefault: true,
})
)
const [refetchInterval, setRefetchInterval] = useState<number | false>(false)
@@ -62,6 +77,16 @@ export const ReadReplicaEligibilityWarnings = () => {
}
}, [projectDetail?.is_physical_backups_enabled, isProjectDetailSuccess])
const handleRecommendCompute = (size: RecommendedComputeForReadReplicas) => {
// Notify the infrastructure form before closing the sheet. Closing first
// unmounts this component and previously dropped a follow-up URL write.
const delivered = requestRecommendCompute(size)
setAddReplica(false)
if (!delivered) {
toast.error('Could not open compute settings. Scroll to Scaling and choose a size manually.')
}
}
if (hasOverdueInvoices) {
return (
<Admonition type="warning" title="Your organization has overdue invoices">
@@ -132,13 +157,22 @@ export const ReadReplicaEligibilityWarnings = () => {
This is to ensure that read replicas can keep up with the primary databases' activities.
</p>
<div className="flex items-center gap-x-2 mt-2">
<UpgradePlanButton
variant="default"
plan="Pro"
addon="computeSize"
source="read-replicas"
featureProposition="deploy Read Replicas"
/>
{isFreePlan ? (
<UpgradePlanButton
variant="default"
plan="Pro"
addon="computeSize"
source="read-replicas"
featureProposition="deploy Read Replicas"
/>
) : (
<Button
variant="default"
onClick={() => handleRecommendCompute(RECOMMENDED_COMPUTE_FOR_READ_REPLICAS.minimum)}
>
Change to Small compute
</Button>
)}
<DocsButton href={`${DOCS_URL}/guides/platform/read-replicas#prerequisites`} />
</div>
</Admonition>
@@ -229,16 +263,15 @@ export const ReadReplicaEligibilityWarnings = () => {
<span className="text-foreground">{READ_REPLICAS_MAX_COUNT}</span> replicas if your
project is on an XL compute or higher.
</p>
<UpgradePlanButton
<Button
variant="default"
plan="Pro"
addon="computeSize"
source="read-replicas"
featureProposition="deploy Read Replicas"
className="mt-2"
onClick={() =>
handleRecommendCompute(RECOMMENDED_COMPUTE_FOR_READ_REPLICAS.unlockMaxReplicas)
}
>
Change compute size
</UpgradePlanButton>
Change to XL compute
</Button>
</>
)}
</Admonition>
@@ -0,0 +1,38 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
requestRecommendCompute,
subscribeRecommendCompute,
} from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/recommendCompute'
describe('recommendCompute bridge', () => {
afterEach(() => {
// Clear any leftover subscriber between tests.
subscribeRecommendCompute(() => {})()
})
it('delivers the recommended size to the active subscriber', () => {
const listener = vi.fn()
const unsubscribe = subscribeRecommendCompute(listener)
expect(requestRecommendCompute('ci_small')).toBe(true)
expect(listener).toHaveBeenCalledWith('ci_small')
unsubscribe()
expect(requestRecommendCompute('ci_xlarge')).toBe(false)
expect(listener).toHaveBeenCalledTimes(1)
})
it('replaces the previous subscriber', () => {
const first = vi.fn()
const second = vi.fn()
subscribeRecommendCompute(first)
subscribeRecommendCompute(second)
expect(requestRecommendCompute('ci_xlarge')).toBe(true)
expect(first).not.toHaveBeenCalled()
expect(second).toHaveBeenCalledWith('ci_xlarge')
})
})
@@ -0,0 +1,42 @@
import { parseAsStringEnum } from 'nuqs'
/** Compute sizes recommended from the Add read replica sheet (close sheet → scroll + pre-select). */
export const RECOMMENDED_COMPUTE_FOR_READ_REPLICAS = {
/** Minimum size that can deploy any read replicas. */
minimum: 'ci_small',
/** Size that unlocks the default max replica count (5). */
unlockMaxReplicas: 'ci_xlarge',
} as const
export type RecommendedComputeForReadReplicas =
(typeof RECOMMENDED_COMPUTE_FOR_READ_REPLICAS)[keyof typeof RECOMMENDED_COMPUTE_FOR_READ_REPLICAS]
export const recommendComputeParser = parseAsStringEnum<RecommendedComputeForReadReplicas>([
RECOMMENDED_COMPUTE_FOR_READ_REPLICAS.minimum,
RECOMMENDED_COMPUTE_FOR_READ_REPLICAS.unlockMaxReplicas,
]).withOptions({ history: 'replace', clearOnDefault: true })
type RecommendComputeListener = (size: RecommendedComputeForReadReplicas) => void
/**
* In-page bridge from the Add read replica sheet → DiskManagementForm.
* Avoids a nuqs race where closing the sheet unmounts the CTA before a second
* query-param write can land.
*/
let recommendComputeListener: RecommendComputeListener | null = null
export function subscribeRecommendCompute(listener: RecommendComputeListener) {
recommendComputeListener = listener
return () => {
if (recommendComputeListener === listener) {
recommendComputeListener = null
}
}
}
/** Returns true when a subscriber received the recommendation. */
export function requestRecommendCompute(size: RecommendedComputeForReadReplicas) {
if (!recommendComputeListener) return false
recommendComputeListener(size)
return true
}
@@ -28,12 +28,10 @@ export const ResizingState = () => {
<Loader2 className="animate-spin text-foreground-light" size={18} />
</div>
<div className="flex flex-col gap-1">
<p>Resizing Project Compute size</p>
<p>Resizing project</p>
<p className="text-sm text-foreground-light">
Your project is being restarted to apply compute size changes.
</p>
<p className="text-sm text-foreground-light">
This can take a few minutes. Project will be offline while it is being restarted.
Your project is being restarted to apply compute size changes. It will remain
offline until fully restarted. This can take a few minutes.
</p>
</div>
</div>
@@ -22,9 +22,12 @@ import {
TooltipTrigger,
} from 'ui'
import { REPLICA_STATUS } from '../interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicas.constants'
import { Markdown } from '@/components/interfaces/Markdown'
import { getInfrastructurePath } from '@/components/interfaces/Settings/Infrastructure/Infrastructure.utils'
import { REPLICA_STATUS } from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicas.constants'
import {
getAddReadReplicaPath,
getInfrastructurePath,
} from '@/components/interfaces/Settings/Infrastructure/Infrastructure.utils'
import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
import { formatDatabaseID, formatDatabaseRegion } from '@/data/read-replicas/replicas.utils'
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
@@ -74,7 +77,7 @@ export const DatabaseSelector = ({
const selectedAdditionalOption = additionalOptions.find((x) => x.id === selectedDatabaseId)
const newReplicaURL = `/project/${projectRef}/database/replication?destinationType=Read+Replica`
const newReplicaURL = getAddReadReplicaPath(projectRef)
useEffect(() => {
if (_selectedDatabaseId && !isForm) state.setSelectedDatabaseId(_selectedDatabaseId)
@@ -72,7 +72,7 @@ export const UpgradePlanButton = ({
? `/org/${slug ?? '_'}/billing?panel=costControl&source=${source}`
: isOnPaidPlanAndRequestingToPurchaseAddon
? addon === 'computeSize'
? getInfrastructurePath(ref)
? `${getInfrastructurePath(ref)}#compute`
: `/project/${ref ?? '_'}/settings/addons?panel=${addon}&source=${source}`
: `/org/${slug ?? '_'}/billing?panel=subscriptionPlan&source=${source}`
@@ -16,7 +16,7 @@ vi.mock('@/data/database/enable-physical-backups-mutation', () => ({
useEnablePhysicalBackupsMutation: () => ({ mutate: vi.fn(), isPending: false }),
}))
vi.mock('@/hooks/misc/useSelectedOrganization', () => ({
useSelectedOrganizationQuery: () => ({ data: { slug: 'test-org' } }),
useSelectedOrganizationQuery: () => ({ data: { slug: 'test-org', plan: { id: 'pro' } } }),
}))
vi.mock('@/hooks/misc/useSelectedProject', () => ({
useSelectedProjectQuery: () => ({ data: { dbVersion: 'supabase-postgres-15.1.0' } }),
@@ -52,6 +52,7 @@ describe('ReadReplicaEligibilityWarnings – below small compute', () => {
"This is to ensure that read replicas can keep up with the primary databases' activities."
)
).toBeInTheDocument()
expect(screen.getByRole('button', { name: /change to small compute/i })).toBeInTheDocument()
})
})
@@ -67,7 +68,7 @@ describe('ReadReplicaEligibilityWarnings – max replicas reached', () => {
screen.getByText('You can only deploy up to 4 read replicas at once')
).toBeInTheDocument()
expect(screen.getByText(/you may deploy up to/i)).toBeInTheDocument()
expect(screen.getByRole('link', { name: /change compute size/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /change to xl compute/i })).toBeInTheDocument()
})
it('does NOT show the compute upsell when already at the default cap (XL+)', () => {
@@ -82,6 +83,6 @@ describe('ReadReplicaEligibilityWarnings – max replicas reached', () => {
).toBeInTheDocument()
expect(screen.queryByText(/you may deploy up to/i)).not.toBeInTheDocument()
expect(screen.queryByText(/XL compute or higher/i)).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: /change compute size/i })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /change to xl compute/i })).not.toBeInTheDocument()
})
})