BugListPage.tsx

  1import { CircleDot, CircleCheck, ChevronLeft, ChevronRight } from "lucide-react";
  2import { useState, useEffect } from "react";
  3
  4import { useBugListQuery } from "@/__generated__/graphql";
  5import { BugRow } from "@/components/bugs/BugRow";
  6import { IssueFilters } from "@/components/bugs/IssueFilters";
  7import type { SortValue } from "@/components/bugs/IssueFilters";
  8import { QueryInput } from "@/components/bugs/QueryInput";
  9import { Button } from "@/components/ui/button";
 10import { Skeleton } from "@/components/ui/skeleton";
 11import { useRepo } from "@/lib/repo";
 12import { cn } from "@/lib/utils";
 13
 14const PAGE_SIZE = 25;
 15
 16type StatusFilter = "open" | "closed";
 17
 18// Issue list page (/:repo/issues). Search bar with structured query, open/closed toggle,
 19// label+author filter dropdowns, and paginated bug rows.
 20export function BugListPage() {
 21  const repo = useRepo();
 22  const [statusFilter, setStatusFilter] = useState<StatusFilter>("open");
 23  const [selectedLabels, setSelectedLabels] = useState<string[]>([]);
 24  // humanId — uniquely identifies the selection for the dropdown UI
 25  const [selectedAuthorId, setSelectedAuthorId] = useState<string | null>(null);
 26  // query value (login/name) — what goes into author:... in the query string
 27  const [selectedAuthorQuery, setSelectedAuthorQuery] = useState<string | null>(null);
 28  const [freeText, setFreeText] = useState("");
 29  const [sort, setSort] = useState<SortValue>("creation-desc");
 30  const [draft, setDraft] = useState(() => buildQueryString("open", [], null, "", "creation-desc"));
 31
 32  // Cursor-stack pagination: cursors[i] is the `after` value to fetch page i.
 33  // cursors[0] is always undefined (first page needs no cursor).
 34  const [cursors, setCursors] = useState<(string | undefined)[]>([undefined]);
 35  const page = cursors.length - 1; // 0-indexed current page
 36
 37  // Build separate query strings: two for the always-visible counts (open/closed),
 38  // one for the paginated list. The count queries share all filters except status.
 39  const baseQuery = buildBaseQuery(selectedLabels, selectedAuthorQuery, freeText);
 40  const openQuery = `status:open ${baseQuery}`.trim();
 41  const closedQuery = `status:closed ${baseQuery}`.trim();
 42  const listQuery = buildQueryString(
 43    statusFilter,
 44    selectedLabels,
 45    selectedAuthorQuery,
 46    freeText,
 47    sort,
 48  );
 49
 50  const { data, loading, error } = useBugListQuery({
 51    variables: {
 52      ref: repo,
 53      openQuery,
 54      closedQuery,
 55      listQuery,
 56      first: PAGE_SIZE,
 57      after: cursors[page],
 58    },
 59  });
 60
 61  const openCount = data?.repository?.openCount.totalCount ?? 0;
 62  const closedCount = data?.repository?.closedCount.totalCount ?? 0;
 63  const bugs = data?.repository?.bugs;
 64  const totalCount = bugs?.totalCount ?? 0;
 65  const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE));
 66  const hasNext = bugs?.pageInfo.hasNextPage ?? false;
 67  const hasPrev = page > 0;
 68
 69  // Reset to page 1 whenever the list query changes.
 70  useEffect(() => {
 71    setCursors([undefined]);
 72  }, [listQuery]);
 73
 74  // Apply all filters at once, keeping draft in sync with the structured state.
 75  function applyFilters(
 76    status: StatusFilter,
 77    labels: string[],
 78    authorId: string | null,
 79    authorQuery: string | null,
 80    text: string,
 81    sortVal: SortValue = sort,
 82  ) {
 83    setStatusFilter(status);
 84    setSelectedLabels(labels);
 85    setSelectedAuthorId(authorId);
 86    setSelectedAuthorQuery(authorQuery);
 87    setFreeText(text);
 88    setSort(sortVal);
 89    setDraft(buildQueryString(status, labels, authorQuery, text, sortVal));
 90  }
 91
 92  // Parse the draft text box on submit so manual edits update the dropdowns too.
 93  // When parsing we don't know the humanId — clear it so the dropdown resets.
 94  // Called both from the <form> onSubmit (with event) and from QueryInput's
 95  // Enter-key handler (without event), so e is optional.
 96  function handleSearch(e?: React.FormEvent) {
 97    e?.preventDefault();
 98    const p = parseQueryString(draft);
 99    applyFilters(p.status, p.labels, null, p.author, p.freeText, p.sort);
100  }
101
102  function goNext() {
103    const endCursor = bugs?.pageInfo.endCursor;
104    if (!endCursor) return;
105    setCursors((prev) => [...prev, endCursor]);
106  }
107
108  function goPrev() {
109    setCursors((prev) => prev.slice(0, -1));
110  }
111
112  return (
113    <div>
114      {/* Search bar */}
115      <form onSubmit={handleSearch} className="mb-4 flex gap-2">
116        <QueryInput
117          value={draft}
118          onChange={setDraft}
119          onSubmit={handleSearch}
120          placeholder="status:open author:… label:…"
121        />
122        <Button type="submit">Search</Button>
123      </form>
124
125      {/* List container */}
126      <div className="rounded-md border border-border">
127        {/* Open / Closed toggle + filter dropdowns */}
128        <div className="flex items-center gap-2 overflow-x-auto border-b border-border px-4 py-2">
129          <div className="flex shrink-0 items-center gap-1">
130            <button
131              onClick={() =>
132                applyFilters(
133                  "open",
134                  selectedLabels,
135                  selectedAuthorId,
136                  selectedAuthorQuery,
137                  freeText,
138                )
139              }
140              className={cn(
141                "flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
142                statusFilter === "open"
143                  ? "bg-accent text-accent-foreground"
144                  : "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
145              )}
146            >
147              <CircleDot
148                className={cn(
149                  "size-4",
150                  statusFilter === "open" && "text-green-600 dark:text-green-400",
151                )}
152              />
153              Open
154              <span className="ml-0.5 rounded-full bg-muted px-1.5 py-0.5 text-xs tabular-nums leading-none">
155                {openCount}
156              </span>
157            </button>
158
159            <button
160              onClick={() =>
161                applyFilters(
162                  "closed",
163                  selectedLabels,
164                  selectedAuthorId,
165                  selectedAuthorQuery,
166                  freeText,
167                )
168              }
169              className={cn(
170                "flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
171                statusFilter === "closed"
172                  ? "bg-accent text-accent-foreground"
173                  : "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
174              )}
175            >
176              <CircleCheck
177                className={cn(
178                  "size-4",
179                  statusFilter === "closed" && "text-purple-600 dark:text-purple-400",
180                )}
181              />
182              Closed
183              <span className="ml-0.5 rounded-full bg-muted px-1.5 py-0.5 text-xs tabular-nums leading-none">
184                {closedCount}
185              </span>
186            </button>
187          </div>
188
189          <div className="ml-auto">
190            <IssueFilters
191              selectedLabels={selectedLabels}
192              onLabelsChange={(labels) =>
193                applyFilters(statusFilter, labels, selectedAuthorId, selectedAuthorQuery, freeText)
194              }
195              selectedAuthorId={selectedAuthorId}
196              onAuthorChange={(id, qv) =>
197                applyFilters(statusFilter, selectedLabels, id, qv, freeText)
198              }
199              recentAuthorIds={bugs?.nodes?.map((b) => b.author.humanId) ?? []}
200              sort={sort}
201              onSortChange={(s) =>
202                applyFilters(
203                  statusFilter,
204                  selectedLabels,
205                  selectedAuthorId,
206                  selectedAuthorQuery,
207                  freeText,
208                  s,
209                )
210              }
211            />
212          </div>
213        </div>
214
215        {/* Bug rows */}
216        {error && (
217          <p className="px-4 py-8 text-center text-sm text-destructive">
218            Failed to load issues: {error.message}
219          </p>
220        )}
221
222        {loading && !data && <BugListSkeleton />}
223
224        {bugs?.nodes.length === 0 && (
225          <p className="px-4 py-8 text-center text-sm text-muted-foreground">
226            No {statusFilter} issues found.
227          </p>
228        )}
229
230        {bugs?.nodes.map((bug) => (
231          <BugRow
232            key={bug.id}
233            id={bug.id}
234            humanId={bug.humanId}
235            status={bug.status}
236            title={bug.title}
237            labels={bug.labels}
238            author={bug.author}
239            createdAt={bug.createdAt}
240            commentCount={bug.comments.totalCount}
241            repo={repo}
242            onLabelClick={(name) => {
243              if (!selectedLabels.includes(name)) {
244                applyFilters(
245                  statusFilter,
246                  [...selectedLabels, name],
247                  selectedAuthorId,
248                  selectedAuthorQuery,
249                  freeText,
250                );
251              }
252            }}
253          />
254        ))}
255
256        {totalPages > 1 && (
257          <div className="flex items-center justify-center gap-2 border-t border-border px-4 py-2">
258            <Button
259              variant="ghost"
260              size="sm"
261              onClick={goPrev}
262              disabled={!hasPrev || loading}
263              className="gap-1 text-muted-foreground"
264            >
265              <ChevronLeft className="size-4" />
266              Previous
267            </Button>
268            <span className="text-sm text-muted-foreground">
269              Page {page + 1} of {totalPages}
270            </span>
271            <Button
272              variant="ghost"
273              size="sm"
274              onClick={goNext}
275              disabled={!hasNext || loading}
276              className="gap-1 text-muted-foreground"
277            >
278              Next
279              <ChevronRight className="size-4" />
280            </Button>
281          </div>
282        )}
283      </div>
284    </div>
285  );
286}
287
288// buildBaseQuery returns the filter parts (labels, author, freeText) without
289// the status prefix, so it can be combined with "status:open" / "status:closed".
290function buildBaseQuery(labels: string[], author: string | null, freeText: string): string {
291  const parts: string[] = [];
292  for (const label of labels) {
293    parts.push(label.includes(" ") ? `label:"${label}"` : `label:${label}`);
294  }
295  if (author) {
296    parts.push(author.includes(" ") ? `author:"${author}"` : `author:${author}`);
297  }
298  if (freeText.trim()) parts.push(freeText.trim());
299  return parts.join(" ");
300}
301
302// Build the structured query string sent to the GraphQL allBugs(query:) argument.
303// Multi-word label/author values are wrapped in quotes so the backend parser
304// treats them as a single token (e.g. label:"my label" vs label:my label).
305function buildQueryString(
306  status: StatusFilter,
307  labels: string[],
308  author: string | null,
309  freeText: string,
310  sort: SortValue = "creation-desc",
311): string {
312  const parts = [`status:${status}`];
313  const base = buildBaseQuery(labels, author, freeText);
314  if (base) parts.push(base);
315  if (sort !== "creation-desc") parts.push(`sort:${sort}`);
316  return parts.join(" ");
317}
318
319// Tokenize a query string, keeping quoted spans (e.g. author:"René Descartes")
320// as single tokens. Quotes are preserved in the output so callers can strip them
321// when extracting values.
322function tokenizeQuery(input: string): string[] {
323  const tokens: string[] = [];
324  let current = "";
325  let inQuote = false;
326  for (const ch of input.trim()) {
327    if (ch === '"') {
328      inQuote = !inQuote;
329      current += ch;
330    } else if (ch === " " && !inQuote) {
331      if (current) {
332        tokens.push(current);
333        current = "";
334      }
335    } else current += ch;
336  }
337  if (current) tokens.push(current);
338  return tokens;
339}
340
341// Parse a free-text query string back into structured filter state so that
342// manual edits to the search box are reflected in the dropdown UI on submit.
343// Strips surrounding quotes from values (they're an encoding detail, not part
344// of the value itself). Unknown tokens fall through to freeText.
345const VALID_SORTS = new Set<SortValue>(["creation-desc", "creation-asc", "edit-desc", "edit-asc"]);
346
347function parseQueryString(input: string): {
348  status: StatusFilter;
349  labels: string[];
350  author: string | null;
351  freeText: string;
352  sort: SortValue;
353} {
354  let status: StatusFilter = "open";
355  const labels: string[] = [];
356  let author: string | null = null;
357  let sort: SortValue = "creation-desc";
358  const free: string[] = [];
359
360  for (const token of tokenizeQuery(input)) {
361    if (token === "status:open") status = "open";
362    else if (token === "status:closed") status = "closed";
363    else if (token.startsWith("label:")) labels.push(token.slice(6));
364    else if (token.startsWith("author:")) author = token.slice(7).replace(/^"|"$/g, "");
365    else if (token.startsWith("sort:")) {
366      const v = token.slice(5) as SortValue;
367      if (VALID_SORTS.has(v)) sort = v;
368    } else free.push(token);
369  }
370
371  return { status, labels, author, freeText: free.join(" "), sort };
372}
373
374function BugListSkeleton() {
375  return (
376    <div className="divide-y divide-border">
377      {Array.from({ length: 8 }).map((_, i) => (
378        <div key={i} className="flex items-start gap-3 px-4 py-3">
379          <Skeleton className="mt-0.5 size-4 rounded-full" />
380          <div className="flex-1 space-y-2">
381            <Skeleton className="h-4 w-2/3" />
382            <Skeleton className="h-3 w-1/3" />
383          </div>
384        </div>
385      ))}
386    </div>
387  );
388}