IssueFilters.tsx

  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}