diff --git a/app/api/util.ts b/app/api/util.ts index 94492377f..f3091f865 100644 --- a/app/api/util.ts +++ b/app/api/util.ts @@ -107,6 +107,15 @@ export const poolHasIpVersion = (versions: Iterable) => { return (pool: { ipVersion: IpVersion }): boolean => versionSet.has(pool.ipVersion) } +/** Sort pools: defaults first, then v4 before v6, then by name */ +export const sortPools = (pools: T[]) => + R.sortBy( + pools, + (p) => !p.isDefault, // false sorts first → defaults first + (p) => p.ipVersion, // v4 before v6 + (p) => p.name + ) + const instanceActions = { // NoVmm maps to to Stopped: // https://github.com/oxidecomputer/omicron/blob/6dd9802/nexus/db-model/src/instance_state.rs#L55 diff --git a/app/components/AttachEphemeralIpModal.tsx b/app/components/AttachEphemeralIpModal.tsx index 57d0927fc..06fa56d7a 100644 --- a/app/components/AttachEphemeralIpModal.tsx +++ b/app/components/AttachEphemeralIpModal.tsx @@ -15,12 +15,14 @@ import { poolHasIpVersion, q, queryClient, + sortPools, useApiMutation, usePrefetchedQuery, type IpVersion, } from '~/api' -import { IpPoolSelector } from '~/components/form/fields/IpPoolSelector' +import { ListboxField } from '~/components/form/fields/ListboxField' import { HL } from '~/components/HL' +import { toIpPoolItem } from '~/components/IpPoolListboxItem' import { useInstanceSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { Message } from '~/ui/lib/Message' @@ -84,12 +86,14 @@ export const AttachEphemeralIpModal = ({ {infoMessage && }
-
diff --git a/app/components/IpPoolListboxItem.tsx b/app/components/IpPoolListboxItem.tsx new file mode 100644 index 000000000..8de4e0bc0 --- /dev/null +++ b/app/components/IpPoolListboxItem.tsx @@ -0,0 +1,32 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import type { SiloIpPool } from '@oxide/api' +import { Badge } from '@oxide/design-system/ui' + +import { IpVersionBadge } from '~/components/IpVersionBadge' +import type { ListboxItem } from '~/ui/lib/Listbox' + +/** Format a SiloIpPool for use as a ListboxField item */ +export function toIpPoolItem(p: SiloIpPool): ListboxItem { + const value = p.name + const selectedLabel = p.name + const label = ( +
+
+ {p.name} + {p.isDefault && default} + +
+ {!!p.description && ( +
{p.description}
+ )} +
+ ) + return { value, selectedLabel, label } +} diff --git a/app/components/form/fields/IpPoolSelector.tsx b/app/components/form/fields/IpPoolSelector.tsx deleted file mode 100644 index f5cede7f9..000000000 --- a/app/components/form/fields/IpPoolSelector.tsx +++ /dev/null @@ -1,105 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright Oxide Computer Company - */ -import cn from 'classnames' -import { useMemo } from 'react' -import type { Control, FieldPath, FieldValues } from 'react-hook-form' -import * as R from 'remeda' - -import { - poolHasIpVersion, - type IpVersion, - type SiloIpPool, - type UnicastIpPool, -} from '@oxide/api' -import { Badge } from '@oxide/design-system/ui' - -import { IpVersionBadge } from '~/components/IpVersionBadge' - -import { ListboxField } from './ListboxField' - -function toIpPoolItem(p: SiloIpPool) { - const value = p.name - const selectedLabel = p.name - const label = ( -
-
- {p.name} - {p.isDefault && default} - -
- {!!p.description && ( -
{p.description}
- )} -
- ) - return { value, selectedLabel, label } -} - -const ALL_IP_VERSIONS: IpVersion[] = ['v4', 'v6'] - -type IpPoolSelectorProps< - TFieldValues extends FieldValues, - TName extends FieldPath, -> = { - className?: string - control: Control - poolFieldName: TName - pools: UnicastIpPool[] - disabled?: boolean - /** Compatible IP versions based on network interface type */ - compatibleVersions?: IpVersion[] - required?: boolean - hideOptionalTag?: boolean - label?: string - /** Hide visible label, using it as aria-label instead */ - hideLabel?: boolean -} - -export function IpPoolSelector< - TFieldValues extends FieldValues, - TName extends FieldPath, ->({ - className, - control, - poolFieldName, - pools, - disabled = false, - compatibleVersions = ALL_IP_VERSIONS, - required = true, - hideOptionalTag = false, - label = 'Pool', - hideLabel = false, -}: IpPoolSelectorProps) { - // Note: pools are already filtered by poolType before being passed to this component - const sortedPools = useMemo(() => { - const compatPools = pools.filter(poolHasIpVersion(compatibleVersions)) - return R.sortBy( - compatPools, - (p) => !p.isDefault, // false sorts first, so this defaults first - (p) => p.ipVersion, // sort v4 first - (p) => p.name - ) - }, [pools, compatibleVersions]) - - return ( -
- -
- ) -} diff --git a/app/forms/floating-ip-create.tsx b/app/forms/floating-ip-create.tsx index 9da15c241..27e772d77 100644 --- a/app/forms/floating-ip-create.tsx +++ b/app/forms/floating-ip-create.tsx @@ -14,16 +14,18 @@ import { isUnicastPool, q, queryClient, + sortPools, useApiMutation, usePrefetchedQuery, type FloatingIpCreate, } from '@oxide/api' import { DescriptionField } from '~/components/form/fields/DescriptionField' -import { IpPoolSelector } from '~/components/form/fields/IpPoolSelector' +import { ListboxField } from '~/components/form/fields/ListboxField' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' import { HL } from '~/components/HL' +import { toIpPoolItem } from '~/components/IpPoolListboxItem' import { titleCrumb } from '~/hooks/use-crumbs' import { useProjectSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' @@ -97,7 +99,15 @@ export default function CreateFloatingIpSideModalForm() { > - + ) diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index a1872401f..4659af7e2 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -54,7 +54,7 @@ import { } from '~/components/form/fields/DisksTableField' import { FileField } from '~/components/form/fields/FileField' import { BootDiskImageSelectField as ImageSelectField } from '~/components/form/fields/ImageSelectField' -import { IpPoolSelector } from '~/components/form/fields/IpPoolSelector' +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' @@ -63,6 +63,7 @@ import { SshKeysField } from '~/components/form/fields/SshKeysField' import { Form } from '~/components/form/Form' import { FullPageForm } from '~/components/form/FullPageForm' import { HL } from '~/components/HL' +import { toIpPoolItem } from '~/components/IpPoolListboxItem' import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { Button } from '~/ui/lib/Button' @@ -331,15 +332,17 @@ function EphemeralIpCheckbox({
-
diff --git a/test/e2e/floating-ip-create.e2e.ts b/test/e2e/floating-ip-create.e2e.ts index 208674ba6..c33bfeef4 100644 --- a/test/e2e/floating-ip-create.e2e.ts +++ b/test/e2e/floating-ip-create.e2e.ts @@ -30,6 +30,13 @@ test('can create a floating IP', async ({ page }) => { // Default silo has both v4 and v6 defaults, so no pool is preselected const poolDropdown = page.getByLabel('Pool') await expect(poolDropdown).toContainText('Select a pool') + + // Pool selection is required when no default can be chosen automatically + const dialog = page.getByRole('dialog', { name: 'Create floating IP' }) + await page.getByRole('button', { name: 'Create floating IP' }).click() + await expect(dialog).toBeVisible() + await expect(dialog.getByText('Pool is required')).toBeVisible() + await poolDropdown.click() await page.getByRole('option', { name: 'ip-pool-1' }).click() await page.getByRole('button', { name: 'Create floating IP' }).click()