Skip to content

Commit f1fce1b

Browse files
Combobox (#2267)
* Putting in example code to see how it works * Continued development; styling * Extracted Combobox * Shift from useState to form control * CSS mostly working, though some redundancies * Remove existing dropdown * Add no-match option, update mock API limit * add placeholder * Update pagination test to use Snapshots * Update tests * Add ErrorMessage * share props type more directly between Combobox and ComboboxField * increase gap * fix double border on options * don't match on value, only on label (visible text) * fix clearing field behavior, use matchSorter to get fuzzy matching * use data attr instead of render prop for open * cut a line out of the diff * clean up listbox option too * Add Combobox to disk tab in instance create form * Add max-width container to ComboboxField directly * Smarter zIndex; also migrate IP Pool page to Combobox * Migrate SiloIpPoolsTab to Combobox * Cleanup unnecessary field modifications * more cleanup * Remove extra line from diff * Change remaining 'simple' ListboxFields to ComboboxFields * Remove prop accidentally included in last commit * Update test with combobox * not yet handling differing labels and values * Test fixes * Remove unneeded comment change --------- Co-authored-by: David Crespo <[email protected]>
1 parent c52d684 commit f1fce1b

20 files changed

+321
-58
lines changed
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/*
2+
* This Source Code Form is subject to the terms of the Mozilla Public
3+
* License, v. 2.0. If a copy of the MPL was not distributed with this
4+
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
5+
*
6+
* Copyright Oxide Computer Company
7+
*/
8+
9+
import {
10+
useController,
11+
type Control,
12+
type FieldPath,
13+
type FieldValues,
14+
} from 'react-hook-form'
15+
16+
import { Combobox, type ComboboxBaseProps } from '~/ui/lib/Combobox'
17+
import { capitalize } from '~/util/str'
18+
19+
import { ErrorMessage } from './ErrorMessage'
20+
21+
export type ComboboxFieldProps<
22+
TFieldValues extends FieldValues,
23+
TName extends FieldPath<TFieldValues>,
24+
> = {
25+
name: TName
26+
control: Control<TFieldValues>
27+
onChange?: (value: string | null | undefined) => void
28+
disabled?: boolean
29+
} & ComboboxBaseProps
30+
31+
export function ComboboxField<
32+
TFieldValues extends FieldValues,
33+
TName extends FieldPath<TFieldValues>,
34+
// TODO: constrain TValue to extend string
35+
>({
36+
control,
37+
name,
38+
label = capitalize(name),
39+
required,
40+
onChange,
41+
disabled,
42+
...props
43+
}: ComboboxFieldProps<TFieldValues, TName>) {
44+
const { field, fieldState } = useController({ name, control, rules: { required } })
45+
return (
46+
<div className="max-w-lg">
47+
<Combobox
48+
isDisabled={disabled}
49+
label={label}
50+
required={required}
51+
selected={field.value || null}
52+
hasError={fieldState.error !== undefined}
53+
onChange={(value) => {
54+
field.onChange(value)
55+
onChange?.(value)
56+
}}
57+
{...props}
58+
/>
59+
<ErrorMessage error={fieldState.error} label={label} />
60+
</div>
61+
)
62+
}

app/components/form/fields/ImageSelectField.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ 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).
3537
<ListboxField
3638
disabled={disabled}
3739
control={control}

app/components/form/fields/SubnetListbox.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,12 @@ import { useApiQuery } from '@oxide/api'
1111

1212
import { useProjectSelector } from '~/hooks'
1313

14-
import { ListboxField, type ListboxFieldProps } from './ListboxField'
14+
import { ComboboxField, type ComboboxFieldProps } from './ComboboxField'
1515

1616
type SubnetListboxProps<
1717
TFieldValues extends FieldValues,
1818
TName extends FieldPath<TFieldValues>,
19-
> = Omit<ListboxFieldProps<TFieldValues, TName>, 'items'> & {
19+
> = Omit<ComboboxFieldProps<TFieldValues, TName>, 'items'> & {
2020
vpcNameField: FieldPath<TFieldValues>
2121
}
2222

@@ -47,7 +47,7 @@ export function SubnetListbox<
4747
).data?.items || []
4848

