diff --git a/webui2/src/components/bugs/IssueFilters.tsx b/webui2/src/components/bugs/IssueFilters.tsx index 26c752fc72c3db25c04bf9f81ab699d3977d0890..147b85daa15eed7f4331f2ee477224dce0c56e5b 100644 --- a/webui2/src/components/bugs/IssueFilters.tsx +++ b/webui2/src/components/bugs/IssueFilters.tsx @@ -1,11 +1,9 @@ import { ArrowUpDown, ChevronDown, Tag, User, X, Search, Check } from "lucide-react"; import { useMemo, useState } from "react"; -import { useValidLabelsQuery, useAllIdentitiesQuery } from "@/__generated__/graphql"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { useAuth } from "@/lib/auth"; -import { useRepo } from "@/lib/repo"; import { cn } from "@/lib/utils"; import { LabelBadge } from "./LabelBadge"; @@ -41,7 +39,24 @@ const SORT_OPTIONS: { value: SortValue; label: string }[] = [ { value: "edit-asc", label: "Least recently updated" }, ]; +export interface LabelItem { + name: string; + color: { R: number; G: number; B: number }; +} + +export interface IdentityItem { + id: string; + humanId: string; + name?: string | null; + email?: string | null; + login?: string | null; + displayName: string; + avatarUrl?: string | null; +} + interface IssueFiltersProps { + labels: readonly LabelItem[]; + identities: readonly IdentityItem[]; selectedLabels: string[]; onLabelsChange: (labels: string[]) => void; selectedAuthorId: string | null; @@ -64,6 +79,8 @@ interface IssueFiltersProps { // queryValue (login/name for the query string). They're kept separate because // two identities can share the same display name, but humanId is always unique. export function IssueFilters({ + labels, + identities, selectedLabels, onLabelsChange, selectedAuthorId, @@ -73,26 +90,17 @@ export function IssueFilters({ onSortChange, }: IssueFiltersProps) { const { user } = useAuth(); - const repo = useRepo(); - const { data: labelsData } = useValidLabelsQuery({ variables: { ref: repo } }); - const { data: authorsData } = useAllIdentitiesQuery({ variables: { ref: repo } }); const [labelSearch, setLabelSearch] = useState(""); const [authorSearch, setAuthorSearch] = useState(""); const validLabels = useMemo( - () => - (labelsData?.repository?.validLabels.nodes ?? []).toSorted((a, b) => - a.name.localeCompare(b.name), - ), - [labelsData], + () => labels.toSorted((a, b) => a.name.localeCompare(b.name)), + [labels], ); const allIdentities = useMemo( - () => - (authorsData?.repository?.allIdentities.nodes ?? []).toSorted((a, b) => - a.displayName.localeCompare(b.displayName), - ), - [authorsData], + () => identities.toSorted((a, b) => a.displayName.localeCompare(b.displayName)), + [identities], ); const filteredLabels = labelSearch.trim() diff --git a/webui2/src/components/bugs/QueryInput.tsx b/webui2/src/components/bugs/QueryInput.tsx index 5a029569c20c786128172f538720007be98eb294..4e0fbce73f993744b54408389ed1bc20f160b69f 100644 --- a/webui2/src/components/bugs/QueryInput.tsx +++ b/webui2/src/components/bugs/QueryInput.tsx @@ -12,8 +12,7 @@ import { Search } from "lucide-react"; import { useState, useRef, useMemo, type ChangeEvent } from "react"; -import { useValidLabelsQuery, useAllIdentitiesQuery } from "@/__generated__/graphql"; -import { useRepo } from "@/lib/repo"; +import type { LabelItem, IdentityItem } from "@/components/bugs/IssueFilters"; import { cn } from "@/lib/utils"; // ── Segment parsing (for the syntax-highlight backdrop) ─────────────────────── @@ -152,28 +151,28 @@ interface QueryInputProps { onSubmit: () => void; placeholder?: string; className?: string; + labels: readonly LabelItem[]; + identities: readonly IdentityItem[]; } -export function QueryInput({ value, onChange, onSubmit, placeholder, className }: QueryInputProps) { +export function QueryInput({ + value, + onChange, + onSubmit, + placeholder, + className, + labels, + identities, +}: QueryInputProps) { const inputRef = useRef(null); - const repo = useRepo(); // Autocomplete state: null when the dropdown is hidden. const [completion, setCompletion] = useState(null); // Keyboard-highlighted index within the visible suggestions list. const [acIndex, setAcIndex] = useState(0); - // Fetch all labels and identities for autocomplete suggestions. - // These queries are cheap (cached by Apollo) and already used by IssueFilters, - // so there is no extra network cost. - const { data: labelsData } = useValidLabelsQuery({ variables: { ref: repo } }); - const { data: authorsData } = useAllIdentitiesQuery({ variables: { ref: repo } }); - - const allLabels = useMemo(() => labelsData?.repository?.validLabels.nodes ?? [], [labelsData]); - const allAuthors = useMemo( - () => authorsData?.repository?.allIdentities.nodes ?? [], - [authorsData], - ); + const allLabels = labels; + const allAuthors = identities; // Compute the filtered suggestion list whenever completion info changes. const suggestions = useMemo(() => { diff --git a/webui2/src/routes/$repo/issues/index.tsx b/webui2/src/routes/$repo/issues/index.tsx index 9daa1e9e7b977e9b231873638a15e0c347c80ca5..50602df752a0d4ebb3bb967d8e0fff0e08525580 100644 --- a/webui2/src/routes/$repo/issues/index.tsx +++ b/webui2/src/routes/$repo/issues/index.tsx @@ -1,9 +1,17 @@ +import { useReadQuery } from "@apollo/client/react"; import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; import { CircleDot, CircleCheck, ChevronLeft, ChevronRight } from "lucide-react"; import { useState } from "react"; import * as v from "valibot"; -import { useBugListQuery } from "@/__generated__/graphql"; +import { + type BugListQuery, + BugListDocument, + type ValidLabelsQuery, + ValidLabelsDocument, + type AllIdentitiesQuery, + AllIdentitiesDocument, +} from "@/__generated__/graphql"; import { BugRow } from "@/components/bugs/BugRow"; import { IssueFilters } from "@/components/bugs/IssueFilters"; import type { SortValue } from "@/components/bugs/IssueFilters"; @@ -11,6 +19,7 @@ import { QueryInput } from "@/components/bugs/QueryInput"; import { Button } from "@/components/ui/button"; import { ButtonLink } from "@/components/ui/button-link"; import { Skeleton } from "@/components/ui/skeleton"; +import { preloadQuery } from "@/lib/apollo"; import { useRepo } from "@/lib/repo"; import { cn } from "@/lib/utils"; @@ -21,7 +30,32 @@ const issuesSearchSchema = v.object({ export const Route = createFileRoute("/$repo/issues/")({ component: RouteComponent, + pendingComponent: BugListSkeleton, validateSearch: (search) => v.parse(issuesSearchSchema, search), + loaderDeps: ({ search: { q, after } }) => ({ q, after }), + loader: ({ params: { repo }, deps: { q, after } }) => { + const ref = repo === "_" ? null : repo; + const parsed = parseQueryString(q); + const baseQuery = buildBaseQuery(parsed.labels, parsed.author, parsed.freeText); + return { + bugListRef: preloadQuery(BugListDocument, { + variables: { + ref, + openQuery: `status:open ${baseQuery}`.trim(), + closedQuery: `status:closed ${baseQuery}`.trim(), + listQuery: q, + first: PAGE_SIZE, + after: after || undefined, + }, + }), + labelsRef: preloadQuery(ValidLabelsDocument, { + variables: { ref }, + }), + identitiesRef: preloadQuery(AllIdentitiesDocument, { + variables: { ref }, + }), + }; + }, }); const PAGE_SIZE = 25; @@ -47,26 +81,16 @@ function RouteComponent() { // Draft is the text input value — starts from URL, only committed on submit const [draft, setDraft] = useState(q); - // Build the three query variants from the URL state - const baseQuery = buildBaseQuery(selectedLabels, selectedAuthorQuery, parsed.freeText); - const openQuery = `status:open ${baseQuery}`.trim(); - const closedQuery = `status:closed ${baseQuery}`.trim(); - const listQuery = q; - - const { data, loading, error } = useBugListQuery({ - variables: { - ref: repo, - openQuery, - closedQuery, - listQuery, - first: PAGE_SIZE, - after: after || undefined, - }, - }); + const { bugListRef, labelsRef, identitiesRef } = Route.useLoaderData(); + const { data } = useReadQuery(bugListRef); + const { data: labelsData } = useReadQuery(labelsRef); + const { data: identitiesData } = useReadQuery(identitiesRef); 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 totalCount = bugs?.totalCount ?? 0; const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE)); const hasNext = bugs?.pageInfo.hasNextPage ?? false; @@ -109,6 +133,8 @@ function RouteComponent() { onChange={setDraft} onSubmit={handleSearch} placeholder="status:open author:… label:…" + labels={validLabels} + identities={allIdentities} /> @@ -167,6 +193,8 @@ function RouteComponent() {
applyFilters(statusFilter, labels, selectedAuthorQuery, parsed.freeText) @@ -185,14 +213,6 @@ function RouteComponent() {
{/* Bug rows */} - {error && ( -

- Failed to load issues: {error.message} -

- )} - - {loading && !data && } - {bugs?.nodes.length === 0 && (

No {statusFilter} issues found. @@ -232,7 +252,7 @@ function RouteComponent() { search={{ q, after: "" }} variant="ghost" size="sm" - disabled={!hasPrev || loading} + disabled={!hasPrev} className="text-muted-foreground gap-1" > @@ -247,7 +267,7 @@ function RouteComponent() { search={{ q, after: bugs?.pageInfo.endCursor ?? "" }} variant="ghost" size="sm" - disabled={!hasNext || loading} + disabled={!hasNext} className="text-muted-foreground gap-1" > Next