diff --git a/apps/studio/components/interfaces/ProjectCreation/AdvancedConfiguration.tsx b/apps/studio/components/interfaces/ProjectCreation/AdvancedConfiguration.tsx index d903acadbf3..1e9a21290d4 100644 --- a/apps/studio/components/interfaces/ProjectCreation/AdvancedConfiguration.tsx +++ b/apps/studio/components/interfaces/ProjectCreation/AdvancedConfiguration.tsx @@ -1,4 +1,5 @@ import { useFlag } from 'common' +import type { ReactNode } from 'react' import { UseFormReturn } from 'react-hook-form' import { Badge, @@ -23,9 +24,21 @@ import { DOCS_URL } from '@/lib/constants' interface AdvancedConfigurationProps { form: UseFormReturn + /** Hidden when no OrioleDB image is available for the current selection. */ + hasPostgresTypeOption?: boolean + open?: boolean + onOpenChange?: (open: boolean) => void + /** Extra fields rendered above the Postgres type field, e.g. the region picker in option B. */ + children?: ReactNode } -export const AdvancedConfiguration = ({ form }: AdvancedConfigurationProps) => { +export const AdvancedConfiguration = ({ + form, + hasPostgresTypeOption = true, + open, + onOpenChange, + children, +}: AdvancedConfigurationProps) => { const disableOrioleProjectCreation = useFlag('disableOrioleProjectCreation') return ( @@ -33,86 +46,97 @@ export const AdvancedConfiguration = ({ form }: AdvancedConfigurationProps) => { - ( - <> - - - field.onChange(value === 'true')} - defaultValue={field.value.toString()} +
+ {children} + + {hasPostgresTypeOption && ( + ( + <> + - - - - Postgres - Default - - } - description="Recommended for production workloads" - className="[&>div>div>p]:text-left [&>div>div>p]:text-xs [&>div>div>div]:flex [&>div>div>div]:items-center [&>div>div>div]:gap-x-2" - /> - - - - - - + + field.onChange(value === 'true')} + defaultValue={field.value.toString()} + > + + - Postgres with OrioleDB - Beta + Postgres + Default } - description="Not recommended for production workloads" - className={cn( - '[&>div>div>p]:text-left [&>div>div>p]:text-xs [&>div>div>div]:flex [&>div>div>div]:items-center [&>div>div>div]:gap-x-2', - form.getValues('useOrioleDb') ? 'rounded-b-none!' : '' - )} - disabled={disableOrioleProjectCreation} + description="Recommended for production workloads" + className="[&>div>div>p]:text-left [&>div>div>p]:text-xs [&>div>div>div]:flex [&>div>div>div]:items-center [&>div>div>div]:gap-x-2" /> - - {disableOrioleProjectCreation && ( - - OrioleDB is temporarily disabled for new projects. Please try again - later. - - )} - - - - - - {form.getValues('useOrioleDb') && ( - - - - )} - - + + + + + + + + Postgres with OrioleDB + Beta + + } + description="Not recommended for production workloads" + className={cn( + '[&>div>div>p]:text-left [&>div>div>p]:text-xs [&>div>div>div]:flex [&>div>div>div]:items-center [&>div>div>div]:gap-x-2', + form.getValues('useOrioleDb') ? 'rounded-b-none!' : '' + )} + disabled={disableOrioleProjectCreation} + /> + + {disableOrioleProjectCreation && ( + + OrioleDB is temporarily disabled for new projects. Please try + again later. + + )} + + + + + + {form.getValues('useOrioleDb') && ( + + + + )} + + + )} + /> )} - /> +
) diff --git a/apps/studio/components/interfaces/ProjectCreation/AdvancedRegionConfig.test.ts b/apps/studio/components/interfaces/ProjectCreation/AdvancedRegionConfig.test.ts new file mode 100644 index 00000000000..23c6de25248 --- /dev/null +++ b/apps/studio/components/interfaces/ProjectCreation/AdvancedRegionConfig.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, test } from 'vitest' + +import { + parseAdvancedRegionConfigVariant, + resolveAdvancedRegionConfigVariant, +} from './AdvancedRegionConfig' + +describe('parseAdvancedRegionConfigVariant', () => { + test('returns known variants', () => { + expect(parseAdvancedRegionConfigVariant('option_a')).toBe('option_a') + expect(parseAdvancedRegionConfigVariant('control')).toBe('control') + }) + + test('returns undefined for anything else', () => { + expect(parseAdvancedRegionConfigVariant('option_d')).toBeUndefined() + expect(parseAdvancedRegionConfigVariant(true)).toBeUndefined() + expect(parseAdvancedRegionConfigVariant(undefined)).toBeUndefined() + }) +}) + +describe('resolveAdvancedRegionConfigVariant', () => { + test('defaults to control', () => { + expect( + resolveAdvancedRegionConfigVariant({ + queryParamValue: null, + flagValue: undefined, + isProduction: false, + }) + ).toBe('control') + }) + + test('falls back to control for an unknown flag value', () => { + expect( + resolveAdvancedRegionConfigVariant({ + queryParamValue: null, + flagValue: 'option_z', + isProduction: false, + }) + ).toBe('control') + }) + + test('uses the remote flag when there is no override', () => { + expect( + resolveAdvancedRegionConfigVariant({ + queryParamValue: null, + flagValue: 'option_b', + isProduction: true, + }) + ).toBe('option_b') + }) + + test('the query param wins outside production', () => { + expect( + resolveAdvancedRegionConfigVariant({ + queryParamValue: 'option_c', + flagValue: 'option_b', + isProduction: false, + }) + ).toBe('option_c') + }) + + test('the query param is ignored in production', () => { + expect( + resolveAdvancedRegionConfigVariant({ + queryParamValue: 'option_c', + flagValue: 'option_b', + isProduction: true, + }) + ).toBe('option_b') + }) + + test('an unknown query param falls through to the flag', () => { + expect( + resolveAdvancedRegionConfigVariant({ + queryParamValue: 'nonsense', + flagValue: 'option_a', + isProduction: false, + }) + ).toBe('option_a') + }) +}) diff --git a/apps/studio/components/interfaces/ProjectCreation/AdvancedRegionConfig.tsx b/apps/studio/components/interfaces/ProjectCreation/AdvancedRegionConfig.tsx new file mode 100644 index 00000000000..b81cd47de43 --- /dev/null +++ b/apps/studio/components/interfaces/ProjectCreation/AdvancedRegionConfig.tsx @@ -0,0 +1,404 @@ +import { ChevronRight } from 'lucide-react' +import { parseAsString, useQueryState } from 'nuqs' +import { useEffect, useState } from 'react' +import { UseFormReturn } from 'react-hook-form' +import { + Button, + FormControl, + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectSeparator, + SelectTrigger, + SelectValue, +} from 'ui' +import { Admonition } from 'ui-patterns/Admonition' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' + +import { CreateProjectForm } from './ProjectCreation.schema' +import { + GeneralRegionSelectItems, + RegionSelectTriggerValue, + SpecificRegionSelectItems, + useRegionSelectorOptions, + type RegionSelectProps, +} from './RegionSelector.shared' +import { SELECT_DIFFERENT_REGION } from './RegionSelector.utils' +import Panel from '@/components/ui/Panel' +import { RegionFlag } from '@/components/ui/RegionFlag' +import type { DesiredInstanceSize } from '@/data/projects/new-project.constants' +import { usePHFlag } from '@/hooks/ui/useFlag' + +/** + * Advanced region config experiment (P-PROD-4259). Everything experiment-specific lives in this + * file: variant resolution, the instrumentation stubs, and the three treatments. Deleting this + * file plus its two call sites in RegionSelector/ProjectCreationForm removes the experiment. + */ + +export const ADVANCED_REGION_CONFIG_VARIANTS = [ + 'control', + 'option_a', + 'option_b', + 'option_c', +] as const + +export type AdvancedRegionConfigVariant = (typeof ADVANCED_REGION_CONFIG_VARIANTS)[number] + +export const ADVANCED_REGION_CONFIG_FLAG_KEY = 'advancedRegionConfig' +export const ADVANCED_REGION_CONFIG_EXPERIMENT_ID = 'advanced_region_config' +export const ADVANCED_REGION_CONFIG_QUERY_PARAM = 'regionVariant' + +export function parseAdvancedRegionConfigVariant( + value: unknown +): AdvancedRegionConfigVariant | undefined { + return ADVANCED_REGION_CONFIG_VARIANTS.find((variant) => variant === value) +} + +/** + * Resolution order, first match wins: + * 1. `?regionVariant=` — ignored in production so the override can't leak into the experiment + * 2. the remote PostHog flag value + * 3. `control` + */ +export function resolveAdvancedRegionConfigVariant({ + queryParamValue, + flagValue, + isProduction, +}: { + queryParamValue: string | null | undefined + flagValue: string | boolean | undefined + isProduction: boolean +}): AdvancedRegionConfigVariant { + if (!isProduction) { + const override = parseAdvancedRegionConfigVariant(queryParamValue) + if (override !== undefined) return override + } + + return parseAdvancedRegionConfigVariant(flagValue) ?? 'control' +} + +/** Variant assignment. Unknown flag values and unknown query params both fall back to `control`. */ +export function useAdvancedRegionConfigVariant(): AdvancedRegionConfigVariant { + const [queryParamValue] = useQueryState(ADVANCED_REGION_CONFIG_QUERY_PARAM, parseAsString) + const flagValue = usePHFlag(ADVANCED_REGION_CONFIG_FLAG_KEY) + + return resolveAdvancedRegionConfigVariant({ + queryParamValue, + flagValue, + isProduction: process.env.NEXT_PUBLIC_ENVIRONMENT === 'prod', + }) +} + +/** + * TODO(P-PROD-4259): prototype-only no-op instrumentation. + * + * Each handler below is where the real PostHog event belongs once a variant wins. Wire them up + * the way the existing project-creation experiments do: + * - exposure -> `useTrackExperimentExposure(ADVANCED_REGION_CONFIG_EXPERIMENT_ID, variant)` from + * `hooks/misc/useTrackExperimentExposure.ts`, which is what the plan-presentation experiment + * uses and what the Best-available region work would plug into + * - the two interaction events -> `useTrack()` from `lib/telemetry/track`, after adding the event + * names to `packages/common/telemetry-constants.ts` + * - carry the resolved variant as a property on every event, and add it to the existing + * `project_creation_simple_version_submitted` payload in ProjectCreationForm the same way + * `dataApiRevokeOnCreateDefaultEnabled` is attached there + */ +export function useAdvancedRegionConfigTelemetry(variant: AdvancedRegionConfigVariant) { + useEffect(() => { + // TODO(P-PROD-4259): variant exposure + void variant + }, [variant]) + + const trackDisclosureOpened = () => { + // TODO(P-PROD-4259): disclosure opened + void variant + } + + const trackSpecificRegionSelected = (_regionCode: string | undefined) => { + // TODO(P-PROD-4259): specific region selected + void variant + } + + return { trackDisclosureOpened, trackSpecificRegionSelected } +} + +const TRIGGER_CLASS = + '[&>:nth-child(1)]:w-full [&>:nth-child(1)]:flex [&>:nth-child(1)]:items-start' + +/** + * Option A — the specific regions stay in the same dropdown, but behind an inline disclosure at + * the bottom of the list. + */ +export const RegionSelectorOptionA = ({ + options, + value, + onChange, + triggerRef, + onDisclosureOpened, + onSpecificRegionSelected, +}: RegionSelectProps) => { + const { + generalRegions, + specificRegions, + recommendedGeneralRegionCodes, + recommendedSpecificRegionCodes, + selectedRegion, + selectedRegionLabel, + specificRegionsLabel, + hasGeneralRegions, + isLoading, + } = options + + const [hasOpenedDisclosure, setHasOpenedDisclosure] = useState(false) + const isSpecificRegionSelected = specificRegions.some((region) => region.name === value) + const areSpecificRegionsVisible = + hasOpenedDisclosure || isSpecificRegionSelected || !hasGeneralRegions + + const handleOpenDisclosure = () => { + setHasOpenedDisclosure(true) + onDisclosureOpened() + } + + const handleValueChange = (nextValue: string) => { + const specificRegion = specificRegions.find((region) => region.name === nextValue) + if (specificRegion !== undefined) onSpecificRegionSelected(specificRegion.code) + onChange(nextValue) + } + + return ( + + + + ) +} + +/** + * Option B — the main form only reports the current region as text; the picker itself is rendered + * inside the "Advanced Configuration" section by ProjectCreationForm. + */ +export const RegionSummaryRow = ({ + form, + hasSelectedOrganization, + instanceSize, + onChangeRegionClick, +}: { + form: UseFormReturn + hasSelectedOrganization: boolean + instanceSize?: DesiredInstanceSize + onChangeRegionClick: () => void +}) => { + const { selectedRegion, selectedRegionLabel, selectedRestrictionCopy, isLoading } = + useRegionSelectorOptions({ form, hasSelectedOrganization, instanceSize }) + + return ( + + +
+ {!isLoading && selectedRegion?.code && ( + + )} + + {isLoading ? 'Loading available regions...' : (selectedRegionLabel ?? 'Not selected')} + + +
+
+ + {selectedRestrictionCopy !== undefined && ( + + + + )} +
+ ) +} + +/** + * Sentinel for the "Choose a specific region" entry in the first dropdown. It is never written to + * `dbRegion` — picking it only reveals the second selector. + */ +const CHOOSE_SPECIFIC_REGION = '__choose_specific_region__' + +/** + * Option C — two-step selector. The first dropdown offers the general regions plus an entry that + * reveals a second dropdown holding the specific regions. + */ +export const RegionSelectorOptionC = ({ + options, + value, + onChange, + triggerRef, + onDisclosureOpened, + onSpecificRegionSelected, +}: RegionSelectProps) => { + const { + generalRegions, + specificRegions, + recommendedGeneralRegionCodes, + recommendedSpecificRegionCodes, + selectedRegion, + selectedRegionLabel, + specificRegionsLabel, + hasGeneralRegions, + isLoading, + } = options + + const [isChoosingSpecificRegion, setIsChoosingSpecificRegion] = useState(false) + const isSpecificRegionSelected = specificRegions.some((region) => region.name === value) + const isSpecificSelectVisible = + isChoosingSpecificRegion || isSpecificRegionSelected || !hasGeneralRegions + + const handleGeneralValueChange = (nextValue: string) => { + if (nextValue === CHOOSE_SPECIFIC_REGION) { + setIsChoosingSpecificRegion(true) + onDisclosureOpened() + return + } + + // Picking a general region clears the specific choice by overwriting `dbRegion` outright. + setIsChoosingSpecificRegion(false) + onChange(nextValue) + } + + const handleSpecificValueChange = (nextValue: string) => { + const specificRegion = specificRegions.find((region) => region.name === nextValue) + if (specificRegion !== undefined) onSpecificRegionSelected(specificRegion.code) + onChange(nextValue) + } + + return ( +
+ + + + + {isSpecificSelectVisible && ( + + )} +
+ ) +} diff --git a/apps/studio/components/interfaces/ProjectCreation/ProjectCreationForm.tsx b/apps/studio/components/interfaces/ProjectCreation/ProjectCreationForm.tsx index 45284928fb2..4aa7c8adf7d 100644 --- a/apps/studio/components/interfaces/ProjectCreation/ProjectCreationForm.tsx +++ b/apps/studio/components/interfaces/ProjectCreation/ProjectCreationForm.tsx @@ -14,6 +14,7 @@ import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { z } from 'zod' import { AdvancedConfiguration } from './AdvancedConfiguration' +import { RegionSummaryRow, useAdvancedRegionConfigVariant } from './AdvancedRegionConfig' import { ComputeSizeSelector } from './ComputeSizeSelector' import { DatabasePasswordInput } from './DatabasePasswordInput' import { DataSeeding } from './DataSeeding' @@ -132,6 +133,10 @@ export const ProjectCreationForm = ({ 'integrations.github_connections' ) const showAdvancedConfig = useIsFeatureEnabled('project_creation:show_advanced_config') + const regionVariant = useAdvancedRegionConfigVariant() + // Option B only applies where the advanced section actually renders — otherwise the region + // picker would have nowhere to live. + const isRegionInAdvancedConfig = regionVariant === 'option_b' && showAdvancedConfig const { hasAccess: hasAccessToGitHubIntegration } = useCheckEntitlements( 'integrations.github_connections' ) @@ -157,6 +162,8 @@ export const ProjectCreationForm = ({ const [isComputeCostsConfirmationModalVisible, setIsComputeCostsConfirmationModalVisible] = useState(false) const [projectCreationError, setProjectCreationError] = useState() + const [isAdvancedConfigOpen, setIsAdvancedConfigOpen] = useState(false) + const advancedConfigRef = useRef(null) const form = useForm>({ resolver: zodResolver(FormSchema), @@ -325,6 +332,15 @@ export const ProjectCreationForm = ({ { enabled: currentOrg !== null } ) + const hasPostgresTypeOption = + showAdvancedConfig && !!availableOrioleVersion && highAvailability !== true + const isAdvancedConfigVisible = hasPostgresTypeOption || isRegionInAdvancedConfig + + const handleChangeRegionClick = () => { + setIsAdvancedConfigOpen(true) + advancedConfigRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }) + } + const userPrimaryEmail = profile?.primary_email?.toLowerCase() const isUserAtFreeProjectLimit = userPrimaryEmail ? membersExceededLimit.some( @@ -777,11 +793,21 @@ export const ProjectCreationForm = ({ - + {isRegionInAdvancedConfig ? ( + + ) : ( + + )} {isVercelIntegrationFlow && !!externalId && } @@ -789,9 +815,25 @@ export const ProjectCreationForm = ({ {showInternalOnlyConfiguration && } - {showAdvancedConfig && - !!availableOrioleVersion && - highAvailability !== true && } + {isAdvancedConfigVisible && ( +
+ + {isRegionInAdvancedConfig && ( + + )} + +
+ )} {shouldShowFreeProjectInfo ? ( void + triggerRef: Ref + onDisclosureOpened: () => void + onSpecificRegionSelected: (regionCode: string | undefined) => void +} + +/** Backend region names that read better in the UI under a different label. */ +export const getDisplayNameForGeneralRegion = (name: string): string => + name === 'APAC' ? 'Asia-Pacific' : name + +export type RegionSelectorOptions = { + dbRegion: string | undefined + generalRegions: SelectableRegion[] + specificRegions: RestrictableRegion[] + recommendedGeneralRegionCodes: Set + recommendedSpecificRegionCodes: Set + selectedRegion: SelectableRegion | undefined + selectedRegionLabel: string | undefined + selectedRestrictionCopy: RegionRestrictionCopy | undefined + specificRegionsLabel: string + hasGeneralRegions: boolean + isLoading: boolean + isError: boolean + error: ResponseError | null + affectingIncidents: unknown[] + isStatusPageEnabled: boolean + isHighAvailabilityRestricted: boolean + highAvailabilityRegionLabel: string | undefined +} + +/** + * Region data, derived option lists and selection bookkeeping shared by every rendering of the + * region picker, so no experiment variant has to duplicate any of it. + */ +export function useRegionSelectorOptions({ + form, + hasSelectedOrganization, + instanceSize, +}: { + form: UseFormReturn + hasSelectedOrganization: boolean + instanceSize?: DesiredInstanceSize +}): RegionSelectorOptions { + const { slug } = useParams() + const cloudProvider = useWatch({ control: form.control, name: 'cloudProvider' }) as CloudProvider + const highAvailability = useWatch({ control: form.control, name: 'highAvailability' }) + const dbRegion = useWatch({ control: form.control, name: 'dbRegion' }) + const highAvailabilityRegionCode = getHighAvailabilityRegionCode() + + const { hasLoaded: flagsLoaded } = useFeatureFlags() + const smartRegionEnabled = cloudProvider !== 'AWS_NIMBUS' + const isStatusPageEnabled = useFlag('incidentIoStatusPage') === true + + const { getRegionRestriction } = useRegionRestriction() + + const { data: statusData } = useIncidentStatusQuery({ enabled: !isStatusPageEnabled }) + const { incidents = [] } = statusData ?? {} + + const { isPending: isLoadingDefaultRegion } = useDefaultRegionQuery( + { cloudProvider }, + { enabled: flagsLoaded && !smartRegionEnabled } + ) + + const { + data: availableRegionsData, + isPending: isLoadingAvailableRegions, + isError, + error, + } = useOrganizationAvailableRegionsQuery( + { slug, cloudProvider, desiredInstanceSize: instanceSize, highAvailability }, + { enabled: smartRegionEnabled && hasSelectedOrganization, staleTime: 1000 * 60 * 5 } + ) + + const isLocal = process.env.NEXT_PUBLIC_ENVIRONMENT === 'local' + const allSmartRegions = availableRegionsData?.all.smartGroup ?? [] + const allRegions = availableRegionsData?.all.specific ?? [] + const isHighAvailabilityRestricted = + highAvailability && !isLocal && highAvailabilityRegionCode !== undefined + const generalRegions = highAvailability ? [] : allSmartRegions + + const recommendedGeneralRegionCodes = new Set( + [availableRegionsData?.recommendations.smartGroup.code].filter(Boolean) + ) + const recommendedSpecificRegionCodes = new Set( + availableRegionsData?.recommendations.specific.map((region) => region.code) + ) + + const regionsArray = Object.entries(getAvailableRegions(cloudProvider)).map(([_key, value]) => ({ + code: value.code, + name: value.displayName, + provider: cloudProvider, + status: undefined, + })) + + const unfilteredRegionOptions = smartRegionEnabled ? allRegions : regionsArray + const regionOptions = filterHighAvailabilityRegions( + [...unfilteredRegionOptions], + highAvailability + ) + const specificRegions: RestrictableRegion[] = regionOptions.map((region) => ({ + ...region, + restriction: getRegionRestriction(region), + })) + const isLoading = smartRegionEnabled ? isLoadingAvailableRegions : isLoadingDefaultRegion + + const allSelectableRegions = [...generalRegions, ...regionOptions] + + // react-hook-form intermittently drops this field's value when its Controller + // remounts (e.g. a sibling section mounting/unmounting in the same update, such as + // toggling high availability), so a one-shot effect isn't enough. Instead this effect + // re-asserts off the watched value: a region present in the current list is kept (and + // remembered in lastValidRegionRef), and when it's missing or cleared out from under us + // it restores the last valid region. allSelectableRegions is intentionally omitted from + // deps — it's a new array every render, and comparing it by reference would defeat the + // point of reacting to genuine content changes on every render where they occur. + const lastValidRegionRef = useRef(undefined) + useEffect(() => { + if (allSelectableRegions.length === 0) return + const isRegionAvailable = (name: string | undefined) => + !!name && allSelectableRegions.some((region) => region.name === name) + + if (isRegionAvailable(dbRegion)) { + lastValidRegionRef.current = dbRegion + return + } + + const lastValidRegion = lastValidRegionRef.current + if (lastValidRegion !== undefined && isRegionAvailable(lastValidRegion)) { + form.setValue('dbRegion', lastValidRegion) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dbRegion, form]) + + const selectedRegion = allSelectableRegions.find( + (region) => !!region.name && region.name === dbRegion + ) + const selectedRestriction = specificRegions.find( + (region) => region.name === dbRegion + )?.restriction + + return { + dbRegion, + generalRegions, + specificRegions, + recommendedGeneralRegionCodes, + recommendedSpecificRegionCodes, + selectedRegion, + selectedRegionLabel: selectedRegion?.name + ? getDisplayNameForGeneralRegion(selectedRegion.name) + : dbRegion, + selectedRestrictionCopy: + selectedRestriction !== undefined ? getRegionRestrictionCopy(selectedRestriction) : undefined, + specificRegionsLabel: highAvailability ? 'High Availability Regions' : 'Specific regions', + hasGeneralRegions: smartRegionEnabled && !highAvailability, + isLoading, + isError, + error, + affectingIncidents: incidents.filter((incident) => { + const affectedRegions = incident.cache?.affected_regions ?? [] + if (affectedRegions.length === 0 || selectedRegion?.code === undefined) return false + return affectedRegions.some((affectedRegion) => + regionMatches(selectedRegion.code, affectedRegion) + ) + }), + isStatusPageEnabled, + isHighAvailabilityRestricted, + highAvailabilityRegionLabel: regionOptions[0]?.name ?? highAvailabilityRegionCode, + } +} + +export const RegionSelectTriggerValue = ({ + region, + label, + isLoading, +}: { + region: SelectableRegion | undefined + label: string | undefined + isLoading: boolean +}) => ( +
+ {isLoading && } + {region?.code && } + {label} +
+) + +export const GeneralRegionSelectItems = ({ + regions, + recommendedCodes, +}: { + regions: SelectableRegion[] + recommendedCodes: Set +}) => + regions.map((region) => ( + +
+
+ + {getDisplayNameForGeneralRegion(region.name)} +
+ +
+ {recommendedCodes.has(region.code) && ( + + Recommended + + )} +
+
+
+ )) + +export const SpecificRegionSelectItems = ({ + regions, + recommendedCodes, +}: { + regions: RestrictableRegion[] + recommendedCodes: Set +}) => + regions.map((region) => { + const restrictionCopy = + region.restriction !== undefined ? getRegionRestrictionCopy(region.restriction) : undefined + + return ( + :nth-child(2)]:w-full', + restrictionCopy !== undefined && 'pointer-events-auto!' + )} + > +
+
+ +
+ {region.name} + {region.code} +
+
+ + {recommendedCodes.has(region.code) && ( + + Recommended + + )} + + {restrictionCopy !== undefined && ( + + + + {restrictionCopy.badge} + + + {restrictionCopy.tooltip} + + )} +
+
+ ) + }) diff --git a/apps/studio/components/interfaces/ProjectCreation/RegionSelector.tsx b/apps/studio/components/interfaces/ProjectCreation/RegionSelector.tsx index da573afda9d..0a9f386ee42 100644 --- a/apps/studio/components/interfaces/ProjectCreation/RegionSelector.tsx +++ b/apps/studio/components/interfaces/ProjectCreation/RegionSelector.tsx @@ -1,49 +1,38 @@ -import { useFeatureFlags, useFlag, useParams } from 'common' -import { Loader2 } from 'lucide-react' -import { useEffect, useRef } from 'react' import { UseFormReturn } from 'react-hook-form' -import type { CloudProvider } from 'shared-data' import { - Badge, - cn, FormControl, FormField, Select, SelectContent, SelectGroup, - SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue, - Tooltip, - TooltipContent, - TooltipTrigger, - useWatch, } from 'ui' import { Admonition } from 'ui-patterns/Admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' -import { CreateProjectForm } from './ProjectCreation.schema' import { - filterHighAvailabilityRegions, - getAvailableRegions, - getHighAvailabilityRegionCode, -} from './ProjectCreation.utils' + RegionSelectorOptionA, + RegionSelectorOptionC, + useAdvancedRegionConfigTelemetry, + type AdvancedRegionConfigVariant, +} from './AdvancedRegionConfig' +import { CreateProjectForm } from './ProjectCreation.schema' import { ProjectCreationStatusAdmonition } from './ProjectCreationStatusAdmonition' import { - getRegionRestrictionCopy, - regionMatches, - SELECT_DIFFERENT_REGION, -} from './RegionSelector.utils' -import { useRegionRestriction } from './useRegionRestriction' + GeneralRegionSelectItems, + RegionSelectTriggerValue, + SpecificRegionSelectItems, + useRegionSelectorOptions, + type RegionSelectorOptions, + type RegionSelectProps, +} from './RegionSelector.shared' +import { SELECT_DIFFERENT_REGION } from './RegionSelector.utils' import { AlertError } from '@/components/ui/AlertError' import { InlineLink } from '@/components/ui/InlineLink' import Panel from '@/components/ui/Panel' -import { RegionFlag } from '@/components/ui/RegionFlag' -import { useDefaultRegionQuery } from '@/data/misc/get-default-region-query' -import { useOrganizationAvailableRegionsQuery } from '@/data/organizations/organization-available-regions-query' -import { useIncidentStatusQuery } from '@/data/platform/incident-status-query' import type { DesiredInstanceSize } from '@/data/projects/new-project.constants' interface RegionSelectorProps { @@ -51,352 +40,199 @@ interface RegionSelectorProps { hasSelectedOrganization: boolean instanceSize?: DesiredInstanceSize layout?: 'vertical' | 'horizontal' + /** Experiment arm (P-PROD-4259). `option_b` renders the control picker — it only moves place. */ + variant?: AdvancedRegionConfigVariant + /** Set when rendered inside a section that already provides its own padding. */ + isEmbedded?: boolean } // [Joshen] Let's use a library to maintain the flag SVGs in the future // I tried using https://flagpack.xyz/docs/development/react/ but couldn't get it to render // ^ can try again next time -// Map backend region names to user-friendly display names -const getDisplayNameForSmartRegion = (name: string): string => { - if (name === 'APAC') { - return 'Asia-Pacific' - } - return name +const isLocalEnvironment = process.env.NEXT_PUBLIC_ENVIRONMENT === 'local' +const showNonProdFields = isLocalEnvironment || process.env.NEXT_PUBLIC_ENVIRONMENT === 'staging' + +/** The region picker as it ships today — the `control` arm of the experiment. */ +const RegionSelectControl = ({ options, value, onChange, triggerRef }: RegionSelectProps) => { + const { + generalRegions, + specificRegions, + recommendedGeneralRegionCodes, + recommendedSpecificRegionCodes, + selectedRegion, + selectedRegionLabel, + specificRegionsLabel, + hasGeneralRegions, + isLoading, + } = options + + return ( + + + + ) } -const isLocal = process.env.NEXT_PUBLIC_ENVIRONMENT === 'local' +const RegionSelectorDescription = ({ options }: { options: RegionSelectorOptions }) => { + if (options.isHighAvailabilityRestricted) { + return ( +
+ High Availability projects are currently limited to {options.highAvailabilityRegionLabel}. +
+ ) + } + + if (!showNonProdFields) return null + + return ( +
+

Only these regions are supported for local/staging projects:

+
    +
  • East US (North Virginia)
  • +
  • Central EU (Frankfurt)
  • +
  • Southeast Asia (Singapore)
  • +
+ {isLocalEnvironment && ( +

Use Central EU (Frankfurt) unless you're on a personal dev stack.

+ )} +
+ ) +} export const RegionSelector = ({ form, hasSelectedOrganization, instanceSize, layout = 'horizontal', + variant = 'control', + isEmbedded = false, }: RegionSelectorProps) => { - const { slug } = useParams() - const cloudProvider = form.getValues('cloudProvider') as CloudProvider - const highAvailability = useWatch({ control: form.control, name: 'highAvailability' }) - const dbRegion = useWatch({ control: form.control, name: 'dbRegion' }) - const highAvailabilityRegionCode = getHighAvailabilityRegionCode() + const options = useRegionSelectorOptions({ form, hasSelectedOrganization, instanceSize }) + const { trackDisclosureOpened, trackSpecificRegionSelected } = + useAdvancedRegionConfigTelemetry(variant) - const { hasLoaded: flagsLoaded } = useFeatureFlags() - const smartRegionEnabled = cloudProvider !== 'AWS_NIMBUS' - const isStatusPageEnabled = useFlag('incidentIoStatusPage') === true - - const { getRegionRestriction } = useRegionRestriction() - - const { data: statusData } = useIncidentStatusQuery({ enabled: !isStatusPageEnabled }) - const { incidents = [] } = statusData ?? {} - - const { isPending: isLoadingDefaultRegion } = useDefaultRegionQuery( - { cloudProvider }, - { enabled: flagsLoaded && !smartRegionEnabled } - ) - - const { - data: availableRegionsData, - isPending: isLoadingAvailableRegions, - isError: isErrorAvailableRegions, - error: errorAvailableRegions, - } = useOrganizationAvailableRegionsQuery( - { slug, cloudProvider, desiredInstanceSize: instanceSize, highAvailability }, - { - enabled: smartRegionEnabled && hasSelectedOrganization, - staleTime: 1000 * 60 * 5, - } - ) - - const allSmartRegions = availableRegionsData?.all.smartGroup ?? [] - const allRegions = availableRegionsData?.all.specific ?? [] - const restrictHighAvailabilityRegion = - highAvailability && !isLocal && highAvailabilityRegionCode !== undefined - const smartRegions = highAvailability ? [] : allSmartRegions - - const recommendedSmartRegions = new Set( - [availableRegionsData?.recommendations.smartGroup.code].filter(Boolean) - ) - - const recommendedSpecificRegions = new Set( - availableRegionsData?.recommendations.specific.map((region) => region.code) - ) - - const availableRegions = getAvailableRegions(cloudProvider) - const regionsArray = Object.entries(availableRegions).map(([_key, value]) => { - return { - code: value.code, - name: value.displayName, - provider: cloudProvider, - status: undefined, - } - }) - - const unfilteredRegionOptions = smartRegionEnabled ? allRegions : regionsArray - const regionOptions = filterHighAvailabilityRegions( - [...unfilteredRegionOptions], - highAvailability - ) - const regionOptionsWithRestriction = regionOptions.map((region) => ({ - ...region, - restriction: getRegionRestriction(region), - })) - const isLoading = smartRegionEnabled ? isLoadingAvailableRegions : isLoadingDefaultRegion - - const isLocalEnvironment = process.env.NEXT_PUBLIC_ENVIRONMENT === 'local' - const showNonProdFields = isLocalEnvironment || process.env.NEXT_PUBLIC_ENVIRONMENT === 'staging' - - const allSelectableRegions = [...smartRegions, ...regionOptions] - - // react-hook-form intermittently drops this field's value when its Controller - // remounts (e.g. a sibling section mounting/unmounting in the same update, such as - // toggling high availability), so a one-shot effect isn't enough. Instead this effect - // re-asserts off the watched value: a region present in the current list is kept (and - // remembered in lastValidRegionRef), and when it's missing or cleared out from under us - // it restores the last valid region. allSelectableRegions is intentionally omitted from - // deps — it's a new array every render, and comparing it by reference would defeat the - // point of reacting to genuine content changes on every render where they occur. - const lastValidRegionRef = useRef(undefined) - useEffect(() => { - if (allSelectableRegions.length === 0) return - const isRegionAvailable = (name: string | undefined) => - !!name && allSelectableRegions.some((region) => region.name === name) - - if (isRegionAvailable(dbRegion)) { - lastValidRegionRef.current = dbRegion - return - } - - const lastValidRegion = lastValidRegionRef.current - if (lastValidRegion !== undefined && isRegionAvailable(lastValidRegion)) { - form.setValue('dbRegion', lastValidRegion) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [dbRegion, form]) - - if (isErrorAvailableRegions) { - return + if (options.isError) { + return } - return ( - - { - const selectedRegion = allSelectableRegions.find((region) => { - return !!region.name && region.name === dbRegion - }) + const content = ( + { + const controlProps: RegionSelectProps = { + options, + value: options.dbRegion, + onChange: (value: string) => { + if (value === '') return + field.onChange(value) + }, + triggerRef: field.ref, + onDisclosureOpened: trackDisclosureOpened, + onSpecificRegionSelected: trackSpecificRegionSelected, + } - const selectedRegionLabel = selectedRegion?.name - ? getDisplayNameForSmartRegion(selectedRegion.name) - : dbRegion - const selectedRestriction = regionOptionsWithRestriction.find( - (region) => region.name === dbRegion - )?.restriction - const selectedRestrictionCopy = - selectedRestriction !== undefined - ? getRegionRestrictionCopy(selectedRestriction) - : undefined - const triggerLabel = isLoading ? 'Loading available regions...' : selectedRegionLabel + const renderRegionControl = () => { + if (variant === 'option_a') return + if (variant === 'option_c') return + return + } - const affectingIncidents = incidents.filter((incident) => { - const affectedRegions = incident.cache?.affected_regions ?? [] - if (affectedRegions.length === 0 || selectedRegion?.code === undefined) return false + return ( + <> + } + > + {renderRegionControl()} + - return affectedRegions.some((affectedRegion) => - regionMatches(selectedRegion.code, affectedRegion) - ) - }) + {options.isStatusPageEnabled && ( + + )} - return ( - <> - - High Availability projects are currently limited to{' '} - {regionOptions[0]?.name ?? highAvailabilityRegionCode}. - - ) : showNonProdFields ? ( -
-

Only these regions are supported for local/staging projects:

-
    -
  • East US (North Virginia)
  • -
  • Central EU (Frankfurt)
  • -
  • Southeast Asia (Singapore)
  • -
- {isLocalEnvironment && ( -

- Use Central EU (Frankfurt) unless you're on a personal dev stack. -

- )} -
- ) : undefined - } - > - - - + {!options.isStatusPageEnabled && options.affectingIncidents.length > 0 && ( + + + We're currently investigating an issue that may impact projects in this + region. Follow updates on{' '} + + status.supabase.com + + . + + } + className="mt-3" + /> + )} - {isStatusPageEnabled ? ( - - ) : ( - affectingIncidents.length > 0 && ( - - - We're currently investigating an issue that may impact projects in this - region. Follow updates on{' '} - - status.supabase.com - - . - - } - className="mt-3" - /> - - ) - )} - - {selectedRestrictionCopy !== undefined && ( - - - - )} - - ) - }} - /> -
+ {options.selectedRestrictionCopy !== undefined && ( + + + + )} + + ) + }} + /> ) + + if (isEmbedded) return content + + return {content} } diff --git a/apps/studio/tests/pages/new/[slug].test.tsx b/apps/studio/tests/pages/new/[slug].test.tsx index 753e9a172ab..13012d32789 100644 --- a/apps/studio/tests/pages/new/[slug].test.tsx +++ b/apps/studio/tests/pages/new/[slug].test.tsx @@ -277,7 +277,9 @@ const DEFAULT_FLAGS = { projectCreationRestrictedRegions: false, } -async function renderWizard(options: { flags?: Record } = {}) { +async function renderWizard( + options: { flags?: Record; searchParams?: string } = {} +) { const { default: Wizard } = await import('@/pages/new/[slug]') return customRender( > , - { profileContext: createMockProfileContext() } + { + profileContext: createMockProfileContext(), + ...(options.searchParams ? { nuqs: { searchParams: options.searchParams } } : {}), + } ) } @@ -1236,4 +1241,102 @@ describe('project creation wizard', () => { ) ) }) + // P-PROD-4259 — the variants only move the picker around; every arm must still submit the + // same region_selection payload for both a general and a specific region. + describe('advanced region config experiment', () => { + // Option B renders two "Region" labels (the summary row and the picker in advanced + // configuration), so the shared helper can't disambiguate them. + const getLastSelectTriggerByLabel = (labelText: string) => { + const labels = screen.getAllByText(labelText) + const row = labels[labels.length - 1].closest( + '[data-formlayout-id="labelContainer"]' + )?.parentElement + const trigger = row?.querySelector('[role="combobox"]') + if (!trigger) throw new Error(`No combobox trigger found near label "${labelText}"`) + return trigger as HTMLElement + } + + const getTriggerByPlaceholder = (placeholder: string) => { + const trigger = screen.getByText(placeholder).closest('[role="combobox"]') + if (!trigger) throw new Error(`No combobox trigger found for "${placeholder}"`) + return trigger as HTMLElement + } + + const renderVariant = async (variant: string) => { + mockWizardEndpoints() + const onRequest = vi.fn() + mockCreateProject(onRequest) + + await renderWizard({ searchParams: `?regionVariant=${variant}` }) + + await fillProjectName(`${variant} project`) + await generateAndWaitForStrongPassword() + + return onRequest + } + + const submitAndReadRegion = async (onRequest: ReturnType) => { + fireEvent.click(screen.getByRole('button', { name: 'Create new project' })) + await waitFor(() => expect(onRequest).toHaveBeenCalled()) + return onRequest.mock.calls[0][0].region_selection + } + + test('option A submits a general region, and a specific region behind the inline disclosure', async () => { + const onRequest = await renderVariant('option_a') + + await selectRegion(/Americas/) + expect(getSelectTriggerByLabel('Region')).toHaveTextContent('Americas') + + await user.click(getSelectTriggerByLabel('Region')) + await user.click( + await screen.findByRole('button', { name: /Advanced: choose a specific region/ }) + ) + await user.click(await screen.findByRole('option', { name: /East US/ })) + + expect(await submitAndReadRegion(onRequest)).toMatchObject({ code: 'us-east-1' }) + }) + + test('option B keeps the picker in advanced configuration and mirrors it in the summary row', async () => { + const onRequest = await renderVariant('option_b') + + await waitFor(() => expect(screen.getByText('Americas')).toBeInTheDocument()) + expect(screen.queryByRole('combobox', { name: 'Region' })).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Change in advanced configuration' })) + + await user.click(await waitFor(() => getLastSelectTriggerByLabel('Region'))) + await user.click(await screen.findByRole('option', { name: /East US/ })) + + await waitFor(() => + expect(screen.getAllByText('East US (North Virginia)').length).toBeGreaterThan(1) + ) + expect(await submitAndReadRegion(onRequest)).toMatchObject({ code: 'us-east-1' }) + }) + + test('option C reveals a second selector for specific regions', async () => { + const onRequest = await renderVariant('option_c') + + await selectRegion(/Americas/) + + await user.click(getSelectTriggerByLabel('Region')) + await user.click(await screen.findByRole('option', { name: 'Choose a specific region' })) + + await user.click(getTriggerByPlaceholder('Select a specific region...')) + await user.click(await screen.findByRole('option', { name: /East US/ })) + + expect(await submitAndReadRegion(onRequest)).toMatchObject({ code: 'us-east-1' }) + }) + + test('option C keeps the previous general region until a specific one is picked', async () => { + const onRequest = await renderVariant('option_c') + + await selectRegion(/Americas/) + + await user.click(getSelectTriggerByLabel('Region')) + await user.click(await screen.findByRole('option', { name: 'Choose a specific region' })) + + expect(getTriggerByPlaceholder('Select a specific region...')).toBeInTheDocument() + expect(await submitAndReadRegion(onRequest)).toMatchObject({ code: 'americas' }) + }) + }) }) diff --git a/packages/ui-patterns/src/CollapsibleCardSection.tsx b/packages/ui-patterns/src/CollapsibleCardSection.tsx index a0d3df4ceb1..67c5e11876b 100644 --- a/packages/ui-patterns/src/CollapsibleCardSection.tsx +++ b/packages/ui-patterns/src/CollapsibleCardSection.tsx @@ -6,15 +6,19 @@ type CollapsibleCardSectionProps = PropsWithChildren<{ title: string description?: ReactNode defaultOpen?: boolean + open?: boolean + onOpenChange?: (open: boolean) => void }> export const CollapsibleCardSection = ({ title, description, defaultOpen = false, + open, + onOpenChange, children, }: CollapsibleCardSectionProps) => ( - + {title}