index.tsx

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