diff --git a/frontend/src/routes/components/AdvancedFilters.tsx b/frontend/src/routes/components/AdvancedFilters.tsx
new file mode 100644
index 00000000..0a22d1d0
--- /dev/null
+++ b/frontend/src/routes/components/AdvancedFilters.tsx
@@ -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 (
+
+
+
+ );
+}
diff --git a/frontend/src/routes/components/StatusFilter.tsx b/frontend/src/routes/components/StatusFilter.tsx
index 82b7e96d..dc680221 100644
--- a/frontend/src/routes/components/StatusFilter.tsx
+++ b/frontend/src/routes/components/StatusFilter.tsx
@@ -58,6 +58,12 @@ export function StatusFilter() {
isActive={arraysEqual(status ?? [], STATUS_FILTER_GROUPS.closed)}
testId="filter-closed"
/>
+
);
}
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..2ffe3766
--- /dev/null
+++ b/frontend/src/routes/components/filters/FilterTrigger.tsx
@@ -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 (
+
+ {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..5ccfb656
--- /dev/null
+++ b/frontend/src/routes/components/filters/PillFilter.tsx
@@ -0,0 +1,138 @@
+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 {type ArrayFilterKey} from '../useActiveFilters';
+
+import {useFilterEditor} from './useFilterEditor';
+
+type PillVariant = NonNullable;
+
+interface PillFilterProps {
+ label: string;
+ filterKey: ArrayFilterKey;
+ options: readonly T[];
+}
+
+export function PillFilter({
+ label,
+ filterKey,
+ options,
+}: PillFilterProps) {
+ 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())
+ );
+
+ return (
+
+
+
+
+ {isEditing ? (
+
+ {selected.map(v => (
+
toggle(v)}
+ aria-label={`Remove ${v}`}
+ >
+
+
+ }
+ >
+ {v}
+
+ ))}
+
{
+ setInputValue(e.target.value);
+ setFocusedIndex(0);
+ }}
+ 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"
+ />
+
+ ) : 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..93700eee
--- /dev/null
+++ b/frontend/src/routes/components/filters/TagFilter.tsx
@@ -0,0 +1,133 @@
+import {useQuery} from '@tanstack/react-query';
+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 {type ArrayFilterKey} from '../useActiveFilters';
+
+import {useFilterEditor} from './useFilterEditor';
+
+interface TagFilterProps {
+ label: string;
+ filterKey: ArrayFilterKey;
+ tagType: TagType;
+}
+
+export function TagFilter({label, filterKey, tagType}: TagFilterProps) {
+ const {
+ isEditing,
+ selected,
+ inputValue,
+ focusedIndex,
+ inputRef,
+ setInputValue,
+ setFocusedIndex,
+ toggle,
+ open,
+ close,
+ handleKeyDown,
+ } = useFilterEditor({filterKey});
+ const {data: suggestions = []} = useQuery(tagsQueryOptions(tagType));
+
+ const available = suggestions.filter(
+ (s: string) =>
+ !selected.includes(s) && s.toLowerCase().includes(inputValue.toLowerCase())
+ );
+
+ return (
+
+
+
+
+ {isEditing ? (
+
+ {selected.map(v => (
+ toggle(v)}
+ aria-label={`Remove ${v}`}
+ >
+
+
+ }
+ >
+ {v}
+
+ ))}
+ {
+ setInputValue(e.target.value);
+ setFocusedIndex(0);
+ }}
+ 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"
+ />
+
+ ) : selected.length > 0 ? (
+
+ {selected.map(v => (
+ {v}
+ ))}
+
+ ) : (
+
Any
+ )}
+
+ {isEditing && available.length > 0 && (
+
+
+ {available.map((option: string, index: number) => (
+
+ ))}
+
+
+ )}
+
+
+ {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..e020d4f2
--- /dev/null
+++ b/frontend/src/routes/components/filters/UserFilter.tsx
@@ -0,0 +1,176 @@
+import {useEffect, useRef, useState} from 'react';
+import {useInfiniteQuery} from '@tanstack/react-query';
+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 {type ArrayFilterKey} from '../useActiveFilters';
+
+import {useFilterEditor} from './useFilterEditor';
+
+interface UserFilterProps {
+ label: string;
+ filterKey: ArrayFilterKey;
+}
+
+export function UserFilter({label, filterKey}: UserFilterProps) {
+ const [debouncedSearch, setDebouncedSearch] = useState('');
+ const scrollSentinelRef = useRef(null);
+
+ const {
+ isEditing,
+ selected,
+ inputValue,
+ focusedIndex,
+ inputRef,
+ setInputValue,
+ setFocusedIndex,
+ toggle,
+ open,
+ close,
+ handleKeyDown,
+ } = useFilterEditor({
+ filterKey,
+ onClose: () => setDebouncedSearch(''),
+ onOpen: () => setDebouncedSearch(''),
+ });
+
+ 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]);
+
+ return (
+
+
+
+
+ {isEditing ? (
+
+ {selected.map(v => (
+ toggle(v)}
+ aria-label={`Remove ${v}`}
+ >
+
+
+ }
+ >
+ {v}
+
+ ))}
+ {
+ setInputValue(e.target.value);
+ setFocusedIndex(0);
+ }}
+ onKeyDown={handleKeyDown(available.map(u => u.email))}
+ 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 && (
+
+ )}
+
+ );
+}
diff --git a/frontend/src/routes/components/filters/useFilterEditor.ts b/frontend/src/routes/components/filters/useFilterEditor.ts
new file mode 100644
index 00000000..4bd5e0a5
--- /dev/null
+++ b/frontend/src/routes/components/filters/useFilterEditor.ts
@@ -0,0 +1,121 @@
+import {useCallback, useEffect, useRef, useState} from 'react';
+import {useNavigate} from '@tanstack/react-router';
+
+import {useActiveFilters, type ArrayFilterKey} from '../useActiveFilters';
+
+interface UseFilterEditorOptions {
+ filterKey: ArrayFilterKey;
+ onClose?: () => void;
+ onOpen?: () => void;
+}
+
+// Just a helper hook to prevent duplicate code in the different editor types.
+export function useFilterEditor({filterKey, onClose, onOpen}: UseFilterEditorOptions) {
+ 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 toggle = useCallback((value: string) => {
+ setDraft(prev =>
+ prev.includes(value) ? prev.filter(v => v !== value) : [...prev, value]
+ );
+ }, []);
+
+ const close = useCallback(() => {
+ setIsEditing(false);
+ setInputValue('');
+ setFocusedIndex(0);
+ onClose?.();
+ setDraft(prev => {
+ navigate({
+ to: '/',
+ search: (s: Record) => ({
+ ...s,
+ [filterKey]: prev.length > 0 ? prev : undefined,
+ }),
+ replace: true,
+ });
+ return prev;
+ });
+ }, [navigate, filterKey, onClose]);
+
+ const open = () => {
+ setDraft(committed);
+ setIsEditing(true);
+ setInputValue('');
+ setFocusedIndex(0);
+ onOpen?.();
+ };
+
+ 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 = (identities: string[]) => (e: React.KeyboardEvent) => {
+ switch (e.key) {
+ case 'ArrowDown':
+ e.preventDefault();
+ if (identities.length > 0) {
+ setFocusedIndex(prev => (prev + 1) % identities.length);
+ }
+ break;
+ case 'ArrowUp':
+ e.preventDefault();
+ if (identities.length > 0) {
+ setFocusedIndex(prev => (prev - 1 + identities.length) % identities.length);
+ }
+ break;
+ case 'Enter':
+ if (focusedIndex >= 0 && focusedIndex < identities.length) {
+ e.preventDefault();
+ toggle(identities[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 {
+ isEditing,
+ selected,
+ inputValue,
+ focusedIndex,
+ inputRef,
+ setInputValue,
+ setFocusedIndex,
+ toggle,
+ open,
+ close,
+ handleKeyDown,
+ };
+}
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 2442aab7..b473440a 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,
},
],
};
@@ -52,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/') {
@@ -60,6 +72,12 @@ function setupDefaultMocks() {
if (args.path === '/ui/users/me/') {
return Promise.resolve(mockCurrentUser);
}
+ 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'));
});
}
@@ -171,12 +189,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 () => {
@@ -242,10 +261,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');
});
});
@@ -389,6 +410,53 @@ 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.getByTestId('advanced-filters-toggle')).toHaveAttribute(
+ 'aria-expanded',
+ 'false'
+ );
+ });
+
+ 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();
+ });
+
+ it('does not auto-open when URL has advanced filters', async () => {
+ renderRoute('/?severity=P0');
+
+ await screen.findByText('INC-1247');
+
+ expect(screen.getByTestId('advanced-filters-toggle')).toHaveAttribute(
+ 'aria-expanded',
+ 'false'
+ );
+ });
+});
+
describe('Route States', () => {
beforeEach(() => {
queryClient.clear();
@@ -457,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}) => {
diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx
index 5d5d70da..8b07268c 100644
--- a/frontend/src/routes/index.tsx
+++ b/frontend/src/routes/index.tsx
@@ -1,4 +1,4 @@
-import {useEffect, useRef} from 'react';
+import {useEffect, useRef, useState} from 'react';
import {useSuspenseInfiniteQuery} from '@tanstack/react-query';
import {createFileRoute} from '@tanstack/react-router';
import {zodValidator} from '@tanstack/zod-adapter';
@@ -8,6 +8,7 @@ import {Spinner} from 'components/Spinner';
import {arraysEqual} from 'utils/arrays';
import {z} from 'zod';
+import {FilterPanel, FilterTrigger} from './components/AdvancedFilters';
import {IncidentCard} from './components/IncidentCard';
import {IncidentListSkeleton} from './components/IncidentListSkeleton';
import {StatusFilter} from './components/StatusFilter';
@@ -38,10 +39,16 @@ const incidentListSearchSchema = z.object({
});
function IncidentsLayout({children}: {children: React.ReactNode}) {
+ const [open, setOpen] = useState(false);
+
return (
-
-
-
+
+
+
+ setOpen(prev => !prev)} />
+
+ {open &&
}
+
{children}
);
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),
+ });
+}
diff --git a/frontend/src/routes/types.ts b/frontend/src/routes/types.ts
index d0c9bc07..52720e0f 100644
--- a/frontend/src/routes/types.ts
+++ b/frontend/src/routes/types.ts
@@ -12,10 +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 IncidentStatus[],
};