Skip to content
Merged
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
2 changes: 1 addition & 1 deletion app/components/AttachEphemeralIpModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void })
label="IP pool"
placeholder={
siloPools?.items && siloPools.items.length > 0
? 'Select pool'
? 'Select a pool'
: 'No pools available'
}
items={
Expand Down
2 changes: 1 addition & 1 deletion app/components/AttachFloatingIpModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export const AttachFloatingIpModal = ({
control={form.control}
name="floatingIp"
label="Floating IP"
placeholder="Select floating IP"
placeholder="Select a floating IP"
items={floatingIps.map((ip) => ({
value: ip.id,
label: <FloatingIpLabel fip={ip} />,
Expand Down
3 changes: 3 additions & 0 deletions app/components/form/fields/ListboxField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export type ListboxFieldProps<
items: ListboxItem[]
onChange?: (value: string | null | undefined) => void
isLoading?: boolean
noItemsPlaceholder?: string
}

export function ListboxField<
Expand All @@ -52,6 +53,7 @@ export function ListboxField<
control,
onChange,
isLoading,
noItemsPlaceholder,
}: ListboxFieldProps<TFieldValues, TName>) {
// TODO: recreate this logic
// validate: (v) => (required && !v ? `${name} is required` : undefined),
Expand All @@ -64,6 +66,7 @@ export function ListboxField<
tooltipText={tooltipText}
required={required}
placeholder={placeholder}
noItemsPlaceholder={noItemsPlaceholder}
selected={field.value || null}
items={items}
onChange={(value) => {
Expand Down
8 changes: 5 additions & 3 deletions app/components/form/fields/SubnetListbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@ import { useApiQuery } from '@oxide/api'

import { useProjectSelector } from '~/hooks'

import { ComboboxField, type ComboboxFieldProps } from './ComboboxField'
import { ListboxField, type ListboxFieldProps } from './ListboxField'

type SubnetListboxProps<
TFieldValues extends FieldValues,
TName extends FieldPath<TFieldValues>,
> = Omit<ComboboxFieldProps<TFieldValues, TName>, 'items'> & {
> = Omit<ListboxFieldProps<TFieldValues, TName>, 'items'> & {
vpcNameField: FieldPath<TFieldValues>
}

Expand Down Expand Up @@ -47,11 +47,13 @@ export function SubnetListbox<
).data?.items || []

return (
<ComboboxField
<ListboxField
{...fieldProps}
items={subnets.map(({ name }) => ({ value: name, label: name }))}
disabled={!vpcExists}
control={control}
placeholder="Select a subnet"
noItemsPlaceholder={vpcName ? 'No subnets found' : 'Select a VPC to see subnets'}
/>
)
}
2 changes: 1 addition & 1 deletion app/forms/floating-ip-create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ export function CreateFloatingIpSideModalForm() {
items={(allPools?.items || []).map((p) => toListboxItem(p))}
label="IP pool"
control={form.control}
placeholder="Select pool"
placeholder="Select a pool"
/>
</AccordionItem>
</Accordion.Root>
Expand Down
11 changes: 4 additions & 7 deletions app/forms/instance-create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ import {
import { AccordionItem } from '~/components/AccordionItem'
import { DocsPopover } from '~/components/DocsPopover'
import { CheckboxField } from '~/components/form/fields/CheckboxField'
import { ComboboxField } from '~/components/form/fields/ComboboxField'
import { DescriptionField } from '~/components/form/fields/DescriptionField'
import { DiskSizeField } from '~/components/form/fields/DiskSizeField'
import {
Expand All @@ -46,6 +45,7 @@ import {
} from '~/components/form/fields/DisksTableField'
import { FileField } from '~/components/form/fields/FileField'
import { BootDiskImageSelectField as ImageSelectField } from '~/components/form/fields/ImageSelectField'
import { ListboxField } from '~/components/form/fields/ListboxField'
import { NameField } from '~/components/form/fields/NameField'
import { NetworkInterfaceField } from '~/components/form/fields/NetworkInterfaceField'
import { NumberField } from '~/components/form/fields/NumberField'
Expand Down Expand Up @@ -221,9 +221,6 @@ export function CreateInstanceForm() {
const defaultValues: InstanceCreateInput = {
...baseDefaultValues,
bootDiskSourceType: defaultSource,
siloImageSource: siloImages?.[0]?.id || '',
projectImageSource: projectImages?.[0]?.id || '',
diskSource: disks?.[0]?.value || '',
sshPublicKeys: allKeys,
bootDiskSize: nearest10(defaultImage?.size / GiB),
externalIps: [{ type: 'ephemeral', pool: defaultPool }],
Expand Down Expand Up @@ -550,7 +547,7 @@ export function CreateInstanceForm() {
/>
</div>
) : (
<ComboboxField
<ListboxField
label="Disk"
name="diskSource"
description="Existing disks that are not attached to an instance"
Expand Down Expand Up @@ -732,7 +729,7 @@ const AdvancedAccordion = ({
<Listbox
name="pools"
label="IP pool for ephemeral IP"
placeholder={defaultPool ? `${defaultPool} (default)` : 'Select pool'}
placeholder={defaultPool ? `${defaultPool} (default)` : 'Select a pool'}
selected={`${siloPools.find((pool) => pool.name === selectedPool)?.name}`}
items={
siloPools.map((pool) => ({
Expand Down Expand Up @@ -837,7 +834,7 @@ const AdvancedAccordion = ({
)
}}
required
placeholder="Select floating IP"
placeholder="Select a floating IP"
selected={selectedFloatingIp?.name || ''}
/>
</form>
Expand Down
5 changes: 3 additions & 2 deletions app/forms/network-interface-create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import { useMemo } from 'react'

import { useApiQuery, type ApiError, type InstanceNetworkInterfaceCreate } from '@oxide/api'

import { ComboboxField } from '~/components/form/fields/ComboboxField'
import { DescriptionField } from '~/components/form/fields/DescriptionField'
import { ListboxField } from '~/components/form/fields/ListboxField'
import { NameField } from '~/components/form/fields/NameField'
import { SubnetListbox } from '~/components/form/fields/SubnetListbox'
import { TextField } from '~/components/form/fields/TextField'
Expand Down Expand Up @@ -65,12 +65,13 @@ export function CreateNetworkInterfaceForm({
<DescriptionField name="description" control={form.control} />
<FormDivider />

<ComboboxField
<ListboxField
name="vpcName"
label="VPC"
items={vpcs.map(({ name }) => ({ label: name, value: name }))}
required
control={form.control}
placeholder="Select a VPC"
/>
<SubnetListbox
name="subnetName"
Expand Down
1 change: 1 addition & 0 deletions app/forms/snapshot-create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export function CreateSnapshotSideModalForm() {
<ComboboxField
label="Disk"
name="disk"
placeholder="Select a disk"
items={diskItems}
required
control={form.control}
Expand Down
2 changes: 1 addition & 1 deletion app/pages/project/floating-ips/FloatingIpsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,7 @@ const AttachFloatingIpModal = ({
form.setValue('instanceId', e)
}}
required
placeholder="Select instance"
placeholder="Select an instance"
selected={form.watch('instanceId')}
/>
</form>
Expand Down
2 changes: 1 addition & 1 deletion app/pages/system/SiloImagesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ const PromoteImageModal = ({ onDismiss }: { onDismiss: () => void }) => {
<Modal.Section>
<form autoComplete="off" onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<ComboboxField
placeholder="Filter images by project"
placeholder="Select a project"
name="project"
label="Project"
items={projectItems}
Expand Down
2 changes: 1 addition & 1 deletion app/pages/system/networking/IpPoolPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,7 @@ function LinkSiloModal({ onDismiss }: { onDismiss: () => void }) {
/>

<ComboboxField
placeholder="Select silo"
placeholder="Select a silo"
name="silo"
label="Silo"
items={unlinkedSiloItems}
Expand Down
2 changes: 1 addition & 1 deletion app/pages/system/silos/SiloIpPoolsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ function LinkPoolModal({ onDismiss }: { onDismiss: () => void }) {
/>

<ComboboxField
placeholder="Select pool"
placeholder="Select a pool"
name="pool"
label="IP pool"
items={unlinkedPoolItems}
Expand Down
2 changes: 1 addition & 1 deletion test/e2e/floating-ip-create.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ test('can detach and attach a floating IP', async ({ page }) => {
// Now click back to floating IPs and reattach it to db1
await page.getByRole('link', { name: 'Floating IPs' }).click()
await clickRowAction(page, 'cola-float', 'Attach')
await page.getByRole('button', { name: 'Select instance' }).click()
await page.getByRole('button', { name: 'Select an instance' }).click()
await page.getByRole('option', { name: 'db1' }).click()

await page.getByRole('button', { name: 'Attach' }).click()
Expand Down
2 changes: 1 addition & 1 deletion test/e2e/images.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ test('can promote an image from silo', async ({ page }) => {
await expectNotVisible(page, ['role=cell[name="image-1"]'])

// Listboxes are visible
await expect(page.getByPlaceholder('Filter images by project')).toBeVisible()
await expect(page.getByPlaceholder('Select a project')).toBeVisible()
await expect(page.locator(`text="Select an image"`)).toBeVisible()

// Notice is visible
Expand Down
75 changes: 45 additions & 30 deletions test/e2e/instance-create.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,34 @@
*
* Copyright Oxide Computer Company
*/
import { floatingIp, images } from '@oxide/api-mocks'
import { floatingIp } from '@oxide/api-mocks'

import {
expect,
expectNotVisible,
expectRowVisible,
expectVisible,
test,
type Page,
} from './utils'

const selectASiloImage = async (page: Page, name: string) => {
await page.getByRole('tab', { name: 'Silo images' }).click()
await page.getByLabel('Image', { exact: true }).click()
await page.getByRole('option', { name }).click()
}

import { expect, expectNotVisible, expectRowVisible, expectVisible, test } from './utils'
const selectAProjectImage = async (page: Page, name: string) => {
await page.getByRole('tab', { name: 'Project images' }).click()
await page.getByLabel('Image', { exact: true }).click()
await page.getByRole('option', { name }).click()
}

const selectAnExistingDisk = async (page: Page, name: string) => {
await page.getByRole('tab', { name: 'Existing disks' }).click()
await page.getByRole('button', { name: 'Select a disk' }).click()
await page.getByRole('option', { name }).click()
}

test('can create an instance', async ({ page }) => {
await page.goto('/projects/mock-project/instances')
Expand Down Expand Up @@ -36,9 +61,7 @@ test('can create an instance', async ({ page }) => {
await diskSizeInput.fill('20')

// pick a project image just to show we can
await page.getByRole('tab', { name: 'Project images' }).click()
await page.getByRole('button', { name: 'Image' }).click()
await page.getByRole('option', { name: images[2].name }).click()
await selectAProjectImage(page, 'image-3')

// should be hidden in accordion
await expectNotVisible(page, [
Expand Down Expand Up @@ -104,6 +127,7 @@ test('can create an instance', async ({ page }) => {
test('duplicate instance name produces visible error', async ({ page }) => {
await page.goto('/projects/mock-project/instances-new')
await page.fill('input[name=name]', 'db1')
await selectAProjectImage(page, 'image-1')
await page.locator('button:has-text("Create instance")').click()
await expect(page.getByText('Instance name already exists')).toBeVisible()
})
Expand Down Expand Up @@ -144,9 +168,7 @@ test('can create an instance with custom hardware', async ({ page }) => {
await page.keyboard.press('Tab')

// pick a project image just to show we can
await page.getByRole('tab', { name: 'Project images' }).click()
await page.getByRole('button', { name: 'Image' }).click()
await page.getByRole('option', { name: images[2].name }).click()
await selectAProjectImage(page, 'image-3')
// the disk size should bot have been changed from what was entered earlier
await expect(diskSizeInput).toHaveValue('20')

Expand Down Expand Up @@ -182,23 +204,19 @@ test('automatically updates disk size when larger image selected', async ({ page
await page.keyboard.press('Tab')

// pick a disk image that's smaller than 5GiB (the first project image works [4GiB])
await page.getByRole('tab', { name: 'Project images' }).click()
await page.getByRole('button', { name: 'Image' }).click()
await page.getByRole('option', { name: images[0].name }).click()
await selectAProjectImage(page, 'image-1')

// test that it still says 5, as that's larger than the given image
await expect(diskSizeInput).toHaveValue('5')

// pick a disk image that's larger than 5GiB (the third project image works [6GiB])
await page.getByRole('button', { name: 'Image' }).click()
await page.getByRole('option', { name: images[2].name }).click()
await selectAProjectImage(page, 'image-3')

// test that it has been automatically increased to next-largest incremement of 10
await expect(diskSizeInput).toHaveValue('10')

// pick another image, just to verify that the diskSizeInput stays as it was
await page.getByRole('button', { name: 'Image' }).click()
await page.getByRole('option', { name: images[1].name }).click()
await selectAProjectImage(page, 'image-2')
await expect(diskSizeInput).toHaveValue('10')

const submitButton = page.getByRole('button', { name: 'Create instance' })
Expand All @@ -211,6 +229,7 @@ test('automatically updates disk size when larger image selected', async ({ page
test('with disk name already taken', async ({ page }) => {
await page.goto('/projects/mock-project/instances-new')
await page.fill('input[name=name]', 'my-instance')
await selectAProjectImage(page, 'image-1')
await page.fill('input[name=bootDiskName]', 'disk-1')

await page.getByRole('button', { name: 'Create instance' }).click()
Expand Down Expand Up @@ -248,44 +267,38 @@ test('add ssh key from instance create form', async ({ page }) => {
test('shows object not found error on no default pool', async ({ page }) => {
await page.goto('/projects/mock-project/instances-new')
await page.getByRole('textbox', { name: 'Name', exact: true }).fill('no-default-pool')
await selectAProjectImage(page, 'image-1')
await page.getByRole('button', { name: 'Create instance' }).click()

await expect(page.getByText('Not found: default IP pool')).toBeVisible()
await expect(page.getByText('Not found: default IP pool for current silo')).toBeVisible()
})

test('create instance with existing disk', async ({ page }) => {
const instanceName = 'my-existing-disk-instance'
await page.goto('/projects/mock-project/instances-new')
await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName)
await page.getByRole('tab', { name: 'Existing disks' }).click()
await selectAnExistingDisk(page, 'disk-3')
await page.getByRole('button', { name: 'Create instance' }).click()
await expect(page).toHaveURL(`/projects/mock-project/instances/${instanceName}/storage`)
await expectVisible(page, [`h1:has-text("${instanceName}")`, 'text=8 GiB'])
await expectRowVisible(page.getByRole('table'), { Disk: 'disk-3' })
})

test('create instance with a different existing disk', async ({ page }) => {
test('create instance with a silo image', async ({ page }) => {
const instanceName = 'my-existing-disk-2'
await page.goto('/projects/mock-project/instances-new')
await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName)
await page.getByRole('tab', { name: 'Existing disks' }).click()
// verify combobox text entry
await page.getByPlaceholder('Select a disk').fill('disk-')
await page.getByRole('option', { name: 'disk-4' }).click()
await selectASiloImage(page, 'ubuntu-22-04')
await page.getByRole('button', { name: 'Create instance' }).click()
await expect(page).toHaveURL(`/projects/mock-project/instances/${instanceName}/storage`)
await expectVisible(page, [`h1:has-text("${instanceName}")`, 'text=8 GiB'])
await expectRowVisible(page.getByRole('table'), { Disk: 'disk-4' })
await expectVisible(page, [`h1:has-text("${instanceName}")`, 'text=10 GiB'])
})

test('start with an existing disk, but then switch to a silo image', async ({ page }) => {
const instanceName = 'silo-image-instance'
await page.goto('/projects/mock-project/instances-new')
await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName)
await page.getByRole('tab', { name: 'Existing disks' }).click()
await page.getByPlaceholder('Select a disk').fill('disk-')
await page.getByRole('option', { name: 'disk-7' }).click()
await page.getByRole('tab', { name: 'Silo images' }).click()
await selectAnExistingDisk(page, 'disk-7')
await selectASiloImage(page, 'ubuntu-22-04')
await page.getByRole('button', { name: 'Create instance' }).click()
await expect(page).toHaveURL(`/projects/mock-project/instances/${instanceName}/storage`)
await expectVisible(page, [`h1:has-text("${instanceName}")`, 'text=8 GiB'])
Expand Down Expand Up @@ -352,6 +365,7 @@ test('maintains selected values even when changing tabs', async ({ page }) => {
test('does not attach an ephemeral IP when the checkbox is unchecked', async ({ page }) => {
await page.goto('/projects/mock-project/instances-new')
await page.getByRole('textbox', { name: 'Name', exact: true }).fill('no-ephemeral-ip')
await selectAProjectImage(page, 'image-1')
await page.getByRole('button', { name: 'Networking' }).click()
await page
.getByRole('checkbox', { name: 'Allocate and attach an ephemeral IP address' })
Expand All @@ -363,13 +377,14 @@ test('does not attach an ephemeral IP when the checkbox is unchecked', async ({

test('attaches a floating IP; disables button when no IPs available', async ({ page }) => {
const attachFloatingIpButton = page.getByRole('button', { name: 'Attach floating IP' })
const selectFloatingIpButton = page.getByRole('button', { name: 'Select floating ip' })
const selectFloatingIpButton = page.getByRole('button', { name: 'Select a floating ip' })
const rootbeerFloatOption = page.getByRole('option', { name: 'rootbeer-float' })
const attachButton = page.getByRole('button', { name: 'Attach', exact: true })

const instanceName = 'with-floating-ip'
await page.goto('/projects/mock-project/instances-new')
await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName)
await selectAProjectImage(page, 'image-1')
await page.getByRole('button', { name: 'Networking' }).click()

await attachFloatingIpButton.click()
Expand Down
Loading