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