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