From 34ee3eff3c1367015d3e5e4a247ae7ec0a3cda44 Mon Sep 17 00:00:00 2001 From: Spencer Murray Date: Fri, 27 Feb 2026 14:37:35 -0500 Subject: [PATCH 01/20] Add advanced filtering UI for incident list --- .../src/routes/components/AdvancedFilters.tsx | 293 ++++++++++++++++++ frontend/src/routes/components/DateFilter.tsx | 44 +++ .../routes/components/MultiSelectFilter.tsx | 91 ++++++ .../src/routes/components/TextInputFilter.tsx | 79 +++++ frontend/src/routes/index.test.tsx | 66 ++++ frontend/src/routes/index.tsx | 2 + 6 files changed, 575 insertions(+) create mode 100644 frontend/src/routes/components/AdvancedFilters.tsx create mode 100644 frontend/src/routes/components/DateFilter.tsx create mode 100644 frontend/src/routes/components/MultiSelectFilter.tsx create mode 100644 frontend/src/routes/components/TextInputFilter.tsx diff --git a/frontend/src/routes/components/AdvancedFilters.tsx b/frontend/src/routes/components/AdvancedFilters.tsx new file mode 100644 index 00000000..8b3a4ba7 --- /dev/null +++ b/frontend/src/routes/components/AdvancedFilters.tsx @@ -0,0 +1,293 @@ +import {useState} from 'react'; +import {useQuery} from '@tanstack/react-query'; +import {useNavigate, useSearch} from '@tanstack/react-router'; +import {Button} from 'components/Button'; +import {Pill} from 'components/Pill'; +import {Tag} from 'components/Tag'; +import {SlidersHorizontalIcon, XIcon} from 'lucide-react'; +import { + tagsQueryOptions, + type TagType, +} from 'routes/$incidentId/queries/tagsQueryOptions'; +import {ServiceTierSchema, SeveritySchema} from 'routes/queries/incidentsQueryOptions'; + +import {DateFilter} from './DateFilter'; +import {MultiSelectFilter} from './MultiSelectFilter'; +import {TextInputFilter} from './TextInputFilter'; + +type ArrayFilterKey = + | 'severity' + | 'service_tier' + | 'affected_service' + | 'root_cause' + | 'impact_type' + | 'affected_region' + | 'captain' + | 'reporter'; + +type DateFilterKey = 'created_after' | 'created_before'; + +const FILTER_LABELS: Record = { + severity: 'Severity', + service_tier: 'Service Tier', + affected_service: 'Affected Service', + root_cause: 'Root Cause', + impact_type: 'Impact Type', + affected_region: 'Affected Region', + captain: 'Captain', + reporter: 'Reporter', + created_after: 'Created After', + created_before: 'Created Before', +}; + +const ARRAY_FILTER_KEYS: ArrayFilterKey[] = [ + 'severity', + 'service_tier', + 'affected_service', + 'root_cause', + 'impact_type', + 'affected_region', + 'captain', + 'reporter', +]; + +function hasAdvancedFilters(search: Record): boolean { + for (const key of ARRAY_FILTER_KEYS) { + const val = search[key]; + if (Array.isArray(val) && val.length > 0) return true; + } + if (search.created_after || search.created_before) return true; + return false; +} + +export function AdvancedFilters() { + const search = useSearch({from: '/'}); + const navigate = useNavigate(); + const [open, setOpen] = useState(() => hasAdvancedFilters(search)); + + function updateArrayFilter(key: ArrayFilterKey, value: string) { + const current = (search[key] as string[] | undefined) ?? []; + const next = current.includes(value) + ? current.filter(v => v !== value) + : [...current, value]; + navigate({ + to: '/', + search: prev => ({...prev, [key]: next.length > 0 ? next : undefined}), + }); + } + + function removeArrayFilterValue(key: ArrayFilterKey, value: string) { + const current = (search[key] as string[] | undefined) ?? []; + const next = current.filter(v => v !== value); + navigate({ + to: '/', + search: prev => ({...prev, [key]: next.length > 0 ? next : undefined}), + }); + } + + function updateDateFilter(key: DateFilterKey, value: string | undefined) { + navigate({ + to: '/', + search: prev => ({...prev, [key]: value}), + }); + } + + function clearAll() { + navigate({ + to: '/', + search: prev => { + const next = {...prev}; + for (const key of ARRAY_FILTER_KEYS) { + delete next[key]; + } + delete next.created_after; + delete next.created_before; + return next; + }, + }); + } + + const activeFilters: {key: ArrayFilterKey; value: string; label: string}[] = []; + for (const key of ARRAY_FILTER_KEYS) { + const values = (search[key] as string[] | undefined) ?? []; + for (const value of values) { + activeFilters.push({key, value, label: FILTER_LABELS[key]}); + } + } + + const hasActive = + activeFilters.length > 0 || !!search.created_after || !!search.created_before; + + return ( +
+ + + {open && ( +
+
+ updateArrayFilter('severity', v)} + renderOption={v => {v}} + /> + updateArrayFilter('service_tier', v)} + renderOption={v => {v}} + /> + updateArrayFilter('affected_service', v)} + /> + updateArrayFilter('root_cause', v)} + /> + updateArrayFilter('impact_type', v)} + /> + updateArrayFilter('affected_region', v)} + /> + updateArrayFilter('captain', v)} + onRemove={v => removeArrayFilterValue('captain', v)} + placeholder="Enter email" + /> + updateArrayFilter('reporter', v)} + onRemove={v => removeArrayFilterValue('reporter', v)} + placeholder="Enter email" + /> + updateDateFilter('created_after', v)} + /> + updateDateFilter('created_before', v)} + /> +
+ + {hasActive && ( +
+ {activeFilters.map(({key, value, label}) => ( + removeArrayFilterValue(key, value)} + className="text-content-secondary hover:text-content-primary cursor-pointer" + > + + + } + > + {label}: {value} + + ))} + {search.created_after && ( + updateDateFilter('created_after', undefined)} + className="text-content-secondary hover:text-content-primary cursor-pointer" + > + + + } + > + After: {search.created_after} + + )} + {search.created_before && ( + updateDateFilter('created_before', undefined)} + className="text-content-secondary hover:text-content-primary cursor-pointer" + > + + + } + > + Before: {search.created_before} + + )} + +
+ )} +
+ )} +
+ ); +} + +function TagMultiSelect({ + label, + tagType, + selected, + onToggle, +}: { + label: string; + tagType: TagType; + selected: string[]; + onToggle: (value: string) => void; +}) { + const {data: options = []} = useQuery(tagsQueryOptions(tagType)); + + return ( + + ); +} diff --git a/frontend/src/routes/components/DateFilter.tsx b/frontend/src/routes/components/DateFilter.tsx new file mode 100644 index 00000000..0744431f --- /dev/null +++ b/frontend/src/routes/components/DateFilter.tsx @@ -0,0 +1,44 @@ +import {Button} from 'components/Button'; +import {Calendar} from 'components/Calendar'; +import {Popover, PopoverContent, PopoverTrigger} from 'components/Popover'; +import {format, parseISO} from 'date-fns'; +import {CalendarIcon, XIcon} from 'lucide-react'; + +interface DateFilterProps { + label: string; + value?: string; + onChange: (value: string | undefined) => void; +} + +export function DateFilter({label, value, onChange}: DateFilterProps) { + const date = value ? parseISO(value) : undefined; + + return ( +
+ + + + + + onChange(d ? format(d, 'yyyy-MM-dd') : undefined)} + /> + + + {value && ( + + )} +
+ ); +} diff --git a/frontend/src/routes/components/MultiSelectFilter.tsx b/frontend/src/routes/components/MultiSelectFilter.tsx new file mode 100644 index 00000000..4423c69f --- /dev/null +++ b/frontend/src/routes/components/MultiSelectFilter.tsx @@ -0,0 +1,91 @@ +import {useState} from 'react'; +import {Button} from 'components/Button'; +import {Input} from 'components/Input'; +import {Popover, PopoverContent, PopoverTrigger} from 'components/Popover'; +import {CheckIcon, ChevronDownIcon} from 'lucide-react'; +import {cn} from 'utils/cn'; + +interface MultiSelectFilterProps { + label: string; + options: string[]; + selected: string[]; + onToggle: (value: string) => void; + renderOption?: (value: string) => React.ReactNode; + searchable?: boolean; +} + +export function MultiSelectFilter({ + label, + options, + selected, + onToggle, + renderOption, + searchable = false, +}: MultiSelectFilterProps) { + const [search, setSearch] = useState(''); + + const filtered = searchable + ? options.filter(o => o.toLowerCase().includes(search.toLowerCase())) + : options; + + return ( + + + + + + {searchable && ( +
+ setSearch(e.target.value)} + /> +
+ )} +
    + {filtered.map(option => { + const isSelected = selected.includes(option); + return ( +
  • onToggle(option)} + > + + {isSelected && } + + {renderOption ? renderOption(option) : option} +
  • + ); + })} + {filtered.length === 0 && ( +
  • + No results +
  • + )} +
