Skip to content

Commit 570e20c

Browse files
authored
Merge pull request #15 from Climate-REF/add-filters
2 parents 6e0b479 + a78d0bf commit 570e20c

2 files changed

Lines changed: 215 additions & 0 deletions

File tree

frontend/src/components/diagnostics/figureGallery.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ import {
2525
} from "@/components/ui/select.tsx";
2626
import { FigureGalleryModal } from "./figureGalleryModal.tsx";
2727
import { FigureGallerySkeleton } from "./figureGallerySkeleton.tsx";
28+
import {
29+
matchesSelectorFilters,
30+
SelectorFilterPanel,
31+
} from "./selectorFilterPanel.tsx";
2832

2933
interface DiagnosticFigureGalleryProps {
3034
providerSlug: string;
@@ -71,6 +75,9 @@ export function FigureGallery({
7175
}: DiagnosticFigureGalleryProps) {
7276
const [filter, setFilter] = useState("");
7377
const [selectedGroup, setSelectedGroup] = useState<string>("all");
78+
const [selectorFilters, setSelectorFilters] = useState<
79+
Record<string, string[]>
80+
>({});
7481
const [selectedFigureIndex, setSelectedFigureIndex] = useState<number | null>(
7582
null,
7683
);
@@ -117,6 +124,9 @@ export function FigureGallery({
117124
) {
118125
return false;
119126
}
127+
if (!matchesSelectorFilters(executionGroup.selectors, selectorFilters)) {
128+
return false;
129+
}
120130
if (filter) {
121131
try {
122132
const regex = new RegExp(filter, "i");
@@ -189,6 +199,12 @@ export function FigureGallery({
189199
</div>
190200
</div>
191201

202+
<SelectorFilterPanel
203+
executionGroups={executionGroups?.data ?? []}
204+
filters={selectorFilters}
205+
onFiltersChange={setSelectorFilters}
206+
/>
207+
192208
{filteredFigures.length > 0 ? (
193209
<div
194210
className="relative"
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import { Check, X } from "lucide-react";
2+
import { useMemo } from "react";
3+
import type { ExecutionGroup } from "@/client";
4+
import { Badge } from "@/components/ui/badge.tsx";
5+
import { Button } from "@/components/ui/button.tsx";
6+
import {
7+
Command,
8+
CommandEmpty,
9+
CommandGroup,
10+
CommandInput,
11+
CommandItem,
12+
CommandList,
13+
} from "@/components/ui/command.tsx";
14+
import {
15+
Popover,
16+
PopoverContent,
17+
PopoverTrigger,
18+
} from "@/components/ui/popover.tsx";
19+
import { cn } from "@/lib/utils";
20+
21+
interface SelectorFacet {
22+
key: string;
23+
values: string[];
24+
}
25+
26+
interface SelectorFilterPanelProps {
27+
executionGroups: ExecutionGroup[];
28+
filters: Record<string, string[]>;
29+
onFiltersChange: (filters: Record<string, string[]>) => void;
30+
}
31+
32+
function extractSelectorFacets(groups: ExecutionGroup[]): SelectorFacet[] {
33+
const facetMap = new Map<string, Set<string>>();
34+
for (const group of groups) {
35+
for (const pairs of Object.values(group.selectors)) {
36+
for (const [key, value] of pairs) {
37+
if (!facetMap.has(key)) {
38+
facetMap.set(key, new Set());
39+
}
40+
facetMap.get(key)!.add(value);
41+
}
42+
}
43+
}
44+
return Array.from(facetMap.entries())
45+
.map(([key, values]) => ({
46+
key,
47+
values: Array.from(values).sort(),
48+
}))
49+
.sort((a, b) => a.key.localeCompare(b.key));
50+
}
51+
52+
export function matchesSelectorFilters(
53+
selectors: ExecutionGroup["selectors"],
54+
filters: Record<string, string[]>,
55+
): boolean {
56+
for (const [filterKey, filterValues] of Object.entries(filters)) {
57+
if (filterValues.length === 0) continue;
58+
const groupValues = Object.values(selectors).flatMap((pairs) =>
59+
pairs.filter(([k]) => k === filterKey).map(([, v]) => v),
60+
);
61+
if (!filterValues.some((v) => groupValues.includes(v))) {
62+
return false;
63+
}
64+
}
65+
return true;
66+
}
67+
68+
function MultiSelectFacet({
69+
facet,
70+
selected,
71+
onSelectionChange,
72+
}: {
73+
facet: SelectorFacet;
74+
selected: string[];
75+
onSelectionChange: (values: string[]) => void;
76+
}) {
77+
return (
78+
<Popover>
79+
<PopoverTrigger asChild>
80+
<Button variant="outline" size="sm" className="h-8">
81+
{facet.key}
82+
{selected.length > 0 && (
83+
<Badge variant="secondary" className="ml-1 px-1">
84+
{selected.length}
85+
</Badge>
86+
)}
87+
</Button>
88+
</PopoverTrigger>
89+
<PopoverContent className="w-[220px] p-0" align="start">
90+
<Command>
91+
<CommandInput placeholder={`Search ${facet.key}...`} />
92+
<CommandList>
93+
<CommandEmpty>No values found.</CommandEmpty>
94+
<CommandGroup>
95+
{facet.values.map((value) => {
96+
const isSelected = selected.includes(value);
97+
return (
98+
<CommandItem
99+
key={value}
100+
value={value}
101+
onSelect={() => {
102+
onSelectionChange(
103+
isSelected
104+
? selected.filter((v) => v !== value)
105+
: [...selected, value],
106+
);
107+
}}
108+
>
109+
<div
110+
className={cn(
111+
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
112+
isSelected
113+
? "bg-primary text-primary-foreground"
114+
: "opacity-50 [&_svg]:invisible",
115+
)}
116+
>
117+
<Check className="h-4 w-4" />
118+
</div>
119+
<span>{value}</span>
120+
</CommandItem>
121+
);
122+
})}
123+
</CommandGroup>
124+
</CommandList>
125+
</Command>
126+
</PopoverContent>
127+
</Popover>
128+
);
129+
}
130+
131+
export function SelectorFilterPanel({
132+
executionGroups,
133+
filters,
134+
onFiltersChange,
135+
}: SelectorFilterPanelProps) {
136+
const facets = useMemo(
137+
() => extractSelectorFacets(executionGroups),
138+
[executionGroups],
139+
);
140+
141+
const hasActiveFilters = Object.values(filters).some((v) => v.length > 0);
142+
143+
if (facets.length === 0) return null;
144+
145+
return (
146+
<div className="space-y-2">
147+
<div className="flex items-center gap-2 flex-wrap">
148+
<span className="text-sm text-muted-foreground">Selectors:</span>
149+
{facets.map((facet) => (
150+
<MultiSelectFacet
151+
key={facet.key}
152+
facet={facet}
153+
selected={filters[facet.key] ?? []}
154+
onSelectionChange={(values) =>
155+
onFiltersChange({ ...filters, [facet.key]: values })
156+
}
157+
/>
158+
))}
159+
{hasActiveFilters && (
160+
<Button
161+
variant="ghost"
162+
size="sm"
163+
className="h-8"
164+
onClick={() => onFiltersChange({})}
165+
>
166+
Clear all
167+
</Button>
168+
)}
169+
</div>
170+
{hasActiveFilters && (
171+
<div className="flex items-center gap-1 flex-wrap">
172+
{Object.entries(filters).flatMap(([key, values]) =>
173+
values.map((value) => (
174+
<Badge
175+
key={`${key}:${value}`}
176+
variant="secondary"
177+
className="gap-1"
178+
>
179+
{key}: {value}
180+
<button
181+
type="button"
182+
className="ml-1 rounded-full outline-none focus:ring-2 focus:ring-ring"
183+
onClick={() =>
184+
onFiltersChange({
185+
...filters,
186+
[key]: values.filter((v) => v !== value),
187+
})
188+
}
189+
>
190+
<X className="h-3 w-3" />
191+
</button>
192+
</Badge>
193+
)),
194+
)}
195+
</div>
196+
)}
197+
</div>
198+
);
199+
}

0 commit comments

Comments
 (0)