1import { ArrowUpDown, ChevronDown, Tag, User, X, Search, Check } from "lucide-react";
2import { 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 = [...(labelsData?.repository?.validLabels.nodes ?? [])].sort((a, b) =>
83 a.name.localeCompare(b.name),
84 );
85
86 const allIdentities = [...(authorsData?.repository?.allIdentities.nodes ?? [])].sort((a, b) =>
87 a.displayName.localeCompare(b.displayName),
88 );
89
90 const filteredLabels = labelSearch.trim()
91 ? validLabels.filter((l) => l.name.toLowerCase().includes(labelSearch.toLowerCase()))
92 : validLabels;
93
94 // Selected labels float to top, then alphabetical
95 const sortedLabels = [
96 ...filteredLabels.filter((l) => selectedLabels.includes(l.name)),
97 ...filteredLabels.filter((l) => !selectedLabels.includes(l.name)),
98 ];
99
100 // Build the displayed identity list:
101 // - When searching: filter full list reactively as-you-type
102 // - When not searching: show current user first, then recently-seen authors,
103 // then others up to INITIAL_AUTHOR_LIMIT
104 const isSearching = authorSearch.trim() !== "";
105
106 const matchesSearch = (i: (typeof allIdentities)[number]) => {
107 const q = authorSearch.toLowerCase();
108 return (
109 i.displayName.toLowerCase().includes(q) ||
110 (i.name ?? "").toLowerCase().includes(q) ||
111 (i.login ?? "").toLowerCase().includes(q) ||
112 (i.email ?? "").toLowerCase().includes(q)
113 );
114 };
115
116 let visibleIdentities: typeof allIdentities;
117 if (isSearching) {
118 visibleIdentities = allIdentities.filter(matchesSearch);
119 } else {
120 const pinned = new Set<string>();
121 const result: typeof allIdentities = [];
122
123 // 1. Current user
124 if (user) {
125 const me = allIdentities.find((i) => i.id === user.id);
126 if (me) {
127 result.push(me);
128 pinned.add(me.id);
129 }
130 }
131 // 2. Selected author (if not already added)
132 if (selectedAuthorId) {
133 const sel = allIdentities.find((i) => i.humanId === selectedAuthorId);
134 if (sel && !pinned.has(sel.id)) {
135 result.push(sel);
136 pinned.add(sel.id);
137 }
138 }
139 // 3. Recently seen authors (recentAuthorIds are humanIds from bug rows)
140 for (const humanId of recentAuthorIds) {
141 const match = allIdentities.find((i) => i.humanId === humanId);
142 if (match && !pinned.has(match.id)) {
143 result.push(match);
144 pinned.add(match.id);
145 }
146 }
147 // 4. Fill up to limit with remaining alphabetical
148 for (const i of allIdentities) {
149 if (result.length >= INITIAL_AUTHOR_LIMIT) break;
150 if (!pinned.has(i.id)) result.push(i);
151 }
152 visibleIdentities = result;
153 }
154
155 function toggleLabel(name: string) {
156 if (selectedLabels.includes(name)) {
157 onLabelsChange(selectedLabels.filter((l) => l !== name));
158 } else {
159 onLabelsChange([...selectedLabels, name]);
160 }
161 }
162
163 const selectedAuthorIdentity = allIdentities.find((i) => i.humanId === selectedAuthorId);
164
165 return (
166 <div className="flex shrink-0 items-center gap-1">
167 {/* Label filter */}
168 <Popover
169 onOpenChange={(open) => {
170 if (!open) setLabelSearch("");
171 }}
172 >
173 <PopoverTrigger asChild>
174 <button
175 className={cn(
176 "flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
177 selectedLabels.length > 0
178 ? "bg-accent text-accent-foreground"
179 : "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
180 )}
181 >
182 <Tag className="size-3.5" />
183 Labels
184 {selectedLabels.length > 0 && (
185 <span className="rounded-full bg-muted px-1.5 py-0.5 text-xs leading-none">
186 {selectedLabels.length}
187 </span>
188 )}
189 <ChevronDown className="size-3" />
190 </button>
191 </PopoverTrigger>
192 <PopoverContent align="end" className="w-56 bg-popover p-0 shadow-lg">
193 {/* Search */}
194 <div className="flex items-center gap-2 border-b border-border px-3 py-2">
195 <Search className="size-3.5 shrink-0 text-muted-foreground" />
196 <input
197 autoFocus
198 placeholder="Search labels…"
199 value={labelSearch}
200 onChange={(e) => setLabelSearch(e.target.value)}
201 className="w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
202 />
203 </div>
204 <div className="max-h-64 overflow-y-auto p-1">
205 {sortedLabels.length === 0 && (
206 <p className="px-2 py-3 text-center text-xs text-muted-foreground">No labels found</p>
207 )}
208 {sortedLabels.map((label) => {
209 const active = selectedLabels.includes(label.name);
210 return (
211 <button
212 key={label.name}
213 onClick={() => toggleLabel(label.name)}
214 className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted"
215 >
216 <span
217 className="size-2 shrink-0 rounded-full"
218 style={{
219 backgroundColor: `rgb(${label.color.R},${label.color.G},${label.color.B})`,
220 opacity: active ? 1 : 0.35,
221 }}
222 />
223 <LabelBadge name={label.name} color={label.color} />
224 {active && <Check className="ml-auto size-3.5 shrink-0 text-foreground" />}
225 </button>
226 );
227 })}
228 </div>
229 {selectedLabels.length > 0 && (
230 <div className="border-t border-border p-1">
231 <button
232 onClick={() => onLabelsChange([])}
233 className="flex w-full items-center gap-1.5 rounded px-2 py-1.5 text-xs text-muted-foreground hover:bg-muted"
234 >
235 <X className="size-3" />
236 Clear labels
237 </button>
238 </div>
239 )}
240 </PopoverContent>
241 </Popover>
242
243 {/* Author filter */}
244 <Popover
245 onOpenChange={(open) => {
246 if (!open) setAuthorSearch("");
247 }}
248 >
249 <PopoverTrigger asChild>
250 <button
251 className={cn(
252 "flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
253 selectedAuthorId
254 ? "bg-accent text-accent-foreground"
255 : "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
256 )}
257 >
258 {selectedAuthorIdentity ? (
259 <>
260 <Avatar className="size-4">
261 <AvatarImage
262 src={selectedAuthorIdentity.avatarUrl ?? undefined}
263 alt={selectedAuthorIdentity.displayName}
264 />
265 <AvatarFallback className="text-[8px]">
266 {selectedAuthorIdentity.displayName.slice(0, 2).toUpperCase()}
267 </AvatarFallback>
268 </Avatar>
269 {selectedAuthorIdentity.displayName}
270 </>
271 ) : (
272 <>
273 <User className="size-3.5" />
274 Author
275 </>
276 )}
277 <ChevronDown className="size-3" />
278 </button>
279 </PopoverTrigger>
280 <PopoverContent align="end" className="w-56 bg-popover p-0 shadow-lg">
281 {/* Search */}
282 <div className="flex items-center gap-2 border-b border-border px-3 py-2">
283 <Search className="size-3.5 shrink-0 text-muted-foreground" />
284 <input
285 autoFocus
286 placeholder="Search authors…"
287 value={authorSearch}
288 onChange={(e) => setAuthorSearch(e.target.value)}
289 className="w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
290 />
291 </div>
292 <div className="max-h-64 overflow-y-auto p-1">
293 {visibleIdentities.length === 0 && (
294 <p className="px-2 py-3 text-center text-xs text-muted-foreground">
295 No authors found
296 </p>
297 )}
298 {visibleIdentities.map((identity) => {
299 const active = selectedAuthorId === identity.humanId;
300 return (
301 <button
302 key={identity.id}
303 onClick={() =>
304 onAuthorChange(
305 active ? null : identity.humanId,
306 active ? null : authorQueryValue(identity),
307 )
308 }
309 className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted"
310 >
311 <Avatar className="size-5 shrink-0">
312 <AvatarImage src={identity.avatarUrl ?? undefined} alt={identity.displayName} />
313 <AvatarFallback className="text-[8px]">
314 {identity.displayName.slice(0, 2).toUpperCase()}
315 </AvatarFallback>
316 </Avatar>
317 <div className="min-w-0 flex-1 text-left">
318 <div className="truncate">{identity.displayName}</div>
319 {identity.login && identity.login !== identity.displayName && (
320 <div className="truncate text-xs text-muted-foreground">
321 @{identity.login}
322 </div>
323 )}
324 </div>
325 {active && <Check className="size-3.5 shrink-0 text-foreground" />}
326 </button>
327 );
328 })}
329 {!isSearching && allIdentities.length > INITIAL_AUTHOR_LIMIT && (
330 <p className="px-2 py-1.5 text-center text-xs text-muted-foreground">
331 {allIdentities.length - visibleIdentities.length} more — type to search
332 </p>
333 )}
334 </div>
335 {selectedAuthorId && (
336 <div className="border-t border-border p-1">
337 <button
338 onClick={() => onAuthorChange(null, null)}
339 className="flex w-full items-center gap-1.5 rounded px-2 py-1.5 text-xs text-muted-foreground hover:bg-muted"
340 >
341 <X className="size-3" />
342 Clear author
343 </button>
344 </div>
345 )}
346 </PopoverContent>
347 </Popover>
348
349 {/* Sort */}
350 <Popover>
351 <PopoverTrigger asChild>
352 <button
353 className={cn(
354 "flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors whitespace-nowrap",
355 sort !== "creation-desc"
356 ? "bg-accent text-accent-foreground"
357 : "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
358 )}
359 >
360 <ArrowUpDown className="size-3.5" />
361 {SORT_OPTIONS.find((o) => o.value === sort)?.label ?? "Sort"}
362 <ChevronDown className="size-3" />
363 </button>
364 </PopoverTrigger>
365 <PopoverContent align="end" className="w-56 bg-popover p-1 shadow-lg">
366 {SORT_OPTIONS.map((opt) => (
367 <button
368 key={opt.value}
369 onClick={() => onSortChange(opt.value)}
370 className="flex w-full items-center gap-2 whitespace-nowrap rounded px-2 py-1.5 text-sm hover:bg-muted"
371 >
372 {opt.label}
373 {sort === opt.value && (
374 <Check className="ml-auto size-3.5 shrink-0 text-foreground" />
375 )}
376 </button>
377 ))}
378 </PopoverContent>
379 </Popover>
380 </div>
381 );
382}