Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
60b0cf6
Putting in example code to see how it works
charliepark May 23, 2024
24b734a
Merge branch 'main' into stub_of_new_combobox
charliepark May 28, 2024
44fd9a6
Continued development; styling
charliepark May 31, 2024
857a8c2
Extracted Combobox
charliepark May 31, 2024
ff1818a
Shift from useState to form control
charliepark Jun 3, 2024
af7262b
CSS mostly working, though some redundancies
charliepark Jun 3, 2024
9b0f08e
Remove existing dropdown
charliepark Jun 3, 2024
22ce73b
Add no-match option, update mock API limit
charliepark Jun 3, 2024
4944b4c
add placeholder
charliepark Jun 3, 2024
22aec5f
Update pagination test to use Snapshots
charliepark Jun 3, 2024
b5043e2
Update tests
charliepark Jun 3, 2024
201ab6a
Add ErrorMessage
charliepark Jun 4, 2024
55a8f83
Merge branch 'main' into stub_of_new_combobox
david-crespo Jun 6, 2024
cbc55bd
share props type more directly between Combobox and ComboboxField
david-crespo Jun 6, 2024
8d9a7db
increase gap
david-crespo Jun 6, 2024
3249dcc
fix double border on options
david-crespo Jun 6, 2024
032c891
don't match on value, only on label (visible text)
david-crespo Jun 6, 2024
ef8ce80
fix clearing field behavior, use matchSorter to get fuzzy matching
david-crespo Jun 6, 2024
64a2598
use data attr instead of render prop for open
david-crespo Jun 6, 2024
37c4978
cut a line out of the diff
david-crespo Jun 6, 2024
63c4389
clean up listbox option too
david-crespo Jun 6, 2024
f22a958
Merge branch 'main' into stub_of_new_combobox
charliepark Jun 17, 2024
e1a7030
Add Combobox to disk tab in instance create form
charliepark Jun 18, 2024
dfa3648
Add max-width container to ComboboxField directly
charliepark Jun 18, 2024
372d8f2
Smarter zIndex; also migrate IP Pool page to Combobox
charliepark Jun 18, 2024
f1f546f
Migrate SiloIpPoolsTab to Combobox
charliepark Jun 18, 2024
39b1011
Cleanup unnecessary field modifications
charliepark Jun 18, 2024
312193d
more cleanup
charliepark Jun 18, 2024
33fffa0
Remove extra line from diff
charliepark Jun 20, 2024
644c4ba
Change remaining 'simple' ListboxFields to ComboboxFields
charliepark Jun 20, 2024
e04a183
Remove prop accidentally included in last commit
charliepark Jun 20, 2024
3b36d25
Update test with combobox
charliepark Jun 20, 2024
341505c
not yet handling differing labels and values
charliepark Jun 20, 2024
37f2a21
Test fixes
charliepark Jun 20, 2024
2041764
Remove unneeded comment change
charliepark Jun 20, 2024
789b92b
Merge branch 'main' into stub_of_new_combobox
charliepark Jun 21, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions app/components/form/fields/ComboboxField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright Oxide Computer Company
*/

import {
useController,
type Control,
type FieldPath,
type FieldValues,
} from 'react-hook-form'

import { Combobox, type ComboboxBaseProps } from '~/ui/lib/Combobox'
import { capitalize } from '~/util/str'

import { ErrorMessage } from './ErrorMessage'

export type ComboboxFieldProps<
TFieldValues extends FieldValues,
TName extends FieldPath<TFieldValues>,
> = {
name: TName
control: Control<TFieldValues>
onChange?: (value: string | null | undefined) => void
disabled?: boolean
} & ComboboxBaseProps

