Skip to content
Open
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
34ee3ef
Add advanced filtering UI for incident list
spalmurray Feb 27, 2026
b8660fc
Fix import paths in AdvancedFilters to use relative imports
spalmurray Feb 27, 2026
98b5fa1
Create filters layout
spalmurray Mar 6, 2026
6348eab
Add filter panel with severity, service tier, and tag filters
spalmurray Mar 11, 2026
b21eaf3
Defer filter param updates until edit close, add All status tab
spalmurray Mar 11, 2026
a585969
Add captain and reporter filters with server-side user search
spalmurray Mar 11, 2026
56c350c
Add date range filter with responsive grid layout
spalmurray Mar 11, 2026
6122efb
Refactor filter components
spalmurray Mar 11, 2026
4606bce
Reset all params instead of explicitly setting everything to undefined
spalmurray Mar 11, 2026
9f21fd4
Use IncidentStatus type instead of string[]
spalmurray Mar 11, 2026
0c5b0de
Add some filter tests
spalmurray Mar 11, 2026
48617da
Fix space key hijacking search input, remove unused MultiSelect
spalmurray Mar 11, 2026
1da7d03
Pull shared editor code out to a hook
spalmurray Mar 12, 2026
c80bc6c
Use ref to access draft state in close callback
spalmurray Apr 9, 2026
23f8f86
Remove unused exports from useActiveFilters
spalmurray Apr 9, 2026
d6da9db
Separate StatusFilterValue from IncidentStatus
spalmurray Apr 9, 2026
19df7d2
Improve advanced filter UX: always-visible X buttons, click-to-edit, …
spalmurray Apr 9, 2026
374d403
Fix pencil icon visibility delay when opening filter edit mode
spalmurray Apr 9, 2026
919a823
Refactor DateRangeFilter to use Tag component with inline close buttons
spalmurray Apr 9, 2026
a4b6bc0
Stabilize useFilterEditor callbacks and remove dead code in useActive…
spalmurray Apr 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions frontend/src/routes/components/AdvancedFilters.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import {Card} from 'components/Card';

import {ServiceTierSchema, SeveritySchema} from '../types';

import {DateRangeFilter} from './filters/DateRangeFilter';
import {PillFilter} from './filters/PillFilter';
import {TagFilter} from './filters/TagFilter';
import {UserFilter} from './filters/UserFilter';

export {FilterTrigger} from './filters/FilterTrigger';

export function FilterPanel() {
return (
<Card className="flex flex-col gap-space-md" data-testid="advanced-filters">
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-space-md">
<PillFilter
label="Severity"
filterKey="severity"
options={SeveritySchema.options}
/>
<PillFilter
label="Service Tier"
filterKey="service_tier"
options={ServiceTierSchema.options}
/>
<TagFilter label="Impact Type" filterKey="impact_type" tagType="IMPACT_TYPE" />
<TagFilter
label="Affected Service"
filterKey="affected_service"
tagType="AFFECTED_SERVICE"
/>
<TagFilter
label="Affected Region"
filterKey="affected_region"
tagType="AFFECTED_REGION"
/>
<TagFilter label="Root Cause" filterKey="root_cause" tagType="ROOT_CAUSE" />
<UserFilter label="Captain" filterKey="captain" />
<UserFilter label="Reporter" filterKey="reporter" />
<DateRangeFilter />
</div>
</Card>
);
}
4 changes: 2 additions & 2 deletions frontend/src/routes/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {Link, useRouterState} from '@tanstack/react-router';
import {Avatar} from 'components/Avatar';

import {currentUserQueryOptions} from '../queries/currentUserQueryOptions';
import type {IncidentStatus} from '../types';
import type {StatusFilterValue} from '../types';

const STORAGE_KEY = 'firetower_list_search';

