1+ import * as Ariakit from "@ariakit/react" ;
12import { XMarkIcon } from "@heroicons/react/20/solid" ;
2- import { Form , type MetaFunction , useSearchParams as useRemixSearchParams } from "@remix-run/react" ;
3+ import { Form , type MetaFunction } from "@remix-run/react" ;
34import { type LoaderFunctionArgs } from "@remix-run/server-runtime" ;
45import { type ErrorGroupStatus } from "@trigger.dev/database" ;
6+ import { IconBugFilled } from "@tabler/icons-react" ;
57import { ErrorId } from "@trigger.dev/core/v3/isomorphic" ;
6- import { Suspense , useMemo } from "react" ;
8+ import { Suspense , useMemo , type ReactNode } from "react" ;
79import {
810 Bar ,
911 BarChart ,
@@ -18,12 +20,21 @@ import { PageBody } from "~/components/layout/AppLayout";
1820import { SearchInput } from "~/components/primitives/SearchInput" ;
1921import { LogsTaskFilter } from "~/components/logs/LogsTaskFilter" ;
2022import { LogsVersionFilter } from "~/components/logs/LogsVersionFilter" ;
23+ import { AppliedFilter } from "~/components/primitives/AppliedFilter" ;
2124import { Button } from "~/components/primitives/Buttons" ;
2225import { Callout } from "~/components/primitives/Callout" ;
2326import { formatDateTime , RelativeDateTime } from "~/components/primitives/DateTime" ;
2427import { Header3 } from "~/components/primitives/Headers" ;
2528import { NavBar , PageTitle } from "~/components/primitives/PageHeader" ;
2629import { Paragraph } from "~/components/primitives/Paragraph" ;
30+ import {
31+ ComboBox ,
32+ SelectItem ,
33+ SelectList ,
34+ SelectPopover ,
35+ SelectProvider ,
36+ SelectTrigger ,
37+ } from "~/components/primitives/Select" ;
2738import { Spinner } from "~/components/primitives/Spinner" ;
2839import {
2940 CopyableTableCell ,
@@ -36,9 +47,10 @@ import {
3647 TableRow ,
3748} from "~/components/primitives/Table" ;
3849import TooltipPortal from "~/components/primitives/TooltipPortal" ;
39- import { TimeFilter } from "~/components/runs/v3/SharedFilters" ;
50+ import { appliedSummary , FilterMenuProvider , TimeFilter } from "~/components/runs/v3/SharedFilters" ;
4051import { $replica } from "~/db.server" ;
4152import { useOptimisticLocation } from "~/hooks/useOptimisticLocation" ;
53+ import { useSearchParams } from "~/hooks/useSearchParam" ;
4254import { findProjectBySlug } from "~/models/project.server" ;
4355import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server" ;
4456import {
@@ -55,7 +67,6 @@ import { ListPagination } from "~/components/ListPagination";
5567import { formatNumberCompact } from "~/utils/numberFormatter" ;
5668import { EnvironmentParamSchema , v3ErrorPath } from "~/utils/pathBuilder" ;
5769import { ServiceValidationError } from "~/v3/services/baseService.server" ;
58- import SegmentedControl from "~/components/primitives/SegmentedControl" ;
5970
6071export const meta : MetaFunction = ( ) => {
6172 return [
@@ -84,11 +95,11 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
8495 const url = new URL ( request . url ) ;
8596 const tasks = url . searchParams . getAll ( "tasks" ) . filter ( ( t ) => t . length > 0 ) ;
8697 const versions = url . searchParams . getAll ( "versions" ) . filter ( ( v ) => v . length > 0 ) ;
87- const statusParam = url . searchParams . get ( "status" ) ?? undefined ;
88- const status =
89- statusParam === "UNRESOLVED" || statusParam === "RESOLVED" || statusParam === "IGNORED"
90- ? ( statusParam as ErrorGroupStatus )
91- : undefined ;
98+ const statuses = url . searchParams
99+ . getAll ( "status" )
100+ . filter (
101+ ( s ) : s is ErrorGroupStatus => s === "UNRESOLVED" || s === "RESOLVED" || s === "IGNORED"
102+ ) ;
92103 const search = url . searchParams . get ( "search" ) ?? undefined ;
93104 const period = url . searchParams . get ( "period" ) ?? undefined ;
94105 const fromStr = url . searchParams . get ( "from" ) ;
@@ -111,7 +122,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
111122 projectId : project . id ,
112123 tasks : tasks . length > 0 ? tasks : undefined ,
113124 versions : versions . length > 0 ? versions : undefined ,
114- status ,
125+ statuses : statuses . length > 0 ? statuses : undefined ,
115126 search,
116127 period,
117128 from,
@@ -237,38 +248,117 @@ export default function Page() {
237248 ) ;
238249}
239250
240- function StatusFilterButton ( ) {
241- const [ searchParams , setSearchParams ] = useRemixSearchParams ( ) ;
242- const currentStatus = searchParams . get ( "status" ) ?? "all" ;
251+ const errorStatusOptions = [
252+ { value : "UNRESOLVED" , label : "Unresolved" } ,
253+ { value : "RESOLVED" , label : "Resolved" } ,
254+ { value : "IGNORED" , label : "Ignored" } ,
255+ ] as const ;
243256
244- const options : Array < { value : string ; label : string } > = [
245- { value : "all" , label : "All" } ,
246- { value : "UNRESOLVED" , label : "Unresolved" } ,
247- { value : "RESOLVED" , label : "Resolved" } ,
248- { value : "IGNORED" , label : "Ignored" } ,
249- ] ;
257+ const statusIcon = < IconBugFilled className = "size-4" /> ;
258+ const statusShortcut = { key : "s" } ;
259+
260+ function StatusFilter ( ) {
261+ const { values, del } = useSearchParams ( ) ;
262+ const selectedStatuses = values ( "status" ) ;
263+
264+ if ( selectedStatuses . length === 0 || selectedStatuses . every ( ( v ) => v === "" ) ) {
265+ return (
266+ < FilterMenuProvider >
267+ { ( search , setSearch ) => (
268+ < ErrorStatusDropdown
269+ trigger = {
270+ < SelectTrigger
271+ icon = { statusIcon }
272+ variant = "secondary/small"
273+ shortcut = { statusShortcut }
274+ tooltipTitle = "Filter by status"
275+ >
276+ < span className = "ml-0.5" > Status</ span >
277+ </ SelectTrigger >
278+ }
279+ searchValue = { search }
280+ clearSearchValue = { ( ) => setSearch ( "" ) }
281+ />
282+ ) }
283+ </ FilterMenuProvider >
284+ ) ;
285+ }
250286
251287 return (
252- < SegmentedControl
253- name = "status"
254- value = { currentStatus }
255- variant = "secondary/small"
256- options = { options . map ( ( opt ) => ( {
257- label : opt . label ,
258- value : opt . value ,
259- } ) ) }
260- onChange = { ( value ) => {
261- const next = new URLSearchParams ( searchParams ) ;
262- next . delete ( "cursor" ) ;
263- next . delete ( "direction" ) ;
264- if ( value === "all" ) {
265- next . delete ( "status" ) ;
266- } else {
267- next . set ( "status" , value ) ;
268- }
269- setSearchParams ( next ) ;
270- } }
271- />
288+ < FilterMenuProvider >
289+ { ( search , setSearch ) => (
290+ < ErrorStatusDropdown
291+ trigger = {
292+ < Ariakit . Select render = { < div className = "group cursor-pointer focus-custom" /> } >
293+ < AppliedFilter
294+ label = "Status"
295+ icon = { statusIcon }
296+ value = { appliedSummary (
297+ selectedStatuses . map ( ( s ) => {
298+ const opt = errorStatusOptions . find ( ( o ) => o . value === s ) ;
299+ return opt ? opt . label : s ;
300+ } )
301+ ) }
302+ onRemove = { ( ) => del ( [ "status" , "cursor" , "direction" ] ) }
303+ variant = "secondary/small"
304+ />
305+ </ Ariakit . Select >
306+ }
307+ searchValue = { search }
308+ clearSearchValue = { ( ) => setSearch ( "" ) }
309+ />
310+ ) }
311+ </ FilterMenuProvider >
312+ ) ;
313+ }
314+
315+ function ErrorStatusDropdown ( {
316+ trigger,
317+ clearSearchValue,
318+ searchValue,
319+ onClose,
320+ } : {
321+ trigger : ReactNode ;
322+ clearSearchValue : ( ) => void ;
323+ searchValue : string ;
324+ onClose ?: ( ) => void ;
325+ } ) {
326+ const { values, replace } = useSearchParams ( ) ;
327+
328+ const handleChange = ( values : string [ ] ) => {
329+ clearSearchValue ( ) ;
330+ replace ( { status : values . length > 0 ? values : undefined , cursor : undefined , direction : undefined } ) ;
331+ } ;
332+
333+ const filtered = useMemo ( ( ) => {
334+ return errorStatusOptions . filter ( ( item ) =>
335+ item . label . toLowerCase ( ) . includes ( searchValue . toLowerCase ( ) )
336+ ) ;
337+ } , [ searchValue ] ) ;
338+
339+ return (
340+ < SelectProvider value = { values ( "status" ) } setValue = { handleChange } virtualFocus = { true } >
341+ { trigger }
342+ < SelectPopover
343+ className = "min-w-0 max-w-[min(240px,var(--popover-available-width))]"
344+ hideOnEscape = { ( ) => {
345+ if ( onClose ) {
346+ onClose ( ) ;
347+ return false ;
348+ }
349+ return true ;
350+ } }
351+ >
352+ < ComboBox placeholder = "Filter by status..." value = { searchValue } />
353+ < SelectList >
354+ { filtered . map ( ( item ) => (
355+ < SelectItem key = { item . value } value = { item . value } >
356+ { item . label }
357+ </ SelectItem >
358+ ) ) }
359+ </ SelectList >
360+ </ SelectPopover >
361+ </ SelectProvider >
272362 ) ;
273363}
274364
@@ -284,6 +374,7 @@ function FiltersBar({
284374 const location = useOptimisticLocation ( ) ;
285375 const searchParams = new URLSearchParams ( location . search ) ;
286376 const hasFilters =
377+ searchParams . has ( "status" ) ||
287378 searchParams . has ( "tasks" ) ||
288379 searchParams . has ( "versions" ) ||
289380 searchParams . has ( "search" ) ||
@@ -296,7 +387,7 @@ function FiltersBar({
296387 < div className = "flex flex-row flex-wrap items-center gap-1" >
297388 { list ? (
298389 < >
299- < StatusFilterButton />
390+ < StatusFilter />
300391 < LogsTaskFilter possibleTasks = { list . filters . possibleTasks } />
301392 < LogsVersionFilter />
302393 < TimeFilter
@@ -317,7 +408,7 @@ function FiltersBar({
317408 </ >
318409 ) : (
319410 < >
320- < StatusFilterButton />
411+ < StatusFilter />
321412 < LogsTaskFilter possibleTasks = { [ ] } />
322413 < LogsVersionFilter />
323414 < TimeFilter defaultPeriod = { defaultPeriod } maxPeriodDays = { retentionLimitDays } />
0 commit comments