feat: add searchable filter field combobox
This commit is contained in:
@@ -27,6 +27,12 @@ describe('FilterBuilder value inputs', () => {
|
||||
)
|
||||
}
|
||||
|
||||
function openFieldCombobox() {
|
||||
const input = screen.getByTestId('filter-field-combobox-input')
|
||||
fireEvent.focus(input)
|
||||
return input
|
||||
}
|
||||
|
||||
it('renders a plain text input for text operators', () => {
|
||||
renderBuilder()
|
||||
expect(screen.getByTestId('filter-value-input')).toBeInTheDocument()
|
||||
@@ -129,7 +135,74 @@ describe('FilterBuilder value inputs', () => {
|
||||
expect(screen.getByTestId('date-picker-trigger')).toHaveAttribute('title', 'Mar 28, 2026')
|
||||
})
|
||||
|
||||
it('shows body field in field dropdown separated from property fields', () => {
|
||||
it('filters the field combobox as the user types', () => {
|
||||
render(
|
||||
<FilterBuilder
|
||||
group={{ all: [{ field: 'title', op: 'contains', value: '' }] }}
|
||||
onChange={vi.fn()}
|
||||
availableFields={['type', 'status', 'title', 'Owner']}
|
||||
/>,
|
||||
)
|
||||
|
||||
const input = openFieldCombobox()
|
||||
fireEvent.change(input, { target: { value: 'tit' } })
|
||||
|
||||
expect(screen.getByTestId('filter-field-option-title')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('filter-field-option-status')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('filter-field-option-Owner')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('supports keyboard navigation and Enter selection in the field combobox', () => {
|
||||
render(
|
||||
<FilterBuilder
|
||||
group={{ all: [{ field: 'title', op: 'contains', value: '' }] }}
|
||||
onChange={onChange}
|
||||
availableFields={['title', 'status', 'Owner']}
|
||||
/>,
|
||||
)
|
||||
|
||||
const input = openFieldCombobox()
|
||||
fireEvent.change(input, { target: { value: 'sta' } })
|
||||
fireEvent.keyDown(input, { key: 'ArrowDown' })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
all: [{ field: 'status', op: 'contains', value: '' }],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('shows an empty state when no field matches the search text', () => {
|
||||
renderBuilder()
|
||||
|
||||
const input = openFieldCombobox()
|
||||
fireEvent.change(input, { target: { value: 'zzz' } })
|
||||
|
||||
expect(screen.getByTestId('filter-field-combobox-empty')).toHaveTextContent('No results')
|
||||
})
|
||||
|
||||
it('reopens the field combobox with the selected field prefilled and all options visible', () => {
|
||||
render(
|
||||
<FilterBuilder
|
||||
group={{ all: [{ field: 'status', op: 'contains', value: '' }] }}
|
||||
onChange={vi.fn()}
|
||||
availableFields={['type', 'status', 'title']}
|
||||
/>,
|
||||
)
|
||||
|
||||
const input = openFieldCombobox()
|
||||
|
||||
expect(input).toHaveValue('status')
|
||||
expect(screen.getByTestId('filter-field-option-type')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('filter-field-option-status')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('filter-field-option-title')).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(input, { key: 'Escape' })
|
||||
expect(screen.queryByTestId('filter-field-combobox-options')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows body field in the searchable field combobox', () => {
|
||||
render(
|
||||
<FilterBuilder
|
||||
group={{ all: [{ field: 'body', op: 'contains', value: 'test' }] }}
|
||||
@@ -138,6 +211,8 @@ describe('FilterBuilder value inputs', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('body')).toBeInTheDocument()
|
||||
openFieldCombobox()
|
||||
|
||||
expect(screen.getByTestId('filter-field-option-body')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,10 +4,11 @@ import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Calendar } from '@/components/ui/calendar'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { FilterCondition, FilterOp, FilterGroup, FilterNode } from '../types'
|
||||
import { parseDateFilterInput } from '../utils/filterDates'
|
||||
import { FilterFieldCombobox } from './FilterFieldCombobox'
|
||||
|
||||
const OPERATORS: { value: FilterOp; label: string }[] = [
|
||||
{ value: 'equals', label: 'equals' },
|
||||
@@ -58,42 +59,6 @@ function setGroupChildren(mode: 'all' | 'any', children: FilterNode[]): FilterGr
|
||||
return mode === 'all' ? { all: children } : { any: children }
|
||||
}
|
||||
|
||||
const CONTENT_FIELDS = new Set(['body'])
|
||||
|
||||
function FieldSelect({ value, fields, onChange }: {
|
||||
value: string
|
||||
fields: string[]
|
||||
onChange: (v: string) => void
|
||||
}) {
|
||||
const isCustom = value !== '' && !fields.includes(value)
|
||||
const propertyFields = fields.filter(f => !CONTENT_FIELDS.has(f))
|
||||
const contentFields = fields.filter(f => CONTENT_FIELDS.has(f))
|
||||
return (
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="h-8 min-w-[100px] flex-1 gap-1 border-input bg-background px-2 text-sm shadow-none"
|
||||
>
|
||||
<SelectValue placeholder="field" />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
{isCustom && <SelectItem value={value}>{value}</SelectItem>}
|
||||
{propertyFields.map((f) => (
|
||||
<SelectItem key={f} value={f}>{f}</SelectItem>
|
||||
))}
|
||||
{contentFields.length > 0 && (
|
||||
<>
|
||||
<SelectSeparator />
|
||||
{contentFields.map((f) => (
|
||||
<SelectItem key={f} value={f}>{f}</SelectItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
function OperatorSelect({ value, onChange }: {
|
||||
value: FilterOp
|
||||
onChange: (v: FilterOp) => void
|
||||
@@ -217,7 +182,7 @@ function FilterRow({ condition, fields, onUpdate, onRemove }: {
|
||||
const invalidRegex = regexSupported && hasInvalidRegex(String(condition.value ?? ''), regexEnabled)
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<FieldSelect
|
||||
<FilterFieldCombobox
|
||||
value={condition.field}
|
||||
fields={fields}
|
||||
onChange={(v) => onUpdate({ ...condition, field: v })}
|
||||
|
||||
215
src/components/FilterFieldCombobox.tsx
Normal file
215
src/components/FilterFieldCombobox.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import { CaretUpDown } from '@phosphor-icons/react'
|
||||
import { useId, useMemo, useRef, useState } from 'react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const CONTENT_FIELDS = new Set(['body'])
|
||||
|
||||
interface FilterFieldComboboxProps {
|
||||
value: string
|
||||
fields: string[]
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
interface FieldGroup {
|
||||
key: 'property' | 'content'
|
||||
label: string
|
||||
options: string[]
|
||||
}
|
||||
|
||||
function normalizeFieldQuery(query: string): string {
|
||||
return query.trim().toLowerCase()
|
||||
}
|
||||
|
||||
function buildFieldGroups(fields: string[], currentValue: string, query: string): FieldGroup[] {
|
||||
const allFields = currentValue !== '' && !fields.includes(currentValue)
|
||||
? [currentValue, ...fields]
|
||||
: fields
|
||||
const normalized = normalizeFieldQuery(query)
|
||||
const matches = (field: string) => normalized === '' || field.toLowerCase().includes(normalized)
|
||||
const propertyOptions = allFields.filter((field) => !CONTENT_FIELDS.has(field) && matches(field))
|
||||
const contentOptions = allFields.filter((field) => CONTENT_FIELDS.has(field) && matches(field))
|
||||
const groups: FieldGroup[] = []
|
||||
|
||||
if (propertyOptions.length > 0) groups.push({ key: 'property', label: 'Properties', options: propertyOptions })
|
||||
if (contentOptions.length > 0) groups.push({ key: 'content', label: 'Content', options: contentOptions })
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
function flattenGroups(groups: FieldGroup[]): string[] {
|
||||
return groups.flatMap((group) => group.options)
|
||||
}
|
||||
|
||||
function initialHighlightIndex(options: string[], currentValue: string): number {
|
||||
if (options.length === 0) return -1
|
||||
const currentIndex = options.indexOf(currentValue)
|
||||
return currentIndex >= 0 ? currentIndex : 0
|
||||
}
|
||||
|
||||
function optionTestId(field: string): string {
|
||||
return `filter-field-option-${field.replace(/[^a-z0-9_-]+/gi, '-')}`
|
||||
}
|
||||
|
||||
export function FilterFieldCombobox({ value, fields, onChange }: FilterFieldComboboxProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState(value)
|
||||
const [hasTyped, setHasTyped] = useState(false)
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(-1)
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const listboxId = useId()
|
||||
const effectiveQuery = hasTyped ? query : ''
|
||||
const fieldGroups = useMemo(() => buildFieldGroups(fields, value, effectiveQuery), [fields, value, effectiveQuery])
|
||||
const options = useMemo(() => flattenGroups(fieldGroups), [fieldGroups])
|
||||
|
||||
const resetToCurrentValue = () => {
|
||||
setQuery(value)
|
||||
setHasTyped(false)
|
||||
setHighlightedIndex(initialHighlightIndex(flattenGroups(buildFieldGroups(fields, value, '')), value))
|
||||
}
|
||||
|
||||
const openCombobox = () => {
|
||||
resetToCurrentValue()
|
||||
setOpen(true)
|
||||
requestAnimationFrame(() => inputRef.current?.select())
|
||||
}
|
||||
|
||||
const closeCombobox = () => {
|
||||
setOpen(false)
|
||||
resetToCurrentValue()
|
||||
}
|
||||
|
||||
const selectOption = (nextValue: string) => {
|
||||
onChange(nextValue)
|
||||
setQuery(nextValue)
|
||||
setHasTyped(false)
|
||||
setHighlightedIndex(-1)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className="relative flex-1 min-w-[160px]"
|
||||
onBlur={(event) => {
|
||||
if (rootRef.current?.contains(event.relatedTarget as Node | null)) return
|
||||
closeCombobox()
|
||||
}}
|
||||
data-testid="filter-field-combobox"
|
||||
>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={open ? query : value}
|
||||
onFocus={() => openCombobox()}
|
||||
onChange={(event) => {
|
||||
const nextQuery = event.target.value
|
||||
const nextGroups = buildFieldGroups(fields, value, nextQuery)
|
||||
const nextOptions = flattenGroups(nextGroups)
|
||||
setOpen(true)
|
||||
setQuery(nextQuery)
|
||||
setHasTyped(true)
|
||||
setHighlightedIndex(nextOptions.length > 0 ? 0 : -1)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
if (!open) {
|
||||
openCombobox()
|
||||
return
|
||||
}
|
||||
if (options.length === 0) return
|
||||
setHighlightedIndex((current) => {
|
||||
if (current < 0) return 0
|
||||
return (current + 1) % options.length
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
if (!open) {
|
||||
openCombobox()
|
||||
return
|
||||
}
|
||||
if (options.length === 0) return
|
||||
setHighlightedIndex((current) => {
|
||||
if (current < 0) return options.length - 1
|
||||
return (current - 1 + options.length) % options.length
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
if (!open || highlightedIndex < 0 || options[highlightedIndex] === undefined) return
|
||||
event.preventDefault()
|
||||
selectOption(options[highlightedIndex])
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
if (!open) return
|
||||
event.preventDefault()
|
||||
closeCombobox()
|
||||
}
|
||||
}}
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
aria-controls={listboxId}
|
||||
aria-expanded={open}
|
||||
aria-activedescendant={highlightedIndex >= 0 ? `${listboxId}-option-${highlightedIndex}` : undefined}
|
||||
className="h-8 pr-7 text-sm"
|
||||
data-testid="filter-field-combobox-input"
|
||||
/>
|
||||
<CaretUpDown
|
||||
size={14}
|
||||
className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{open && (
|
||||
<div
|
||||
id={listboxId}
|
||||
role="listbox"
|
||||
className="absolute left-0 top-full z-50 mt-1 max-h-60 w-full min-w-[220px] overflow-y-auto rounded-md border border-border bg-popover p-1 shadow-md"
|
||||
data-testid="filter-field-combobox-options"
|
||||
>
|
||||
{options.length === 0 ? (
|
||||
<div className="px-2 py-6 text-center text-sm text-muted-foreground" data-testid="filter-field-combobox-empty">
|
||||
No results
|
||||
</div>
|
||||
) : (
|
||||
fieldGroups.map((group, groupIndex) => (
|
||||
<div key={group.key}>
|
||||
{groupIndex > 0 && <div className="my-1 border-t border-border" />}
|
||||
{group.options.map((field) => {
|
||||
const optionIndex = options.indexOf(field)
|
||||
return (
|
||||
<button
|
||||
key={field}
|
||||
id={`${listboxId}-option-${optionIndex}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={optionIndex === highlightedIndex}
|
||||
className={cn(
|
||||
'flex w-full items-center rounded px-2 py-1.5 text-left text-sm',
|
||||
optionIndex === highlightedIndex
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
)}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onMouseEnter={() => setHighlightedIndex(optionIndex)}
|
||||
onClick={() => selectOption(field)}
|
||||
data-testid={optionTestId(field)}
|
||||
>
|
||||
<span className="truncate">{field}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user