Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
7316242
feat(admin): manage an organization's custom regions
DavidCockerill Oct 2, 2026
f9d7070
feat(clusters): key region plans by id and let staff deploy custom re…
DavidCockerill Oct 2, 2026
2d46b59
fix(clusters): reconcile drafts with the live cluster and survive a f…
DavidCockerill Oct 2, 2026
dadfe6c
fix(clusters): keep unresolved draft regions for validation and unblo…
DavidCockerill Oct 2, 2026
9b9e0ce
fix(clusters): let a version-only edit through without region plans
DavidCockerill Oct 2, 2026
2396d3f
feat(admin): derive a custom region's blocks from its shape
DavidCockerill Oct 5, 2026
d494275
fix(clusters): refuse a custom region with no datacenters on the orga…
DavidCockerill Oct 5, 2026
918e193
fix(clusters): a member retrying a failed cluster can swap out its cu…
DavidCockerill Oct 5, 2026
d3bd934
feat(clusters): define custom regions first, then pick them per row
DavidCockerill Oct 5, 2026
86a5f06
fix(admin): name the provider a custom region must list, and explain …
DavidCockerill Oct 7, 2026
673ffbb
fix(clusters): defining a custom region no longer submits the cluster…
DavidCockerill Oct 7, 2026
8eba2da
fix(clusters): let the Units box be emptied while retyping
DavidCockerill Oct 7, 2026
ef04b29
refactor(admin): drop the Custom regions admin page; the org tab will…
DavidCockerill Oct 7, 2026
c101013
fix(clusters): an option description too long for its select ends in …
DavidCockerill Oct 7, 2026
fb363df
fix(clusters): drop the picker hint that pointed at the removed admin…
DavidCockerill Oct 7, 2026
137159f
test(clusters): give the refused-edit test the custom-region props
dawsontoth Oct 14, 2026
99ffe84
fix(clusters): hide Define custom region on a central manager without…
DavidCockerill Oct 22, 2026
8411d29
feat(clusters): show a custom region's units and datacenters on the c…
DavidCockerill Oct 22, 2026
4d1923a
fix(clusters): keep custom regions on the form when the catalog has none
DavidCockerill Oct 22, 2026
0df89bc
fix(admin): report custom-region write failures and keep the lost-wri…
DavidCockerill Oct 22, 2026
24214ab
fix(clusters): open retries and stale drafts instead of refusing or d…
DavidCockerill Oct 22, 2026
cc3c40f
fix(clusters): revalidate the whole form before payment review submits
DavidCockerill Oct 22, 2026
5770f73
fix(cluster): show a custom region's datacenters only once the provid…
DavidCockerill Oct 22, 2026
6f24aad
fix(clusters): say so when a saved draft cannot be restored, and star…
DavidCockerill Oct 22, 2026
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { OrganizationRegion } from '@/integrations/api/api.patch';
import { describe, expect, it } from 'vitest';
import {
NO_FALLBACK,
OrganizationRegionFormSchema,
toCreatePayload,
toFormValues,
toPatch,
} from './OrganizationRegionFormSchema';

const stored: OrganizationRegion = {
id: 'oreg-1',
organizationId: 'org-1',
name: 'EU edge',
placement: { linode: ['fr-par', 'it-mil'], gcp: [] },
fallbackGroup: 'europe',
active: true,
};

const valid = {
name: 'EU edge',
linodeDatacenters: ['fr-par'],
gcpDatacenters: [],
fallbackGroup: NO_FALLBACK,
active: true,
};

describe('OrganizationRegionFormSchema', () => {
it('accepts a region with one provider populated', () => {
expect(OrganizationRegionFormSchema.safeParse(valid).success).toBe(true);
});

it('requires at least one datacenter across providers', () => {
const result = OrganizationRegionFormSchema.safeParse({ ...valid, linodeDatacenters: [] });
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].path).toEqual(['gcpDatacenters']);
}
});

it('trims and requires the name', () => {
expect(OrganizationRegionFormSchema.safeParse({ ...valid, name: ' ' }).success).toBe(false);
const parsed = OrganizationRegionFormSchema.safeParse({ ...valid, name: ' EU ' });
expect(parsed.success && parsed.data.name).toBe('EU');
});
});

describe('toFormValues', () => {
it('maps a stored row and defaults a new one', () => {
expect(toFormValues(stored)).toEqual({
name: 'EU edge',
linodeDatacenters: ['fr-par', 'it-mil'],
gcpDatacenters: [],
fallbackGroup: 'europe',
active: true,
});
expect(toFormValues(null)).toEqual({
name: '',
linodeDatacenters: [],
gcpDatacenters: [],
fallbackGroup: NO_FALLBACK,
active: true,
});
});
});

describe('toCreatePayload', () => {
it('stores the no-fallback choice as null', () => {
expect(toCreatePayload('org-1', valid)).toEqual({
organizationId: 'org-1',
name: 'EU edge',
placement: { linode: ['fr-par'], gcp: [] },
fallbackGroup: null,
active: true,
});
});
});

