IssueFilters.tsx

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