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