diff --git a/app/components/form/fields/DisksTableField.tsx b/app/components/form/fields/DisksTableField.tsx index ac0f0fe4c8..d2b9c8facf 100644 --- a/app/components/form/fields/DisksTableField.tsx +++ b/app/components/form/fields/DisksTableField.tsx @@ -8,7 +8,7 @@ import { useState } from 'react' import { useController, type Control } from 'react-hook-form' -import type { DiskCreate } from '@oxide/api' +import type { Disk, DiskCreate } from '@oxide/api' import { Error16Icon } from '@oxide/design-system/icons/react' import { AttachDiskSideModalForm } from '~/forms/disk-attach' @@ -30,10 +30,12 @@ export type DiskTableItem = */ export function DisksTableField({ control, - disabled, + isSubmitting, + availableDisks, }: { control: Control - disabled: boolean + isSubmitting: boolean + availableDisks: Array }) { const [showDiskCreate, setShowDiskCreate] = useState(false) const [showDiskAttach, setShowDiskAttach] = useState(false) @@ -42,6 +44,16 @@ export function DisksTableField({ field: { value: items, onChange }, } = useController({ control, name: 'disks' }) + const attachedDiskNames = items.map((disk) => disk.name) + const availableDiskNames = availableDisks + .filter((disk) => !attachedDiskNames.includes(disk.name)) + .map((disk) => disk.name) + + const noDisksAvailable = availableDiskNames.length === 0 + + const disabled = isSubmitting || noDisksAvailable + const disabledReason = noDisksAvailable ? 'No unattached disks are available' : undefined + return ( <>
@@ -91,7 +103,7 @@ export function DisksTableField({ )}
- @@ -116,6 +129,7 @@ export function DisksTableField({ )} {showDiskAttach && ( setShowDiskAttach(false)} onSubmit={(values) => { onChange([...items, { type: 'attach', ...values }]) diff --git a/app/forms/disk-attach.tsx b/app/forms/disk-attach.tsx index f7995fd307..afbffca185 100644 --- a/app/forms/disk-attach.tsx +++ b/app/forms/disk-attach.tsx @@ -5,15 +5,16 @@ * * Copyright Oxide Computer Company */ -import { useApiQuery, type ApiError } from '@oxide/api' +import { type ApiError } from '@oxide/api' import { ListboxField } from '~/components/form/fields/ListboxField' import { SideModalForm } from '~/components/form/SideModalForm' -import { useForm, useProjectSelector } from '~/hooks' +import { useForm } from '~/hooks' const defaultValues = { name: '' } type AttachDiskProps = { + availableDiskNames: string[] /** If defined, this overrides the usual mutation */ onSubmit: (diskAttach: { name: string }) => void onDismiss: () => void @@ -26,22 +27,12 @@ type AttachDiskProps = { * the optional `loading` and `submitError` */ export function AttachDiskSideModalForm({ + availableDiskNames, onSubmit, onDismiss, loading, submitError = null, }: AttachDiskProps) { - const projectSelector = useProjectSelector() - - // TODO: loading state? because this fires when the modal opens and not when - // they focus the combobox, it will almost always be done by the time they - // click in - // TODO: error handling - const detachedDisks = - useApiQuery('diskList', { query: projectSelector }).data?.items.filter( - (d) => d.state.state === 'detached' - ) || [] - const form = useForm({ defaultValues }) return ( @@ -58,7 +49,7 @@ export function AttachDiskSideModalForm({ ({ value: name, label: name }))} + items={availableDiskNames.map((name) => ({ value: name, label: name }))} required control={form.control} /> diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index 5bbffa4cb9..184d5ac760 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -192,7 +192,7 @@ export function CreateInstanceForm() { () => allDisks.filter(diskCan.attach).map(({ name }) => ({ value: name, label: name })), [allDisks] ) - + const availableDisks = allDisks.filter((d) => d.state.state === 'detached') || [] const { data: sshKeys } = usePrefetchedApiQuery('currentUserSshKeyList', {}) const allKeys = useMemo(() => sshKeys.items.map((key) => key.id), [sshKeys]) @@ -553,7 +553,11 @@ export function CreateInstanceForm() { Additional disks - + Authentication diff --git a/app/pages/project/instances/instance/tabs/StorageTab.tsx b/app/pages/project/instances/instance/tabs/StorageTab.tsx index 7cb5c5851a..e1a10a7e2e 100644 --- a/app/pages/project/instances/instance/tabs/StorageTab.tsx +++ b/app/pages/project/instances/instance/tabs/StorageTab.tsx @@ -47,6 +47,7 @@ StorageTab.loader = async ({ params }: LoaderFunctionArgs) => { path: { instance }, query: { project }, }), + apiQueryClient.prefetchQuery('diskList', { query: { project } }), ]) return null } @@ -101,6 +102,16 @@ export function StorageTab() { }) const { data: instance } = usePrefetchedApiQuery('instanceView', instancePathQuery) + const { data: disks } = usePrefetchedApiQuery('instanceDiskList', { + path: { instance: instanceName }, + query: { project, limit: PAGE_SIZE }, + }) + const attachedDisks = disks.items.map((d) => d.name) + const { data: allDisks } = usePrefetchedApiQuery('diskList', { query: { project } }) + const availableDisks = + allDisks.items.filter( + (d) => d.state.state === 'detached' && !attachedDisks.includes(d.name) + ) || [] const makeActions = useCallback( (disk: Disk): MenuAction[] => [ @@ -166,6 +177,15 @@ export function StorageTab() { const columns = useColsWithActions(staticCols, makeActions) + const isAttachDisabled = !instanceCan.attachDisk(instance) || availableDisks.length === 0 + const attachDisabledReason = !instanceCan.attachDisk(instance) ? ( + <> + Instance must be stopped to attach a disk + + ) : ( + 'No unattached disks are available' + ) + return ( <> @@ -188,13 +208,8 @@ export function StorageTab() { variant="secondary" size="sm" onClick={() => setShowDiskAttach(true)} - disabledReason={ - <> - Instance must be stopped to attach a - disk - - } - disabled={!instanceCan.attachDisk(instance)} + disabled={isAttachDisabled} + disabledReason={attachDisabledReason} > Attach existing disk @@ -218,6 +233,7 @@ export function StorageTab() { )} {showDiskAttach && ( d.name)} onDismiss={() => setShowDiskAttach(false)} onSubmit={({ name }) => { attachDisk.mutate({ ...instancePathQuery, body: { disk: name } }) diff --git a/test/e2e/instance-disks.e2e.ts b/test/e2e/instance-disks.e2e.ts index 59d1651d1e..3caf69c3ee 100644 --- a/test/e2e/instance-disks.e2e.ts +++ b/test/e2e/instance-disks.e2e.ts @@ -58,6 +58,9 @@ test('Attach disk', async ({ page }) => { await expectVisible(page, ['role=dialog >> text="Disk name is required"']) await page.click('role=button[name*="Disk name"]') + + // disk-1 is already attached, so should not be visible in the list + await expectNotVisible(page, ['role=option[name="disk-1"]']) await expectVisible(page, ['role=option[name="disk-3"]', 'role=option[name="disk-4"]']) await page.click('role=option[name="disk-3"]')