IssueFilters.tsx

  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}