4949
return (
50-
<ListboxField
50+
<ComboboxField
5151
{...fieldProps}
5252
items={subnets.map(({ name }) => ({ value: name, label: name }))}
5353
disabled={!vpcExists}

app/forms/disk-attach.tsx

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88
import { useApiQuery, type ApiError } from '@oxide/api'
99

10-
import { ListboxField } from '~/components/form/fields/ListboxField'
10+
import { ComboboxField } from '~/components/form/fields/ComboboxField'
1111
import { SideModalForm } from '~/components/form/SideModalForm'
1212
import { useForm, useProjectSelector } from '~/hooks'
1313

@@ -33,14 +33,11 @@ export function AttachDiskSideModalForm({
3333
loading,
3434
submitError = null,
3535
}: AttachDiskProps) {
36-
const projectSelector = useProjectSelector()
36+
const { project } = useProjectSelector()
3737

38-
// TODO: loading state? because this fires when the modal opens and not when
39-
// they focus the combobox, it will almost always be done by the time they
40-
// click in
41-
// TODO: error handling
38+
const { data } = useApiQuery('diskList', { query: { project, limit: 1000 } })
4239
const detachedDisks =
43-
useApiQuery('diskList', { query: projectSelector }).data?.items.filter(
40+
data?.items.filter(
4441
(d) => d.state.state === 'detached' && !diskNamesToExclude.includes(d.name)
4542
) || []
4643

@@ -51,14 +48,15 @@ export function AttachDiskSideModalForm({
5148
form={form}
5249
formType="create"
5350
resourceName="disk"
54-
title="Attach Disk"
51+
title="Attach disk"
5552
onSubmit={onSubmit}
5653
loading={loading}
5754
submitError={submitError}
5855
onDismiss={onDismiss}
5956
>
60-
<ListboxField
57+
<ComboboxField
6158
label="Disk name"
59+
placeholder="Select a disk"
6260
name="name"
6361
items={detachedDisks.map(({ name }) => ({ value: name, label: name }))}
6462
required

app/forms/instance-create.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
import { AccordionItem } from '~/components/AccordionItem'
3838
import { DocsPopover } from '~/components/DocsPopover'
3939
import { CheckboxField } from '~/components/form/fields/CheckboxField'
40+
import { ComboboxField } from '~/components/form/fields/ComboboxField'
4041
import { DescriptionField } from '~/components/form/fields/DescriptionField'
4142
import { DiskSizeField } from '~/components/form/fields/DiskSizeField'
4243
import {
@@ -45,7 +46,6 @@ import {
4546
} from '~/components/form/fields/DisksTableField'
4647
import { FileField } from '~/components/form/fields/FileField'
4748
import { BootDiskImageSelectField as ImageSelectField } from '~/components/form/fields/ImageSelectField'
48-
import { ListboxField } from '~/components/form/fields/ListboxField'
4949
import { NameField } from '~/components/form/fields/NameField'
5050
import { NetworkInterfaceField } from '~/components/form/fields/NetworkInterfaceField'
5151
import { NumberField } from '~/components/form/fields/NumberField'
@@ -550,13 +550,14 @@ export function CreateInstanceForm() {
550550
/>
551551
</div>
552552
) : (
553-
<ListboxField
553+
<ComboboxField
554554
label="Disk"
555555
name="diskSource"
556556
description="Existing disks that are not attached to an instance"
557557
items={disks}
558558
required
559559
control={control}
560+
placeholder="Select a disk"
560561
/>
561562
)}
562563
</Tabs.Content>

app/forms/network-interface-create.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ import { useMemo } from 'react'
99

1010
import { useApiQuery, type ApiError, type InstanceNetworkInterfaceCreate } from '@oxide/api'
1111

12+
import { ComboboxField } from '~/components/form/fields/ComboboxField'
1213
import { DescriptionField } from '~/components/form/fields/DescriptionField'
13-
import { ListboxField } from '~/components/form/fields/ListboxField'
1414
import { NameField } from '~/components/form/fields/NameField'
1515
import { SubnetListbox } from '~/components/form/fields/SubnetListbox'
1616
import { TextField } from '~/components/form/fields/TextField'
@@ -65,7 +65,7 @@ export function CreateNetworkInterfaceForm({
6565
<DescriptionField name="description" control={form.control} />
6666
<FormDivider />
6767

68-
<ListboxField
68+
<ComboboxField
6969
name="vpcName"
7070
label="VPC"
7171
items={vpcs.map(({ name }) => ({ label: name, value: name }))}

app/forms/snapshot-create.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ import {
1616
type SnapshotCreate,
1717
} from '@oxide/api'
1818

19+
import { ComboboxField } from '~/components/form/fields/ComboboxField'
1920
import { DescriptionField } from '~/components/form/fields/DescriptionField'
20-
import { ListboxField } from '~/components/form/fields/ListboxField'
2121
import { NameField } from '~/components/form/fields/NameField'
2222
import { SideModalForm } from '~/components/form/SideModalForm'
2323
import { useForm, useProjectSelector } from '~/hooks'
@@ -73,7 +73,13 @@ export function CreateSnapshotSideModalForm() {
7373
>
7474
<NameField name="name" control={form.control} />
7575
<DescriptionField name="description" control={form.control} />
76-
<ListboxField name="disk" items={diskItems} required control={form.control} />
76+
<ComboboxField
77+
label="Disk"
78+
name="disk"
79+
items={diskItems}
80+
required
81+
control={form.control}
82+
/>
7783
</SideModalForm>
7884
)
7985
}

app/pages/system/SiloImagesPage.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
import { Images16Icon, Images24Icon } from '@oxide/design-system/icons/react'
2121

2222
import { DocsPopover } from '~/components/DocsPopover'
23+
import { ComboboxField } from '~/components/form/fields/ComboboxField'
2324
import { toListboxItem } from '~/components/form/fields/ImageSelectField'
2425
import { ListboxField } from '~/components/form/fields/ListboxField'
2526
import { useForm } from '~/hooks'
@@ -167,7 +168,7 @@ const PromoteImageModal = ({ onDismiss }: { onDismiss: () => void }) => {
167168
<Modal.Body>
168169
<Modal.Section>
169170
<form autoComplete="off" onSubmit={handleSubmit(onSubmit)} className="space-y-4">
170-
<ListboxField
171+
<ComboboxField
171172
placeholder="Filter images by project"
172173
name="project"
173174
label="Project"
@@ -268,7 +269,7 @@ const DemoteImageModal = ({
268269
content="Once an image has been demoted it is only visible to the project that it is demoted into. This will not affect disks already created with the image."
269270
/>
270271

271-
<ListboxField
272+
<ComboboxField
272273
placeholder="Select project for image"
273274
name="project"
274275
label="Project"

app/pages/system/networking/IpPoolPage.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import { IpGlobal16Icon, IpGlobal24Icon } from '@oxide/design-system/icons/react
2424

2525
import { CapacityBar } from '~/components/CapacityBar'
2626
import { DocsPopover } from '~/components/DocsPopover'
27-
import { ListboxField } from '~/components/form/fields/ListboxField'
27+
import { ComboboxField } from '~/components/form/fields/ComboboxField'
2828
import { HL } from '~/components/HL'
2929
import { QueryParamTabs } from '~/components/QueryParamTabs'
3030
import { getIpPoolSelector, useForm, useIpPoolSelector } from '~/hooks'
@@ -368,7 +368,7 @@ function LinkSiloModal({ onDismiss }: { onDismiss: () => void }) {
368368
content="Users in the selected silo will be able to allocate IPs from this pool."
369369
/>
370370

371-
<ListboxField
371+
<ComboboxField
372372
placeholder="Select silo"
373373
name="silo"
374374
label="Silo"

app/pages/system/silos/SiloIpPoolsTab.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { useCallback, useMemo, useState } from 'react'
1212
import { useApiMutation, useApiQuery, useApiQueryClient, type SiloIpPool } from '@oxide/api'
1313
import { Networking24Icon } from '@oxide/design-system/icons/react'
1414

15-
import { ListboxField } from '~/components/form/fields/ListboxField'
15+
import { ComboboxField } from '~/components/form/fields/ComboboxField'
1616
import { HL } from '~/components/HL'
1717
import { useForm, useSiloSelector } from '~/hooks'
1818
import { confirmAction } from '~/stores/confirm-action'
@@ -235,7 +235,7 @@ function LinkPoolModal({ onDismiss }: { onDismiss: () => void }) {
235235
content="Users in this silo will be able to allocate IPs from the selected pool."
236236
/>
237237

238-
<ListboxField
238+
<ComboboxField
239239
placeholder="Select pool"
240240
name="pool"
241241
label="IP pool"

0 commit comments

Comments
 (0)