From 3f33ae8a5caeb3bb3f8eec9b8d39ee148526f5ea Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 15 Oct 2026 12:07:50 -0400 Subject: [PATCH 1/7] Add checkboxes to allow for opt-out of default VPC, subnet --- app/forms/project-create.tsx | 24 +++++++++-- app/forms/vpc-create.tsx | 24 ++++++++--- mock-api/msw/handlers.ts | 75 ++++++++++++++++++++++------------ test/e2e/project-create.e2e.ts | 21 ++++++++++ test/e2e/vpcs.e2e.ts | 26 ++++++++++++ 5 files changed, 134 insertions(+), 36 deletions(-) diff --git a/app/forms/project-create.tsx b/app/forms/project-create.tsx index 0f865b6a7f..3bcca8146e 100644 --- a/app/forms/project-create.tsx +++ b/app/forms/project-create.tsx @@ -8,8 +8,9 @@ import { useForm } from 'react-hook-form' import { useNavigate } from 'react-router' -import { api, q, queryClient, useApiMutation, type ProjectCreate } from '@oxide/api' +import { api, q, queryClient, useApiMutation } from '@oxide/api' +import { CheckboxField } from '~/components/form/fields/CheckboxField' import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' @@ -20,9 +21,16 @@ import { SideModalFormDocs } from '~/ui/lib/ModalLinks' import { docLinks } from '~/util/links' import { pb } from '~/util/path-builder' -const defaultValues: ProjectCreate = { +type ProjectCreateFormValues = { + name: string + description: string + createDefaultVpc: boolean +} + +const defaultValues: ProjectCreateFormValues = { name: '', description: '', + createDefaultVpc: true, } export const handle = titleCrumb('New project') @@ -53,14 +61,22 @@ export default function ProjectCreateSideModalForm() { formType="create" resourceName="project" onDismiss={onDismiss} - onSubmit={({ name, description }) => { - createProject.mutate({ body: { name, description } }) + onSubmit={({ name, description, createDefaultVpc }) => { + // omitting `defaults` creates all of them. `{}` creates none, including + // any project defaults the API adds later, so if `ProjectCreateDefaults` + // gains a field besides `vpc`, unchecking "Create default VPC" will skip + // it too and this form will need updating + const defaults = createDefaultVpc ? undefined : {} + createProject.mutate({ body: { name, description, defaults } }) }} loading={createProject.isPending || createProject.isSuccess} submitError={createProject.error} > + + Create default VPC + ) diff --git a/app/forms/vpc-create.tsx b/app/forms/vpc-create.tsx index 06fcf43af9..6bc7f2c59e 100644 --- a/app/forms/vpc-create.tsx +++ b/app/forms/vpc-create.tsx @@ -10,6 +10,7 @@ import { useNavigate } from 'react-router' import { api, q, queryClient, useApiMutation, type VpcCreate } from '@oxide/api' +import { CheckboxField } from '~/components/form/fields/CheckboxField' import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { TextField } from '~/components/form/fields/TextField' @@ -28,8 +29,7 @@ type VpcCreateFormValues = { description: string dnsName: string ipv6Prefix: string - // defaults will go here once we support it in the UI. for now, omitting means - // all defaults + createDefaultSubnet: boolean } const defaultValues: VpcCreateFormValues = { @@ -37,6 +37,7 @@ const defaultValues: VpcCreateFormValues = { description: '', dnsName: '', ipv6Prefix: '', + createDefaultSubnet: true, } export const handle = titleCrumb('New VPC') @@ -67,9 +68,16 @@ export default function CreateVpcSideModalForm() { form={form} formType="create" resourceName="VPC" - onSubmit={({ ipv6Prefix, ...rest }) => { - // omitting `defaults` means create all defaults, i.e., the original behavior - const body: VpcCreate = { ...rest, ipv6Prefix: ipv6Prefix.trim() || undefined } + onSubmit={({ ipv6Prefix, createDefaultSubnet, ...rest }) => { + const body: VpcCreate = { + ...rest, + ipv6Prefix: ipv6Prefix.trim() || undefined, + // omitting `defaults` creates all of them. `{}` creates none, including + // any VPC defaults the API adds later, so if `VpcCreateDefaults` gains a + // field besides `subnet`, unchecking "Create default subnet" will skip + // it too and this form will need updating + defaults: createDefaultSubnet ? undefined : {}, + } createVpc.mutate({ query: projectSelector, body }) }} onDismiss={() => navigate(pb.vpcs(projectSelector))} @@ -90,6 +98,12 @@ export default function CreateVpcSideModalForm() { return validateVpcIpv6Prefix(prefix) }} /> + + Create default subnet + + A subnet is required for instances to attach to this VPC + + ) diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 69a3889a93..0f9641c70a 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -91,6 +91,40 @@ import { // client camel-cases the keys and parses date fields. Inside the mock API everything // is *JSON type. +/** Shared by `vpcCreate` and `projectCreate`, which can create a default VPC */ +function createVpc(projectId: string, body: Json) { + const { defaults, ...rest } = body + const newVpc: Json = { + id: uuid(), + project_id: projectId, + system_router_id: uuid(), + ...rest, + // API is supposed to generate one if none provided. close enough + ipv6_prefix: body.ipv6_prefix || 'fd2d:4569:88b2::/64', + ...getTimestamps(), + } + db.vpcs.push(newVpc) + + // omitting `defaults` creates all of them. otherwise only the ones listed + if (!defaults || defaults.subnet) { + const newSubnet: Json = { + id: uuid(), + name: 'default', + vpc_id: newVpc.id, + ipv6_block: 'fd2d:4569:88b1::/64', + description: '', + ipv4_block: '', + ...getTimestamps(), + } + db.vpcSubnets.push(newSubnet) + } + + // populate default firewall rules, which are always created + db.vpcFirewallRules.push(...defaultFirewallRules(newVpc.id)) + + return newVpc +} + export const handlers = makeHandlers({ logout: () => 204, ping: () => ({ status: 'ok' }), @@ -117,14 +151,26 @@ export const handlers = makeHandlers({ const user = currentUser(cookies) errIfExists(db.projects, { name: body.name, silo_id: user.silo_id }, 'project') + const { defaults, ...rest } = body const newProject = { id: uuid(), - ...body, + ...rest, ...getTimestamps(), silo_id: user.silo_id, } db.projects.push(newProject) + // omitting `defaults` creates all of them. otherwise only the ones listed + const vpcSelection = defaults ? defaults.vpc : { type: 'all' as const } + if (vpcSelection) { + createVpc(newProject.id, { + name: 'default', + dns_name: 'default', + description: 'Default VPC', + defaults: vpcSelection.type === 'all' ? undefined : vpcSelection.defaults, + }) + } + return json(R.omit(newProject, ['silo_id']), { status: 201 }) }, projectView: ({ path }) => { @@ -1582,32 +1628,7 @@ export const handlers = makeHandlers({ requireRole(cookies, 'project', project.id, 'collaborator') errIfExists(db.vpcs, { name: body.name, project_id: project.id }) - const newVpc: Json = { - id: uuid(), - project_id: project.id, - system_router_id: uuid(), - ...body, - // API is supposed to generate one if none provided. close enough - ipv6_prefix: body.ipv6_prefix || 'fd2d:4569:88b2::/64', - ...getTimestamps(), - } - db.vpcs.push(newVpc) - - // Also create a default subnet - const newSubnet: Json = { - id: uuid(), - name: 'default', - vpc_id: newVpc.id, - ipv6_block: 'fd2d:4569:88b1::/64', - description: '', - ipv4_block: '', - ...getTimestamps(), - } - db.vpcSubnets.push(newSubnet) - - // populate default firewall rules - db.vpcFirewallRules.push(...defaultFirewallRules(newVpc.id)) - + const newVpc = createVpc(project.id, body) return json(newVpc, { status: 201 }) }, vpcView: ({ path, query }) => lookup.vpc({ ...path, ...query }), diff --git a/test/e2e/project-create.e2e.ts b/test/e2e/project-create.e2e.ts index db469b1c00..25d30f8a7e 100644 --- a/test/e2e/project-create.e2e.ts +++ b/test/e2e/project-create.e2e.ts @@ -29,6 +29,27 @@ test.describe('Project create', () => { await expect(page).toHaveURL('/projects/mock-project-2/instances') }) + test('creates default VPC by default', async ({ page }) => { + await page.getByRole('textbox', { name: 'Name' }).fill('mock-project-2') + await expect(page.getByRole('checkbox', { name: 'Create default VPC' })).toBeChecked() + await page.getByRole('button', { name: 'Create project' }).click() + await expect(page).toHaveURL('/projects/mock-project-2/instances') + + // client-side nav to keep the mock db state + await page.getByRole('link', { name: 'VPCs' }).click() + await expect(page.getByRole('cell', { name: 'default' }).first()).toBeVisible() + }) + + test('can skip creating the default VPC', async ({ page }) => { + await page.getByRole('textbox', { name: 'Name' }).fill('mock-project-2') + await page.getByRole('checkbox', { name: 'Create default VPC' }).uncheck() + await page.getByRole('button', { name: 'Create project' }).click() + await expect(page).toHaveURL('/projects/mock-project-2/instances') + + await page.getByRole('link', { name: 'VPCs' }).click() + await expect(page.getByText('No VPCs')).toBeVisible() + }) + test('clicking the scrim dismisses the side modal', async ({ page }) => { const dialog = page.getByRole('dialog', { name: /Create project/ }) await expect(dialog).toBeVisible() diff --git a/test/e2e/vpcs.e2e.ts b/test/e2e/vpcs.e2e.ts index 36d42894ee..b89aee1d70 100644 --- a/test/e2e/vpcs.e2e.ts +++ b/test/e2e/vpcs.e2e.ts @@ -129,6 +129,32 @@ test('whitespace-only IPv6 prefix is omitted on VPC create', async ({ page }) => await expect(page.getByRole('heading', { name: 'vpc-generated-v6' })).toBeVisible() }) +test('can create VPC with and without default subnet', async ({ page }) => { + await page.goto('/projects/mock-project/vpcs') + + const dialog = page.getByRole('dialog', { name: 'Create VPC' }) + const createVpc = async (name: string, createDefaultSubnet: boolean) => { + await page.getByRole('link', { name: 'New VPC' }).click() + await dialog.getByRole('textbox', { name: 'Name', exact: true }).fill(name) + await dialog.getByRole('textbox', { name: 'DNS name' }).fill(name) + const checkbox = dialog.getByRole('checkbox', { name: 'Create default subnet' }) + await expect(checkbox).toBeChecked() + await checkbox.setChecked(createDefaultSubnet) + await dialog.getByRole('button', { name: 'Create VPC' }).click() + await expect(dialog).toBeHidden() + await expect(page.getByRole('heading', { name })).toBeVisible() + await page.getByRole('tab', { name: 'VPC Subnets' }).click() + } + + await createVpc('vpc-with-subnet', true) + await expectRowVisible(page.getByRole('table'), { name: 'default' }) + + // client-side nav back to the list to keep the mock db state + await page.getByRole('link', { name: 'VPCs' }).first().click() + await createVpc('vpc-without-subnet', false) + await expect(page.getByText('No VPC subnets')).toBeVisible() +}) + test('can create and delete subnet', async ({ page }) => { await page.goto('/projects/mock-project/vpcs/default') await page.getByRole('tab', { name: 'VPC Subnets' }).click() From 17c9002ae9db9217547bd64424a2651373a028d1 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 15 Oct 2026 13:50:52 -0400 Subject: [PATCH 2/7] refactoring, plus new information box styling --- app/forms/project-create.tsx | 20 ++++++++-------- app/forms/vpc-create.tsx | 22 ++++++++++-------- mock-api/msw/handlers.ts | 41 +++------------------------------ mock-api/msw/util.ts | 40 ++++++++++++++++++++++++++++++++ test/e2e/vpcs.e2e.ts | 44 ++++++++++++++++++++---------------- 5 files changed, 90 insertions(+), 77 deletions(-) diff --git a/app/forms/project-create.tsx b/app/forms/project-create.tsx index 3bcca8146e..5b96ac4352 100644 --- a/app/forms/project-create.tsx +++ b/app/forms/project-create.tsx @@ -8,7 +8,7 @@ import { useForm } from 'react-hook-form' import { useNavigate } from 'react-router' -import { api, q, queryClient, useApiMutation } from '@oxide/api' +import { api, q, queryClient, useApiMutation, type ProjectCreate } from '@oxide/api' import { CheckboxField } from '~/components/form/fields/CheckboxField' import { DescriptionField } from '~/components/form/fields/DescriptionField' @@ -21,9 +21,7 @@ import { SideModalFormDocs } from '~/ui/lib/ModalLinks' import { docLinks } from '~/util/links' import { pb } from '~/util/path-builder' -type ProjectCreateFormValues = { - name: string - description: string +type ProjectCreateFormValues = Omit & { createDefaultVpc: boolean } @@ -62,12 +60,14 @@ export default function ProjectCreateSideModalForm() { resourceName="project" onDismiss={onDismiss} onSubmit={({ name, description, createDefaultVpc }) => { - // omitting `defaults` creates all of them. `{}` creates none, including - // any project defaults the API adds later, so if `ProjectCreateDefaults` - // gains a field besides `vpc`, unchecking "Create default VPC" will skip - // it too and this form will need updating - const defaults = createDefaultVpc ? undefined : {} - createProject.mutate({ body: { name, description, defaults } }) + createProject.mutate({ + body: { + name, + description, + // omitting `defaults` creates all of them. `{}` also skips any the API adds later + defaults: createDefaultVpc ? undefined : {}, + }, + }) }} loading={createProject.isPending || createProject.isSuccess} submitError={createProject.error} diff --git a/app/forms/vpc-create.tsx b/app/forms/vpc-create.tsx index 6bc7f2c59e..c568d9c131 100644 --- a/app/forms/vpc-create.tsx +++ b/app/forms/vpc-create.tsx @@ -19,6 +19,7 @@ import { HL } from '~/components/HL' import { titleCrumb } from '~/hooks/use-crumbs' import { useProjectSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' +import { Message } from '~/ui/lib/Message' import { SideModalFormDocs } from '~/ui/lib/ModalLinks' import { validateVpcIpv6Prefix } from '~/util/ip' import { docLinks } from '~/util/links' @@ -72,10 +73,7 @@ export default function CreateVpcSideModalForm() { const body: VpcCreate = { ...rest, ipv6Prefix: ipv6Prefix.trim() || undefined, - // omitting `defaults` creates all of them. `{}` creates none, including - // any VPC defaults the API adds later, so if `VpcCreateDefaults` gains a - // field besides `subnet`, unchecking "Create default subnet" will skip - // it too and this form will need updating + // omitting `defaults` creates all of them. `{}` also skips any the API adds later defaults: createDefaultSubnet ? undefined : {}, } createVpc.mutate({ query: projectSelector, body }) @@ -98,12 +96,16 @@ export default function CreateVpcSideModalForm() { return validateVpcIpv6Prefix(prefix) }} /> - - Create default subnet - - A subnet is required for instances to attach to this VPC - - +
+ + Create default subnet + + +
) diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 0f9641c70a..07e6c91bb1 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -47,7 +47,6 @@ import { defaultSilo, toIdp } from '../silo' import { SUPPORT_BUNDLE_SIZE } from '../support-bundle' import { timeseriesSchemas } from '../timeseries-schema' import { getTimestamps } from '../util' -import { defaultFirewallRules } from '../vpc' import { resendableAlerts, retryPendingDeliveries, validateSubscription } from './alert' import { db, @@ -60,6 +59,7 @@ import { } from './db' import { alreadyExistsErr, + createVpc, currentUser, errIfExists, errIfInvalidDiskSize, @@ -91,40 +91,6 @@ import { // client camel-cases the keys and parses date fields. Inside the mock API everything // is *JSON type. -/** Shared by `vpcCreate` and `projectCreate`, which can create a default VPC */ -function createVpc(projectId: string, body: Json) { - const { defaults, ...rest } = body - const newVpc: Json = { - id: uuid(), - project_id: projectId, - system_router_id: uuid(), - ...rest, - // API is supposed to generate one if none provided. close enough - ipv6_prefix: body.ipv6_prefix || 'fd2d:4569:88b2::/64', - ...getTimestamps(), - } - db.vpcs.push(newVpc) - - // omitting `defaults` creates all of them. otherwise only the ones listed - if (!defaults || defaults.subnet) { - const newSubnet: Json = { - id: uuid(), - name: 'default', - vpc_id: newVpc.id, - ipv6_block: 'fd2d:4569:88b1::/64', - description: '', - ipv4_block: '', - ...getTimestamps(), - } - db.vpcSubnets.push(newSubnet) - } - - // populate default firewall rules, which are always created - db.vpcFirewallRules.push(...defaultFirewallRules(newVpc.id)) - - return newVpc -} - export const handlers = makeHandlers({ logout: () => 204, ping: () => ({ status: 'ok' }), @@ -161,13 +127,12 @@ export const handlers = makeHandlers({ db.projects.push(newProject) // omitting `defaults` creates all of them. otherwise only the ones listed - const vpcSelection = defaults ? defaults.vpc : { type: 'all' as const } - if (vpcSelection) { + if (!defaults || defaults.vpc) { createVpc(newProject.id, { name: 'default', dns_name: 'default', description: 'Default VPC', - defaults: vpcSelection.type === 'all' ? undefined : vpcSelection.defaults, + defaults: defaults?.vpc?.type === 'explicit' ? defaults.vpc.defaults : undefined, }) } diff --git a/mock-api/msw/util.ts b/mock-api/msw/util.ts index 5dd54c56d8..2058af01b9 100644 --- a/mock-api/msw/util.ts +++ b/mock-api/msw/util.ts @@ -11,6 +11,7 @@ import { differenceInSeconds, subHours } from 'date-fns' import { IPv4, IPv6 } from 'ip-num/IPNumber.js' import * as R from 'remeda' import { match } from 'ts-pattern' +import { v4 as uuid } from 'uuid' import { FLEET_ID, @@ -31,6 +32,9 @@ import { type SystemMetricQueryParams, type TimeseriesQuery, type User, + type Vpc, + type VpcCreate, + type VpcSubnet, } from '@oxide/api' import { json, type Json } from '~/api/__generated__/msw-handlers' @@ -55,6 +59,8 @@ import { getMockValues, getHistoPoints, } from '../oxql-metrics' +import { getTimestamps } from '../util' +import { defaultFirewallRules } from '../vpc' import { db, lookupById } from './db' interface PaginateOptions { @@ -763,3 +769,37 @@ export function randomHex(length: number) { .map(() => hexChars.charAt(Math.floor(Math.random() * hexChars.length))) .join('') } + +/** Shared by `vpcCreate` and `projectCreate`, which can create a default VPC */ +export function createVpc(projectId: string, body: Json) { + const { defaults, ...rest } = body + const newVpc: Json = { + id: uuid(), + project_id: projectId, + system_router_id: uuid(), + ...rest, + // API is supposed to generate one if none provided. close enough + ipv6_prefix: body.ipv6_prefix || 'fd2d:4569:88b2::/64', + ...getTimestamps(), + } + db.vpcs.push(newVpc) + + // omitting `defaults` creates all of them. otherwise only the ones listed + if (!defaults || defaults.subnet) { + const newSubnet: Json = { + id: uuid(), + name: 'default', + vpc_id: newVpc.id, + ipv6_block: 'fd2d:4569:88b1::/64', + description: '', + ipv4_block: '', + ...getTimestamps(), + } + db.vpcSubnets.push(newSubnet) + } + + // populate default firewall rules, which are always created + db.vpcFirewallRules.push(...defaultFirewallRules(newVpc.id)) + + return newVpc +} diff --git a/test/e2e/vpcs.e2e.ts b/test/e2e/vpcs.e2e.ts index b89aee1d70..c4e81052ca 100644 --- a/test/e2e/vpcs.e2e.ts +++ b/test/e2e/vpcs.e2e.ts @@ -129,29 +129,35 @@ test('whitespace-only IPv6 prefix is omitted on VPC create', async ({ page }) => await expect(page.getByRole('heading', { name: 'vpc-generated-v6' })).toBeVisible() }) -test('can create VPC with and without default subnet', async ({ page }) => { - await page.goto('/projects/mock-project/vpcs') +test('creates default subnet on VPC create by default', async ({ page }) => { + await page.goto('/projects/mock-project/vpcs-new') const dialog = page.getByRole('dialog', { name: 'Create VPC' }) - const createVpc = async (name: string, createDefaultSubnet: boolean) => { - await page.getByRole('link', { name: 'New VPC' }).click() - await dialog.getByRole('textbox', { name: 'Name', exact: true }).fill(name) - await dialog.getByRole('textbox', { name: 'DNS name' }).fill(name) - const checkbox = dialog.getByRole('checkbox', { name: 'Create default subnet' }) - await expect(checkbox).toBeChecked() - await checkbox.setChecked(createDefaultSubnet) - await dialog.getByRole('button', { name: 'Create VPC' }).click() - await expect(dialog).toBeHidden() - await expect(page.getByRole('heading', { name })).toBeVisible() - await page.getByRole('tab', { name: 'VPC Subnets' }).click() - } - - await createVpc('vpc-with-subnet', true) + await dialog.getByRole('textbox', { name: 'Name', exact: true }).fill('vpc-with-subnet') + await dialog.getByRole('textbox', { name: 'DNS name' }).fill('vpc-with-subnet') + await expect( + dialog.getByRole('checkbox', { name: 'Create default subnet' }) + ).toBeChecked() + await dialog.getByRole('button', { name: 'Create VPC' }).click() + + await expect(page.getByRole('heading', { name: 'vpc-with-subnet' })).toBeVisible() + await page.getByRole('tab', { name: 'VPC Subnets' }).click() await expectRowVisible(page.getByRole('table'), { name: 'default' }) +}) - // client-side nav back to the list to keep the mock db state - await page.getByRole('link', { name: 'VPCs' }).first().click() - await createVpc('vpc-without-subnet', false) +test('can skip creating the default subnet', async ({ page }) => { + await page.goto('/projects/mock-project/vpcs-new') + + const dialog = page.getByRole('dialog', { name: 'Create VPC' }) + await dialog + .getByRole('textbox', { name: 'Name', exact: true }) + .fill('vpc-without-subnet') + await dialog.getByRole('textbox', { name: 'DNS name' }).fill('vpc-without-subnet') + await dialog.getByRole('checkbox', { name: 'Create default subnet' }).uncheck() + await dialog.getByRole('button', { name: 'Create VPC' }).click() + + await expect(page.getByRole('heading', { name: 'vpc-without-subnet' })).toBeVisible() + await page.getByRole('tab', { name: 'VPC Subnets' }).click() await expect(page.getByText('No VPC subnets')).toBeVisible() }) From 042e9d1531598f35be36129ebf61760ab03c4709 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 15 Oct 2026 17:22:39 -0700 Subject: [PATCH 3/7] Update copy; conditionally show help text --- app/forms/vpc-create.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/app/forms/vpc-create.tsx b/app/forms/vpc-create.tsx index c568d9c131..1bb2e0e870 100644 --- a/app/forms/vpc-create.tsx +++ b/app/forms/vpc-create.tsx @@ -63,6 +63,7 @@ export default function CreateVpcSideModalForm() { }) const form = useForm({ defaultValues }) + const createDefaultSubnet = form.watch('createDefaultSubnet') return ( Create default subnet - + {!createDefaultSubnet && ( + + )} From dc32b950a0d471826736ceaf659f9bca6d2245a2 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 21 Oct 2026 16:45:31 -0700 Subject: [PATCH 4/7] be more explicit with what we're creating --- app/forms/project-create.tsx | 22 +++++++++++++++++----- app/forms/vpc-create.tsx | 3 +-- test/e2e/project-create.e2e.ts | 6 ++++-- 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/app/forms/project-create.tsx b/app/forms/project-create.tsx index 5b96ac4352..c9ad89332f 100644 --- a/app/forms/project-create.tsx +++ b/app/forms/project-create.tsx @@ -17,6 +17,7 @@ import { SideModalForm } from '~/components/form/SideModalForm' import { HL } from '~/components/HL' import { titleCrumb } from '~/hooks/use-crumbs' import { addToast } from '~/stores/toast' +import { Message } from '~/ui/lib/Message' import { SideModalFormDocs } from '~/ui/lib/ModalLinks' import { docLinks } from '~/util/links' import { pb } from '~/util/path-builder' @@ -52,6 +53,7 @@ export default function ProjectCreateSideModalForm() { }) const form = useForm({ defaultValues }) + const createDefaultVpc = form.watch('createDefaultVpc') return ( - - Create default VPC - +
+ + Create default VPC and subnet + + {!createDefaultVpc && ( + + )} +
) diff --git a/app/forms/vpc-create.tsx b/app/forms/vpc-create.tsx index 1bb2e0e870..3dcc49c010 100644 --- a/app/forms/vpc-create.tsx +++ b/app/forms/vpc-create.tsx @@ -74,8 +74,7 @@ export default function CreateVpcSideModalForm() { const body: VpcCreate = { ...rest, ipv6Prefix: ipv6Prefix.trim() || undefined, - // omitting `defaults` creates all of them. `{}` also skips any the API adds later - defaults: createDefaultSubnet ? undefined : {}, + defaults: createDefaultSubnet ? { subnet: {} } : {}, } createVpc.mutate({ query: projectSelector, body }) }} diff --git a/test/e2e/project-create.e2e.ts b/test/e2e/project-create.e2e.ts index 25d30f8a7e..a945d13b28 100644 --- a/test/e2e/project-create.e2e.ts +++ b/test/e2e/project-create.e2e.ts @@ -31,7 +31,9 @@ test.describe('Project create', () => { test('creates default VPC by default', async ({ page }) => { await page.getByRole('textbox', { name: 'Name' }).fill('mock-project-2') - await expect(page.getByRole('checkbox', { name: 'Create default VPC' })).toBeChecked() + await expect( + page.getByRole('checkbox', { name: 'Create default VPC and subnet' }) + ).toBeChecked() await page.getByRole('button', { name: 'Create project' }).click() await expect(page).toHaveURL('/projects/mock-project-2/instances') @@ -42,7 +44,7 @@ test.describe('Project create', () => { test('can skip creating the default VPC', async ({ page }) => { await page.getByRole('textbox', { name: 'Name' }).fill('mock-project-2') - await page.getByRole('checkbox', { name: 'Create default VPC' }).uncheck() + await page.getByRole('checkbox', { name: 'Create default VPC and subnet' }).uncheck() await page.getByRole('button', { name: 'Create project' }).click() await expect(page).toHaveURL('/projects/mock-project-2/instances') From 976baa91c365abcbb46da3881f7b1f013cbd2c20 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 22 Oct 2026 16:46:27 -0700 Subject: [PATCH 5/7] Use RequiredDeep as tsc check for API changes --- app/forms/project-create.tsx | 16 ++++++++++++++-- app/forms/vpc-create.tsx | 16 ++++++++++++++-- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/app/forms/project-create.tsx b/app/forms/project-create.tsx index c9ad89332f..ac351868ea 100644 --- a/app/forms/project-create.tsx +++ b/app/forms/project-create.tsx @@ -7,8 +7,16 @@ */ import { useForm } from 'react-hook-form' import { useNavigate } from 'react-router' +import type { RequiredDeep } from 'type-fest' -import { api, q, queryClient, useApiMutation, type ProjectCreate } from '@oxide/api' +import { + api, + q, + queryClient, + useApiMutation, + type ProjectCreate, + type ProjectCreateDefaults, +} from '@oxide/api' import { CheckboxField } from '~/components/form/fields/CheckboxField' import { DescriptionField } from '~/components/form/fields/DescriptionField' @@ -66,8 +74,12 @@ export default function ProjectCreateSideModalForm() { body: { name, description, + // RequiredDeep will make tsc fail if an API bump adds a new + // default resource, so we can decide how to handle it defaults: createDefaultVpc - ? { vpc: { type: 'explicit', defaults: { subnet: {} } } } + ? ({ + vpc: { type: 'explicit', defaults: { subnet: {} } }, + } satisfies RequiredDeep) : {}, }, }) diff --git a/app/forms/vpc-create.tsx b/app/forms/vpc-create.tsx index 3dcc49c010..3cfce1c20e 100644 --- a/app/forms/vpc-create.tsx +++ b/app/forms/vpc-create.tsx @@ -7,8 +7,16 @@ */ import { useForm } from 'react-hook-form' import { useNavigate } from 'react-router' +import type { RequiredDeep } from 'type-fest' -import { api, q, queryClient, useApiMutation, type VpcCreate } from '@oxide/api' +import { + api, + q, + queryClient, + useApiMutation, + type VpcCreate, + type VpcCreateDefaults, +} from '@oxide/api' import { CheckboxField } from '~/components/form/fields/CheckboxField' import { DescriptionField } from '~/components/form/fields/DescriptionField' @@ -74,7 +82,11 @@ export default function CreateVpcSideModalForm() { const body: VpcCreate = { ...rest, ipv6Prefix: ipv6Prefix.trim() || undefined, - defaults: createDefaultSubnet ? { subnet: {} } : {}, + // RequiredDeep will make tsc fail if an API bump adds a new + // default resource, so we can decide how to handle it + defaults: createDefaultSubnet + ? ({ subnet: {} } satisfies RequiredDeep) + : {}, } createVpc.mutate({ query: projectSelector, body }) }} From ecbf6f773271e966231b41190272fa94a60734ef Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 22 Oct 2026 17:00:13 -0700 Subject: [PATCH 6/7] Use tip icon instead of message box --- app/forms/project-create.tsx | 18 +++++++----------- app/forms/vpc-create.tsx | 20 +++++++++----------- 2 files changed, 16 insertions(+), 22 deletions(-) diff --git a/app/forms/project-create.tsx b/app/forms/project-create.tsx index ac351868ea..af31884c3b 100644 --- a/app/forms/project-create.tsx +++ b/app/forms/project-create.tsx @@ -25,8 +25,8 @@ import { SideModalForm } from '~/components/form/SideModalForm' import { HL } from '~/components/HL' import { titleCrumb } from '~/hooks/use-crumbs' import { addToast } from '~/stores/toast' -import { Message } from '~/ui/lib/Message' import { SideModalFormDocs } from '~/ui/lib/ModalLinks' +import { TipIcon } from '~/ui/lib/TipIcon' import { docLinks } from '~/util/links' import { pb } from '~/util/path-builder' @@ -61,7 +61,6 @@ export default function ProjectCreateSideModalForm() { }) const form = useForm({ defaultValues }) - const createDefaultVpc = form.watch('createDefaultVpc') return ( -
+ Create default VPC and subnet - {!createDefaultVpc && ( - - )} -
+ + Instances created with default networking attach to the default VPC and subnet. + Without a VPC, instances in this project can’t have network interfaces. + +
) diff --git a/app/forms/vpc-create.tsx b/app/forms/vpc-create.tsx index 3cfce1c20e..fd1552928a 100644 --- a/app/forms/vpc-create.tsx +++ b/app/forms/vpc-create.tsx @@ -27,8 +27,8 @@ import { HL } from '~/components/HL' import { titleCrumb } from '~/hooks/use-crumbs' import { useProjectSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' -import { Message } from '~/ui/lib/Message' import { SideModalFormDocs } from '~/ui/lib/ModalLinks' +import { TipIcon } from '~/ui/lib/TipIcon' import { validateVpcIpv6Prefix } from '~/util/ip' import { docLinks } from '~/util/links' import { pb } from '~/util/path-builder' @@ -71,7 +71,6 @@ export default function CreateVpcSideModalForm() { }) const form = useForm({ defaultValues }) - const createDefaultSubnet = form.watch('createDefaultSubnet') return ( -
+ Create default subnet - {!createDefaultSubnet && ( - - )} -
+ +

+ An instance can only attach to a VPC with a subnet. +
A subnet can be added later. +

+
+
) From 9f2a8379dbb54a396c868b526ca70d86478be287 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 22 Oct 2026 17:28:32 -0700 Subject: [PATCH 7/7] copy change --- app/forms/project-create.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/app/forms/project-create.tsx b/app/forms/project-create.tsx index af31884c3b..70f5c63cd7 100644 --- a/app/forms/project-create.tsx +++ b/app/forms/project-create.tsx @@ -92,10 +92,7 @@ export default function ProjectCreateSideModalForm() { Create default VPC and subnet - - Instances created with default networking attach to the default VPC and subnet. - Without a VPC, instances in this project can’t have network interfaces. - + An instance can only attach to a VPC with a subnet.