mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat: add pagination component to components directory (#3295)
* proof of concept * added tests * fixed tests * wrote unit tests * preettier
This commit is contained in:
@@ -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<PaginationWidgetProps> = (args: PaginationWidgetProps) => (
|
||||
<PaginationWidget {...args} />
|
||||
)
|
||||
|
||||
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,
|
||||
}
|
||||
@@ -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(
|
||||
<PaginationWidget
|
||||
prevLabel="Previous"
|
||||
nextLabel="Next"
|
||||
onPrevClick={() => 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(
|
||||
<PaginationWidget
|
||||
prevLabel="Previous"
|
||||
nextLabel="Next"
|
||||
onPrevClick={() => 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(
|
||||
<PaginationWidget
|
||||
prevLabel="Previous"
|
||||
nextLabel="Next"
|
||||
onPrevClick={() => 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)
|
||||
})
|
||||
})
|
||||
@@ -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 (
|
||||
<div style={containerStyle} className={styles.defaultContainerStyles}>
|
||||
<Button
|
||||
className={styles.prevLabelStyles}
|
||||
aria-label="Previous page"
|
||||
disabled={firstPageActive}
|
||||
onClick={onPrevClick}
|
||||
>
|
||||
<KeyboardArrowLeft />
|
||||
<div>{prevLabel}</div>
|
||||
</Button>
|
||||
{numPages > 0 &&
|
||||
buildPagedList(numPages, activePage).map((page) =>
|
||||
typeof page !== "number" ? (
|
||||
<Button className={styles.pageButton} key={`Page${page}`} disabled>
|
||||
<div>...</div>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className={
|
||||
activePage === page
|
||||
? `${styles.pageButton} ${styles.activePageButton}`
|
||||
: styles.pageButton
|
||||
}
|
||||
aria-label={`${page === activePage ? "Current Page" : ""} ${
|
||||
page === numPages ? "Last Page" : ""
|
||||
} Page${page}`}
|
||||
name="Page button"
|
||||
key={`Page${page}`}
|
||||
onClick={() => onPageClick && onPageClick(page)}
|
||||
>
|
||||
<div>{page}</div>
|
||||
</Button>
|
||||
),
|
||||
)}
|
||||
<Button aria-label="Next page" disabled={lastPageActive} onClick={onNextClick}>
|
||||
<div>{nextLabel}</div>
|
||||
<KeyboardArrowRight />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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}`,
|
||||
},
|
||||
}))
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user