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