Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/features/clusters/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,7 @@ 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. The correction keeps the chosen region and moves only its latency
when the tier offers that region at all, and falls back to the default region only when it does not (#1275).
177 changes: 177 additions & 0 deletions src/features/clusters/upsert/ClusterForm.test.tsx
Original file line number Diff line number Diff line change
@@ -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<UpsertClusterSchemaType>) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={queryClient}>
<ClusterForm
alreadyUsingFree={false}
defaultValues={{
clusterName: 'Preview',
abbreviatedName: '',
version: '5.2.13',
deploymentDescription: 'Colocated',
performanceDescription: 'Medium tier',
fqdn: '',
instances: [],
regionPlans: [],
...defaultValues,
}}
deploymentToPerformanceToPlan={groupThenKeyBy(plans, 'deploymentDescription', 'performanceDescription')}
harperVersions={{ name: 'versions', description: '', value: [{ name: 'stable', version: '5.2.13' }] }}
mode={undefined}
organization={organization}
organizationId="org-a"
partialUpgrade={null}
planTypes={plans}
regionLocationsColocated={[...regions]}
regionLocationsDedicated={[...regions]}
setSavedClusterState={vi.fn()}
startOffOnBilling={false}
/>
</QueryClientProvider>,
);
}

function selectedRegionPlans(container: HTMLElement) {
const rows = [];
for (let index = 0;; index++) {
const regionName = container.querySelector<HTMLSelectElement>(`select[name="regionPlans.${index}.regionName"]`);
if (!regionName) {
return rows;
}
const latency = container.querySelector<HTMLSelectElement>(
`select[name="regionPlans.${index}.latencyDescription"]`,
);
rows.push(`${regionName.value} / ${latency?.value}`);
}
}

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 () => {
const { container } = renderForm({
regionPlans: [
{ regionName: 'Europe', latencyDescription: '45ms, medium distribution' },
{ regionName: 'Asia', latencyDescription: '140ms, medium distribution' },
],
});

await removeRegion(1);

expect(selectedRegionPlans(container)).toEqual(['Europe / 45ms, medium distribution']);
});

it('keeps the first region when the second is removed on a tier that limits regions (#1275)', async () => {
const { container } = renderForm({
performanceDescription: 'Free tier',
regionPlans: [
{ regionName: 'Europe', latencyDescription: '45ms, medium distribution' },
{ regionName: 'Asia', latencyDescription: '140ms, medium distribution' },
],
});
expect(selectedRegionPlans(container)).toEqual([
'Europe / 45ms, medium distribution',
'Asia / 140ms, medium distribution',
]);

await removeRegion(1);

expect(selectedRegionPlans(container)).toEqual(['Europe / 60ms, small distribution']);
});

it('keeps the surviving region when the first is removed on a tier that limits regions', async () => {
const { container } = renderForm({
performanceDescription: 'Free tier',
regionPlans: [
{ regionName: 'Asia', latencyDescription: '140ms, medium distribution' },
{ regionName: 'Europe', latencyDescription: '45ms, medium distribution' },
],
});

await removeRegion(0);

expect(selectedRegionPlans(container)).toEqual(['Europe / 60ms, small distribution']);
});

it('leaves an allowed first region untouched on a tier that limits regions', async () => {
const { container } = renderForm({
performanceDescription: 'Free tier',
regionPlans: [
{ regionName: 'Europe', latencyDescription: '60ms, small distribution' },
{ regionName: 'Asia', latencyDescription: '140ms, medium distribution' },
],
});

await removeRegion(1);

expect(selectedRegionPlans(container)).toEqual(['Europe / 60ms, small distribution']);
});

it('falls back to the default region only when the tier offers nothing in the chosen one', async () => {
const { container } = renderForm({
performanceDescription: 'Free tier',
regionPlans: [
{ regionName: 'Asia', latencyDescription: '140ms, medium distribution' },
{ regionName: 'Europe', latencyDescription: '45ms, medium distribution' },
],
});

await removeRegion(1);

expect(selectedRegionPlans(container)).toEqual(['US / 90ms, small distribution']);
});
});
3 changes: 2 additions & 1 deletion src/features/clusters/upsert/ClusterForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,8 @@ export function ClusterForm({
?.[firstRegion.latencyDescription];
if (!allowedRegionIds.includes(firstSelectedRegion?.id)) {
const possibleRegions = regionLocations?.filter(r => allowedRegionIds.includes(r.id));
const regionToSelect = possibleRegions?.find(r => r.region === 'US') || possibleRegions?.[0];
const regionToSelect = possibleRegions?.find(r => r.region === firstRegion.regionName)
|| possibleRegions?.find(r => r.region === 'US') || possibleRegions?.[0];
if (regionToSelect) {
form.setValue('regionPlans.0.regionName', regionToSelect.region);
form.setValue('regionPlans.0.latencyDescription', regionToSelect.latencyDescription);
Expand Down
Loading