Detailed changes
@@ -4,6 +4,7 @@ import { useMemo, useState } from "react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { useAuth } from "@/lib/auth";
+import { SORT_OPTIONS, type SortValue } from "@/lib/query-utils";
import { cn } from "@/lib/utils";
import { LabelBadge } from "@/components/shared/label-badge";
@@ -30,14 +31,7 @@ function authorQueryValue(i: {
return i.login || i.name || i.humanId;
}
-export type SortValue = "creation-desc" | "creation-asc" | "edit-desc" | "edit-asc";
-
-const SORT_OPTIONS: { value: SortValue; label: string }[] = [
- { value: "creation-desc", label: "Newest" },
- { value: "creation-asc", label: "Oldest" },
- { value: "edit-desc", label: "Recently updated" },
- { value: "edit-asc", label: "Least recently updated" },
-];
+export type { SortValue } from "@/lib/query-utils";
export interface LabelItem {
name: string;
@@ -0,0 +1,97 @@
+// Query string utilities for the bug filter system.
+// Handles building and parsing structured filter queries like:
+// "status:open label:bug author:janedoe sort:creation-desc some free text"
+
+export type StatusFilter = "open" | "closed";
+
+export type SortValue = "creation-desc" | "creation-asc" | "edit-desc" | "edit-asc";
+
+export const SORT_OPTIONS: { value: SortValue; label: string }[] = [
+ { value: "creation-desc", label: "Newest" },
+ { value: "creation-asc", label: "Oldest" },
+ { value: "edit-desc", label: "Recently updated" },
+ { value: "edit-asc", label: "Least recently updated" },
+];
+
+const VALID_SORTS = new Set<string>(["creation-desc", "creation-asc", "edit-desc", "edit-asc"]);
+
+function isValidSort(val: string): val is SortValue {
+ return VALID_SORTS.has(val);
+}
+
+// Tokenize a query string, keeping quoted spans as single tokens.
+export function tokenizeQuery(input: string): string[] {
+ const tokens: string[] = [];
+ let current = "";
+ let inQuote = false;
+ for (const ch of input.trim()) {
+ if (ch === '"') {
+ inQuote = !inQuote;
+ current += ch;
+ } else if (ch === " " && !inQuote) {
+ if (current) {
+ tokens.push(current);
+ current = "";
+ }
+ } else current += ch;
+ }
+ if (current) tokens.push(current);
+ return tokens;
+}
+
+// Parse a query string back into structured filter state.
+export function parseQueryString(input: string): {
+ status: StatusFilter;
+ labels: string[];
+ author: string | null;
+ freeText: string;
+ sort: SortValue;
+} {
+ let status: StatusFilter = "open";
+ const labels: string[] = [];
+ let author: string | null = null;
+ let sort: SortValue = "creation-desc";
+ const free: string[] = [];
+
+ for (const token of tokenizeQuery(input)) {
+ if (token === "status:open") status = "open";
+ else if (token === "status:closed") status = "closed";
+ else if (token.startsWith("label:")) labels.push(token.slice(6));
+ else if (token.startsWith("author:")) author = token.slice(7).replace(/^"|"$/g, "");
+ else if (token.startsWith("sort:")) {
+ const val = token.slice(5);
+ if (isValidSort(val)) sort = val;
+ } else free.push(token);
+ }
+
+ return { status, labels, author, freeText: free.join(" "), sort };
+}
+
+// Returns the filter parts (labels, author, freeText) without the status prefix,
+// so it can be combined with "status:open" / "status:closed".
+export function buildBaseQuery(labels: string[], author: string | null, freeText: string): string {
+ const parts: string[] = [];
+ for (const label of labels) {
+ parts.push(label.includes(" ") ? `label:"${label}"` : `label:${label}`);
+ }
+ if (author) {
+ parts.push(author.includes(" ") ? `author:"${author}"` : `author:${author}`);
+ }
+ if (freeText.trim()) parts.push(freeText.trim());
+ return parts.join(" ");
+}
+
+// Build the structured query string sent to the GraphQL allBugs(query:) argument.
+export function buildQueryString(
+ status: StatusFilter,
+ labels: string[],
+ author: string | null,
+ freeText: string,
+ sort: SortValue = "creation-desc",
+): string {
+ const parts = [`status:${status}`];
+ const base = buildBaseQuery(labels, author, freeText);
+ if (base) parts.push(base);
+ if (sort !== "creation-desc") parts.push(`sort:${sort}`);
+ return parts.join(" ");
+}
@@ -7,15 +7,16 @@ import * as v from "valibot";
import { type BugListQuery, BugListDocument } from "@/__generated__/graphql";
import { IssueFilters } from "@/components/bugs/issue-filters";
-import type { SortValue } from "@/components/bugs/issue-filters";
import * as IssueRow from "@/components/shared/issue-row";
import { LabelBadgeLink } from "@/components/shared/label-badge";
-import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/shared/empty-state";
import * as Pagination from "@/components/shared/pagination";
import * as QueryInput from "@/components/shared/query-input";
import type { CompletionProvider } from "@/components/shared/query-input";
+import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
+import type { SortValue, StatusFilter } from "@/lib/query-utils";
+import { buildBaseQuery, buildQueryString, parseQueryString } from "@/lib/query-utils";
import { cn } from "@/lib/utils";
const issuesSearchSchema = v.object({
@@ -47,8 +48,6 @@ export const Route = createFileRoute("/$repo/_issues/issues/")({
const PAGE_SIZE = 25;
-type StatusFilter = "open" | "closed";
-
function RouteComponent() {
const { repo } = Route.useParams();
const navigate = useNavigate({ from: "/$repo/issues/" });
@@ -331,89 +330,6 @@ function RouteComponent() {
);
}
-// buildBaseQuery returns the filter parts (labels, author, freeText) without
-// the status prefix, so it can be combined with "status:open" / "status:closed".
-function buildBaseQuery(labels: string[], author: string | null, freeText: string): string {
- const parts: string[] = [];
- for (const label of labels) {
- parts.push(label.includes(" ") ? `label:"${label}"` : `label:${label}`);
- }
- if (author) {
- parts.push(author.includes(" ") ? `author:"${author}"` : `author:${author}`);
- }
- if (freeText.trim()) parts.push(freeText.trim());
- return parts.join(" ");
-}
-
-// Build the structured query string sent to the GraphQL allBugs(query:) argument.
-function buildQueryString(
- status: StatusFilter,
- labels: string[],
- author: string | null,
- freeText: string,
- sort: SortValue = "creation-desc",
-): string {
- const parts = [`status:${status}`];
- const base = buildBaseQuery(labels, author, freeText);
- if (base) parts.push(base);
- if (sort !== "creation-desc") parts.push(`sort:${sort}`);
- return parts.join(" ");
-}
-
-// Tokenize a query string, keeping quoted spans as single tokens.
-function tokenizeQuery(input: string): string[] {
- const tokens: string[] = [];
- let current = "";
- let inQuote = false;
- for (const ch of input.trim()) {
- if (ch === '"') {
- inQuote = !inQuote;
- current += ch;
- } else if (ch === " " && !inQuote) {
- if (current) {
- tokens.push(current);
- current = "";
- }
- } else current += ch;
- }
- if (current) tokens.push(current);
- return tokens;
-}
-
-// Parse a query string back into structured filter state.
-const VALID_SORTS = new Set<string>(["creation-desc", "creation-asc", "edit-desc", "edit-asc"]);
-
-function isValidSort(val: string): val is SortValue {
- return VALID_SORTS.has(val);
-}
-
-function parseQueryString(input: string): {
- status: StatusFilter;
- labels: string[];
- author: string | null;
- freeText: string;
- sort: SortValue;
-} {
- let status: StatusFilter = "open";
- const labels: string[] = [];
- let author: string | null = null;
- let sort: SortValue = "creation-desc";
- const free: string[] = [];
-
- for (const token of tokenizeQuery(input)) {
- if (token === "status:open") status = "open";
- else if (token === "status:closed") status = "closed";
- else if (token.startsWith("label:")) labels.push(token.slice(6));
- else if (token.startsWith("author:")) author = token.slice(7).replace(/^"|"$/g, "");
- else if (token.startsWith("sort:")) {
- const val = token.slice(5);
- if (isValidSort(val)) sort = val;
- } else free.push(token);
- }
-
- return { status, labels, author, freeText: free.join(" "), sort };
-}
-
function BugListSkeleton() {
return (
<div className="divide-border divide-y">