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 ( +
+
+

{label}

+ +
+ +
+ {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 ( +
+
+

{label}

+ +
+ +
+ {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 ( +
+
+

{label}

+ +
+ +
+ {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[], };