From 288853fe40bc2bb030a0e56c0747b69c080d3038 Mon Sep 17 00:00:00 2001 From: mnoah1 Date: Tue, 27 Oct 2026 17:27:18 +0000 Subject: [PATCH 1/9] feat(stovepipe-web): add compact request tabs --- web/platform/facts.tsx | 15 ++-- web/stovepipe/README.md | 2 +- web/stovepipe/view/app.test.tsx | 43 +++++++--- web/stovepipe/view/app.tsx | 85 +++++++++++-------- web/stovepipe/view/pages.tsx | 45 ++++++---- web/stovepipe/view/projects.tsx | 18 ++-- web/stovepipe/view/styles.css | 10 +++ web/test/e2e/stovepipe/stovepipe.spec.ts | 80 ++++++++++++----- .../package/react18/react18-runtime.test.mjs | 7 +- 9 files changed, 207 insertions(+), 98 deletions(-) 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/stovepipe/README.md b/web/stovepipe/README.md index 9bb53bd18..1fc41cca5 100644 --- a/web/stovepipe/README.md +++ b/web/stovepipe/README.md @@ -24,7 +24,7 @@ projects; `?projects=` carries its continuation and can be shared. 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 expandable change and base URIs. Project results are the default view; History is a separate tab at `?view=history`. Switching tabs preserves the project cursor. 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. Browser Back and Forward restore the selected tab and cursor; links also work without JavaScript. The repository result appears in the summary above both 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/view/app.test.tsx b/web/stovepipe/view/app.test.tsx index 26198f8ee..451f0ef71 100644 --- a/web/stovepipe/view/app.test.tsx +++ b/web/stovepipe/view/app.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, render, screen, waitFor, within } from "../../platform/test/rtl.js"; +import { cleanup, fireEvent, render, screen, waitFor, within } from "../../platform/test/rtl.js"; import { afterEach, expect, it } from "vitest"; import { StovepipeApp } from "./app.js"; import type { StovepipePageModel } from "../entity/index.js"; @@ -56,6 +56,7 @@ const model: StovepipePageModel = { }; it("displays the backend summary above the retained history", () => { render(); + fireEvent.click(screen.getByText("Change and base URIs")); const status = screen.getByRole("region", { name: "Request status" }); expect(within(status).getByText("completed")).toBeVisible(); expect(within(status).getByText("git://repo/sha")).toBeVisible(); @@ -65,12 +66,12 @@ 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(); + const history = screen.getByText("accepted").closest("section")!; + expect(history).not.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"]); + ).toEqual(["Project results"]); }); it("links queue entries by request ID even when the change URI is missing", () => { if (!model.history.ok) throw new Error("No summary"); @@ -102,9 +103,10 @@ it("retains the summary and history after a failed refresh", async () => { projectStatus: { ok: false, error: "Projects unavailable" }, }} />, ); + fireEvent.click(screen.getByText("Change and base URIs")); 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" })) @@ -139,11 +141,13 @@ 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", - ); + for (const link of within(projects).getAllByRole("link", { name: /Next projects/ })) { + expect(link).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" }); + const pagination = within(projects).getByRole("navigation", { name: "Project pages (bottom)" }); + const topPagination = within(projects).getByRole("navigation", { name: "Project pages (top)" }); + expect(topPagination.compareDocumentPosition(table) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); expect(table.compareDocumentPosition(timestamp) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); expect(pagination.compareDocumentPosition(timestamp) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); }); @@ -155,6 +159,7 @@ it("does not show a previous project's page after a failed pagination load", () projectStatus: { ok: false, error: "Projects unavailable" }, }} />); expect(screen.queryByText("green-project")).toBeNull(); + fireEvent.click(screen.getByText("Change and base URIs")); expect(screen.getByText("git://repo/sha")).toBeVisible(); }); @@ -205,10 +210,26 @@ 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(); + fireEvent.click(screen.getByText("Change and base URIs")); expect(screen.getByText("git://repo/sha")).toBeVisible(); expect(screen.queryByText("Repository result")).toBeNull(); }); + + +it.each([false, true])("keeps the project cursor when selecting either request view (history: %s)", (historySelected) => { + const query = new URLSearchParams({ projects: "opaque+/=", filter: "keep" }); + if (historySelected) query.set("view", "history"); + render(); + const navigation = screen.getByRole("navigation", { name: "Request views" }); + const projects = within(navigation).getByRole("link", { name: "Project results" }); + const history = within(navigation).getByRole("link", { name: "History" }); + expect(projects).toHaveAttribute("href", "/repo%2Fmain/request/7?projects=opaque%2B%2F%3D&filter=keep"); + expect(history).toHaveAttribute("href", "/repo%2Fmain/request/7?projects=opaque%2B%2F%3D&filter=keep&view=history"); + expect(historySelected ? history : projects).toHaveAttribute("aria-current", "page"); + expect(screen.getByRole("region", { name: historySelected ? "History" : "Project results" })).toBeVisible(); + expect(screen.queryByRole("region", { name: historySelected ? "Project results" : "History" })).toBeNull(); +}); diff --git a/web/stovepipe/view/app.tsx b/web/stovepipe/view/app.tsx index a72b1bf04..2ca83e2be 100644 --- a/web/stovepipe/view/app.tsx +++ b/web/stovepipe/view/app.tsx @@ -1,9 +1,10 @@ "use client"; -import { Fact, Timestamp, TimeZoneProvider, TimeZoneToggle } from "@submitqueue/web-platform"; +import { Timestamp, TimeZoneProvider, TimeZoneToggle } from "@submitqueue/web-platform"; +import { styled } from "baseui"; import { Block } from "baseui/block"; import { Tag } from "baseui/tag"; -import { HeadingLarge, HeadingSmall, LabelMedium, ParagraphSmall } from "baseui/typography"; +import { HeadingLarge, HeadingMedium, HeadingSmall, LabelMedium, ParagraphSmall } from "baseui/typography"; import { RefreshControls } from "./refresh.js"; import type { StovepipePageModel } from "../entity/index.js"; import { Link, Section } from "./components.js"; @@ -11,9 +12,34 @@ import { RequestDetails, History } from "./pages.js"; import { QueueRequests } from "./queue.js"; import { ProjectResults, RecordedResult } from "./projects.js"; +const RequestViewLink = styled(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", + gap: $theme.sizing.scale700, + borderBottom: `1px solid ${$theme.colors.borderOpaque}`, +})); + +function requestViews(routeKey: string) { + const [path, ...query] = routeKey.split("?"); + const search = new URLSearchParams(query.join("?")); + const historySelected = search.get("view") === "history"; + search.delete("view"); + const projectsHref = path + (search.size ? `?${search}` : ""); + search.set("view", "history"); + return { historySelected, projectsHref, historyHref: `${path}?${search}` }; +} + function Page({ model }: { model: StovepipePageModel }) { + const views = requestViews(model.key); return ( - - - + + {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,26 +88,22 @@ function Page({ model }: { model: StovepipePageModel }) { ) : (
{(history) => ( -
+
{(projects) => ( - +
- Request status - {history && } - {projects && ( - - - - - - )} + } />
-
- History + + Project results + History + + -
- Project results + @@ -104,5 +120,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/pages.tsx b/web/stovepipe/view/pages.tsx index fb8675dbc..ffce30d16 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,34 @@ 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 && + Change and base URIs + + + {request.changeUri || "Unknown URI"} + + + {request.baseUri || "No baseline selected"} + + + } + ); } export function History({ events }: { events: HistoryEventModel[] }) { diff --git a/web/stovepipe/view/projects.tsx b/web/stovepipe/view/projects.tsx index a9674ba2a..68406b7a5 100644 --- a/web/stovepipe/view/projects.tsx +++ b/web/stovepipe/view/projects.tsx @@ -21,6 +21,7 @@ export function RecordedResult({ degree }: { degree: number | null }) { export function ProjectResults({ model }: { model: ProjectStatusModel }) { return ( <> + {model.projects.map((entry) => ( @@ -33,14 +34,19 @@ export function ProjectResults({ model }: { model: ProjectStatusModel }) { {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" : } ); } + +function ProjectPagination({ model, position }: { model: ProjectStatusModel; position: "top" | "bottom" }) { + return + {model.projects.length} projects on this page{model.nextHref ? " · More results available" : " · Last page"} + + {model.firstHref && First projects} + {model.nextHref && Next projects →} + + ; +} diff --git a/web/stovepipe/view/styles.css b/web/stovepipe/view/styles.css index 063d466f6..d8ce32a20 100644 --- a/web/stovepipe/view/styles.css +++ b/web/stovepipe/view/styles.css @@ -1 +1,11 @@ @import "@submitqueue/web-platform/styles.css"; + +.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/test/e2e/stovepipe/stovepipe.spec.ts b/web/test/e2e/stovepipe/stovepipe.spec.ts index b4bf082b0..8150943ef 100644 --- a/web/test/e2e/stovepipe/stovepipe.spec.ts +++ b/web/test/e2e/stovepipe/stovepipe.spec.ts @@ -26,16 +26,12 @@ test.describe("server-rendered styling", () => { 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.locator("details")).not.toHaveAttribute("open", ""); + await status.getByText("Change and base URIs", { exact: true }).click(); + 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(); + expect(new URL(page.url()).searchParams.get("view")).toBe("history"); }); test("keeps continuation links usable without JavaScript", async ({ page }) => { await page.goto("/web%2Fpagination"); @@ -60,6 +56,8 @@ 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.screenshot({ path: `${process.env.TEST_UNDECLARED_OUTPUTS_DIR}/stovepipe-request-narrow.png` }); + 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 +266,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(); @@ -306,6 +305,7 @@ test("real ingestion, queue selection, cursors, request summaries, history, proj ).toBeVisible(); const status = page.getByRole("region", { name: "Request status" }); await expect(status.getByText("failed", { exact: true })).toBeVisible(); + await status.getByText("Change and base URIs", { exact: true }).click(); await expect( status.getByText("git://web/pagination/51?file=src%2Fmain#fragment", { exact: true, @@ -313,19 +313,19 @@ 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 expect(page.getByRole("heading", { level: 2 })).toHaveText(["History"]); + await page.screenshot({ path: `${process.env.TEST_UNDECLARED_OUTPUTS_DIR}/stovepipe-request-history.png` }); + 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 status.getByText("Change and base URIs", { exact: true }).click(); 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 () => { @@ -356,7 +356,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,7 +366,7 @@ 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([]); @@ -395,3 +395,45 @@ test("real ingestion, queue selection, cursors, request summaries, history, proj }); expect(errors).toEqual([]); }); + + +test("pages through project results and switches views without reloading the document", async ({ page }) => { + await page.goto("/web%2Fpagination/request/51"); + const views = page.getByRole("navigation", { name: "Request views" }); + const projects = page.getByRole("table", { name: "Recorded project results", exact: true }); + const topPager = page.getByRole("navigation", { name: "Project pages (top)" }); + const bottomPager = page.getByRole("navigation", { name: "Project pages (bottom)" }); + await expect(projects.locator("tbody tr")).toHaveCount(50); + await page.evaluate(() => { document.documentElement.dataset.pageSession = "retained"; }); + let documentRequests = 0; + page.on("request", request => { + if (request.isNavigationRequest() && request.resourceType() === "document") documentRequests++; + }); + await topPager.getByRole("link", { name: "Next projects →", exact: true }).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"); + expect(cursor).toBeTruthy(); + await expect(topPager).toContainText("Last page"); + await expect(bottomPager.getByRole("link", { name: "Next projects →", exact: true })).toHaveCount(0); + await views.getByRole("link", { name: "History", exact: true }).click(); + await expect(page.getByRole("table", { name: "Request history" })).toBeVisible(); + expect(new URL(page.url()).searchParams.get("projects")).toBe(cursor); + expect(new URL(page.url()).searchParams.get("view")).toBe("history"); + await views.getByRole("link", { name: "Project results", exact: true }).click(); + await expect(projects.locator("tbody tr")).toHaveCount(1); + await page.goBack(); + await expect(page.getByRole("table", { name: "Request history" })).toBeVisible(); + await page.goBack(); + await expect(projects.locator("tbody tr")).toHaveCount(1); + await page.goBack(); + await expect(projects.locator("tbody tr")).toHaveCount(50); + await page.goForward(); + await expect(projects.locator("tbody tr")).toHaveCount(1); + await bottomPager.getByRole("link", { name: "First projects", exact: true }).click(); + await expect(projects.locator("tbody tr")).toHaveCount(50); + expect(new URL(page.url()).searchParams.has("projects")).toBe(false); + expect(documentRequests).toBe(0); + expect(await page.evaluate(() => document.documentElement.dataset.pageSession)).toBe("retained"); + expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]); +}); diff --git a/web/test/package/react18/react18-runtime.test.mjs b/web/test/package/react18/react18-runtime.test.mjs index 302b4ace4..ffc9fcc44 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/" }); From d445c6789d33f5e3c8248149d460ca1f5ef03306 Mon Sep 17 00:00:00 2001 From: mnoah1 Date: Tue, 27 Oct 2026 17:55:44 +0000 Subject: [PATCH 2/9] fix(stovepipe-web): keep project pagination within results panel --- web/platform/navigation.tsx | 6 +++++- web/platform/table.tsx | 5 +++-- web/stovepipe/README.md | 2 +- web/stovepipe/view/app.tsx | 4 ++-- web/stovepipe/view/components.tsx | 4 ++-- web/stovepipe/view/projects.tsx | 6 +++--- web/test/e2e/stovepipe/stovepipe.spec.ts | 22 ++++++++++++++++++++-- 7 files changed, 36 insertions(+), 13 deletions(-) diff --git a/web/platform/navigation.tsx b/web/platform/navigation.tsx index 180dfeaaa..69b5358b4 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. diff --git a/web/platform/table.tsx b/web/platform/table.tsx index e6abe2735..e6e138a25 100644 --- a/web/platform/table.tsx +++ b/web/platform/table.tsx @@ -10,12 +10,13 @@ import { } from "baseui/table-semantic"; import type { ReactNode } from "react"; -export function Table({ label, headers, children }: { +export function Table({ label, headers, children, height }: { label: string; headers: string[]; children: ReactNode; + height?: string; }) { - return + return {headers.map(header => {header})} diff --git a/web/stovepipe/README.md b/web/stovepipe/README.md index 1fc41cca5..2821f2ab5 100644 --- a/web/stovepipe/README.md +++ b/web/stovepipe/README.md @@ -24,7 +24,7 @@ projects; `?projects=` carries its continuation and can be shared. First-page navigation removes the cursor. Pages are live rather than a snapshot; return to the first page to see newly recorded projects. -The request page shows a compact summary with expandable change and base URIs. Project results are the default view; History is a separate tab at `?view=history`. Switching tabs preserves the project cursor. 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. Browser Back and Forward restore the selected tab and cursor; links also work without JavaScript. The repository result appears in the summary above both 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. +The request page shows a compact summary with expandable change and base URIs. Project results are the default view; History is a separate tab at `?view=history`. Switching tabs preserves the project cursor. Project pagination controls appear above and below a fixed-height, scrollable 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 both 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/view/app.tsx b/web/stovepipe/view/app.tsx index 2ca83e2be..663ac3e20 100644 --- a/web/stovepipe/view/app.tsx +++ b/web/stovepipe/view/app.tsx @@ -95,8 +95,8 @@ function Page({ model }: { model: StovepipePageModel }) { } /> - Project results - History + Project results + History
+
{model.projects.map((entry) => ( {entry.project} @@ -45,8 +45,8 @@ function ProjectPagination({ model, position }: { model: ProjectStatusModel; pos return {model.projects.length} projects on this page{model.nextHref ? " · More results available" : " · Last page"} - {model.firstHref && First projects} - {model.nextHref && Next projects →} + {model.firstHref && First projects} + {model.nextHref && Next projects →} ; } diff --git a/web/test/e2e/stovepipe/stovepipe.spec.ts b/web/test/e2e/stovepipe/stovepipe.spec.ts index 8150943ef..d008c7b48 100644 --- a/web/test/e2e/stovepipe/stovepipe.spec.ts +++ b/web/test/e2e/stovepipe/stovepipe.spec.ts @@ -398,9 +398,11 @@ test("real ingestion, queue selection, cursors, request summaries, history, proj test("pages through project results and switches views without reloading the document", async ({ page }) => { + await page.setViewportSize({ width: 1000, height: 650 }); await page.goto("/web%2Fpagination/request/51"); const views = page.getByRole("navigation", { name: "Request views" }); const projects = page.getByRole("table", { name: "Recorded project results", exact: true }); + const projectScroll = page.getByRole("region", { name: "Recorded project results", exact: true }); const topPager = page.getByRole("navigation", { name: "Project pages (top)" }); const bottomPager = page.getByRole("navigation", { name: "Project pages (bottom)" }); await expect(projects.locator("tbody tr")).toHaveCount(50); @@ -409,8 +411,18 @@ test("pages through project results and switches views without reloading the doc page.on("request", request => { if (request.isNavigationRequest() && request.resourceType() === "document") documentRequests++; }); - await topPager.getByRole("link", { name: "Next projects →", exact: true }).click(); + await projectScroll.focus(); + await page.keyboard.press("End"); + await expect.poll(() => projectScroll.evaluate(element => element.scrollTop)).toBeGreaterThan(0); + await bottomPager.scrollIntoViewIfNeeded(); + const documentScroll = await page.evaluate(() => window.scrollY); + expect(documentScroll).toBeGreaterThan(0); + const resultHeight = await projectScroll.evaluate(element => element.clientHeight); + await bottomPager.getByRole("link", { name: "Next projects →", exact: true }).click(); await expect(projects.locator("tbody tr")).toHaveCount(1); + await expect.poll(() => page.evaluate(() => window.scrollY)).toBe(documentScroll); + expect(await projectScroll.evaluate(element => element.scrollTop)).toBe(0); + expect(await projectScroll.evaluate(element => element.clientHeight)).toBe(resultHeight); await expect(projects.getByRole("cell", { name: "project-51", exact: true })).toBeVisible(); const cursor = new URL(page.url()).searchParams.get("projects"); expect(cursor).toBeTruthy(); @@ -432,7 +444,13 @@ test("pages through project results and switches views without reloading the doc await expect(projects.locator("tbody tr")).toHaveCount(1); await bottomPager.getByRole("link", { name: "First projects", exact: true }).click(); await expect(projects.locator("tbody tr")).toHaveCount(50); - expect(new URL(page.url()).searchParams.has("projects")).toBe(false); + await expect.poll(() => page.evaluate(() => window.scrollY)).toBe(documentScroll); + await topPager.scrollIntoViewIfNeeded(); + const topPagerScroll = await page.evaluate(() => window.scrollY); + await topPager.getByRole("link", { name: "Next projects →", exact: true }).click(); + await expect(projects.locator("tbody tr")).toHaveCount(1); + await expect.poll(() => page.evaluate(() => window.scrollY)).toBe(topPagerScroll); + expect(new URL(page.url()).searchParams.get("projects")).toBe(cursor); expect(documentRequests).toBe(0); expect(await page.evaluate(() => document.documentElement.dataset.pageSession)).toBe("retained"); expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]); From 33e9f012d755f0ed3c64299bd9b500394d632949 Mon Sep 17 00:00:00 2001 From: mnoah1 Date: Tue, 27 Oct 2026 18:19:39 +0000 Subject: [PATCH 3/9] feat(stovepipe-web): allow selecting project results page size --- web/platform/index.ts | 1 + web/platform/navigation.tsx | 2 +- web/platform/select-overrides.tsx | 19 ++++++ web/service/shared/navigation.tsx | 2 +- web/stovepipe/README.md | 7 ++- web/stovepipe/controller/projects.ts | 13 +++-- web/stovepipe/core/paths.ts | 13 ++++- web/stovepipe/entity/index.ts | 4 ++ web/stovepipe/module.test.ts | 31 ++++++++++ web/stovepipe/module.ts | 5 +- web/stovepipe/view/app.test.tsx | 4 ++ web/stovepipe/view/projects.tsx | 30 +++++++++- web/submitqueue/view/components.tsx | 21 +------ web/test/e2e/stovepipe/stovepipe.spec.ts | 73 ++++++++++++++++++++++++ 14 files changed, 193 insertions(+), 32 deletions(-) create mode 100644 web/platform/select-overrides.tsx diff --git a/web/platform/index.ts b/web/platform/index.ts index e0409d847..8ba71cbd2 100644 --- a/web/platform/index.ts +++ b/web/platform/index.ts @@ -5,3 +5,4 @@ export * from "./auto-refresh.js"; export * from "./timestamp.js"; export * from "./table.js"; export * from "./facts.js"; +export * from "./select-overrides.js"; diff --git a/web/platform/navigation.tsx b/web/platform/navigation.tsx index 69b5358b4..6f71153a2 100644 --- a/web/platform/navigation.tsx +++ b/web/platform/navigation.tsx @@ -17,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/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 2821f2ab5..d69cb72f9 100644 --- a/web/stovepipe/README.md +++ b/web/stovepipe/README.md @@ -19,8 +19,11 @@ Mount `StovepipeQueueProvider` 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. 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 451f0ef71..27bd550f4 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 }, diff --git a/web/stovepipe/view/projects.tsx b/web/stovepipe/view/projects.tsx index fb3ec74bf..cf881ed99 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"; @@ -44,9 +46,33 @@ export function ProjectResults({ model }: { model: ProjectStatusModel }) { function ProjectPagination({ model, position }: { model: ProjectStatusModel; position: "top" | "bottom" }) { return {model.projects.length} projects on this page{model.nextHref ? " · More results available" : " · Last page"} - + + {model.firstHref && First projects} {model.nextHref && Next projects →} ; } + +function ProjectPageSize({ model, id }: { model: ProjectStatusModel; id: string }) { + const navigation = useWebNavigation(); + const [pending, startTransition] = useTransition(); + const options = model.pageSizeChoices.map(choice => ({ + id: choice.value, label: String(choice.value), href: choice.href, + })); + return + + + option.id === model.pageSize)} + overrides={selectOverrides} isLoading={pending} disabled={pending} + onChange={({ value }) => { + const option = value[0]; + if (!option || option.id === model.pageSize || typeof option.href !== "string") return; + const href = option.href; + if (navigation.push) startTransition(() => navigation.push?.(href, { scroll: false })); + else window.location.assign(href); + }} /> + + + {model.firstHref && First projects} + {model.nextHref && Next projects →} + + ; + } return ( <> - -
+ {renderPagination("top")} +
{model.projects.map((entry) => ( {entry.project} @@ -36,43 +66,9 @@ export function ProjectResults({ model }: { model: ProjectStatusModel }) { {model.complete ? "Project result recording is complete." : "Project result recording is still in progress; more results may arrive."} - + {renderPagination("bottom")} Results updated: {model.updatedAtMs === "0" ? "Unknown" : } ); } - -function ProjectPagination({ model, position }: { model: ProjectStatusModel; position: "top" | "bottom" }) { - return - {model.projects.length} projects on this page{model.nextHref ? " · More results available" : " · Last page"} - - - {model.firstHref && First projects} - {model.nextHref && Next projects →} - - ; -} - -function ProjectPageSize({ model, id }: { model: ProjectStatusModel; id: string }) { - const navigation = useWebNavigation(); - const [pending, startTransition] = useTransition(); - const options = model.pageSizeChoices.map(choice => ({ - id: choice.value, label: String(choice.value), href: choice.href, - })); - return - - -
{events.map((event) => ( diff --git a/web/stovepipe/view/projects.tsx b/web/stovepipe/view/projects.tsx index 3b23de913..fa6182b8f 100644 --- a/web/stovepipe/view/projects.tsx +++ b/web/stovepipe/view/projects.tsx @@ -54,7 +54,7 @@ export function ProjectResults({ model, pageKey }: { model: ProjectStatusModel; return ( <> {renderPagination("top")} -
+
{model.projects.map((entry) => ( {entry.project} diff --git a/web/stovepipe/view/styles.css b/web/stovepipe/view/styles.css index d8ce32a20..247028a19 100644 --- a/web/stovepipe/view/styles.css +++ b/web/stovepipe/view/styles.css @@ -1,5 +1,23 @@ @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; 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 e36b169e8..050ba3a71 100644 --- a/web/test/e2e/stovepipe/stovepipe.spec.ts +++ b/web/test/e2e/stovepipe/stovepipe.spec.ts @@ -391,6 +391,21 @@ test("real ingestion, queue selection, cursors, request summaries, history, proj }); +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"); @@ -404,7 +419,6 @@ test("pages through project results without reloading or moving the surrounding await expect.poll(() => projectScroll.evaluate(element => element.scrollTop)).toBeGreaterThan(0); await pager.scrollIntoViewIfNeeded(); const scrollBeforeNext = await page.evaluate(() => window.scrollY); - expect(scrollBeforeNext).toBeGreaterThan(0); await pager.getByRole("link", { name: "Next projects →", exact: true }).click(); await expect(rows).toHaveCount(1); await expect.poll(() => page.evaluate(() => window.scrollY)).toBe(scrollBeforeNext); From d06f50130ab142c9850c8e60e41e6ace62c325bd Mon Sep 17 00:00:00 2001 From: mnoah1 Date: Wed, 28 Oct 2026 21:25:59 +0000 Subject: [PATCH 9/9] feat(stovepipe-web): move project metadata below the tab heading --- web/stovepipe/view/projects.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/web/stovepipe/view/projects.tsx b/web/stovepipe/view/projects.tsx index fa6182b8f..ac6f0c87f 100644 --- a/web/stovepipe/view/projects.tsx +++ b/web/stovepipe/view/projects.tsx @@ -53,6 +53,11 @@ export function ProjectResults({ model, pageKey }: { model: ProjectStatusModel; } return ( <> + {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) => ( @@ -63,12 +68,7 @@ export function ProjectResults({ model, pageKey }: { 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."} {renderPagination("bottom")} - Results updated: {model.updatedAtMs === "0" - ? "Unknown" : } ); }