+
+
+ ); +} diff --git a/frontend/src/routes/components/TextInputFilter.tsx b/frontend/src/routes/components/TextInputFilter.tsx new file mode 100644 index 00000000..2a85e6ea --- /dev/null +++ b/frontend/src/routes/components/TextInputFilter.tsx @@ -0,0 +1,79 @@ +import {useState} from 'react'; +import {Button} from 'components/Button'; +import {Input} from 'components/Input'; +import {Popover, PopoverContent, PopoverTrigger} from 'components/Popover'; +import {Tag} from 'components/Tag'; +import {ChevronDownIcon, XIcon} from 'lucide-react'; + +interface TextInputFilterProps { + label: string; + values: string[]; + onAdd: (value: string) => void; + onRemove: (value: string) => void; + placeholder?: string; +} + +export function TextInputFilter({ + label, + values, + onAdd, + onRemove, + placeholder = 'Type and press Enter', +}: TextInputFilterProps) { + const [input, setInput] = useState(''); + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === 'Enter') { + e.preventDefault(); + const trimmed = input.trim(); + if (trimmed && !values.includes(trimmed)) { + onAdd(trimmed); + } + setInput(''); + } + } + + return ( + + + + + + setInput(e.target.value)} + onKeyDown={handleKeyDown} + /> + {values.length > 0 && ( +
+ {values.map(v => ( + onRemove(v)} + className="text-content-secondary hover:text-content-primary cursor-pointer" + > + + + } + > + {v} + + ))} +
+ )} +
+
+ ); +} diff --git a/frontend/src/routes/index.test.tsx b/frontend/src/routes/index.test.tsx index 2442aab7..290939a1 100644 --- a/frontend/src/routes/index.test.tsx +++ b/frontend/src/routes/index.test.tsx @@ -60,6 +60,9 @@ function setupDefaultMocks() { if (args.path === '/ui/users/me/') { return Promise.resolve(mockCurrentUser); } + if (args.path === '/tags/') { + return Promise.resolve(['tag-1', 'tag-2']); + } return Promise.reject(new Error('Not found')); }); } @@ -389,6 +392,69 @@ describe('Advanced Filter Params', () => { }); }); +describe('Advanced Filters UI', () => { + beforeEach(() => { + queryClient.clear(); + setupDefaultMocks(); + }); + + it('shows filters toggle button', async () => { + renderRoute(); + + expect(await screen.findByTestId('advanced-filters-toggle')).toBeInTheDocument(); + }); + + it('does not show filter controls by default', async () => { + renderRoute(); + + await screen.findByText('INC-1247'); + + expect(screen.queryByText('Severity')).not.toBeInTheDocument(); + }); + + it('shows filter controls when toggle is clicked', async () => { + const user = userEvent.setup(); + renderRoute(); + + await screen.findByText('INC-1247'); + + const toggle = screen.getByTestId('advanced-filters-toggle'); + await user.click(toggle); + + expect(screen.getByText('Severity')).toBeInTheDocument(); + expect(screen.getByText('Service Tier')).toBeInTheDocument(); + expect(screen.getByText('Captain')).toBeInTheDocument(); + expect(screen.getByText('Reporter')).toBeInTheDocument(); + expect(screen.getByText('Created After')).toBeInTheDocument(); + expect(screen.getByText('Created Before')).toBeInTheDocument(); + }); + + it('auto-opens when URL has advanced filters', async () => { + renderRoute('/?severity=P0'); + + await screen.findByText('INC-1247'); + + expect(screen.getByText('Severity')).toBeInTheDocument(); + }); + + it('shows active filter tags', async () => { + renderRoute('/?severity=P0&service_tier=T0'); + + await screen.findByText('INC-1247'); + + expect(screen.getByText('Severity: P0')).toBeInTheDocument(); + expect(screen.getByText('Service Tier: T0')).toBeInTheDocument(); + }); + + it('shows clear all button when filters are active', async () => { + renderRoute('/?severity=P0'); + + await screen.findByText('INC-1247'); + + expect(screen.getByTestId('clear-all-filters')).toBeInTheDocument(); + }); +}); + describe('Route States', () => { beforeEach(() => { queryClient.clear(); diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx index 5d5d70da..0517f4f1 100644 --- a/frontend/src/routes/index.tsx +++ b/frontend/src/routes/index.tsx @@ -8,6 +8,7 @@ import {Spinner} from 'components/Spinner'; import {arraysEqual} from 'utils/arrays'; import {z} from 'zod'; +import {AdvancedFilters} from './components/AdvancedFilters'; import {IncidentCard} from './components/IncidentCard'; import {IncidentListSkeleton} from './components/IncidentListSkeleton'; import {StatusFilter} from './components/StatusFilter'; @@ -41,6 +42,7 @@ function IncidentsLayout({children}: {children: React.ReactNode}) { return (
+
{children}
From b8660fcaa51f6e023a3010f60de413144ce642a0 Mon Sep 17 00:00:00 2001 From: Spencer Murray Date: Fri, 27 Feb 2026 15:45:02 -0500 Subject: [PATCH 02/20] Fix import paths in AdvancedFilters to use relative imports --- frontend/src/routes/components/AdvancedFilters.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/frontend/src/routes/components/AdvancedFilters.tsx b/frontend/src/routes/components/AdvancedFilters.tsx index 8b3a4ba7..97d04db2 100644 --- a/frontend/src/routes/components/AdvancedFilters.tsx +++ b/frontend/src/routes/components/AdvancedFilters.tsx @@ -5,11 +5,9 @@ import {Button} from 'components/Button'; import {Pill} from 'components/Pill'; import {Tag} from 'components/Tag'; import {SlidersHorizontalIcon, XIcon} from 'lucide-react'; -import { - tagsQueryOptions, - type TagType, -} from 'routes/$incidentId/queries/tagsQueryOptions'; -import {ServiceTierSchema, SeveritySchema} from 'routes/queries/incidentsQueryOptions'; + +import {tagsQueryOptions, type TagType} from '../$incidentId/queries/tagsQueryOptions'; +import {ServiceTierSchema, SeveritySchema} from '../types'; import {DateFilter} from './DateFilter'; import {MultiSelectFilter} from './MultiSelectFilter'; From 98b5fa10244d0519bc245e56f075b61518354951 Mon Sep 17 00:00:00 2001 From: Spencer Murray Date: Fri, 6 Mar 2026 12:10:39 -0500 Subject: [PATCH 03/20] Create filters layout --- .../src/routes/components/AdvancedFilters.tsx | 303 ++++++------------ frontend/src/routes/components/DateFilter.tsx | 44 --- .../routes/components/MultiSelectFilter.tsx | 91 ------ .../src/routes/components/TextInputFilter.tsx | 79 ----- frontend/src/routes/index.tsx | 34 +- 5 files changed, 128 insertions(+), 423 deletions(-) delete mode 100644 frontend/src/routes/components/DateFilter.tsx delete mode 100644 frontend/src/routes/components/MultiSelectFilter.tsx delete mode 100644 frontend/src/routes/components/TextInputFilter.tsx diff --git a/frontend/src/routes/components/AdvancedFilters.tsx b/frontend/src/routes/components/AdvancedFilters.tsx index 97d04db2..60e976dd 100644 --- a/frontend/src/routes/components/AdvancedFilters.tsx +++ b/frontend/src/routes/components/AdvancedFilters.tsx @@ -1,18 +1,8 @@ -import {useState} from 'react'; -import {useQuery} from '@tanstack/react-query'; import {useNavigate, useSearch} from '@tanstack/react-router'; import {Button} from 'components/Button'; -import {Pill} from 'components/Pill'; import {Tag} from 'components/Tag'; import {SlidersHorizontalIcon, XIcon} from 'lucide-react'; -import {tagsQueryOptions, type TagType} from '../$incidentId/queries/tagsQueryOptions'; -import {ServiceTierSchema, SeveritySchema} from '../types'; - -import {DateFilter} from './DateFilter'; -import {MultiSelectFilter} from './MultiSelectFilter'; -import {TextInputFilter} from './TextInputFilter'; - type ArrayFilterKey = | 'severity' | 'service_tier' @@ -49,30 +39,50 @@ const ARRAY_FILTER_KEYS: ArrayFilterKey[] = [ 'reporter', ]; -function hasAdvancedFilters(search: Record): boolean { +function useActiveFilters() { + const search = useSearch({from: '/'}); + + const activeFilters: {key: ArrayFilterKey; value: string; label: string}[] = []; for (const key of ARRAY_FILTER_KEYS) { - const val = search[key]; - if (Array.isArray(val) && val.length > 0) return true; + const values = (search[key] as string[] | undefined) ?? []; + for (const value of values) { + activeFilters.push({key, value, label: FILTER_LABELS[key]}); + } } - if (search.created_after || search.created_before) return true; - return false; + + const activeCount = + activeFilters.length + + (search.created_after ? 1 : 0) + + (search.created_before ? 1 : 0); + + return {search, activeFilters, activeCount}; } -export function AdvancedFilters() { - const search = useSearch({from: '/'}); - const navigate = useNavigate(); - const [open, setOpen] = useState(() => hasAdvancedFilters(search)); +export function FilterTrigger({open, onToggle}: {open: boolean; onToggle: () => void}) { + const {activeCount} = useActiveFilters(); - function updateArrayFilter(key: ArrayFilterKey, value: string) { - const current = (search[key] as string[] | undefined) ?? []; - const next = current.includes(value) - ? current.filter(v => v !== value) - : [...current, value]; - navigate({ - to: '/', - search: prev => ({...prev, [key]: next.length > 0 ? next : undefined}), - }); - } + return ( + + ); +} + +export function FilterPanel() { + const navigate = useNavigate(); + const {search, activeFilters, activeCount} = useActiveFilters(); function removeArrayFilterValue(key: ArrayFilterKey, value: string) { const current = (search[key] as string[] | undefined) ?? []; @@ -105,187 +115,74 @@ export function AdvancedFilters() { }); } - const activeFilters: {key: ArrayFilterKey; value: string; label: string}[] = []; - for (const key of ARRAY_FILTER_KEYS) { - const values = (search[key] as string[] | undefined) ?? []; - for (const value of values) { - activeFilters.push({key, value, label: FILTER_LABELS[key]}); - } - } - - const hasActive = - activeFilters.length > 0 || !!search.created_after || !!search.created_before; - return ( -
- - - {open && ( -
-
- updateArrayFilter('severity', v)} - renderOption={v => {v}} - /> - updateArrayFilter('service_tier', v)} - renderOption={v => {v}} - /> - updateArrayFilter('affected_service', v)} - /> - updateArrayFilter('root_cause', v)} - /> - updateArrayFilter('impact_type', v)} - /> - updateArrayFilter('affected_region', v)} - /> - updateArrayFilter('captain', v)} - onRemove={v => removeArrayFilterValue('captain', v)} - placeholder="Enter email" - /> - updateArrayFilter('reporter', v)} - onRemove={v => removeArrayFilterValue('reporter', v)} - placeholder="Enter email" - /> - updateDateFilter('created_after', v)} - /> - updateDateFilter('created_before', v)} - /> -
- - {hasActive && ( -
- {activeFilters.map(({key, value, label}) => ( - removeArrayFilterValue(key, value)} - className="text-content-secondary hover:text-content-primary cursor-pointer" - > - - - } +
+
+
Column 1
+
Column 2
+
+ + {activeCount > 0 && ( +
+ {activeFilters.map(({key, value, label}) => ( + removeArrayFilterValue(key, value)} + className="text-content-secondary hover:text-content-primary cursor-pointer" > - {label}: {value} - - ))} - {search.created_after && ( - updateDateFilter('created_after', undefined)} - className="text-content-secondary hover:text-content-primary cursor-pointer" - > - - - } + + + } + > + {label}: {value} + + ))} + {search.created_after && ( + updateDateFilter('created_after', undefined)} + className="text-content-secondary hover:text-content-primary cursor-pointer" > - After: {search.created_after} - - )} - {search.created_before && ( - updateDateFilter('created_before', undefined)} - className="text-content-secondary hover:text-content-primary cursor-pointer" - > - - - } + + + } + > + After: {search.created_after} + + )} + {search.created_before && ( + updateDateFilter('created_before', undefined)} + className="text-content-secondary hover:text-content-primary cursor-pointer" > - Before: {search.created_before} - - )} - -
+ + + } + > + Before: {search.created_before} + )} +
)}
); } - -function TagMultiSelect({ - label, - tagType, - selected, - onToggle, -}: { - label: string; - tagType: TagType; - selected: string[]; - onToggle: (value: string) => void; -}) { - const {data: options = []} = useQuery(tagsQueryOptions(tagType)); - - return ( - - ); -} diff --git a/frontend/src/routes/components/DateFilter.tsx b/frontend/src/routes/components/DateFilter.tsx deleted file mode 100644 index 0744431f..00000000 --- a/frontend/src/routes/components/DateFilter.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import {Button} from 'components/Button'; -import {Calendar} from 'components/Calendar'; -import {Popover, PopoverContent, PopoverTrigger} from 'components/Popover'; -import {format, parseISO} from 'date-fns'; -import {CalendarIcon, XIcon} from 'lucide-react'; - -interface DateFilterProps { - label: string; - value?: string; - onChange: (value: string | undefined) => void; -} - -export function DateFilter({label, value, onChange}: DateFilterProps) { - const date = value ? parseISO(value) : undefined; - - return ( -
- - - - - - onChange(d ? format(d, 'yyyy-MM-dd') : undefined)} - /> - - - {value && ( - - )} -
- ); -} diff --git a/frontend/src/routes/components/MultiSelectFilter.tsx b/frontend/src/routes/components/MultiSelectFilter.tsx deleted file mode 100644 index 4423c69f..00000000 --- a/frontend/src/routes/components/MultiSelectFilter.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import {useState} from 'react'; -import {Button} from 'components/Button'; -import {Input} from 'components/Input'; -import {Popover, PopoverContent, PopoverTrigger} from 'components/Popover'; -import {CheckIcon, ChevronDownIcon} from 'lucide-react'; -import {cn} from 'utils/cn'; - -interface MultiSelectFilterProps { - label: string; - options: string[]; - selected: string[]; - onToggle: (value: string) => void; - renderOption?: (value: string) => React.ReactNode; - searchable?: boolean; -} - -export function MultiSelectFilter({ - label, - options, - selected, - onToggle, - renderOption, - searchable = false, -}: MultiSelectFilterProps) { - const [search, setSearch] = useState(''); - - const filtered = searchable - ? options.filter(o => o.toLowerCase().includes(search.toLowerCase())) - : options; - - return ( - - - - - - {searchable && ( -
- setSearch(e.target.value)} - /> -
- )} -
    - {filtered.map(option => { - const isSelected = selected.includes(option); - return ( -
  • onToggle(option)} - > - - {isSelected && } - - {renderOption ? renderOption(option) : option} -
  • - ); - })} - {filtered.length === 0 && ( -
  • - No results -
  • - )} -
-
-
- ); -} diff --git a/frontend/src/routes/components/TextInputFilter.tsx b/frontend/src/routes/components/TextInputFilter.tsx deleted file mode 100644 index 2a85e6ea..00000000 --- a/frontend/src/routes/components/TextInputFilter.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import {useState} from 'react'; -import {Button} from 'components/Button'; -import {Input} from 'components/Input'; -import {Popover, PopoverContent, PopoverTrigger} from 'components/Popover'; -import {Tag} from 'components/Tag'; -import {ChevronDownIcon, XIcon} from 'lucide-react'; - -interface TextInputFilterProps { - label: string; - values: string[]; - onAdd: (value: string) => void; - onRemove: (value: string) => void; - placeholder?: string; -} - -export function TextInputFilter({ - label, - values, - onAdd, - onRemove, - placeholder = 'Type and press Enter', -}: TextInputFilterProps) { - const [input, setInput] = useState(''); - - function handleKeyDown(e: React.KeyboardEvent) { - if (e.key === 'Enter') { - e.preventDefault(); - const trimmed = input.trim(); - if (trimmed && !values.includes(trimmed)) { - onAdd(trimmed); - } - setInput(''); - } - } - - return ( - - - - - - setInput(e.target.value)} - onKeyDown={handleKeyDown} - /> - {values.length > 0 && ( -
- {values.map(v => ( - onRemove(v)} - className="text-content-secondary hover:text-content-primary cursor-pointer" - > - - - } - > - {v} - - ))} -
- )} -
-
- ); -} diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx index 0517f4f1..0eca6469 100644 --- a/frontend/src/routes/index.tsx +++ b/frontend/src/routes/index.tsx @@ -1,6 +1,6 @@ -import {useEffect, useRef} from 'react'; +import {useEffect, useRef, useState} from 'react'; import {useSuspenseInfiniteQuery} from '@tanstack/react-query'; -import {createFileRoute} from '@tanstack/react-router'; +import {createFileRoute, useSearch} from '@tanstack/react-router'; import {zodValidator} from '@tanstack/zod-adapter'; import {ErrorState} from 'components/ErrorState'; import {GetHelpLink} from 'components/GetHelpLink'; @@ -8,7 +8,7 @@ import {Spinner} from 'components/Spinner'; import {arraysEqual} from 'utils/arrays'; import {z} from 'zod'; -import {AdvancedFilters} from './components/AdvancedFilters'; +import {FilterPanel, FilterTrigger} from './components/AdvancedFilters'; import {IncidentCard} from './components/IncidentCard'; import {IncidentListSkeleton} from './components/IncidentListSkeleton'; import {StatusFilter} from './components/StatusFilter'; @@ -39,10 +39,32 @@ const incidentListSearchSchema = z.object({ }); function IncidentsLayout({children}: {children: React.ReactNode}) { + const search = useSearch({from: '/'}); + const [open, setOpen] = useState(() => { + const keys = [ + 'severity', + 'service_tier', + 'affected_service', + 'root_cause', + 'impact_type', + 'affected_region', + 'captain', + 'reporter', + ] as const; + for (const key of keys) { + const val = search[key]; + if (Array.isArray(val) && val.length > 0) return true; + } + return !!(search.created_after || search.created_before); + }); + return ( -
- - +
+
+ + setOpen(prev => !prev)} /> +
+ {open && }
{children}
From 6348eabff34856dff0251ddb0340e98703d3806f Mon Sep 17 00:00:00 2001 From: Spencer Murray Date: Wed, 11 Mar 2026 12:20:38 -0400 Subject: [PATCH 04/20] Add filter panel with severity, service tier, and tag filters --- frontend/src/components/MultiSelect.tsx | 100 +++ .../src/routes/components/AdvancedFilters.tsx | 619 +++++++++++++----- .../src/routes/components/useActiveFilters.ts | 56 ++ frontend/src/routes/index.test.tsx | 34 +- frontend/src/routes/index.tsx | 25 +- 5 files changed, 632 insertions(+), 202 deletions(-) create mode 100644 frontend/src/components/MultiSelect.tsx create mode 100644 frontend/src/routes/components/useActiveFilters.ts diff --git a/frontend/src/components/MultiSelect.tsx b/frontend/src/components/MultiSelect.tsx new file mode 100644 index 00000000..c2ceabe9 --- /dev/null +++ b/frontend/src/components/MultiSelect.tsx @@ -0,0 +1,100 @@ +import React, {useRef, useState} from 'react'; + +import {Input} from './Input'; +import {Popover, PopoverContent, PopoverTrigger} from './Popover'; + +interface MultiSelectProps { + options: readonly T[]; + selected: T[]; + onToggle: (value: T) => void; + renderOption: (value: T) => React.ReactNode; + trigger: React.ReactNode; + searchable?: boolean; + searchPlaceholder?: string; +} + +function MultiSelect({ + options, + selected, + onToggle, + renderOption, + trigger, + searchable, + searchPlaceholder = 'Search…', +}: MultiSelectProps) { + const [search, setSearch] = useState(''); + const listRef = useRef(null); + + const available = options.filter(o => !selected.includes(o)); + const filtered = searchable + ? available.filter(o => o.toLowerCase().includes(search.toLowerCase())) + : available; + + function handleKeyDown(e: React.KeyboardEvent) { + const list = listRef.current; + if (!list) return; + + const items = Array.from(list.querySelectorAll('[role="option"]')); + const active = document.activeElement as HTMLElement; + const idx = items.indexOf(active as HTMLLIElement); + + if (e.key === 'ArrowDown') { + e.preventDefault(); + const next = idx < items.length - 1 ? idx + 1 : 0; + items[next]?.focus(); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + const prev = idx > 0 ? idx - 1 : items.length - 1; + items[prev]?.focus(); + } + } + + return ( + setSearch('')}> + {trigger} + + {searchable && ( +
+ setSearch(e.target.value)} + placeholder={searchPlaceholder} + autoFocus + /> +
+ )} +
    + {filtered.map(option => ( +
  • onToggle(option)} + onKeyDown={e => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onToggle(option); + } + }} + > + {renderOption(option)} +
  • + ))} +
