-
Notifications
You must be signed in to change notification settings - Fork 979
feat: add pagination component to components directory #3295
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 3 commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
65 changes: 65 additions & 0 deletions
65
site/src/components/PaginationWidget/PaginationWidget.stories.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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, | ||
} |
57 changes: 57 additions & 0 deletions
57
site/src/components/PaginationWidget/PaginationWidget.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) | ||
}) | ||
}) |
158 changes: 158 additions & 0 deletions
158
site/src/components/PaginationWidget/PaginationWidget.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,158 @@ | ||
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 | ||
|
||
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() | ||
|
||
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 ( | ||
<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().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}`, | ||
}, | ||
})) |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I like to pull pure functions out of components as much as possible, maybe that can be done here? Then they can be unit tested easily, although I know you've tested the component, so it's up to you.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sure, I can take a stab at that!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have a diff opinion, I think it is easier to manage/find code by keeping them close to where they are used, and it looks like this function only will be used on this component so I would keep it in the component file but it is up to you.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@presleyp @BrunoQuaresma I compromised here and added some unit tests but didn't separate the function from its component file so it should still be easy to find.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great! I think separating the function from the component is the important part.