mirror of
https://github.com/supabase/supabase.git
synced 2026-10-07 18:35:07 +03:00
feat: multiple options for region config
This commit is contained in:
1 parent
87681812a0
commit
c834a5f220
8 files changed
+1218
-430
No files matched your search
@@ -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<CreateProjectForm>
|
||||
/** 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) => {
|
||||
<CollapsibleCardSection
|
||||
title="Advanced Configuration"
|
||||
description="These settings cannot be changed after the project is created"
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<FormField
|
||||
name="useOrioleDb"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<>
|
||||
<FormItemLayout
|
||||
layout="horizontal"
|
||||
label="Postgres Type"
|
||||
className="[&>div>label]:break-normal!"
|
||||
>
|
||||
<FormControl>
|
||||
<RadioGroupStacked
|
||||
// Due to radio group not supporting boolean values
|
||||
// value is converted to boolean
|
||||
onValueChange={(value) => field.onChange(value === 'true')}
|
||||
defaultValue={field.value.toString()}
|
||||
<div className="flex flex-col gap-y-6">
|
||||
{children}
|
||||
|
||||
{hasPostgresTypeOption && (
|
||||
<FormField
|
||||
name="useOrioleDb"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<>
|
||||
<FormItemLayout
|
||||
layout="horizontal"
|
||||
label="Postgres Type"
|
||||
className="[&>div>label]:break-normal!"
|
||||
>
|
||||
<FormItem asChild>
|
||||
<FormControl>
|
||||
<RadioGroupStackedItem
|
||||
value="false"
|
||||
// @ts-ignore
|
||||
label={
|
||||
<>
|
||||
Postgres
|
||||
<Badge>Default</Badge>
|
||||
</>
|
||||
}
|
||||
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"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
<FormItem asChild>
|
||||
<FormControl>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<FormControl>
|
||||
<RadioGroupStacked
|
||||
// Due to radio group not supporting boolean values
|
||||
// value is converted to boolean
|
||||
onValueChange={(value) => field.onChange(value === 'true')}
|
||||
defaultValue={field.value.toString()}
|
||||
>
|
||||
<FormItem asChild>
|
||||
<FormControl>
|
||||
<RadioGroupStackedItem
|
||||
value="true"
|
||||
value="false"
|
||||
// @ts-ignore
|
||||
label={
|
||||
<>
|
||||
Postgres with OrioleDB
|
||||
<Badge variant="warning">Beta</Badge>
|
||||
Postgres
|
||||
<Badge>Default</Badge>
|
||||
</>
|
||||
}
|
||||
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"
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
{disableOrioleProjectCreation && (
|
||||
<TooltipContent side="right" className="w-60 text-center">
|
||||
OrioleDB is temporarily disabled for new projects. Please try again
|
||||
later.
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
</RadioGroupStacked>
|
||||
</FormControl>
|
||||
{form.getValues('useOrioleDb') && (
|
||||
<Admonition
|
||||
type="warning"
|
||||
className="rounded-t-none [&>div]:text-xs"
|
||||
title="OrioleDB is not production ready"
|
||||
description="Postgres with OrioleDB extension is currently in Public Beta and not recommended for production usage yet."
|
||||
>
|
||||
<DocsButton className="mt-2" href={`${DOCS_URL}/guides/database/orioledb`} />
|
||||
</Admonition>
|
||||
)}
|
||||
</FormItemLayout>
|
||||
</>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
<FormItem asChild>
|
||||
<FormControl>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<RadioGroupStackedItem
|
||||
value="true"
|
||||
// @ts-ignore
|
||||
label={
|
||||
<>
|
||||
Postgres with OrioleDB
|
||||
<Badge variant="warning">Beta</Badge>
|
||||
</>
|
||||
}
|
||||
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}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
{disableOrioleProjectCreation && (
|
||||
<TooltipContent side="right" className="w-60 text-center">
|
||||
OrioleDB is temporarily disabled for new projects. Please try
|
||||
again later.
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
</RadioGroupStacked>
|
||||
</FormControl>
|
||||
{form.getValues('useOrioleDb') && (
|
||||
<Admonition
|
||||
type="warning"
|
||||
className="rounded-t-none [&>div]:text-xs"
|
||||
title="OrioleDB is not production ready"
|
||||
description="Postgres with OrioleDB extension is currently in Public Beta and not recommended for production usage yet."
|
||||
>
|
||||
<DocsButton
|
||||
className="mt-2"
|
||||
href={`${DOCS_URL}/guides/database/orioledb`}
|
||||
/>
|
||||
</Admonition>
|
||||
)}
|
||||
</FormItemLayout>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleCardSection>
|
||||
</Panel.Content>
|
||||
)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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<string>(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 (
|
||||
<FormControl>
|
||||
<Select value={value} onValueChange={handleValueChange} disabled={isLoading}>
|
||||
<SelectTrigger ref={triggerRef} id="region" className={TRIGGER_CLASS}>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
isLoading ? 'Loading available regions...' : 'Select a region for your project..'
|
||||
}
|
||||
>
|
||||
{value !== undefined && (
|
||||
<RegionSelectTriggerValue
|
||||
region={selectedRegion}
|
||||
label={isLoading ? 'Loading available regions...' : selectedRegionLabel}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{hasGeneralRegions && (
|
||||
<>
|
||||
<SelectGroup>
|
||||
<SelectLabel>General regions</SelectLabel>
|
||||
<GeneralRegionSelectItems
|
||||
regions={generalRegions}
|
||||
recommendedCodes={recommendedGeneralRegionCodes}
|
||||
/>
|
||||
</SelectGroup>
|
||||
<SelectSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{!areSpecificRegionsVisible && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenDisclosure}
|
||||
className="w-full flex items-center gap-x-1 px-2 py-1.5 text-sm text-foreground-light hover:text-foreground rounded-sm hover:bg-overlay-hover transition"
|
||||
>
|
||||
<ChevronRight size={14} strokeWidth={1.5} />
|
||||
Advanced: choose a specific region
|
||||
</button>
|
||||
)}
|
||||
|
||||
{areSpecificRegionsVisible && (
|
||||
<SelectGroup>
|
||||
<SelectLabel>{specificRegionsLabel}</SelectLabel>
|
||||
<SpecificRegionSelectItems
|
||||
regions={specificRegions}
|
||||
recommendedCodes={recommendedSpecificRegionCodes}
|
||||
/>
|
||||
</SelectGroup>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<CreateProjectForm>
|
||||
hasSelectedOrganization: boolean
|
||||
instanceSize?: DesiredInstanceSize
|
||||
onChangeRegionClick: () => void
|
||||
}) => {
|
||||
const { selectedRegion, selectedRegionLabel, selectedRestrictionCopy, isLoading } =
|
||||
useRegionSelectorOptions({ form, hasSelectedOrganization, instanceSize })
|
||||
|
||||
return (
|
||||
<Panel.Content>
|
||||
<FormItemLayout layout="horizontal" label="Region" isReactForm={false}>
|
||||
<div className="flex items-center gap-x-3 min-h-[38px]">
|
||||
{!isLoading && selectedRegion?.code && (
|
||||
<RegionFlag className="w-5" region={selectedRegion.code} />
|
||||
)}
|
||||
<span className="text-sm text-foreground">
|
||||
{isLoading ? 'Loading available regions...' : (selectedRegionLabel ?? 'Not selected')}
|
||||
</span>
|
||||
<Button type="button" variant="link" size="tiny" onClick={onChangeRegionClick}>
|
||||
Change in advanced configuration
|
||||
</Button>
|
||||
</div>
|
||||
</FormItemLayout>
|
||||
|
||||
{selectedRestrictionCopy !== undefined && (
|
||||
<FormItemLayout layout="horizontal" isReactForm={false}>
|
||||
<Admonition
|
||||
type="warning"
|
||||
title={selectedRestrictionCopy.title}
|
||||
description={`${selectedRestrictionCopy.notice} ${SELECT_DIFFERENT_REGION}`}
|
||||
className="mt-3"
|
||||
/>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
</Panel.Content>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<FormControl>
|
||||
<Select
|
||||
value={isSpecificSelectVisible ? CHOOSE_SPECIFIC_REGION : value}
|
||||
onValueChange={handleGeneralValueChange}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<SelectTrigger ref={triggerRef} id="region" className={TRIGGER_CLASS}>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
isLoading ? 'Loading available regions...' : 'Select a region for your project..'
|
||||
}
|
||||
>
|
||||
{isSpecificSelectVisible && (
|
||||
<span className="text-foreground">Choose a specific region</span>
|
||||
)}
|
||||
{!isSpecificSelectVisible && value !== undefined && (
|
||||
<RegionSelectTriggerValue
|
||||
region={selectedRegion}
|
||||
label={isLoading ? 'Loading available regions...' : selectedRegionLabel}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{hasGeneralRegions && (
|
||||
<>
|
||||
<SelectGroup>
|
||||
<SelectLabel>General regions</SelectLabel>
|
||||
<GeneralRegionSelectItems
|
||||
regions={generalRegions}
|
||||
recommendedCodes={recommendedGeneralRegionCodes}
|
||||
/>
|
||||
</SelectGroup>
|
||||
<SelectSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
<SelectItem value={CHOOSE_SPECIFIC_REGION}>Choose a specific region</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{isSpecificSelectVisible && (
|
||||
<Select
|
||||
value={isSpecificRegionSelected ? value : ''}
|
||||
onValueChange={handleSpecificValueChange}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<SelectTrigger id="specific-region" className={TRIGGER_CLASS}>
|
||||
<SelectValue placeholder="Select a specific region...">
|
||||
{isSpecificRegionSelected && (
|
||||
<RegionSelectTriggerValue
|
||||
region={selectedRegion}
|
||||
label={selectedRegionLabel}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel>{specificRegionsLabel}</SelectLabel>
|
||||
<SpecificRegionSelectItems
|
||||
regions={specificRegions}
|
||||
recommendedCodes={recommendedSpecificRegionCodes}
|
||||
/>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<string>()
|
||||
const [isAdvancedConfigOpen, setIsAdvancedConfigOpen] = useState(false)
|
||||
const advancedConfigRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const form = useForm<z.infer<typeof FormSchema>>({
|
||||
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 = ({
|
||||
|
||||
<DatabasePasswordInput form={form} />
|
||||
|
||||
<RegionSelector
|
||||
form={form}
|
||||
hasSelectedOrganization={hasSelectedOrganization}
|
||||
instanceSize={instanceSize as DesiredInstanceSize}
|
||||
/>
|
||||
{isRegionInAdvancedConfig ? (
|
||||
<RegionSummaryRow
|
||||
form={form}
|
||||
hasSelectedOrganization={hasSelectedOrganization}
|
||||
instanceSize={instanceSize as DesiredInstanceSize}
|
||||
onChangeRegionClick={handleChangeRegionClick}
|
||||
/>
|
||||
) : (
|
||||
<RegionSelector
|
||||
form={form}
|
||||
hasSelectedOrganization={hasSelectedOrganization}
|
||||
instanceSize={instanceSize as DesiredInstanceSize}
|
||||
variant={regionVariant}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isVercelIntegrationFlow && !!externalId && <DataSeeding form={form} />}
|
||||
|
||||
@@ -789,9 +815,25 @@ export const ProjectCreationForm = ({
|
||||
|
||||
{showInternalOnlyConfiguration && <InternalOnlyConfiguration form={form} />}
|
||||
|
||||
{showAdvancedConfig &&
|
||||
!!availableOrioleVersion &&
|
||||
highAvailability !== true && <AdvancedConfiguration form={form} />}
|
||||
{isAdvancedConfigVisible && (
|
||||
<div ref={advancedConfigRef}>
|
||||
<AdvancedConfiguration
|
||||
form={form}
|
||||
hasPostgresTypeOption={hasPostgresTypeOption}
|
||||
open={isAdvancedConfigOpen}
|
||||
onOpenChange={setIsAdvancedConfigOpen}
|
||||
>
|
||||
{isRegionInAdvancedConfig && (
|
||||
<RegionSelector
|
||||
form={form}
|
||||
hasSelectedOrganization={hasSelectedOrganization}
|
||||
instanceSize={instanceSize as DesiredInstanceSize}
|
||||
isEmbedded
|
||||
/>
|
||||
)}
|
||||
</AdvancedConfiguration>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shouldShowFreeProjectInfo ? (
|
||||
<Admonition
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
import { useFeatureFlags, useFlag, useParams } from 'common'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useEffect, useRef, type Ref } from 'react'
|
||||
import { UseFormReturn } from 'react-hook-form'
|
||||
import type { CloudProvider } from 'shared-data'
|
||||
import { Badge, cn, SelectItem, Tooltip, TooltipContent, TooltipTrigger, useWatch } from 'ui'
|
||||
|
||||
import { CreateProjectForm } from './ProjectCreation.schema'
|
||||
import {
|
||||
filterHighAvailabilityRegions,
|
||||
getAvailableRegions,
|
||||
getHighAvailabilityRegionCode,
|
||||
} from './ProjectCreation.utils'
|
||||
import {
|
||||
getRegionRestrictionCopy,
|
||||
regionMatches,
|
||||
type RegionRestrictionCopy,
|
||||
} from './RegionSelector.utils'
|
||||
import { useRegionRestriction } from './useRegionRestriction'
|
||||
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'
|
||||
import type { ResponseError } from '@/types'
|
||||
|
||||
export type SelectableRegion = { code: string; name: string; status?: string }
|
||||
export type RestrictableRegion = SelectableRegion & { restriction: string | undefined }
|
||||
|
||||
/** Props every arm of the region picker takes. */
|
||||
export type RegionSelectProps = {
|
||||
options: RegionSelectorOptions
|
||||
value: string | undefined
|
||||
onChange: (value: string) => void
|
||||
triggerRef: Ref<HTMLButtonElement>
|
||||
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<string | undefined>
|
||||
recommendedSpecificRegionCodes: Set<string>
|
||||
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<CreateProjectForm>
|
||||
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<string | undefined>(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
|
||||
}) => (
|
||||
<div className="flex items-center gap-x-3">
|
||||
{isLoading && <Loader2 size={14} className="animate-spin" />}
|
||||
{region?.code && <RegionFlag className="w-5" region={region.code} />}
|
||||
<span className="text-foreground">{label}</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
export const GeneralRegionSelectItems = ({
|
||||
regions,
|
||||
recommendedCodes,
|
||||
}: {
|
||||
regions: SelectableRegion[]
|
||||
recommendedCodes: Set<string | undefined>
|
||||
}) =>
|
||||
regions.map((region) => (
|
||||
<SelectItem key={region.code} value={region.name} className="w-full [&>:nth-child(2)]:w-full">
|
||||
<div className="flex flex-row items-center justify-between w-full">
|
||||
<div className="flex items-center gap-x-3">
|
||||
<RegionFlag className="w-5" region={region.code} />
|
||||
<span className="text-foreground">{getDisplayNameForGeneralRegion(region.name)}</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{recommendedCodes.has(region.code) && (
|
||||
<Badge variant="success" className="mr-1">
|
||||
Recommended
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
|
||||
export const SpecificRegionSelectItems = ({
|
||||
regions,
|
||||
recommendedCodes,
|
||||
}: {
|
||||
regions: RestrictableRegion[]
|
||||
recommendedCodes: Set<string>
|
||||
}) =>
|
||||
regions.map((region) => {
|
||||
const restrictionCopy =
|
||||
region.restriction !== undefined ? getRegionRestrictionCopy(region.restriction) : undefined
|
||||
|
||||
return (
|
||||
<SelectItem
|
||||
key={region.code}
|
||||
value={region.name}
|
||||
className={cn(
|
||||
'w-full [&>:nth-child(2)]:w-full',
|
||||
restrictionCopy !== undefined && 'pointer-events-auto!'
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-row items-center justify-between w-full gap-x-2">
|
||||
<div className="flex items-center gap-x-3">
|
||||
<RegionFlag className="w-5" region={region.code} />
|
||||
<div className="flex items-center gap-x-2">
|
||||
<span className="text-foreground">{region.name}</span>
|
||||
<span className="text-xs text-foreground-lighter font-mono">{region.code}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{recommendedCodes.has(region.code) && (
|
||||
<Badge variant="success" className="mr-1">
|
||||
Recommended
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{restrictionCopy !== undefined && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Badge variant="warning" className="mr-1">
|
||||
{restrictionCopy.badge}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{restrictionCopy.tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
)
|
||||
})
|
||||
@@ -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 (
|
||||
<FormControl>
|
||||
<Select value={value} onValueChange={onChange} disabled={isLoading}>
|
||||
<SelectTrigger
|
||||
ref={triggerRef}
|
||||
id="region"
|
||||
className="[&>:nth-child(1)]:w-full [&>:nth-child(1)]:flex [&>:nth-child(1)]:items-start"
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
isLoading ? 'Loading available regions...' : 'Select a region for your project..'
|
||||
}
|
||||
>
|
||||
{value !== undefined && (
|
||||
<RegionSelectTriggerValue
|
||||
region={selectedRegion}
|
||||
label={isLoading ? 'Loading available regions...' : selectedRegionLabel}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{hasGeneralRegions && (
|
||||
<>
|
||||
<SelectGroup>
|
||||
<SelectLabel>General regions</SelectLabel>
|
||||
<GeneralRegionSelectItems
|
||||
regions={generalRegions}
|
||||
recommendedCodes={recommendedGeneralRegionCodes}
|
||||
/>
|
||||
</SelectGroup>
|
||||
<SelectSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
<SelectGroup>
|
||||
<SelectLabel>{specificRegionsLabel}</SelectLabel>
|
||||
<SpecificRegionSelectItems
|
||||
regions={specificRegions}
|
||||
recommendedCodes={recommendedSpecificRegionCodes}
|
||||
/>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
)
|
||||
}
|
||||
|
||||
const isLocal = process.env.NEXT_PUBLIC_ENVIRONMENT === 'local'
|
||||
const RegionSelectorDescription = ({ options }: { options: RegionSelectorOptions }) => {
|
||||
if (options.isHighAvailabilityRestricted) {
|
||||
return (
|
||||
<div className="text-warning">
|
||||
High Availability projects are currently limited to {options.highAvailabilityRegionLabel}.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!showNonProdFields) return null
|
||||
|
||||
return (
|
||||
<div className="text-warning">
|
||||
<p>Only these regions are supported for local/staging projects:</p>
|
||||
<ul className="list-disc list-inside mt-1">
|
||||
<li>East US (North Virginia)</li>
|
||||
<li>Central EU (Frankfurt)</li>
|
||||
<li>Southeast Asia (Singapore)</li>
|
||||
</ul>
|
||||
{isLocalEnvironment && (
|
||||
<p className="mt-1">Use Central EU (Frankfurt) unless you're on a personal dev stack.</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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<string | undefined>(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 <AlertError subject="Error loading available regions" error={errorAvailableRegions} />
|
||||
if (options.isError) {
|
||||
return <AlertError subject="Error loading available regions" error={options.error} />
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel.Content>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="dbRegion"
|
||||
render={({ field }) => {
|
||||
const selectedRegion = allSelectableRegions.find((region) => {
|
||||
return !!region.name && region.name === dbRegion
|
||||
})
|
||||
const content = (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="dbRegion"
|
||||
render={({ field }) => {
|
||||
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 <RegionSelectorOptionA {...controlProps} />
|
||||
if (variant === 'option_c') return <RegionSelectorOptionC {...controlProps} />
|
||||
return <RegionSelectControl {...controlProps} />
|
||||
}
|
||||
|
||||
const affectingIncidents = incidents.filter((incident) => {
|
||||
const affectedRegions = incident.cache?.affected_regions ?? []
|
||||
if (affectedRegions.length === 0 || selectedRegion?.code === undefined) return false
|
||||
return (
|
||||
<>
|
||||
<FormItemLayout
|
||||
id="region"
|
||||
layout={layout}
|
||||
label="Region"
|
||||
description={<RegionSelectorDescription options={options} />}
|
||||
>
|
||||
{renderRegionControl()}
|
||||
</FormItemLayout>
|
||||
|
||||
return affectedRegions.some((affectedRegion) =>
|
||||
regionMatches(selectedRegion.code, affectedRegion)
|
||||
)
|
||||
})
|
||||
{options.isStatusPageEnabled && (
|
||||
<ProjectCreationStatusAdmonition selectedRegionCode={options.selectedRegion?.code} />
|
||||
)}
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormItemLayout
|
||||
id="region"
|
||||
layout={layout}
|
||||
label="Region"
|
||||
description={
|
||||
restrictHighAvailabilityRegion ? (
|
||||
<div className="text-warning">
|
||||
High Availability projects are currently limited to{' '}
|
||||
{regionOptions[0]?.name ?? highAvailabilityRegionCode}.
|
||||
</div>
|
||||
) : showNonProdFields ? (
|
||||
<div className="text-warning">
|
||||
<p>Only these regions are supported for local/staging projects:</p>
|
||||
<ul className="list-disc list-inside mt-1">
|
||||
<li>East US (North Virginia)</li>
|
||||
<li>Central EU (Frankfurt)</li>
|
||||
<li>Southeast Asia (Singapore)</li>
|
||||
</ul>
|
||||
{isLocalEnvironment && (
|
||||
<p className="mt-1">
|
||||
Use Central EU (Frankfurt) unless you're on a personal dev stack.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={dbRegion}
|
||||
onValueChange={(value) => {
|
||||
if (value === '') return
|
||||
field.onChange(value)
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<SelectTrigger
|
||||
ref={field.ref}
|
||||
id="region"
|
||||
className="[&>:nth-child(1)]:w-full [&>:nth-child(1)]:flex [&>:nth-child(1)]:items-start"
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
isLoading
|
||||
? 'Loading available regions...'
|
||||
: 'Select a region for your project..'
|
||||
}
|
||||
>
|
||||
{dbRegion !== undefined && (
|
||||
<div className="flex items-center gap-x-3">
|
||||
{isLoading && <Loader2 size={14} className="animate-spin" />}
|
||||
{selectedRegion?.code && (
|
||||
<RegionFlag className="w-5" region={selectedRegion.code} />
|
||||
)}
|
||||
<span className="text-foreground">{triggerLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{smartRegionEnabled && !highAvailability && (
|
||||
<>
|
||||
<SelectGroup>
|
||||
<SelectLabel>General regions</SelectLabel>
|
||||
{smartRegions.map((value) => {
|
||||
return (
|
||||
<SelectItem
|
||||
key={value.code}
|
||||
value={value.name}
|
||||
className="w-full [&>:nth-child(2)]:w-full"
|
||||
>
|
||||
<div className="flex flex-row items-center justify-between w-full">
|
||||
<div className="flex items-center gap-x-3">
|
||||
<RegionFlag className="w-5" region={value.code} />
|
||||
<span className="text-foreground">
|
||||
{getDisplayNameForSmartRegion(value.name)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{recommendedSmartRegions.has(value.code) && (
|
||||
<Badge variant="success" className="mr-1">
|
||||
Recommended
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectGroup>
|
||||
<SelectSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
<SelectGroup>
|
||||
<SelectLabel>
|
||||
{highAvailability ? 'High Availability Regions' : 'Specific regions'}
|
||||
</SelectLabel>
|
||||
{regionOptionsWithRestriction.map((value) => {
|
||||
const restrictionCopy =
|
||||
value.restriction !== undefined
|
||||
? getRegionRestrictionCopy(value.restriction)
|
||||
: undefined
|
||||
return (
|
||||
<SelectItem
|
||||
key={value.code}
|
||||
value={value.name}
|
||||
className={cn(
|
||||
'w-full [&>:nth-child(2)]:w-full',
|
||||
restrictionCopy !== undefined && 'pointer-events-auto!'
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-row items-center justify-between w-full gap-x-2">
|
||||
<div className="flex items-center gap-x-3">
|
||||
<RegionFlag className="w-5" region={value.code} />
|
||||
<div className="flex items-center gap-x-2">
|
||||
<span className="text-foreground">{value.name}</span>
|
||||
<span className="text-xs text-foreground-lighter font-mono">
|
||||
{value.code}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{recommendedSpecificRegions.has(value.code) && (
|
||||
<Badge variant="success" className="mr-1">
|
||||
Recommended
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{restrictionCopy !== undefined && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Badge variant="warning" className="mr-1">
|
||||
{restrictionCopy.badge}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{restrictionCopy.tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
{!options.isStatusPageEnabled && options.affectingIncidents.length > 0 && (
|
||||
<FormItemLayout layout="horizontal">
|
||||
<Admonition
|
||||
type="warning"
|
||||
title="Incident in progress for this region"
|
||||
description={
|
||||
<>
|
||||
We're currently investigating an issue that may impact projects in this
|
||||
region. Follow updates on{' '}
|
||||
<InlineLink href="https://status.supabase.com">
|
||||
status.supabase.com
|
||||
</InlineLink>
|
||||
.
|
||||
</>
|
||||
}
|
||||
className="mt-3"
|
||||
/>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
|
||||
{isStatusPageEnabled ? (
|
||||
<ProjectCreationStatusAdmonition selectedRegionCode={selectedRegion?.code} />
|
||||
) : (
|
||||
affectingIncidents.length > 0 && (
|
||||
<FormItemLayout layout="horizontal">
|
||||
<Admonition
|
||||
type="warning"
|
||||
title="Incident in progress for this region"
|
||||
description={
|
||||
<>
|
||||
We're currently investigating an issue that may impact projects in this
|
||||
region. Follow updates on{' '}
|
||||
<InlineLink href="https://status.supabase.com">
|
||||
status.supabase.com
|
||||
</InlineLink>
|
||||
.
|
||||
</>
|
||||
}
|
||||
className="mt-3"
|
||||
/>
|
||||
</FormItemLayout>
|
||||
)
|
||||
)}
|
||||
|
||||
{selectedRestrictionCopy !== undefined && (
|
||||
<FormItemLayout layout="horizontal" isReactForm={false}>
|
||||
<Admonition
|
||||
type="warning"
|
||||
title={selectedRestrictionCopy.title}
|
||||
description={`${selectedRestrictionCopy.notice} ${SELECT_DIFFERENT_REGION}`}
|
||||
className="mt-3"
|
||||
/>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Panel.Content>
|
||||
{options.selectedRestrictionCopy !== undefined && (
|
||||
<FormItemLayout layout="horizontal" isReactForm={false}>
|
||||
<Admonition
|
||||
type="warning"
|
||||
title={options.selectedRestrictionCopy.title}
|
||||
description={`${options.selectedRestrictionCopy.notice} ${SELECT_DIFFERENT_REGION}`}
|
||||
className="mt-3"
|
||||
/>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
if (isEmbedded) return content
|
||||
|
||||
return <Panel.Content>{content}</Panel.Content>
|
||||
}
|
||||
@@ -277,7 +277,9 @@ const DEFAULT_FLAGS = {
|
||||
projectCreationRestrictedRegions: false,
|
||||
}
|
||||
|
||||
async function renderWizard(options: { flags?: Record<string, boolean | string> } = {}) {
|
||||
async function renderWizard(
|
||||
options: { flags?: Record<string, boolean | string>; searchParams?: string } = {}
|
||||
) {
|
||||
const { default: Wizard } = await import('@/pages/new/[slug]')
|
||||
return customRender(
|
||||
<FeatureFlagContext.Provider
|
||||
@@ -285,7 +287,10 @@ async function renderWizard(options: { flags?: Record<string, boolean | string>
|
||||
>
|
||||
<Wizard dehydratedState={undefined} />
|
||||
</FeatureFlagContext.Provider>,
|
||||
{ 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<typeof vi.fn>) => {
|
||||
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' })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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) => (
|
||||
<Collapsible defaultOpen={defaultOpen}>
|
||||
<Collapsible defaultOpen={defaultOpen} open={open} onOpenChange={onOpenChange}>
|
||||
<CollapsibleTrigger className="group/trigger font-mono uppercase tracking-widest text-xs flex items-center gap-1 text-foreground-lighter/75 hover:text-foreground-light transition data-open:text-foreground-light">
|
||||
{title}
|
||||
<ChevronRight
|
||||
|
||||
Reference in new issue
Block a user