Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions web/platform/facts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <FactRow>
<LabelXSmall as="dt" color="contentSecondary" marginBottom="scale300">{label}</LabelXSmall>
export function Fact({ label, children, compact = false }: { label: string; children: ReactNode; compact?: boolean }) {
return <FactRow $compact={compact}>
<LabelXSmall as="dt" color="contentSecondary" marginBottom={compact ? "scale100" : "scale300"}>{label}</LabelXSmall>
<ParagraphSmall as="dd" margin="0">{children}</ParagraphSmall>
</FactRow>;
}
1 change: 1 addition & 0 deletions web/platform/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
8 changes: 6 additions & 2 deletions web/platform/navigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@

import { createContext, useContext, type AnchorHTMLAttributes, type ComponentType, type ReactNode } from "react";

export type WebLinkProps = AnchorHTMLAttributes<HTMLAnchorElement> & { href: string };
export type WebLinkProps = AnchorHTMLAttributes<HTMLAnchorElement> & {
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.
Expand All @@ -13,7 +17,7 @@ export interface WebNavigation {
/** Renders in-app links, e.g. a framework router link. */
Link?: ComponentType<WebLinkProps>;
/** Moves to an in-app href. */
push?: (href: string) => void;
push?: (href: string, options?: Pick<WebLinkProps, "scroll">) => void;
/** Re-renders the current page with a fresh model, or moves to `href`; settles once rendered. Enables polling. */
refresh?: (href: string | null) => Promise<void>;
}
Expand Down
19 changes: 19 additions & 0 deletions web/platform/select-overrides.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement, ComponentPropsWithoutRef<typeof StyledInput>>((props, ref) => {
const activeDescendant = props["aria-activedescendant"];
const [closedActiveDescendant, setClosedActiveDescendant] = useState(activeDescendant);
if (!props.$isOpen && closedActiveDescendant !== activeDescendant) setClosedActiveDescendant(activeDescendant);
return <StyledInput {...props} ref={ref}
aria-activedescendant={props.$isOpen && activeDescendant !== closedActiveDescendant ? activeDescendant : undefined} />;
});

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 } },
};
5 changes: 3 additions & 2 deletions web/platform/table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <StyledRoot tabIndex={0} role="region" aria-label={label}>
return <StyledRoot tabIndex={0} role="region" aria-label={label} $style={fill ? { flex: "1 1 0px", height: 0, minHeight: "12rem", overscrollBehavior: "contain" } : {}}>
<StyledTable aria-label={label}>
<StyledTableHead><StyledTableHeadRow>
{headers.map((header, index) => <StyledTableHeadCell key={index} scope="col">{header}</StyledTableHeadCell>)}
Expand Down
2 changes: 1 addition & 1 deletion web/service/shared/navigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export function useNextNavigation(): WebNavigation {

return useMemo(() => ({
Link: NextWebLink,
push: (href: string) => router.push(href),
push: (href: string, options?: Pick<WebLinkProps, "scroll">) => router.push(href, options),
refresh,
}), [refresh, router]);
}
Expand Down
9 changes: 6 additions & 3 deletions web/stovepipe/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,15 @@ Mount `RequestPagesProvider` above route changes with a `loadPage(href, signal)`
Request pages use `/<queue>/request/<request-id>` 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=<opaque-token>` 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=<opaque-token>` 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
Expand Down
13 changes: 9 additions & 4 deletions web/stovepipe/controller/projects.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -14,10 +14,11 @@ export async function loadProjectStatus(
queue: string,
requestId: string,
pageToken: string,
pageSize: number,
signal?: AbortSignal,
): Promise<ProjectStatusModel> {
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)
Expand All @@ -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,
};
}
13 changes: 11 additions & 2 deletions web/stovepipe/core/paths.ts
Original file line number Diff line number Diff line change
@@ -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 = "") {
Expand All @@ -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;
}
}
4 changes: 4 additions & 0 deletions web/stovepipe/entity/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
31 changes: 31 additions & 0 deletions web/stovepipe/module.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
5 changes: 3 additions & 2 deletions web/stovepipe/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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",
Expand Down
69 changes: 51 additions & 18 deletions web/stovepipe/view/app.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down Expand Up @@ -54,7 +58,7 @@ const model: StovepipePageModel = {
},
},
};
it("displays the backend summary above the retained history", () => {
it("displays request summary data", () => {
render(<StovepipeApp model={model} />);
const status = screen.getByRole("region", { name: "Request status" });
expect(within(status).getByText("completed")).toBeVisible();
Expand All @@ -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");
Expand Down Expand Up @@ -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" }))
Expand All @@ -129,7 +129,7 @@ it("discards previous results when the request changes", () => {

it("renders recorded project results, completion and shareable pagination", () => {
render(<StovepipeApp model={model} />);
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();
Expand All @@ -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(<StovepipeApp model={model} />);
const next = within(screen.getByRole("navigation", { name: "Project pages (top)" }))
.getByRole("link", { name: /Next projects/ });
next.focus();
rerender(<StovepipeApp model={{
...model,
key: "/repo%2Fmain/request/7?" + query,
projectStatus: { ok: true, data: {
...model.projectStatus.data,
projects: [{ project: "next-project", breakageDegree: 0 }],
} },
}} />);
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", () => {
Expand Down Expand Up @@ -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(<StovepipeApp model={{
...model, projectStatus: { ok: false, error: "Projects unavailable" },
...model, key: "/repo%2Fmain/request/7?view=history", projectStatus: { ok: false, error: "Projects unavailable" },
}} />);
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(<StovepipeApp model={{ ...model, key: "/repo%2Fmain/request/7?view=" + view }} />);
const navigation = screen.getByRole("navigation", { name: "Request views" });
expect(within(navigation).getByRole("link", { name: label })).toHaveAttribute("aria-current", "page");
const section = screen.getByRole("region", { name: label });
expect(section).toBeVisible();
if (view === "issues" || view === "fix-attempts") {
expect(within(section).getByText("Coming soon")).toBeVisible();
}
});
Loading
Loading