+
+
+ ); +} + +export {MultiSelect, type MultiSelectProps}; diff --git a/frontend/src/routes/components/AdvancedFilters.tsx b/frontend/src/routes/components/AdvancedFilters.tsx index 60e976dd..f9e800d1 100644 --- a/frontend/src/routes/components/AdvancedFilters.tsx +++ b/frontend/src/routes/components/AdvancedFilters.tsx @@ -1,188 +1,495 @@ -import {useNavigate, useSearch} from '@tanstack/react-router'; +import {useCallback, useEffect, useRef, useState} from 'react'; +import {useQuery} from '@tanstack/react-query'; +import {useNavigate} from '@tanstack/react-router'; import {Button} from 'components/Button'; +import {Card} from 'components/Card'; +import {Pill, type PillProps} from 'components/Pill'; import {Tag} from 'components/Tag'; -import {SlidersHorizontalIcon, XIcon} from 'lucide-react'; - -type ArrayFilterKey = - | 'severity' - | 'service_tier' - | 'affected_service' - | 'root_cause' - | 'impact_type' - | 'affected_region' - | 'captain' - | 'reporter'; - -type DateFilterKey = 'created_after' | 'created_before'; - -const FILTER_LABELS: Record = { - severity: 'Severity', - service_tier: 'Service Tier', - affected_service: 'Affected Service', - root_cause: 'Root Cause', - impact_type: 'Impact Type', - affected_region: 'Affected Region', - captain: 'Captain', - reporter: 'Reporter', - created_after: 'Created After', - created_before: 'Created Before', -}; - -const ARRAY_FILTER_KEYS: ArrayFilterKey[] = [ - 'severity', - 'service_tier', - 'affected_service', - 'root_cause', - 'impact_type', - 'affected_region', - 'captain', - 'reporter', -]; - -function useActiveFilters() { - const search = useSearch({from: '/'}); - - const activeFilters: {key: ArrayFilterKey; value: string; label: string}[] = []; - for (const key of ARRAY_FILTER_KEYS) { - const values = (search[key] as string[] | undefined) ?? []; - for (const value of values) { - activeFilters.push({key, value, label: FILTER_LABELS[key]}); - } - } +import {Pencil, SlidersHorizontalIcon, XIcon} from 'lucide-react'; +import {cn} from 'utils/cn'; - const activeCount = - activeFilters.length + - (search.created_after ? 1 : 0) + - (search.created_before ? 1 : 0); +import {tagsQueryOptions, type TagType} from '../$incidentId/queries/tagsQueryOptions'; +import {ServiceTierSchema, SeveritySchema} from '../types'; - return {search, activeFilters, activeCount}; -} +import {useActiveFilters, type ArrayFilterKey} from './useActiveFilters'; export function FilterTrigger({open, onToggle}: {open: boolean; onToggle: () => void}) { + const navigate = useNavigate(); const {activeCount} = useActiveFilters(); return ( - )} - + +
); } -export function FilterPanel() { +type PillVariant = NonNullable; + +interface PillFilterProps { + label: string; + filterKey: ArrayFilterKey; + options: readonly T[]; +} + +function PillFilter({ + label, + filterKey, + options, +}: PillFilterProps) { const navigate = useNavigate(); - const {search, activeFilters, activeCount} = useActiveFilters(); - - function removeArrayFilterValue(key: ArrayFilterKey, value: string) { - const current = (search[key] as string[] | undefined) ?? []; - const next = current.filter(v => v !== value); - navigate({ - to: '/', - search: prev => ({...prev, [key]: next.length > 0 ? next : undefined}), - }); - } - - function updateDateFilter(key: DateFilterKey, value: string | undefined) { - navigate({ - to: '/', - search: prev => ({...prev, [key]: value}), - }); - } - - function clearAll() { - navigate({ - to: '/', - search: prev => { - const next = {...prev}; - for (const key of ARRAY_FILTER_KEYS) { - delete next[key]; + const {search} = useActiveFilters(); + const selected = ((search[filterKey] as string[] | undefined) ?? []) as string[]; + const [isEditing, setIsEditing] = useState(false); + const [inputValue, setInputValue] = useState(''); + const [focusedIndex, setFocusedIndex] = useState(0); + const inputRef = useRef(null); + + const available = options.filter( + o => !selected.includes(o) && o.toLowerCase().includes(inputValue.toLowerCase()) + ); + + const toggle = useCallback( + (value: string) => { + const current = ((search[filterKey] as string[] | undefined) ?? []) as string[]; + const next = current.includes(value) + ? current.filter(v => v !== value) + : [...current, value]; + navigate({ + to: '/', + search: prev => ({...prev, [filterKey]: next.length > 0 ? next : undefined}), + replace: true, + }); + }, + [search, filterKey, navigate] + ); + + const close = useCallback(() => { + setIsEditing(false); + setInputValue(''); + setFocusedIndex(0); + }, []); + + const open = () => { + setIsEditing(true); + setInputValue(''); + setFocusedIndex(0); + }; + + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus(); + } + }, [isEditing]); + + useEffect(() => { + if (!isEditing) return; + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + close(); + } + }; + document.addEventListener('keydown', handleEscape); + return () => document.removeEventListener('keydown', handleEscape); + }, [isEditing, close]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + if (available.length > 0) { + setFocusedIndex(prev => (prev + 1) % available.length); + } + break; + case 'ArrowUp': + e.preventDefault(); + if (available.length > 0) { + setFocusedIndex(prev => (prev - 1 + available.length) % available.length); + } + break; + case 'Enter': + case ' ': + if (focusedIndex >= 0 && focusedIndex < available.length) { + e.preventDefault(); + toggle(available[focusedIndex]); + setInputValue(''); + setFocusedIndex(0); + inputRef.current?.focus(); + } else if (e.key === 'Enter' && !inputValue.trim()) { + close(); + } + break; + case 'Backspace': + if (inputValue === '' && selected.length > 0) { + toggle(selected[selected.length - 1]); } - delete next.created_after; - delete next.created_before; - return next; - }, - }); - } + break; + } + }; return ( -
-
-
Column 1
-
Column 2
+
+
+

{label}

+
- {activeCount > 0 && ( -
- {activeFilters.map(({key, value, label}) => ( - removeArrayFilterValue(key, value)} - className="text-content-secondary hover:text-content-primary cursor-pointer" - > - - - } - > - {label}: {value} - - ))} - {search.created_after && ( - + {isEditing ? ( +
+ {selected.map(v => ( + toggle(v)} + aria-label={`Remove ${v}`} + > + + + } + > + {v} + + ))} + { + setInputValue(e.target.value); + setFocusedIndex(0); + }} + onKeyDown={handleKeyDown} + placeholder="Add..." + className="px-space-sm py-space-xs text-size-sm placeholder:text-content-disabled min-w-[100px] flex-1 bg-transparent focus:outline-none" + /> +
+ ) : selected.length > 0 ? ( +
+ {selected.map(v => ( + + {v} + + ))} +
+ ) : ( +

Any

+ )} + + {isEditing && available.length > 0 && ( +
+
+ {available.map((option, index) => ( - } - > - After: {search.created_after} - - )} - {search.created_before && ( - +
+ )} +
+ + {isEditing && ( + + ); +} + +interface TagFilterProps { + label: string; + filterKey: ArrayFilterKey; + tagType: TagType; +} + +function TagFilter({label, filterKey, tagType}: TagFilterProps) { + const navigate = useNavigate(); + const {search} = useActiveFilters(); + const selected = ((search[filterKey] as string[] | undefined) ?? []) as string[]; + const {data: suggestions = []} = useQuery(tagsQueryOptions(tagType)); + const [isEditing, setIsEditing] = useState(false); + const [inputValue, setInputValue] = useState(''); + const [focusedIndex, setFocusedIndex] = useState(0); + const inputRef = useRef(null); + + const available = suggestions.filter( + s => !selected.includes(s) && s.toLowerCase().includes(inputValue.toLowerCase()) + ); + + const toggle = useCallback( + (value: string) => { + const current = ((search[filterKey] as string[] | undefined) ?? []) as string[]; + const next = current.includes(value) + ? current.filter(v => v !== value) + : [...current, value]; + navigate({ + to: '/', + search: prev => ({...prev, [filterKey]: next.length > 0 ? next : undefined}), + replace: true, + }); + }, + [search, filterKey, navigate] + ); + + const close = useCallback(() => { + setIsEditing(false); + setInputValue(''); + setFocusedIndex(0); + }, []); + + const open = () => { + setIsEditing(true); + setInputValue(''); + setFocusedIndex(0); + }; + + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus(); + } + }, [isEditing]); + + useEffect(() => { + if (!isEditing) return; + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + close(); + } + }; + document.addEventListener('keydown', handleEscape); + return () => document.removeEventListener('keydown', handleEscape); + }, [isEditing, close]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + if (available.length > 0) { + setFocusedIndex(prev => (prev + 1) % available.length); + } + break; + case 'ArrowUp': + e.preventDefault(); + if (available.length > 0) { + setFocusedIndex(prev => (prev - 1 + available.length) % available.length); + } + break; + case 'Enter': + case ' ': + if (focusedIndex >= 0 && focusedIndex < available.length) { + e.preventDefault(); + toggle(available[focusedIndex]); + setInputValue(''); + setFocusedIndex(0); + inputRef.current?.focus(); + } else if (e.key === 'Enter' && !inputValue.trim()) { + close(); + } + break; + case 'Backspace': + if (inputValue === '' && selected.length > 0) { + toggle(selected[selected.length - 1]); + } + break; + } + }; + + return ( +
+
+

{label}

+ +
+ +
+ {isEditing ? ( +
+ {selected.map(v => ( + toggle(v)} + aria-label={`Remove ${v}`} + > + + + } + > + {v} + + ))} + { + setInputValue(e.target.value); + setFocusedIndex(0); + }} + onKeyDown={handleKeyDown} + placeholder="Add..." + className="px-space-sm py-space-xs text-size-sm placeholder:text-content-disabled min-w-[100px] flex-1 bg-transparent focus:outline-none" + /> +
+ ) : selected.length > 0 ? ( +
+ {selected.map(v => ( + {v} + ))} +
+ ) : ( +

Any

+ )} + + {isEditing && available.length > 0 && ( +
+
+ {available.map((option, index) => ( - } - > - Before: {search.created_before} - - )} - -
+ ))} +
+
+ )} +
+ + {isEditing && ( + ); } + +export function FilterPanel() { + return ( + +
+ + + + + + +
+
+ ); +} diff --git a/frontend/src/routes/components/useActiveFilters.ts b/frontend/src/routes/components/useActiveFilters.ts new file mode 100644 index 00000000..4e5f25b2 --- /dev/null +++ b/frontend/src/routes/components/useActiveFilters.ts @@ -0,0 +1,56 @@ +import {useSearch} from '@tanstack/react-router'; + +export type ArrayFilterKey = + | 'severity' + | 'service_tier' + | 'affected_service' + | 'root_cause' + | 'impact_type' + | 'affected_region' + | 'captain' + | 'reporter'; + +export type DateFilterKey = 'created_after' | 'created_before'; + +export const FILTER_LABELS: Record = { + severity: 'Severity', + service_tier: 'Service Tier', + affected_service: 'Affected Service', + root_cause: 'Root Cause', + impact_type: 'Impact Type', + affected_region: 'Affected Region', + captain: 'Captain', + reporter: 'Reporter', + created_after: 'Created After', + created_before: 'Created Before', +}; + +export const ARRAY_FILTER_KEYS: ArrayFilterKey[] = [ + 'severity', + 'service_tier', + 'affected_service', + 'root_cause', + 'impact_type', + 'affected_region', + 'captain', + 'reporter', +]; + +export function useActiveFilters() { + const search = useSearch({from: '/'}); + + const activeFilters: {key: ArrayFilterKey; value: string; label: string}[] = []; + for (const key of ARRAY_FILTER_KEYS) { + const values = (search[key] as string[] | undefined) ?? []; + for (const value of values) { + activeFilters.push({key, value, label: FILTER_LABELS[key]}); + } + } + + const activeCount = + activeFilters.length + + (search.created_after ? 1 : 0) + + (search.created_before ? 1 : 0); + + return {search, activeFilters, activeCount}; +} diff --git a/frontend/src/routes/index.test.tsx b/frontend/src/routes/index.test.tsx index 290939a1..a7e2ce00 100644 --- a/frontend/src/routes/index.test.tsx +++ b/frontend/src/routes/index.test.tsx @@ -409,7 +409,10 @@ describe('Advanced Filters UI', () => { await screen.findByText('INC-1247'); - expect(screen.queryByText('Severity')).not.toBeInTheDocument(); + expect(screen.getByTestId('advanced-filters-toggle')).toHaveAttribute( + 'aria-expanded', + 'false' + ); }); it('shows filter controls when toggle is clicked', async () => { @@ -421,37 +424,18 @@ describe('Advanced Filters UI', () => { const toggle = screen.getByTestId('advanced-filters-toggle'); await user.click(toggle); - expect(screen.getByText('Severity')).toBeInTheDocument(); - expect(screen.getByText('Service Tier')).toBeInTheDocument(); - expect(screen.getByText('Captain')).toBeInTheDocument(); - expect(screen.getByText('Reporter')).toBeInTheDocument(); - expect(screen.getByText('Created After')).toBeInTheDocument(); - expect(screen.getByText('Created Before')).toBeInTheDocument(); - }); - - it('auto-opens when URL has advanced filters', async () => { - renderRoute('/?severity=P0'); - - await screen.findByText('INC-1247'); - expect(screen.getByText('Severity')).toBeInTheDocument(); }); - it('shows active filter tags', async () => { - renderRoute('/?severity=P0&service_tier=T0'); - - await screen.findByText('INC-1247'); - - expect(screen.getByText('Severity: P0')).toBeInTheDocument(); - expect(screen.getByText('Service Tier: T0')).toBeInTheDocument(); - }); - - it('shows clear all button when filters are active', async () => { + it('does not auto-open when URL has advanced filters', async () => { renderRoute('/?severity=P0'); await screen.findByText('INC-1247'); - expect(screen.getByTestId('clear-all-filters')).toBeInTheDocument(); + expect(screen.getByTestId('advanced-filters-toggle')).toHaveAttribute( + 'aria-expanded', + 'false' + ); }); }); diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx index 0eca6469..8b07268c 100644 --- a/frontend/src/routes/index.tsx +++ b/frontend/src/routes/index.tsx @@ -1,6 +1,6 @@ import {useEffect, useRef, useState} from 'react'; import {useSuspenseInfiniteQuery} from '@tanstack/react-query'; -import {createFileRoute, useSearch} from '@tanstack/react-router'; +import {createFileRoute} from '@tanstack/react-router'; import {zodValidator} from '@tanstack/zod-adapter'; import {ErrorState} from 'components/ErrorState'; import {GetHelpLink} from 'components/GetHelpLink'; @@ -39,33 +39,16 @@ const incidentListSearchSchema = z.object({ }); function IncidentsLayout({children}: {children: React.ReactNode}) { - const search = useSearch({from: '/'}); - const [open, setOpen] = useState(() => { - const keys = [ - 'severity', - 'service_tier', - 'affected_service', - 'root_cause', - 'impact_type', - 'affected_region', - 'captain', - 'reporter', - ] as const; - for (const key of keys) { - const val = search[key]; - if (Array.isArray(val) && val.length > 0) return true; - } - return !!(search.created_after || search.created_before); - }); + const [open, setOpen] = useState(false); return ( -
+
setOpen(prev => !prev)} />
{open && } -
+
{children}
); From b21eaf37dca94d3941303fd27ac210a889a69aa7 Mon Sep 17 00:00:00 2001 From: Spencer Murray Date: Wed, 11 Mar 2026 13:34:58 -0400 Subject: [PATCH 05/20] Defer filter param updates until edit close, add All status tab --- .../src/routes/components/AdvancedFilters.tsx | 70 ++++++++++--------- .../src/routes/components/StatusFilter.tsx | 10 ++- frontend/src/routes/index.test.tsx | 5 +- frontend/src/routes/types.ts | 1 + 4 files changed, 51 insertions(+), 35 deletions(-) diff --git a/frontend/src/routes/components/AdvancedFilters.tsx b/frontend/src/routes/components/AdvancedFilters.tsx index f9e800d1..169a2455 100644 --- a/frontend/src/routes/components/AdvancedFilters.tsx +++ b/frontend/src/routes/components/AdvancedFilters.tsx @@ -81,38 +81,41 @@ function PillFilter({ }: PillFilterProps) { const navigate = useNavigate(); const {search} = useActiveFilters(); - const selected = ((search[filterKey] as string[] | undefined) ?? []) as string[]; + const committed = ((search[filterKey] as string[] | undefined) ?? []) as string[]; const [isEditing, setIsEditing] = useState(false); + const [draft, setDraft] = useState([]); const [inputValue, setInputValue] = useState(''); const [focusedIndex, setFocusedIndex] = useState(0); const inputRef = useRef(null); + const selected = isEditing ? draft : committed; + const available = options.filter( o => !selected.includes(o) && o.toLowerCase().includes(inputValue.toLowerCase()) ); - const toggle = useCallback( - (value: string) => { - const current = ((search[filterKey] as string[] | undefined) ?? []) as string[]; - const next = current.includes(value) - ? current.filter(v => v !== value) - : [...current, value]; - navigate({ - to: '/', - search: prev => ({...prev, [filterKey]: next.length > 0 ? next : undefined}), - replace: true, - }); - }, - [search, filterKey, navigate] - ); + const toggle = useCallback((value: string) => { + setDraft(prev => + prev.includes(value) ? prev.filter(v => v !== value) : [...prev, value] + ); + }, []); const close = useCallback(() => { setIsEditing(false); setInputValue(''); setFocusedIndex(0); - }, []); + setDraft(prev => { + navigate({ + to: '/', + search: s => ({...s, [filterKey]: prev.length > 0 ? prev : undefined}), + replace: true, + }); + return prev; + }); + }, [navigate, filterKey]); const open = () => { + setDraft(committed); setIsEditing(true); setInputValue(''); setFocusedIndex(0); @@ -277,39 +280,42 @@ interface TagFilterProps { function TagFilter({label, filterKey, tagType}: TagFilterProps) { const navigate = useNavigate(); const {search} = useActiveFilters(); - const selected = ((search[filterKey] as string[] | undefined) ?? []) as string[]; + const committed = ((search[filterKey] as string[] | undefined) ?? []) as string[]; const {data: suggestions = []} = useQuery(tagsQueryOptions(tagType)); const [isEditing, setIsEditing] = useState(false); + const [draft, setDraft] = useState([]); const [inputValue, setInputValue] = useState(''); const [focusedIndex, setFocusedIndex] = useState(0); const inputRef = useRef(null); + const selected = isEditing ? draft : committed; + const available = suggestions.filter( s => !selected.includes(s) && s.toLowerCase().includes(inputValue.toLowerCase()) ); - const toggle = useCallback( - (value: string) => { - const current = ((search[filterKey] as string[] | undefined) ?? []) as string[]; - const next = current.includes(value) - ? current.filter(v => v !== value) - : [...current, value]; - navigate({ - to: '/', - search: prev => ({...prev, [filterKey]: next.length > 0 ? next : undefined}), - replace: true, - }); - }, - [search, filterKey, navigate] - ); + const toggle = useCallback((value: string) => { + setDraft(prev => + prev.includes(value) ? prev.filter(v => v !== value) : [...prev, value] + ); + }, []); const close = useCallback(() => { setIsEditing(false); setInputValue(''); setFocusedIndex(0); - }, []); + setDraft(prev => { + navigate({ + to: '/', + search: s => ({...s, [filterKey]: prev.length > 0 ? prev : undefined}), + replace: true, + }); + return prev; + }); + }, [navigate, filterKey]); const open = () => { + setDraft(committed); setIsEditing(true); setInputValue(''); setFocusedIndex(0); diff --git a/frontend/src/routes/components/StatusFilter.tsx b/frontend/src/routes/components/StatusFilter.tsx index 82b7e96d..f1bd60f6 100644 --- a/frontend/src/routes/components/StatusFilter.tsx +++ b/frontend/src/routes/components/StatusFilter.tsx @@ -2,10 +2,10 @@ import {Link, useSearch} from '@tanstack/react-router'; import {arraysEqual} from 'utils/arrays'; import {cn} from 'utils/cn'; -import {STATUS_FILTER_GROUPS, type IncidentStatus} from '../types'; +import {STATUS_FILTER_GROUPS} from '../types'; interface FilterLinkProps { - statuses?: IncidentStatus[]; + statuses?: string[]; label: string; isActive: boolean; testId?: string; @@ -58,6 +58,12 @@ export function StatusFilter() { isActive={arraysEqual(status ?? [], STATUS_FILTER_GROUPS.closed)} testId="filter-closed" /> +
); } diff --git a/frontend/src/routes/index.test.tsx b/frontend/src/routes/index.test.tsx index a7e2ce00..a9607730 100644 --- a/frontend/src/routes/index.test.tsx +++ b/frontend/src/routes/index.test.tsx @@ -174,12 +174,13 @@ describe('StatusFilter', () => { setupDefaultMocks(); }); - it('renders all three filter buttons', async () => { + it('renders all four filter buttons', async () => { renderRoute(); expect(await screen.findByTestId('filter-active')).toBeInTheDocument(); expect(await screen.findByTestId('filter-review')).toBeInTheDocument(); expect(await screen.findByTestId('filter-closed')).toBeInTheDocument(); + expect(await screen.findByTestId('filter-all')).toBeInTheDocument(); }); it('shows Active filter as active by default', async () => { @@ -245,10 +246,12 @@ describe('StatusFilter', () => { const activeButton = screen.getByTestId('filter-active'); const reviewButton = screen.getByTestId('filter-review'); const closedButton = screen.getByTestId('filter-closed'); + const allButton = screen.getByTestId('filter-all'); expect(activeButton).toHaveAttribute('aria-selected', 'false'); expect(reviewButton).toHaveAttribute('aria-selected', 'false'); expect(closedButton).toHaveAttribute('aria-selected', 'false'); + expect(allButton).toHaveAttribute('aria-selected', 'false'); }); }); diff --git a/frontend/src/routes/types.ts b/frontend/src/routes/types.ts index d0c9bc07..4e8746d8 100644 --- a/frontend/src/routes/types.ts +++ b/frontend/src/routes/types.ts @@ -18,4 +18,5 @@ export const STATUS_FILTER_GROUPS = { active: ['Active', 'Mitigated'] as IncidentStatus[], review: ['Postmortem'] as IncidentStatus[], closed: ['Done', 'Cancelled'] as IncidentStatus[], + all: ['Any'] as string[], }; From a585969aa26c69d06fc1ed42c7b790e376a767a6 Mon Sep 17 00:00:00 2001 From: Spencer Murray Date: Wed, 11 Mar 2026 13:52:38 -0400 Subject: [PATCH 06/20] Add captain and reporter filters with server-side user search --- .../src/routes/components/AdvancedFilters.tsx | 245 +++++++++++++++++- frontend/src/routes/index.test.tsx | 2 + .../src/routes/queries/usersQueryOptions.ts | 39 +++ 3 files changed, 284 insertions(+), 2 deletions(-) create mode 100644 frontend/src/routes/queries/usersQueryOptions.ts diff --git a/frontend/src/routes/components/AdvancedFilters.tsx b/frontend/src/routes/components/AdvancedFilters.tsx index 169a2455..e7df5b08 100644 --- a/frontend/src/routes/components/AdvancedFilters.tsx +++ b/frontend/src/routes/components/AdvancedFilters.tsx @@ -1,5 +1,5 @@ import {useCallback, useEffect, useRef, useState} from 'react'; -import {useQuery} from '@tanstack/react-query'; +import {useInfiniteQuery, useQuery} from '@tanstack/react-query'; import {useNavigate} from '@tanstack/react-router'; import {Button} from 'components/Button'; import {Card} from 'components/Card'; @@ -9,6 +9,7 @@ import {Pencil, SlidersHorizontalIcon, XIcon} from 'lucide-react'; import {cn} from 'utils/cn'; import {tagsQueryOptions, type TagType} from '../$incidentId/queries/tagsQueryOptions'; +import {usersInfiniteQueryOptions} from '../queries/usersQueryOptions'; import {ServiceTierSchema, SeveritySchema} from '../types'; import {useActiveFilters, type ArrayFilterKey} from './useActiveFilters'; @@ -469,10 +470,248 @@ function TagFilter({label, filterKey, tagType}: TagFilterProps) { ); } +interface UserFilterProps { + label: string; + filterKey: ArrayFilterKey; +} + +function UserFilter({label, filterKey}: UserFilterProps) { + const navigate = useNavigate(); + const {search} = useActiveFilters(); + const committed = ((search[filterKey] as string[] | undefined) ?? []) as string[]; + const [isEditing, setIsEditing] = useState(false); + const [draft, setDraft] = useState([]); + const [inputValue, setInputValue] = useState(''); + const [debouncedSearch, setDebouncedSearch] = useState(''); + const [focusedIndex, setFocusedIndex] = useState(0); + const inputRef = useRef(null); + const scrollSentinelRef = useRef(null); + + const selected = isEditing ? draft : committed; + + const { + data: users = [], + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useInfiniteQuery({ + ...usersInfiniteQueryOptions(debouncedSearch), + enabled: isEditing, + }); + + const available = users.filter(u => !selected.includes(u.email)); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedSearch(inputValue), 300); + return () => clearTimeout(timer); + }, [inputValue]); + + useEffect(() => { + const target = scrollSentinelRef.current; + if (!target || !isEditing) return; + + const observer = new IntersectionObserver( + entries => { + if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, + {threshold: 0.1} + ); + + observer.observe(target); + return () => observer.disconnect(); + }, [isEditing, fetchNextPage, hasNextPage, isFetchingNextPage]); + + const toggle = useCallback((value: string) => { + setDraft(prev => + prev.includes(value) ? prev.filter(v => v !== value) : [...prev, value] + ); + }, []); + + const close = useCallback(() => { + setIsEditing(false); + setInputValue(''); + setDebouncedSearch(''); + setFocusedIndex(0); + setDraft(prev => { + navigate({ + to: '/', + search: s => ({...s, [filterKey]: prev.length > 0 ? prev : undefined}), + replace: true, + }); + return prev; + }); + }, [navigate, filterKey]); + + const open = () => { + setDraft(committed); + setIsEditing(true); + setInputValue(''); + setDebouncedSearch(''); + setFocusedIndex(0); + }; + + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus(); + } + }, [isEditing]); + + useEffect(() => { + if (!isEditing) return; + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + close(); + } + }; + document.addEventListener('keydown', handleEscape); + return () => document.removeEventListener('keydown', handleEscape); + }, [isEditing, close]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + if (available.length > 0) { + setFocusedIndex(prev => (prev + 1) % available.length); + } + break; + case 'ArrowUp': + e.preventDefault(); + if (available.length > 0) { + setFocusedIndex(prev => (prev - 1 + available.length) % available.length); + } + break; + case 'Enter': + case ' ': + if (focusedIndex >= 0 && focusedIndex < available.length) { + e.preventDefault(); + toggle(available[focusedIndex].email); + setInputValue(''); + setFocusedIndex(0); + inputRef.current?.focus(); + } else if (e.key === 'Enter' && !inputValue.trim()) { + close(); + } + break; + case 'Backspace': + if (inputValue === '' && selected.length > 0) { + toggle(selected[selected.length - 1]); + } + break; + } + }; + + return ( +
+
+

{label}

+ +
+ +
+ {isEditing ? ( +
+ {selected.map(v => ( + toggle(v)} + aria-label={`Remove ${v}`} + > + + + } + > + {v} + + ))} + { + setInputValue(e.target.value); + setFocusedIndex(0); + }} + onKeyDown={handleKeyDown} + placeholder="Search users..." + className="px-space-sm py-space-xs text-size-sm placeholder:text-content-disabled min-w-[100px] flex-1 bg-transparent focus:outline-none" + /> +
+ ) : selected.length > 0 ? ( +
+ {selected.map(v => ( + {v} + ))} +
+ ) : ( +

Any

+ )} + + {isEditing && ( +
+
+ {available.length > 0 ? ( + available.map((user, index) => ( + + )) + ) : ( +

+ No users found +

+ )} +
+
+
+ )} +
+ + {isEditing && ( + + ); +} + export function FilterPanel() { return ( -
+
+ +
); diff --git a/frontend/src/routes/index.test.tsx b/frontend/src/routes/index.test.tsx index a9607730..f3c21b42 100644 --- a/frontend/src/routes/index.test.tsx +++ b/frontend/src/routes/index.test.tsx @@ -32,6 +32,7 @@ const mockIncidents: PaginatedIncidents = { service_tier: null, created_at: '2024-08-27T18:14:00Z', is_private: false, + captain: null, }, { id: 'INC-1246', @@ -43,6 +44,7 @@ const mockIncidents: PaginatedIncidents = { service_tier: null, created_at: '2024-08-27T15:32:00Z', is_private: true, + captain: null, }, ], }; diff --git a/frontend/src/routes/queries/usersQueryOptions.ts b/frontend/src/routes/queries/usersQueryOptions.ts new file mode 100644 index 00000000..605d0aa0 --- /dev/null +++ b/frontend/src/routes/queries/usersQueryOptions.ts @@ -0,0 +1,39 @@ +import {infiniteQueryOptions} from '@tanstack/react-query'; +import {Api} from 'api'; +import {z} from 'zod'; + +const UserSchema = z.object({ + email: z.string(), + name: z.string(), + avatar_url: z.string().nullable(), +}); + +const PaginatedUsersSchema = z.object({ + count: z.number(), + next: z.string().nullable(), + previous: z.string().nullable(), + results: z.array(UserSchema), +}); + +export type User = z.infer; + +export function usersInfiniteQueryOptions(search?: string) { + return infiniteQueryOptions({ + queryKey: ['Users', {search}], + queryFn: ({signal, pageParam}) => + Api.get({ + path: '/users/', + query: {...(search ? {search} : {}), page: pageParam}, + signal, + responseSchema: PaginatedUsersSchema, + }), + initialPageParam: 1, + getNextPageParam: lastPage => { + if (!lastPage.next) return undefined; + const url = new URL(lastPage.next); + const page = url.searchParams.get('page'); + return page ? parseInt(page, 10) : undefined; + }, + select: data => data.pages.flatMap(page => page.results), + }); +} From 56c350c9363c5b21908b88f22aa4256689ce056e Mon Sep 17 00:00:00 2001 From: Spencer Murray Date: Wed, 11 Mar 2026 16:15:21 -0400 Subject: [PATCH 07/20] Add date range filter with responsive grid layout --- .../src/routes/components/AdvancedFilters.tsx | 126 +++++++++++++++++- 1 file changed, 125 insertions(+), 1 deletion(-) diff --git a/frontend/src/routes/components/AdvancedFilters.tsx b/frontend/src/routes/components/AdvancedFilters.tsx index e7df5b08..1a1778e4 100644 --- a/frontend/src/routes/components/AdvancedFilters.tsx +++ b/frontend/src/routes/components/AdvancedFilters.tsx @@ -2,8 +2,10 @@ import {useCallback, useEffect, useRef, useState} from 'react'; import {useInfiniteQuery, useQuery} from '@tanstack/react-query'; import {useNavigate} from '@tanstack/react-router'; import {Button} from 'components/Button'; +import {Calendar} from 'components/Calendar'; import {Card} from 'components/Card'; import {Pill, type PillProps} from 'components/Pill'; +import {Popover, PopoverContent, PopoverTrigger} from 'components/Popover'; import {Tag} from 'components/Tag'; import {Pencil, SlidersHorizontalIcon, XIcon} from 'lucide-react'; import {cn} from 'utils/cn'; @@ -708,10 +710,131 @@ function UserFilter({label, filterKey}: UserFilterProps) { ); } +function formatDateDisplay(dateStr: string | undefined): string { + if (!dateStr) return ''; + const date = new Date(dateStr.includes('T') ? dateStr : dateStr + 'T00:00:00'); + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); +} + +function toDateString(date: Date): string { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; +} + +function DateRangeFilter() { + const navigate = useNavigate(); + const {search} = useActiveFilters(); + const after = search.created_after as string | undefined; + const before = search.created_before as string | undefined; + const [editing, setEditing] = useState<'after' | 'before' | null>(null); + + const afterDate = after ? new Date(after + 'T00:00:00') : undefined; + const beforeDate = before ? new Date(before + 'T00:00:00') : undefined; + + const update = (key: 'created_after' | 'created_before', value: string | undefined) => { + navigate({ + to: '/', + search: prev => ({...prev, [key]: value}), + replace: true, + }); + }; + + const handleDateSelect = ( + key: 'created_after' | 'created_before', + date: Date | undefined + ) => { + update(key, date ? toDateString(date) : undefined); + setEditing(null); + }; + + return ( +
+
+

+ Created Date +

+
+
+
+ setEditing(o ? 'after' : null)} + > + + + + + handleDateSelect('created_after', d)} + /> + + + {after && ( + + )} +
+ to +
+ setEditing(o ? 'before' : null)} + > + + + + + handleDateSelect('created_before', d)} + /> + + + {before && ( + + )} +
+
+
+ ); +} + export function FilterPanel() { return ( -
+
+
); From 6122efb6b311598b648567e06db6a9a754e3cb7a Mon Sep 17 00:00:00 2001 From: Spencer Murray Date: Wed, 11 Mar 2026 16:33:28 -0400 Subject: [PATCH 08/20] Refactor filter components --- .../src/routes/components/AdvancedFilters.tsx | 832 +----------------- .../components/filters/DateRangeFilter.tsx | 129 +++ .../components/filters/FilterTrigger.tsx | 59 ++ .../routes/components/filters/PillFilter.tsx | 214 +++++ .../routes/components/filters/TagFilter.tsx | 208 +++++ .../routes/components/filters/UserFilter.tsx | 248 ++++++ 6 files changed, 863 insertions(+), 827 deletions(-) create mode 100644 frontend/src/routes/components/filters/DateRangeFilter.tsx create mode 100644 frontend/src/routes/components/filters/FilterTrigger.tsx create mode 100644 frontend/src/routes/components/filters/PillFilter.tsx create mode 100644 frontend/src/routes/components/filters/TagFilter.tsx create mode 100644 frontend/src/routes/components/filters/UserFilter.tsx diff --git a/frontend/src/routes/components/AdvancedFilters.tsx b/frontend/src/routes/components/AdvancedFilters.tsx index 1a1778e4..0a22d1d0 100644 --- a/frontend/src/routes/components/AdvancedFilters.tsx +++ b/frontend/src/routes/components/AdvancedFilters.tsx @@ -1,835 +1,13 @@ -import {useCallback, useEffect, useRef, useState} from 'react'; -import {useInfiniteQuery, useQuery} from '@tanstack/react-query'; -import {useNavigate} from '@tanstack/react-router'; -import {Button} from 'components/Button'; -import {Calendar} from 'components/Calendar'; import {Card} from 'components/Card'; -import {Pill, type PillProps} from 'components/Pill'; -import {Popover, PopoverContent, PopoverTrigger} from 'components/Popover'; -import {Tag} from 'components/Tag'; -import {Pencil, SlidersHorizontalIcon, XIcon} from 'lucide-react'; -import {cn} from 'utils/cn'; -import {tagsQueryOptions, type TagType} from '../$incidentId/queries/tagsQueryOptions'; -import {usersInfiniteQueryOptions} from '../queries/usersQueryOptions'; import {ServiceTierSchema, SeveritySchema} from '../types'; -import {useActiveFilters, type ArrayFilterKey} from './useActiveFilters'; +import {DateRangeFilter} from './filters/DateRangeFilter'; +import {PillFilter} from './filters/PillFilter'; +import {TagFilter} from './filters/TagFilter'; +import {UserFilter} from './filters/UserFilter'; -export function FilterTrigger({open, onToggle}: {open: boolean; onToggle: () => void}) { - const navigate = useNavigate(); - const {activeCount} = useActiveFilters(); - - return ( -
- {activeCount > 0 && ( - - )} - -
- ); -} - -type PillVariant = NonNullable; - -interface PillFilterProps { - label: string; - filterKey: ArrayFilterKey; - options: readonly T[]; -} - -function PillFilter({ - label, - filterKey, - options, -}: PillFilterProps) { - const navigate = useNavigate(); - const {search} = useActiveFilters(); - const committed = ((search[filterKey] as string[] | undefined) ?? []) as string[]; - const [isEditing, setIsEditing] = useState(false); - const [draft, setDraft] = useState([]); - const [inputValue, setInputValue] = useState(''); - const [focusedIndex, setFocusedIndex] = useState(0); - const inputRef = useRef(null); - - const selected = isEditing ? draft : committed; - - const available = options.filter( - o => !selected.includes(o) && o.toLowerCase().includes(inputValue.toLowerCase()) - ); - - const toggle = useCallback((value: string) => { - setDraft(prev => - prev.includes(value) ? prev.filter(v => v !== value) : [...prev, value] - ); - }, []); - - const close = useCallback(() => { - setIsEditing(false); - setInputValue(''); - setFocusedIndex(0); - setDraft(prev => { - navigate({ - to: '/', - search: s => ({...s, [filterKey]: prev.length > 0 ? prev : undefined}), - replace: true, - }); - return prev; - }); - }, [navigate, filterKey]); - - const open = () => { - setDraft(committed); - setIsEditing(true); - setInputValue(''); - setFocusedIndex(0); - }; - - useEffect(() => { - if (isEditing && inputRef.current) { - inputRef.current.focus(); - } - }, [isEditing]); - - useEffect(() => { - if (!isEditing) return; - const handleEscape = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - e.preventDefault(); - close(); - } - }; - document.addEventListener('keydown', handleEscape); - return () => document.removeEventListener('keydown', handleEscape); - }, [isEditing, close]); - - const handleKeyDown = (e: React.KeyboardEvent) => { - switch (e.key) { - case 'ArrowDown': - e.preventDefault(); - if (available.length > 0) { - setFocusedIndex(prev => (prev + 1) % available.length); - } - break; - case 'ArrowUp': - e.preventDefault(); - if (available.length > 0) { - setFocusedIndex(prev => (prev - 1 + available.length) % available.length); - } - break; - case 'Enter': - case ' ': - if (focusedIndex >= 0 && focusedIndex < available.length) { - e.preventDefault(); - toggle(available[focusedIndex]); - setInputValue(''); - setFocusedIndex(0); - inputRef.current?.focus(); - } else if (e.key === 'Enter' && !inputValue.trim()) { - close(); - } - break; - case 'Backspace': - if (inputValue === '' && selected.length > 0) { - toggle(selected[selected.length - 1]); - } - break; - } - }; - - return ( -
-
-

{label}

- -
- -
- {isEditing ? ( -
- {selected.map(v => ( - toggle(v)} - aria-label={`Remove ${v}`} - > - - - } - > - {v} - - ))} - { - setInputValue(e.target.value); - setFocusedIndex(0); - }} - onKeyDown={handleKeyDown} - placeholder="Add..." - className="px-space-sm py-space-xs text-size-sm placeholder:text-content-disabled min-w-[100px] flex-1 bg-transparent focus:outline-none" - /> -
- ) : selected.length > 0 ? ( -
- {selected.map(v => ( - - {v} - - ))} -
- ) : ( -

Any

- )} - - {isEditing && available.length > 0 && ( -
-
- {available.map((option, index) => ( - - ))} -
-
- )} -
- - {isEditing && ( - - ); -} - -interface TagFilterProps { - label: string; - filterKey: ArrayFilterKey; - tagType: TagType; -} - -function TagFilter({label, filterKey, tagType}: TagFilterProps) { - const navigate = useNavigate(); - const {search} = useActiveFilters(); - const committed = ((search[filterKey] as string[] | undefined) ?? []) as string[]; - const {data: suggestions = []} = useQuery(tagsQueryOptions(tagType)); - const [isEditing, setIsEditing] = useState(false); - const [draft, setDraft] = useState([]); - const [inputValue, setInputValue] = useState(''); - const [focusedIndex, setFocusedIndex] = useState(0); - const inputRef = useRef(null); - - const selected = isEditing ? draft : committed; - - const available = suggestions.filter( - s => !selected.includes(s) && s.toLowerCase().includes(inputValue.toLowerCase()) - ); - - const toggle = useCallback((value: string) => { - setDraft(prev => - prev.includes(value) ? prev.filter(v => v !== value) : [...prev, value] - ); - }, []); - - const close = useCallback(() => { - setIsEditing(false); - setInputValue(''); - setFocusedIndex(0); - setDraft(prev => { - navigate({ - to: '/', - search: s => ({...s, [filterKey]: prev.length > 0 ? prev : undefined}), - replace: true, - }); - return prev; - }); - }, [navigate, filterKey]); - - const open = () => { - setDraft(committed); - setIsEditing(true); - setInputValue(''); - setFocusedIndex(0); - }; - - useEffect(() => { - if (isEditing && inputRef.current) { - inputRef.current.focus(); - } - }, [isEditing]); - - useEffect(() => { - if (!isEditing) return; - const handleEscape = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - e.preventDefault(); - close(); - } - }; - document.addEventListener('keydown', handleEscape); - return () => document.removeEventListener('keydown', handleEscape); - }, [isEditing, close]); - - const handleKeyDown = (e: React.KeyboardEvent) => { - switch (e.key) { - case 'ArrowDown': - e.preventDefault(); - if (available.length > 0) { - setFocusedIndex(prev => (prev + 1) % available.length); - } - break; - case 'ArrowUp': - e.preventDefault(); - if (available.length > 0) { - setFocusedIndex(prev => (prev - 1 + available.length) % available.length); - } - break; - case 'Enter': - case ' ': - if (focusedIndex >= 0 && focusedIndex < available.length) { - e.preventDefault(); - toggle(available[focusedIndex]); - setInputValue(''); - setFocusedIndex(0); - inputRef.current?.focus(); - } else if (e.key === 'Enter' && !inputValue.trim()) { - close(); - } - break; - case 'Backspace': - if (inputValue === '' && selected.length > 0) { - toggle(selected[selected.length - 1]); - } - break; - } - }; - - return ( -
-
-

{label}

- -
- -
- {isEditing ? ( -
- {selected.map(v => ( - toggle(v)} - aria-label={`Remove ${v}`} - > - - - } - > - {v} - - ))} - { - setInputValue(e.target.value); - setFocusedIndex(0); - }} - onKeyDown={handleKeyDown} - placeholder="Add..." - className="px-space-sm py-space-xs text-size-sm placeholder:text-content-disabled min-w-[100px] flex-1 bg-transparent focus:outline-none" - /> -
- ) : selected.length > 0 ? ( -
- {selected.map(v => ( - {v} - ))} -
- ) : ( -

Any

- )} - - {isEditing && available.length > 0 && ( -
-
- {available.map((option, index) => ( - - ))} -
-
- )} -
- - {isEditing && ( - - ); -} - -interface UserFilterProps { - label: string; - filterKey: ArrayFilterKey; -} - -function UserFilter({label, filterKey}: UserFilterProps) { - const navigate = useNavigate(); - const {search} = useActiveFilters(); - const committed = ((search[filterKey] as string[] | undefined) ?? []) as string[]; - const [isEditing, setIsEditing] = useState(false); - const [draft, setDraft] = useState([]); - const [inputValue, setInputValue] = useState(''); - const [debouncedSearch, setDebouncedSearch] = useState(''); - const [focusedIndex, setFocusedIndex] = useState(0); - const inputRef = useRef(null); - const scrollSentinelRef = useRef(null); - - const selected = isEditing ? draft : committed; - - const { - data: users = [], - fetchNextPage, - hasNextPage, - isFetchingNextPage, - } = useInfiniteQuery({ - ...usersInfiniteQueryOptions(debouncedSearch), - enabled: isEditing, - }); - - const available = users.filter(u => !selected.includes(u.email)); - - useEffect(() => { - const timer = setTimeout(() => setDebouncedSearch(inputValue), 300); - return () => clearTimeout(timer); - }, [inputValue]); - - useEffect(() => { - const target = scrollSentinelRef.current; - if (!target || !isEditing) return; - - const observer = new IntersectionObserver( - entries => { - if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) { - fetchNextPage(); - } - }, - {threshold: 0.1} - ); - - observer.observe(target); - return () => observer.disconnect(); - }, [isEditing, fetchNextPage, hasNextPage, isFetchingNextPage]); - - const toggle = useCallback((value: string) => { - setDraft(prev => - prev.includes(value) ? prev.filter(v => v !== value) : [...prev, value] - ); - }, []); - - const close = useCallback(() => { - setIsEditing(false); - setInputValue(''); - setDebouncedSearch(''); - setFocusedIndex(0); - setDraft(prev => { - navigate({ - to: '/', - search: s => ({...s, [filterKey]: prev.length > 0 ? prev : undefined}), - replace: true, - }); - return prev; - }); - }, [navigate, filterKey]); - - const open = () => { - setDraft(committed); - setIsEditing(true); - setInputValue(''); - setDebouncedSearch(''); - setFocusedIndex(0); - }; - - useEffect(() => { - if (isEditing && inputRef.current) { - inputRef.current.focus(); - } - }, [isEditing]); - - useEffect(() => { - if (!isEditing) return; - const handleEscape = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - e.preventDefault(); - close(); - } - }; - document.addEventListener('keydown', handleEscape); - return () => document.removeEventListener('keydown', handleEscape); - }, [isEditing, close]); - - const handleKeyDown = (e: React.KeyboardEvent) => { - switch (e.key) { - case 'ArrowDown': - e.preventDefault(); - if (available.length > 0) { - setFocusedIndex(prev => (prev + 1) % available.length); - } - break; - case 'ArrowUp': - e.preventDefault(); - if (available.length > 0) { - setFocusedIndex(prev => (prev - 1 + available.length) % available.length); - } - break; - case 'Enter': - case ' ': - if (focusedIndex >= 0 && focusedIndex < available.length) { - e.preventDefault(); - toggle(available[focusedIndex].email); - setInputValue(''); - setFocusedIndex(0); - inputRef.current?.focus(); - } else if (e.key === 'Enter' && !inputValue.trim()) { - close(); - } - break; - case 'Backspace': - if (inputValue === '' && selected.length > 0) { - toggle(selected[selected.length - 1]); - } - break; - } - }; - - return ( -
-
-

{label}

- -
- -
- {isEditing ? ( -
- {selected.map(v => ( - toggle(v)} - aria-label={`Remove ${v}`} - > - - - } - > - {v} - - ))} - { - setInputValue(e.target.value); - setFocusedIndex(0); - }} - onKeyDown={handleKeyDown} - placeholder="Search users..." - className="px-space-sm py-space-xs text-size-sm placeholder:text-content-disabled min-w-[100px] flex-1 bg-transparent focus:outline-none" - /> -
- ) : selected.length > 0 ? ( -
- {selected.map(v => ( - {v} - ))} -
- ) : ( -

Any

- )} - - {isEditing && ( -
-
- {available.length > 0 ? ( - available.map((user, index) => ( - - )) - ) : ( -

- No users found -

- )} -
-
-
- )} -
- - {isEditing && ( - - ); -} - -function formatDateDisplay(dateStr: string | undefined): string { - if (!dateStr) return ''; - const date = new Date(dateStr.includes('T') ? dateStr : dateStr + 'T00:00:00'); - return date.toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - }); -} - -function toDateString(date: Date): string { - const y = date.getFullYear(); - const m = String(date.getMonth() + 1).padStart(2, '0'); - const d = String(date.getDate()).padStart(2, '0'); - return `${y}-${m}-${d}`; -} - -function DateRangeFilter() { - const navigate = useNavigate(); - const {search} = useActiveFilters(); - const after = search.created_after as string | undefined; - const before = search.created_before as string | undefined; - const [editing, setEditing] = useState<'after' | 'before' | null>(null); - - const afterDate = after ? new Date(after + 'T00:00:00') : undefined; - const beforeDate = before ? new Date(before + 'T00:00:00') : undefined; - - const update = (key: 'created_after' | 'created_before', value: string | undefined) => { - navigate({ - to: '/', - search: prev => ({...prev, [key]: value}), - replace: true, - }); - }; - - const handleDateSelect = ( - key: 'created_after' | 'created_before', - date: Date | undefined - ) => { - update(key, date ? toDateString(date) : undefined); - setEditing(null); - }; - - return ( -
-
-

- Created Date -

-
-
-
- setEditing(o ? 'after' : null)} - > - - - - - handleDateSelect('created_after', d)} - /> - - - {after && ( - - )} -
- to -
- setEditing(o ? 'before' : null)} - > - - - - - handleDateSelect('created_before', d)} - /> - - - {before && ( - - )} -
-
-
- ); -} +export {FilterTrigger} from './filters/FilterTrigger'; export function FilterPanel() { return ( diff --git a/frontend/src/routes/components/filters/DateRangeFilter.tsx b/frontend/src/routes/components/filters/DateRangeFilter.tsx new file mode 100644 index 00000000..95c882ea --- /dev/null +++ b/frontend/src/routes/components/filters/DateRangeFilter.tsx @@ -0,0 +1,129 @@ +import {useState} from 'react'; +import {useNavigate} from '@tanstack/react-router'; +import {Button} from 'components/Button'; +import {Calendar} from 'components/Calendar'; +import {Popover, PopoverContent, PopoverTrigger} from 'components/Popover'; +import {XIcon} from 'lucide-react'; + +import {useActiveFilters} from '../useActiveFilters'; + +function formatDateDisplay(dateStr: string | undefined): string { + if (!dateStr) return ''; + const date = new Date(dateStr.includes('T') ? dateStr : dateStr + 'T00:00:00'); + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); +} + +function toDateString(date: Date): string { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; +} + +export function DateRangeFilter() { + const navigate = useNavigate(); + const {search} = useActiveFilters(); + const after = search.created_after as string | undefined; + const before = search.created_before as string | undefined; + const [editing, setEditing] = useState<'after' | 'before' | null>(null); + + const afterDate = after ? new Date(after + 'T00:00:00') : undefined; + const beforeDate = before ? new Date(before + 'T00:00:00') : undefined; + + const update = (key: 'created_after' | 'created_before', value: string | undefined) => { + navigate({ + to: '/', + search: prev => ({...prev, [key]: value}), + replace: true, + }); + }; + + const handleDateSelect = ( + key: 'created_after' | 'created_before', + date: Date | undefined + ) => { + update(key, date ? toDateString(date) : undefined); + setEditing(null); + }; + + return ( +
+
+

+ Created Date +

+
+
+
+ setEditing(o ? 'after' : null)} + > + + + + + handleDateSelect('created_after', d)} + /> + + + {after && ( + + )} +
+ to +
+ setEditing(o ? 'before' : null)} + > + + + + + handleDateSelect('created_before', d)} + /> + + + {before && ( + + )} +
+
+
+ ); +} diff --git a/frontend/src/routes/components/filters/FilterTrigger.tsx b/frontend/src/routes/components/filters/FilterTrigger.tsx new file mode 100644 index 00000000..b7089d6e --- /dev/null +++ b/frontend/src/routes/components/filters/FilterTrigger.tsx @@ -0,0 +1,59 @@ +import {useNavigate} from '@tanstack/react-router'; +import {Button} from 'components/Button'; +import {SlidersHorizontalIcon} from 'lucide-react'; + +import {useActiveFilters} from '../useActiveFilters'; + +export function FilterTrigger({open, onToggle}: {open: boolean; onToggle: () => void}) { + const navigate = useNavigate(); + const {activeCount} = useActiveFilters(); + + return ( +
+ {activeCount > 0 && ( + + )} + +
+ ); +} diff --git a/frontend/src/routes/components/filters/PillFilter.tsx b/frontend/src/routes/components/filters/PillFilter.tsx new file mode 100644 index 00000000..9cb8b4d3 --- /dev/null +++ b/frontend/src/routes/components/filters/PillFilter.tsx @@ -0,0 +1,214 @@ +import {useCallback, useEffect, useRef, useState} from 'react'; +import {useNavigate} from '@tanstack/react-router'; +import {Button} from 'components/Button'; +import {Pill, type PillProps} from 'components/Pill'; +import {Tag} from 'components/Tag'; +import {Pencil, XIcon} from 'lucide-react'; +import {cn} from 'utils/cn'; + +import {useActiveFilters, type ArrayFilterKey} from '../useActiveFilters'; + +type PillVariant = NonNullable; + +interface PillFilterProps { + label: string; + filterKey: ArrayFilterKey; + options: readonly T[]; +} + +export function PillFilter({ + label, + filterKey, + options, +}: PillFilterProps) { + const navigate = useNavigate(); + const {search} = useActiveFilters(); + const committed = ((search[filterKey] as string[] | undefined) ?? []) as string[]; + const [isEditing, setIsEditing] = useState(false); + const [draft, setDraft] = useState([]); + const [inputValue, setInputValue] = useState(''); + const [focusedIndex, setFocusedIndex] = useState(0); + const inputRef = useRef(null); + + const selected = isEditing ? draft : committed; + + const available = options.filter( + o => !selected.includes(o) && o.toLowerCase().includes(inputValue.toLowerCase()) + ); + + const toggle = useCallback((value: string) => { + setDraft(prev => + prev.includes(value) ? prev.filter(v => v !== value) : [...prev, value] + ); + }, []); + + const close = useCallback(() => { + setIsEditing(false); + setInputValue(''); + setFocusedIndex(0); + setDraft(prev => { + navigate({ + to: '/', + search: s => ({...s, [filterKey]: prev.length > 0 ? prev : undefined}), + replace: true, + }); + return prev; + }); + }, [navigate, filterKey]); + + const open = () => { + setDraft(committed); + setIsEditing(true); + setInputValue(''); + setFocusedIndex(0); + }; + + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus(); + } + }, [isEditing]); + + useEffect(() => { + if (!isEditing) return; + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + close(); + } + }; + document.addEventListener('keydown', handleEscape); + return () => document.removeEventListener('keydown', handleEscape); + }, [isEditing, close]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + if (available.length > 0) { + setFocusedIndex(prev => (prev + 1) % available.length); + } + break; + case 'ArrowUp': + e.preventDefault(); + if (available.length > 0) { + setFocusedIndex(prev => (prev - 1 + available.length) % available.length); + } + break; + case 'Enter': + case ' ': + if (focusedIndex >= 0 && focusedIndex < available.length) { + e.preventDefault(); + toggle(available[focusedIndex]); + setInputValue(''); + setFocusedIndex(0); + inputRef.current?.focus(); + } else if (e.key === 'Enter' && !inputValue.trim()) { + close(); + } + break; + case 'Backspace': + if (inputValue === '' && selected.length > 0) { + toggle(selected[selected.length - 1]); + } + break; + } + }; + + return ( +
+
+

{label}

+ +
+ +
+ {isEditing ? ( +
+ {selected.map(v => ( + toggle(v)} + aria-label={`Remove ${v}`} + > + + + } + > + {v} + + ))} + { + setInputValue(e.target.value); + setFocusedIndex(0); + }} + onKeyDown={handleKeyDown} + placeholder="Add..." + className="px-space-sm py-space-xs text-size-sm placeholder:text-content-disabled min-w-[100px] flex-1 bg-transparent focus:outline-none" + /> +
+ ) : selected.length > 0 ? ( +
+ {selected.map(v => ( + + {v} + + ))} +
+ ) : ( +

Any

+ )} + + {isEditing && available.length > 0 && ( +
+
+ {available.map((option, index) => ( + + ))} +
+
+ )} +
+ + {isEditing && ( + + ); +} diff --git a/frontend/src/routes/components/filters/TagFilter.tsx b/frontend/src/routes/components/filters/TagFilter.tsx new file mode 100644 index 00000000..c204b234 --- /dev/null +++ b/frontend/src/routes/components/filters/TagFilter.tsx @@ -0,0 +1,208 @@ +import {useCallback, useEffect, useRef, useState} from 'react'; +import {useQuery} from '@tanstack/react-query'; +import {useNavigate} from '@tanstack/react-router'; +import {Button} from 'components/Button'; +import {Tag} from 'components/Tag'; +import {Pencil, XIcon} from 'lucide-react'; +import {cn} from 'utils/cn'; + +import {tagsQueryOptions, type TagType} from '../../$incidentId/queries/tagsQueryOptions'; +import {useActiveFilters, type ArrayFilterKey} from '../useActiveFilters'; + +interface TagFilterProps { + label: string; + filterKey: ArrayFilterKey; + tagType: TagType; +} + +export function TagFilter({label, filterKey, tagType}: TagFilterProps) { + const navigate = useNavigate(); + const {search} = useActiveFilters(); + const committed = ((search[filterKey] as string[] | undefined) ?? []) as string[]; + const {data: suggestions = []} = useQuery(tagsQueryOptions(tagType)); + const [isEditing, setIsEditing] = useState(false); + const [draft, setDraft] = useState([]); + const [inputValue, setInputValue] = useState(''); + const [focusedIndex, setFocusedIndex] = useState(0); + const inputRef = useRef(null); + + const selected = isEditing ? draft : committed; + + const available = suggestions.filter( + s => !selected.includes(s) && s.toLowerCase().includes(inputValue.toLowerCase()) + ); + + const toggle = useCallback((value: string) => { + setDraft(prev => + prev.includes(value) ? prev.filter(v => v !== value) : [...prev, value] + ); + }, []); + + const close = useCallback(() => { + setIsEditing(false); + setInputValue(''); + setFocusedIndex(0); + setDraft(prev => { + navigate({ + to: '/', + search: s => ({...s, [filterKey]: prev.length > 0 ? prev : undefined}), + replace: true, + }); + return prev; + }); + }, [navigate, filterKey]); + + const open = () => { + setDraft(committed); + setIsEditing(true); + setInputValue(''); + setFocusedIndex(0); + }; + + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus(); + } + }, [isEditing]); + + useEffect(() => { + if (!isEditing) return; + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + close(); + } + }; + document.addEventListener('keydown', handleEscape); + return () => document.removeEventListener('keydown', handleEscape); + }, [isEditing, close]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + if (available.length > 0) { + setFocusedIndex(prev => (prev + 1) % available.length); + } + break; + case 'ArrowUp': + e.preventDefault(); + if (available.length > 0) { + setFocusedIndex(prev => (prev - 1 + available.length) % available.length); + } + break; + case 'Enter': + case ' ': + if (focusedIndex >= 0 && focusedIndex < available.length) { + e.preventDefault(); + toggle(available[focusedIndex]); + setInputValue(''); + setFocusedIndex(0); + inputRef.current?.focus(); + } else if (e.key === 'Enter' && !inputValue.trim()) { + close(); + } + break; + case 'Backspace': + if (inputValue === '' && selected.length > 0) { + toggle(selected[selected.length - 1]); + } + break; + } + }; + + return ( +
+
+

{label}

+ +
+ +
+ {isEditing ? ( +
+ {selected.map(v => ( + toggle(v)} + aria-label={`Remove ${v}`} + > + + + } + > + {v} + + ))} + { + setInputValue(e.target.value); + setFocusedIndex(0); + }} + onKeyDown={handleKeyDown} + placeholder="Add..." + className="px-space-sm py-space-xs text-size-sm placeholder:text-content-disabled min-w-[100px] flex-1 bg-transparent focus:outline-none" + /> +
+ ) : selected.length > 0 ? ( +
+ {selected.map(v => ( + {v} + ))} +
+ ) : ( +

Any

+ )} + + {isEditing && available.length > 0 && ( +
+
+ {available.map((option, index) => ( + + ))} +
+
+ )} +
+ + {isEditing && ( + + ); +} diff --git a/frontend/src/routes/components/filters/UserFilter.tsx b/frontend/src/routes/components/filters/UserFilter.tsx new file mode 100644 index 00000000..5f367df7 --- /dev/null +++ b/frontend/src/routes/components/filters/UserFilter.tsx @@ -0,0 +1,248 @@ +import {useCallback, useEffect, useRef, useState} from 'react'; +import {useInfiniteQuery} from '@tanstack/react-query'; +import {useNavigate} from '@tanstack/react-router'; +import {Button} from 'components/Button'; +import {Tag} from 'components/Tag'; +import {Pencil, XIcon} from 'lucide-react'; +import {cn} from 'utils/cn'; + +import {usersInfiniteQueryOptions} from '../../queries/usersQueryOptions'; +import {useActiveFilters, type ArrayFilterKey} from '../useActiveFilters'; + +interface UserFilterProps { + label: string; + filterKey: ArrayFilterKey; +} + +export function UserFilter({label, filterKey}: UserFilterProps) { + const navigate = useNavigate(); + const {search} = useActiveFilters(); + const committed = ((search[filterKey] as string[] | undefined) ?? []) as string[]; + const [isEditing, setIsEditing] = useState(false); + const [draft, setDraft] = useState([]); + const [inputValue, setInputValue] = useState(''); + const [debouncedSearch, setDebouncedSearch] = useState(''); + const [focusedIndex, setFocusedIndex] = useState(0); + const inputRef = useRef(null); + const scrollSentinelRef = useRef(null); + + const selected = isEditing ? draft : committed; + + const { + data: users = [], + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useInfiniteQuery({ + ...usersInfiniteQueryOptions(debouncedSearch), + enabled: isEditing, + }); + + const available = users.filter(u => !selected.includes(u.email)); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedSearch(inputValue), 300); + return () => clearTimeout(timer); + }, [inputValue]); + + useEffect(() => { + const target = scrollSentinelRef.current; + if (!target || !isEditing) return; + + const observer = new IntersectionObserver( + entries => { + if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, + {threshold: 0.1} + ); + + observer.observe(target); + return () => observer.disconnect(); + }, [isEditing, fetchNextPage, hasNextPage, isFetchingNextPage]); + + const toggle = useCallback((value: string) => { + setDraft(prev => + prev.includes(value) ? prev.filter(v => v !== value) : [...prev, value] + ); + }, []); + + const close = useCallback(() => { + setIsEditing(false); + setInputValue(''); + setDebouncedSearch(''); + setFocusedIndex(0); + setDraft(prev => { + navigate({ + to: '/', + search: s => ({...s, [filterKey]: prev.length > 0 ? prev : undefined}), + replace: true, + }); + return prev; + }); + }, [navigate, filterKey]); + + const open = () => { + setDraft(committed); + setIsEditing(true); + setInputValue(''); + setDebouncedSearch(''); + setFocusedIndex(0); + }; + + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus(); + } + }, [isEditing]); + + useEffect(() => { + if (!isEditing) return; + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + close(); + } + }; + document.addEventListener('keydown', handleEscape); + return () => document.removeEventListener('keydown', handleEscape); + }, [isEditing, close]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + if (available.length > 0) { + setFocusedIndex(prev => (prev + 1) % available.length); + } + break; + case 'ArrowUp': + e.preventDefault(); + if (available.length > 0) { + setFocusedIndex(prev => (prev - 1 + available.length) % available.length); + } + break; + case 'Enter': + case ' ': + if (focusedIndex >= 0 && focusedIndex < available.length) { + e.preventDefault(); + toggle(available[focusedIndex].email); + setInputValue(''); + setFocusedIndex(0); + inputRef.current?.focus(); + } else if (e.key === 'Enter' && !inputValue.trim()) { + close(); + } + break; + case 'Backspace': + if (inputValue === '' && selected.length > 0) { + toggle(selected[selected.length - 1]); + } + break; + } + }; + + return ( +
+
+

{label}

+ +
+ +
+ {isEditing ? ( +
+ {selected.map(v => ( + toggle(v)} + aria-label={`Remove ${v}`} + > + + + } + > + {v} + + ))} + { + setInputValue(e.target.value); + setFocusedIndex(0); + }} + onKeyDown={handleKeyDown} + placeholder="Search users..." + className="px-space-sm py-space-xs text-size-sm placeholder:text-content-disabled min-w-[100px] flex-1 bg-transparent focus:outline-none" + /> +
+ ) : selected.length > 0 ? ( +
+ {selected.map(v => ( + {v} + ))} +
+ ) : ( +

Any

+ )} + + {isEditing && ( +
+
+ {available.length > 0 ? ( + available.map((user, index) => ( + + )) + ) : ( +

+ No users found +

+ )} +
+
+
+ )} +
+ + {isEditing && ( + + ); +} From 4606bceb5c2de5031dcdcfe7ed01a389ecea1da7 Mon Sep 17 00:00:00 2001 From: Spencer Murray Date: Wed, 11 Mar 2026 16:49:11 -0400 Subject: [PATCH 09/20] Reset all params instead of explicitly setting everything to undefined --- .../routes/components/filters/FilterTrigger.tsx | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/frontend/src/routes/components/filters/FilterTrigger.tsx b/frontend/src/routes/components/filters/FilterTrigger.tsx index b7089d6e..2ffe3766 100644 --- a/frontend/src/routes/components/filters/FilterTrigger.tsx +++ b/frontend/src/routes/components/filters/FilterTrigger.tsx @@ -17,20 +17,7 @@ export function FilterTrigger({open, onToggle}: {open: boolean; onToggle: () => onClick={() => { navigate({ to: '/', - search: prev => ({ - ...prev, - severity: undefined, - service_tier: undefined, - affected_service: undefined, - root_cause: undefined, - impact_type: undefined, - affected_region: undefined, - captain: undefined, - reporter: undefined, - created_after: undefined, - created_before: undefined, - status: undefined, - }), + search: {}, replace: true, }); }} From 9f21fd41f0d66a17d26c59533bd1a4f8aafd9106 Mon Sep 17 00:00:00 2001 From: Spencer Murray Date: Wed, 11 Mar 2026 16:49:54 -0400 Subject: [PATCH 10/20] Use IncidentStatus type instead of string[] --- frontend/src/routes/components/StatusFilter.tsx | 4 ++-- frontend/src/routes/types.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/routes/components/StatusFilter.tsx b/frontend/src/routes/components/StatusFilter.tsx index f1bd60f6..dc680221 100644 --- a/frontend/src/routes/components/StatusFilter.tsx +++ b/frontend/src/routes/components/StatusFilter.tsx @@ -2,10 +2,10 @@ import {Link, useSearch} from '@tanstack/react-router'; import {arraysEqual} from 'utils/arrays'; import {cn} from 'utils/cn'; -import {STATUS_FILTER_GROUPS} from '../types'; +import {STATUS_FILTER_GROUPS, type IncidentStatus} from '../types'; interface FilterLinkProps { - statuses?: string[]; + statuses?: IncidentStatus[]; label: string; isActive: boolean; testId?: string; diff --git a/frontend/src/routes/types.ts b/frontend/src/routes/types.ts index 4e8746d8..52720e0f 100644 --- a/frontend/src/routes/types.ts +++ b/frontend/src/routes/types.ts @@ -12,11 +12,11 @@ export const SeveritySchema = z.enum(['P0', 'P1', 'P2', 'P3', 'P4']); export const ServiceTierSchema = z.enum(['T0', 'T1', 'T2', 'T3', 'T4']); -export type IncidentStatus = z.infer; +export type IncidentStatus = z.infer | 'Any'; export const STATUS_FILTER_GROUPS = { active: ['Active', 'Mitigated'] as IncidentStatus[], review: ['Postmortem'] as IncidentStatus[], closed: ['Done', 'Cancelled'] as IncidentStatus[], - all: ['Any'] as string[], + all: ['Any'] as IncidentStatus[], }; From 0c5b0de7a50ba523bd288133a02b4e84387cc577 Mon Sep 17 00:00:00 2001 From: Spencer Murray Date: Wed, 11 Mar 2026 16:54:45 -0400 Subject: [PATCH 11/20] Add some filter tests --- frontend/src/routes/index.test.tsx | 78 ++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/frontend/src/routes/index.test.tsx b/frontend/src/routes/index.test.tsx index f3c21b42..b473440a 100644 --- a/frontend/src/routes/index.test.tsx +++ b/frontend/src/routes/index.test.tsx @@ -54,6 +54,16 @@ const mockCurrentUser: CurrentUser = { avatar_url: null, }; +const mockPaginatedUsers = { + count: 2, + next: null, + previous: null, + results: [ + {email: 'alice@example.com', name: 'Alice Smith', avatar_url: null}, + {email: 'bob@example.com', name: 'Bob Jones', avatar_url: null}, + ], +}; + function setupDefaultMocks() { mockApiGet.mockImplementation((args: {path: string}) => { if (args.path === '/ui/incidents/') { @@ -65,6 +75,9 @@ function setupDefaultMocks() { if (args.path === '/tags/') { return Promise.resolve(['tag-1', 'tag-2']); } + if (args.path === '/users/') { + return Promise.resolve(mockPaginatedUsers); + } return Promise.reject(new Error('Not found')); }); } @@ -512,6 +525,71 @@ describe('Route States', () => { }); }); +describe('Filter Interactions', () => { + beforeEach(() => { + queryClient.clear(); + setupDefaultMocks(); + }); + + async function openFilters() { + const user = userEvent.setup(); + renderRoute(); + await screen.findByText('INC-1247'); + const toggle = screen.getByTestId('advanced-filters-toggle'); + await user.click(toggle); + return user; + } + + it('PillFilter: selecting a severity option adds it to the filter', async () => { + const user = await openFilters(); + + const editButton = screen.getByLabelText('Edit Severity'); + await user.click(editButton); + + const option = await screen.findByRole('button', {name: 'P0'}); + await user.click(option); + + expect(screen.getByText('P0')).toBeInTheDocument(); + }); + + it('TagFilter: selecting a tag option adds it to the filter', async () => { + const user = await openFilters(); + + const editButton = screen.getByLabelText('Edit Impact Type'); + await user.click(editButton); + + const option = await screen.findByRole('button', {name: 'tag-1'}); + await user.click(option); + + expect(screen.getByText('tag-1')).toBeInTheDocument(); + }); + + it('UserFilter: selecting a user adds their email to the filter', async () => { + const user = await openFilters(); + + const editButton = screen.getByLabelText('Edit Captain'); + await user.click(editButton); + + const option = await screen.findByRole('button', {name: /Alice Smith/}); + await user.click(option); + + expect(screen.getByText('alice@example.com')).toBeInTheDocument(); + }); + + it('DateRangeFilter: selecting a date shows clear button', async () => { + renderRoute('/?created_after=2024-06-15'); + + await screen.findByText('INC-1247'); + + const user = userEvent.setup(); + const toggle = screen.getByTestId('advanced-filters-toggle'); + await user.click(toggle); + + expect(screen.getByText('Jun 15, 2024')).toBeInTheDocument(); + expect(screen.getByLabelText('Clear start date')).toBeInTheDocument(); + }); +}); + describe('Empty States', () => { it('shows no active incidents message', async () => { mockApiGet.mockImplementation((args: {path: string}) => { From 48617da43e2472241890dbf0f61796386d32d9b1 Mon Sep 17 00:00:00 2001 From: Spencer Murray Date: Wed, 11 Mar 2026 19:36:10 -0400 Subject: [PATCH 12/20] Fix space key hijacking search input, remove unused MultiSelect --- frontend/src/components/MultiSelect.tsx | 100 ------------------ .../routes/components/filters/PillFilter.tsx | 3 +- .../routes/components/filters/TagFilter.tsx | 3 +- .../routes/components/filters/UserFilter.tsx | 3 +- 4 files changed, 3 insertions(+), 106 deletions(-) delete mode 100644 frontend/src/components/MultiSelect.tsx diff --git a/frontend/src/components/MultiSelect.tsx b/frontend/src/components/MultiSelect.tsx deleted file mode 100644 index c2ceabe9..00000000 --- a/frontend/src/components/MultiSelect.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import React, {useRef, useState} from 'react'; - -import {Input} from './Input'; -import {Popover, PopoverContent, PopoverTrigger} from './Popover'; - -interface MultiSelectProps { - options: readonly T[]; - selected: T[]; - onToggle: (value: T) => void; - renderOption: (value: T) => React.ReactNode; - trigger: React.ReactNode; - searchable?: boolean; - searchPlaceholder?: string; -} - -function MultiSelect({ - options, - selected, - onToggle, - renderOption, - trigger, - searchable, - searchPlaceholder = 'Search…', -}: MultiSelectProps) { - const [search, setSearch] = useState(''); - const listRef = useRef(null); - - const available = options.filter(o => !selected.includes(o)); - const filtered = searchable - ? available.filter(o => o.toLowerCase().includes(search.toLowerCase())) - : available; - - function handleKeyDown(e: React.KeyboardEvent) { - const list = listRef.current; - if (!list) return; - - const items = Array.from(list.querySelectorAll('[role="option"]')); - const active = document.activeElement as HTMLElement; - const idx = items.indexOf(active as HTMLLIElement); - - if (e.key === 'ArrowDown') { - e.preventDefault(); - const next = idx < items.length - 1 ? idx + 1 : 0; - items[next]?.focus(); - } else if (e.key === 'ArrowUp') { - e.preventDefault(); - const prev = idx > 0 ? idx - 1 : items.length - 1; - items[prev]?.focus(); - } - } - - return ( - setSearch('')}> - {trigger} - - {searchable && ( -
- setSearch(e.target.value)} - placeholder={searchPlaceholder} - autoFocus - /> -
- )} -
    - {filtered.map(option => ( -
  • onToggle(option)} - onKeyDown={e => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onToggle(option); - } - }} - > - {renderOption(option)} -
  • - ))} -
-
-
- ); -} - -export {MultiSelect, type MultiSelectProps}; diff --git a/frontend/src/routes/components/filters/PillFilter.tsx b/frontend/src/routes/components/filters/PillFilter.tsx index 9cb8b4d3..4cb25fa0 100644 --- a/frontend/src/routes/components/filters/PillFilter.tsx +++ b/frontend/src/routes/components/filters/PillFilter.tsx @@ -96,14 +96,13 @@ export function PillFilter({ } break; case 'Enter': - case ' ': if (focusedIndex >= 0 && focusedIndex < available.length) { e.preventDefault(); toggle(available[focusedIndex]); setInputValue(''); setFocusedIndex(0); inputRef.current?.focus(); - } else if (e.key === 'Enter' && !inputValue.trim()) { + } else if (!inputValue.trim()) { close(); } break; diff --git a/frontend/src/routes/components/filters/TagFilter.tsx b/frontend/src/routes/components/filters/TagFilter.tsx index c204b234..300f9c2e 100644 --- a/frontend/src/routes/components/filters/TagFilter.tsx +++ b/frontend/src/routes/components/filters/TagFilter.tsx @@ -92,14 +92,13 @@ export function TagFilter({label, filterKey, tagType}: TagFilterProps) { } break; case 'Enter': - case ' ': if (focusedIndex >= 0 && focusedIndex < available.length) { e.preventDefault(); toggle(available[focusedIndex]); setInputValue(''); setFocusedIndex(0); inputRef.current?.focus(); - } else if (e.key === 'Enter' && !inputValue.trim()) { + } else if (!inputValue.trim()) { close(); } break; diff --git a/frontend/src/routes/components/filters/UserFilter.tsx b/frontend/src/routes/components/filters/UserFilter.tsx index 5f367df7..c5071487 100644 --- a/frontend/src/routes/components/filters/UserFilter.tsx +++ b/frontend/src/routes/components/filters/UserFilter.tsx @@ -124,14 +124,13 @@ export function UserFilter({label, filterKey}: UserFilterProps) { } break; case 'Enter': - case ' ': if (focusedIndex >= 0 && focusedIndex < available.length) { e.preventDefault(); toggle(available[focusedIndex].email); setInputValue(''); setFocusedIndex(0); inputRef.current?.focus(); - } else if (e.key === 'Enter' && !inputValue.trim()) { + } else if (!inputValue.trim()) { close(); } break; From 1da7d03bd8ba35e251e9518782984b670efa8724 Mon Sep 17 00:00:00 2001 From: Spencer Murray Date: Thu, 12 Mar 2026 11:16:42 -0400 Subject: [PATCH 13/20] Pull shared editor code out to a hook --- .../routes/components/filters/PillFilter.tsx | 109 +++------------- .../routes/components/filters/TagFilter.tsx | 114 +++-------------- .../routes/components/filters/UserFilter.tsx | 115 ++++------------- .../components/filters/useFilterEditor.ts | 121 ++++++++++++++++++ 4 files changed, 180 insertions(+), 279 deletions(-) create mode 100644 frontend/src/routes/components/filters/useFilterEditor.ts diff --git a/frontend/src/routes/components/filters/PillFilter.tsx b/frontend/src/routes/components/filters/PillFilter.tsx index 4cb25fa0..5ccfb656 100644 --- a/frontend/src/routes/components/filters/PillFilter.tsx +++ b/frontend/src/routes/components/filters/PillFilter.tsx @@ -1,12 +1,12 @@ -import {useCallback, useEffect, useRef, useState} from 'react'; -import {useNavigate} from '@tanstack/react-router'; import {Button} from 'components/Button'; import {Pill, type PillProps} from 'components/Pill'; import {Tag} from 'components/Tag'; import {Pencil, XIcon} from 'lucide-react'; import {cn} from 'utils/cn'; -import {useActiveFilters, type ArrayFilterKey} from '../useActiveFilters'; +import {type ArrayFilterKey} from '../useActiveFilters'; + +import {useFilterEditor} from './useFilterEditor'; type PillVariant = NonNullable; @@ -21,99 +21,24 @@ export function PillFilter({ filterKey, options, }: PillFilterProps) { - const navigate = useNavigate(); - const {search} = useActiveFilters(); - const committed = ((search[filterKey] as string[] | undefined) ?? []) as string[]; - const [isEditing, setIsEditing] = useState(false); - const [draft, setDraft] = useState([]); - const [inputValue, setInputValue] = useState(''); - const [focusedIndex, setFocusedIndex] = useState(0); - const inputRef = useRef(null); - - const selected = isEditing ? draft : committed; + const { + isEditing, + selected, + inputValue, + focusedIndex, + inputRef, + setInputValue, + setFocusedIndex, + toggle, + open, + close, + handleKeyDown, + } = useFilterEditor({filterKey}); const available = options.filter( o => !selected.includes(o) && o.toLowerCase().includes(inputValue.toLowerCase()) ); - const toggle = useCallback((value: string) => { - setDraft(prev => - prev.includes(value) ? prev.filter(v => v !== value) : [...prev, value] - ); - }, []); - - const close = useCallback(() => { - setIsEditing(false); - setInputValue(''); - setFocusedIndex(0); - setDraft(prev => { - navigate({ - to: '/', - search: s => ({...s, [filterKey]: prev.length > 0 ? prev : undefined}), - replace: true, - }); - return prev; - }); - }, [navigate, filterKey]); - - const open = () => { - setDraft(committed); - setIsEditing(true); - setInputValue(''); - setFocusedIndex(0); - }; - - useEffect(() => { - if (isEditing && inputRef.current) { - inputRef.current.focus(); - } - }, [isEditing]); - - useEffect(() => { - if (!isEditing) return; - const handleEscape = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - e.preventDefault(); - close(); - } - }; - document.addEventListener('keydown', handleEscape); - return () => document.removeEventListener('keydown', handleEscape); - }, [isEditing, close]); - - const handleKeyDown = (e: React.KeyboardEvent) => { - switch (e.key) { - case 'ArrowDown': - e.preventDefault(); - if (available.length > 0) { - setFocusedIndex(prev => (prev + 1) % available.length); - } - break; - case 'ArrowUp': - e.preventDefault(); - if (available.length > 0) { - setFocusedIndex(prev => (prev - 1 + available.length) % available.length); - } - break; - case 'Enter': - if (focusedIndex >= 0 && focusedIndex < available.length) { - e.preventDefault(); - toggle(available[focusedIndex]); - setInputValue(''); - setFocusedIndex(0); - inputRef.current?.focus(); - } else if (!inputValue.trim()) { - close(); - } - break; - case 'Backspace': - if (inputValue === '' && selected.length > 0) { - toggle(selected[selected.length - 1]); - } - break; - } - }; - return (
@@ -156,7 +81,7 @@ export function PillFilter({ setInputValue(e.target.value); setFocusedIndex(0); }} - onKeyDown={handleKeyDown} + onKeyDown={handleKeyDown(available as string[])} placeholder="Add..." className="px-space-sm py-space-xs text-size-sm placeholder:text-content-disabled min-w-[100px] flex-1 bg-transparent focus:outline-none" /> diff --git a/frontend/src/routes/components/filters/TagFilter.tsx b/frontend/src/routes/components/filters/TagFilter.tsx index 300f9c2e..93700eee 100644 --- a/frontend/src/routes/components/filters/TagFilter.tsx +++ b/frontend/src/routes/components/filters/TagFilter.tsx @@ -1,13 +1,13 @@ -import {useCallback, useEffect, useRef, useState} from 'react'; import {useQuery} from '@tanstack/react-query'; -import {useNavigate} from '@tanstack/react-router'; import {Button} from 'components/Button'; import {Tag} from 'components/Tag'; import {Pencil, XIcon} from 'lucide-react'; import {cn} from 'utils/cn'; import {tagsQueryOptions, type TagType} from '../../$incidentId/queries/tagsQueryOptions'; -import {useActiveFilters, type ArrayFilterKey} from '../useActiveFilters'; +import {type ArrayFilterKey} from '../useActiveFilters'; + +import {useFilterEditor} from './useFilterEditor'; interface TagFilterProps { label: string; @@ -16,100 +16,26 @@ interface TagFilterProps { } export function TagFilter({label, filterKey, tagType}: TagFilterProps) { - const navigate = useNavigate(); - const {search} = useActiveFilters(); - const committed = ((search[filterKey] as string[] | undefined) ?? []) as string[]; + const { + isEditing, + selected, + inputValue, + focusedIndex, + inputRef, + setInputValue, + setFocusedIndex, + toggle, + open, + close, + handleKeyDown, + } = useFilterEditor({filterKey}); const {data: suggestions = []} = useQuery(tagsQueryOptions(tagType)); - const [isEditing, setIsEditing] = useState(false); - const [draft, setDraft] = useState([]); - const [inputValue, setInputValue] = useState(''); - const [focusedIndex, setFocusedIndex] = useState(0); - const inputRef = useRef(null); - - const selected = isEditing ? draft : committed; const available = suggestions.filter( - s => !selected.includes(s) && s.toLowerCase().includes(inputValue.toLowerCase()) + (s: string) => + !selected.includes(s) && s.toLowerCase().includes(inputValue.toLowerCase()) ); - const toggle = useCallback((value: string) => { - setDraft(prev => - prev.includes(value) ? prev.filter(v => v !== value) : [...prev, value] - ); - }, []); - - const close = useCallback(() => { - setIsEditing(false); - setInputValue(''); - setFocusedIndex(0); - setDraft(prev => { - navigate({ - to: '/', - search: s => ({...s, [filterKey]: prev.length > 0 ? prev : undefined}), - replace: true, - }); - return prev; - }); - }, [navigate, filterKey]); - - const open = () => { - setDraft(committed); - setIsEditing(true); - setInputValue(''); - setFocusedIndex(0); - }; - - useEffect(() => { - if (isEditing && inputRef.current) { - inputRef.current.focus(); - } - }, [isEditing]); - - useEffect(() => { - if (!isEditing) return; - const handleEscape = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - e.preventDefault(); - close(); - } - }; - document.addEventListener('keydown', handleEscape); - return () => document.removeEventListener('keydown', handleEscape); - }, [isEditing, close]); - - const handleKeyDown = (e: React.KeyboardEvent) => { - switch (e.key) { - case 'ArrowDown': - e.preventDefault(); - if (available.length > 0) { - setFocusedIndex(prev => (prev + 1) % available.length); - } - break; - case 'ArrowUp': - e.preventDefault(); - if (available.length > 0) { - setFocusedIndex(prev => (prev - 1 + available.length) % available.length); - } - break; - case 'Enter': - if (focusedIndex >= 0 && focusedIndex < available.length) { - e.preventDefault(); - toggle(available[focusedIndex]); - setInputValue(''); - setFocusedIndex(0); - inputRef.current?.focus(); - } else if (!inputValue.trim()) { - close(); - } - break; - case 'Backspace': - if (inputValue === '' && selected.length > 0) { - toggle(selected[selected.length - 1]); - } - break; - } - }; - return (
@@ -152,7 +78,7 @@ export function TagFilter({label, filterKey, tagType}: TagFilterProps) { setInputValue(e.target.value); setFocusedIndex(0); }} - onKeyDown={handleKeyDown} + onKeyDown={handleKeyDown(available)} placeholder="Add..." className="px-space-sm py-space-xs text-size-sm placeholder:text-content-disabled min-w-[100px] flex-1 bg-transparent focus:outline-none" /> @@ -170,7 +96,7 @@ export function TagFilter({label, filterKey, tagType}: TagFilterProps) { {isEditing && available.length > 0 && (
- {available.map((option, index) => ( + {available.map((option: string, index: number) => ( + } + > {v} ))}
) : ( -

Any

+ )} {isEditing && available.length > 0 && ( diff --git a/frontend/src/routes/components/filters/TagFilter.tsx b/frontend/src/routes/components/filters/TagFilter.tsx index 93700eee..efade3aa 100644 --- a/frontend/src/routes/components/filters/TagFilter.tsx +++ b/frontend/src/routes/components/filters/TagFilter.tsx @@ -25,6 +25,7 @@ export function TagFilter({label, filterKey, tagType}: TagFilterProps) { setInputValue, setFocusedIndex, toggle, + remove, open, close, handleKeyDown, @@ -52,7 +53,7 @@ export function TagFilter({label, filterKey, tagType}: TagFilterProps) {
{isEditing ? ( -
+
{selected.map(v => (
) : selected.length > 0 ? ( -
+
{selected.map(v => ( - {v} + { + e.stopPropagation(); + remove(v); + }} + aria-label={`Remove ${v}`} + > + + + } + > + {v} + ))}
) : ( -

Any

+ )} {isEditing && available.length > 0 && ( diff --git a/frontend/src/routes/components/filters/UserFilter.tsx b/frontend/src/routes/components/filters/UserFilter.tsx index e020d4f2..a1a5ff0f 100644 --- a/frontend/src/routes/components/filters/UserFilter.tsx +++ b/frontend/src/routes/components/filters/UserFilter.tsx @@ -28,6 +28,7 @@ export function UserFilter({label, filterKey}: UserFilterProps) { setInputValue, setFocusedIndex, toggle, + remove, open, close, handleKeyDown, @@ -87,7 +88,7 @@ export function UserFilter({label, filterKey}: UserFilterProps) {
{isEditing ? ( -
+
{selected.map(v => (
) : selected.length > 0 ? ( -
+
{selected.map(v => ( - {v} + { + e.stopPropagation(); + remove(v); + }} + aria-label={`Remove ${v}`} + > + + + } + > + {v} + ))}
) : ( -

Any

+ )} {isEditing && ( diff --git a/frontend/src/routes/components/filters/useFilterEditor.ts b/frontend/src/routes/components/filters/useFilterEditor.ts index 37c58705..f735ac11 100644 --- a/frontend/src/routes/components/filters/useFilterEditor.ts +++ b/frontend/src/routes/components/filters/useFilterEditor.ts @@ -33,6 +33,25 @@ export function useFilterEditor({filterKey, onClose, onOpen}: UseFilterEditorOpt ); }, []); + const remove = useCallback( + (value: string) => { + navigate({ + to: '/', + search: (s: Record) => { + const current = ((s[filterKey] as string[] | undefined) ?? []).filter( + v => v !== value + ); + return { + ...s, + [filterKey]: current.length > 0 ? current : undefined, + }; + }, + replace: true, + }); + }, + [navigate, filterKey] + ); + const close = useCallback(() => { setIsEditing(false); setInputValue(''); @@ -117,6 +136,7 @@ export function useFilterEditor({filterKey, onClose, onOpen}: UseFilterEditorOpt setInputValue, setFocusedIndex, toggle, + remove, open, close, handleKeyDown, From 374d40381d83147b4288a03ba2e908abe81abe7b Mon Sep 17 00:00:00 2001 From: Spencer Murray Date: Thu, 9 Apr 2026 13:49:28 -0400 Subject: [PATCH 18/20] Fix pencil icon visibility delay when opening filter edit mode --- frontend/src/routes/components/filters/PillFilter.tsx | 2 +- frontend/src/routes/components/filters/TagFilter.tsx | 2 +- frontend/src/routes/components/filters/UserFilter.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/routes/components/filters/PillFilter.tsx b/frontend/src/routes/components/filters/PillFilter.tsx index 86592788..26a22de0 100644 --- a/frontend/src/routes/components/filters/PillFilter.tsx +++ b/frontend/src/routes/components/filters/PillFilter.tsx @@ -48,7 +48,7 @@ export function PillFilter({ variant="icon" onClick={open} aria-label={`Edit ${label}`} - className={cn(isEditing && 'invisible')} + className={cn('transition-none', isEditing && 'invisible')} > diff --git a/frontend/src/routes/components/filters/TagFilter.tsx b/frontend/src/routes/components/filters/TagFilter.tsx index efade3aa..93a111c2 100644 --- a/frontend/src/routes/components/filters/TagFilter.tsx +++ b/frontend/src/routes/components/filters/TagFilter.tsx @@ -45,7 +45,7 @@ export function TagFilter({label, filterKey, tagType}: TagFilterProps) { variant="icon" onClick={open} aria-label={`Edit ${label}`} - className={cn(isEditing && 'invisible')} + className={cn('transition-none', isEditing && 'invisible')} > diff --git a/frontend/src/routes/components/filters/UserFilter.tsx b/frontend/src/routes/components/filters/UserFilter.tsx index a1a5ff0f..c91cfd35 100644 --- a/frontend/src/routes/components/filters/UserFilter.tsx +++ b/frontend/src/routes/components/filters/UserFilter.tsx @@ -80,7 +80,7 @@ export function UserFilter({label, filterKey}: UserFilterProps) { variant="icon" onClick={open} aria-label={`Edit ${label}`} - className={cn(isEditing && 'invisible')} + className={cn('transition-none', isEditing && 'invisible')} > From 919a8230977318f893c554a2d3d8eea6f574ca2b Mon Sep 17 00:00:00 2001 From: Spencer Murray Date: Thu, 9 Apr 2026 13:53:10 -0400 Subject: [PATCH 19/20] Refactor DateRangeFilter to use Tag component with inline close buttons --- .../components/filters/DateRangeFilter.tsx | 153 ++++++++++-------- 1 file changed, 89 insertions(+), 64 deletions(-) diff --git a/frontend/src/routes/components/filters/DateRangeFilter.tsx b/frontend/src/routes/components/filters/DateRangeFilter.tsx index 95c882ea..7ae58149 100644 --- a/frontend/src/routes/components/filters/DateRangeFilter.tsx +++ b/frontend/src/routes/components/filters/DateRangeFilter.tsx @@ -3,6 +3,7 @@ import {useNavigate} from '@tanstack/react-router'; import {Button} from 'components/Button'; import {Calendar} from 'components/Calendar'; import {Popover, PopoverContent, PopoverTrigger} from 'components/Popover'; +import {Tag} from 'components/Tag'; import {XIcon} from 'lucide-react'; import {useActiveFilters} from '../useActiveFilters'; @@ -58,71 +59,95 @@ export function DateRangeFilter() {
-
- setEditing(o ? 'after' : null)} - > - - - - - handleDateSelect('created_after', d)} - /> - - - {after && ( - - )} -
+ setEditing(o ? 'after' : null)} + > + + {after ? ( + { + e.stopPropagation(); + update('created_after', undefined); + }} + aria-label="Clear start date" + > + + + } + > + {formatDateDisplay(after)} + + ) : ( + + )} + + + handleDateSelect('created_after', d)} + /> + + to -
- setEditing(o ? 'before' : null)} - > - - - - - handleDateSelect('created_before', d)} - /> - - - {before && ( - - )} -
+ setEditing(o ? 'before' : null)} + > + + {before ? ( + { + e.stopPropagation(); + update('created_before', undefined); + }} + aria-label="Clear end date" + > + + + } + > + {formatDateDisplay(before)} + + ) : ( + + )} + + + handleDateSelect('created_before', d)} + /> + +
); From a4b6bc0abb83706e3616d4b1ac470d74211bd825 Mon Sep 17 00:00:00 2001 From: Spencer Murray Date: Thu, 9 Apr 2026 14:13:51 -0400 Subject: [PATCH 20/20] Stabilize useFilterEditor callbacks and remove dead code in useActiveFilters --- .../components/filters/useFilterEditor.ts | 12 ++++++-- .../src/routes/components/useActiveFilters.ts | 28 +++---------------- 2 files changed, 13 insertions(+), 27 deletions(-) diff --git a/frontend/src/routes/components/filters/useFilterEditor.ts b/frontend/src/routes/components/filters/useFilterEditor.ts index f735ac11..e20e0fa2 100644 --- a/frontend/src/routes/components/filters/useFilterEditor.ts +++ b/frontend/src/routes/components/filters/useFilterEditor.ts @@ -17,6 +17,12 @@ export function useFilterEditor({filterKey, onClose, onOpen}: UseFilterEditorOpt const [isEditing, setIsEditing] = useState(false); const [draft, setDraft] = useState([]); const draftRef = useRef(draft); + const onCloseRef = useRef(onClose); + const onOpenRef = useRef(onOpen); + useEffect(() => { + onCloseRef.current = onClose; + onOpenRef.current = onOpen; + }); const [inputValue, setInputValue] = useState(''); const [focusedIndex, setFocusedIndex] = useState(0); const inputRef = useRef(null); @@ -56,7 +62,7 @@ export function useFilterEditor({filterKey, onClose, onOpen}: UseFilterEditorOpt setIsEditing(false); setInputValue(''); setFocusedIndex(0); - onClose?.(); + onCloseRef.current?.(); const current = draftRef.current; navigate({ to: '/', @@ -66,14 +72,14 @@ export function useFilterEditor({filterKey, onClose, onOpen}: UseFilterEditorOpt }), replace: true, }); - }, [navigate, filterKey, onClose]); + }, [navigate, filterKey]); const open = () => { setDraft(committed); setIsEditing(true); setInputValue(''); setFocusedIndex(0); - onOpen?.(); + onOpenRef.current?.(); }; useEffect(() => { diff --git a/frontend/src/routes/components/useActiveFilters.ts b/frontend/src/routes/components/useActiveFilters.ts index f7496d96..b9066301 100644 --- a/frontend/src/routes/components/useActiveFilters.ts +++ b/frontend/src/routes/components/useActiveFilters.ts @@ -10,21 +10,6 @@ export type ArrayFilterKey = | 'captain' | 'reporter'; -type DateFilterKey = 'created_after' | 'created_before'; - -export const FILTER_LABELS: Record = { - severity: 'Severity', - service_tier: 'Service Tier', - affected_service: 'Affected Service', - root_cause: 'Root Cause', - impact_type: 'Impact Type', - affected_region: 'Affected Region', - captain: 'Captain', - reporter: 'Reporter', - created_after: 'Created After', - created_before: 'Created Before', -}; - export const ARRAY_FILTER_KEYS: ArrayFilterKey[] = [ 'severity', 'service_tier', @@ -39,18 +24,13 @@ export const ARRAY_FILTER_KEYS: ArrayFilterKey[] = [ export function useActiveFilters() { const search = useSearch({from: '/'}); - const activeFilters: {key: ArrayFilterKey; value: string; label: string}[] = []; + let activeCount = 0; for (const key of ARRAY_FILTER_KEYS) { const values = (search[key] as string[] | undefined) ?? []; - for (const value of values) { - activeFilters.push({key, value, label: FILTER_LABELS[key]}); - } + activeCount += values.length; } - - const activeCount = - activeFilters.length + - (search.created_after ? 1 : 0) + - (search.created_before ? 1 : 0); + if (search.created_after) activeCount++; + if (search.created_before) activeCount++; return {search, activeCount}; }