Skip to content

Commit 1733b76

Browse files
Allow ReactElements to be used in Combobox dropdowns (#2474)
Co-authored-by: David Crespo <david.crespo@oxidecomputer.com>
1 parent efcaba7 commit 1733b76

17 files changed

+185
-146
lines changed

app/components/form/fields/ComboboxField.tsx

+15-2
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* Copyright Oxide Computer Company
77
*/
88

9+
import { useState } from 'react'
910
import {
1011
useController,
1112
type Control,
@@ -15,7 +16,11 @@ import {
1516
type Validate,
1617
} from 'react-hook-form'
1718

18-
import { Combobox, type ComboboxBaseProps } from '~/ui/lib/Combobox'
19+
import {
20+
Combobox,
21+
getSelectedLabelFromValue,
22+
type ComboboxBaseProps,
23+
} from '~/ui/lib/Combobox'
1924
import { capitalize } from '~/util/str'
2025

2126
import { ErrorMessage } from './ErrorMessage'
@@ -54,6 +59,7 @@ export function ComboboxField<
5459
: allowArbitraryValues
5560
? 'Select an option or enter a custom value'
5661
: 'Select an option',
62+
items,
5763
validate,
5864
...props
5965
}: ComboboxFieldProps<TFieldValues, TName>) {
@@ -62,20 +68,27 @@ export function ComboboxField<
6268
control,
6369
rules: { required, validate },
6470
})
71+
const [selectedItemLabel, setSelectedItemLabel] = useState(
72+
getSelectedLabelFromValue(items, field.value || '')
73+
)
6574
return (
6675
<div className="max-w-lg">
6776
<Combobox
6877
label={label}
6978
placeholder={placeholder}
7079
description={description}
80+
items={items}
7181
required={required}
72-
selected={field.value || null}
82+
selectedItemValue={field.value}
83+
selectedItemLabel={selectedItemLabel}
7384
hasError={fieldState.error !== undefined}
7485
onChange={(value) => {
7586
field.onChange(value)
7687
onChange?.(value)
88+
setSelectedItemLabel(getSelectedLabelFromValue(items, value))
7789
}}
7890
allowArbitraryValues={allowArbitraryValues}
91+
inputRef={field.ref}
7992
{...props}
8093
/>
8194
<ErrorMessage error={fieldState.error} label={label} />

app/components/form/fields/ImageSelectField.tsx

+22-26
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,12 @@ import { useController, type Control } from 'react-hook-form'
1010
import type { Image } from '@oxide/api'
1111

1212
import type { InstanceCreateInput } from '~/forms/instance-create'
13-
import type { ListboxItem } from '~/ui/lib/Listbox'
13+
import type { ComboboxItem } from '~/ui/lib/Combobox'
1414
import { Slash } from '~/ui/lib/Slash'
1515
import { nearest10 } from '~/util/math'
1616
import { bytesToGiB, GiB } from '~/util/units'
1717

18-
import { ListboxField } from './ListboxField'
18+
import { ComboboxField } from './ComboboxField'
1919

2020
type ImageSelectFieldProps = {
2121
images: Image[]
@@ -32,18 +32,22 @@ export function BootDiskImageSelectField({
3232
}: ImageSelectFieldProps) {
3333
const diskSizeField = useController({ control, name: 'bootDiskSize' }).field
3434
return (
35-
// This should be migrated to a `ComboboxField` (and with a `toComboboxItem`), once
36-
// we have a combobox that supports more elaborate labels (beyond just strings).
37-
<ListboxField
35+
<ComboboxField
3836
disabled={disabled}
3937
control={control}
4038
name={name}
4139
label="Image"
42-
placeholder="Select an image"
43-
items={images.map((i) => toListboxItem(i))}
40+
placeholder={
41+
name === 'siloImageSource' ? 'Select a silo image' : 'Select a project image'
42+
}
43+
items={images.map((i) => toImageComboboxItem(i))}
4444
required
4545
onChange={(id) => {
46-
const image = images.find((i) => i.id === id)! // if it's selected, it must be present
46+
const image = images.find((i) => i.id === id)
47+
// the most likely scenario where image would be undefined is if the user has
48+
// manually cleared the ComboboxField; they will need to pick a boot disk image
49+
// in order to submit the form, so we don't need to do anything here
50+
if (!image) return
4751
const imageSizeGiB = image.size / GiB
4852
if (diskSizeField.value < imageSizeGiB) {
4953
diskSizeField.onChange(nearest10(imageSizeGiB))
@@ -53,24 +57,18 @@ export function BootDiskImageSelectField({
5357
)
5458
}
5559

56-
export function toListboxItem(i: Image, includeProjectSiloIndicator = false): ListboxItem {
57-
const { name, os, projectId, size, version } = i
58-
const formattedSize = `${bytesToGiB(size, 1)} GiB`
59-
60-
// filter out any undefined metadata and create a comma-separated list
61-
// for the selected listbox item (shown in selectedLabel)
62-
const condensedImageMetadata = [os, version, formattedSize].filter((i) => !!i).join(', ')
63-
const metadataForSelectedLabel = condensedImageMetadata.length
64-
? ` (${condensedImageMetadata})`
65-
: ''
60+
export function toImageComboboxItem(
61+
image: Image,
62+
includeProjectSiloIndicator = false
63+
): ComboboxItem {
64+
const { id, name, os, projectId, size, version } = image
6665

6766
// for metadata showing in the dropdown's options, include the project / silo indicator if requested
6867
const projectSiloIndicator = includeProjectSiloIndicator
6968
? `${projectId ? 'Project' : 'Silo'} image`
7069
: null
71-
// filter out undefined metadata here, as well, and create a `<Slash />`-separated list
72-
// for the listbox item (shown for each item in the dropdown)
73-
const metadataForLabel = [os, version, formattedSize, projectSiloIndicator]
70+
// filter out undefined metadata and create a `<Slash />`-separated list for each comboboxitem
71+
const itemMetadata = [os, version, `${bytesToGiB(size, 1)} GiB`, projectSiloIndicator]
7472
.filter((i) => !!i)
7573
.map((i, index) => (
7674
<span key={`${i}`}>
@@ -79,14 +77,12 @@ export function toListboxItem(i: Image, includeProjectSiloIndicator = false): Li
7977
</span>
8078
))
8179
return {
82-
value: i.id,
83-
selectedLabel: `${name}${metadataForSelectedLabel}`,
80+
value: id,
81+
selectedLabel: name,
8482
label: (
8583
<>
8684
<div>{name}</div>
87-
<div className="text-tertiary selected:text-accent-secondary">
88-
{metadataForLabel}
89-
</div>
85+
<div className="text-tertiary selected:text-accent-secondary">{itemMetadata}</div>
9086
</>
9187
),
9288
}

app/forms/disk-attach.tsx

+12-5
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@
55
*
66
* Copyright Oxide Computer Company
77
*/
8+
import { useMemo } from 'react'
89
import { useForm } from 'react-hook-form'
910

1011
import { useApiQuery, type ApiError } from '@oxide/api'
1112

1213
import { ComboboxField } from '~/components/form/fields/ComboboxField'
1314
import { SideModalForm } from '~/components/form/SideModalForm'
1415
import { useProjectSelector } from '~/hooks/use-params'
16+
import { toComboboxItems } from '~/ui/lib/Combobox'
1517
import { ALL_ISH } from '~/util/consts'
1618

1719
const defaultValues = { name: '' }
@@ -41,10 +43,15 @@ export function AttachDiskSideModalForm({
4143
const { data } = useApiQuery('diskList', {
4244
query: { project, limit: ALL_ISH },
4345
})
44-
const detachedDisks =
45-
data?.items.filter(
46-
(d) => d.state.state === 'detached' && !diskNamesToExclude.includes(d.name)
47-
) || []
46+
const detachedDisks = useMemo(
47+
() =>
48+
toComboboxItems(
49+
data?.items.filter(
50+
(d) => d.state.state === 'detached' && !diskNamesToExclude.includes(d.name)
51+
)
52+
),
53+
[data, diskNamesToExclude]
54+
)
4855

4956
const form = useForm({ defaultValues })
5057

@@ -63,7 +70,7 @@ export function AttachDiskSideModalForm({
6370
label="Disk name"
6471
placeholder="Select a disk"
6572
name="name"
66-
items={detachedDisks.map(({ name }) => ({ value: name, label: name }))}
73+
items={detachedDisks}
6774
required
6875
control={form.control}
6976
/>

app/forms/disk-create.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import {
2323

2424
import { DescriptionField } from '~/components/form/fields/DescriptionField'
2525
import { DiskSizeField } from '~/components/form/fields/DiskSizeField'
26-
import { toListboxItem } from '~/components/form/fields/ImageSelectField'
26+
import { toImageComboboxItem } from '~/components/form/fields/ImageSelectField'
2727
import { ListboxField } from '~/components/form/fields/ListboxField'
2828
import { NameField } from '~/components/form/fields/NameField'
2929
import { RadioField } from '~/components/form/fields/RadioField'
@@ -210,7 +210,7 @@ const DiskSourceField = ({
210210
label="Source image"
211211
placeholder="Select an image"
212212
isLoading={areImagesLoading}
213-
items={images.map((i) => toListboxItem(i, true))}
213+
items={images.map((i) => toImageComboboxItem(i, true))}
214214
required
215215
onChange={(id) => {
216216
const image = images.find((i) => i.id === id)! // if it's selected, it must be present

app/forms/firewall-rules-common.tsx

+7-8
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { RadioField } from '~/components/form/fields/RadioField'
3434
import { TextField, TextFieldInner } from '~/components/form/fields/TextField'
3535
import { useVpcSelector } from '~/hooks/use-params'
3636
import { Badge } from '~/ui/lib/Badge'
37+
import { toComboboxItems, type ComboboxItem } from '~/ui/lib/Combobox'
3738
import { FormDivider } from '~/ui/lib/Divider'
3839
import { Message } from '~/ui/lib/Message'
3940
import * as MiniTable from '~/ui/lib/MiniTable'
@@ -99,7 +100,7 @@ const DynamicTypeAndValueFields = ({
99100
sectionType: 'target' | 'host'
100101
control: Control<TargetAndHostFormValues>
101102
valueType: TargetAndHostFilterType
102-
items: Array<{ value: string; label: string }>
103+
items: Array<ComboboxItem>
103104
disabled?: boolean
104105
onInputChange?: (value: string) => void
105106
onTypeChange: () => void
@@ -204,8 +205,8 @@ const TypeAndValueTable = ({ sectionType, items }: TypeAndValueTableProps) => (
204205
</MiniTable.Table>
205206
)
206207

207-
// Given an array of committed items (VPCs, Subnets, Instances) and
208-
// a list of all items, return the items that are available
208+
/** Given an array of *committed* items (VPCs, Subnets, Instances) and a list of *all* items,
209+
* return the items that are available */
209210
const availableItems = (
210211
committedItems: Array<VpcFirewallRuleTarget | VpcFirewallRuleHostFilter>,
211212
itemType: 'vpc' | 'subnet' | 'instance',
@@ -214,13 +215,11 @@ const availableItems = (
214215
if (!items) return []
215216
return (
216217
items
217-
.map((item) => item.name)
218218
// remove any items that match the committed items on both type and value
219219
.filter(
220-
(name) =>
220+
({ name }) =>
221221
!committedItems.filter((ci) => ci.type === itemType && ci.value === name).length
222222
)
223-
.map((name) => ({ label: name, value: name }))
224223
)
225224
}
226225

@@ -434,7 +433,7 @@ export const CommonFields = ({ control, nameTaken, error }: CommonFieldsProps) =
434433
sectionType="target"
435434
control={targetForm.control}
436435
valueType={targetType}
437-
items={targetItems[targetType]}
436+
items={toComboboxItems(targetItems[targetType])}
438437
// HACK: reset the whole subform, keeping type (because we just set
439438
// it). most importantly, this resets isSubmitted so the form can go
440439
// back to validating on submit instead of change
@@ -546,7 +545,7 @@ export const CommonFields = ({ control, nameTaken, error }: CommonFieldsProps) =
546545
sectionType="host"
547546
control={hostForm.control}
548547
valueType={hostType}
549-
items={hostFilterItems[hostType]}
548+
items={toComboboxItems(hostFilterItems[hostType])}
550549
// HACK: reset the whole subform, keeping type (because we just set
551550
// it). most importantly, this resets isSubmitted so the form can go
552551
// back to validating on submit instead of change

app/forms/instance-create.tsx

+2-4
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ import { addToast } from '~/stores/toast'
6060
import { Badge } from '~/ui/lib/Badge'
6161
import { Button } from '~/ui/lib/Button'
6262
import { Checkbox } from '~/ui/lib/Checkbox'
63+
import { toComboboxItems } from '~/ui/lib/Combobox'
6364
import { FormDivider } from '~/ui/lib/Divider'
6465
import { EmptyMessage } from '~/ui/lib/EmptyMessage'
6566
import { Listbox } from '~/ui/lib/Listbox'
@@ -197,10 +198,7 @@ export function CreateInstanceForm() {
197198
const allDisks = usePrefetchedApiQuery('diskList', {
198199
query: { project, limit: ALL_ISH },
199200
}).data.items
200-
const disks = useMemo(
201-
() => allDisks.filter(diskCan.attach).map(({ name }) => ({ value: name, label: name })),
202-
[allDisks]
203-
)
201+
const disks = useMemo(() => toComboboxItems(allDisks.filter(diskCan.attach)), [allDisks])
204202

205203
const { data: sshKeys } = usePrefetchedApiQuery('currentUserSshKeyList', {})
206204
const allKeys = useMemo(() => sshKeys.items.map((key) => key.id), [sshKeys])

app/forms/snapshot-create.tsx

+5-6
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
*
66
* Copyright Oxide Computer Company
77
*/
8+
import { useMemo } from 'react'
89
import { useForm } from 'react-hook-form'
910
import { useNavigate } from 'react-router-dom'
1011

@@ -23,18 +24,15 @@ import { NameField } from '~/components/form/fields/NameField'
2324
import { SideModalForm } from '~/components/form/SideModalForm'
2425
import { useProjectSelector } from '~/hooks/use-params'
2526
import { addToast } from '~/stores/toast'
27+
import { toComboboxItems } from '~/ui/lib/Combobox'
2628
import { ALL_ISH } from '~/util/consts'
2729
import { pb } from '~/util/path-builder'
2830

2931
const useSnapshotDiskItems = (projectSelector: PP.Project) => {
3032
const { data: disks } = useApiQuery('diskList', {
3133
query: { ...projectSelector, limit: ALL_ISH },
3234
})
33-
return (
34-
disks?.items
35-
.filter(diskCan.snapshot)
36-
.map((disk) => ({ value: disk.name, label: disk.name })) || []
37-
)
35+
return disks?.items.filter(diskCan.snapshot)
3836
}
3937

4038
const defaultValues: SnapshotCreate = {
@@ -49,6 +47,7 @@ export function CreateSnapshotSideModalForm() {
4947
const navigate = useNavigate()
5048

5149
const diskItems = useSnapshotDiskItems(projectSelector)
50+
const diskItemsForCombobox = useMemo(() => toComboboxItems(diskItems), [diskItems])
5251

5352
const onDismiss = () => navigate(pb.snapshots(projectSelector))
5453

@@ -79,7 +78,7 @@ export function CreateSnapshotSideModalForm() {
7978
label="Disk"
8079
name="disk"
8180
placeholder="Select a disk"
82-
items={diskItems}
81+
items={diskItemsForCombobox}
8382
required
8483
control={form.control}
8584
/>

app/forms/vpc-router-route-common.tsx

+1-5
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,18 @@ import type { UseFormReturn } from 'react-hook-form'
1010

1111
import {
1212
usePrefetchedApiQuery,
13-
type Instance,
1413
type RouteDestination,
1514
type RouterRouteCreate,
1615
type RouterRouteUpdate,
1716
type RouteTarget,
18-
type VpcSubnet,
1917
} from '~/api'
2018
import { ComboboxField } from '~/components/form/fields/ComboboxField'
2119
import { DescriptionField } from '~/components/form/fields/DescriptionField'
2220
import { ListboxField } from '~/components/form/fields/ListboxField'
2321
import { NameField } from '~/components/form/fields/NameField'
2422
import { TextField } from '~/components/form/fields/TextField'
2523
import { useVpcRouterSelector } from '~/hooks/use-params'
24+
import { toComboboxItems } from '~/ui/lib/Combobox'
2625
import { Message } from '~/ui/lib/Message'
2726
import { validateIp, validateIpNet } from '~/util/ip'
2827

@@ -94,9 +93,6 @@ const targetValueDescription: Record<RouteTarget['type'], string | undefined> =
9493
const toListboxItems = (mapping: Record<string, string>) =>
9594
Object.entries(mapping).map(([value, label]) => ({ value, label }))
9695

97-
const toComboboxItems = (items: Array<Instance | VpcSubnet>) =>
98-
items.map(({ name }) => ({ value: name, label: name }))
99-
10096
type RouteFormFieldsProps = {
10197
form: UseFormReturn<RouteFormValues>
10298
disabled?: boolean

0 commit comments

Comments
 (0)