1import { ArrowUpDown, ChevronDown, Tag, User, X, Search, Check } from "lucide-react";
2import { useMemo, useState } from "react";
3
4import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
5import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
6import { useAuth } from "@/lib/auth";
7import { cn } from "@/lib/utils";
8
9import { LabelBadge } from "./LabelBadge";
10
11// Max authors shown in the non-searching state. We intentionally cap this to
12// avoid a giant list — the current-user + recently-seen pattern covers the
13// common case; typing to search handles the rest.
14const INITIAL_AUTHOR_LIMIT = 8;
15
16// Returns the value passed to author:... in the query string.
17// Preference order: login (never has spaces, safest) > name > humanId.
18// We avoid humanId-as-query where possible because it's opaque to the user;
19// the backend Match() also accepts login/name substring matches.
20//
21// Uses || (not ??) so that empty-string login/name fall through to the next
22// option. git-bug identities can have login="" (empty, not null) when the
23// login field was never set; ?? would return "" and the filter would silently
24// produce author:"" which buildQueryString then drops, making the filter a no-op.
25function authorQueryValue(i: {
26 login?: string | null;
27 name?: string | null;
28 humanId: string;
29}): string {
30 return i.login || i.name || i.humanId;
31}
32
33export type SortValue = "creation-desc" | "creation-asc" | "edit-desc" | "edit-asc";
34
35const SORT_OPTIONS: { value: SortValue; label: string }[] = [
36 { value: "creation-desc", label: "Newest" },
37 { value: "creation-asc", label: "Oldest" },
38 { value: "edit-desc", label: "Recently updated" },
39 { value: "edit-asc", label: "Least recently updated" },
40];
41
42export interface LabelItem {
43 name: string;
44 color: { R: number; G: number; B: number };
45}
46
47export interface IdentityItem {
48 id: string;
49 humanId: string;
50 name?: string | null;
51 email?: string | null;
52 login?: string | null;
53 displayName: string;
54 avatarUrl?: string | null;
55}
56
57interface IssueFiltersProps {
58 labels: readonly LabelItem[];
59 identities: readonly IdentityItem[];
60 selectedLabels: string[];
61 onLabelsChange: (labels: string[]) => void;
62 selectedAuthorId: string | null;
63 onAuthorChange: (humanId: string | null, queryValue: string | null) => void;
64 /** humanIds of authors appearing in the current bug list, used to rank the initial suggestions */
65 recentAuthorIds?: string[];
66 sort: SortValue;
67 onSortChange: (sort: SortValue) => void;
68}
69
70// Label and author filter dropdowns shown in the issue list header bar.
71//
72// The author dropdown has two display modes:
73// - Not searching: shows current user first, then recently-seen authors from
74// the visible bug list (recentAuthorIds), then alphabetical fill up to
75// INITIAL_AUTHOR_LIMIT. This surfaces the most useful choices with no typing.
76// - Searching: filters the full identity list reactively as-you-type.
77//
78// Note: onAuthorChange passes TWO values — humanId (for UI matching, unique) and
79// queryValue (login/name for the query string). They're kept separate because
80// two identities can share the same display name, but humanId is always unique.
81export function IssueFilters({
82 labels,
83 identities,
84 selectedLabels,
85 onLabelsChange,
86 selectedAuthorId,
87 onAuthorChange,
88 recentAuthorIds = [],
89 sort,
90 onSortChange,
91}: IssueFiltersProps) {
92 const { user } = useAuth();
93 const [labelSearch, setLabelSearch] = useState("");
94 const [authorSearch, setAuthorSearch] = useState("");
95
96 const validLabels = useMemo(
97 () => labels.toSorted((a, b) => a.name.localeCompare(b.name)),
98 [labels],
99 );
100
101 const allIdentities = useMemo(
102 () => identities.toSorted((a, b) => a.displayName.localeCompare(b.displayName)),
103 [identities],
104 );
105
106 const filteredLabels = labelSearch.trim()
107 ? validLabels.filter((l) => l.name.toLowerCase().includes(labelSearch.toLowerCase()))
108 : validLabels;
109
110 // Selected labels float to top, then alphabetical
111 const sortedLabels = [
112 ...filteredLabels.filter((l) => selectedLabels.includes(l.name)),
113 ...filteredLabels.filter((l) => !selectedLabels.includes(l.name)),
114 ];
115
116 // Build the displayed identity list:
117 // - When searching: filter full list reactively as-you-type
118 // - When not searching: show current user first, then recently-seen authors,
119 // then others up to INITIAL_AUTHOR_LIMIT
120 const isSearching = authorSearch.trim() !== "";
121
122 const matchesSearch = (i: (typeof allIdentities)[number]) => {
123 const q = authorSearch.toLowerCase();
124 return (
125 i.displayName.toLowerCase().includes(q) ||
126 (i.name ?? "").toLowerCase().includes(q) ||
127 (i.login ?? "").toLowerCase().includes(q) ||
128 (i.email ?? "").toLowerCase().includes(q)
129 );
130 };
131
132 let visibleIdentities: typeof allIdentities;
133 if (isSearching) {
134 visibleIdentities = allIdentities.filter(matchesSearch);
135 } else {
136 const pinned = new Set<string>();
137 const result: typeof allIdentities = [];
138
139 // 1. Current user
140 if (user) {
141 const me = allIdentities.find((i) => i.id === user.id);
142 if (me) {
143 result.push(me);
144 pinned.add(me.id);
145 }
146 }
147 // 2. Selected author (if not already added)
148 if (selectedAuthorId) {
149 const sel = allIdentities.find((i) => i.humanId === selectedAuthorId);
150 if (sel && !pinned.has(sel.id)) {
151 result.push(sel);
152 pinned.add(sel.id);
153 }
154 }
155 // 3. Recently seen authors (recentAuthorIds are humanIds from bug rows)
156 for (const humanId of recentAuthorIds) {
157 const match = allIdentities.find((i) => i.humanId === humanId);
158 if (match && !pinned.has(match.id)) {
159 result.push(match);
160 pinned.add(match.id);
161 }
162 }
163 // 4. Fill up to limit with remaining alphabetical
164 for (const i of allIdentities) {
165 if (result.length >= INITIAL_AUTHOR_LIMIT) break;
166 if (!pinned.has(i.id)) result.push(i);
167 }
168 visibleIdentities = result;
169 }
170
171 function toggleLabel(name: string) {
172 if (selectedLabels.includes(name)) {
173 onLabelsChange(selectedLabels.filter((l) => l !== name));
174 } else {
175 onLabelsChange([...selectedLabels, name]);
176 }
177 }
178
179 const selectedAuthorIdentity = allIdentities.find((i) => i.humanId === selectedAuthorId);
180
181 return (
182 <div className="flex shrink-0 items-center gap-1">
183 {/* Label filter */}
184 <Popover
185 onOpenChange={(open) => {
186 if (!open) setLabelSearch("");
187 }}
188 >
189 <PopoverTrigger asChild>
190 <button
191 className={cn(
192 "flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
193 selectedLabels.length > 0
194 ? "bg-accent text-accent-foreground"
195 : "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
196 )}
197 >
198 <Tag className="size-3.5" />
199 Labels
200 {selectedLabels.length > 0 && (
201 <span className="bg-muted rounded-full px-1.5 py-0.5 text-xs leading-none">
202 {selectedLabels.length}
203 </span>
204 )}
205 <ChevronDown className="size-3" />
206 </button>
207 </PopoverTrigger>
208 <PopoverContent align="end" className="bg-popover w-56 p-0 shadow-lg">
209 {/* Search */}
210 <div className="border-border flex items-center gap-2 border-b px-3 py-2">
211 <Search className="text-muted-foreground size-3.5 shrink-0" />
212 <input
213 autoFocus
214 placeholder="Search labels…"
215 value={labelSearch}
216 onChange={(e) => setLabelSearch(e.target.value)}
217 className="placeholder:text-muted-foreground w-full bg-transparent text-sm outline-hidden"
218 />
219 </div>
220 <div className="max-h-64 overflow-y-auto p-1">
221 {sortedLabels.length === 0 && (
222 <p className="text-muted-foreground px-2 py-3 text-center text-xs">No labels found</p>
223 )}
224 {sortedLabels.map((label) => {
225 const active = selectedLabels.includes(label.name);
226 return (
227 <button
228 key={label.name}
229 onClick={() => toggleLabel(label.name)}
230 className="hover:bg-muted flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm"
231 >
232 <span
233 className="size-2 shrink-0 rounded-full"
234 style={{
235 backgroundColor: `rgb(${label.color.R},${label.color.G},${label.color.B})`,
236 opacity: active ? 1 : 0.35,
237 }}
238 />
239 <LabelBadge name={label.name} color={label.color} />
240 {active && <Check className="text-foreground ml-auto size-3.5 shrink-0" />}
241 </button>
242 );
243 })}
244 </div>
245 {selectedLabels.length > 0 && (
246 <div className="border-border border-t p-1">
247 <button
248 onClick={() => onLabelsChange([])}
249 className="text-muted-foreground hover:bg-muted flex w-full items-center gap-1.5 rounded-sm px-2 py-1.5 text-xs"
250 >
251 <X className="size-3" />
252 Clear labels
253 </button>
254 </div>
255 )}
256 </PopoverContent>
257 </Popover>
258
259 {/* Author filter */}
260 <Popover
261 onOpenChange={(open) => {
262 if (!open) setAuthorSearch("");
263 }}
264 >
265 <PopoverTrigger asChild>
266 <button
267 className={cn(
268 "flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
269 selectedAuthorId
270 ? "bg-accent text-accent-foreground"
271 : "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
272 )}
273 >
274 {selectedAuthorIdentity ? (
275 <>
276 <Avatar className="size-4">
277 <AvatarImage
278 src={selectedAuthorIdentity.avatarUrl ?? undefined}
279 alt={selectedAuthorIdentity.displayName}
280 />
281 <AvatarFallback className="text-[8px]">
282 {selectedAuthorIdentity.displayName.slice(0, 2).toUpperCase()}
283 </AvatarFallback>
284 </Avatar>
285 {selectedAuthorIdentity.displayName}
286 </>
287 ) : (
288 <>
289 <User className="size-3.5" />
290 Author
291 </>
292 )}
293 <ChevronDown className="size-3" />
294 </button>
295 </PopoverTrigger>
296 <PopoverContent align="end" className="bg-popover w-56 p-0 shadow-lg">
297 {/* Search */}
298 <div className="border-border flex items-center gap-2 border-b px-3 py-2">
299 <Search className="text-muted-foreground size-3.5 shrink-0" />
300 <input
301 autoFocus
302 placeholder="Search authors…"
303 value={authorSearch}
304 onChange={(e) => setAuthorSearch(e.target.value)}
305 className="placeholder:text-muted-foreground w-full bg-transparent text-sm outline-hidden"
306 />
307 </div>
308 <div className="max-h-64 overflow-y-auto p-1">
309 {visibleIdentities.length === 0 && (
310 <p className="text-muted-foreground px-2 py-3 text-center text-xs">
311 No authors found
312 </p>
313 )}
314 {visibleIdentities.map((identity) => {
315 const active = selectedAuthorId === identity.humanId;
316 return (
317 <button
318 key={identity.id}
319 onClick={() =>
320 onAuthorChange(
321 active ? null : identity.humanId,
322 active ? null : authorQueryValue(identity),
323 )
324 }
325 className="hover:bg-muted flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm"
326 >
327 <Avatar className="size-5 shrink-0">
328 <AvatarImage src={identity.avatarUrl ?? undefined} alt={identity.displayName} />
329 <AvatarFallback className="text-[8px]">
330 {identity.displayName.slice(0, 2).toUpperCase()}
331 </AvatarFallback>
332 </Avatar>
333 <div className="min-w-0 flex-1 text-left">
334 <div className="truncate">{identity.displayName}</div>
335 {identity.login && identity.login !== identity.displayName && (
336 <div className="text-muted-foreground truncate text-xs">
337 @{identity.login}
338 </div>
339 )}
340 </div>
341 {active && <Check className="text-foreground size-3.5 shrink-0" />}
342 </button>
343 );
344 })}
345 {!isSearching && allIdentities.length > INITIAL_AUTHOR_LIMIT && (
346 <p className="text-muted-foreground px-2 py-1.5 text-center text-xs">
347 {allIdentities.length - visibleIdentities.length} more — type to search
348 </p>
349 )}
350 </div>
351 {selectedAuthorId && (
352 <div className="border-border border-t p-1">
353 <button
354 onClick={() => onAuthorChange(null, null)}
355 className="text-muted-foreground hover:bg-muted flex w-full items-center gap-1.5 rounded-sm px-2 py-1.5 text-xs"
356 >
357 <X className="size-3" />
358 Clear author
359 </button>
360 </div>
361 )}
362 </PopoverContent>
363 </Popover>
364
365 {/* Sort */}
366 <Popover>
367 <PopoverTrigger asChild>
368 <button
369 className={cn(
370 "flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors whitespace-nowrap",
371 sort !== "creation-desc"
372 ? "bg-accent text-accent-foreground"
373 : "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
374 )}
375 >
376 <ArrowUpDown className="size-3.5" />
377 {SORT_OPTIONS.find((o) => o.value === sort)?.label ?? "Sort"}
378 <ChevronDown className="size-3" />
379 </button>
380 </PopoverTrigger>
381 <PopoverContent align="end" className="bg-popover w-56 p-1 shadow-lg">
382 {SORT_OPTIONS.map((opt) => (
383 <button
384 key={opt.value}
385 onClick={() => onSortChange(opt.value)}
386 className="hover:bg-muted flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm whitespace-nowrap"
387 >
388 {opt.label}
389 {sort === opt.value && (
390 <Check className="text-foreground ml-auto size-3.5 shrink-0" />
391 )}
392 </button>
393 ))}
394 </PopoverContent>
395 </Popover>
396 </div>
397 );
398}