From 640aa9b9c70322bbb68658b5c3a33af1dbf2807a Mon Sep 17 00:00:00 2001 From: Kira Pilot Date: Thu, 28 Jul 2022 19:25:57 +0000 Subject: [PATCH 1/5] proof of concept --- .../PaginationWidget/PaginationWidget.tsx | 186 ++++++++++++++++++ .../WorkspacesPage/WorkspacesPageView.tsx | 19 +- 2 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 site/src/components/PaginationWidget/PaginationWidget.tsx diff --git a/site/src/components/PaginationWidget/PaginationWidget.tsx b/site/src/components/PaginationWidget/PaginationWidget.tsx new file mode 100644 index 0000000000000..66a31111f78c8 --- /dev/null +++ b/site/src/components/PaginationWidget/PaginationWidget.tsx @@ -0,0 +1,186 @@ +import Button from "@material-ui/core/Button" +import { makeStyles } from "@material-ui/core/styles" +import { Theme } from "@material-ui/core/styles/createMuiTheme" +import KeyboardArrowLeft from "@material-ui/icons/KeyboardArrowLeft" +import KeyboardArrowRight from "@material-ui/icons/KeyboardArrowRight" +import { CSSProperties } from "react" + +type PaginatedWidgetProps = { + 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 + +export const PaginationWidget = ({ + prevLabel, + nextLabel, + onPrevClick, + onNextClick, + onPageClick, + numRecords, + numRecordsPerPage = DEFAULT_RECORDS_PER_PAGE, + activePage = 1, + containerStyle, +}: PaginatedWidgetProps): 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({ isActive: true }) + + console.log("activePage", activePage) + + console.log("firstPageActive", firstPageActive, lastPageActive) + + /** + * A paged list will be constructed and can appear as some permutation of the following: + * [1, 2, 3, 4, 5] + * [1, 2, 3, 4, 5, 'right'] + * ['left', 6, 7, 8, 9] + * [1, 2, 'left', 6, 7, 8, 'right', 18] + */ + const buildPagedList = () => { + 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) + } + + // No need to display any pagination if we know the number of pages is 1 + if (numPages === 1) { + return null + } + + return ( +
+ + {numPages > 0 && + buildPagedList().map((page) => + typeof page !== "number" ? ( + + ) : ( + + ), + )} + +
+ ) +} + +type StyleProps = { + isActive?: boolean +} + +const useStyles = makeStyles((theme) => ({ + defaultContainerStyles: { + justifyContent: "center", + alignItems: "center", + display: "flex", + flexDirection: "row", + padding: "20px", + }, + + // previousLabelText: { + // color: ({ firstPageActive }) => { + // return firstPageActive ? theme.palette.secondary.contrastText : theme.palette.text.primary + // }, + // }, + // + // nextLabelText: { + // color: ({ lastPageActive }) => { + // return lastPageActive ? theme.palette.secondary.contrastText : theme.palette.text.primary + // }, + // }, + + pageButton: { + height: `${theme.spacing(3)}`, + width: `${theme.spacing(3)}`, + + "&:not(:last-of-type)": { + marginRight: theme.spacing(0.5), + }, + }, + + activePageButton: { + borderColor: `${theme.palette.success.main}`, + backgroundColor: `${theme.palette.success.dark}`, + }, +})) diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx index 05547cf582989..1b8240b83d382 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx @@ -1,5 +1,6 @@ import Link from "@material-ui/core/Link" -import { FC } from "react" +import { PaginationWidget } from "components/PaginationWidget/PaginationWidget" +import { FC, useState } from "react" import { Link as RouterLink } from "react-router-dom" import { Margins } from "../../components/Margins/Margins" import { @@ -40,6 +41,22 @@ export const WorkspacesPageView: FC = ({ { query: workspaceFilterQuery.all, name: Language.allWorkspacesButton }, ] + const [activePage, setActivePage] = useState(1) + + return ( +
+ setActivePage(activePage - 1)} + onNextClick={() => setActivePage(activePage + 1)} + numRecordsPerPage={15} + numRecords={400} + activePage={activePage} + /> +
+ ) + return ( From 9c4dcb80ebd5eb778ae10b967fa759805fec3519 Mon Sep 17 00:00:00 2001 From: Kira Pilot Date: Fri, 29 Jul 2022 16:05:53 +0000 Subject: [PATCH 2/5] added tests --- .../PaginationWidget.stories.tsx | 65 +++++++++++++++++++ .../PaginationWidget.test.tsx | 62 ++++++++++++++++++ .../PaginationWidget/PaginationWidget.tsx | 60 +++++------------ .../WorkspacesPage/WorkspacesPageView.tsx | 19 +----- 4 files changed, 144 insertions(+), 62 deletions(-) create mode 100644 site/src/components/PaginationWidget/PaginationWidget.stories.tsx create mode 100644 site/src/components/PaginationWidget/PaginationWidget.test.tsx 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..cb98fabb54286 --- /dev/null +++ b/site/src/components/PaginationWidget/PaginationWidget.test.tsx @@ -0,0 +1,62 @@ +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 () => { + render( + alert("Previous click")} + onNextClick={() => alert("Next click")} + />, + ) + + expect(await screen.findByRole("button", { name: "Previous page" })).toBeTruthy() + // expect(screen.findByText('[aria-label="Previous page"]')).toBeTruthy() + expect(await screen.findByRole("button", { name: "Next page" })).toBeTruthy() + + // expect(screen.findByText('[aria-label="Next page"]')).toBeTruthy() + // Shouldn't render any pages if no records are passed in + expect(await screen.findByRole("button", { name: "Page button" })).toBeUndefined() + + // expect(screen.findByText('[name="Page button"]')).toHaveLength(0) + }) + + it("displays the expected number of pages", async () => { + render( + alert("Previous click")} + onNextClick={() => alert("Next click")} + onPageClick={(page) => alert(`Page ${page} clicked`)} + numRecords={200} + numRecordsPerPage={12} + activePage={1} + />, + ) + + // 7 total spaces. 6 are page numbers, one is ellipsis + // expect(screen.findByText('[name="Page button"]')).toHaveLength(6) + expect(await screen.findByRole("button", { name: "Page button" })).toHaveLength(6) + + render( + alert("Previous click")} + onNextClick={() => alert("Next click")} + onPageClick={(page) => alert(`Page ${page} clicked`)} + numRecords={200} + numRecordsPerPage={12} + activePage={6} + />, + ) + + // 7 total spaces. 2 sets of ellipsis on either side of the active page + // expect(screen.findByText('[name="Page button"]')).toHaveLength(5) + expect(await screen.findByRole("button", { name: "Page button" })).toHaveLength(5) + }) +}) diff --git a/site/src/components/PaginationWidget/PaginationWidget.tsx b/site/src/components/PaginationWidget/PaginationWidget.tsx index 66a31111f78c8..afd1893957392 100644 --- a/site/src/components/PaginationWidget/PaginationWidget.tsx +++ b/site/src/components/PaginationWidget/PaginationWidget.tsx @@ -1,11 +1,10 @@ import Button from "@material-ui/core/Button" import { makeStyles } from "@material-ui/core/styles" -import { Theme } from "@material-ui/core/styles/createMuiTheme" import KeyboardArrowLeft from "@material-ui/icons/KeyboardArrowLeft" import KeyboardArrowRight from "@material-ui/icons/KeyboardArrowRight" import { CSSProperties } from "react" -type PaginatedWidgetProps = { +export type PaginationWidgetProps = { prevLabel: string nextLabel: string onPrevClick: () => void @@ -45,24 +44,13 @@ export const PaginationWidget = ({ numRecordsPerPage = DEFAULT_RECORDS_PER_PAGE, activePage = 1, containerStyle, -}: PaginatedWidgetProps): JSX.Element | null => { +}: 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({ isActive: true }) + const styles = useStyles() - console.log("activePage", activePage) - - console.log("firstPageActive", firstPageActive, lastPageActive) - - /** - * A paged list will be constructed and can appear as some permutation of the following: - * [1, 2, 3, 4, 5] - * [1, 2, 3, 4, 5, 'right'] - * ['left', 6, 7, 8, 9] - * [1, 2, 'left', 6, 7, 8, 'right', 18] - */ const buildPagedList = () => { if (numPages > NUM_PAGE_BLOCKS) { let pages = [] @@ -103,24 +91,23 @@ export const PaginationWidget = ({ return (
- {numPages > 0 && buildPagedList().map((page) => typeof page !== "number" ? ( - ) : ( {numPages > 0 && - buildPagedList().map((page) => + buildPagedList(numPages, activePage).map((page) => typeof page !== "number" ? (