-
-
Notifications
You must be signed in to change notification settings - Fork 0
feat: Add advanced filters panel to incident list #126
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
spalmurray
wants to merge
13
commits into
main
Choose a base branch
from
spalmurray/advanced-filters-ui
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
1b1dbc0
Add advanced filtering UI for incident list
spalmurray 4c84af9
Fix import paths in AdvancedFilters to use relative imports
spalmurray 2324838
Create filters layout
spalmurray 462257e
Add filter panel with severity, service tier, and tag filters
spalmurray f21a772
Defer filter param updates until edit close, add All status tab
spalmurray 5883c75
Add captain and reporter filters with server-side user search
spalmurray d58f6ea
Add date range filter with responsive grid layout
spalmurray 75f4aad
Refactor filter components
spalmurray 16af3a7
Reset all params instead of explicitly setting everything to undefined
spalmurray 980fc93
Use IncidentStatus type instead of string[]
spalmurray 0c04fbd
Add some filter tests
spalmurray 8e2b626
Fix space key hijacking search input, remove unused MultiSelect
spalmurray 055d7fe
Pull shared editor code out to a hook
spalmurray File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
129 changes: 129 additions & 0 deletions
129
frontend/src/routes/components/filters/DateRangeFilter.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| }); | ||
| }} | ||
| 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> | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<PillProps['variant']>; | ||
|
|
||
| interface PillFilterProps<T extends PillVariant> { | ||
| label: string; | ||
| filterKey: ArrayFilterKey; | ||
| options: readonly T[]; | ||
| } | ||
|
|
||
| export function PillFilter<T extends PillVariant>({ | ||
| label, | ||
| filterKey, | ||
| options, | ||
| }: PillFilterProps<T>) { | ||
| 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 ( | ||
| <div> | ||
| <div className="mb-space-md gap-space-xs flex items-center"> | ||
| <h3 className="text-size-md text-content-secondary font-semibold">{label}</h3> | ||
| <Button | ||
| variant="icon" | ||
| onClick={open} | ||
| aria-label={`Edit ${label}`} | ||
| className={cn(isEditing && 'invisible')} | ||
| > | ||
| <Pencil className="h-4 w-4" /> | ||
| </Button> | ||
| </div> | ||
|
|
||
| <div className={cn('relative', isEditing && 'z-50')}> | ||
| {isEditing ? ( | ||
| <div className="gap-space-sm flex flex-wrap items-center"> | ||
| {selected.map(v => ( | ||
| <Tag | ||
| key={v} | ||
| action={ | ||
| <Button | ||
| variant="close" | ||
| size={null} | ||
| onClick={() => toggle(v)} | ||
| aria-label={`Remove ${v}`} | ||
| > | ||
| <XIcon className="h-3.5 w-3.5" /> | ||
| </Button> | ||
| } | ||
| > | ||
| <Pill variant={v as T}>{v}</Pill> | ||
| </Tag> | ||
| ))} | ||
| <input | ||
| ref={inputRef} | ||
| type="text" | ||
| value={inputValue} | ||
| onChange={e => { | ||
| 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" | ||
| /> | ||
| </div> | ||
| ) : selected.length > 0 ? ( | ||
| <div className="gap-space-sm flex flex-wrap"> | ||
| {selected.map(v => ( | ||
| <Tag key={v}> | ||
| <Pill variant={v as T}>{v}</Pill> | ||
| </Tag> | ||
| ))} | ||
| </div> | ||
| ) : ( | ||
| <p className="text-size-sm text-content-disabled italic">Any</p> | ||
| )} | ||
|
|
||
| {isEditing && available.length > 0 && ( | ||
| <div className="mt-space-xs rounded-radius-md bg-background-primary absolute right-0 left-0 z-50 border border-gray-200 shadow-lg"> | ||
| <div className="p-space-sm max-h-[200px] overflow-y-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"> | ||
| {available.map((option, index) => ( | ||
| <button | ||
| key={option} | ||
| type="button" | ||
| onClick={() => { | ||
| toggle(option); | ||
| setInputValue(''); | ||
| setFocusedIndex(0); | ||
| inputRef.current?.focus(); | ||
| }} | ||
| className={cn( | ||
| 'w-full text-left px-space-md py-space-sm cursor-pointer rounded-radius-sm text-size-sm', | ||
| index === focusedIndex | ||
| ? 'bg-background-secondary' | ||
| : 'hover:bg-background-transparent-neutral-muted' | ||
| )} | ||
| > | ||
| <Pill variant={option}>{option}</Pill> | ||
| </button> | ||
| ))} | ||
| </div> | ||
| </div> | ||
| )} | ||
| </div> | ||
|
|
||
| {isEditing && ( | ||
| <div | ||
| className="fixed inset-0 z-40 bg-transparent" | ||
| aria-hidden="true" | ||
| onClick={close} | ||
| /> | ||
| )} | ||
| </div> | ||
| ); | ||
| } | ||
cursor[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.