feat: preview relative date filters
This commit is contained in:
@@ -1,14 +1,11 @@
|
||||
import { Plus, X, CalendarBlank, WarningCircle } from '@phosphor-icons/react'
|
||||
import { format } from 'date-fns'
|
||||
import { Plus, X, WarningCircle } from '@phosphor-icons/react'
|
||||
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, 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'
|
||||
import { DateValueInput } from './filter-builder/DateValueInput'
|
||||
|
||||
const OPERATORS: { value: FilterOp; label: string }[] = [
|
||||
{ value: 'equals', label: 'equals' },
|
||||
@@ -81,43 +78,6 @@ function OperatorSelect({ value, onChange }: {
|
||||
)
|
||||
}
|
||||
|
||||
function DateValueInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
const selected = value ? parseDateFilterInput(value) ?? undefined : undefined
|
||||
return (
|
||||
<div className="flex flex-1 min-w-0 items-center gap-1">
|
||||
<Input
|
||||
className="h-8 flex-1 min-w-0 text-sm"
|
||||
placeholder='YYYY-MM-DD or "10 days ago"'
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
data-testid="date-value-input"
|
||||
/>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-testid="date-picker-trigger"
|
||||
className="h-8 w-8 shrink-0 px-0"
|
||||
title={selected ? format(selected, 'MMM d, yyyy') : 'Pick a date'}
|
||||
aria-label={selected ? `Open date picker (${format(selected, 'MMM d, yyyy')})` : 'Open date picker'}
|
||||
>
|
||||
<CalendarBlank size={14} className="shrink-0 text-muted-foreground" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selected}
|
||||
onSelect={(day) => onChange(day ? format(day, 'yyyy-MM-dd') : '')}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TextValueInput({ value, onChange, regexEnabled, regexSupported, invalidRegex, onToggleRegex }: {
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
|
||||
59
src/components/filter-builder/DateValueInput.test.tsx
Normal file
59
src/components/filter-builder/DateValueInput.test.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useState } from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { DateValueInput } from './DateValueInput'
|
||||
|
||||
describe('DateValueInput', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
function renderControlledDateValueInput(initialValue = '') {
|
||||
function ControlledDateValueInput() {
|
||||
const [value, setValue] = useState(initialValue)
|
||||
return <DateValueInput value={value} onChange={setValue} />
|
||||
}
|
||||
|
||||
return render(<ControlledDateValueInput />)
|
||||
}
|
||||
|
||||
it('shows a debounced resolved-date preview while focused', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-04-08T12:00:00Z'))
|
||||
|
||||
renderControlledDateValueInput()
|
||||
|
||||
const input = screen.getByTestId('date-value-input')
|
||||
fireEvent.focus(input)
|
||||
fireEvent.change(input, { target: { value: '10 days ago' } })
|
||||
|
||||
expect(screen.queryByTestId('date-value-preview')).not.toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(250)
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('date-value-preview')).toHaveTextContent('Resolves to March 29, 2026')
|
||||
})
|
||||
|
||||
it('shows a neutral hint for unrecognized input and hides the preview on blur', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
renderControlledDateValueInput()
|
||||
|
||||
const input = screen.getByTestId('date-value-input')
|
||||
fireEvent.focus(input)
|
||||
fireEvent.change(input, { target: { value: 'eventually maybe' } })
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(250)
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('date-value-preview-unrecognized')).toHaveTextContent('Not recognized')
|
||||
|
||||
fireEvent.blur(input)
|
||||
|
||||
expect(screen.queryByTestId('date-value-preview')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('date-value-preview-unrecognized')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
78
src/components/filter-builder/DateValueInput.tsx
Normal file
78
src/components/filter-builder/DateValueInput.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { CalendarBlank } from '@phosphor-icons/react'
|
||||
import { format } 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 { parseDateFilterInput } from '@/utils/filterDates'
|
||||
|
||||
const DATE_PREVIEW_DEBOUNCE_MS = 250
|
||||
|
||||
export function DateValueInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
const selected = value ? parseDateFilterInput(value) ?? undefined : undefined
|
||||
const [showPreview, setShowPreview] = useState(false)
|
||||
const [debouncedValue, setDebouncedValue] = useState(value)
|
||||
|
||||
useEffect(() => {
|
||||
const timeoutId = window.setTimeout(() => setDebouncedValue(value), DATE_PREVIEW_DEBOUNCE_MS)
|
||||
return () => window.clearTimeout(timeoutId)
|
||||
}, [value])
|
||||
|
||||
const previewValue = showPreview ? debouncedValue.trim() : ''
|
||||
const resolvedPreview = previewValue ? parseDateFilterInput(previewValue) : null
|
||||
const previewLabel = resolvedPreview
|
||||
? format(resolvedPreview, 'MMMM d, yyyy')
|
||||
: previewValue
|
||||
? 'Not recognized'
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 min-w-0 flex-col gap-1">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<Input
|
||||
className="h-8 flex-1 min-w-0 text-sm"
|
||||
placeholder='YYYY-MM-DD or "10 days ago"'
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
setShowPreview(true)
|
||||
onChange(e.target.value)
|
||||
}}
|
||||
onFocus={() => setShowPreview(true)}
|
||||
onBlur={() => setShowPreview(false)}
|
||||
data-testid="date-value-input"
|
||||
/>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-testid="date-picker-trigger"
|
||||
className="h-8 w-8 shrink-0 px-0"
|
||||
title={selected ? format(selected, 'MMM d, yyyy') : 'Pick a date'}
|
||||
aria-label={selected ? `Open date picker (${format(selected, 'MMM d, yyyy')})` : 'Open date picker'}
|
||||
>
|
||||
<CalendarBlank size={14} className="shrink-0 text-muted-foreground" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selected}
|
||||
onSelect={(day) => onChange(day ? format(day, 'yyyy-MM-dd') : '')}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
{previewLabel && (
|
||||
<div
|
||||
className="pl-1 text-[11px] text-muted-foreground"
|
||||
data-testid={resolvedPreview ? 'date-value-preview' : 'date-value-preview-unrecognized'}
|
||||
>
|
||||
{resolvedPreview ? `Resolves to ${previewLabel}` : previewLabel}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -18,6 +18,12 @@ describe('filterDates', () => {
|
||||
expect(parsed && format(parsed, 'yyyy-MM-dd')).toBe('2026-03-28')
|
||||
})
|
||||
|
||||
it('parses numeric relative year phrases', () => {
|
||||
const reference = new Date('2026-04-08T12:00:00Z')
|
||||
const parsed = parseDateFilterInput('10 years ago', reference)
|
||||
expect(parsed && format(parsed, 'yyyy-MM-dd')).toBe('2016-04-08')
|
||||
})
|
||||
|
||||
it('parses word-based relative week phrases', () => {
|
||||
const reference = new Date('2026-04-07T12:00:00Z')
|
||||
const parsed = parseDateFilterInput('one week ago', reference)
|
||||
|
||||
Reference in New Issue
Block a user