Skip to content

Commit 6f83d41

Browse files
Virtual list entry for arbitrary values in comboboxes (#2518)
* Update how allowArbitraryValue functions in comboboxes * Update vertical position, to reduce peek-through of button on lower layer * update test to work with new approach * Update tests, though Safari is still a bit touchy * Select via Enter * Update test * Change UX for allowArbitraryOption * Update test * More test updating * new copy: 'Use custom value:' * gray out "custom:" in virtual item, fix query clearing onClose, e2e test * use key to nuke combobox on host filter value type change * explicit useEffect for clearing query whenever value is cleared --------- Co-authored-by: David Crespo <[email protected]>
1 parent 7dcd41c commit 6f83d41

File tree

3 files changed

+110
-19
lines changed

3 files changed

+110
-19
lines changed

.eslintrc.cjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ module.exports = {
116116
rules: {
117117
'playwright/expect-expect': [
118118
'warn',
119-
{ assertFunctionNames: ['expectVisible', 'expectRowVisible'] },
119+
{ assertFunctionNames: ['expectVisible', 'expectRowVisible', 'expectOptions'] },
120120
],
121121
},
122122
},

app/ui/lib/Combobox.tsx

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
} from '@headlessui/react'
1616
import cn from 'classnames'
1717
import { matchSorter } from 'match-sorter'
18-
import { useId, useState, type ReactNode, type Ref } from 'react'
18+
import { useEffect, useId, useState, type ReactNode, type Ref } from 'react'
1919

2020
import { SelectArrows6Icon } from '@oxide/design-system/icons/react'
2121

