diff --git a/apps/studio/components/interfaces/ProjectCreation/ProjectCreationStatusAdmonition.tsx b/apps/studio/components/interfaces/ProjectCreation/ProjectCreationStatusAdmonition.tsx new file mode 100644 index 00000000000..e2ad9aff1f6 --- /dev/null +++ b/apps/studio/components/interfaces/ProjectCreation/ProjectCreationStatusAdmonition.tsx @@ -0,0 +1,41 @@ +import { useQuery } from '@tanstack/react-query' +import { Admonition } from 'ui-patterns/Admonition' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' + +import { getItemsAffectingProjectCreation } from './RegionSelector.utils' +import { InlineLink } from '@/components/ui/InlineLink' +import { statusPageQueryOptions } from '@/data/platform/status-page-query' +import { normalizeStatusPage } from '@/lib/status-page/status-page.utils' + +export const ProjectCreationStatusAdmonition = ({ + selectedRegionCode, +}: { + selectedRegionCode?: string +}) => { + const { data } = useQuery({ ...statusPageQueryOptions(), select: normalizeStatusPage }) + if (!data) return null + + const items = getItemsAffectingProjectCreation(data.items, selectedRegionCode) + const item = items[0] + if (!item) return null + + const isMaintenance = item.kind === 'maintenance' + const isRegionSpecific = item.projectCreationScope?.type === 'regions' + + return ( + + + {isMaintenance ? 'Maintenance' : 'An incident'} may affect project creation + {isRegionSpecific ? ' in this region' : ''}. Follow updates on{' '} + status page. + + } + className="mt-3" + /> + + ) +} diff --git a/apps/studio/components/interfaces/ProjectCreation/RegionSelector.tsx b/apps/studio/components/interfaces/ProjectCreation/RegionSelector.tsx index 68844b3be40..587b31e247b 100644 --- a/apps/studio/components/interfaces/ProjectCreation/RegionSelector.tsx +++ b/apps/studio/components/interfaces/ProjectCreation/RegionSelector.tsx @@ -1,4 +1,4 @@ -import { useFeatureFlags, useParams } from 'common' +import { useFeatureFlags, useFlag, useParams } from 'common' import { Loader2, ThumbsUp } from 'lucide-react' import { useEffect, useRef } from 'react' import { UseFormReturn } from 'react-hook-form' @@ -30,7 +30,12 @@ import { getAvailableRegions, getHighAvailabilityRegionCode, } from './ProjectCreation.utils' -import { getRegionRestrictionCopy, SELECT_DIFFERENT_REGION } from './RegionSelector.utils' +import { ProjectCreationStatusAdmonition } from './ProjectCreationStatusAdmonition' +import { + getRegionRestrictionCopy, + regionMatches, + SELECT_DIFFERENT_REGION, +} from './RegionSelector.utils' import { useRegionRestriction } from './useRegionRestriction' import { AlertError } from '@/components/ui/AlertError' import { InlineLink } from '@/components/ui/InlineLink' @@ -55,18 +60,6 @@ interface RegionSelectorProps { // I tried using https://flagpack.xyz/docs/development/react/ but couldn't get it to render // ^ can try again next time -// Maps smart region group codes to the specific-region code prefixes they contain. -// Used to check whether an incident affecting specific regions also affects a smart region selection. -const SMART_REGION_PREFIXES: Record> = { - americas: ['us-', 'ca-', 'sa-'], - emea: ['eu-', 'me-', 'af-'], - apac: ['ap-'], -} - -function smartRegionMatchesSpecific(smartCode: string, specificCode: string): boolean { - return (SMART_REGION_PREFIXES[smartCode] ?? []).some((prefix) => specificCode.startsWith(prefix)) -} - // Map backend region names to user-friendly display names const getDisplayNameForSmartRegion = (name: string): string => { if (name === 'APAC') { @@ -100,10 +93,11 @@ export const RegionSelector = ({ const { hasLoaded: flagsLoaded } = useFeatureFlags() const smartRegionEnabled = cloudProvider !== 'AWS_NIMBUS' + const isStatusPageEnabled = useFlag('incidentIoStatusPage') === true const { getRegionRestriction } = useRegionRestriction() - const { data: statusData } = useIncidentStatusQuery() + const { data: statusData } = useIncidentStatusQuery({ enabled: !isStatusPageEnabled }) const { incidents = [] } = statusData ?? {} const { isPending: isLoadingDefaultRegion } = useDefaultRegionQuery( @@ -250,12 +244,8 @@ export const RegionSelector = ({ const affectedRegions = incident.cache?.affected_regions ?? [] if (affectedRegions.length === 0 || selectedRegion?.code === undefined) return false - // Specific region: direct code match - if (affectedRegions.includes(selectedRegion.code)) return true - - // Smart region: match if any affected region falls within the smart group - return affectedRegions.some((specificCode) => - smartRegionMatchesSpecific(selectedRegion.code, specificCode) + return affectedRegions.some((affectedRegion) => + regionMatches(selectedRegion.code, affectedRegion) ) }) @@ -447,24 +437,28 @@ export const RegionSelector = ({ - {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 && ( diff --git a/apps/studio/components/interfaces/ProjectCreation/RegionSelector.utils.test.ts b/apps/studio/components/interfaces/ProjectCreation/RegionSelector.utils.test.ts index 9f2a1f3d6b6..d3c71228949 100644 --- a/apps/studio/components/interfaces/ProjectCreation/RegionSelector.utils.test.ts +++ b/apps/studio/components/interfaces/ProjectCreation/RegionSelector.utils.test.ts @@ -1,13 +1,16 @@ import { describe, expect, it, vi } from 'vitest' import { + getItemsAffectingProjectCreation, getRegionRestrictionCopy, getRegionRestrictionMessage, parseRestrictedRegions, REGION_RESTRICTION_COPY, + regionMatches, resolveRegionRestriction, SELECT_DIFFERENT_REGION, } from './RegionSelector.utils' +import type { StatusItem } from '@/lib/status-page/status-page.utils' vi.mock('@/lib/error-reporting', () => ({ captureCriticalError: vi.fn() })) @@ -103,3 +106,106 @@ describe('getRegionRestrictionCopy', () => { ) }) }) + +describe('regionMatches', () => { + it('matches an exact region code', () => { + expect(regionMatches('us-east-1', 'us-east-1')).toBe(true) + }) + + it('matches a smart group against a specific region it contains', () => { + expect(regionMatches('americas', 'us-east-1')).toBe(true) + }) + + it('does not match a smart group against a region from a different group', () => { + expect(regionMatches('apac', 'us-east-1')).toBe(false) + }) + + it.each([ + ['US-EAST-1', 'us-east-1'], + ['us-east-1', 'US-EAST-1'], + ['AMERICAS', 'us-east-1'], + ])('matches case-insensitively (%s vs %s)', (selectedCode, affectedRegion) => { + expect(regionMatches(selectedCode, affectedRegion)).toBe(true) + }) + + it('does not match unrelated specific regions', () => { + expect(regionMatches('us-east-1', 'eu-central-1')).toBe(false) + }) +}) + +describe('getItemsAffectingProjectCreation', () => { + type IncidentItem = Extract + type UpcomingMaintenanceItem = Extract + + function buildItem(overrides: Partial = {}): IncidentItem { + return { + id: 'incident-1', + name: 'Project creation degraded', + url: 'https://status.supabase.com/incidents/1', + lastUpdateAt: '2026-01-01T00:00:00Z', + lastUpdateMessage: null, + components: [], + scope: { type: 'global' }, + projectCreationScope: null, + isProjectCreationOnly: true, + showBanner: true, + kind: 'incident', + status: 'investigating', + impact: 'partial_outage', + ...overrides, + } + } + + function buildUpcomingMaintenanceItem( + overrides: Partial = {} + ): UpcomingMaintenanceItem { + return { + id: 'maintenance-1', + name: 'Upcoming project creation maintenance', + url: 'https://status.supabase.com/incidents/2', + lastUpdateAt: '2026-01-01T00:00:00Z', + lastUpdateMessage: null, + components: [], + scope: { type: 'global' }, + projectCreationScope: null, + isProjectCreationOnly: true, + showBanner: true, + kind: 'upcoming_maintenance', + startsAt: '2026-02-01T00:00:00Z', + endsAt: '2026-02-02T00:00:00Z', + leadDays: 7, + ...overrides, + } + } + + it('includes a global project creation item regardless of the selected region', () => { + const item = buildItem({ projectCreationScope: { type: 'global' } }) + expect(getItemsAffectingProjectCreation([item], undefined)).toEqual([item]) + expect(getItemsAffectingProjectCreation([item], 'us-east-1')).toEqual([item]) + }) + + it('excludes a region-scoped item when no region is selected', () => { + const item = buildItem({ projectCreationScope: { type: 'regions', regions: ['us-east-1'] } }) + expect(getItemsAffectingProjectCreation([item], undefined)).toEqual([]) + }) + + it('excludes an item that does not affect project creation', () => { + const item = buildItem({ projectCreationScope: null }) + expect(getItemsAffectingProjectCreation([item], 'us-east-1')).toEqual([]) + }) + + it('excludes upcoming maintenance even with a matching project creation scope', () => { + const item = buildUpcomingMaintenanceItem({ projectCreationScope: { type: 'global' } }) + expect(getItemsAffectingProjectCreation([item], undefined)).toEqual([]) + }) + + it('still includes an item with showBanner false', () => { + const item = buildItem({ projectCreationScope: { type: 'global' }, showBanner: false }) + expect(getItemsAffectingProjectCreation([item], undefined)).toEqual([item]) + }) + + it('includes a region-scoped item matching the selected region via a smart group', () => { + const item = buildItem({ projectCreationScope: { type: 'regions', regions: ['us-east-1'] } }) + expect(getItemsAffectingProjectCreation([item], 'americas')).toEqual([item]) + }) +}) diff --git a/apps/studio/components/interfaces/ProjectCreation/RegionSelector.utils.ts b/apps/studio/components/interfaces/ProjectCreation/RegionSelector.utils.ts index 877e9887d20..d6984eb8ad5 100644 --- a/apps/studio/components/interfaces/ProjectCreation/RegionSelector.utils.ts +++ b/apps/studio/components/interfaces/ProjectCreation/RegionSelector.utils.ts @@ -1,6 +1,7 @@ import { z } from 'zod' import { captureCriticalError } from '@/lib/error-reporting' +import { normalizeRegion, type StatusItem } from '@/lib/status-page/status-page.utils' export const RESTRICTED_REGIONS_FLAG_KEY = 'projectCreationRestrictedRegions' @@ -93,3 +94,35 @@ export function resolveRegionRestriction({ }: ResolveRegionRestrictionArgs): string | undefined { return platformStatus ?? flagRestriction } + +// Maps smart region group codes to the specific-region code prefixes they contain. +// Used to check whether a status item affecting specific regions also affects a smart region selection. +export const SMART_REGION_PREFIXES: Record> = { + americas: ['us-', 'ca-', 'sa-'], + emea: ['eu-', 'me-', 'af-'], + apac: ['ap-'], +} + +export function regionMatches(selectedCode: string, affectedRegion: string): boolean { + const normalizedSelected = normalizeRegion(selectedCode) + const normalizedAffected = normalizeRegion(affectedRegion) + if (normalizedSelected === normalizedAffected) return true + return (SMART_REGION_PREFIXES[normalizedSelected] ?? []).some((prefix) => + normalizedAffected.startsWith(prefix) + ) +} + +export function getItemsAffectingProjectCreation( + items: Array, + selectedRegionCode: string | undefined +): Array { + return items.filter((item) => { + if (item.kind !== 'incident' && item.kind !== 'maintenance') return false + if (item.projectCreationScope === null) return false + if (item.projectCreationScope.type === 'global') return true + return ( + selectedRegionCode !== undefined && + item.projectCreationScope.regions.some((region) => regionMatches(selectedRegionCode, region)) + ) + }) +}