feat: add regex mode for view filters

This commit is contained in:
Test
2026-04-07 22:51:23 +02:00
parent e1771ed7e6
commit c19cefe989
6 changed files with 342 additions and 14 deletions

View File

@@ -29,6 +29,12 @@ describe('FilterBuilder value inputs', () => {
expect(screen.getByTestId('filter-value-input')).toHaveAttribute('placeholder', 'value')
})
it('renders a regex toggle for supported text operators', () => {
renderBuilder()
expect(screen.getByTestId('filter-regex-toggle')).toBeInTheDocument()
expect(screen.getByTestId('filter-regex-toggle')).toHaveAttribute('aria-pressed', 'false')
})
it('keeps wikilink-style values in the plain text input without opening a dropdown', () => {
renderBuilder({
all: [{ field: 'belongs to', op: 'contains', value: '[[Alpha Project]]' }],
@@ -55,12 +61,35 @@ describe('FilterBuilder value inputs', () => {
)
})
it('toggles regex mode in the emitted filter payload', () => {
renderBuilder()
fireEvent.click(screen.getByTestId('filter-regex-toggle'))
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({
all: [{ field: 'title', op: 'contains', value: '', regex: true }],
}),
)
})
it('shows an invalid-regex indicator when regex mode is enabled with a broken pattern', () => {
renderBuilder({
all: [{ field: 'title', op: 'contains', value: '(', regex: true }],
})
expect(screen.getByTestId('filter-regex-invalid')).toBeInTheDocument()
expect(screen.getByTestId('filter-value-input')).toHaveAttribute('aria-invalid', 'true')
expect(screen.getByTestId('filter-regex-toggle')).toHaveAttribute('aria-pressed', 'true')
})
it('does not render a value input for empty-check operators', () => {
renderBuilder({
all: [{ field: 'title', op: 'is_empty' }],
})
expect(screen.queryByTestId('filter-value-input')).not.toBeInTheDocument()
expect(screen.queryByTestId('filter-regex-toggle')).not.toBeInTheDocument()
})
it('renders calendar date picker button for date operators', () => {
@@ -80,6 +109,7 @@ describe('FilterBuilder value inputs', () => {
})
expect(screen.getByTestId('date-picker-trigger')).toHaveTextContent('Pick a date')
expect(screen.queryByTestId('filter-regex-toggle')).not.toBeInTheDocument()
})
it('shows body field in field dropdown separated from property fields', () => {

View File

@@ -1,10 +1,11 @@
import { Plus, X, CalendarBlank } from '@phosphor-icons/react'
import { Plus, X, CalendarBlank, WarningCircle } from '@phosphor-icons/react'
import { format, parseISO } from 'date-fns'
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 { cn } from '@/lib/utils'
import type { FilterCondition, FilterOp, FilterGroup, FilterNode } from '../types'
const OPERATORS: { value: FilterOp; label: string }[] = [
@@ -20,6 +21,25 @@ const OPERATORS: { value: FilterOp; label: string }[] = [
const NO_VALUE_OPS = new Set<FilterOp>(['is_empty', 'is_not_empty'])
const DATE_OPS = new Set<FilterOp>(['before', 'after'])
const REGEX_OPS = new Set<FilterOp>(['contains', 'not_contains', 'equals', 'not_equals'])
function supportsRegex(op: FilterOp): boolean {
return REGEX_OPS.has(op)
}
function normalizeRegexFlag(op: FilterOp, enabled: boolean): boolean | undefined {
return supportsRegex(op) && enabled ? true : undefined
}
function hasInvalidRegex(value: string, regexEnabled: boolean): boolean {
if (!regexEnabled) return false
try {
new RegExp(value, 'i')
return false
} catch {
return true
}
}
function isFilterGroup(node: FilterNode): node is FilterGroup {
return 'all' in node || 'any' in node
@@ -121,18 +141,55 @@ function DateValueInput({ value, onChange }: { value: string; onChange: (v: stri
)
}
function TextValueInput({ value, onChange }: {
function TextValueInput({ value, onChange, regexEnabled, regexSupported, invalidRegex, onToggleRegex }: {
value: string
onChange: (v: string) => void
regexEnabled: boolean
regexSupported: boolean
invalidRegex: boolean
onToggleRegex: () => void
}) {
return (
<Input
className="h-8 flex-1 min-w-0 text-sm"
placeholder="value"
value={value}
onChange={(e) => onChange(e.target.value)}
data-testid="filter-value-input"
/>
<div className="flex flex-1 min-w-0 items-center gap-1">
<div className="relative min-w-0 flex-1">
<Input
className={cn(
'h-8 w-full min-w-0 text-sm',
invalidRegex && 'border-destructive pr-7 focus-visible:ring-destructive/20',
)}
placeholder="value"
value={value}
onChange={(e) => onChange(e.target.value)}
data-testid="filter-value-input"
aria-invalid={invalidRegex || undefined}
/>
{invalidRegex && (
<WarningCircle
size={14}
className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-destructive"
data-testid="filter-regex-invalid"
aria-hidden="true"
/>
)}
</div>
{regexSupported && (
<Button
type="button"
variant={regexEnabled ? 'secondary' : 'outline'}
size="sm"
className={cn(
'h-8 shrink-0 px-2 font-mono text-[11px]',
!regexEnabled && 'text-muted-foreground',
)}
onClick={onToggleRegex}
aria-pressed={regexEnabled}
data-testid="filter-regex-toggle"
title="Treat value as regex"
>
.*
</Button>
)}
</div>
)
}
@@ -143,6 +200,9 @@ function FilterRow({ condition, fields, onUpdate, onRemove }: {
onRemove: () => void
}) {
const isDateOp = DATE_OPS.has(condition.op)
const regexSupported = supportsRegex(condition.op)
const regexEnabled = regexSupported && condition.regex === true
const invalidRegex = regexSupported && hasInvalidRegex(String(condition.value ?? ''), regexEnabled)
return (
<div className="flex items-center gap-1.5">
<FieldSelect
@@ -152,12 +212,21 @@ function FilterRow({ condition, fields, onUpdate, onRemove }: {
/>
<OperatorSelect
value={condition.op}
onChange={(op) => onUpdate({ ...condition, op })}
onChange={(op) => onUpdate({ ...condition, op, regex: normalizeRegexFlag(op, regexEnabled) })}
/>
{!NO_VALUE_OPS.has(condition.op) && (
isDateOp
? <DateValueInput value={String(condition.value ?? '')} onChange={(v) => onUpdate({ ...condition, value: v })} />
: <TextValueInput value={String(condition.value ?? '')} onChange={(v) => onUpdate({ ...condition, value: v })} />
: (
<TextValueInput
value={String(condition.value ?? '')}
onChange={(v) => onUpdate({ ...condition, value: v })}
regexEnabled={regexEnabled}
regexSupported={regexSupported}
invalidRegex={invalidRegex}
onToggleRegex={() => onUpdate({ ...condition, regex: regexEnabled ? undefined : true })}
/>
)
)}
<Button
type="button"