diff --git a/src/components/FilterBuilder.tsx b/src/components/FilterBuilder.tsx index 0b018b8a..6760d1bf 100644 --- a/src/components/FilterBuilder.tsx +++ b/src/components/FilterBuilder.tsx @@ -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 ( -
- onChange(e.target.value)} - data-testid="date-value-input" - /> - - - - - - onChange(day ? format(day, 'yyyy-MM-dd') : '')} - /> - - -
- ) -} - function TextValueInput({ value, onChange, regexEnabled, regexSupported, invalidRegex, onToggleRegex }: { value: string onChange: (v: string) => void diff --git a/src/components/filter-builder/DateValueInput.test.tsx b/src/components/filter-builder/DateValueInput.test.tsx new file mode 100644 index 00000000..ac29b9a9 --- /dev/null +++ b/src/components/filter-builder/DateValueInput.test.tsx @@ -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 + } + + return render() + } + + 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() + }) +}) diff --git a/src/components/filter-builder/DateValueInput.tsx b/src/components/filter-builder/DateValueInput.tsx new file mode 100644 index 00000000..e8ea7f5f --- /dev/null +++ b/src/components/filter-builder/DateValueInput.tsx @@ -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 ( +
+
+ { + setShowPreview(true) + onChange(e.target.value) + }} + onFocus={() => setShowPreview(true)} + onBlur={() => setShowPreview(false)} + data-testid="date-value-input" + /> + + + + + + onChange(day ? format(day, 'yyyy-MM-dd') : '')} + /> + + +
+ {previewLabel && ( +
+ {resolvedPreview ? `Resolves to ${previewLabel}` : previewLabel} +
+ )} +
+ ) +} diff --git a/src/utils/filterDates.test.ts b/src/utils/filterDates.test.ts index 000df304..d46732b9 100644 --- a/src/utils/filterDates.test.ts +++ b/src/utils/filterDates.test.ts @@ -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)