diff --git a/web/platform/facts.tsx b/web/platform/facts.tsx index bf2bcfc99..42dfd8c18 100644 --- a/web/platform/facts.tsx +++ b/web/platform/facts.tsx @@ -4,15 +4,16 @@ import type { ReactNode } from "react"; import { styled } from "baseui"; import { LabelXSmall, ParagraphSmall } from "baseui/typography"; -const FactRow = styled("div", ({ $theme }) => ({ - paddingTop: $theme.sizing.scale500, - paddingBottom: $theme.sizing.scale500, - borderBottom: `1px solid ${$theme.colors.borderOpaque}`, +const FactRow = styled<"div", { $compact: boolean }>("div", ({ $theme, $compact }) => ({ + minWidth: 0, + paddingTop: $compact ? $theme.sizing.scale200 : $theme.sizing.scale500, + paddingBottom: $compact ? $theme.sizing.scale200 : $theme.sizing.scale500, + borderBottom: $compact ? "none" : `1px solid ${$theme.colors.borderOpaque}`, })); -export function Fact({ label, children }: { label: string; children: ReactNode }) { - return - {label} +export function Fact({ label, children, compact = false }: { label: string; children: ReactNode; compact?: boolean }) { + return + {label} {children} ; } diff --git a/web/platform/index.ts b/web/platform/index.ts index b891ff0c1..4f49b9eb7 100644 --- a/web/platform/index.ts +++ b/web/platform/index.ts @@ -5,4 +5,5 @@ export * from "./auto-refresh.js"; export * from "./timestamp.js"; export * from "./table.js"; export * from "./facts.js"; +export * from "./select-overrides.js"; export * from "./paged-requests.js"; diff --git a/web/platform/navigation.tsx b/web/platform/navigation.tsx index 180dfeaaa..6f71153a2 100644 --- a/web/platform/navigation.tsx +++ b/web/platform/navigation.tsx @@ -2,7 +2,11 @@ import { createContext, useContext, type AnchorHTMLAttributes, type ComponentType, type ReactNode } from "react"; -export type WebLinkProps = AnchorHTMLAttributes & { href: string }; +export type WebLinkProps = AnchorHTMLAttributes & { + href: string; + /** Whether the host router should scroll the document after navigation. */ + scroll?: boolean; +}; /** * Host-supplied navigation, usually backed by the host framework's router. @@ -13,7 +17,7 @@ export interface WebNavigation { /** Renders in-app links, e.g. a framework router link. */ Link?: ComponentType; /** Moves to an in-app href. */ - push?: (href: string) => void; + push?: (href: string, options?: Pick) => void; /** Re-renders the current page with a fresh model, or moves to `href`; settles once rendered. Enables polling. */ refresh?: (href: string | null) => Promise; } diff --git a/web/platform/select-overrides.tsx b/web/platform/select-overrides.tsx new file mode 100644 index 000000000..993dd7200 --- /dev/null +++ b/web/platform/select-overrides.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { StyledInput, type SelectOverrides } from "baseui/select"; +import { forwardRef, useState, type ComponentPropsWithoutRef } from "react"; + +// Closing removes the options; suppress the old ID until the menu reports a new one. +const SelectInput = forwardRef>((props, ref) => { + const activeDescendant = props["aria-activedescendant"]; + const [closedActiveDescendant, setClosedActiveDescendant] = useState(activeDescendant); + if (!props.$isOpen && closedActiveDescendant !== activeDescendant) setClosedActiveDescendant(activeDescendant); + return ; +}); + +export const selectOverrides: SelectOverrides = { + Input: { component: SelectInput }, + // The enclosing option owns selection semantics; its label is a generic div. + OptionContent: { props: { "aria-selected": undefined, "aria-readonly": undefined } }, +}; diff --git a/web/platform/table.tsx b/web/platform/table.tsx index 99d6ec936..7e4c9856f 100644 --- a/web/platform/table.tsx +++ b/web/platform/table.tsx @@ -10,13 +10,14 @@ import { } from "baseui/table-semantic"; import type { ReactNode } from "react"; -export function Table({ label, headers, children }: { +export function Table({ label, headers, children, fill }: { label: string; /** Column headings; strings, or nodes when a host styles them. */ headers: ReactNode[]; children: ReactNode; + fill?: boolean; }) { - return + return {headers.map((header, index) => {header})} diff --git a/web/service/shared/navigation.tsx b/web/service/shared/navigation.tsx index 8d40ae283..4bcf4df65 100644 --- a/web/service/shared/navigation.tsx +++ b/web/service/shared/navigation.tsx @@ -47,7 +47,7 @@ export function useNextNavigation(): WebNavigation { return useMemo(() => ({ Link: NextWebLink, - push: (href: string) => router.push(href), + push: (href: string, options?: Pick) => router.push(href, options), refresh, }), [refresh, router]); } diff --git a/web/stovepipe/README.md b/web/stovepipe/README.md index 25327a185..0debd70f4 100644 --- a/web/stovepipe/README.md +++ b/web/stovepipe/README.md @@ -19,12 +19,15 @@ Mount `RequestPagesProvider` above route changes with a `loadPage(href, signal)` Request pages use `//request/` and load both the current summary and events with `GetRequestHistoryByID`. The summary is displayed above history and is not reconstructed from retained events. Queue pagination uses the backend's opaque continuations, and the latest URL carries no time window. Project results use `GetProjectStatusByID` for that exact request, with an empty -project selection to read all recorded projects. Each page contains up to 50 -projects; `?projects=` carries its continuation and can be shared. +project selection to read all recorded projects. Pages default to 50 +projects; the Projects per page dropdown offers 25, 50, 100, or 200. +`?projects=` carries the continuation and `projectPageSize` carries +a non-default page size, so both can be shared. Changing the size starts at the +first page. Unsupported sizes fall back to 50. First-page navigation removes the cursor. Pages are live rather than a snapshot; return to the first page to see newly recorded projects. -The repository result appears in the request summary above history; the project results table contains only projects. Recorded results show Green at zero, Partial Breakage between zero and one, and Breakage at one, without percentages. +The request page shows a compact summary with change and base URIs always visible. Project results are the default view; History is a separate tab at `?view=history`. Build and Test Issues (`?view=issues`) and Fix Attempts (`?view=fix-attempts`) show Coming soon. Switching tabs preserves the project cursor. Project results and history tables fill the remaining page height and scroll within the active tab. On small screens, a minimum table height keeps rows usable while the page can scroll to reach the controls. Project pagination controls appear above and below the table, show the number of projects on the page, and use the host router without a document reload or resetting the document scroll position. A new project page starts at the top of the table, while the surrounding page and pagination controls stay in place, including on a short final page. Host router links should honor `WebLinkProps.scroll` to support this behavior. Browser Back and Forward restore the selected tab and cursor; links also work without JavaScript. The repository result appears in the summary above all tabs; the project results table contains only projects. Recorded results show Green at zero, Partial Breakage between zero and one, and Breakage at one, without percentages. Repository breakage, per-project breakage, and recording completion are separate. Zero breakage is green; absent results are never treated as green. Completion diff --git a/web/stovepipe/controller/projects.ts b/web/stovepipe/controller/projects.ts index ba0eafcda..d28fdab0b 100644 --- a/web/stovepipe/controller/projects.ts +++ b/web/stovepipe/controller/projects.ts @@ -1,6 +1,6 @@ import type { ProjectStatusModel } from "../entity/index.js"; import type { StovepipeService } from "../extension/service/index.js"; -import type { StovepipePaths } from "../core/paths.js"; +import { projectPageSizes, type StovepipePaths } from "../core/paths.js"; function breakageDegree(value: number): number { if (!Number.isFinite(value) || value < 0 || value > 1) @@ -14,10 +14,11 @@ export async function loadProjectStatus( queue: string, requestId: string, pageToken: string, + pageSize: number, signal?: AbortSignal, ): Promise { const result = await service.getProjectStatusByID( - { queue, requestId, projects: [], pageSize: 50, pageToken }, + { queue, requestId, projects: [], pageSize, pageToken }, signal ? { signal } : {}, ); if (result.queue !== queue || result.requestId !== requestId) @@ -27,14 +28,18 @@ export async function loadProjectStatus( repositoryBreakageDegree: result.repositoryResult.case === "repositoryBreakageDegree" ? breakageDegree(result.repositoryResult.value) : null, complete: result.projectResultsComplete, + pageSize, + pageSizeChoices: projectPageSizes.map(value => ({ + value, href: paths.request(queue, requestId, "", value), + })), projects: result.projects.map((entry) => ({ project: entry.project, breakageDegree: entry.result.case === "breakageDegree" ? breakageDegree(entry.result.value) : null, })), - firstHref: pageToken ? paths.request(queue, requestId) : null, + firstHref: pageToken ? paths.request(queue, requestId, "", pageSize) : null, nextHref: result.nextPageToken - ? paths.request(queue, requestId, result.nextPageToken) + ? paths.request(queue, requestId, result.nextPageToken, pageSize) : null, }; } diff --git a/web/stovepipe/core/paths.ts b/web/stovepipe/core/paths.ts index 397248b00..fb78b1e5f 100644 --- a/web/stovepipe/core/paths.ts +++ b/web/stovepipe/core/paths.ts @@ -1,5 +1,11 @@ import { encodePathSegment } from "@submitqueue/web-platform/path-segment"; +export const projectPageSizes = [25, 50, 100, 200] as const; + +export function parseProjectPageSize(value: string | null): number { + return projectPageSizes.find(size => String(size) === value) ?? 50; +} + export class StovepipePaths { readonly basePath: string; constructor(basePath = "") { @@ -9,8 +15,11 @@ export class StovepipePaths { const path = `${this.basePath}/${encodePathSegment(queue)}`; return page ? `${path}?${new URLSearchParams({ page })}` : path; } - request(queue: string, requestId: string, projects = ""): string { + request(queue: string, requestId: string, projects = "", projectPageSize = 50): string { const path = `${this.queue(queue)}/request/${encodePathSegment(requestId)}`; - return projects ? `${path}?${new URLSearchParams({ projects })}` : path; + const search = new URLSearchParams(); + if (projects) search.set("projects", projects); + if (projectPageSize !== 50) search.set("projectPageSize", String(projectPageSize)); + return search.size ? `${path}?${search}` : path; } } diff --git a/web/stovepipe/entity/index.ts b/web/stovepipe/entity/index.ts index 564d7cce4..b33c6e6eb 100644 --- a/web/stovepipe/entity/index.ts +++ b/web/stovepipe/entity/index.ts @@ -54,6 +54,10 @@ export interface ProjectStatusModel { repositoryBreakageDegree: number | null; /** Whether production of relevant project results has finished, independent of pagination. */ complete: boolean; + /** Maximum project results requested per page. */ + pageSize: number; + /** Supported page sizes and their first-page URLs for this request. */ + pageSizeChoices: { value: number; href: string }[]; /** Recorded projects on this page; null breakage means no result. */ projects: { project: string; breakageDegree: number | null }[]; /** First project page for this request; null on the first page. */ diff --git a/web/stovepipe/module.test.ts b/web/stovepipe/module.test.ts index f16c306f1..8d950bedf 100644 --- a/web/stovepipe/module.test.ts +++ b/web/stovepipe/module.test.ts @@ -314,6 +314,37 @@ describe("Stovepipe page loading", () => { expect(() => JSON.stringify(result)).not.toThrow(); }); + it.each([25, 100, 200])("requests %s project results and preserves the size in pagination links", async (pageSize) => { + const { app, service, paths } = createPageTestContext(); + const result = await app.handle({ + path: paths.request(summary.queue, summary.requestId), + search: new URLSearchParams({ projects: "opaque+/=", projectPageSize: String(pageSize) }), + }); + expect(service.getProjectStatusByID).toHaveBeenCalledWith({ + queue: summary.queue, requestId: summary.requestId, + projects: [], pageSize, pageToken: "opaque+/=", + }, {}); + expect(result).toMatchObject({ kind: "render", model: { projectStatus: { + ok: true, data: { + pageSize, + firstHref: paths.request(summary.queue, summary.requestId) + `?projectPageSize=${pageSize}`, + nextHref: paths.request(summary.queue, summary.requestId) + `?projects=next%2B%2F%3D&projectPageSize=${pageSize}`, + }, + } } }); + }); + + it.each(["", "0", "-1", "201", "25.5", "invalid"])("uses 50 project results for an invalid page size %j", async (projectPageSize) => { + const { app, service, paths } = createPageTestContext(); + await app.handle({ + path: paths.request(summary.queue, summary.requestId), + search: new URLSearchParams({ projectPageSize }), + }); + expect(service.getProjectStatusByID).toHaveBeenCalledWith({ + queue: summary.queue, requestId: summary.requestId, + projects: [], pageSize: 50, pageToken: "", + }, {}); + }); + it.each([ { queue: "other" }, { requestId: "other" }, diff --git a/web/stovepipe/module.ts b/web/stovepipe/module.ts index 7cae4b8c7..2c6556f1a 100644 --- a/web/stovepipe/module.ts +++ b/web/stovepipe/module.ts @@ -2,7 +2,7 @@ import { createLogCallback, type LogCallback } from "@submitqueue/web-platform/l import { parseStovepipeRoute } from "./core/route.js"; import type { StovepipeService } from "./extension/service/index.js"; import type { Queue, StovepipePageModel } from "./entity/index.js"; -import { StovepipePaths } from "./core/paths.js"; +import { parseProjectPageSize, StovepipePaths } from "./core/paths.js"; import { readSection } from "./controller/page.js"; import { loadQueueRequests } from "./controller/queue.js"; import { loadRequestHistory } from "./controller/request.js"; @@ -88,7 +88,8 @@ export function createStovepipeWeb(options: StovepipeWebOptions) { readSection( () => loadProjectStatus( service, paths, queue.name, requestId, - request.search.get("projects") ?? "", request.signal, + request.search.get("projects") ?? "", + parseProjectPageSize(request.search.get("projectPageSize")), request.signal, ), onLog, "project results", diff --git a/web/stovepipe/view/app.test.tsx b/web/stovepipe/view/app.test.tsx index 26198f8ee..32beb849f 100644 --- a/web/stovepipe/view/app.test.tsx +++ b/web/stovepipe/view/app.test.tsx @@ -19,6 +19,10 @@ const model: StovepipePageModel = { updatedAtMs: "1000", repositoryBreakageDegree: null, complete: false, + pageSize: 50, + pageSizeChoices: [25, 50, 100, 200].map(value => ({ + value, href: "/repo%2Fmain/request/7?projectPageSize=" + value, + })), projects: [ { project: "green-project", breakageDegree: 0 }, { project: "partial-breakage-project", breakageDegree: 0.25 }, @@ -54,7 +58,7 @@ const model: StovepipePageModel = { }, }, }; -it("displays the backend summary above the retained history", () => { +it("displays request summary data", () => { render(); const status = screen.getByRole("region", { name: "Request status" }); expect(within(status).getByText("completed")).toBeVisible(); @@ -65,12 +69,8 @@ it("displays the backend summary above the retained history", () => { expect(within(status).getByText("Updated")).toBeVisible(); expect(within(status).getByText("Repository result")).toBeVisible(); expect(within(status).getByText("No result recorded")).toBeVisible(); - const history = screen.getByRole("region", { name: "History" }); - expect(within(history).getByText("accepted")).toBeVisible(); - expect(status.compareDocumentPosition(history) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); - expect( - screen.getAllByRole("heading", { level: 2 }).map((heading) => heading.textContent), - ).toEqual(["Request status", "History", "Project results"]); + const history = screen.getByText("accepted").closest("section")!; + expect(history).not.toBeVisible(); }); it("links queue entries by request ID even when the change URI is missing", () => { if (!model.history.ok) throw new Error("No summary"); @@ -104,7 +104,7 @@ it("retains the summary and history after a failed refresh", async () => { ); expect(screen.getByText("git://repo/sha")).toBeVisible(); expect(screen.getByText("all_targets_passed")).toBeVisible(); - expect(screen.getByText("accepted")).toBeVisible(); + expect(screen.getByText("accepted")).toBeInTheDocument(); await waitFor(() => expect(screen.getAllByText("Showing the last successful result.")).toHaveLength(2)); expect(screen.getByText("green-project")).toBeVisible(); expect(within(screen.getByRole("region", { name: "Request status" })) @@ -129,7 +129,7 @@ it("discards previous results when the request changes", () => { it("renders recorded project results, completion and shareable pagination", () => { render(); - const projects = screen.getByRole("region", { name: "Project results" }); + const projects = screen.getByRole("region", { name: "Project Results" }); const table = within(projects).getByRole("table", { name: "Recorded project results" }); expect(within(table).queryByText(/Repository/)).toBeNull(); expect(within(table).getByRole("columnheader", { name: "Project" })).toBeVisible(); @@ -139,13 +139,29 @@ it("renders recorded project results, completion and shareable pagination", () = expect(within(projects).getByText("No result recorded")).toBeVisible(); expect(within(projects).queryByText(/%/)).toBeNull(); expect(within(projects).getByText(/still in progress/)).toBeVisible(); - expect(within(projects).getByRole("link", { name: /Next projects/ })).toHaveAttribute( - "href", "/repo%2Fmain/request/7?projects=opaque%2B%2F%3D", - ); - const timestamp = within(projects).getByText(/Results updated:/); - const pagination = within(projects).getByRole("navigation", { name: "Project pages" }); - expect(table.compareDocumentPosition(timestamp) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); - expect(pagination.compareDocumentPosition(timestamp) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + for (const link of within(projects).getAllByRole("link", { name: /Next projects/ })) { + expect(link).toHaveAttribute("href", "/repo%2Fmain/request/7?projects=opaque%2B%2F%3D"); + } +}); + +it.each(["projects=next", "projectPageSize=25"])("preserves the request summary and pager focus when loading %s", (query) => { + if (!model.projectStatus.ok) throw new Error("No projects"); + const { rerender } = render(); + const next = within(screen.getByRole("navigation", { name: "Project pages (top)" })) + .getByRole("link", { name: /Next projects/ }); + next.focus(); + rerender(); + expect(screen.getByText("git://repo/sha")).toBeVisible(); + expect(next).toHaveFocus(); + expect(screen.queryByText("green-project")).toBeNull(); + expect(screen.getByText("next-project")).toBeVisible(); }); it("does not show a previous project's page after a failed pagination load", () => { @@ -205,10 +221,27 @@ it("shows repository and project results when history is unavailable", async () it("shows history when repository and project results are unavailable", async () => { render(); expect(await screen.findByRole("alert")).toHaveTextContent("Projects unavailable"); - expect(screen.getByText("accepted")).toBeVisible(); + expect(screen.getByText("accepted")).toBeInTheDocument(); expect(screen.getByText("git://repo/sha")).toBeVisible(); expect(screen.queryByText("Repository result")).toBeNull(); }); + + +it.each([ + ["projects", "Project Results"], + ["history", "History"], + ["issues", "Build and Test Issues"], + ["fix-attempts", "Fix Attempts"], +] as const)("renders the selected %s view", (view, label) => { + render((Link, ({ $theme, $active }) => ({ + display: "inline-block", + paddingTop: $theme.sizing.scale400, + paddingBottom: $theme.sizing.scale400, + textDecoration: "none", + borderBottom: `2px solid ${$active ? $theme.colors.contentPrimary : "transparent"}`, +})); + +const RequestViewNavigation = styled("nav", ({ $theme }) => ({ + display: "flex", + flexWrap: "wrap", + columnGap: $theme.sizing.scale700, + borderBottom: `1px solid ${$theme.colors.borderOpaque}`, +})); + +const requestViewTabs = [ + { id: "projects", label: "Project Results" }, + { id: "history", label: "History" }, + { id: "issues", label: "Build and Test Issues" }, + { id: "fix-attempts", label: "Fix Attempts" }, +] as const; + +function requestViews(routeKey: string) { + const [path, ...query] = routeKey.split("?"); + const search = new URLSearchParams(query.join("?")); + const selected = requestViewTabs.find(tab => tab.id === search.get("view"))?.id ?? "projects"; + search.delete("view"); + const projectsHref = path + (search.size ? `?${search}` : ""); + const tabs = requestViewTabs.map(tab => { + const params = new URLSearchParams(search); + if (tab.id !== "projects") params.set("view", tab.id); + return { ...tab, selected: tab.id === selected, href: path + (params.size ? `?${params}` : "") }; + }); + return { projectsHref, tabs }; +} + function Page({ model }: { model: StovepipePageModel }) { + const views = requestViews(model.key); return ( - @@ -25,8 +63,8 @@ function Page({ model }: { model: StovepipePageModel }) { - - + + {model.queues.map((queue) => ( ))} - +
{model.kind === "request" && ( - - ← Queue requests + + ← Queue requests / {model.queue} )} - - {model.kind === "queue" - ? model.queue - : `Request ${model.requestId}`} - - - {model.kind === "queue" - ? "Requests ordered by acceptance time, newest first." - : model.queue} - + {model.kind === "queue" ? <> + {model.queue} + Requests ordered by acceptance time, newest first. + : Request {model.requestId}}
{model.kind === "queue" && model.showLatestLink && ( @@ -68,28 +100,25 @@ function Page({ model }: { model: StovepipePageModel }) { ) : (
{(history) => ( -
+
{(projects) => ( - +
- Request status - {history && } - {projects && ( - - - - - - )} -
-
- History - {history && } -
-
- Project results - {projects && } + } />
+ + {views.tabs.map(tab => ( + {tab.label} + ))} + + {views.tabs.map(tab => ( + + ))}
)}
@@ -104,5 +133,6 @@ function Page({ model }: { model: StovepipePageModel }) { ); } export function StovepipeApp({ model }: { model: StovepipePageModel }) { - return ; + const pageKey = model.kind === "request" ? model.key.split("?")[0] : model.key; + return ; } diff --git a/web/stovepipe/view/components.tsx b/web/stovepipe/view/components.tsx index a708dae56..9fc92cfbb 100644 --- a/web/stovepipe/view/components.tsx +++ b/web/stovepipe/view/components.tsx @@ -9,28 +9,28 @@ import { ParagraphSmall } from "baseui/typography"; export { Table } from "@submitqueue/web-platform"; -export function Link({ className = "", ...props }: WebLinkProps) { +export function Link({ className = "", scroll, ...props }: WebLinkProps) { const { Link: HostLink } = useWebNavigation(); - return ; + return ; } export function Section({ result, + cacheKey, children, }: { result: ReadResult; + cacheKey?: string; children(data: T | null): ReactNode; }) { - const [previous, setPrevious] = useState( - result.ok ? result.data : null, - ); - if (result.ok && result.data !== previous) setPrevious(result.data); - const data = result.ok ? result.data : previous; + const [previous, setPrevious] = useState({ cacheKey, data: result.ok ? result.data : null }); + const data = result.ok ? result.data : previous.cacheKey === cacheKey ? previous.data : null; + if (previous.cacheKey !== cacheKey || previous.data !== data) setPrevious({ cacheKey, data }); return ( <> {!result.ok && ( {result.error} - {previous !== null && Showing the last successful result.} + {data !== null && Showing the last successful result.} )} {children(data)} diff --git a/web/stovepipe/view/pages.tsx b/web/stovepipe/view/pages.tsx index fb8675dbc..0026000ae 100644 --- a/web/stovepipe/view/pages.tsx +++ b/web/stovepipe/view/pages.tsx @@ -5,6 +5,7 @@ import { Block } from "baseui/block"; import { StyledTableBodyCell, StyledTableBodyRow } from "baseui/table-semantic"; import { Tag } from "baseui/tag"; import { ParagraphSmall } from "baseui/typography"; +import type { ReactNode } from "react"; import type { RequestSummaryModel, HistoryEventModel, @@ -64,24 +65,31 @@ export function RequestList({ ); } -export function RequestDetails({ request }: { request: RequestSummaryModel }) { +export function RequestDetails({ request, repositoryResult }: { request: RequestSummaryModel | null; repositoryResult: ReactNode }) { return ( - - - - {request.state || "Unknown"} - - - - {request.changeUri || "Unknown URI"} - - - {request.baseUri || "No baseline selected"} - - - - {request.outcomeReason || "—"} - + <> + + {request && + + {request.state || "Unknown"} + + } + {repositoryResult && {repositoryResult}} + {request && <> + + + {request.outcomeReason || "—"} + } + + {request && + + {request.changeUri || "Unknown URI"} + + + {request.baseUri || "No baseline selected"} + + } + ); } export function History({ events }: { events: HistoryEventModel[] }) { @@ -89,6 +97,7 @@ export function History({ events }: { events: HistoryEventModel[] }) { {events.map((event) => ( diff --git a/web/stovepipe/view/projects.tsx b/web/stovepipe/view/projects.tsx index a9674ba2a..ac6f0c87f 100644 --- a/web/stovepipe/view/projects.tsx +++ b/web/stovepipe/view/projects.tsx @@ -1,10 +1,12 @@ "use client"; -import { Timestamp } from "@submitqueue/web-platform"; +import { selectOverrides, Timestamp, useWebNavigation } from "@submitqueue/web-platform"; import { Block } from "baseui/block"; +import { Select } from "baseui/select"; import { StyledTableBodyCell, StyledTableBodyRow } from "baseui/table-semantic"; import { Tag } from "baseui/tag"; import { ParagraphSmall } from "baseui/typography"; +import { useTransition } from "react"; import type { ProjectStatusModel } from "../entity/index.js"; import { Link, Table } from "./components.js"; @@ -18,10 +20,46 @@ export function RecordedResult({ degree }: { degree: number | null }) { ); } -export function ProjectResults({ model }: { model: ProjectStatusModel }) { +export function ProjectResults({ model, pageKey }: { model: ProjectStatusModel; pageKey: string }) { + const navigation = useWebNavigation(); + const [pending, startTransition] = useTransition(); + const options = model.pageSizeChoices.map(choice => ({ + id: choice.value, label: String(choice.value), href: choice.href, + })); + function renderPagination(position: "top" | "bottom") { + const id = `project-page-size-${position}`; + return + {model.projects.length} projects on this page{model.nextHref ? " · More results available" : " · Last page"} + + + + +
+ {model.complete + ? "Project result recording is complete." + : "Project result recording is still in progress; more results may arrive."} + Results updated: {model.updatedAtMs === "0" + ? "Unknown" : } + {renderPagination("top")} +
{model.projects.map((entry) => ( {entry.project} @@ -30,17 +68,7 @@ export function ProjectResults({ model }: { model: ProjectStatusModel }) { ))}
{!model.projects.length && No project results recorded on this page.} - {model.complete - ? "Project result recording is complete." - : "Project result recording is still in progress; more results may arrive."} - {(model.firstHref || model.nextHref) && ( - - {model.firstHref && First projects} - {model.nextHref && Next projects →} - - )} - Results updated: {model.updatedAtMs === "0" - ? "Unknown" : } + {renderPagination("bottom")} ); } diff --git a/web/stovepipe/view/styles.css b/web/stovepipe/view/styles.css index 063d466f6..247028a19 100644 --- a/web/stovepipe/view/styles.css +++ b/web/stovepipe/view/styles.css @@ -1 +1,29 @@ @import "@submitqueue/web-platform/styles.css"; + +.sq-stovepipe-request > main, +.sq-stovepipe-request-view:not([hidden]) { + display: flex; + flex-direction: column; +} + +.sq-stovepipe-request > main > *, +.sq-stovepipe-request-content > *, +.sq-stovepipe-request-view > * { + flex-shrink: 0; +} + +.sq-stovepipe-request-content, +.sq-stovepipe-request-view:not([hidden]) { + flex: 1; + min-height: 0; +} + +.sq-stovepipe-request :is(th, td) { + padding-top: 6px; + padding-bottom: 6px; +} + +.sq-stovepipe-request td > span[data-baseweb="tag"] { + margin-top: 0; + margin-bottom: 0; +} diff --git a/web/submitqueue/view/components.tsx b/web/submitqueue/view/components.tsx index 1eeb08aca..50b17fce9 100644 --- a/web/submitqueue/view/components.tsx +++ b/web/submitqueue/view/components.tsx @@ -2,17 +2,17 @@ import { ErrorState } from "./error-state.js"; import { useLastSuccessfulData } from "./last-successful-data.js"; -import { Fact, PagedRequests, Table, Timestamp, useWebNavigation } from "@submitqueue/web-platform"; +import { Fact, PagedRequests, selectOverrides, Table, Timestamp, useWebNavigation } from "@submitqueue/web-platform"; import { styled, withStyle } from "baseui"; import { Block } from "baseui/block"; import { Button, KIND as BUTTON_KIND, SIZE } from "baseui/button"; import { KIND, Notification } from "baseui/notification"; -import { Select, StyledInput } from "baseui/select"; +import { Select } from "baseui/select"; import { Tag } from "baseui/tag"; import { StyledTableBodyCell, StyledTableBodyRow } from "baseui/table-semantic"; import { HeadingMedium, HeadingXSmall, LabelXSmall, ParagraphSmall, ParagraphXSmall } from "baseui/typography"; import { AccentLink, useWebApp, WebLink } from "./web-app.js"; -import { forwardRef, useState, type ComponentPropsWithoutRef, type ReactNode } from "react"; +import { useState, type ReactNode } from "react"; import type { ChangeDetailModel, HistoryEventModel, LoadResult, QueueModel, QueueRequestsModel, RequestDetailModel, RequestSummaryModel, } from "../entity/index.js"; @@ -20,21 +20,6 @@ import { requestChangeLabels, requestChangeLinks } from "../core/change.js"; import { WebPaths } from "../core/paths.js"; import { knownRequestStatuses, statusDisplay } from "../core/status.js"; -// Closing removes the options; suppress the old ID until the menu reports a new one. -const SelectInput = forwardRef>((props, ref) => { - const activeDescendant = props["aria-activedescendant"]; - const [closedActiveDescendant, setClosedActiveDescendant] = useState(activeDescendant); - if (!props.$isOpen && closedActiveDescendant !== activeDescendant) setClosedActiveDescendant(activeDescendant); - return ; -}); - -const selectOverrides = { - Input: { component: SelectInput }, - // The enclosing option owns selection semantics; its label is a generic div. - OptionContent: { props: { "aria-selected": undefined, "aria-readonly": undefined } }, -}; - const MetadataRow = styled("div", ({ $theme }) => ({ display: "grid", gridTemplateColumns: "minmax(0, 1fr)", diff --git a/web/test/e2e/stovepipe/run_e2e.py b/web/test/e2e/stovepipe/run_e2e.py index 04b6f3686..b7694dba3 100644 --- a/web/test/e2e/stovepipe/run_e2e.py +++ b/web/test/e2e/stovepipe/run_e2e.py @@ -49,6 +49,9 @@ def _pagination_fixture_sql() -> str: ) for i in range(1, 52): degree = 1.0 if i == 50 else 0.25 if i == 1 else 0 + statements.append( + f"INSERT INTO request_log VALUES ('web/pagination','51','fact-{i}',{2000+i},'','validation_fact_recorded',1,'','{{}}');" + ) statements.append( f"INSERT INTO validation_fact VALUES ('web/pagination','{uri}','project-{i:02d}',{degree},'51',{2000+i});" ) diff --git a/web/test/e2e/stovepipe/stovepipe.spec.ts b/web/test/e2e/stovepipe/stovepipe.spec.ts index b4bf082b0..050ba3a71 100644 --- a/web/test/e2e/stovepipe/stovepipe.spec.ts +++ b/web/test/e2e/stovepipe/stovepipe.spec.ts @@ -19,22 +19,17 @@ test.describe("server-rendered styling", () => { await expect(page.getByRole("table", { name: "Requests", exact: true }).locator("tbody tr")).toHaveCount(50); await expect(page.getByRole("status")).toContainText("More requests available"); }); - test("separates request status fields before hydration", async ({ page }) => { + test("renders request status and views without JavaScript", async ({ page }) => { await page.goto("/web%2Fpagination/request/51"); const status = page.getByRole("region", { name: "Request status" }); - const rows = status.locator("dl > div"); - await expect(rows).toHaveCount(7); await expect(status.getByText("Repository result", { exact: true })).toBeVisible(); await expect(status.getByText("Partial Breakage", { exact: true })).toBeVisible(); - const layout = await rows.evaluateAll(fields => fields.map(field => ({ - height: field.getBoundingClientRect().height, - labelGap: field.querySelector("dd")!.getBoundingClientRect().top - field.querySelector("dt")!.getBoundingClientRect().bottom, - separator: parseFloat(getComputedStyle(field).borderBottomWidth), - }))); - for (const row of layout) { - expect(row.height).toBeGreaterThanOrEqual(60); - expect(row.labelGap).toBeGreaterThanOrEqual(6); - expect(row.separator).toBeGreaterThan(0); + await expect(status.getByText("git://web/pagination/51?file=src%2Fmain#fragment", { exact: true })).toBeVisible(); + await page.getByRole("navigation", { name: "Request views" }).getByRole("link", { name: "History", exact: true }).click(); + await expect(page.getByRole("table", { name: "Request history" })).toBeVisible(); + for (const label of ["Build and Test Issues", "Fix Attempts"]) { + await page.getByRole("navigation", { name: "Request views" }).getByRole("link", { name: label, exact: true }).click(); + await expect(page.getByRole("region", { name: label, exact: true }).getByText("Coming soon", { exact: true })).toBeVisible(); } }); test("keeps continuation links usable without JavaScript", async ({ page }) => { @@ -60,6 +55,7 @@ test("keeps queue tables inside a narrow viewport", async ({ page }) => { await page.getByRole("link", { name: "51", exact: true }).click(); await expect(page.getByRole("heading", { name: "Request 51", exact: true })).toBeVisible(); expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); + await page.getByRole("navigation", { name: "Request views" }).getByRole("link", { name: "History", exact: true }).click(); const history = page.getByRole("region", { name: "Request history", exact: true }); await history.focus(); await expect(history).toBeFocused(); @@ -268,10 +264,11 @@ test("real ingestion, queue selection, cursors, request summaries, history, proj await expect( page.getByRole("heading", { name: `Request ${id}`, exact: true }), ).toBeVisible(); + await page.getByRole("navigation", { name: "Request views" }).getByRole("link", { name: "History", exact: true }).click(); await expect( page.getByRole("table", { name: "Request history" }).locator("tbody tr"), ).not.toHaveCount(0); - await expect(page).toHaveURL(new RegExp(`/monorepo%2Fmain/request/${id}$`)); + await expect(page).toHaveURL(new RegExp(`/monorepo%2Fmain/request/${id}\\?view=history$`)); await expect( page.getByRole("region", { name: "Request status" }), ).toBeVisible(); @@ -313,24 +310,21 @@ test("real ingestion, queue selection, cursors, request summaries, history, proj ).toBeVisible(); await expect(status.getByText("Accepted", { exact: true })).toBeVisible(); await expect(status.getByText("Updated", { exact: true })).toBeVisible(); + await page.getByRole("navigation", { name: "Request views" }).getByRole("link", { name: "History", exact: true }).click(); await expect( page.getByRole("table", { name: "Request history" }).getByText("accepted", { exact: true, }), ).toBeVisible(); - await expect(page.getByRole("heading", { level: 2 })).toHaveText([ - "Request status", - "History", - "Project results", - ]); + await page.getByRole("navigation", { name: "Request views" }).getByRole("link", { name: "Project Results", exact: true }).click(); + await expect(page.getByRole("table", { name: "Recorded project results" })).toBeVisible(); await page.screenshot({ path: `${process.env.TEST_UNDECLARED_OUTPUTS_DIR}/stovepipe-request.png`, - fullPage: true, }); }); await test.step("Read recorded project results and reload their cursor", async () => { const status = page.getByRole("region", { name: "Request status", exact: true }); - const section = page.getByRole("region", { name: "Project results", exact: true }); + const section = page.getByRole("region", { name: "Project Results", exact: true }); const projects = section.getByRole("table", { name: "Recorded project results", exact: true }); await expect(projects.locator("tbody tr")).toHaveCount(50); await expect(projects.locator("tbody tr").nth(0).getByRole("cell", { name: "project-50", exact: true })).toBeVisible(); @@ -356,7 +350,7 @@ test("real ingestion, queue selection, cursors, request summaries, history, proj ).toBeVisible(); await page.reload(); await expect(projects.locator("tbody tr")).toHaveCount(50); - await section.getByRole("link", { name: "Next projects →", exact: true }).click(); + await section.getByRole("link", { name: "Next projects →", exact: true }).first().click(); await expect(projects.locator("tbody tr")).toHaveCount(1); await expect(projects.getByRole("cell", { name: "project-51", exact: true })).toBeVisible(); const cursor = new URL(page.url()).searchParams.get("projects"); @@ -366,14 +360,14 @@ test("real ingestion, queue selection, cursors, request summaries, history, proj await expect(status.getByText("Partial Breakage", { exact: true })).toBeVisible(); expect(new URL(page.url()).searchParams.get("projects")).toBe(cursor); await expect(section.getByRole("link", { name: "Next projects →", exact: true })).toHaveCount(0); - await section.getByRole("link", { name: "First projects", exact: true }).click(); + await section.getByRole("link", { name: "First projects", exact: true }).last().click(); await expect(projects.locator("tbody tr")).toHaveCount(50); expect(new URL(page.url()).searchParams.has("projects")).toBe(false); expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]); }); await test.step("Keep another request's missing results distinct from green", async () => { await page.goto("/web%2Fpagination/request/49"); - const section = page.getByRole("region", { name: "Project results", exact: true }); + const section = page.getByRole("region", { name: "Project Results", exact: true }); await expect(section.getByText("No project results recorded on this page.", { exact: true })).toBeVisible(); const projects = section.getByRole("table", { name: "Recorded project results", exact: true }); await expect(projects.locator("tbody tr")).toHaveCount(0); @@ -395,3 +389,92 @@ test("real ingestion, queue selection, cursors, request summaries, history, proj }); expect(errors).toEqual([]); }); + + +test("fills the available height and scrolls long results within each tab", async ({ page }) => { + for (const [view, label] of [["projects", "Recorded project results"], ["history", "Request history"]]) { + await page.setViewportSize({ width: 1280, height: 900 }); + await page.goto(`/web%2Fpagination/request/51?view=${view}`); + const table = page.getByRole("region", { name: label, exact: true }); + const initialHeight = await table.evaluate(element => element.clientHeight); + await page.setViewportSize({ width: 1280, height: 1200 }); + await expect.poll(() => table.evaluate(element => element.clientHeight)).toBeGreaterThan(initialHeight); + await table.focus(); + await page.keyboard.press("End"); + await expect.poll(() => table.evaluate(element => element.scrollTop)).toBeGreaterThan(0); + expect(await page.evaluate(() => window.scrollY)).toBe(0); + } +}); + +test("pages through project results without reloading or moving the surrounding page", async ({ page }) => { + await page.setViewportSize({ width: 1000, height: 650 }); + await page.goto("/web%2Fpagination/request/51"); + const rows = page.getByRole("table", { name: "Recorded project results" }).locator("tbody tr"); + const projectScroll = page.getByRole("region", { name: "Recorded project results", exact: true }); + const pager = page.getByRole("navigation", { name: "Project pages (bottom)" }); + await expect(rows).toHaveCount(50); + await page.evaluate(() => { document.documentElement.dataset.pageSession = "retained"; }); + await projectScroll.focus(); + await page.keyboard.press("End"); + await expect.poll(() => projectScroll.evaluate(element => element.scrollTop)).toBeGreaterThan(0); + await pager.scrollIntoViewIfNeeded(); + const scrollBeforeNext = await page.evaluate(() => window.scrollY); + await pager.getByRole("link", { name: "Next projects →", exact: true }).click(); + await expect(rows).toHaveCount(1); + await expect.poll(() => page.evaluate(() => window.scrollY)).toBe(scrollBeforeNext); + expect(await projectScroll.evaluate(element => element.scrollTop)).toBe(0); + await expect(pager.getByRole("link", { name: "Next projects →", exact: true })).toHaveCount(0); + await page.goBack(); + await expect(rows).toHaveCount(50); + await page.goForward(); + await expect(rows).toHaveCount(1); + await pager.scrollIntoViewIfNeeded(); + const scrollBeforeFirst = await page.evaluate(() => window.scrollY); + await pager.getByRole("link", { name: "First projects", exact: true }).click(); + await expect(rows).toHaveCount(50); + await expect.poll(() => page.evaluate(() => window.scrollY)).toBe(scrollBeforeFirst); + expect(await page.evaluate(() => document.documentElement.dataset.pageSession)).toBe("retained"); +}); + +test("retains project paging across views and resets the cursor when page size changes", async ({ page }) => { + await page.goto("/web%2Fpagination/request/51"); + const rows = page.getByRole("table", { name: "Recorded project results" }).locator("tbody tr"); + const views = page.getByRole("navigation", { name: "Request views" }); + const pager = page.getByRole("navigation", { name: "Project pages (bottom)" }); + const selectSize = async (size: string) => { + await pager.getByRole("combobox", { name: /Projects per page/u }).click(); + await page.getByRole("option", { name: size, exact: true }).click(); + }; + await expect(rows).toHaveCount(50); + await page.evaluate(() => { document.documentElement.dataset.pageSession = "retained"; }); + await pager.scrollIntoViewIfNeeded(); + const scrollBeforeSize = await page.evaluate(() => window.scrollY); + await selectSize("25"); + await expect(rows).toHaveCount(25); + await expect.poll(() => page.evaluate(() => window.scrollY)).toBe(scrollBeforeSize); + const firstProjects = await rows.locator("td:first-child").allTextContents(); + await pager.getByRole("link", { name: "Next projects →", exact: true }).click(); + await expect.poll(() => rows.locator("td:first-child").allTextContents()).not.toEqual(firstProjects); + const nextProjects = await rows.locator("td:first-child").allTextContents(); + expect(nextProjects.every(project => !firstProjects.includes(project))).toBe(true); + const cursor = new URL(page.url()).searchParams.get("projects"); + expect(cursor).toBeTruthy(); + for (const label of ["History", "Build and Test Issues", "Fix Attempts"]) { + await views.getByRole("link", { name: label, exact: true }).click(); + const section = page.getByRole("region", { name: label, exact: true }); + await expect(section).toBeVisible(); + if (label !== "History") await expect(section.getByText("Coming soon", { exact: true })).toBeVisible(); + } + await views.getByRole("link", { name: "Project Results", exact: true }).click(); + await expect(rows).toHaveCount(25); + expect(new URL(page.url()).searchParams.get("projects")).toBe(cursor); + expect(new URL(page.url()).searchParams.get("projectPageSize")).toBe("25"); + expect(await rows.locator("td:first-child").allTextContents()).toEqual(nextProjects); + await selectSize("100"); + await expect(rows).toHaveCount(51); + expect(new URL(page.url()).searchParams.has("projects")).toBe(false); + expect(await page.evaluate(() => document.documentElement.dataset.pageSession)).toBe("retained"); + await page.reload(); + await expect(rows).toHaveCount(51); + expect(new URL(page.url()).searchParams.get("projectPageSize")).toBe("100"); +}); diff --git a/web/test/package/react18/react18-runtime.test.mjs b/web/test/package/react18/react18-runtime.test.mjs index 2cfc154f7..6b1750cf9 100644 --- a/web/test/package/react18/react18-runtime.test.mjs +++ b/web/test/package/react18/react18-runtime.test.mjs @@ -58,11 +58,12 @@ test("renders and hydrates packaged pages with React 18 and host themes", async }), getRequestHistoryByID: async () => ({ requestSummary: { requestId: "1", queue: "repo/main", changeUri: "git://repo/main/sha", baseUri: "", requestState: "accepted", stateUpdatedAtMs: 100n, acceptedAtMs: 100n, outcomeReason: "" }, events: [] }), } }); - for (const path of ["/repo%2Fmain", "/repo%2Fmain/request/1"]) { - const result = await stovepipe.handle({ path, search: new URLSearchParams() }); + for (const href of ["/repo%2Fmain", "/repo%2Fmain/request/1", "/repo%2Fmain/request/1?view=history"]) { + const url = new URL(href, "https://submitqueue.test"); + const result = await stovepipe.handle({ path: url.pathname, search: url.searchParams }); assert.equal(result.kind, "render"); const page = createElement(StovepipeApp, { model: result.model }); - addPage(path, page); + addPage(href, page); } const dom = new JSDOM("", { url: "https://submitqueue.test/" });