Skip to content
29 changes: 18 additions & 11 deletions app/components/form/fields/DisksTableField.tsx
Original file line number Original file line Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { DiskCreate } from '@oxide/api'
import { AttachDiskModalForm } from '~/forms/disk-attach' import { AttachDiskModalForm } from '~/forms/disk-attach'
import { CreateDiskSideModalForm } from '~/forms/disk-create' import { CreateDiskSideModalForm } from '~/forms/disk-create'
import type { InstanceCreateInput } from '~/forms/instance-create' import type { InstanceCreateInput } from '~/forms/instance-create'
import { EmptyCell } from '~/table/cells/EmptyCell'
import { Badge } from '~/ui/lib/Badge' import { Badge } from '~/ui/lib/Badge'
import { Button } from '~/ui/lib/Button' import { Button } from '~/ui/lib/Button'
import * as MiniTable from '~/ui/lib/MiniTable' import * as MiniTable from '~/ui/lib/MiniTable'
Expand Down Expand Up @@ -45,18 +46,18 @@ export function DisksTableField({


return ( return (
<> <>
<div className="max-w-lg"> <div className="flex max-w-lg flex-col items-end gap-3">
{!!items.length && ( <MiniTable.Table aria-label="Disks">
<MiniTable.Table className="mb-4" aria-label="Disks">
<MiniTable.Header> <MiniTable.Header>
<MiniTable.HeadCell>Name</MiniTable.HeadCell> <MiniTable.HeadCell>Name</MiniTable.HeadCell>
<MiniTable.HeadCell>Type</MiniTable.HeadCell> <MiniTable.HeadCell>Type</MiniTable.HeadCell>
<MiniTable.HeadCell>Size</MiniTable.HeadCell> <MiniTable.HeadCell>Size</MiniTable.HeadCell>
{/* For remove button */} {/* For remove button */}
<MiniTable.HeadCell className="w-12" /> <MiniTable.HeadCell />
</MiniTable.Header> </MiniTable.Header>
<MiniTable.Body> <MiniTable.Body>
{items.map((item, index) => ( {items.length ? (
items.map((item, index) => (
<MiniTable.Row <MiniTable.Row
tabIndex={0} tabIndex={0}
aria-rowindex={index + 1} aria-rowindex={index + 1}
Expand All @@ -67,15 +68,15 @@ export function DisksTableField({
<Truncate text={item.name} maxLength={35} /> <Truncate text={item.name} maxLength={35} />
</MiniTable.Cell> </MiniTable.Cell>
<MiniTable.Cell> <MiniTable.Cell>
<Badge variant="solid">{item.type}</Badge> <Badge>{item.type}</Badge>
</MiniTable.Cell> </MiniTable.Cell>
<MiniTable.Cell> <MiniTable.Cell>
{item.type === 'attach' ? ( {item.type === 'attach' ? (
'—' <EmptyCell />
) : ( ) : (
<> <>
<span>{bytesToGiB(item.size)}</span> <span>{bytesToGiB(item.size)}</span>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a change we need to make now, but should we make a size component that ensures the item and the unit are handled consistently / spacing and text colour.

<span className="ml-1 inline-block text-accent-secondary">GiB</span> <span className="ml-1 inline-block text-tertiary">GiB</span>
</> </>
)} )}
</MiniTable.Cell> </MiniTable.Cell>
Expand All @@ -84,17 +85,23 @@ export function DisksTableField({
label={`remove disk ${item.name}`} label={`remove disk ${item.name}`}
/> />
</MiniTable.Row> </MiniTable.Row>
))} ))
) : (
<MiniTable.EmptyState
title="No disks"
body="Add a disk to see it here"
colSpan={4}
/>
)}
</MiniTable.Body> </MiniTable.Body>
</MiniTable.Table> </MiniTable.Table>
)}


<div className="space-x-3"> <div className="space-x-3">
<Button size="sm" onClick={() => setShowDiskCreate(true)} disabled={disabled}> <Button size="sm" onClick={() => setShowDiskCreate(true)} disabled={disabled}>
Create new disk Create new disk
</Button> </Button>
<Button <Button
variant="ghost" variant="secondary"
size="sm" size="sm"
onClick={() => setShowDiskAttach(true)} onClick={() => setShowDiskAttach(true)}
disabled={disabled} disabled={disabled}
Expand Down
2 changes: 1 addition & 1 deletion app/forms/firewall-rules-common.tsx
Original file line number Original file line Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ const TargetAndHostFilterSubform = ({
key={`${type}|${value}`} key={`${type}|${value}`}
> >
<MiniTable.Cell> <MiniTable.Cell>
<Badge variant="solid">{type}</Badge> <Badge>{type}</Badge>
</MiniTable.Cell> </MiniTable.Cell>
<MiniTable.Cell>{value}</MiniTable.Cell> <MiniTable.Cell>{value}</MiniTable.Cell>
<MiniTable.RemoveCell <MiniTable.RemoveCell
Expand Down
36 changes: 21 additions & 15 deletions app/forms/instance-create.tsx
Original file line number Original file line Diff line number Diff line change
Expand Up @@ -777,7 +777,16 @@ const AdvancedAccordion = ({
detached from them as needed detached from them as needed
</TipIcon> </TipIcon>
</h2> </h2>
{isFloatingIpAttached && ( {floatingIpList.items.length === 0 ? (
<div className="flex max-w-lg items-center justify-center rounded-lg border border-default">
<EmptyMessage
icon={<IpGlobal16Icon />}
title="No floating IPs found"
body="Create a floating IP to attach it to this instance"
/>
</div>
) : (
<div className="flex flex-col items-end gap-3">
<MiniTable.Table> <MiniTable.Table>
<MiniTable.Header> <MiniTable.Header>
<MiniTable.HeadCell>Name</MiniTable.HeadCell> <MiniTable.HeadCell>Name</MiniTable.HeadCell>
Expand All @@ -786,7 +795,8 @@ const AdvancedAccordion = ({
<MiniTable.HeadCell className="w-12" /> <MiniTable.HeadCell className="w-12" />
</MiniTable.Header> </MiniTable.Header>
<MiniTable.Body> <MiniTable.Body>
{attachedFloatingIpsData.map((item, index) => ( {isFloatingIpAttached ? (
attachedFloatingIpsData.map((item, index) => (
<MiniTable.Row <MiniTable.Row
tabIndex={0} tabIndex={0}
aria-rowindex={index + 1} aria-rowindex={index + 1}
Expand All @@ -800,21 +810,18 @@ const AdvancedAccordion = ({
label={`remove floating IP ${item.name}`} label={`remove floating IP ${item.name}`}
/> />
</MiniTable.Row> </MiniTable.Row>
))} ))
) : (
<MiniTable.EmptyState
title="No floating IPs attached"
body="Attach a floating IP to see it here"
colSpan={3}
/>
)}
</MiniTable.Body> </MiniTable.Body>
</MiniTable.Table> </MiniTable.Table>
)}
{floatingIpList.items.length === 0 ? (
<div className="flex max-w-lg items-center justify-center rounded-lg border p-6 border-default">
<EmptyMessage
icon={<IpGlobal16Icon />}
title="No floating IPs found"
body="Create a floating IP to attach it to this instance"
/>
</div>
) : (
<div>
<Button <Button
variant="secondary"
size="sm" size="sm"
className="shrink-0" className="shrink-0"
disabled={availableFloatingIps.length === 0} disabled={availableFloatingIps.length === 0}
Expand All @@ -825,7 +832,6 @@ const AdvancedAccordion = ({
</Button> </Button>
</div> </div>
)} )}

<Modal <Modal
isOpen={floatingIpModalOpen} isOpen={floatingIpModalOpen}
onDismiss={closeFloatingIpModal} onDismiss={closeFloatingIpModal}
Expand Down
32 changes: 32 additions & 0 deletions app/ui/lib/MiniTable.tsx
Original file line number Original file line Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Error16Icon } from '@oxide/design-system/icons/react'
import { classed } from '~/util/classed' import { classed } from '~/util/classed'


import { Button } from './Button' import { Button } from './Button'
import { EmptyMessage } from './EmptyMessage'
import { Table as BigTable } from './Table' import { Table as BigTable } from './Table'


type Children = { children: React.ReactNode } type Children = { children: React.ReactNode }
Expand All @@ -36,6 +37,37 @@ export const Cell = ({ children }: Children) => {
) )
} }


export const EmptyState = (props: { title: string; body: string; colSpan: number }) => (
<Row>
<td colSpan={props.colSpan}>
<div className="!m-0 !w-full !flex-col !border-none !bg-transparent !py-14">
<EmptyMessage title={props.title} body={props.body} />
</div>
</td>
</Row>
)

export const InputCell = ({
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not used in this branch, but will be used in follow-up

colSpan,
defaultValue,
placeholder,
}: {
colSpan?: number
defaultValue: string
placeholder: string
}) => (
<td colSpan={colSpan}>
<div>
<input
type="text"
className="text-sm m-0 w-full bg-transparent p-0 !outline-none text-default placeholder:text-quaternary"
placeholder={placeholder}
defaultValue={defaultValue}
/>
</div>
</td>
)

// followed this for icon in button best practices // followed this for icon in button best practices
// https://www.sarasoueidan.com/blog/accessible-icon-buttons/ // https://www.sarasoueidan.com/blog/accessible-icon-buttons/
export const RemoveCell = ({ onClick, label }: { onClick: () => void; label: string }) => ( export const RemoveCell = ({ onClick, label }: { onClick: () => void; label: string }) => (
Expand Down
47 changes: 27 additions & 20 deletions app/ui/styles/components/mini-table.css
Original file line number Original file line Diff line number Diff line change
Expand Up @@ -11,47 +11,54 @@
border-spacing: 0px; border-spacing: 0px;
} }


& td { /* all rows */
@apply relative px-0 pt-2;
}

& tr { & tr {
@apply bg-default;
@apply relative; @apply relative;
} }


/* all cells */
& td {
@apply relative px-0 pt-2;
}

/* a fake left border for all cells that aren't first */
& td + td:before { & td + td:before {
@apply absolute bottom-[2px] top-[calc(0.5rem+1px)] block w-[1px] border-l opacity-40 border-accent-tertiary; @apply absolute bottom-[2px] top-[calc(0.5rem+1px)] block w-[1px] border-l border-secondary;
content: ' '; content: ' ';
} }


& tr:last-child td + td:before { & tr:last-child td + td:before {
@apply bottom-[calc(0.5rem+2px)]; @apply bottom-[calc(0.5rem+2px)];
} }


/* all divs */
& td > div { & td > div {
@apply flex h-11 items-center border-y py-3 pl-3 pr-6 text-accent bg-accent-secondary border-accent-tertiary; @apply flex h-9 items-center border border-y border-r-0 py-3 pl-3 pr-6 border-default;
} }


& td:last-child > div { /* first cell's div */
@apply w-12 justify-center pl-0 pr-0; & td:first-child > div {
} @apply ml-2 rounded-l border-l;
& td:last-child > div > button {
@apply -mx-3 -my-3 flex items-center justify-center px-3 py-3;
} }
& td:last-child > div:has(button:hover, button:focus) {
@apply bg-accent-secondary-hover; /* second-to-last cell's div */
& td:nth-last-child(2) > div {
@apply rounded-r border-r;
} }


& tr:last-child td { /* last cell's div (the div for the delete button) */
@apply pb-2; & td:last-child > div {
@apply flex w-8 items-center justify-center border-none px-5;
} }


& td:first-child > div { /* the delete button */
@apply ml-2 rounded-l border-l; & td:last-child > div > button {
@apply -m-2 flex items-center justify-center p-2 text-tertiary hover:text-secondary focus:text-secondary;
} }


& td:last-child > div { & tr:last-child td {
@apply mr-2 rounded-r border-r; @apply pb-2;
} }


& thead tr:first-of-type th:first-of-type { & thead tr:first-of-type th:first-of-type {
Expand All @@ -61,7 +68,7 @@


& thead tr:first-of-type th:last-of-type { & thead tr:first-of-type th:last-of-type {
border-top-right-radius: var(--border-radius-lg); border-top-right-radius: var(--border-radius-lg);
@apply border-r; @apply w-8 border-r;
} }


& tbody tr:last-of-type td:first-of-type { & tbody tr:last-of-type td:first-of-type {
Expand Down
2 changes: 2 additions & 0 deletions test/e2e/instance-create.e2e.ts
Original file line number Original file line Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ test('can’t create a disk with a name that collides with the boot disk name',
await page.fill('input[name=bootDiskName]', 'disk-11') await page.fill('input[name=bootDiskName]', 'disk-11')


// Attempt to create a disk with the same name // Attempt to create a disk with the same name
await expect(page.getByText('No disks')).toBeVisible()
await page.getByRole('button', { name: 'Create new disk' }).click() await page.getByRole('button', { name: 'Create new disk' }).click()
const dialog = page.getByRole('dialog') const dialog = page.getByRole('dialog')
await dialog.getByRole('textbox', { name: 'name' }).fill('disk-11') await dialog.getByRole('textbox', { name: 'name' }).fill('disk-11')
Expand All @@ -268,6 +269,7 @@ test('can’t create a disk with a name that collides with the boot disk name',
await dialog.getByRole('button', { name: 'Create disk' }).click() await dialog.getByRole('button', { name: 'Create disk' }).click()
// The disk has been "created" (is in the list of Additional Disks) // The disk has been "created" (is in the list of Additional Disks)
await expectVisible(page, ['text=disk-12']) await expectVisible(page, ['text=disk-12'])
await expect(page.getByText('No disks')).toBeHidden()
// Create the instance // Create the instance
await page.getByRole('button', { name: 'Create instance' }).click() await page.getByRole('button', { name: 'Create instance' }).click()
await expect(page).toHaveURL('/projects/mock-project/instances/another-instance/storage') await expect(page).toHaveURL('/projects/mock-project/instances/another-instance/storage')
Expand Down
Loading