@@ -97,6 +97,43 @@ export const Combobox = ({
9797
keys: ['selectedLabel', 'label'],
9898
sorter: (items) => items, // preserve original order, don't sort by match
9999
})
100+
101+
// In the arbitraryValues case, clear the query whenever the value is cleared.
102+
// this is necessary, e.g., for the firewall rules form when you submit the
103+
// targets subform and clear the field. Two possible changes we might want to make
104+
// here if we run into issues:
105+
//
106+
// 1. do it all the time, not just in the arbitraryValues case
107+
// 2. do it on all value changes, not just on clear
108+
//
109+
// Currently, I don't think there are any arbitraryValues=false cases where we
110+
// set the value from outside. There is an arbitraryvalues=true case where we
111+
// setValue to something other than empty string, but we don't need the
112+
// sync because that setValue is done in onInputChange and we already are
113+
// doing setQuery in here along with it.
114+
useEffect(() => {
115+
if (allowArbitraryValues && !selectedItemValue) {
116+
setQuery('')
117+
}
118+
}, [allowArbitraryValues, selectedItemValue])
119+
120+
// If the user has typed in a value that isn't in the list,
121+
// add it as an option if `allowArbitraryValues` is true
122+
if (
123+
allowArbitraryValues &&
124+
query.length > 0 &&
125+
!filteredItems.some((i) => i.selectedLabel === query)
126+
) {
127+
filteredItems.push({
128+
value: query,
129+
label: (
130+
<>
131+
<span className="text-secondary">Custom:</span> {query}
132+
</>
133+
),
134+
selectedLabel: query,
135+
})
136+
}
100137
const zIndex = usePopoverZIndex()
101138
const id = useId()
102139
return (
@@ -106,7 +143,8 @@ export const Combobox = ({
106143
value={selectedItemValue}
107144
// fallback to '' allows clearing field to work
108145
onChange={(val) => onChange(val || '')}
109-
onClose={() => setQuery('')}
146+
// we only want to keep the query on close when arbitrary values are allowed
147+
onClose={allowArbitraryValues ? undefined : () => setQuery('')}
110148
disabled={disabled || isLoading}
111149
immediate
112150
{...props}
@@ -177,18 +215,13 @@ export const Combobox = ({
177215
</ComboboxButton>
178216
)}
179217
</div>
180-
{items.length > 0 && (
218+
{(items.length > 0 || allowArbitraryValues) && (
181219
<ComboboxOptions
182220
anchor="bottom start"
183221
// 14px gap is presumably because it's measured from inside the outline or something
184-
className={`ox-menu pointer-events-auto ${zIndex} relative w-[calc(var(--input-width)+var(--button-width))] overflow-y-auto border !outline-none border-secondary [--anchor-gap:14px] empty:hidden`}
222+
className={`ox-menu pointer-events-auto ${zIndex} relative w-[calc(var(--input-width)+var(--button-width))] overflow-y-auto border !outline-none border-secondary [--anchor-gap:13px] empty:hidden`}
185223
modal={false}
186224
>
187-
{!allowArbitraryValues && filteredItems.length === 0 && (
188-
<ComboboxOption disabled value="no-matches" className="relative">
189-
<div className="ox-menu-item !text-disabled">No items match</div>
190-
</ComboboxOption>
191-
)}
192225
{filteredItems.map((item) => (
193226
<ComboboxOption
194227
key={item.value}
@@ -202,7 +235,7 @@ export const Combobox = ({
202235
// of those rules one by one. Better to rely on the shared classes.
203236
<div
204237
className={cn('ox-menu-item', {
205-
'is-selected': selected,
238+
'is-selected': selected && query !== item.value,
206239
'is-highlighted': focus,
207240
})}
208241
>
@@ -211,6 +244,11 @@ export const Combobox = ({
211244
)}
212245
</ComboboxOption>
213246
))}
247+
{!allowArbitraryValues && filteredItems.length === 0 && (
248+
<ComboboxOption disabled value="no-matches" className="relative">
249+
<div className="ox-menu-item !text-disabled">No items match</div>
250+
</ComboboxOption>
251+
)}
214252
</ComboboxOptions>
215253
)}
216254
</HCombobox>

test/e2e/firewall-rules.e2e.ts

Lines changed: 61 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,9 @@
66
* Copyright Oxide Computer Company
77
*/
88

9-
import {
10-
clickRowAction,
11-
expect,
12-
expectRowVisible,
13-
selectOption,
14-
test,
15-
type Locator,
16-
} from './utils'
9+
import { expect, test, type Locator, type Page } from '@playwright/test'
10+
11+
import { clickRowAction, expectRowVisible, selectOption } from './utils'
1712

1813
const defaultRules = ['allow-internal-inbound', 'allow-ssh', 'allow-icmp']
1914

@@ -53,6 +48,7 @@ test('can create firewall rule', async ({ page }) => {
5348
// add host filter instance "host-filter-instance"
5449
await selectOption(page, 'Host type', 'Instance')
5550
await page.getByRole('combobox', { name: 'Instance name' }).fill('host-filter-instance')
51+
await page.getByText('host-filter-instance').click()
5652
await page.getByRole('button', { name: 'Add host filter' }).click()
5753

5854
// host is added to hosts table
@@ -167,11 +163,13 @@ test('firewall rule form targets table', async ({ page }) => {
167163
// add targets with overlapping names and types to test delete
168164

169165
await targetVpcNameField.fill('abc')
166+
await targetVpcNameField.press('Enter')
170167
await addButton.click()
171168
await expectRowVisible(targets, { Type: 'vpc', Value: 'abc' })
172169

173170
// enter a VPC called 'mock-subnet', even if that doesn't make sense here, to test dropdown later
174171
await targetVpcNameField.fill('mock-subnet')
172+
await targetVpcNameField.press('Enter')
175173
await addButton.click()
176174
await expectRowVisible(targets, { Type: 'vpc', Value: 'mock-subnet' })
177175

@@ -188,6 +186,7 @@ test('firewall rule form targets table', async ({ page }) => {
188186
// now add a subnet by entering text
189187
await selectOption(page, 'Target type', 'VPC subnet')
190188
await subnetNameField.fill('abc')
189+
await page.getByText('abc').first().click()
191190
await addButton.click()
192191
await expectRowVisible(targets, { Type: 'subnet', Value: 'abc' })
193192

@@ -221,6 +220,7 @@ test('firewall rule form target validation', async ({ page }) => {
221220
// Enter invalid VPC name
222221
const vpcNameField = page.getByRole('combobox', { name: 'VPC name' }).first()
223222
await vpcNameField.fill('ab-')
223+
await vpcNameField.press('Enter')
224224
await addButton.click()
225225
await expect(nameError).toBeVisible()
226226

@@ -246,6 +246,7 @@ test('firewall rule form target validation', async ({ page }) => {
246246
await expect(ipError).toBeHidden()
247247
await expect(nameError).toBeHidden()
248248
await vpcNameField.fill('abc')
249+
await page.getByText('abc').click()
249250
await addButton.click()
250251
await expectRowVisible(targets, { Type: 'vpc', Value: 'abc' })
251252

@@ -284,6 +285,7 @@ test('firewall rule form host validation', async ({ page }) => {
284285
// Enter invalid VPC name
285286
const vpcNameField = page.getByRole('combobox', { name: 'VPC name' }).nth(1)
286287
await vpcNameField.fill('ab-')
288+
await vpcNameField.press('Enter')
287289
await addButton.click()
288290
await expect(nameError).toBeVisible()
289291

@@ -309,6 +311,7 @@ test('firewall rule form host validation', async ({ page }) => {
309311
await expect(ipError).toBeHidden()
310312
await expect(nameError).toBeHidden()
311313
await vpcNameField.fill('abc')
314+
await page.getByText('abc').click()
312315
await addButton.click()
313316
await expectRowVisible(hosts, { Type: 'vpc', Value: 'abc' })
314317

@@ -350,10 +353,12 @@ test('firewall rule form hosts table', async ({ page }) => {
350353
// add hosts with overlapping names and types to test delete
351354

352355
await hostFiltersVpcNameField.fill('abc')
356+
await hostFiltersVpcNameField.press('Enter')
353357
await addButton.click()
354358
await expectRowVisible(hosts, { Type: 'vpc', Value: 'abc' })
355359

356360
await hostFiltersVpcNameField.fill('def')
361+
await hostFiltersVpcNameField.press('Enter')
357362
await addButton.click()
358363
await expectRowVisible(hosts, { Type: 'vpc', Value: 'def' })
359364

@@ -364,6 +369,7 @@ test('firewall rule form hosts table', async ({ page }) => {
364369

365370
await selectOption(page, 'Host type', 'VPC subnet')
366371
await page.getByRole('combobox', { name: 'Subnet name' }).fill('abc')
372+
await page.getByRole('combobox', { name: 'Subnet name' }).press('Enter')
367373
await addButton.click()
368374
await expectRowVisible(hosts, { Type: 'subnet', Value: 'abc' })
369375

@@ -427,6 +433,7 @@ test('can update firewall rule', async ({ page }) => {
427433
// add host filter
428434
await selectOption(page, 'Host type', 'VPC subnet')
429435
await page.getByRole('combobox', { name: 'Subnet name' }).fill('edit-filter-subnet')
436+
await page.getByText('edit-filter-subnet').click()
430437
await page.getByRole('button', { name: 'Add host filter' }).click()
431438

432439
// new host is added to hosts table
@@ -561,3 +568,49 @@ test('name conflict error on edit', async ({ page }) => {
561568
await page.getByRole('button', { name: 'Update rule' }).click()
562569
await expectRowVisible(page.getByRole('table'), { Name: 'allow-icmp2', Priority: '37' })
563570
})
571+
572+
async function expectOptions(page: Page, options: string[]) {
573+
const selector = page.getByRole('option')
574+
await expect(selector).toHaveCount(options.length)
575+
for (const option of options) {
576+
await expect(page.getByRole('option', { name: option })).toBeVisible()
577+
}
578+
}
579+
580+
test('arbitrary values combobox', async ({ page }) => {
581+
await page.goto('/projects/mock-project/vpcs/mock-vpc/firewall-rules-new')
582+
583+
// test for bug where we'd persist the d after add and only show 'Custom: d'
584+
const vpcInput = page.getByRole('combobox', { name: 'VPC name' }).first()
585+
await vpcInput.focus()
586+
await expectOptions(page, ['mock-vpc'])
587+
588+
await vpcInput.fill('d')
589+
await expectOptions(page, ['Custom: d'])
590+
591+
await vpcInput.blur()
592+
page.getByRole('button', { name: 'Add target' }).click()
593+
await expect(vpcInput).toHaveValue('')
594+
595+
await vpcInput.focus()
596+
await expectOptions(page, ['mock-vpc']) // bug cause failure here
597+
598+
// test keeping query around on blur
599+
await selectOption(page, 'Target type', 'Instance')
600+
const input = page.getByRole('combobox', { name: 'Instance name' })
601+
602+
await input.focus()
603+
await expectOptions(page, ['db1', 'you-fail', 'not-there-yet'])
604+
605+
await input.fill('d')
606+
await expectOptions(page, ['db1', 'Custom: d'])
607+
608+
await input.blur()
609+
await expect(page.getByRole('option')).toBeHidden()
610+
611+
await expect(input).toHaveValue('d')
612+
await input.focus()
613+
614+
// same options show up after blur (there was a bug around this)
615+
await expectOptions(page, ['db1', 'Custom: d'])
616+
})

0 commit comments

Comments
 (0)