From 84e923c99d265186caaa448fd4731731ee49f940 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Sun, 5 Apr 2026 12:09:34 +0200 Subject: [PATCH] refactor(web): extract QueryInput as provider-based composition component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New generic QueryInput with pluggable CompletionProviders: - Root manages state (cursor tracking, keyboard nav, suggestions) via context - Input renders the two-layer syntax-highlighted input - Completions renders the autocomplete dropdown - Icon is a positioned slot Providers define prefix, highlight color, and getSuggestions (sync or async). Adding a new filter type means adding a provider — zero component changes. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/ui/query-input.stories.tsx | 150 ++++++ webui2/src/components/ui/query-input.tsx | 456 ++++++++++++++++++ .../src/routes/$repo/_issues/issues/index.tsx | 79 ++- 3 files changed, 672 insertions(+), 13 deletions(-) create mode 100644 webui2/src/components/ui/query-input.stories.tsx create mode 100644 webui2/src/components/ui/query-input.tsx diff --git a/webui2/src/components/ui/query-input.stories.tsx b/webui2/src/components/ui/query-input.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2b4c35982c04972d41307abbda3761e30db9d531 --- /dev/null +++ b/webui2/src/components/ui/query-input.stories.tsx @@ -0,0 +1,150 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Search } from "lucide-react"; +import { useState } from "react"; + +import type { CompletionProvider } from "./query-input"; +import * as QueryInput from "./query-input"; + +const meta = { + component: QueryInput.Root, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const sampleLabels = [ + { name: "bug", color: { R: 252, G: 41, B: 41 } }, + { name: "enhancement", color: { R: 163, G: 230, B: 53 } }, + { name: "documentation", color: { R: 30, G: 80, B: 160 } }, + { name: "help wanted", color: { R: 0, G: 150, B: 136 } }, + { name: "wontfix", color: { R: 200, G: 200, B: 200 } }, +]; + +const sampleAuthors = [ + { displayName: "Jane Doe", login: "janedoe" }, + { displayName: "Bob Smith", login: "bobsmith" }, + { displayName: "Alice Wu", login: "alicewu" }, +]; + +const providers: CompletionProvider[] = [ + { + prefix: "label:", + highlightClass: "text-yellow-600 dark:text-yellow-500", + getSuggestions: (query) => + sampleLabels + .filter((l) => query === "" || l.name.toLowerCase().includes(query.toLowerCase())) + .map((l) => ({ + value: l.name.includes(" ") ? `"${l.name}"` : l.name, + label: l.name, + icon: ( + + ), + })), + }, + { + prefix: "author:", + highlightClass: "text-blue-600 dark:text-blue-400", + getSuggestions: (query) => + sampleAuthors + .filter( + (a) => + query === "" || + a.displayName.toLowerCase().includes(query.toLowerCase()) || + a.login.toLowerCase().includes(query.toLowerCase()), + ) + .map((a) => ({ + value: a.login, + label: a.displayName, + description: `@${a.login}`, + })), + }, +]; + +export const Default: Story = { + args: { children: null, value: "", onChange: () => {}, onSubmit: () => {} }, + render: () => { + const [value, setValue] = useState("status:open"); + return ( + {}} + providers={providers} + > + + + + + ); + }, +}; + +export const WithFilters: Story = { + args: { children: null, value: "", onChange: () => {}, onSubmit: () => {} }, + render: () => { + const [value, setValue] = useState('status:open label:bug author:janedoe fix login'); + return ( + {}} + providers={providers} + > + + + + + ); + }, +}; + +export const SyntaxOnly: Story = { + args: { children: null, value: "", onChange: () => {}, onSubmit: () => {} }, + render: () => { + const [value, setValue] = useState("status:open label:bug"); + return ( + {}}> + + + + ); + }, +}; + +const asyncProviders: CompletionProvider[] = [ + { + prefix: "label:", + highlightClass: "text-yellow-600 dark:text-yellow-500", + getSuggestions: async (query) => { + await new Promise((r) => setTimeout(r, 500)); + return sampleLabels + .filter((l) => query === "" || l.name.toLowerCase().includes(query.toLowerCase())) + .map((l) => ({ + value: l.name.includes(" ") ? `"${l.name}"` : l.name, + label: l.name, + })); + }, + }, +]; + +export const AsyncCompletions: Story = { + args: { children: null, value: "", onChange: () => {}, onSubmit: () => {} }, + render: () => { + const [value, setValue] = useState(""); + return ( + {}} + providers={asyncProviders} + > + + + + + ); + }, +}; diff --git a/webui2/src/components/ui/query-input.tsx b/webui2/src/components/ui/query-input.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d576cfc3f7335422eb07eb13f9443c3db48364fa --- /dev/null +++ b/webui2/src/components/ui/query-input.tsx @@ -0,0 +1,456 @@ +// Generic syntax-highlighted search input with pluggable autocomplete providers. +// +// Architecture: two layers share the same font/padding so they appear identical: +// 1. A "backdrop" div (aria-hidden) renders colored s for each token. +// 2. The real floats on top with transparent text and bg, so the caret +// is visible but the text itself is hidden in favour of the backdrop. + +import { + createContext, + useContext, + useState, + useRef, + useMemo, + useEffect, + type ChangeEvent, + type ReactNode, +} from "react"; + +import { cn } from "@/lib/utils"; + +// ── Public types ────────────────────────────────────────────────────────────── + +export interface Suggestion { + /** What gets inserted into the input (already quoted if needed). */ + value: string; + /** Display label shown in the dropdown. */ + label: string; + /** Optional leading decoration (icon, color dot, etc.). */ + icon?: ReactNode; + /** Optional right-aligned secondary text. */ + description?: string; +} + +export interface CompletionProvider { + /** The prefix this provider handles, including the colon (e.g. "label:"). */ + prefix: string; + /** Tailwind classes for syntax-highlighting this prefix in the backdrop. */ + highlightClass: string; + /** Return suggestions for the partial query typed after the prefix. */ + getSuggestions(query: string): Suggestion[] | Promise; +} + +/** Static syntax rules for tokens that aren't completable but should be colored. */ +export interface SyntaxRule { + /** Exact token match (e.g. "status:open") or a prefix match (e.g. "sort:"). */ + match: string | ((token: string) => boolean); + /** Tailwind classes for the prefix/key portion. */ + highlightClass: string; +} + +// ── Defaults ────────────────────────────────────────────────────────────────── + +const DEFAULT_SYNTAX_RULES: SyntaxRule[] = [ + { match: "status:open", highlightClass: "text-green-600 dark:text-green-400" }, + { match: "status:closed", highlightClass: "text-purple-600 dark:text-purple-400" }, + { match: (t) => t.startsWith("sort:"), highlightClass: "text-orange-600 dark:text-orange-400" }, +]; + +// ── Segment parsing ─────────────────────────────────────────────────────────── + +interface Segment { + text: string; + highlightClass: string | null; +} + +function parseSegments( + input: string, + providers: CompletionProvider[], + syntaxRules: SyntaxRule[], +): Segment[] { + const segments: Segment[] = []; + let i = 0; + + while (i < input.length) { + // Whitespace runs + if (input[i] === " ") { + let j = i; + while (j < input.length && input[j] === " ") j++; + segments.push({ text: input.slice(i, j), highlightClass: null }); + i = j; + continue; + } + + // Token — consume until an unquoted space + let j = i; + let inQuote = false; + while (j < input.length) { + if (input[j] === '"') { + inQuote = !inQuote; + j++; + continue; + } + if (!inQuote && input[j] === " ") break; + j++; + } + + const token = input.slice(i, j); + + // Check providers first (they also define syntax highlighting) + let highlightClass: string | null = null; + for (const p of providers) { + if (token.startsWith(p.prefix)) { + highlightClass = p.highlightClass; + break; + } + } + + // Then check static syntax rules + if (!highlightClass) { + for (const rule of syntaxRules) { + const matches = + typeof rule.match === "string" ? token === rule.match : rule.match(token); + if (matches) { + highlightClass = rule.highlightClass; + break; + } + } + } + + segments.push({ text: token, highlightClass }); + i = j; + } + + return segments; +} + +function renderSegment(seg: Segment, i: number): ReactNode { + if (!seg.highlightClass) { + return {seg.text}; + } + const colon = seg.text.indexOf(":"); + if (colon === -1) { + return ( + + {seg.text} + + ); + } + const key = seg.text.slice(0, colon + 1); + const val = seg.text.slice(colon + 1); + return ( + + {key} + {val} + + ); +} + +// ── Cursor / token utilities ────────────────────────────────────────────────── + +interface CompletionInfo { + provider: CompletionProvider; + query: string; + tokenStart: number; +} + +function getCompletionInfo( + value: string, + cursor: number, + providers: CompletionProvider[], +): CompletionInfo | null { + let tokenStart = 0; + for (let i = cursor - 1; i >= 0; i--) { + if (value[i] === " ") { + tokenStart = i + 1; + break; + } + } + + const partial = value.slice(tokenStart, cursor); + for (const provider of providers) { + if (partial.startsWith(provider.prefix)) { + const query = partial.slice(provider.prefix.length).replace(/^"/, ""); + return { provider, query, tokenStart }; + } + } + return null; +} + +function getTokenEnd(value: string, tokenStart: number): number { + let inQuote = false; + for (let i = tokenStart; i < value.length; i++) { + if (value[i] === '"') { + inQuote = !inQuote; + continue; + } + if (!inQuote && value[i] === " ") return i; + } + return value.length; +} + +// ── Context ─────────────────────────────────────────────────────────────────── + +interface QueryInputContextValue { + value: string; + segments: Segment[]; + inputRef: React.RefObject; + suggestions: Suggestion[]; + activeIndex: number; + showDropdown: boolean; + loading: boolean; + handleChange: (e: ChangeEvent) => void; + handleKeyDown: (e: React.KeyboardEvent) => void; + handleSelect: (e: React.SyntheticEvent) => void; + selectSuggestion: (index: number) => void; +} + +const QueryInputContext = createContext(null); + +function useQueryInput() { + const ctx = useContext(QueryInputContext); + if (!ctx) throw new Error("QueryInput sub-components must be used within QueryInput.Root"); + return ctx; +} + +// ── Components ──────────────────────────────────────────────────────────────── + +interface RootProps { + value: string; + onChange: (value: string) => void; + onSubmit: () => void; + providers?: CompletionProvider[]; + syntaxRules?: SyntaxRule[]; + className?: string; + children: ReactNode; +} + +export function Root({ + value, + onChange, + onSubmit, + providers = [], + syntaxRules = DEFAULT_SYNTAX_RULES, + className, + children, +}: RootProps) { + const inputRef = useRef(null); + const [completion, setCompletion] = useState(null); + const [activeIndex, setActiveIndex] = useState(0); + const [suggestions, setSuggestions] = useState([]); + const [loading, setLoading] = useState(false); + + const segments = useMemo( + () => parseSegments(value, providers, syntaxRules), + [value, providers, syntaxRules], + ); + + // Fetch suggestions when completion changes + useEffect(() => { + if (!completion) { + setSuggestions([]); + setLoading(false); + return; + } + + let cancelled = false; + const result = completion.provider.getSuggestions(completion.query); + + if (result instanceof Promise) { + setLoading(true); + void result.then((items) => { + if (!cancelled) { + setSuggestions(items); + setLoading(false); + } + }); + } else { + setSuggestions(result); + setLoading(false); + } + + return () => { + cancelled = true; + }; + }, [completion]); + + function updateCompletion(newValue: string, cursor: number) { + const info = getCompletionInfo(newValue, cursor, providers); + setCompletion(info); + setActiveIndex(0); + } + + function handleChange(e: ChangeEvent) { + const newValue = e.target.value; + const cursor = e.target.selectionStart ?? newValue.length; + onChange(newValue); + updateCompletion(newValue, cursor); + } + + function handleSelect(e: React.SyntheticEvent) { + updateCompletion(value, e.currentTarget.selectionStart ?? value.length); + } + + function applySuggestion(s: Suggestion) { + if (!completion) return; + const tokenEnd = getTokenEnd(value, completion.tokenStart); + const completedToken = `${completion.provider.prefix}${s.value}`; + const newValue = + value.slice(0, completion.tokenStart) + + completedToken + + " " + + value.slice(tokenEnd).trimStart(); + onChange(newValue); + setCompletion(null); + setSuggestions([]); + + const newCursor = completion.tokenStart + completedToken.length + 1; + requestAnimationFrame(() => { + inputRef.current?.focus(); + inputRef.current?.setSelectionRange(newCursor, newCursor); + }); + } + + function selectSuggestion(index: number) { + const s = suggestions[index]; + if (s) applySuggestion(s); + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === "Enter" && !completion) { + e.preventDefault(); + onSubmit(); + return; + } + + if (!completion || suggestions.length === 0) return; + + if (e.key === "ArrowDown") { + e.preventDefault(); + setActiveIndex((i) => (i + 1) % suggestions.length); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setActiveIndex((i) => (i - 1 + suggestions.length) % suggestions.length); + } else if (e.key === "Enter" || e.key === "Tab") { + e.preventDefault(); + selectSuggestion(activeIndex); + } else if (e.key === "Escape") { + setCompletion(null); + setSuggestions([]); + } + } + + const showDropdown = suggestions.length > 0; + + const ctx: QueryInputContextValue = { + value, + segments, + inputRef, + suggestions, + activeIndex, + showDropdown, + loading, + handleChange, + handleKeyDown, + handleSelect, + selectSuggestion, + }; + + return ( + +
inputRef.current?.focus()} + > + {children} +
+
+ ); +} + +interface IconProps { + children: ReactNode; +} + +export function Icon({ children }: IconProps) { + return ( +
+ {children} +
+ ); +} + +interface InputProps { + placeholder?: string; + className?: string; +} + +export function Input({ placeholder, className }: InputProps) { + const { value, segments, inputRef, handleChange, handleKeyDown, handleSelect } = useQueryInput(); + + return ( + <> + {/* Colored backdrop */} +
+ {value === "" ? null : segments.map((seg, i) => renderSegment(seg, i))} +
+ + {/* Actual input */} + + + ); +} + +export function Completions() { + const { suggestions, activeIndex, showDropdown, loading, selectSuggestion } = useQueryInput(); + + if (!showDropdown && !loading) return null; + + return ( +
+ {loading && suggestions.length === 0 && ( +
Loading…
+ )} + {suggestions.map((s, i) => ( + + ))} +
+ ); +} diff --git a/webui2/src/routes/$repo/_issues/issues/index.tsx b/webui2/src/routes/$repo/_issues/issues/index.tsx index dcdb301e16b195a9e01c39fbc95636ee4b46874a..58cf2331b1c4dc7ad1d784e73a0f28bba414d490 100644 --- a/webui2/src/routes/$repo/_issues/issues/index.tsx +++ b/webui2/src/routes/$repo/_issues/issues/index.tsx @@ -1,18 +1,19 @@ import { useReadQuery } from "@apollo/client/react"; import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; import { formatDistanceToNow } from "date-fns"; -import { CircleDot, CircleCheck, ChevronLeft, ChevronRight } from "lucide-react"; -import { useState } from "react"; +import { CircleDot, CircleCheck, ChevronLeft, ChevronRight, Search } from "lucide-react"; +import { useMemo, useState } from "react"; import * as v from "valibot"; import { type BugListQuery, BugListDocument } from "@/__generated__/graphql"; import { IssueFilters } from "@/components/bugs/IssueFilters"; +import type { SortValue } from "@/components/bugs/IssueFilters"; import * as IssueRow from "@/components/bugs/IssueRow"; import { LabelBadgeLink } from "@/components/bugs/LabelBadge"; -import type { SortValue } from "@/components/bugs/IssueFilters"; -import { QueryInput } from "@/components/bugs/QueryInput"; import { Button } from "@/components/ui/button"; import { ButtonLink } from "@/components/ui/button-link"; +import * as QueryInput from "@/components/ui/query-input"; +import type { CompletionProvider } from "@/components/ui/query-input"; import { Skeleton } from "@/components/ui/skeleton"; import { cn } from "@/lib/utils"; @@ -75,8 +76,58 @@ function RouteComponent() { const openCount = data?.repository?.openCount.totalCount ?? 0; const closedCount = data?.repository?.closedCount.totalCount ?? 0; const bugs = data?.repository?.bugs; - const validLabels = labelsData?.repository?.validLabels.nodes ?? []; - const allIdentities = identitiesData?.repository?.allIdentities.nodes ?? []; + const validLabels = labelsData?.repository?.validLabels.nodes; + const allIdentities = identitiesData?.repository?.allIdentities.nodes; + + const completionProviders: CompletionProvider[] = useMemo( + () => [ + { + prefix: "label:", + highlightClass: "text-yellow-600 dark:text-yellow-500", + getSuggestions: (query: string) => + (validLabels ?? []) + .filter((l) => query === "" || l.name.toLowerCase().includes(query.toLowerCase())) + .slice(0, 8) + .map((l) => ({ + value: l.name.includes(" ") ? `"${l.name}"` : l.name, + label: l.name, + icon: ( + + ), + })), + }, + { + prefix: "author:", + highlightClass: "text-blue-600 dark:text-blue-400", + getSuggestions: (query: string) => + (allIdentities ?? []) + .filter( + (a) => + query === "" || + a.displayName.toLowerCase().includes(query.toLowerCase()) || + (a.login ?? "").toLowerCase().includes(query.toLowerCase()) || + (a.name ?? "").toLowerCase().includes(query.toLowerCase()), + ) + .slice(0, 8) + .map((a) => { + const qv = a.login || a.name || a.humanId; + return { + value: qv.includes(" ") ? `"${qv}"` : qv, + label: a.displayName, + description: + a.login && a.login !== a.displayName ? `@${a.login}` : undefined, + }; + }), + }, + ], + [validLabels, allIdentities], + ); + const totalCount = bugs?.totalCount ?? 0; const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE)); const hasNext = bugs?.pageInfo.hasNextPage ?? false; @@ -114,14 +165,16 @@ function RouteComponent() {
{/* Search bar */}
- + providers={completionProviders} + > + + + + @@ -179,8 +232,8 @@ function RouteComponent() {
applyFilters(statusFilter, labels, selectedAuthorQuery, parsed.freeText)