index.tsx

  1import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
  2import { CircleDot, CircleCheck, ChevronLeft, ChevronRight } from "lucide-react";
  3import { useState } from "react";
  4import * as v from "valibot";
  5
  6import { useBugListQuery } from "@/__generated__/graphql";
  7import { BugRow } from "@/components/bugs/BugRow";
  8import { IssueFilters } from "@/components/bugs/IssueFilters";
  9import type { SortValue } from "@/components/bugs/IssueFilters";
 10import { QueryInput } from "@/components/bugs/QueryInput";
 11import { Button } from "@/components/ui/button";
 12import { ButtonLink } from "@/components/ui/button-link";
 13import { Skeleton } from "@/components/ui/skeleton";
 14import { useRepo } from "@/lib/repo";
 15import { cn } from "@/lib/utils";
 16
 17const issuesSearchSchema = v.object({
 18  q: v.fallback(v.string(), "status:open"),
 19  after: v.fallback(v.string(), ""),
 20});
 21
 22export const Route = createFileRoute("/$repo/issues/")({
 23  component: RouteComponent,
 24  validateSearch: (search) => v.parse(issuesSearchSchema, search),
 25});
 26
 27const PAGE_SIZE = 25;
 28
 29type StatusFilter = "open" | "closed";
 30
 31function RouteComponent() {
 32  const repo = useRepo();
 33  const navigate = useNavigate({ from: "/$repo/issues/" });
 34  const { q, after } = Route.useSearch();
 35
 36  // Parse the URL query into structured filter state for the dropdowns
 37  const parsed = parseQueryString(q);
 38  const {
 39    status: statusFilter,
 40    labels: selectedLabels,
 41    author: selectedAuthorQuery,
 42    sort,
 43  } = parsed;
 44  // We don't have the humanId from URL — the dropdown will match by query value
 45  const selectedAuthorId: string | null = null;
 46
 47  // Draft is the text input value — starts from URL, only committed on submit
 48  const [draft, setDraft] = useState(q);
 49
 50  // Build the three query variants from the URL state
 51  const baseQuery = buildBaseQuery(selectedLabels, selectedAuthorQuery, parsed.freeText);
 52  const openQuery = `status:open ${baseQuery}`.trim();
 53  const closedQuery = `status:closed ${baseQuery}`.trim();
 54  const listQuery = q;
 55
 56  const { data, loading, error } = useBugListQuery({
 57    variables: {
 58      ref: repo,
 59      openQuery,
 60      closedQuery,
 61      listQuery,
 62      first: PAGE_SIZE,
 63      after: after || undefined,
 64    },
 65  });
 66
 67  const openCount = data?.repository?.openCount.totalCount ?? 0;
 68  const closedCount = data?.repository?.closedCount.totalCount ?? 0;
 69  const bugs = data?.repository?.bugs;
 70  const totalCount = bugs?.totalCount ?? 0;
 71  const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE));
 72  const hasNext = bugs?.pageInfo.hasNextPage ?? false;
 73  const hasPrev = !!after;
 74
 75  // Navigate to new search params (resets pagination)
 76  function setSearch(newQ: string) {
 77    setDraft(newQ);
 78    void navigate({ search: { q: newQ, after: "" } });
 79  }
 80
 81  // Apply structured filters → build query string → navigate
 82  function applyFilters(
 83    status: StatusFilter,
 84    labels: string[],
 85    authorQuery: string | null,
 86    text: string,
 87    sortVal: SortValue = sort,
 88  ) {
 89    setSearch(buildQueryString(status, labels, authorQuery, text, sortVal));
 90  }
 91
 92  // Parse the draft text box on submit
 93  function handleSearch(e?: React.FormEvent) {
 94    e?.preventDefault();
 95    setSearch(draft);
 96  }
 97
 98  // Build query string with toggled status
 99  function queryWithStatus(status: StatusFilter): string {
100    return buildQueryString(status, selectedLabels, selectedAuthorQuery, parsed.freeText, sort);
101  }
102
103  return (
104    <div>
105      {/* Search bar */}
106      <form onSubmit={handleSearch} className="mb-4 flex gap-2">
107        <QueryInput
108          value={draft}
109          onChange={setDraft}
110          onSubmit={handleSearch}
111          placeholder="status:open author:… label:…"
112        />
113        <Button type="submit">Search</Button>
114      </form>
115
116      {/* List container */}
117      <div className="border-border rounded-md border">
118        {/* Open / Closed toggle + filter dropdowns */}
119        <div className="border-border flex items-center gap-2 overflow-x-auto border-b px-4 py-2">
120          <div className="flex shrink-0 items-center gap-1">
121            <Link
122              to="/$repo/issues"
123              params={{ repo: repo! }}
124              search={{ q: queryWithStatus("open"), after: "" }}
125              className={cn(
126                "flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
127                statusFilter === "open"
128                  ? "bg-accent text-accent-foreground"
129                  : "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
130              )}
131            >
132              <CircleDot
133                className={cn(
134                  "size-4",
135                  statusFilter === "open" && "text-green-600 dark:text-green-400",
136                )}
137              />
138              Open
139              <span className="bg-muted ml-0.5 rounded-full px-1.5 py-0.5 text-xs leading-none tabular-nums">
140                {openCount}
141              </span>
142            </Link>
143
144            <Link
145              to="/$repo/issues"
146              params={{ repo: repo! }}
147              search={{ q: queryWithStatus("closed"), after: "" }}
148              className={cn(
149                "flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
150                statusFilter === "closed"
151                  ? "bg-accent text-accent-foreground"
152                  : "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
153              )}
154            >
155              <CircleCheck
156                className={cn(
157                  "size-4",
158                  statusFilter === "closed" && "text-purple-600 dark:text-purple-400",
159                )}
160              />
161              Closed
162              <span className="bg-muted ml-0.5 rounded-full px-1.5 py-0.5 text-xs leading-none tabular-nums">
163                {closedCount}
164              </span>
165            </Link>
166          </div>
167
168          <div className="ml-auto">
169            <IssueFilters
170              selectedLabels={selectedLabels}
171              onLabelsChange={(labels) =>
172                applyFilters(statusFilter, labels, selectedAuthorQuery, parsed.freeText)
173              }
174              selectedAuthorId={selectedAuthorId}
175              onAuthorChange={(_id, qv) =>
176                applyFilters(statusFilter, selectedLabels, qv, parsed.freeText)
177              }
178              recentAuthorIds={bugs?.nodes?.map((b) => b.author.humanId) ?? []}
179              sort={sort}
180              onSortChange={(s) =>
181                applyFilters(statusFilter, selectedLabels, selectedAuthorQuery, parsed.freeText, s)
182              }
183            />
184          </div>
185        </div>
186
187        {/* Bug rows */}
188        {error && (
189          <p className="text-destructive px-4 py-8 text-center text-sm">
190            Failed to load issues: {error.message}
191          </p>
192        )}
193
194        {loading && !data && <BugListSkeleton />}
195
196        {bugs?.nodes.length === 0 && (
197          <p className="text-muted-foreground px-4 py-8 text-center text-sm">
198            No {statusFilter} issues found.
199          </p>
200        )}
201
202        {bugs?.nodes.map((bug) => (
203          <BugRow
204            key={bug.id}
205            id={bug.id}
206            humanId={bug.humanId}
207            status={bug.status}
208            title={bug.title}
209            labels={bug.labels}
210            author={bug.author}
211            createdAt={bug.createdAt}
212            commentCount={bug.comments.totalCount}
213            repo={repo}
214            onLabelClick={(name) => {
215              if (!selectedLabels.includes(name)) {
216                applyFilters(
217                  statusFilter,
218                  [...selectedLabels, name],
219                  selectedAuthorQuery,
220                  parsed.freeText,
221                );
222              }
223            }}
224          />
225        ))}
226
227        {totalPages > 1 && (
228          <div className="border-border flex items-center justify-center gap-2 border-t px-4 py-2">
229            <ButtonLink
230              to="/$repo/issues"
231              params={{ repo: repo! }}
232              search={{ q, after: "" }}
233              variant="ghost"
234              size="sm"
235              disabled={!hasPrev || loading}
236              className="text-muted-foreground gap-1"
237            >
238              <ChevronLeft className="size-4" />
239              Previous
240            </ButtonLink>
241            <span className="text-muted-foreground text-sm">
242              Page {after ? 2 : 1} of {totalPages}
243            </span>
244            <ButtonLink
245              to="/$repo/issues"
246              params={{ repo: repo! }}
247              search={{ q, after: bugs?.pageInfo.endCursor ?? "" }}
248              variant="ghost"
249              size="sm"
250              disabled={!hasNext || loading}
251              className="text-muted-foreground gap-1"
252            >
253              Next
254              <ChevronRight className="size-4" />
255            </ButtonLink>
256          </div>
257        )}
258      </div>
259    </div>
260  );
261}
262
263// buildBaseQuery returns the filter parts (labels, author, freeText) without
264// the status prefix, so it can be combined with "status:open" / "status:closed".
265function buildBaseQuery(labels: string[], author: string | null, freeText: string): string {
266  const parts: string[] = [];
267  for (const label of labels) {
268    parts.push(label.includes(" ") ? `label:"${label}"` : `label:${label}`);
269  }
270  if (author) {
271    parts.push(author.includes(" ") ? `author:"${author}"` : `author:${author}`);
272  }
273  if (freeText.trim()) parts.push(freeText.trim());
274  return parts.join(" ");
275}
276
277// Build the structured query string sent to the GraphQL allBugs(query:) argument.
278function buildQueryString(
279  status: StatusFilter,
280  labels: string[],
281  author: string | null,
282  freeText: string,
283  sort: SortValue = "creation-desc",
284): string {
285  const parts = [`status:${status}`];
286  const base = buildBaseQuery(labels, author, freeText);
287  if (base) parts.push(base);
288  if (sort !== "creation-desc") parts.push(`sort:${sort}`);
289  return parts.join(" ");
290}
291
292// Tokenize a query string, keeping quoted spans as single tokens.
293function tokenizeQuery(input: string): string[] {
294  const tokens: string[] = [];
295  let current = "";
296  let inQuote = false;
297  for (const ch of input.trim()) {
298    if (ch === '"') {
299      inQuote = !inQuote;
300      current += ch;
301    } else if (ch === " " && !inQuote) {
302      if (current) {
303        tokens.push(current);
304        current = "";
305      }
306    } else current += ch;
307  }
308  if (current) tokens.push(current);
309  return tokens;
310}
311
312// Parse a query string back into structured filter state.
313const VALID_SORTS = new Set<string>(["creation-desc", "creation-asc", "edit-desc", "edit-asc"]);
314
315function isValidSort(val: string): val is SortValue {
316  return VALID_SORTS.has(val);
317}
318
319function parseQueryString(input: string): {
320  status: StatusFilter;
321  labels: string[];
322  author: string | null;
323  freeText: string;
324  sort: SortValue;
325} {
326  let status: StatusFilter = "open";
327  const labels: string[] = [];
328  let author: string | null = null;
329  let sort: SortValue = "creation-desc";
330  const free: string[] = [];
331
332  for (const token of tokenizeQuery(input)) {
333    if (token === "status:open") status = "open";
334    else if (token === "status:closed") status = "closed";
335    else if (token.startsWith("label:")) labels.push(token.slice(6));
336    else if (token.startsWith("author:")) author = token.slice(7).replace(/^"|"$/g, "");
337    else if (token.startsWith("sort:")) {
338      const val = token.slice(5);
339      if (isValidSort(val)) sort = val;
340    } else free.push(token);
341  }
342
343  return { status, labels, author, freeText: free.join(" "), sort };
344}
345
346function BugListSkeleton() {
347  return (
348    <div className="divide-border divide-y">
349      {Array.from({ length: 8 }).map((_, i) => (
350        <div key={i} className="flex items-start gap-3 px-4 py-3">
351          <Skeleton className="mt-0.5 size-4 rounded-full" />
352          <div className="flex-1 space-y-2">
353            <Skeleton className="h-4 w-2/3" />
354            <Skeleton className="h-3 w-1/3" />
355          </div>
356        </div>
357      ))}
358    </div>
359  );
360}