diff --git a/src/main/frontend/components/container.tsx b/src/main/frontend/components/container.tsx
index 535642f70..a659ce151 100644
--- a/src/main/frontend/components/container.tsx
+++ b/src/main/frontend/components/container.tsx
@@ -1,8 +1,8 @@
import { useJobs } from "../context/jobs-provider.tsx";
import { useUserPreferences } from "../context/user-preference-provider.tsx";
-import Cell from "./cell.tsx";
import Notice from "./notice.tsx";
import OptionsButton from "./options-button";
+import PagedGrid from "./paged-grid.tsx";
function Container() {
const { jobs, isLoading } = useJobs();
@@ -14,19 +14,11 @@ function Container() {
<>
{jobs.length === 0 && }
{jobs.length > 0 && (
-
- {jobs.map((job) => (
- |
- ))}
-
+
)}
>
)}
diff --git a/src/main/frontend/components/paged-grid.tsx b/src/main/frontend/components/paged-grid.tsx
new file mode 100644
index 000000000..6fe17250d
--- /dev/null
+++ b/src/main/frontend/components/paged-grid.tsx
@@ -0,0 +1,171 @@
+import { useEffect, useMemo, useRef, useState } from "react";
+
+import { Job } from "../models/job.ts";
+import {
+ getColumnCount,
+ getRowsPerPage,
+ paginateItems,
+} from "../utils/grid-pagination.ts";
+import Cell from "./cell.tsx";
+
+const getInitialViewportHeight = () =>
+ typeof window === "undefined" ? 0 : window.innerHeight;
+const getInitialViewportWidth = () =>
+ typeof window === "undefined" ? 0 : window.innerWidth;
+
+interface PagedGridProps {
+ jobs: Job[];
+ textSize: number;
+ maximumNumberOfColumns: number;
+}
+
+function PagedGrid({ jobs, textSize, maximumNumberOfColumns }: PagedGridProps) {
+ const viewportRef = useRef(null);
+ const [viewportHeight, setViewportHeight] = useState(
+ getInitialViewportHeight,
+ );
+ const [viewportWidth, setViewportWidth] = useState(getInitialViewportWidth);
+ const [currentPage, setCurrentPage] = useState(0);
+
+ const columnCount = getColumnCount(jobs.length, maximumNumberOfColumns);
+ const rowsPerPage = getRowsPerPage({ viewportHeight, textSize });
+ const pages = useMemo(
+ () => paginateItems(jobs, columnCount, rowsPerPage),
+ [jobs, columnCount, rowsPerPage],
+ );
+
+ const scrollToPage = (pageIndex: number, behavior: ScrollBehavior = "smooth") => {
+ const viewport = viewportRef.current;
+ if (!viewport) {
+ return;
+ }
+
+ viewport.scrollTo({
+ left: pageIndex * viewport.clientWidth,
+ behavior,
+ });
+ };
+
+ useEffect(() => {
+ const viewport = viewportRef.current;
+ if (!viewport) {
+ return;
+ }
+
+ const updateViewportSize = () => {
+ setViewportHeight(viewport.clientHeight);
+ setViewportWidth(viewport.clientWidth);
+ };
+
+ updateViewportSize();
+
+ if (typeof ResizeObserver === "undefined") {
+ window.addEventListener("resize", updateViewportSize);
+ return () => window.removeEventListener("resize", updateViewportSize);
+ }
+
+ const observer = new ResizeObserver(updateViewportSize);
+ observer.observe(viewport);
+
+ return () => observer.disconnect();
+ }, []);
+
+ useEffect(() => {
+ setCurrentPage((page) => Math.min(page, Math.max(pages.length - 1, 0)));
+ }, [pages.length]);
+
+ useEffect(() => {
+ if (viewportWidth <= 0) {
+ return;
+ }
+
+ scrollToPage(currentPage, "auto");
+ }, [viewportWidth]);
+
+ useEffect(() => {
+ const lastPageIndex = Math.max(pages.length - 1, 0);
+ if (currentPage <= lastPageIndex) {
+ return;
+ }
+
+ setCurrentPage(lastPageIndex);
+ scrollToPage(lastPageIndex, "auto");
+ }, [currentPage, pages.length]);
+
+ const handleScroll = () => {
+ const viewport = viewportRef.current;
+ if (!viewport) {
+ return;
+ }
+
+ const nextPage = Math.max(
+ 0,
+ Math.min(
+ pages.length - 1,
+ Math.round(viewport.scrollLeft / Math.max(viewport.clientWidth, 1)),
+ ),
+ );
+
+ if (nextPage !== currentPage) {
+ setCurrentPage(nextPage);
+ }
+ };
+
+ return (
+
+
+
+ {pages.map((pageJobs, pageIndex) => (
+
+
+ {pageJobs.map((job) => (
+ |
+ ))}
+
+
+ ))}
+
+
+
+ {pages.length > 1 && (
+
+ {pages.map((_, pageIndex) => (
+
+ )}
+
+ );
+}
+
+export default PagedGrid;
diff --git a/src/main/frontend/styles/_grid.scss b/src/main/frontend/styles/_grid.scss
index bc399dd3c..aa05ad0e6 100644
--- a/src/main/frontend/styles/_grid.scss
+++ b/src/main/frontend/styles/_grid.scss
@@ -14,9 +14,8 @@
padding-bottom: calc(var(--section-padding) / 2);
// Force all rows to have equal height
grid-auto-rows: 1fr;
- // Fill the height of the parent container
- flex: 1 1 auto;
- max-height: 100vh;
+ min-height: 0;
+ height: 100%;
@media screen and (prefers-reduced-motion: no-preference) {
& > * {
@@ -32,3 +31,71 @@
scale: 95%;
}
}
+
+.bm-grid-shell {
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ min-height: 0;
+}
+
+.bm-grid-viewport {
+ flex: 1 1 auto;
+ min-height: 0;
+ overflow-x: auto;
+ overflow-y: hidden;
+ overscroll-behavior-x: contain;
+ -webkit-overflow-scrolling: touch;
+ scroll-snap-type: x mandatory;
+ scrollbar-width: none;
+ touch-action: pan-x pinch-zoom;
+
+ &::-webkit-scrollbar {
+ display: none;
+ }
+
+ @media screen and (prefers-reduced-motion: no-preference) {
+ scroll-behavior: smooth;
+ }
+}
+
+.bm-grid-track {
+ display: flex;
+ gap: calc(var(--section-padding) / 2);
+ height: 100%;
+}
+
+.bm-grid-page {
+ flex: 0 0 100%;
+ min-width: 100%;
+ scroll-snap-align: start;
+}
+
+.bm-grid-pagination {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.25rem;
+ padding-bottom: calc(var(--section-padding) / 2);
+}
+
+.bm-grid-pagination__dot {
+ width: 0.75rem;
+ height: 0.75rem;
+ border-radius: 999px;
+ border: none;
+ background: none;
+ box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--text-color-secondary), transparent);
+ cursor: pointer;
+ transition: var(--standard-transition);
+ scale: 75%;
+
+ &:hover,
+ &:focus {
+ box-shadow: inset 0 0 0 0.75rem color-mix(in srgb, var(--text-color-secondary) 75%, transparent);
+ }
+
+ &--active {
+ box-shadow: inset 0 0 0 0.75rem var(--text-color-secondary);
+ }
+}
diff --git a/src/main/frontend/utils/grid-pagination.ts b/src/main/frontend/utils/grid-pagination.ts
new file mode 100644
index 000000000..735175589
--- /dev/null
+++ b/src/main/frontend/utils/grid-pagination.ts
@@ -0,0 +1,65 @@
+const BASE_CELL_HEIGHT_PX = 208;
+const ROW_GAP_ALLOWANCE_PX = 16;
+const MAXIMUM_ROWS_PER_PAGE = 4;
+const MINIMUM_CELL_HEIGHT_PX = 160;
+
+interface RowsPerPageOptions {
+ viewportHeight: number;
+ textSize: number;
+}
+
+export function getColumnCount(
+ jobCount: number,
+ maximumNumberOfColumns: number,
+) {
+ if (jobCount <= 0) {
+ return 0;
+ }
+
+ const safeMaximumColumns = Number.isFinite(maximumNumberOfColumns)
+ ? Math.max(1, Math.floor(maximumNumberOfColumns))
+ : 1;
+
+ return Math.min(jobCount, safeMaximumColumns);
+}
+
+export function getRowsPerPage({
+ viewportHeight,
+ textSize,
+}: RowsPerPageOptions) {
+ if (viewportHeight <= 0) {
+ return 1;
+ }
+
+ const safeTextSize = Number.isFinite(textSize) ? Math.max(0.1, textSize) : 1;
+ const minimumCellHeight = Math.max(
+ MINIMUM_CELL_HEIGHT_PX,
+ Math.round(BASE_CELL_HEIGHT_PX * safeTextSize),
+ );
+
+ return Math.max(
+ 1,
+ Math.min(
+ MAXIMUM_ROWS_PER_PAGE,
+ Math.floor(
+ (viewportHeight + ROW_GAP_ALLOWANCE_PX) /
+ (minimumCellHeight + ROW_GAP_ALLOWANCE_PX),
+ ),
+ ),
+ );
+}
+
+export function paginateItems(
+ items: T[],
+ columns: number,
+ rowsPerPage: number,
+) {
+ const pageSize = Math.max(1, columns * rowsPerPage);
+ const pages: T[][] = [];
+
+ for (let index = 0; index < items.length; index += pageSize) {
+ pages.push(items.slice(index, index + pageSize));
+ }
+
+ return pages;
+}
diff --git a/src/test/java/com/smartcodeltd/jenkinsci/plugins/buildmonitor/e2e/ShouldSnapBetweenPagesTest.java b/src/test/java/com/smartcodeltd/jenkinsci/plugins/buildmonitor/e2e/ShouldSnapBetweenPagesTest.java
new file mode 100644
index 000000000..8c7b6a141
--- /dev/null
+++ b/src/test/java/com/smartcodeltd/jenkinsci/plugins/buildmonitor/e2e/ShouldSnapBetweenPagesTest.java
@@ -0,0 +1,42 @@
+package com.smartcodeltd.jenkinsci.plugins.buildmonitor.e2e;
+
+import static com.smartcodeltd.jenkinsci.plugins.buildmonitor.e2e.utils.BuildMonitorViewUtils.createBuildMonitorView;
+import static com.smartcodeltd.jenkinsci.plugins.buildmonitor.e2e.utils.FreeStyleProjectUtils.createFreeStyleProject;
+
+import com.microsoft.playwright.Page;
+import com.microsoft.playwright.junit.UsePlaywright;
+import com.smartcodeltd.jenkinsci.plugins.buildmonitor.e2e.config.PlaywrightConfig;
+import com.smartcodeltd.jenkinsci.plugins.buildmonitor.e2e.pages.BuildMonitorViewPage;
+import hudson.model.FreeStyleProject;
+import hudson.model.Result;
+import java.util.stream.IntStream;
+import org.junit.jupiter.api.Test;
+import org.jvnet.hudson.test.JenkinsRule;
+import org.jvnet.hudson.test.junit.jupiter.WithJenkins;
+
+@WithJenkins
+@UsePlaywright(PlaywrightConfig.class)
+class ShouldSnapBetweenPagesTest {
+
+ @Test
+ void snapsToTheSecondPageWhenTheViewportIsScrolledPastTheBoundary(Page p, JenkinsRule j) {
+ FreeStyleProject[] projects = IntStream.rangeClosed(1, 5)
+ .mapToObj(index -> createFreeStyleProject(j, "Job " + index)
+ .run(Result.SUCCESS)
+ .getProject())
+ .toArray(FreeStyleProject[]::new);
+
+ var view = createBuildMonitorView(j, "Build Monitor")
+ .addJobs(projects)
+ .withMaximumColumns(2)
+ .withTextScale(2.0);
+
+ BuildMonitorViewPage.from(p, view)
+ .goTo()
+ .hasJobsCount(5)
+ .hasPageCount(3)
+ .scrollPastPageBoundaryTowards(2)
+ .hasSnappedToPage(2)
+ .hasActivePage(2);
+ }
+}
diff --git a/src/test/java/com/smartcodeltd/jenkinsci/plugins/buildmonitor/e2e/pages/BuildMonitorViewPage.java b/src/test/java/com/smartcodeltd/jenkinsci/plugins/buildmonitor/e2e/pages/BuildMonitorViewPage.java
index 5d8c0fb5b..ae24d5f4b 100644
--- a/src/test/java/com/smartcodeltd/jenkinsci/plugins/buildmonitor/e2e/pages/BuildMonitorViewPage.java
+++ b/src/test/java/com/smartcodeltd/jenkinsci/plugins/buildmonitor/e2e/pages/BuildMonitorViewPage.java
@@ -6,6 +6,7 @@
import com.microsoft.playwright.Page;
import com.microsoft.playwright.options.AriaRole;
import com.smartcodeltd.jenkinsci.plugins.buildmonitor.e2e.utils.BuildMonitorViewUtils;
+import java.util.List;
public class BuildMonitorViewPage extends JenkinsPage {
@@ -20,8 +21,8 @@ public static BuildMonitorViewPage from(Page page, BuildMonitorViewUtils.FluentB
@Override
BuildMonitorViewPage waitForLoaded() {
super.waitForLoaded();
- Locator grid = page.locator(".bm-grid");
- assertThat(grid).isVisible();
+ Locator content = page.locator(".bm-grid-viewport, .jenkins-notice").first();
+ assertThat(content).isVisible();
return this;
}
@@ -51,4 +52,37 @@ public BuildMonitorViewPage hasNoJobs() {
assertThat(link).isVisible();
return this;
}
+
+ public BuildMonitorViewPage hasPageCount(int pageCount) {
+ Locator pages = page.locator(".bm-grid-page");
+ assertThat(pages).hasCount(pageCount);
+ return this;
+ }
+
+ public BuildMonitorViewPage scrollPastPageBoundaryTowards(int pageNumber) {
+ int pageIndex = pageNumber - 1;
+ Locator viewport = page.locator(".bm-grid-viewport");
+ viewport.evaluate(
+ "(element, pageIndex) => element.scrollTo({ left: element.clientWidth * pageIndex + 120 })", pageIndex);
+ return this;
+ }
+
+ public BuildMonitorViewPage hasSnappedToPage(int pageNumber) {
+ int pageIndex = pageNumber - 1;
+
+ page.waitForFunction(
+ "([selector, pageIndex]) => {"
+ + " const element = document.querySelector(selector);"
+ + " return !!element && Math.abs(element.scrollLeft - (element.clientWidth * pageIndex)) < 4;"
+ + "}",
+ List.of(".bm-grid-viewport", pageIndex));
+
+ return this;
+ }
+
+ public BuildMonitorViewPage hasActivePage(int pageNumber) {
+ Locator dot = page.locator(".bm-grid-pagination__dot").nth(pageNumber - 1);
+ assertThat(dot).hasAttribute("aria-current", "page");
+ return this;
+ }
}
diff --git a/src/test/java/com/smartcodeltd/jenkinsci/plugins/buildmonitor/e2e/utils/BuildMonitorViewUtils.java b/src/test/java/com/smartcodeltd/jenkinsci/plugins/buildmonitor/e2e/utils/BuildMonitorViewUtils.java
index b994650d0..4dba89123 100644
--- a/src/test/java/com/smartcodeltd/jenkinsci/plugins/buildmonitor/e2e/utils/BuildMonitorViewUtils.java
+++ b/src/test/java/com/smartcodeltd/jenkinsci/plugins/buildmonitor/e2e/utils/BuildMonitorViewUtils.java
@@ -46,6 +46,16 @@ public FluentBuildMonitorView displayNestedProjectsFromSubfolders() {
return this;
}
+ public FluentBuildMonitorView withMaximumColumns(int maxColumns) {
+ view.getConfig().setMaxColumns(maxColumns);
+ return this;
+ }
+
+ public FluentBuildMonitorView withTextScale(double textScale) {
+ view.getConfig().setTextScale(textScale);
+ return this;
+ }
+
public BuildMonitorView get() {
return view;
}