describe('toPatch', () => {
it('sends only the fields that changed', () => {
expect(toPatch(stored, toFormValues(stored))).toEqual({});
expect(toPatch(stored, { ...toFormValues(stored), active: false })).toEqual({ active: false });
});

it('sends the whole placement when either provider list changes', () => {
expect(toPatch(stored, { ...toFormValues(stored), gcpDatacenters: ['europe-west1'] })).toEqual({
placement: { linode: ['fr-par', 'it-mil'], gcp: ['europe-west1'] },
});
});

it('clears the fallback pool with null', () => {
expect(toPatch(stored, { ...toFormValues(stored), fallbackGroup: NO_FALLBACK })).toEqual({ fallbackGroup: null });
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { OrganizationRegion, OrganizationRegionPatch, OrganizationRegionPayload } from '@/integrations/api/api.patch';
import { z } from 'zod';

/** Fields central-manager freezes once a live cluster deploys the region. */
export const FROZEN_WHILE_REFERENCED = ['name', 'linodeDatacenters', 'gcpDatacenters', 'fallbackGroup'] as const;

/** The form's "no fallback pool" choice; the API stores null for it. */
export const NO_FALLBACK = '';

// A repeated datacenter asks for another instance there.
export const OrganizationRegionFormSchema = z.object({
name: z.string().trim().min(1, 'Name is required').max(64, 'Keep the name to 64 characters'),
linodeDatacenters: z.array(z.string()),
gcpDatacenters: z.array(z.string()),
fallbackGroup: z.string(),
active: z.boolean(),
}).superRefine((values, ctx) => {
if (!values.linodeDatacenters.length && !values.gcpDatacenters.length) {
ctx.addIssue({
code: 'custom',
path: ['gcpDatacenters'],
message: 'Add at least one datacenter under Linode or GCP',
});
}
});

export type OrganizationRegionFormValues = z.infer<typeof OrganizationRegionFormSchema>;

export function toFormValues(region?: OrganizationRegion | null): OrganizationRegionFormValues {
return {
name: region?.name ?? '',
linodeDatacenters: region?.placement?.linode ?? [],
gcpDatacenters: region?.placement?.gcp ?? [],
fallbackGroup: region?.fallbackGroup ?? NO_FALLBACK,
active: region?.active ?? true,
};
}

export function toCreatePayload(
organizationId: string,
values: OrganizationRegionFormValues,
): OrganizationRegionPayload {
return {
organizationId,
name: values.name,
placement: { linode: values.linodeDatacenters, gcp: values.gcpDatacenters },
fallbackGroup: values.fallbackGroup === NO_FALLBACK ? null : values.fallbackGroup,
active: values.active,
};
}

export function toPatch(region: OrganizationRegion, values: OrganizationRegionFormValues): OrganizationRegionPatch {
const stored = toFormValues(region);
const patch: OrganizationRegionPatch = {};
if (values.name !== stored.name) {
patch.name = values.name;
}
const sameList = (a: string[], b: string[]) => a.length === b.length && a.every((v, i) => v === b[i]);
if (
!sameList(values.linodeDatacenters, stored.linodeDatacenters)
|| !sameList(values.gcpDatacenters, stored.gcpDatacenters)
) {
patch.placement = { linode: values.linodeDatacenters, gcp: values.gcpDatacenters };
}
if (values.fallbackGroup !== stored.fallbackGroup) {
patch.fallbackGroup = values.fallbackGroup === NO_FALLBACK ? null : values.fallbackGroup;
}
if (values.active !== stored.active) {
patch.active = values.active;
}
return patch;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* @vitest-environment jsdom
*/
import { MutationCache, QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { AxiosError } from 'axios';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const { get, patch } = vi.hoisted(() => ({ get: vi.fn(), patch: vi.fn() }));
vi.mock('@/config/apiClient', () => ({ apiClient: { get, patch, post: vi.fn() } }));
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));

import { OrganizationRegion } from '@/integrations/api/api.patch';
import { UNANSWERED_WRITE_MESSAGE } from '@/integrations/api/unansweredWrite';
import { mutationErrorHandler } from '@/react-query/queryClient';
import { toast } from 'sonner';
import { OrganizationRegionFormModal } from './OrganizationRegionFormModal';

const region: OrganizationRegion = {
id: 'oreg-1',
organizationId: 'org-1',
name: 'EU edge',
placement: { gcp: ['europe-west1-b'] },
fallbackGroup: null,
active: true,
};

/** A PATCH that reached the server but whose response never came back. */
function lostResponse(): AxiosError {
return {
isAxiosError: true,
code: 'ERR_NETWORK',
message: 'Network Error',
config: { method: 'patch', url: '/OrganizationRegion/oreg-1' },
} as AxiosError;
}

let consoleError: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
get.mockImplementation(async (url: string) => {
if (url === '/Location/') {
return { data: [{ location: 'europe-west1-b', locationName: 'Belgium', cloudProvider: 'gcp', regions: [] }] };
}
if (url.startsWith('/Organization/')) {
return { data: { id: 'org-1', name: 'Org', channel: 'bravo' } };
}
return { data: { ...region, clusters: [] } };
});
});
afterEach(() => {
consoleError.mockRestore();
vi.resetAllMocks();
});

describe('OrganizationRegionFormModal', () => {
it('tells the user a lost write may have applied, and still reports it', async () => {
patch.mockRejectedValue(lostResponse());
const client = new QueryClient({
mutationCache: new MutationCache({ onError: mutationErrorHandler }),
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
render(
<QueryClientProvider client={client}>
<OrganizationRegionFormModal open={true} onOpenChange={vi.fn()} organizationId="org-1" region={region} />
</QueryClientProvider>,
);

fireEvent.change(await screen.findByDisplayValue('EU edge'), { target: { value: 'EU core' } });
fireEvent.click(screen.getByRole('button', { name: 'Save changes' }));

expect((await screen.findByRole('alert')).textContent).toBe(UNANSWERED_WRITE_MESSAGE);
await waitFor(() => expect(consoleError).toHaveBeenCalledWith(expect.objectContaining({ code: 'ERR_NETWORK' })));
expect(toast.error).not.toHaveBeenCalled();
});
});
Loading
Loading