Skip to content

Commit

Permalink
fix: machine list pagination (#5029) 3.3 backport (#5031)
Browse files Browse the repository at this point in the history
- create a local copy of Pagination from react-components
- allow custom totalPages for Pagination
  • Loading branch information
petermakowski authored Jun 30, 2023
1 parent 03a8280 commit 7c3f981
Show file tree
Hide file tree
Showing 21 changed files with 672 additions and 23 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const MachineSelectBox = ({
const [searchText, setSearchText] = useState("");
const [debouncedText, setDebouncedText] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const { machines, machineCount, loading } = useFetchMachines({
const { machines, machineCount, loading, totalPages } = useFetchMachines({
filters: {
[FilterGroupKey.FreeText]: debouncedText,
...(filters ? filters : {}),
Expand Down Expand Up @@ -58,6 +58,7 @@ const MachineSelectBox = ({
machineCount={machineCount}
machinesLoading={loading}
paginate={setCurrentPage}
totalPages={totalPages}
/>
</div>
</div>
Expand Down
147 changes: 147 additions & 0 deletions src/app/base/components/Pagination/Pagination.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

import Pagination from "./Pagination";
import { Label as PaginationButtonLabel } from "./PaginationButton/PaginationButton";

describe("<Pagination />", () => {
// snapshot tests
it("renders and matches the snapshot", () => {
render(
<Pagination
currentPage={1}
itemsPerPage={10}
paginate={jest.fn()}
totalItems={50}
/>
);

expect(screen.getByRole("navigation")).toMatchSnapshot();
});

// unit tests
it("renders no pagination with only a single page", () => {
render(
<Pagination
currentPage={1}
itemsPerPage={10}
paginate={jest.fn()}
totalItems={5}
/>
);

expect(screen.queryByRole("navigation")).not.toBeInTheDocument();
});

it("renders a simple paginator with back and forward arrows if only five pages or less", () => {
render(
<Pagination
currentPage={1}
itemsPerPage={10}
paginate={jest.fn()}
totalItems={50}
/>
);

expect(
screen.queryByRole("listitem", { name: "…" })
).not.toBeInTheDocument();
expect(
screen.getByRole("button", { name: PaginationButtonLabel.Next })
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: PaginationButtonLabel.Previous })
).toBeInTheDocument();
expect(screen.getByRole("button", { name: "1" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "2" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "3" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "4" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "5" })).toBeInTheDocument();
});

it("renders a complex paginator with truncation if more than five pages", () => {
render(
<Pagination
currentPage={5}
itemsPerPage={10}
paginate={jest.fn()}
totalItems={1000}
/>
);

expect(screen.getAllByText("…")).toHaveLength(2);
expect(
screen.getByRole("button", { name: PaginationButtonLabel.Next })
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: PaginationButtonLabel.Previous })
).toBeInTheDocument();
expect(screen.getByRole("button", { name: "1" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "4" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "5" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "6" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "100" })).toBeInTheDocument();
});

it("does not render a truncation separator if currentPage is contiguous at start", () => {
render(
<Pagination
currentPage={2}
itemsPerPage={10}
paginate={jest.fn()}
totalItems={1000}
/>
);

// There should only be one ellipsis.
expect(screen.getAllByText("…")).toHaveLength(1);
});

it("does not render a truncation separator if currentPage is contiguous at end", () => {
render(
<Pagination
currentPage={98}
itemsPerPage={10}
paginate={jest.fn()}
totalItems={1000}
/>
);

// There should only be one ellipsis.
expect(screen.getAllByText("…")).toHaveLength(1);
});

it("does not trigger form submission on pagination button click by default", async () => {
const handleOnSubmit = jest.fn();
render(
<form onSubmit={handleOnSubmit}>
<Pagination
currentPage={98}
itemsPerPage={10}
paginate={jest.fn()}
totalItems={1000}
/>
</form>
);

await userEvent.click(screen.getByRole("button", { name: "Next page" }));
await userEvent.click(screen.getByRole("button", { name: "99" }));
expect(handleOnSubmit).not.toHaveBeenCalled();
});

it("can be centered", () => {
render(
<Pagination
centered
currentPage={98}
itemsPerPage={10}
paginate={jest.fn()}
totalItems={1000}
/>
);
// eslint-disable-next-line testing-library/no-node-access
expect(document.querySelector(".p-pagination__items")).toHaveClass(
"u-align--center"
);
});
});
199 changes: 199 additions & 0 deletions src/app/base/components/Pagination/Pagination.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
/* eslint-disable react/no-multi-comp */
import type { HTMLProps } from "react";

import type { PropsWithSpread } from "@canonical/react-components";
import classNames from "classnames";

import PaginationButton from "./PaginationButton";
import PaginationItem from "./PaginationItem";

const scrollTop = () => window.scrollTo(0, 0);

const generatePaginationItems = (
pageNumbers: number[],
currentPage: number,
truncateThreshold: number,
changePage: (page: number) => void
) => {
const lastPage = pageNumbers.length;
const truncated = lastPage > truncateThreshold;

let visiblePages;
if (truncated) {
// the default range for pages outside the start and end threshold
let start = currentPage - 2;
let end = currentPage + 1;
// on page 1, also show pages 2, 3 and 4
if (currentPage === 1) {
start = 1;
end = currentPage + 3;
}
// on page 2, show page 1, and also pages 3, and 4
if (currentPage === 2) {
start = 1;
end = currentPage + 2;
}
// on the last page and page before last, also show the 3 previous pages
if (currentPage === lastPage || currentPage === lastPage - 1) {
start = lastPage - 4;
end = lastPage - 1;
}
visiblePages = pageNumbers.slice(start, end);
} else {
visiblePages = pageNumbers;
}

const items = [];
if (truncated) {
// render first in sequence
items.push(
<PaginationItem
isActive={currentPage === 1}
key={1}
number={1}
onClick={() => changePage(1)}
/>
);
if (![1, 2, 3].includes(currentPage)) {
items.push(<PaginationItemSeparator key="sep1" />);
}
}

items.push(
visiblePages.map((number) => (
<PaginationItem
isActive={number === currentPage}
key={number}
number={number}
onClick={() => changePage(number)}
/>
))
);

if (truncated) {
// render last in sequence
if (![lastPage, lastPage - 1, lastPage - 2].includes(currentPage)) {
items.push(<PaginationItemSeparator key="sep2" />);
}
items.push(
<PaginationItem
isActive={currentPage === lastPage}
key={lastPage}
number={lastPage}
onClick={() => changePage(lastPage)}
/>
);
}
return items;
};

const PaginationItemSeparator = (): JSX.Element => (
<li className="p-pagination__item p-pagination__item--truncation">
&hellip;
</li>
);

export type Props = PropsWithSpread<
{
/**
* The current page being viewed.
*/
currentPage: number;
/**
* The number of items to show per page.
*/
itemsPerPage: number;
/**
* The total number of pages.
*/
totalPages?: number;
/**
* Function to handle paginating the items.
*/
paginate: (page: number) => void;
/**
* The total number of items.
*/
totalItems: number;
/**
* Whether to scroll to the top of the list on page change.
*/
scrollToTop?: boolean;
/**
* The number of pages at which to truncate the pagination items.
*/
truncateThreshold?: number;
/**
* Whether or not the pagination is ceneterd on the page.
*/
centered?: boolean;
},
HTMLProps<HTMLElement>
>;

const Pagination = ({
itemsPerPage,
totalItems,
paginate,
currentPage,
scrollToTop,
truncateThreshold = 10,
centered,
totalPages,
...navProps
}: Props): JSX.Element | null => {
// return early if no pagination is required
if (totalItems <= itemsPerPage) {
return null;
}

const pageNumbers = [];

if (totalPages) {
for (let i = 1; i <= totalPages; i++) {
pageNumbers.push(i);
}
} else {
for (let i = 1; i <= Math.ceil(totalItems / itemsPerPage); i++) {
pageNumbers.push(i);
}
}

const changePage = (page: number) => {
paginate(page);
scrollToTop && scrollTop();
};

return (
<nav aria-label="Pagination" className="p-pagination" {...navProps}>
<ol
className={classNames("p-pagination__items", {
"u-align--center": centered,
})}
>
<PaginationButton
direction="back"
disabled={currentPage === 1}
key="back"
onClick={() => changePage(currentPage - 1)}
/>

{generatePaginationItems(
pageNumbers,
currentPage,
truncateThreshold,
changePage
)}

<PaginationButton
direction="forward"
disabled={currentPage === pageNumbers.length}
key="forward"
onClick={() => changePage(currentPage + 1)}
/>
</ol>
</nav>
);
};

export default Pagination;
Loading

0 comments on commit 7c3f981

Please sign in to comment.