export function ComboboxField<
TFieldValues extends FieldValues,
TName extends FieldPath<TFieldValues>,
// TODO: constrain TValue to extend string
>({
control,
name,
label = capitalize(name),
required,
onChange,
disabled,
...props
}: ComboboxFieldProps<TFieldValues, TName>) {
const { field, fieldState } = useController({ name, control, rules: { required } })
return (
<div className="max-w-lg">
<Combobox
isDisabled={disabled}
label={label}
required={required}
selected={field.value || null}
hasError={fieldState.error !== undefined}
onChange={(value) => {
field.onChange(value)
onChange?.(value)
}}
{...props}
/>
<ErrorMessage error={fieldState.error} label={label} />
</div>
)
}
2 changes: 2 additions & 0 deletions app/components/form/fields/ImageSelectField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ export function BootDiskImageSelectField({
}: ImageSelectFieldProps) {
const diskSizeField = useController({ control, name: 'bootDiskSize' }).field
return (
// This should be migrated to a `ComboboxField` (and with a `toComboboxItem`), once
// we have a combobox that supports more elaborate labels (beyond just strings).
<ListboxField
disabled={disabled}
control={control}
Expand Down
6 changes: 3 additions & 3 deletions app/components/form/fields/SubnetListbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@ import { useApiQuery } from '@oxide/api'

import { useProjectSelector } from '~/hooks'

import { ListboxField, type ListboxFieldProps } from './ListboxField'
import { ComboboxField, type ComboboxFieldProps } from './ComboboxField'

type SubnetListboxProps<
TFieldValues extends FieldValues,
TName extends FieldPath<TFieldValues>,
> = Omit<ListboxFieldProps<TFieldValues, TName>, 'items'> & {
> = Omit<ComboboxFieldProps<TFieldValues, TName>, 'items'> & {
vpcNameField: FieldPath<TFieldValues>
}

Expand Down Expand Up @@ -47,7 +47,7 @@ export function SubnetListbox<
).data?.items || []

return (
<ListboxField
<ComboboxField
{...fieldProps}
items={subnets.map(({ name }) => ({ value: name, label: name }))}
disabled={!vpcExists}
Expand Down
16 changes: 7 additions & 9 deletions app/forms/disk-attach.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*/
import { useApiQuery, type ApiError } from '@oxide/api'

import { ListboxField } from '~/components/form/fields/ListboxField'
import { ComboboxField } from '~/components/form/fields/ComboboxField'
import { SideModalForm } from '~/components/form/SideModalForm'
import { useForm, useProjectSelector } from '~/hooks'

Expand All @@ -33,14 +33,11 @@ export function AttachDiskSideModalForm({
loading,
submitError = null,
}: AttachDiskProps) {
const projectSelector = useProjectSelector()
const { project } = useProjectSelector()

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

Expand All @@ -51,14 +48,15 @@ export function AttachDiskSideModalForm({
form={form}
formType="create"
resourceName="disk"
title="Attach Disk"
title="Attach disk"
onSubmit={onSubmit}
loading={loading}
submitError={submitError}
onDismiss={onDismiss}
>
<ListboxField
<ComboboxField
label="Disk name"
placeholder="Select a disk"
name="name"
items={detachedDisks.map(({ name }) => ({ value: name, label: name }))}
required
Expand Down
5 changes: 3 additions & 2 deletions app/forms/instance-create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
import { AccordionItem } from '~/components/AccordionItem'
import { DocsPopover } from '~/components/DocsPopover'
import { CheckboxField } from '~/components/form/fields/CheckboxField'
import { ComboboxField } from '~/components/form/fields/ComboboxField'
import { DescriptionField } from '~/components/form/fields/DescriptionField'
import { DiskSizeField } from '~/components/form/fields/DiskSizeField'
import {
Expand All @@ -45,7 +46,6 @@ import {
} from '~/components/form/fields/DisksTableField'
import { FileField } from '~/components/form/fields/FileField'
import { BootDiskImageSelectField as ImageSelectField } from '~/components/form/fields/ImageSelectField'
import { ListboxField } from '~/components/form/fields/ListboxField'
import { NameField } from '~/components/form/fields/NameField'
import { NetworkInterfaceField } from '~/components/form/fields/NetworkInterfaceField'
import { NumberField } from '~/components/form/fields/NumberField'
Expand Down Expand Up @@ -550,13 +550,14 @@ export function CreateInstanceForm() {
/>
</div>
) : (
<ListboxField
<ComboboxField
label="Disk"
name="diskSource"
description="Existing disks that are not attached to an instance"
items={disks}
required
control={control}
placeholder="Select a disk"
/>
)}
</Tabs.Content>
Expand Down
4 changes: 2 additions & 2 deletions app/forms/network-interface-create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import { useMemo } from 'react'

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

import { ComboboxField } from '~/components/form/fields/ComboboxField'
import { DescriptionField } from '~/components/form/fields/DescriptionField'
import { ListboxField } from '~/components/form/fields/ListboxField'
import { NameField } from '~/components/form/fields/NameField'
import { SubnetListbox } from '~/components/form/fields/SubnetListbox'
import { TextField } from '~/components/form/fields/TextField'
Expand Down Expand Up @@ -65,7 +65,7 @@ export function CreateNetworkInterfaceForm({
<DescriptionField name="description" control={form.control} />
<FormDivider />

<ListboxField
<ComboboxField
name="vpcName"
label="VPC"
items={vpcs.map(({ name }) => ({ label: name, value: name }))}
Expand Down
10 changes: 8 additions & 2 deletions app/forms/snapshot-create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ import {
type SnapshotCreate,
} from '@oxide/api'

import { ComboboxField } from '~/components/form/fields/ComboboxField'
import { DescriptionField } from '~/components/form/fields/DescriptionField'
import { ListboxField } from '~/components/form/fields/ListboxField'
import { NameField } from '~/components/form/fields/NameField'
import { SideModalForm } from '~/components/form/SideModalForm'
import { useForm, useProjectSelector } from '~/hooks'
Expand Down Expand Up @@ -73,7 +73,13 @@ export function CreateSnapshotSideModalForm() {
>
<NameField name="name" control={form.control} />
<DescriptionField name="description" control={form.control} />
<ListboxField name="disk" items={diskItems} required control={form.control} />
<ComboboxField
label="Disk"
name="disk"
items={diskItems}
required
control={form.control}
/>
</SideModalForm>
)
}
5 changes: 3 additions & 2 deletions app/pages/system/SiloImagesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
import { Images16Icon, Images24Icon } from '@oxide/design-system/icons/react'

import { DocsPopover } from '~/components/DocsPopover'
import { ComboboxField } from '~/components/form/fields/ComboboxField'
import { toListboxItem } from '~/components/form/fields/ImageSelectField'
import { ListboxField } from '~/components/form/fields/ListboxField'
import { useForm } from '~/hooks'
Expand Down Expand Up @@ -167,7 +168,7 @@ const PromoteImageModal = ({ onDismiss }: { onDismiss: () => void }) => {
<Modal.Body>
<Modal.Section>
<form autoComplete="off" onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<ListboxField
<ComboboxField
placeholder="Filter images by project"
name="project"
label="Project"
Expand Down Expand Up @@ -268,7 +269,7 @@ const DemoteImageModal = ({
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."
/>

<ListboxField
<ComboboxField
placeholder="Select project for image"
name="project"
label="Project"
Expand Down
4 changes: 2 additions & 2 deletions app/pages/system/networking/IpPoolPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import { IpGlobal16Icon, IpGlobal24Icon } from '@oxide/design-system/icons/react

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

<ListboxField
<ComboboxField
placeholder="Select silo"
name="silo"
label="Silo"
Expand Down
4 changes: 2 additions & 2 deletions app/pages/system/silos/SiloIpPoolsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { useCallback, useMemo, useState } from 'react'
import { useApiMutation, useApiQuery, useApiQueryClient, type SiloIpPool } from '@oxide/api'
import { Networking24Icon } from '@oxide/design-system/icons/react'

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

<ListboxField
<ComboboxField
placeholder="Select pool"
name="pool"
label="IP pool"
Expand Down
Loading