Skip to content

Add component for empty MiniTable #2811

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Jun 9, 2025
47 changes: 27 additions & 20 deletions app/components/form/fields/DisksTableField.tsx
Original file line number 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 { CreateDiskSideModalForm } from '~/forms/disk-create'
import type { InstanceCreateInput } from '~/forms/instance-create'
import { EmptyCell } from '~/table/cells/EmptyCell'
import { Badge } from '~/ui/lib/Badge'
import { Button } from '~/ui/lib/Button'
import * as MiniTable from '~/ui/lib/MiniTable'
Expand Down Expand Up @@ -45,18 +46,18 @@ export function DisksTableField({

return (
<>
<div className="max-w-lg">
{!!items.length && (
<MiniTable.Table className="mb-4" aria-label="Disks">
<MiniTable.Header>
<MiniTable.HeadCell>Name</MiniTable.HeadCell>
<MiniTable.HeadCell>Type</MiniTable.HeadCell>
<MiniTable.HeadCell>Size</MiniTable.HeadCell>
{/* For remove button */}
<MiniTable.HeadCell className="w-12" />
</MiniTable.Header>
<MiniTable.Body>
{items.map((item, index) => (
<div className="flex max-w-lg flex-col items-end gap-3">
<MiniTable.Table aria-label="Disks">
<MiniTable.Header>
<MiniTable.HeadCell>Name</MiniTable.HeadCell>
<MiniTable.HeadCell>Type</MiniTable.HeadCell>
<MiniTable.HeadCell>Size</MiniTable.HeadCell>
{/* For remove button */}
<MiniTable.HeadCell />
</MiniTable.Header>
<MiniTable.Body>
{items.length ? (
items.map((item, index) => (
<MiniTable.Row
tabIndex={0}
aria-rowindex={index + 1}
Expand All @@ -67,15 +68,15 @@ export function DisksTableField({
<Truncate text={item.name} maxLength={35} />
</MiniTable.Cell>
<MiniTable.Cell>
<Badge variant="solid">{item.type}</Badge>
<Badge>{item.type}</Badge>
</MiniTable.Cell>
<MiniTable.Cell>
{item.type === 'attach' ? (
'—'
<EmptyCell />
) : (
<>
<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>
Expand All @@ -84,17 +85,23 @@ export function DisksTableField({
label={`remove disk ${item.name}`}
/>
</MiniTable.Row>
))}
</MiniTable.Body>
</MiniTable.Table>
)}
))
) : (
<MiniTable.EmptyState
title="No disks"
body="Add a disk to see it here"
colSpan={4}
/>
)}
</MiniTable.Body>
</MiniTable.Table>

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

<Modal
isOpen={floatingIpModalOpen}
onDismiss={closeFloatingIpModal}
Expand Down
32 changes: 32 additions & 0 deletions app/ui/lib/MiniTable.tsx
Original file line number 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 { Button } from './Button'
import { EmptyMessage } from './EmptyMessage'
import { Table as BigTable } from './Table'

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
// https://www.sarasoueidan.com/blog/accessible-icon-buttons/
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 Diff line number Diff line change
Expand Up @@ -11,47 +11,54 @@
border-spacing: 0px;
}

& td {
@apply relative px-0 pt-2;
}

/* all rows */
& tr {
@apply bg-default;
@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 {
@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: ' ';
}

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

/* all divs */
& 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 {
@apply w-12 justify-center pl-0 pr-0;
}
& td:last-child > div > button {
@apply -mx-3 -my-3 flex items-center justify-center px-3 py-3;
/* first cell's div */
& td:first-child > div {
@apply ml-2 rounded-l border-l;
}
& 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 {
@apply pb-2;
/* last cell's div (the div for the delete button) */
& td:last-child > div {
@apply flex w-8 items-center justify-center border-none px-5;
}

& td:first-child > div {
@apply ml-2 rounded-l border-l;
/* the delete button */
& 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 {
@apply mr-2 rounded-r border-r;
& tr:last-child td {
@apply pb-2;
}

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

& thead tr:first-of-type th:last-of-type {
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 {
Expand Down
2 changes: 2 additions & 0 deletions test/e2e/instance-create.e2e.ts
Original file line number 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')

// 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()
const dialog = page.getByRole('dialog')
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()
// The disk has been "created" (is in the list of Additional Disks)
await expectVisible(page, ['text=disk-12'])
await expect(page.getByText('No disks')).toBeHidden()
// Create the instance
await page.getByRole('button', { name: 'Create instance' }).click()
await expect(page).toHaveURL('/projects/mock-project/instances/another-instance/storage')
Expand Down
Loading