Skip to content

Commit aaa2db6

Browse files
authored
feat: add pagination component to components directory (coder#3295)
* proof of concept * added tests * fixed tests * wrote unit tests * preettier
1 parent b9936d2 commit aaa2db6

File tree

4 files changed

+298
-0
lines changed

4 files changed

+298
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { action } from "@storybook/addon-actions"
2+
import { Story } from "@storybook/react"
3+
import { PaginationWidget, PaginationWidgetProps } from "./PaginationWidget"
4+
5+
export default {
6+
title: "components/PaginationWidget",
7+
component: PaginationWidget,
8+
}
9+
10+
const Template: Story<PaginationWidgetProps> = (args: PaginationWidgetProps) => (
11+
<PaginationWidget {...args} />
12+
)
13+
14+
const defaultProps = {
15+
prevLabel: "Previous",
16+
nextLabel: "Next",
17+
onPrevClick: action("previous"),
18+
onNextClick: action("next"),
19+
onPageClick: action("clicked"),
20+
}
21+
22+
export const UnknownPageNumbers = Template.bind({})
23+
UnknownPageNumbers.args = {
24+
...defaultProps,
25+
}
26+
27+
export const LessThan8Pages = Template.bind({})
28+
LessThan8Pages.args = {
29+
...defaultProps,
30+
numRecords: 84,
31+
numRecordsPerPage: 12,
32+
activePage: 1,
33+
}
34+
35+
export const MoreThan8Pages = Template.bind({})
36+
MoreThan8Pages.args = {
37+
...defaultProps,
38+
numRecords: 200,
39+
numRecordsPerPage: 12,
40+
activePage: 1,
41+
}
42+
43+
export const MoreThan7PagesWithActivePageCloseToStart = Template.bind({})
44+
MoreThan7PagesWithActivePageCloseToStart.args = {
45+
...defaultProps,
46+
numRecords: 200,
47+
numRecordsPerPage: 12,
48+
activePage: 2,
49+
}
50+
51+
export const MoreThan7PagesWithActivePageFarFromBoundaries = Template.bind({})
52+
MoreThan7PagesWithActivePageFarFromBoundaries.args = {
53+
...defaultProps,
54+
numRecords: 200,
55+
numRecordsPerPage: 12,
56+
activePage: 4,
57+
}
58+
59+
export const MoreThan7PagesWithActivePageCloseToEnd = Template.bind({})
60+
MoreThan7PagesWithActivePageCloseToEnd.args = {
61+
...defaultProps,
62+
numRecords: 200,
63+
numRecordsPerPage: 12,
64+
activePage: 17,
65+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { screen } from "@testing-library/react"
2+
import { render } from "../../testHelpers/renderHelpers"
3+
import { PaginationWidget } from "./PaginationWidget"
4+
5+
describe("PaginatedList", () => {
6+
it("displays an accessible previous and next button regardless of the number of pages", async () => {
7+
const { container } = render(
8+
<PaginationWidget
9+
prevLabel="Previous"
10+
nextLabel="Next"
11+
onPrevClick={() => jest.fn()}
12+
onNextClick={() => jest.fn()}
13+
/>,
14+
)
15+
16+
expect(await screen.findByRole("button", { name: "Previous page" })).toBeTruthy()
17+
expect(await screen.findByRole("button", { name: "Next page" })).toBeTruthy()
18+
// Shouldn't render any pages if no records are passed in
19+
expect(await container.querySelectorAll(`button[name="Page button"]`)).toHaveLength(0)
20+
})
21+
22+
it("displays the expected number of pages with one ellipsis tile", async () => {
23+
const { container } = render(
24+
<PaginationWidget
25+
prevLabel="Previous"
26+
nextLabel="Next"
27+
onPrevClick={() => jest.fn()}
28+
onNextClick={() => jest.fn()}
29+
onPageClick={(_) => jest.fn()}
30+
numRecords={200}
31+
numRecordsPerPage={12}
32+
activePage={1}
33+
/>,
34+
)
35+
36+
// 7 total spaces. 6 are page numbers, one is ellipsis
37+
expect(await container.querySelectorAll(`button[name="Page button"]`)).toHaveLength(6)
38+
})
39+
40+
it("displays the expected number of pages with two ellipsis tiles", async () => {
41+
const { container } = render(
42+
<PaginationWidget
43+
prevLabel="Previous"
44+
nextLabel="Next"
45+
onPrevClick={() => jest.fn()}
46+
onNextClick={() => jest.fn()}
47+
onPageClick={(_) => jest.fn()}
48+
numRecords={200}
49+
numRecordsPerPage={12}
50+
activePage={6}
51+
/>,
52+
)
53+
54+
// 7 total spaces. 2 sets of ellipsis on either side of the active page
55+
expect(await container.querySelectorAll(`button[name="Page button"]`)).toHaveLength(5)
56+
})
57+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import Button from "@material-ui/core/Button"
2+
import { makeStyles } from "@material-ui/core/styles"
3+
import KeyboardArrowLeft from "@material-ui/icons/KeyboardArrowLeft"
4+
import KeyboardArrowRight from "@material-ui/icons/KeyboardArrowRight"
5+
import { CSSProperties } from "react"
6+
7+
export type PaginationWidgetProps = {
8+
prevLabel: string
9+
nextLabel: string
10+
onPrevClick: () => void
11+
onNextClick: () => void
12+
onPageClick?: (page: number) => void
13+
numRecordsPerPage?: number
14+
numRecords?: number
15+
activePage?: number
16+
containerStyle?: CSSProperties
17+
}
18+
19+
/**
20+
* Generates a ranged array with an option to step over values.
21+
* Shamelessly stolen from:
22+
* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/from#sequence_generator_range
23+
*/
24+
const range = (start: number, stop: number, step = 1) =>
25+
Array.from({ length: (stop - start) / step + 1 }, (_, i) => start + i * step)
26+
27+
const DEFAULT_RECORDS_PER_PAGE = 25
28+
// Number of pages to the left or right of the current page selection.
29+
const PAGE_NEIGHBORS = 1
30+
// Number of pages displayed for cases where there are multiple ellipsis showing. This can be
31+
// thought of as the minimum number of page numbers to display when multiple ellipsis are showing.
32+
const PAGES_TO_DISPLAY = PAGE_NEIGHBORS * 2 + 3
33+
// Total page blocks(page numbers or ellipsis) displayed, including the maximum number of ellipsis (2).
34+
// This gives us maximum number of 7 page blocks to be displayed when the page neighbors value is 1.
35+
const NUM_PAGE_BLOCKS = PAGES_TO_DISPLAY + 2
36+
37+
/**
38+
* Builds a list of pages based on how many pages exist and where the user is in their navigation of those pages.
39+
* List result is used to from the buttons that make up the Pagination Widget
40+
*/
41+
export const buildPagedList = (numPages: number, activePage: number): (string | number)[] => {
42+
if (numPages > NUM_PAGE_BLOCKS) {
43+
let pages = []
44+
const leftBound = activePage - PAGE_NEIGHBORS
45+
const rightBound = activePage + PAGE_NEIGHBORS
46+
const beforeLastPage = numPages - 1
47+
const startPage = leftBound > 2 ? leftBound : 2
48+
const endPage = rightBound < beforeLastPage ? rightBound : beforeLastPage
49+
50+
pages = range(startPage, endPage)
51+
52+
const singleSpillOffset = PAGES_TO_DISPLAY - pages.length - 1
53+
const hasLeftOverflow = startPage > 2
54+
const hasRightOverflow = endPage < beforeLastPage
55+
const leftOverflowPage = "left"
56+
const rightOverflowPage = "right"
57+
58+
if (hasLeftOverflow && !hasRightOverflow) {
59+
const extraPages = range(startPage - singleSpillOffset, startPage - 1)
60+
pages = [leftOverflowPage, ...extraPages, ...pages]
61+
} else if (!hasLeftOverflow && hasRightOverflow) {
62+
const extraPages = range(endPage + 1, endPage + singleSpillOffset)
63+
pages = [...pages, ...extraPages, rightOverflowPage]
64+
} else if (hasLeftOverflow && hasRightOverflow) {
65+
pages = [leftOverflowPage, ...pages, rightOverflowPage]
66+
}
67+
68+
return [1, ...pages, numPages]
69+
}
70+
71+
return range(1, numPages)
72+
}
73+
74+
export const PaginationWidget = ({
75+
prevLabel,
76+
nextLabel,
77+
onPrevClick,
78+
onNextClick,
79+
onPageClick,
80+
numRecords,
81+
numRecordsPerPage = DEFAULT_RECORDS_PER_PAGE,
82+
activePage = 1,
83+
containerStyle,
84+
}: PaginationWidgetProps): JSX.Element | null => {
85+
const numPages = numRecords ? Math.ceil(numRecords / numRecordsPerPage) : 0
86+
const firstPageActive = activePage === 1 && numPages !== 0
87+
const lastPageActive = activePage === numPages && numPages !== 0
88+
89+
const styles = useStyles()
90+
91+
// No need to display any pagination if we know the number of pages is 1
92+
if (numPages === 1) {
93+
return null
94+
}
95+
96+
return (
97+
<div style={containerStyle} className={styles.defaultContainerStyles}>
98+
<Button
99+
className={styles.prevLabelStyles}
100+
aria-label="Previous page"
101+
disabled={firstPageActive}
102+
onClick={onPrevClick}
103+
>
104+
<KeyboardArrowLeft />
105+
<div>{prevLabel}</div>
106+
</Button>
107+
{numPages > 0 &&
108+
buildPagedList(numPages, activePage).map((page) =>
109+
typeof page !== "number" ? (
110+
<Button className={styles.pageButton} key={`Page${page}`} disabled>
111+
<div>...</div>
112+
</Button>
113+
) : (
114+
<Button
115+
className={
116+
activePage === page
117+
? `${styles.pageButton} ${styles.activePageButton}`
118+
: styles.pageButton
119+
}
120+
aria-label={`${page === activePage ? "Current Page" : ""} ${
121+
page === numPages ? "Last Page" : ""
122+
} Page${page}`}
123+
name="Page button"
124+
key={`Page${page}`}
125+
onClick={() => onPageClick && onPageClick(page)}
126+
>
127+
<div>{page}</div>
128+
</Button>
129+
),
130+
)}
131+
<Button aria-label="Next page" disabled={lastPageActive} onClick={onNextClick}>
132+
<div>{nextLabel}</div>
133+
<KeyboardArrowRight />
134+
</Button>
135+
</div>
136+
)
137+
}
138+
139+
const useStyles = makeStyles((theme) => ({
140+
defaultContainerStyles: {
141+
justifyContent: "center",
142+
alignItems: "center",
143+
display: "flex",
144+
flexDirection: "row",
145+
padding: "20px",
146+
},
147+
148+
prevLabelStyles: {
149+
marginRight: `${theme.spacing(0.5)}px`,
150+
},
151+
152+
pageButton: {
153+
"&:not(:last-of-type)": {
154+
marginRight: theme.spacing(0.5),
155+
},
156+
},
157+
158+
activePageButton: {
159+
borderColor: `${theme.palette.info.main}`,
160+
backgroundColor: `${theme.palette.info.dark}`,
161+
},
162+
}))
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { buildPagedList } from "./PaginationWidget"
2+
3+
describe("unit/PaginationWidget", () => {
4+
describe("buildPagedList", () => {
5+
it.each<{ numPages: number; activePage: number; expected: (string | number)[] }>([
6+
{ numPages: 7, activePage: 1, expected: [1, 2, 3, 4, 5, 6, 7] },
7+
{ numPages: 17, activePage: 1, expected: [1, 2, 3, 4, 5, "right", 17] },
8+
{ numPages: 17, activePage: 9, expected: [1, "left", 8, 9, 10, "right", 17] },
9+
{ numPages: 17, activePage: 17, expected: [1, "left", 13, 14, 15, 16, 17] },
10+
])(`buildPagedList($numPages, $activePage)`, ({ numPages, activePage, expected }) => {
11+
expect(buildPagedList(numPages, activePage)).toEqual(expected)
12+
})
13+
})
14+
})

0 commit comments

Comments
 (0)