Expand All @@ -13,7 +13,7 @@ export const Header = () => {

const isRootRoute = routerState.location.pathname === '/';

const getPreservedSearch = (): {status?: IncidentStatus[]} | undefined => {
const getPreservedSearch = (): {status?: StatusFilterValue[]} | undefined => {
const stored = sessionStorage.getItem(STORAGE_KEY);
if (stored) {
try {
Expand Down
10 changes: 8 additions & 2 deletions frontend/src/routes/components/StatusFilter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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, type StatusFilterValue} from '../types';

interface FilterLinkProps {
statuses?: IncidentStatus[];
statuses?: StatusFilterValue[];
label: string;
isActive: boolean;
testId?: string;
Expand Down Expand Up @@ -58,6 +58,12 @@ export function StatusFilter() {
isActive={arraysEqual(status ?? [], STATUS_FILTER_GROUPS.closed)}
testId="filter-closed"
/>
<FilterLink
statuses={STATUS_FILTER_GROUPS.all}
label="All"
isActive={arraysEqual(status ?? [], STATUS_FILTER_GROUPS.all)}
testId="filter-all"
/>
</div>
);
}
129 changes: 129 additions & 0 deletions frontend/src/routes/components/filters/DateRangeFilter.tsx
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same thing in here with the large ternaries, tho if you struggle to break them up, i would move them into their own local component just to keep things a bit tidier imo

Original file line number Diff line number Diff line change
@@ -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 (
<div className="col-span-1">
<div className="mb-space-md">
<h3 className="text-size-md text-content-secondary font-semibold">
Created Date
</h3>
</div>
<div className="gap-space-xs flex flex-wrap items-center">
<div className="flex items-center gap-space-xs">
<Popover
open={editing === 'after'}
onOpenChange={o => setEditing(o ? 'after' : null)}
>
<PopoverTrigger asChild>
<Button variant="secondary" className="text-size-sm">
{after ? formatDateDisplay(after) : 'Any'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto overflow-hidden p-0" align="start">
<Calendar
mode="single"
selected={afterDate}
defaultMonth={afterDate}
captionLayout="dropdown"
showOutsideDays={false}
onSelect={d => handleDateSelect('created_after', d)}
/>
</PopoverContent>
</Popover>
{after && (
<Button
variant="close"
onClick={() => update('created_after', undefined)}
aria-label="Clear start date"
size={null}
>
<XIcon className="h-3.5 w-3.5" />
</Button>
)}
</div>
<span className="text-content-disabled text-size-sm">to</span>
<div className="flex items-center gap-space-xs">
<Popover
open={editing === 'before'}
onOpenChange={o => setEditing(o ? 'before' : null)}
>
<PopoverTrigger asChild>
<Button variant="secondary" className="text-size-sm">
{before ? formatDateDisplay(before) : 'Any'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto overflow-hidden p-0" align="start">
<Calendar
mode="single"
selected={beforeDate}
defaultMonth={beforeDate}
captionLayout="dropdown"
showOutsideDays={false}
onSelect={d => handleDateSelect('created_before', d)}
/>
</PopoverContent>
</Popover>
{before && (
<Button
variant="close"
onClick={() => update('created_before', undefined)}
aria-label="Clear end date"
size={null}
>
<XIcon className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
</div>
);
}
46 changes: 46 additions & 0 deletions frontend/src/routes/components/filters/FilterTrigger.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
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 (
<div className="flex items-center gap-space-md">
{activeCount > 0 && (
<button
type="button"
className="text-content-accent text-size-sm cursor-pointer hover:underline"
onClick={() => {
navigate({
to: '/',
search: {},
replace: true,
});

Check warning on line 22 in frontend/src/routes/components/filters/FilterTrigger.tsx

View workflow job for this annotation

GitHub Actions / warden: code-review

"Clear all filters" unexpectedly clears the status filter

The `navigate({search: {}})` call clears ALL URL search parameters, including `status` which is managed by the separate StatusFilter component. The `activeCount` from `useActiveFilters()` doesn't count `status`, so users see a badge showing only advanced filter counts but clicking 'Clear all' also resets the status tabs (Active/In Review/Closed/All) to the default. Other filter components in this PR use `search: (prev) => ({...prev, ...})` to preserve unrelated params.
}}

Check warning on line 23 in frontend/src/routes/components/filters/FilterTrigger.tsx

View check run for this annotation

@sentry/warden / warden: code-review

"Clear all filters" clears status filter which is not reflected in the filter count badge

The `activeCount` badge only counts advanced filters (severity, service_tier, etc. plus date filters) but excludes the status filter. However, the "Clear all filters" button uses `search: {}` which clears ALL URL params including `status`. This creates a UX inconsistency: if a user is viewing "Closed" incidents with severity=P0, the badge shows "1" but clicking "Clear all filters" also resets the status view from "Closed" to "Active" (the default). Users may not expect their status selection to be reset when the badge doesn't indicate it as an active filter.
Comment on lines +19 to +23
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Clicking "Clear all filters" incorrectly removes the status URL parameter, resetting the user's selected status tab to the default.
Severity: MEDIUM

Suggested Fix

Update the onClick handler in FilterTrigger.tsx to preserve existing URL search parameters when clearing filters. Instead of search: {}, use a functional update like search: s => ({ ...s, ...clearedFilters }) to only remove the relevant filter keys, preserving others like status.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: frontend/src/routes/components/filters/FilterTrigger.tsx#L19-L23

Potential issue: The "Clear all filters" button in `FilterTrigger.tsx` clears all URL
search parameters by calling `navigate` with `search: {}`. When a user is on a
non-default status tab, such as "In Review", the `status` is stored in the URL. If the
user applies advanced filters and then clicks "Clear all filters", this action will
remove not only the advanced filter parameters but also the `status` parameter, causing
the view to unexpectedly reset to the default "Active" status tab.

data-testid="clear-all-filters"
>
Clear all filters
</button>
)}
<Button
variant="secondary"
size="sm"
onClick={onToggle}
aria-expanded={open}
data-testid="advanced-filters-toggle"
>
<SlidersHorizontalIcon className="h-3.5 w-3.5" />
{open ? 'Hide filters' : 'Show filters'}
{activeCount > 0 && (
<span className="bg-background-accent-vibrant text-content-on-vibrant-light ml-space-2xs inline-flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-xs leading-none">
{activeCount}
</span>
)}
</Button>
</div>
);
}
Loading
Loading