From 571ee432aab480a247db118969a7e2f01e1976a0 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Sun, 5 Apr 2026 16:50:50 +0200 Subject: [PATCH] refactor(web): extract query utilities to src/lib/query-utils.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move tokenizeQuery, parseQueryString, buildBaseQuery, buildQueryString, SortValue, StatusFilter, and SORT_OPTIONS from the route file and issue-filters into a shared module. These are pure functions with no React deps — removes ~80 lines from the issues route. Co-Authored-By: Claude Opus 4.6 (1M context) --- webui2/src/components/bugs/issue-filters.tsx | 10 +- webui2/src/lib/query-utils.ts | 97 +++++++++++++++++++ .../src/routes/$repo/_issues/issues/index.tsx | 90 +---------------- 3 files changed, 102 insertions(+), 95 deletions(-) create mode 100644 webui2/src/lib/query-utils.ts diff --git a/webui2/src/components/bugs/issue-filters.tsx b/webui2/src/components/bugs/issue-filters.tsx index 614ead7740729af73c5e582936c7071940bacfbe..511a470a2f440bc192b33435f67ce04fdbb170ac 100644 --- a/webui2/src/components/bugs/issue-filters.tsx +++ b/webui2/src/components/bugs/issue-filters.tsx @@ -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; diff --git a/webui2/src/lib/query-utils.ts b/webui2/src/lib/query-utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..bd824d8128d093636636d84a58bc589335e49160 --- /dev/null +++ b/webui2/src/lib/query-utils.ts @@ -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(["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(" "); +} diff --git a/webui2/src/routes/$repo/_issues/issues/index.tsx b/webui2/src/routes/$repo/_issues/issues/index.tsx index 5e5a21dc4a38760383caf5b853b3e2998d6be276..cf4b5fac8bd579d640ebc059116a8ff81b5fc958 100644 --- a/webui2/src/routes/$repo/_issues/issues/index.tsx +++ b/webui2/src/routes/$repo/_issues/issues/index.tsx @@ -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(["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 (