diff --git a/app/components/AttachEphemeralIpModal.tsx b/app/components/AttachEphemeralIpModal.tsx index d16fbcbf6..73b71f6c2 100644 --- a/app/components/AttachEphemeralIpModal.tsx +++ b/app/components/AttachEphemeralIpModal.tsx @@ -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={ diff --git a/app/components/AttachFloatingIpModal.tsx b/app/components/AttachFloatingIpModal.tsx index 1430a72ea..eaedd2dbd 100644 --- a/app/components/AttachFloatingIpModal.tsx +++ b/app/components/AttachFloatingIpModal.tsx @@ -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: , diff --git a/app/components/form/fields/ListboxField.tsx b/app/components/form/fields/ListboxField.tsx index 20f3da2b5..6ffefbea6 100644 --- a/app/components/form/fields/ListboxField.tsx +++ b/app/components/form/fields/ListboxField.tsx @@ -34,6 +34,7 @@ export type ListboxFieldProps< items: ListboxItem[] onChange?: (value: string | null | undefined) => void isLoading?: boolean + noItemsPlaceholder?: string } export function ListboxField< @@ -52,6 +53,7 @@ export function ListboxField< control, onChange, isLoading, + noItemsPlaceholder, }: ListboxFieldProps) { // TODO: recreate this logic // validate: (v) => (required && !v ? `${name} is required` : undefined), @@ -64,6 +66,7 @@ export function ListboxField< tooltipText={tooltipText} required={required} placeholder={placeholder} + noItemsPlaceholder={noItemsPlaceholder} selected={field.value || null} items={items} onChange={(value) => { diff --git a/app/components/form/fields/SubnetListbox.tsx b/app/components/form/fields/SubnetListbox.tsx index 398dfde7b..77931f63b 100644 --- a/app/components/form/fields/SubnetListbox.tsx +++ b/app/components/form/fields/SubnetListbox.tsx @@ -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, -> = Omit, 'items'> & { +> = Omit, 'items'> & { vpcNameField: FieldPath } @@ -47,11 +47,13 @@ export function SubnetListbox< ).data?.items || [] return ( - ({ value: name, label: name }))} disabled={!vpcExists} control={control} + placeholder="Select a subnet" + noItemsPlaceholder={vpcName ? 'No subnets found' : 'Select a VPC to see subnets'} /> ) } diff --git a/app/forms/floating-ip-create.tsx b/app/forms/floating-ip-create.tsx index af80f3bb2..6bc8fc8cb 100644 --- a/app/forms/floating-ip-create.tsx +++ b/app/forms/floating-ip-create.tsx @@ -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" /> diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index 9fba26874..4324ce620 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -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 { @@ -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' @@ -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 }], @@ -550,7 +547,7 @@ export function CreateInstanceForm() { /> ) : ( - pool.name === selectedPool)?.name}`} items={ siloPools.map((pool) => ({ @@ -837,7 +834,7 @@ const AdvancedAccordion = ({ ) }} required - placeholder="Select floating IP" + placeholder="Select a floating IP" selected={selectedFloatingIp?.name || ''} /> diff --git a/app/forms/network-interface-create.tsx b/app/forms/network-interface-create.tsx index 756da6c0e..c3e452d32 100644 --- a/app/forms/network-interface-create.tsx +++ b/app/forms/network-interface-create.tsx @@ -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' @@ -65,12 +65,13 @@ export function CreateNetworkInterfaceForm({ - ({ label: name, value: name }))} required control={form.control} + placeholder="Select a VPC" /> diff --git a/app/pages/system/SiloImagesPage.tsx b/app/pages/system/SiloImagesPage.tsx index 61cd29212..fa7fa3467 100644 --- a/app/pages/system/SiloImagesPage.tsx +++ b/app/pages/system/SiloImagesPage.tsx @@ -169,7 +169,7 @@ const PromoteImageModal = ({ onDismiss }: { onDismiss: () => void }) => {
void }) { /> void }) { /> { // 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() diff --git a/test/e2e/images.e2e.ts b/test/e2e/images.e2e.ts index 67b910cdf..0c34aae0b 100644 --- a/test/e2e/images.e2e.ts +++ b/test/e2e/images.e2e.ts @@ -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 diff --git a/test/e2e/instance-create.e2e.ts b/test/e2e/instance-create.e2e.ts index 2796d69b0..5ccf320d3 100644 --- a/test/e2e/instance-create.e2e.ts +++ b/test/e2e/instance-create.e2e.ts @@ -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') @@ -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, [ @@ -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() }) @@ -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') @@ -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' }) @@ -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() @@ -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']) @@ -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' }) @@ -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() diff --git a/test/e2e/instance-networking.e2e.ts b/test/e2e/instance-networking.e2e.ts index 93045d287..2ae4fb4ef 100644 --- a/test/e2e/instance-networking.e2e.ts +++ b/test/e2e/instance-networking.e2e.ts @@ -17,7 +17,7 @@ test('Instance networking tab — NIC table', async ({ page }) => { await expect(page.getByRole('link', { name: '123.4.56.0' })).toBeVisible() // Instance networking tab - await page.click('role=tab[name="Networking"]') + await page.getByRole('tab', { name: 'Networking' }).click() const nicTable = page.getByRole('table', { name: 'Network interfaces' }) @@ -44,20 +44,20 @@ test('Instance networking tab — NIC table', async ({ page }) => { // TODO: modal title is not getting hooked up, IDs are wrong await expectVisible(page, [ 'role=heading[name="Add network interface"]', - 'role=textbox[name="Name"]', 'role=textbox[name="Description"]', - 'role=button[name*="VPC"]', // listbox - 'role=button[name*="Subnet"]', // listbox 'role=textbox[name="IP Address"]', ]) - await page.fill('role=textbox[name="Name"]', 'nic-2') - await page.click('role=button[name*="VPC"]') - await page.click('role=option[name="mock-vpc"]') - await page.click('role=button[name*="Subnet"]') - await page.click('role=option[name="mock-subnet"]') - await page.click('role=dialog >> role=button[name="Add network interface"]') - await expectVisible(page, ['role=cell[name="nic-2"]']) + await page.getByRole('textbox', { name: 'Name' }).fill('nic-2') + await page.getByLabel('VPC', { exact: true }).click() + await page.getByRole('option', { name: 'mock-vpc' }).click() + await page.getByLabel('Subnet').click() + await page.getByRole('option', { name: 'mock-subnet' }).click() + await page + .getByRole('dialog') + .getByRole('button', { name: 'Add network interface' }) + .click() + await expect(page.getByRole('cell', { name: 'nic-2' })).toBeVisible() // Make this interface primary await clickRowAction(page, 'nic-2', 'Make primary') @@ -66,8 +66,8 @@ test('Instance networking tab — NIC table', async ({ page }) => { // Make an edit to the network interface await clickRowAction(page, 'nic-2', 'Edit') - await page.fill('role=textbox[name="Name"]', 'nic-3') - await page.click('role=button[name="Update network interface"]') + await page.getByRole('textbox', { name: 'Name' }).fill('nic-3') + await page.getByRole('button', { name: 'Update network interface' }).click() await expect(page.getByRole('cell', { name: 'nic-2' })).toBeHidden() const nic3 = page.getByRole('cell', { name: 'nic-3' }) await expect(nic3).toBeVisible() @@ -129,7 +129,7 @@ test('Instance networking tab — floating IPs', async ({ page }) => { // Select the 'rootbeer-float' option const dialog = page.getByRole('dialog') // TODO: this "select the option" syntax is awkward; it's working, but I suspect there's a better way - await dialog.getByRole('button', { name: 'Select floating IP' }).click() + await dialog.getByRole('button', { name: 'Select a floating IP' }).click() await page.keyboard.press('ArrowDown') await page.keyboard.press('Enter') // await dialog.getByRole('button', { name: 'rootbeer-float' }).click() diff --git a/test/e2e/ip-pools.e2e.ts b/test/e2e/ip-pools.e2e.ts index 32becca80..947079489 100644 --- a/test/e2e/ip-pools.e2e.ts +++ b/test/e2e/ip-pools.e2e.ts @@ -101,7 +101,7 @@ test('IP pool link silo', async ({ page }) => { await expect(modal).toBeVisible() // select silo in combobox and click link - await page.getByPlaceholder('Select silo').fill('m') + await page.getByPlaceholder('Select a silo').fill('m') await page.getByRole('option', { name: 'myriad' }).click() await modal.getByRole('button', { name: 'Link' }).click() diff --git a/test/e2e/network-interface-create.e2e.ts b/test/e2e/network-interface-create.e2e.ts index 4ff5a4723..c735553fe 100644 --- a/test/e2e/network-interface-create.e2e.ts +++ b/test/e2e/network-interface-create.e2e.ts @@ -20,7 +20,7 @@ test('can create a NIC with a specified IP address', async ({ page }) => { // fill out the form await page.getByLabel('Name').fill('nic-1') - await page.getByRole('button', { name: 'VPC' }).click() + await page.getByLabel('VPC', { exact: true }).click() await page.getByRole('option', { name: 'mock-vpc' }).click() await page.getByRole('button', { name: 'Subnet' }).click() await page.getByRole('option', { name: 'mock-subnet' }).click() @@ -47,7 +47,7 @@ test('can create a NIC with a blank IP address', async ({ page }) => { // fill out the form await page.getByLabel('Name').fill('nic-2') - await page.getByRole('button', { name: 'VPC' }).click() + await page.getByLabel('VPC', { exact: true }).click() await page.getByRole('option', { name: 'mock-vpc' }).click() await page.getByRole('button', { name: 'Subnet' }).click() await page.getByRole('option', { name: 'mock-subnet' }).click() diff --git a/test/e2e/silos.e2e.ts b/test/e2e/silos.e2e.ts index cbdb893d6..43b1154c8 100644 --- a/test/e2e/silos.e2e.ts +++ b/test/e2e/silos.e2e.ts @@ -243,7 +243,7 @@ test('Silo IP pools link pool', async ({ page }) => { await expect(modal).toBeVisible() // select silo in combobox and click link - await page.getByPlaceholder('Select pool').fill('ip-pool') + await page.getByPlaceholder('Select a pool').fill('ip-pool') await page.getByRole('option', { name: 'ip-pool-3' }).click() await modal.getByRole('button', { name: 'Link' }).click() diff --git a/test/e2e/z-index.e2e.ts b/test/e2e/z-index.e2e.ts index 9b7ce9fd5..6379685bc 100644 --- a/test/e2e/z-index.e2e.ts +++ b/test/e2e/z-index.e2e.ts @@ -55,7 +55,7 @@ test('Dropdown content in SidebarModal shows on screen', async ({ page }) => { // select the VPC and subnet via the dropdowns. The fact that the options are // clickable means they are not obscured due to having a too-low z-index - await page.getByRole('button', { name: 'VPC' }).click() + await page.getByLabel('VPC', { exact: true }).click() await page.getByRole('option', { name: 'mock-vpc' }).click() await page.getByRole('button', { name: 'Subnet' }).click() await page.getByRole('option', { name: 'mock-subnet' }).click()