diff --git a/webui2/src/components/bugs/BugRow.tsx b/webui2/src/components/bugs/BugRow.tsx index 10ce30635ec24c7256250c169e5bc86b7e9eb60f..2629af235d74ad3591dbbb41413706d2924f40ae 100644 --- a/webui2/src/components/bugs/BugRow.tsx +++ b/webui2/src/components/bugs/BugRow.tsx @@ -68,6 +68,7 @@ export function BugRow({ {author.displayName} diff --git a/webui2/src/components/bugs/Timeline.tsx b/webui2/src/components/bugs/Timeline.tsx index 403f1f0aa978522cc9e0fb1595ea9dcd72e275b4..35e7989bd1e99f3c3059ce25b1f6c0e17b87e582 100644 --- a/webui2/src/components/bugs/Timeline.tsx +++ b/webui2/src/components/bugs/Timeline.tsx @@ -107,6 +107,7 @@ function CommentItem({ {item.author.displayName} @@ -186,6 +187,7 @@ function LabelChangeItem({ item, repo }: { item: LabelChangeItem; repo: string | {item.author.displayName} @@ -228,6 +230,7 @@ function StatusChangeItem({ item, repo }: { item: StatusChangeItem; repo: string {item.author.displayName} @@ -246,6 +249,7 @@ function TitleChangeItem({ item, repo }: { item: TitleChangeItem; repo: string | {item.author.displayName} diff --git a/webui2/src/components/layout/Header.tsx b/webui2/src/components/layout/Header.tsx index 2a078b4f0c74e1d351d4e5162ae8c06233ccdffd..f66883bebef51086ae4871882deb0789419ef420 100644 --- a/webui2/src/components/layout/Header.tsx +++ b/webui2/src/components/layout/Header.tsx @@ -80,7 +80,11 @@ export function Header() { New issue - + diff --git a/webui2/src/routes/$repo/_issues/issues/$id.tsx b/webui2/src/routes/$repo/_issues/issues/$id.tsx index c74f46437fe6e557d64217e36bc0450dd71e2504..55e56fbd3a963d6388d248a07a7505aa1f3054f8 100644 --- a/webui2/src/routes/$repo/_issues/issues/$id.tsx +++ b/webui2/src/routes/$repo/_issues/issues/$id.tsx @@ -57,6 +57,7 @@ function RouteComponent() { {bug.author.displayName} @@ -96,6 +97,7 @@ function RouteComponent() { key={p.id} to="/$repo/user/$id" params={{ repo: repo, id: p.humanId }} + search={{ status: "open" as const, after: "" }} title={p.displayName} > diff --git a/webui2/src/routes/$repo/_issues/user/$id.tsx b/webui2/src/routes/$repo/_issues/user/$id.tsx index 8d058df34119120fca7b70f81ef8b6fba547ecf1..2a3e26f83b0486ad9643e91fb462e0968576116e 100644 --- a/webui2/src/routes/$repo/_issues/user/$id.tsx +++ b/webui2/src/routes/$repo/_issues/user/$id.tsx @@ -1,11 +1,9 @@ // User profile page (/user/:id). Fetches an identity by prefix and shows: // - avatar, display name, login, email, humanId, protected badge // - open/closed issue toggle with BOTH counts always visible -// - paginated list of that user's bugs (cursor-stack, same approach as BugListPage) -// -// The :id param is treated as a humanId prefix and passed directly to the -// identity(prefix) and allBugs(query:"author:...") GraphQL arguments. +// - paginated list of that user's bugs +import { useReadQuery } from "@apollo/client/react"; import { createFileRoute, Link } from "@tanstack/react-router"; import { formatDistanceToNow } from "date-fns"; import { @@ -16,81 +14,48 @@ import { ChevronLeft, ChevronRight, } from "lucide-react"; -import { useState } from "react"; +import * as v from "valibot"; -import { - Status, - useUserProfileQuery, - type UserProfileQuery, - UserProfileDocument, -} from "@/__generated__/graphql"; +import { Status, type UserProfileQuery, UserProfileDocument } from "@/__generated__/graphql"; import { LabelBadge } from "@/components/bugs/LabelBadge"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { BackLink } from "@/components/ui/back-link"; -import { Button } from "@/components/ui/button"; +import { ButtonLink } from "@/components/ui/button-link"; import { Skeleton } from "@/components/ui/skeleton"; import { cn } from "@/lib/utils"; +const profileSearchSchema = v.object({ + status: v.fallback(v.picklist(["open", "closed"]), "open"), + after: v.fallback(v.string(), ""), +}); + +const PAGE_SIZE = 25; + export const Route = createFileRoute("/$repo/_issues/user/$id")({ component: RouteComponent, pendingComponent: ProfileSkeleton, - loader: async ({ context: { preloadQuery, ref }, params: { id } }) => { - // Preload the initial page (open issues, no cursor) so the router - // waits before transitioning. Subsequent pagination/filter changes - // use useQuery which hits the Apollo cache or fetches fresh. + validateSearch: (search) => v.parse(profileSearchSchema, search), + loaderDeps: ({ search: { status, after } }) => ({ status, after }), + loader: async ({ context: { preloadQuery, ref }, params: { id }, deps: { status, after } }) => { const profileRef = preloadQuery(UserProfileDocument, { variables: { ref, prefix: id, openQuery: `author:${id} status:open`, closedQuery: `author:${id} status:closed`, - listQuery: `author:${id} status:open`, - after: undefined, + listQuery: `author:${id} status:${status}`, + after: after || undefined, }, }); return { profileRef: await preloadQuery.toPromise(profileRef) }; }, }); -const PAGE_SIZE = 25; - function RouteComponent() { const { id, repo } = Route.useParams(); - const { ref } = Route.useRouteContext(); - const [statusFilter, setStatusFilter] = useState<"open" | "closed">("open"); - - // Cursor-stack pagination: cursors[i] is the `after` value to fetch page i. - // Resetting to [undefined] returns to page 1. Shared pattern with BugListPage. - const [cursors, setCursors] = useState<(string | undefined)[]>([undefined]); - const page = cursors.length - 1; - - // Three allBugs aliases in one round-trip: - // openCount / closedCount — always fetched so both badge numbers are visible - // bugs — paginated list for the selected tab - const { data, loading, error } = useUserProfileQuery({ - variables: { - ref, - prefix: id, - openQuery: `author:${id} status:open`, - closedQuery: `author:${id} status:closed`, - listQuery: `author:${id} status:${statusFilter}`, - after: cursors[page], - }, - }); - - function switchStatus(next: "open" | "closed") { - if (next === statusFilter) return; - setStatusFilter(next); - setCursors([undefined]); // reset to page 1 on tab change - } - - if (error) { - return ( -
- Failed to load profile: {error.message} -
- ); - } + const { status: statusFilter, after } = Route.useSearch(); + const { profileRef } = Route.useLoaderData(); + const { data } = useReadQuery(profileRef); const identity = data?.repository?.identity; if (!identity) { @@ -103,16 +68,7 @@ function RouteComponent() { const bugs = data?.repository?.bugs; const totalPages = Math.max(1, Math.ceil((bugs?.totalCount ?? 0) / PAGE_SIZE)); const hasNext = bugs?.pageInfo.hasNextPage ?? false; - const hasPrev = page > 0; - - function goNext() { - const cursor = bugs?.pageInfo.endCursor; - if (cursor) setCursors((prev) => [...prev, cursor]); - } - - function goPrev() { - setCursors((prev) => prev.slice(0, -1)); - } + const hasPrev = !!after; return (
@@ -132,7 +88,6 @@ function RouteComponent() {

{identity.displayName}

- {/* isProtected means this identity has been cryptographically signed */} {identity.isProtected && ( @@ -145,7 +100,6 @@ function RouteComponent() {

#{identity.humanId}

- {/* Aggregate stats — always visible, independent of selected tab */}
@@ -161,10 +115,12 @@ function RouteComponent() { {/* ── Issue list ─────────────────────────────────────────────────── */}
- {/* Open / Closed toggle — mirrors BugListPage style */} + {/* Open / Closed toggle */}
- + - +
{bugs?.nodes.length === 0 && ( @@ -232,7 +190,7 @@ function RouteComponent() {
{bug.title} @@ -256,32 +214,35 @@ function RouteComponent() { ); })} - {/* Pagination footer — only shown when there is more than one page */} {totalPages > 1 && (
- + - Page {page + 1} of {totalPages} + Page {after ? 2 : 1} of {totalPages} - +
)}
@@ -297,13 +258,24 @@ function ProfileSkeleton() {
- +
-
- {Array.from({ length: 4 }).map((_, i) => ( - - ))} +
+
+ +
+
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ +
+ + +
+
+ ))} +
);