Skip to content

Commit 83a4761

Browse files
benjaminleonarddavid-crespocharliepark
authored
Add data minitable component (#2841)
* Add data minitable component * Remove unnecessary var * Fix test * `rowLabel` and `onRemoveItem` not optional * rename render to cell like react table * remove rowLabel prop, not needed * last few tweaks * convert firewall rules minitable * convert the rest of the minitables (opencode) * rename DataMiniTable to MiniTable * let MiniTable hide itself * Show size of attached disks in mini table (#2842) * Show size of attached disks in mini table * update test * get disk size more directly --------- Co-authored-by: David Crespo <[email protected]> --------- Co-authored-by: David Crespo <[email protected]> Co-authored-by: Charlie Park <[email protected]>
1 parent cdb9f73 commit 83a4761

14 files changed

+226
-276
lines changed

app/components/form/fields/DisksTableField.tsx

Lines changed: 27 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,15 @@ import type { DiskCreate } from '@oxide/api'
1313
import { AttachDiskModalForm } from '~/forms/disk-attach'
1414
import { CreateDiskSideModalForm } from '~/forms/disk-create'
1515
import type { InstanceCreateInput } from '~/forms/instance-create'
16-
import { EmptyCell } from '~/table/cells/EmptyCell'
16+
import { sizeCellInner } from '~/table/columns/common'
1717
import { Badge } from '~/ui/lib/Badge'
1818
import { Button } from '~/ui/lib/Button'
19-
import * as MiniTable from '~/ui/lib/MiniTable'
19+
import { MiniTable } from '~/ui/lib/MiniTable'
2020
import { Truncate } from '~/ui/lib/Truncate'
21-
import { bytesToGiB } from '~/util/units'
2221

2322
export type DiskTableItem =
2423
| (DiskCreate & { type: 'create' })
25-
| { name: string; type: 'attach' }
24+
| { name: string; type: 'attach'; size: number }
2625

2726
/**
2827
* Designed less for reuse, more to encapsulate logic that would otherwise
@@ -47,54 +46,28 @@ export function DisksTableField({
4746
return (
4847
<>
4948
<div className="flex max-w-lg flex-col items-end gap-3">
50-
<MiniTable.Table aria-label="Disks">
51-
<MiniTable.Header>
52-
<MiniTable.HeadCell>Name</MiniTable.HeadCell>
53-
<MiniTable.HeadCell>Type</MiniTable.HeadCell>
54-
<MiniTable.HeadCell>Size</MiniTable.HeadCell>
55-
{/* For remove button */}
56-
<MiniTable.HeadCell />
57-
</MiniTable.Header>
58-
<MiniTable.Body>
59-
{items.length ? (
60-
items.map((item, index) => (
61-
<MiniTable.Row
62-
tabIndex={0}
63-
aria-rowindex={index + 1}
64-
aria-label={`Name: ${item.name}, Type: ${item.type}`}
65-
key={item.name}
66-
>
67-
<MiniTable.Cell>
68-
<Truncate text={item.name} maxLength={35} />
69-
</MiniTable.Cell>
70-
<MiniTable.Cell>
71-
<Badge>{item.type}</Badge>
72-
</MiniTable.Cell>
73-
<MiniTable.Cell>
74-
{item.type === 'attach' ? (
75-
<EmptyCell />
76-
) : (
77-
<>
78-
<span>{bytesToGiB(item.size)}</span>
79-
<span className="ml-1 inline-block text-tertiary">GiB</span>
80-
</>
81-
)}
82-
</MiniTable.Cell>
83-
<MiniTable.RemoveCell
84-
onClick={() => onChange(items.filter((i) => i.name !== item.name))}
85-
label={`remove disk ${item.name}`}
86-
/>
87-
</MiniTable.Row>
88-
))
89-
) : (
90-
<MiniTable.EmptyState
91-
title="No disks"
92-
body="Add a disk to see it here"
93-
colSpan={4}
94-
/>
95-
)}
96-
</MiniTable.Body>
97-
</MiniTable.Table>
49+
<MiniTable
50+
ariaLabel="Disks"
51+
items={items}
52+
columns={[
53+
{
54+
header: 'Name',
55+
cell: (item) => <Truncate text={item.name} maxLength={35} />,
56+
},
57+
{
58+
header: 'Type',
59+
cell: (item) => <Badge>{item.type}</Badge>,
60+
},
61+
{
62+
header: 'Size',
63+
cell: (item) => sizeCellInner(item.size),
64+
},
65+
]}
66+
rowKey={(item) => item.name}
67+
onRemoveItem={(item) => onChange(items.filter((i) => i.name !== item.name))}
68+
removeLabel={(item) => `Remove disk ${item.name}`}
69+
emptyState={{ title: 'No disks', body: 'Add a disk to see it here' }}
70+
/>
9871

9972
<div className="space-x-3">
10073
<Button size="sm" onClick={() => setShowDiskCreate(true)} disabled={disabled}>
@@ -124,8 +97,8 @@ export function DisksTableField({
12497
{showDiskAttach && (
12598
<AttachDiskModalForm
12699
onDismiss={() => setShowDiskAttach(false)}
127-
onSubmit={(values) => {
128-
onChange([...items, { type: 'attach', ...values }])
100+
onSubmit={({ name, size }: { name: string; size: number }) => {
101+
onChange([...items, { type: 'attach', name, size } satisfies DiskTableItem])
129102
setShowDiskAttach(false)
130103
}}
131104
diskNamesToExclude={items.filter((i) => i.type === 'attach').map((i) => i.name)}

app/components/form/fields/NetworkInterfaceField.tsx

Lines changed: 19 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import type { InstanceCreateInput } from '~/forms/instance-create'
1717
import { CreateNetworkInterfaceForm } from '~/forms/network-interface-create'
1818
import { Button } from '~/ui/lib/Button'
1919
import { FieldLabel } from '~/ui/lib/FieldLabel'
20-
import * as MiniTable from '~/ui/lib/MiniTable'
20+
import { MiniTable } from '~/ui/lib/MiniTable'
2121
import { Radio } from '~/ui/lib/Radio'
2222
import { RadioGroup } from '~/ui/lib/RadioGroup'
2323

@@ -75,40 +75,24 @@ export function NetworkInterfaceField({
7575
</RadioGroup>
7676
{value.type === 'create' && (
7777
<>
78-
{value.params.length > 0 && (
79-
<MiniTable.Table className="pt-2">
80-
<MiniTable.Header>
81-
<MiniTable.HeadCell>Name</MiniTable.HeadCell>
82-
<MiniTable.HeadCell>VPC</MiniTable.HeadCell>
83-
<MiniTable.HeadCell>Subnet</MiniTable.HeadCell>
84-
{/* For remove button */}
85-
<MiniTable.HeadCell className="w-12" />
86-
</MiniTable.Header>
87-
<MiniTable.Body>
88-
{value.params.map((item, index) => (
89-
<MiniTable.Row
90-
tabIndex={0}
91-
aria-rowindex={index + 1}
92-
aria-label={`Name: ${item.name}, Vpc: ${item.vpcName}, Subnet: ${item.subnetName}`}
93-
key={item.name}
94-
>
95-
<MiniTable.Cell>{item.name}</MiniTable.Cell>
96-
<MiniTable.Cell>{item.vpcName}</MiniTable.Cell>
97-
<MiniTable.Cell>{item.subnetName}</MiniTable.Cell>
98-
<MiniTable.RemoveCell
99-
onClick={() =>
100-
onChange({
101-
type: 'create',
102-
params: value.params.filter((i) => i.name !== item.name),
103-
})
104-
}
105-
label={`remove network interface ${item.name}`}
106-
/>
107-
</MiniTable.Row>
108-
))}
109-
</MiniTable.Body>
110-
</MiniTable.Table>
111-
)}
78+
<MiniTable
79+
className="pt-2"
80+
ariaLabel="Network Interfaces"
81+
items={value.params}
82+
columns={[
83+
{ header: 'Name', cell: (item) => item.name },
84+
{ header: 'VPC', cell: (item) => item.vpcName },
85+
{ header: 'Subnet', cell: (item) => item.subnetName },
86+
]}
87+
rowKey={(item) => item.name}
88+
onRemoveItem={(item) =>
89+
onChange({
90+
type: 'create',
91+
params: value.params.filter((i) => i.name !== item.name),
92+
})
93+
}
94+
removeLabel={(item) => `remove network interface ${item.name}`}
95+
/>
11296

11397
{showForm && (
11498
<CreateNetworkInterfaceForm

app/components/form/fields/TlsCertsField.tsx

Lines changed: 10 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import type { CertificateCreate } from '@oxide/api'
1414
import type { SiloCreateFormValues } from '~/forms/silo-create'
1515
import { Button } from '~/ui/lib/Button'
1616
import { FieldLabel } from '~/ui/lib/FieldLabel'
17-
import * as MiniTable from '~/ui/lib/MiniTable'
17+
import { MiniTable } from '~/ui/lib/MiniTable'
1818
import { Modal } from '~/ui/lib/Modal'
1919

2020
import { DescriptionField } from './DescriptionField'
@@ -46,31 +46,15 @@ export function TlsCertsField({ control }: { control: Control<SiloCreateFormValu
4646
<FieldLabel id="tls-certificates-label" className="mb-3">
4747
TLS Certificates
4848
</FieldLabel>
49-
{!!items.length && (
50-
<MiniTable.Table className="mb-4">
51-
<MiniTable.Header>
52-
<MiniTable.HeadCell>Name</MiniTable.HeadCell>
53-
{/* For remove button */}
54-
<MiniTable.HeadCell className="w-12" />
55-
</MiniTable.Header>
56-
<MiniTable.Body>
57-
{items.map((item, index) => (
58-
<MiniTable.Row
59-
tabIndex={0}
60-
aria-rowindex={index + 1}
61-
aria-label={`Name: ${item.name}, Description: ${item.description}`}
62-
key={item.name}
63-
>
64-
<MiniTable.Cell>{item.name}</MiniTable.Cell>
65-
<MiniTable.RemoveCell
66-
onClick={() => onChange(items.filter((i) => i.name !== item.name))}
67-
label={`remove cert ${item.name}`}
68-
/>
69-
</MiniTable.Row>
70-
))}
71-
</MiniTable.Body>
72-
</MiniTable.Table>
73-
)}
49+
<MiniTable
50+
className="mb-4"
51+
ariaLabel="TLS Certificates"
52+
items={items}
53+
columns={[{ header: 'Name', cell: (item) => item.name }]}
54+
rowKey={(item) => item.name}
55+
onRemoveItem={(item) => onChange(items.filter((i) => i.name !== item.name))}
56+
removeLabel={(item) => `remove cert ${item.name}`}
57+
/>
7458

7559
{/* ref on button element allows scrollTo to work when the form has a "missing TLS cert" error */}
7660
<Button size="sm" onClick={() => setShowAddCert(true)} ref={ref}>

app/forms/disk-attach.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ const defaultValues = { name: '' }
2020

2121
type AttachDiskProps = {
2222
/** If defined, this overrides the usual mutation */
23-
onSubmit: (diskAttach: { name: string }) => void
23+
onSubmit: (diskAttach: { name: string; size: number }) => void
2424
onDismiss: () => void
2525
diskNamesToExclude?: string[]
2626
loading?: boolean
@@ -64,7 +64,13 @@ export function AttachDiskModalForm({
6464
submitError={submitError}
6565
loading={loading}
6666
title="Attach disk"
67-
onSubmit={onSubmit}
67+
onSubmit={({ name }) => {
68+
// because the ComboboxField is required and does not allow arbitrary
69+
// values (values not in the list of disks), we can only get here if the
70+
// disk is defined and in the list
71+
const disk = data!.items.find((d) => d.name === name)!
72+
onSubmit({ name, size: disk.size })
73+
}}
6874
>
6975
<ComboboxField
7076
label="Disk name"

app/forms/firewall-rules-common.tsx

Lines changed: 31 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ import { toComboboxItems } from '~/ui/lib/Combobox'
3333
import { FormDivider } from '~/ui/lib/Divider'
3434
import { FieldLabel } from '~/ui/lib/FieldLabel'
3535
import { Message } from '~/ui/lib/Message'
36-
import * as MiniTable from '~/ui/lib/MiniTable'
36+
import { ClearAndAddButtons, MiniTable } from '~/ui/lib/MiniTable'
3737
import { SideModal } from '~/ui/lib/SideModal'
3838
import { TextInputHint } from '~/ui/lib/TextInput'
3939
import { KEYS } from '~/ui/util/keys'
@@ -148,11 +148,12 @@ const TargetAndHostFilterSubform = ({
148148
subform.setValue('value', value)
149149
}
150150

151+
const noun = sectionType === 'target' ? 'target' : 'host filter'
152+
const nounTitle = capitalize(noun) + 's'
153+
151154
return (
152155
<>
153-
<SideModal.Heading>
154-
{sectionType === 'target' ? 'Targets' : 'Host filters'}
155-
</SideModal.Heading>
156+
<SideModal.Heading>{nounTitle}</SideModal.Heading>
156157

157158
<Message variant="info" content={messageContent} />
158159
<ListboxField
@@ -209,48 +210,25 @@ const TargetAndHostFilterSubform = ({
209210
}
210211
/>
211212
)}
212-
<MiniTable.ClearAndAddButtons
213-
addButtonCopy={`Add ${sectionType === 'host' ? 'host filter' : 'target'}`}
213+
<ClearAndAddButtons
214+
addButtonCopy={`Add ${noun}`}
214215
disabled={!value}
215216
onClear={() => subform.reset()}
216217
onSubmit={submitSubform}
217218
/>
218-
{field.value.length > 0 && (
219-
<MiniTable.Table
220-
className="mb-4"
221-
aria-label={sectionType === 'target' ? 'Targets' : 'Host filters'}
222-
>
223-
<MiniTable.Header>
224-
<MiniTable.HeadCell>Type</MiniTable.HeadCell>
225-
<MiniTable.HeadCell>Value</MiniTable.HeadCell>
226-
{/* For remove button */}
227-
<MiniTable.HeadCell className="w-12" />
228-
</MiniTable.Header>
229-
<MiniTable.Body>
230-
{field.value.map(({ type, value }, index) => (
231-
<MiniTable.Row
232-
tabIndex={0}
233-
aria-rowindex={index + 1}
234-
aria-label={`Name: ${value}, Type: ${type}`}
235-
key={`${type}|${value}`}
236-
>
237-
<MiniTable.Cell>
238-
<Badge>{type}</Badge>
239-
</MiniTable.Cell>
240-
<MiniTable.Cell>{value}</MiniTable.Cell>
241-
<MiniTable.RemoveCell
242-
onClick={() =>
243-
field.onChange(
244-
field.value.filter((i) => !(i.value === value && i.type === type))
245-
)
246-
}
247-
label={`remove ${sectionType} ${value}`}
248-
/>
249-
</MiniTable.Row>
250-
))}
251-
</MiniTable.Body>
252-
</MiniTable.Table>
253-
)}
219+
<MiniTable
220+
className="mb-4"
221+
ariaLabel={nounTitle}
222+
items={field.value}
223+
columns={[
224+
{ header: 'Type', cell: (item) => <Badge>{item.type}</Badge> },
225+
{ header: 'Value', cell: (item) => item.value },
226+
]}
227+
rowKey={({ type, value }) => `${type}|${value}`}
228+
onRemoveItem={({ type, value }) => {
229+
field.onChange(field.value.filter((i) => !(i.value === value && i.type === type)))
230+
}}
231+
/>
254232
</>
255233
)
256234
}
@@ -450,32 +428,24 @@ export const CommonFields = ({ control, nameTaken, error }: CommonFieldsProps) =
450428
}}
451429
/>
452430
</div>
453-
<MiniTable.ClearAndAddButtons
431+
<ClearAndAddButtons
454432
addButtonCopy="Add port filter"
455433
disabled={!portValue}
456434
onClear={() => portRangeForm.reset()}
457435
onSubmit={submitPortRange}
458436
/>
459437
</div>
460438
{ports.value.length > 0 && (
461-
<MiniTable.Table className="mb-4" aria-label="Port filters">
462-
<MiniTable.Header>
463-
<MiniTable.HeadCell>Port ranges</MiniTable.HeadCell>
464-
{/* For remove button */}
465-
<MiniTable.HeadCell className="w-12" />
466-
</MiniTable.Header>
467-
<MiniTable.Body>
468-
{ports.value.map((p) => (
469-
<MiniTable.Row tabIndex={0} aria-label={p} key={p}>
470-
<MiniTable.Cell>{p}</MiniTable.Cell>
471-
<MiniTable.RemoveCell
472-
onClick={() => ports.onChange(ports.value.filter((p1) => p1 !== p))}
473-
label={`remove port ${p}`}
474-
/>
475-
</MiniTable.Row>
476-
))}
477-
</MiniTable.Body>
478-
</MiniTable.Table>
439+
<MiniTable
440+
className="mb-4"
441+
ariaLabel="Port filters"
442+
items={ports.value}
443+
columns={[{ header: 'Port ranges', cell: (p) => p }]}
444+
rowKey={(port) => port}
445+
emptyState={{ title: 'No ports', body: 'Add a port to see it here' }}
446+
onRemoveItem={(p) => ports.onChange(ports.value.filter((p1) => p1 !== p))}
447+
removeLabel={(port) => `remove port ${port}`}
448+
/>
479449
)}
480450

481451
<fieldset className="space-y-0.5">

0 commit comments

Comments
 (0)