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; }