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