diff --git a/site/src/components/PaginationWidget/PaginationWidget.stories.tsx b/site/src/components/PaginationWidget/PaginationWidget.stories.tsx new file mode 100644 index 0000000000000..31ed9179da071 --- /dev/null +++ b/site/src/components/PaginationWidget/PaginationWidget.stories.tsx @@ -0,0 +1,65 @@ +import { action } from "@storybook/addon-actions" +import { Story } from "@storybook/react" +import { PaginationWidget, PaginationWidgetProps } from "./PaginationWidget" + +export default { + title: "components/PaginationWidget", + component: PaginationWidget, +} + +const Template: Story = (args: PaginationWidgetProps) => ( + +) + +const defaultProps = { + prevLabel: "Previous", + nextLabel: "Next", + onPrevClick: action("previous"), + onNextClick: action("next"), + onPageClick: action("clicked"), +} + +export const UnknownPageNumbers = Template.bind({}) +UnknownPageNumbers.args = { + ...defaultProps, +} + +export const LessThan8Pages = Template.bind({}) +LessThan8Pages.args = { + ...defaultProps, + numRecords: 84, + numRecordsPerPage: 12, + activePage: 1, +} + +export const MoreThan8Pages = Template.bind({}) +MoreThan8Pages.args = { + ...defaultProps, + numRecords: 200, + numRecordsPerPage: 12, + activePage: 1, +} + +export const MoreThan7PagesWithActivePageCloseToStart = Template.bind({}) +MoreThan7PagesWithActivePageCloseToStart.args = { + ...defaultProps, + numRecords: 200, + numRecordsPerPage: 12, + activePage: 2, +} + +export const MoreThan7PagesWithActivePageFarFromBoundaries = Template.bind({}) +MoreThan7PagesWithActivePageFarFromBoundaries.args = { + ...defaultProps, + numRecords: 200, + numRecordsPerPage: 12, + activePage: 4, +} + +export const MoreThan7PagesWithActivePageCloseToEnd = Template.bind({}) +MoreThan7PagesWithActivePageCloseToEnd.args = { + ...defaultProps, + numRecords: 200, + numRecordsPerPage: 12, + activePage: 17, +} diff --git a/site/src/components/PaginationWidget/PaginationWidget.test.tsx b/site/src/components/PaginationWidget/PaginationWidget.test.tsx new file mode 100644 index 0000000000000..7d9a89a70d504 --- /dev/null +++ b/site/src/components/PaginationWidget/PaginationWidget.test.tsx @@ -0,0 +1,57 @@ +import { screen } from "@testing-library/react" +import { render } from "../../testHelpers/renderHelpers" +import { PaginationWidget } from "./PaginationWidget" + +describe("PaginatedList", () => { + it("displays an accessible previous and next button regardless of the number of pages", async () => { + const { container } = render( + jest.fn()} + onNextClick={() => jest.fn()} + />, + ) + + expect(await screen.findByRole("button", { name: "Previous page" })).toBeTruthy() + expect(await screen.findByRole("button", { name: "Next page" })).toBeTruthy() + // Shouldn't render any pages if no records are passed in + expect(await container.querySelectorAll(`button[name="Page button"]`)).toHaveLength(0) + }) + + it("displays the expected number of pages with one ellipsis tile", async () => { + const { container } = render( + jest.fn()} + onNextClick={() => jest.fn()} + onPageClick={(_) => jest.fn()} + numRecords={200} + numRecordsPerPage={12} + activePage={1} + />, + ) + + // 7 total spaces. 6 are page numbers, one is ellipsis + expect(await container.querySelectorAll(`button[name="Page button"]`)).toHaveLength(6) + }) + + it("displays the expected number of pages with two ellipsis tiles", async () => { + const { container } = render( + jest.fn()} + onNextClick={() => jest.fn()} + onPageClick={(_) => jest.fn()} + numRecords={200} + numRecordsPerPage={12} + activePage={6} + />, + ) + + // 7 total spaces. 2 sets of ellipsis on either side of the active page + expect(await container.querySelectorAll(`button[name="Page button"]`)).toHaveLength(5) + }) +}) diff --git a/site/src/components/PaginationWidget/PaginationWidget.tsx b/site/src/components/PaginationWidget/PaginationWidget.tsx new file mode 100644 index 0000000000000..04ab82a3bd34b --- /dev/null +++ b/site/src/components/PaginationWidget/PaginationWidget.tsx @@ -0,0 +1,162 @@ +import Button from "@material-ui/core/Button" +import { makeStyles } from "@material-ui/core/styles" +import KeyboardArrowLeft from "@material-ui/icons/KeyboardArrowLeft" +import KeyboardArrowRight from "@material-ui/icons/KeyboardArrowRight" +import { CSSProperties } from "react" + +export type PaginationWidgetProps = { + prevLabel: string + nextLabel: string + onPrevClick: () => void + onNextClick: () => void + onPageClick?: (page: number) => void + numRecordsPerPage?: number + numRecords?: number + activePage?: number + containerStyle?: CSSProperties +} + +/** + * Generates a ranged array with an option to step over values. + * Shamelessly stolen from: + * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/from#sequence_generator_range + */ +const range = (start: number, stop: number, step = 1) => + Array.from({ length: (stop - start) / step + 1 }, (_, i) => start + i * step) + +const DEFAULT_RECORDS_PER_PAGE = 25 +// Number of pages to the left or right of the current page selection. +const PAGE_NEIGHBORS = 1 +// Number of pages displayed for cases where there are multiple ellipsis showing. This can be +// thought of as the minimum number of page numbers to display when multiple ellipsis are showing. +const PAGES_TO_DISPLAY = PAGE_NEIGHBORS * 2 + 3 +// Total page blocks(page numbers or ellipsis) displayed, including the maximum number of ellipsis (2). +// This gives us maximum number of 7 page blocks to be displayed when the page neighbors value is 1. +const NUM_PAGE_BLOCKS = PAGES_TO_DISPLAY + 2 + +/** + * Builds a list of pages based on how many pages exist and where the user is in their navigation of those pages. + * List result is used to from the buttons that make up the Pagination Widget + */ +export const buildPagedList = (numPages: number, activePage: number): (string | number)[] => { + if (numPages > NUM_PAGE_BLOCKS) { + let pages = [] + const leftBound = activePage - PAGE_NEIGHBORS + const rightBound = activePage + PAGE_NEIGHBORS + const beforeLastPage = numPages - 1 + const startPage = leftBound > 2 ? leftBound : 2 + const endPage = rightBound < beforeLastPage ? rightBound : beforeLastPage + + pages = range(startPage, endPage) + + const singleSpillOffset = PAGES_TO_DISPLAY - pages.length - 1 + const hasLeftOverflow = startPage > 2 + const hasRightOverflow = endPage < beforeLastPage + const leftOverflowPage = "left" + const rightOverflowPage = "right" + + if (hasLeftOverflow && !hasRightOverflow) { + const extraPages = range(startPage - singleSpillOffset, startPage - 1) + pages = [leftOverflowPage, ...extraPages, ...pages] + } else if (!hasLeftOverflow && hasRightOverflow) { + const extraPages = range(endPage + 1, endPage + singleSpillOffset) + pages = [...pages, ...extraPages, rightOverflowPage] + } else if (hasLeftOverflow && hasRightOverflow) { + pages = [leftOverflowPage, ...pages, rightOverflowPage] + } + + return [1, ...pages, numPages] + } + + return range(1, numPages) +} + +export const PaginationWidget = ({ + prevLabel, + nextLabel, + onPrevClick, + onNextClick, + onPageClick, + numRecords, + numRecordsPerPage = DEFAULT_RECORDS_PER_PAGE, + activePage = 1, + containerStyle, +}: PaginationWidgetProps): JSX.Element | null => { + const numPages = numRecords ? Math.ceil(numRecords / numRecordsPerPage) : 0 + const firstPageActive = activePage === 1 && numPages !== 0 + const lastPageActive = activePage === numPages && numPages !== 0 + + const styles = useStyles() + + // No need to display any pagination if we know the number of pages is 1 + if (numPages === 1) { + return null + } + + return ( +
+ + {numPages > 0 && + buildPagedList(numPages, activePage).map((page) => + typeof page !== "number" ? ( + + ) : ( + + ), + )} + +
+ ) +} + +const useStyles = makeStyles((theme) => ({ + defaultContainerStyles: { + justifyContent: "center", + alignItems: "center", + display: "flex", + flexDirection: "row", + padding: "20px", + }, + + prevLabelStyles: { + marginRight: `${theme.spacing(0.5)}px`, + }, + + pageButton: { + "&:not(:last-of-type)": { + marginRight: theme.spacing(0.5), + }, + }, + + activePageButton: { + borderColor: `${theme.palette.info.main}`, + backgroundColor: `${theme.palette.info.dark}`, + }, +})) diff --git a/site/src/components/PaginationWidget/buildPagedList.test.ts b/site/src/components/PaginationWidget/buildPagedList.test.ts new file mode 100644 index 0000000000000..b36f62a9d34cd --- /dev/null +++ b/site/src/components/PaginationWidget/buildPagedList.test.ts @@ -0,0 +1,14 @@ +import { buildPagedList } from "./PaginationWidget" + +describe("unit/PaginationWidget", () => { + describe("buildPagedList", () => { + it.each<{ numPages: number; activePage: number; expected: (string | number)[] }>([ + { numPages: 7, activePage: 1, expected: [1, 2, 3, 4, 5, 6, 7] }, + { numPages: 17, activePage: 1, expected: [1, 2, 3, 4, 5, "right", 17] }, + { numPages: 17, activePage: 9, expected: [1, "left", 8, 9, 10, "right", 17] }, + { numPages: 17, activePage: 17, expected: [1, "left", 13, 14, 15, 16, 17] }, + ])(`buildPagedList($numPages, $activePage)`, ({ numPages, activePage, expected }) => { + expect(buildPagedList(numPages, activePage)).toEqual(expected) + }) + }) +})