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
20 changes: 6 additions & 14 deletions src/main/frontend/components/container.tsx
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -14,19 +14,11 @@ function Container() {
<>
{jobs.length === 0 && <Notice />}
{jobs.length > 0 && (
<div
className="bm-grid"
style={{
fontSize: textSize + "rem",
gridTemplateColumns: "1fr ".repeat(
Math.min(jobs.length, maximumNumberOfColumns),
),
}}
>
{jobs.map((job) => (
<Cell key={job.url} job={job} />
))}
</div>
<PagedGrid
jobs={jobs}
textSize={textSize}
maximumNumberOfColumns={maximumNumberOfColumns}
/>
)}
</>
)}
Expand Down
171 changes: 171 additions & 0 deletions src/main/frontend/components/paged-grid.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(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 (
<div className="bm-grid-shell">
<div
ref={viewportRef}
className="bm-grid-viewport"
onScroll={handleScroll}
>
<div className="bm-grid-track">
{pages.map((pageJobs, pageIndex) => (
<section
key={`page-${pageIndex}`}
className="bm-grid-page"
aria-label={`Page ${pageIndex + 1} of ${pages.length}`}
>
<div
className="bm-grid"
style={{
fontSize: textSize + "rem",
gridTemplateColumns: "1fr ".repeat(columnCount),
gridTemplateRows: "minmax(0, 1fr) ".repeat(rowsPerPage),
}}
>
{pageJobs.map((job) => (
<Cell key={job.url} job={job} />
))}
</div>
</section>
))}
</div>
</div>

{pages.length > 1 && (
<div className="bm-grid-pagination" aria-label="Build monitor pages">
{pages.map((_, pageIndex) => (
<button
key={`page-dot-${pageIndex}`}
type="button"
className={
"bm-grid-pagination__dot" +
(currentPage === pageIndex ? " bm-grid-pagination__dot--active" : "")
}
aria-label={`Go to page ${pageIndex + 1}`}
aria-current={
currentPage === pageIndex ? ("page" as const) : undefined
}
onClick={() => {
setCurrentPage(pageIndex);
scrollToPage(pageIndex);
}}
/>
))}
</div>
)}
</div>
);
}

export default PagedGrid;
73 changes: 70 additions & 3 deletions src/main/frontend/styles/_grid.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
& > * {
Expand All @@ -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);
}
}
65 changes: 65 additions & 0 deletions src/main/frontend/utils/grid-pagination.ts
Original file line number Diff line number Diff line change
@@ -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<T>(
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;
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading