IssueFilters.tsx

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