diff --git a/src/features/clusters/DESIGN.md b/src/features/clusters/DESIGN.md index f5d88357b..14d54caaa 100644 --- a/src/features/clusters/DESIGN.md +++ b/src/features/clusters/DESIGN.md @@ -14,3 +14,9 @@ create prefilled from it, and once that create succeeds the form terminates the saved form values can bring it back after a billing redirect. So `onClusterSavedCallback` gates the terminate on `creating`, not on `sourceClusterId` alone. Without that gate, an update that failed straight away made an edit delete the cluster it was editing. + +A tier with `allowedRegionIds` takes one region, and the form corrects that region whenever it is the only one left, +which includes the moment a second region is removed. A custom region is never corrected, since the allow-list describes +the catalog. The correction keeps the chosen region, matching the catalog entry's name for the stored region id, and +moves only its latency when the tier offers that region at all. It falls back to the default region only when the tier +offers nothing there (#1275). diff --git a/src/features/clusters/upsert/ClusterForm.test.tsx b/src/features/clusters/upsert/ClusterForm.test.tsx new file mode 100644 index 000000000..8b4291854 --- /dev/null +++ b/src/features/clusters/upsert/ClusterForm.test.tsx @@ -0,0 +1,177 @@ +/** @vitest-environment jsdom */ +import type { SchemaPlan, SchemaRegion } from '@/integrations/api/api.gen'; +import type { Organization } from '@/integrations/api/api.patch'; +import { groupThenKeyBy } from '@/lib/groupThenKeyBy'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { ClusterForm } from './ClusterForm'; +import type { UpsertClusterSchemaType } from './upsertClusterSchema'; + +vi.mock('@tanstack/react-router', () => { + const router = { invalidate: vi.fn() }; + const navigate = vi.fn(); + return { useNavigate: () => navigate, useRouter: () => router }; +}); + +const regions: SchemaRegion[] = [ + { id: 'us-small', region: 'US', latencyDescription: '90ms, small distribution', instanceCount: 2 }, + { id: 'us-medium', region: 'US', latencyDescription: '70ms, medium distribution', instanceCount: 4 }, + { id: 'eu-small', region: 'Europe', latencyDescription: '60ms, small distribution', instanceCount: 2 }, + { id: 'eu-medium', region: 'Europe', latencyDescription: '45ms, medium distribution', instanceCount: 4 }, + { id: 'ap-small', region: 'Asia', latencyDescription: '170ms, small distribution', instanceCount: 2 }, + { id: 'ap-medium', region: 'Asia', latencyDescription: '140ms, medium distribution', instanceCount: 4 }, +] as SchemaRegion[]; + +const colocated = { deploymentType: 'colocated', deploymentDescription: 'Colocated' }; +const plans = [ + { + ...colocated, + id: 'free', + performanceDescription: 'Free tier', + priceUsd: 0, + allowedRegionIds: ['us-small', 'eu-small'], + }, + { ...colocated, id: 'medium', performanceDescription: 'Medium tier', priceUsd: 250 }, +] as SchemaPlan[]; + +const organization = { id: 'org-a', name: 'Org A', subdomain: 'org-a' } as Organization; + +beforeAll(() => { + Object.assign(Element.prototype, { + hasPointerCapture: () => false, + setPointerCapture: () => {}, + releasePointerCapture: () => {}, + scrollIntoView: () => {}, + }); + window.PointerEvent = class extends MouseEvent {} as typeof PointerEvent; +}); +afterEach(cleanup); + +function renderForm(defaultValues: Partial) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + , + ); +} + +// The form stores a region id, so read each row as it is shown. +function selectedRegionPlans() { + const comboboxes = screen.getAllByRole('combobox'); + return screen.getAllByText(/^Region( \d+)?$/, { selector: 'label' }).map(label => { + const region = label.closest('[data-slot="form-item"]')!.querySelector('[role="combobox"]')!; + const latency = comboboxes[comboboxes.indexOf(region as HTMLElement) + 1]; + return `${region.textContent} / ${latency?.textContent}`; + }); +} + +async function removeRegion(index: number) { + await act(async () => { + fireEvent.click(screen.getAllByRole('button', { name: 'Remove' })[index]); + }); +} + +describe('ClusterForm regions', () => { + it('leaves the first region alone when the second is removed', async () => { + renderForm({ + regionPlans: [ + { regionId: 'eu-medium' }, + { regionId: 'ap-medium' }, + ], + }); + + await removeRegion(1); + + expect(selectedRegionPlans()).toEqual(['Europe / 45ms, medium distribution']); + }); + + it('keeps the first region when the second is removed on a tier that limits regions (#1275)', async () => { + renderForm({ + performanceDescription: 'Free tier', + regionPlans: [ + { regionId: 'eu-medium' }, + { regionId: 'ap-medium' }, + ], + }); + expect(selectedRegionPlans()).toEqual([ + 'Europe / 45ms, medium distribution', + 'Asia / 140ms, medium distribution', + ]); + + await removeRegion(1); + + expect(selectedRegionPlans()).toEqual(['Europe / 60ms, small distribution']); + }); + + it('keeps the surviving region when the first is removed on a tier that limits regions', async () => { + renderForm({ + performanceDescription: 'Free tier', + regionPlans: [ + { regionId: 'ap-medium' }, + { regionId: 'eu-medium' }, + ], + }); + + await removeRegion(0); + + expect(selectedRegionPlans()).toEqual(['Europe / 60ms, small distribution']); + }); + + it('leaves an allowed first region untouched on a tier that limits regions', async () => { + renderForm({ + performanceDescription: 'Free tier', + regionPlans: [ + { regionId: 'eu-small' }, + { regionId: 'ap-medium' }, + ], + }); + + await removeRegion(1); + + expect(selectedRegionPlans()).toEqual(['Europe / 60ms, small distribution']); + }); + + it('falls back to the default region only when the tier offers nothing in the chosen one', async () => { + renderForm({ + performanceDescription: 'Free tier', + regionPlans: [ + { regionId: 'ap-medium' }, + { regionId: 'eu-medium' }, + ], + }); + + await removeRegion(1); + + expect(selectedRegionPlans()).toEqual(['US / 90ms, small distribution']); + }); +}); diff --git a/src/features/clusters/upsert/ClusterForm.tsx b/src/features/clusters/upsert/ClusterForm.tsx index 13ae47784..17a43fd2d 100644 --- a/src/features/clusters/upsert/ClusterForm.tsx +++ b/src/features/clusters/upsert/ClusterForm.tsx @@ -335,7 +335,9 @@ export function ClusterForm({ const firstRegionId = selectedRegionPlans[0].regionId; if (!isOrganizationRegionId(firstRegionId) && !allowedRegionIds.includes(firstRegionId)) { const possibleRegions = regionLocations?.filter(r => allowedRegionIds.includes(r.id)); - const regionToSelect = possibleRegions?.find(r => r.region === 'US') || possibleRegions?.[0]; + const chosenRegionName = regionLookup.get(firstRegionId)?.name; + const regionToSelect = possibleRegions?.find(r => r.region === chosenRegionName) + || possibleRegions?.find(r => r.region === 'US') || possibleRegions?.[0]; if (regionToSelect) { form.setValue('regionPlans.0.regionId', regionToSelect.id); void form.trigger();