Skip to content
8 changes: 8 additions & 0 deletions app/components/form/fields/ComboboxField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export function ComboboxField<
label = capitalize(name),
required,
onChange,
onInputChange,
allowArbitraryValues,
placeholder,
// Intent is to not show both a placeholder and a description, while still having good defaults; prefer a description to a placeholder
Expand Down Expand Up @@ -78,6 +79,13 @@ export function ComboboxField<
field.onChange(value)
onChange?.(value)
}}
onInputChange={(value) => {
// for arbitrary values, the field tracks each keystroke; for non-arbitrary,
// the underlying selection is preserved while editing — Combobox swaps the
// displayed text back to the selected item's label on close
if (allowArbitraryValues) field.onChange(value)
onInputChange?.(value)
}}
allowArbitraryValues={allowArbitraryValues}
inputRef={field.ref}
transform={transform}
Expand Down
5 changes: 0 additions & 5 deletions app/forms/firewall-rules-common.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -163,9 +163,6 @@ const TargetAndHostFilterSubform = ({
const onTypeChange = () => {
subform.reset({ type: subform.getValues('type'), value: '' })
}
const onInputChange = (value: string) => {
subform.setValue('value', value)
}

const noun = sectionType === 'target' ? 'target' : 'host filter'
const nounTitle = capitalize(noun) + 's'
Expand All @@ -192,7 +189,6 @@ const TargetAndHostFilterSubform = ({
description="Select an option or enter a custom value"
control={subformControl}
onEnter={submitSubform}
onInputChange={onInputChange}
items={items}
allowArbitraryValues
hideOptionalTag
Expand Down Expand Up @@ -498,7 +494,6 @@ const ProtocolFilters = ({ control }: { control: Control<FirewallRuleValues> })
description="Leave blank to match any type"
placeholder=""
allowArbitraryValues
onInputChange={(value) => protocolForm.setValue('icmpType', value)}
items={icmpTypeItems[selectedProtocolType]}
validate={(value) => {
const result = parseIcmpType(value)
Expand Down
39 changes: 28 additions & 11 deletions app/ui/lib/Combobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,11 @@ export const Combobox = ({
inputRef,
transform,
}: ComboboxProps) => {
const [query, setQuery] = useState(selectedItemValue || '')
const [query, setQuery] = useState('')
// True between the first keystroke and the dropdown closing or a new
// selection being made. While editing, the input shows `query` instead of
// the selected item's label, so the user can see what they're typing.
const [isEditing, setIsEditing] = useState(false)
const q = query.toLowerCase().replace(/\s+/g, '')
const filteredItems = matchSorter(items, q, {
keys: ['selectedLabel'],
Expand Down Expand Up @@ -186,9 +190,13 @@ export const Combobox = ({
by="value"
value={selectedItem}
// fallback to '' allows clearing field to work
onChange={(item) => onChange(item?.value ?? '')}
onChange={(item) => {
setIsEditing(false)
onChange(item?.value ?? '')
}}
onClose={() => {
isOpenRef.current = false
setIsEditing(false)
if (!allowArbitraryValues) setQuery('')
}}
disabled={disabled || isLoading}
Expand Down Expand Up @@ -235,21 +243,26 @@ export const Combobox = ({
>
<ComboboxInput
id={`${id}-input`}
// If an option has been selected, display either the selected item's label or value.
// If no option has been selected yet, or the user has started editing the input, display the query.
// We are using value here, as opposed to Headless UI's displayValue, so we can normalize
// the value entered into the input (via the onChange event).
// While the user is editing, show the query so they can see what they
// typed. Otherwise, show the selected item's display value (or the query
// if nothing is selected yet). On blur the dropdown closes, isEditing
// flips to false, and the input reverts to the selection — preserving it.
// We use `value` instead of HUI's `displayValue` so the input value can
// be normalized via the onChange event.
value={
selectedItemValue
? allowArbitraryValues
? selectedItemValue
: (selectedItem?.selectedLabel ?? '')
: query
isEditing
? query
: selectedItemValue
? allowArbitraryValues
? selectedItemValue
: (selectedItem?.selectedLabel ?? '')
: query
}
onChange={(event) => {
const value = transform
? transform(event.target.value)
: event.target.value
setIsEditing(true)
setQuery(value)
onInputChange?.(value)
}}
Expand Down Expand Up @@ -307,6 +320,10 @@ export const Combobox = ({
// of those rules one by one. Better to rely on the shared classes.
<div
className={cn('ox-menu-item', {
// suppress when the user is actively typing the selected
// value (e.g. the synthesized "Custom: <query>" row in
// arbitrary-values mode) so the row doesn't read as
// committed mid-keystroke
'is-selected': selected && query !== option.value && !noMatch,
'is-highlighted': focus && !noMatch,
'text-disabled!': noMatch,
Expand Down
58 changes: 56 additions & 2 deletions test/e2e/instance-create.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -727,6 +727,60 @@ test('Validate CPU and RAM', async ({ page }) => {
await expect(memMsg).toBeVisible()
})

test('preserves silo image selection when editing the input without committing', async ({
page,
}) => {
const instanceName = 'test-instance'

await page.goto('/projects/mock-project/instances-new')
await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName)

const imageSelectCombobox = page.getByRole('combobox', { name: 'Image' })
await imageSelectCombobox.scrollIntoViewIfNeeded()

// Ensure the combobox is visible and has the expected options
await expect(imageSelectCombobox).toHaveValue('')
await imageSelectCombobox.click()
await expect(page.getByRole('option', { name: 'ubuntu-22-04' })).toBeVisible()
await expect(page.getByRole('option', { name: 'ubuntu-20-04' })).toBeVisible()
await expect(page.getByRole('option', { name: 'arch-2022-06-01' })).toBeVisible()

// Filter the combobox for a particular silo image pattern
await imageSelectCombobox.fill('ubuntu')

// Ensure that only show the options that match the filter are visible
await expect(page.getByRole('option', { name: 'ubuntu-22-04' })).toBeVisible()
await expect(page.getByRole('option', { name: 'ubuntu-20-04' })).toBeVisible()
await expect(page.getByRole('option', { name: 'arch-2022-06-01' })).toBeHidden()

// Select an image
await page.getByRole('option', { name: 'ubuntu-22-04' }).click()
await expect(imageSelectCombobox).toHaveValue('ubuntu-22-04')

// Delete four characters from the end to reveal more options
await page.keyboard.press('Backspace')
await page.keyboard.press('Backspace')
await page.keyboard.press('Backspace')
await page.keyboard.press('Backspace')

// While editing, the input reflects the in-progress query and the dropdown
// re-filters accordingly. The underlying selection is preserved until the
// user commits a different option.
await expect(imageSelectCombobox).toHaveValue('ubuntu-2')
await expect(page.getByRole('option', { name: 'ubuntu-22-04' })).toBeVisible()
await expect(page.getByRole('option', { name: 'ubuntu-20-04' })).toBeVisible()
await expect(page.getByRole('option', { name: 'arch-2022-06-01' })).toBeHidden()

// Blur the field by clicking elsewhere; the previously-selected image is
// preserved (rather than cleared) since no new option was committed.
await page.getByRole('textbox', { name: 'Name', exact: true }).click()
await expect(imageSelectCombobox).toHaveValue('ubuntu-22-04')

// Continue with instance creation using the preserved selection
await page.getByRole('button', { name: 'Create instance' }).click()
await expect(page).toHaveURL(`/projects/mock-project/instances/${instanceName}/storage`)
})

test('create instance with IPv6-only networking', async ({ page }) => {
await page.goto('/projects/mock-project/instances-new')

Expand Down Expand Up @@ -1249,7 +1303,7 @@ test('floating IPs are filtered by NIC IP version', async ({ page }) => {
// Verify only IPv4 floating IP is available (rootbeer-float with IP 123.4.56.4)
await expect(page.getByRole('option', { name: 'rootbeer-float' })).toBeVisible()
// IPv6 floating IP should not be in the list
await expect(page.getByRole('option', { name: 'ipv6-float' })).not.toBeVisible()
await expect(page.getByRole('option', { name: 'ipv6-float' })).toBeHidden()

// Close the listbox dropdown first by pressing Escape
await page.keyboard.press('Escape')
Expand All @@ -1274,7 +1328,7 @@ test('floating IPs are filtered by NIC IP version', async ({ page }) => {
// Verify only IPv6 floating IP is available (ipv6-float)
await expect(page.getByRole('option', { name: 'ipv6-float' })).toBeVisible()
// IPv4 floating IP should not be in the list
await expect(page.getByRole('option', { name: 'rootbeer-float' })).not.toBeVisible()
await expect(page.getByRole('option', { name: 'rootbeer-float' })).toBeHidden()

// Close the listbox dropdown first by pressing Escape
await page.keyboard.press('Escape')
Expand Down
44 changes: 44 additions & 0 deletions test/e2e/instance-disks.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,50 @@ test('Attach disk', async ({ page }) => {
await expectVisible(page, ['role=cell[name="disk-3"]'])
})

test('Combobox typing after select', async ({ page }) => {
await page.goto('/projects/mock-project/instances/db1')
await stopInstance(page)

await page.getByRole('button', { name: 'Attach existing disk' }).click()

const combobox = page.getByRole('combobox', { name: 'Disk name' })
await combobox.click()
await page.getByRole('option', { name: 'disk-3' }).click()
await expect(combobox).toHaveValue('disk-3')

// Click out, then click back in.
const dialogTitle = page
.getByRole('dialog')
.getByText('Attach disk', { exact: true })
.first()
await dialogTitle.click()
await combobox.click()

// Typing edits the visible input AND filters the dropdown in lockstep.
await combobox.press('End')
await combobox.pressSequentially('zzz')
await expect(combobox).toHaveValue('disk-3zzz')
await expect(page.getByRole('option', { name: 'disk-3', exact: true })).toBeHidden()
await expect(page.getByRole('option', { name: 'No items match' })).toBeVisible()

// Blurring (closing the dropdown) without picking anything reverts the
// input to the still-selected label. The form value is unchanged.
await dialogTitle.click()
await expect(combobox).toHaveValue('disk-3')

// Backspacing then blurring also reverts: selection is sticky.
await combobox.click()
await combobox.press('End')
await combobox.press('Backspace')
await expect(combobox).toHaveValue('disk-')
await dialogTitle.click()
await expect(combobox).toHaveValue('disk-3')

// Submitting now uses the still-selected value.
await page.getByRole('button', { name: 'Attach disk' }).click()
await expect(page.getByRole('cell', { name: 'disk-3' })).toBeVisible()
})

test('Create disk', async ({ page }) => {
await page.goto('/projects/mock-project/instances/db-stopped')

Expand Down
Loading