Skip to content

Commit 0b49a01

Browse files
committed
Filter Bar
1 parent d1ebf96 commit 0b49a01

2 files changed

Lines changed: 386 additions & 1 deletion

File tree

knip.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
"src/components/ui/**",
77
"openapi-ts.config.ts",
88
"vite.config.ghpages.js",
9-
"src/types/pipelineRunFilters.ts"
9+
"src/types/pipelineRunFilters.ts",
10+
"src/components/shared/PipelineRunFiltersBar/PipelineRunFiltersBar.tsx"
1011
],
1112
"ignoreDependencies": [
1213
"@radix-ui/react-accordion",
Lines changed: 384 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,384 @@
1+
import { format } from "date-fns";
2+
import {
3+
ArrowDownAZ,
4+
ArrowUpAZ,
5+
ChevronDown,
6+
ChevronUp,
7+
Search,
8+
X,
9+
} from "lucide-react";
10+
import { useState } from "react";
11+
import type { DateRange } from "react-day-picker";
12+
13+
import type { ContainerExecutionStatus } from "@/api/types.gen";
14+
import { AnnotationFilterInput } from "@/components/shared/AnnotationFilterInput/AnnotationFilterInput";
15+
import { CreatedByFilter } from "@/components/shared/CreatedByFilter/CreatedByFilter";
16+
import { Badge } from "@/components/ui/badge";
17+
import { Button } from "@/components/ui/button";
18+
import {
19+
Collapsible,
20+
CollapsibleContent,
21+
CollapsibleTrigger,
22+
} from "@/components/ui/collapsible";
23+
import { DatePickerWithRange } from "@/components/ui/date-picker";
24+
import { Input } from "@/components/ui/input";
25+
import { InlineStack } from "@/components/ui/layout";
26+
import {
27+
Select,
28+
SelectContent,
29+
SelectItem,
30+
SelectTrigger,
31+
SelectValue,
32+
} from "@/components/ui/select";
33+
import { Text } from "@/components/ui/typography";
34+
import { useRunSearchParams } from "@/hooks/useRunSearchParams";
35+
import type { AnnotationFilter, SortField } from "@/types/pipelineRunFilters";
36+
import {
37+
EXECUTION_STATUS_LABELS,
38+
getExecutionStatusLabel,
39+
} from "@/utils/executionStatus";
40+
41+
function isValidStatus(value: string): value is ContainerExecutionStatus {
42+
return value in EXECUTION_STATUS_LABELS;
43+
}
44+
45+
const STATUS_OPTIONS = Object.keys(EXECUTION_STATUS_LABELS).filter(
46+
isValidStatus,
47+
);
48+
49+
const SORT_FIELD_OPTIONS: { value: SortField; label: string }[] = [
50+
{ value: "created_at", label: "Date" },
51+
{ value: "pipeline_name", label: "Name" },
52+
];
53+
54+
function isValidSortField(value: string): value is SortField {
55+
return SORT_FIELD_OPTIONS.some((option) => option.value === value);
56+
}
57+
58+
const MAX_VISIBLE_BADGES = 4;
59+
60+
interface PipelineRunFiltersBarProps {
61+
totalCount?: number;
62+
filteredCount?: number;
63+
}
64+
65+
export function PipelineRunFiltersBar({
66+
totalCount,
67+
filteredCount,
68+
}: PipelineRunFiltersBarProps) {
69+
const {
70+
filters,
71+
setFilter,
72+
setFilters,
73+
setFilterDebounced,
74+
clearFilters,
75+
hasActiveFilters,
76+
activeFilterCount,
77+
} = useRunSearchParams();
78+
79+
const [nameInput, setNameInput] = useState(filters.pipeline_name ?? "");
80+
const [isAdvancedOpen, setIsAdvancedOpen] = useState(false);
81+
const [showAllBadges, setShowAllBadges] = useState(false);
82+
83+
const dateRange: DateRange | undefined =
84+
filters.created_after || filters.created_before
85+
? {
86+
from: filters.created_after
87+
? new Date(filters.created_after)
88+
: undefined,
89+
to: filters.created_before
90+
? new Date(filters.created_before)
91+
: undefined,
92+
}
93+
: undefined;
94+
95+
const handleDateRangeChange = (range: DateRange | undefined) => {
96+
setFilters({
97+
created_after: range?.from?.toISOString(),
98+
created_before: range?.to?.toISOString(),
99+
});
100+
};
101+
102+
const handleAnnotationsChange = (annotations: AnnotationFilter[]) => {
103+
setFilter("annotations", annotations.length > 0 ? annotations : undefined);
104+
};
105+
106+
const handleClearAll = () => {
107+
clearFilters();
108+
setNameInput("");
109+
setShowAllBadges(false);
110+
};
111+
112+
const toggleSortDirection = () => {
113+
const current = filters.sort_direction ?? "desc";
114+
setFilter("sort_direction", current === "desc" ? "asc" : "desc");
115+
};
116+
117+
const hasAnnotations =
118+
filters.annotations && filters.annotations.length > 0;
119+
const hasAdvancedFilters = hasAnnotations || filters.created_by;
120+
121+
// Build list of all active filter badges
122+
const allBadges: Array<{
123+
key: string;
124+
label: string;
125+
onRemove: () => void;
126+
}> = [];
127+
128+
if (filters.status) {
129+
allBadges.push({
130+
key: "status",
131+
label: getExecutionStatusLabel(filters.status),
132+
onRemove: () => setFilter("status", undefined),
133+
});
134+
}
135+
136+
if (filters.pipeline_name) {
137+
allBadges.push({
138+
key: "name",
139+
label: `Name: ${filters.pipeline_name}`,
140+
onRemove: () => {
141+
setFilter("pipeline_name", undefined);
142+
setNameInput("");
143+
},
144+
});
145+
}
146+
147+
if (filters.created_by) {
148+
allBadges.push({
149+
key: "created_by",
150+
label: `Created by: ${filters.created_by}`,
151+
onRemove: () => setFilter("created_by", undefined),
152+
});
153+
}
154+
155+
if (filters.created_after || filters.created_before) {
156+
const fromStr = filters.created_after
157+
? format(new Date(filters.created_after), "MMM d")
158+
: "";
159+
const toStr = filters.created_before
160+
? format(new Date(filters.created_before), "MMM d")
161+
: "";
162+
const separator = fromStr && toStr ? " – " : "";
163+
164+
allBadges.push({
165+
key: "date",
166+
label: `${fromStr}${separator}${toStr}`,
167+
onRemove: () =>
168+
setFilters({ created_after: undefined, created_before: undefined }),
169+
});
170+
}
171+
172+
filters.annotations?.forEach((annotation, index) => {
173+
allBadges.push({
174+
key: `annotation-${index}`,
175+
label: annotation.value
176+
? `${annotation.key}: ${annotation.value}`
177+
: annotation.key,
178+
onRemove: () => {
179+
const newAnnotations = filters.annotations?.filter(
180+
(_, i) => i !== index,
181+
);
182+
setFilter(
183+
"annotations",
184+
newAnnotations && newAnnotations.length > 0
185+
? newAnnotations
186+
: undefined,
187+
);
188+
},
189+
});
190+
});
191+
192+
const visibleBadges = showAllBadges
193+
? allBadges
194+
: allBadges.slice(0, MAX_VISIBLE_BADGES);
195+
const hiddenBadgeCount = allBadges.length - MAX_VISIBLE_BADGES;
196+
const hasHiddenBadges = hiddenBadgeCount > 0 && !showAllBadges;
197+
198+
return (
199+
<Collapsible open={isAdvancedOpen} onOpenChange={setIsAdvancedOpen}>
200+
<div className="space-y-3">
201+
{/* Row 1: Basic Filters */}
202+
<InlineStack gap="3" align="center">
203+
{/* Search Input - flexible width */}
204+
<div className="relative flex-1 min-w-0">
205+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
206+
<Input
207+
placeholder="Search by pipeline name..."
208+
value={nameInput}
209+
onChange={(e) => {
210+
setNameInput(e.target.value);
211+
setFilterDebounced("pipeline_name", e.target.value);
212+
}}
213+
className="pl-9 pr-8 w-full"
214+
/>
215+
{nameInput && (
216+
<button
217+
onClick={() => {
218+
setNameInput("");
219+
setFilter("pipeline_name", undefined);
220+
}}
221+
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
222+
aria-label="Clear search"
223+
>
224+
<X className="h-4 w-4" />
225+
</button>
226+
)}
227+
</div>
228+
229+
{/* Status Filter */}
230+
<Select
231+
value={filters.status ?? "all"}
232+
onValueChange={(value) =>
233+
setFilter("status", isValidStatus(value) ? value : undefined)
234+
}
235+
>
236+
<SelectTrigger className="w-44 shrink-0">
237+
<SelectValue placeholder="Status" />
238+
</SelectTrigger>
239+
<SelectContent>
240+
<SelectItem value="all">All statuses</SelectItem>
241+
{STATUS_OPTIONS.map((status) => (
242+
<SelectItem key={status} value={status}>
243+
{getExecutionStatusLabel(status)}
244+
</SelectItem>
245+
))}
246+
</SelectContent>
247+
</Select>
248+
249+
{/* Date Range */}
250+
<div className="shrink-0">
251+
<DatePickerWithRange
252+
value={dateRange}
253+
onChange={handleDateRangeChange}
254+
placeholder="Date range"
255+
/>
256+
</div>
257+
258+
{/* Sort Controls */}
259+
<InlineStack gap="1" align="center" className="shrink-0">
260+
<Select
261+
value={filters.sort_field ?? "created_at"}
262+
onValueChange={(value) =>
263+
setFilter(
264+
"sort_field",
265+
isValidSortField(value) ? value : undefined,
266+
)
267+
}
268+
>
269+
<SelectTrigger className="w-24">
270+
<SelectValue placeholder="Sort" />
271+
</SelectTrigger>
272+
<SelectContent>
273+
{SORT_FIELD_OPTIONS.map((field) => (
274+
<SelectItem key={field.value} value={field.value}>
275+
{field.label}
276+
</SelectItem>
277+
))}
278+
</SelectContent>
279+
</Select>
280+
<Button variant="ghost" size="icon" onClick={toggleSortDirection}>
281+
{(filters.sort_direction ?? "desc") === "desc" ? (
282+
<ArrowDownAZ className="h-4 w-4" />
283+
) : (
284+
<ArrowUpAZ className="h-4 w-4" />
285+
)}
286+
</Button>
287+
</InlineStack>
288+
289+
{/* Advanced Toggle */}
290+
<CollapsibleTrigger asChild>
291+
<Button
292+
variant={hasAdvancedFilters ? "secondary" : "outline"}
293+
size="sm"
294+
className="shrink-0"
295+
>
296+
Advanced
297+
{hasAdvancedFilters && (
298+
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 px-1">
299+
{(filters.annotations?.length ?? 0) +
300+
(filters.created_by ? 1 : 0)}
301+
</Badge>
302+
)}
303+
{isAdvancedOpen ? (
304+
<ChevronUp className="ml-1 h-4 w-4" />
305+
) : (
306+
<ChevronDown className="ml-1 h-4 w-4" />
307+
)}
308+
</Button>
309+
</CollapsibleTrigger>
310+
</InlineStack>
311+
312+
{/* Row 2: Advanced Filters (Collapsible) */}
313+
<CollapsibleContent>
314+
<div className="space-y-3 rounded-md border bg-muted/30 px-4 py-3">
315+
<CreatedByFilter
316+
value={filters.created_by}
317+
onChange={(value) => setFilter("created_by", value)}
318+
/>
319+
<AnnotationFilterInput
320+
filters={filters.annotations ?? []}
321+
onChange={handleAnnotationsChange}
322+
/>
323+
</div>
324+
</CollapsibleContent>
325+
326+
{/* Row 3: Active Filters & Count */}
327+
{(hasActiveFilters || totalCount !== undefined) && (
328+
<InlineStack gap="2" align="center" blockAlign="center">
329+
{totalCount !== undefined && (
330+
<Text size="sm" tone="subdued">
331+
Showing {filteredCount ?? totalCount} of {totalCount} runs
332+
</Text>
333+
)}
334+
335+
<div className="flex-1" />
336+
337+
{hasActiveFilters && (
338+
<InlineStack gap="2" align="center">
339+
{visibleBadges.map((badge) => (
340+
<Badge key={badge.key} variant="outline">
341+
{badge.label}
342+
<button
343+
onClick={badge.onRemove}
344+
className="ml-1 hover:text-destructive"
345+
aria-label={`Remove ${badge.label} filter`}
346+
>
347+
<X className="h-3 w-3" />
348+
</button>
349+
</Badge>
350+
))}
351+
352+
{hasHiddenBadges && (
353+
<Button
354+
variant="outline"
355+
size="sm"
356+
onClick={() => setShowAllBadges(true)}
357+
className="h-6 px-2 text-xs"
358+
>
359+
+{hiddenBadgeCount} more
360+
</Button>
361+
)}
362+
363+
{showAllBadges && allBadges.length > MAX_VISIBLE_BADGES && (
364+
<Button
365+
variant="ghost"
366+
size="sm"
367+
onClick={() => setShowAllBadges(false)}
368+
className="h-6 px-2 text-xs"
369+
>
370+
Show less
371+
</Button>
372+
)}
373+
374+
<Button variant="ghost" size="sm" onClick={handleClearAll}>
375+
Clear all ({activeFilterCount})
376+
</Button>
377+
</InlineStack>
378+
)}
379+
</InlineStack>
380+
)}
381+
</div>
382+
</Collapsible>
383+
);
384+
}

0 commit comments

Comments
 (0)