IssueFilters.tsx

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