From 3ff593b8b6cc35549bb9e8271dbc528b0498c83c Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:42:14 +1000 Subject: [PATCH] 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. --- .../DiskManagement/DiskManagementForm.tsx | 55 +++++++++++++++++ .../DiskManagementPanelForm.tsx | 4 +- .../fields/ComputeSizeField.tsx | 5 +- .../Subscription/Subscription.tsx | 2 +- .../DatabaseParametersSubMenu.tsx | 3 +- .../ReadReplicaEligibilityWarnings.tsx | 61 ++++++++++++++----- .../ReadReplicas/recommendCompute.test.ts | 38 ++++++++++++ .../ReadReplicas/recommendCompute.ts | 42 +++++++++++++ .../layouts/ProjectLayout/ResizingState.tsx | 8 +-- .../studio/components/ui/DatabaseSelector.tsx | 9 ++- .../components/ui/UpgradePlanButton.tsx | 2 +- .../ReadReplicaEligibilityWarnings.test.tsx | 7 ++- 12 files changed, 203 insertions(+), 33 deletions(-) create mode 100644 apps/studio/components/interfaces/Settings/Infrastructure/ReadReplicas/recommendCompute.test.ts create mode 100644 apps/studio/components/interfaces/Settings/Infrastructure/ReadReplicas/recommendCompute.ts diff --git a/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx b/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx index 8c8f885398f..eb7db775117 100644 --- a/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx +++ b/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx @@ -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 diff --git a/apps/studio/components/interfaces/DiskManagement/DiskManagementPanelForm.tsx b/apps/studio/components/interfaces/DiskManagement/DiskManagementPanelForm.tsx index 724da3d3ec6..0874546d7ac 100644 --- a/apps/studio/components/interfaces/DiskManagement/DiskManagementPanelForm.tsx +++ b/apps/studio/components/interfaces/DiskManagement/DiskManagementPanelForm.tsx @@ -30,8 +30,8 @@ export function DiskManagementPanelForm() { Go to Infrastructure diff --git a/apps/studio/components/interfaces/DiskManagement/fields/ComputeSizeField.tsx b/apps/studio/components/interfaces/DiskManagement/fields/ComputeSizeField.tsx index 2ca4c01d0e6..b7d48f020b1 100644 --- a/apps/studio/components/interfaces/DiskManagement/fields/ComputeSizeField.tsx +++ b/apps/studio/components/interfaces/DiskManagement/fields/ComputeSizeField.tsx @@ -113,7 +113,7 @@ export function ComputeSizeField({ form, disabled }: ComputeSizeFieldProps) { render={({ field }) => (
{ 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) { )}
-
+
{ {currentPlan && currentPlan.id !== 'free' && ( - Cancel Subscription + Cancel subscription )}
diff --git a/apps/studio/components/interfaces/QuerySources/DatabaseParametersSubMenu.tsx b/apps/studio/components/interfaces/QuerySources/DatabaseParametersSubMenu.tsx index f946f4e03f8..16490bf0156 100644 --- a/apps/studio/components/interfaces/QuerySources/DatabaseParametersSubMenu.tsx +++ b/apps/studio/components/interfaces/QuerySources/DatabaseParametersSubMenu.tsx @@ -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 ( diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicaForm/ReadReplicaEligibilityWarnings.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicaForm/ReadReplicaEligibilityWarnings.tsx index e64bda607cf..fd0f691084b 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicaForm/ReadReplicaEligibilityWarnings.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicaForm/ReadReplicaEligibilityWarnings.tsx @@ -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(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 ( @@ -132,13 +157,22 @@ export const ReadReplicaEligibilityWarnings = () => { This is to ensure that read replicas can keep up with the primary databases' activities.

- + {isFreePlan ? ( + + ) : ( + + )}
@@ -229,16 +263,15 @@ export const ReadReplicaEligibilityWarnings = () => { {READ_REPLICAS_MAX_COUNT} replicas if your project is on an XL compute or higher.

- + handleRecommendCompute(RECOMMENDED_COMPUTE_FOR_READ_REPLICAS.unlockMaxReplicas) + } > - Change compute size - + Change to XL compute + )} diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/ReadReplicas/recommendCompute.test.ts b/apps/studio/components/interfaces/Settings/Infrastructure/ReadReplicas/recommendCompute.test.ts new file mode 100644 index 00000000000..74686deaa7f --- /dev/null +++ b/apps/studio/components/interfaces/Settings/Infrastructure/ReadReplicas/recommendCompute.test.ts @@ -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') + }) +}) diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/ReadReplicas/recommendCompute.ts b/apps/studio/components/interfaces/Settings/Infrastructure/ReadReplicas/recommendCompute.ts new file mode 100644 index 00000000000..6fc56882154 --- /dev/null +++ b/apps/studio/components/interfaces/Settings/Infrastructure/ReadReplicas/recommendCompute.ts @@ -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([ + 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 +} diff --git a/apps/studio/components/layouts/ProjectLayout/ResizingState.tsx b/apps/studio/components/layouts/ProjectLayout/ResizingState.tsx index cd5a2438779..94ac6662c50 100644 --- a/apps/studio/components/layouts/ProjectLayout/ResizingState.tsx +++ b/apps/studio/components/layouts/ProjectLayout/ResizingState.tsx @@ -28,12 +28,10 @@ export const ResizingState = () => {
-

Resizing Project Compute size

+

Resizing project

- Your project is being restarted to apply compute size changes. -

-

- 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.

diff --git a/apps/studio/components/ui/DatabaseSelector.tsx b/apps/studio/components/ui/DatabaseSelector.tsx index 212ffc77a96..3f099341a67 100644 --- a/apps/studio/components/ui/DatabaseSelector.tsx +++ b/apps/studio/components/ui/DatabaseSelector.tsx @@ -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) diff --git a/apps/studio/components/ui/UpgradePlanButton.tsx b/apps/studio/components/ui/UpgradePlanButton.tsx index 2967b1b0d7f..2fe24002eff 100644 --- a/apps/studio/components/ui/UpgradePlanButton.tsx +++ b/apps/studio/components/ui/UpgradePlanButton.tsx @@ -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}` diff --git a/apps/studio/tests/components/Settings/Infrastructure/ReadReplicaEligibilityWarnings.test.tsx b/apps/studio/tests/components/Settings/Infrastructure/ReadReplicaEligibilityWarnings.test.tsx index c9471145d67..0715c150c1a 100644 --- a/apps/studio/tests/components/Settings/Infrastructure/ReadReplicaEligibilityWarnings.test.tsx +++ b/apps/studio/tests/components/Settings/Infrastructure/ReadReplicaEligibilityWarnings.test.tsx @@ -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() }) })