diff --git a/src/components/FilterBuilder.test.tsx b/src/components/FilterBuilder.test.tsx index c58bcd25..27fc79cb 100644 --- a/src/components/FilterBuilder.test.tsx +++ b/src/components/FilterBuilder.test.tsx @@ -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( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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( { />, ) - expect(screen.getByText('body')).toBeInTheDocument() + openFieldCombobox() + + expect(screen.getByTestId('filter-field-option-body')).toBeInTheDocument() }) }) diff --git a/src/components/FilterBuilder.tsx b/src/components/FilterBuilder.tsx index 04eae8c1..0b018b8a 100644 --- a/src/components/FilterBuilder.tsx +++ b/src/components/FilterBuilder.tsx @@ -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 ( - - ) -} - 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 (
- onUpdate({ ...condition, field: v })} diff --git a/src/components/FilterFieldCombobox.tsx b/src/components/FilterFieldCombobox.tsx new file mode 100644 index 00000000..1bbb0ee7 --- /dev/null +++ b/src/components/FilterFieldCombobox.tsx @@ -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(null) + const inputRef = useRef(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 ( +
{ + if (rootRef.current?.contains(event.relatedTarget as Node | null)) return + closeCombobox() + }} + data-testid="filter-field-combobox" + > + 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" + /> +