-
Notifications
You must be signed in to change notification settings - Fork 2.1k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #7487 from tinyspeck/sarabee-vtadmin-data-table-pa…
…gination [vtadmin-web] Add DataTable component with URL pagination
- Loading branch information
Showing
17 changed files
with
844 additions
and
136 deletions.
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains 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
This file contains 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,75 @@ | ||
/** | ||
* Copyright 2021 The Vitess Authors. | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
*/ | ||
import qs from 'query-string'; | ||
import * as React from 'react'; | ||
import { useLocation } from 'react-router-dom'; | ||
|
||
import { useURLPagination } from '../../hooks/useURLPagination'; | ||
import { useURLQuery } from '../../hooks/useURLQuery'; | ||
import { PaginationNav } from './PaginationNav'; | ||
|
||
interface Props<T> { | ||
columns: string[]; | ||
data: T[]; | ||
pageSize?: number; | ||
renderRows: (rows: T[]) => JSX.Element[]; | ||
} | ||
|
||
// Generally, page sizes of ~100 rows are fine in terms of performance, | ||
// but anything over ~50 feels unwieldy in terms of UX. | ||
const DEFAULT_PAGE_SIZE = 50; | ||
|
||
export const DataTable = <T extends object>({ columns, data, pageSize = DEFAULT_PAGE_SIZE, renderRows }: Props<T>) => { | ||
const { pathname } = useLocation(); | ||
const urlQuery = useURLQuery(); | ||
|
||
const totalPages = Math.ceil(data.length / pageSize); | ||
const { page } = useURLPagination({ totalPages }); | ||
|
||
const startIndex = (page - 1) * pageSize; | ||
const endIndex = startIndex + pageSize; | ||
const dataPage = data.slice(startIndex, endIndex); | ||
|
||
const startRow = startIndex + 1; | ||
const lastRow = Math.min(data.length, startIndex + pageSize); | ||
|
||
const formatPageLink = (p: number) => ({ | ||
pathname, | ||
search: qs.stringify({ ...urlQuery, page: p === 1 ? undefined : p }), | ||
}); | ||
|
||
return ( | ||
<div> | ||
<table> | ||
<thead> | ||
<tr> | ||
{columns.map((col, cdx) => ( | ||
<th key={cdx}>{col}</th> | ||
))} | ||
</tr> | ||
</thead> | ||
<tbody>{renderRows(dataPage)}</tbody> | ||
</table> | ||
|
||
<PaginationNav currentPage={page} formatLink={formatPageLink} totalPages={totalPages} /> | ||
{!!data.length && ( | ||
<p className="text-color-secondary"> | ||
Showing {startRow} {lastRow > startRow ? `- ${lastRow}` : null} of {data.length} | ||
</p> | ||
)} | ||
</div> | ||
); | ||
}; |
51 changes: 51 additions & 0 deletions
51
web/vtadmin/src/components/dataTable/PaginationNav.module.scss
This file contains 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,51 @@ | ||
/** | ||
* Copyright 2021 The Vitess Authors. | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
*/ | ||
.links { | ||
display: flex; | ||
list-style-type: none; | ||
margin: 0; | ||
padding: 0; | ||
} | ||
|
||
.placeholder, | ||
a.link { | ||
border: solid 1px var(--backgroundPrimaryHighlight); | ||
border-radius: 6px; | ||
color: var(--textColorSecondary); | ||
display: block; | ||
line-height: 36px; | ||
margin-right: 8px; | ||
text-align: center; | ||
width: 36px; | ||
} | ||
|
||
a.link { | ||
cursor: pointer; | ||
text-decoration: none; | ||
|
||
&:hover { | ||
border-color: var(--colorPrimary); | ||
} | ||
|
||
&.activeLink { | ||
border-color: var(--colorPrimary); | ||
color: var(--colorPrimary); | ||
} | ||
} | ||
|
||
.placeholder::before { | ||
content: '...'; | ||
} |
99 changes: 99 additions & 0 deletions
99
web/vtadmin/src/components/dataTable/PaginationNav.test.tsx
This file contains 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,99 @@ | ||
/** | ||
* Copyright 2021 The Vitess Authors. | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
*/ | ||
import { render, screen, within } from '@testing-library/react'; | ||
import { MemoryRouter } from 'react-router-dom'; | ||
import { PaginationNav, Props } from './PaginationNav'; | ||
|
||
const formatLink = (page: number) => ({ | ||
pathname: '/test', | ||
search: `?hello=world&page=${page}`, | ||
}); | ||
|
||
describe('PaginationNav', () => { | ||
const tests: { | ||
name: string; | ||
props: Props; | ||
expected: null | Array<number | null>; | ||
}[] = [ | ||
{ | ||
name: 'renders without breaks', | ||
props: { currentPage: 1, formatLink, maxVisible: 3, totalPages: 2 }, | ||
expected: [1, 2], | ||
}, | ||
{ | ||
name: 'renders breaks on the right', | ||
props: { currentPage: 1, formatLink, maxVisible: 5, totalPages: 11 }, | ||
expected: [1, 2, 3, null, 11], | ||
}, | ||
{ | ||
name: 'renders breaks on the left', | ||
props: { currentPage: 11, formatLink, maxVisible: 5, totalPages: 11 }, | ||
expected: [1, null, 9, 10, 11], | ||
}, | ||
{ | ||
name: 'renders breaks in the middle', | ||
props: { currentPage: 6, formatLink, maxVisible: 5, totalPages: 11 }, | ||
expected: [1, null, 6, null, 11], | ||
}, | ||
{ | ||
name: 'renders widths according to the minWidth prop', | ||
props: { currentPage: 6, formatLink, maxVisible: 9, minWidth: 2, totalPages: 100 }, | ||
expected: [1, 2, null, 5, 6, 7, null, 99, 100], | ||
}, | ||
{ | ||
name: 'does not render if totalPages == 0', | ||
props: { currentPage: 1, formatLink, totalPages: 0 }, | ||
expected: null, | ||
}, | ||
{ | ||
name: 'renders even if page > totalPages', | ||
props: { currentPage: 100000, formatLink, maxVisible: 5, totalPages: 11 }, | ||
expected: [1, null, 9, 10, 11], | ||
}, | ||
]; | ||
|
||
test.each(tests.map(Object.values))('%s', (name: string, props: Props, expected: Array<number | null>) => { | ||
render(<PaginationNav {...props} />, { wrapper: MemoryRouter }); | ||
|
||
const nav = screen.queryByRole('navigation'); | ||
if (expected === null) { | ||
expect(nav).toBeNull(); | ||
return; | ||
} | ||
|
||
const lis = screen.getAllByRole('listitem'); | ||
expect(lis).toHaveLength(expected.length); | ||
|
||
lis.forEach((li, idx) => { | ||
const e = expected[idx]; | ||
const link = within(li).queryByRole('link'); | ||
|
||
if (e === null) { | ||
// Placeholders don't render links | ||
expect(link).toBeNull(); | ||
} else { | ||
expect(link).toHaveAttribute('href', `/test?hello=world&page=${e}`); | ||
expect(link).toHaveTextContent(`${e}`); | ||
|
||
if (e === props.currentPage) { | ||
expect(link).toHaveClass('activeLink'); | ||
} else { | ||
expect(link).not.toHaveClass('activeLink'); | ||
} | ||
} | ||
}); | ||
}); | ||
}); |
This file contains 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,119 @@ | ||
/** | ||
* Copyright 2021 The Vitess Authors. | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
*/ | ||
import cx from 'classnames'; | ||
import * as React from 'react'; | ||
import { Link, LinkProps } from 'react-router-dom'; | ||
|
||
import style from './PaginationNav.module.scss'; | ||
|
||
export interface Props { | ||
currentPage: number; | ||
formatLink: (page: number) => LinkProps['to']; | ||
// The maximum number of pagination elements to show. Note that this includes any placeholders. | ||
// It's recommended for this value to be >= 5 to handle the case where there are | ||
// breaks on either side of the list. | ||
maxVisible?: number; | ||
// The minimum number of pagination elements to show at the beginning/end of a sequence, | ||
// adjacent to any sequence breaks. | ||
minWidth?: number; | ||
// The total number of pages | ||
totalPages: number; | ||
} | ||
|
||
const DEFAULT_MAX_VISIBLE = 8; | ||
const DEFAULT_MIN_WIDTH = 1; | ||
|
||
// This assumes we always want to 1-index our pages, where "page 1" is the first page. | ||
// If we find a need for zero-indexed pagination, we can make this configurable. | ||
const FIRST_PAGE = 1; | ||
|
||
// PageSpecifiers with a numeric value are links. `null` is used | ||
// to signify a break in the sequence. | ||
type PageSpecifier = number | null; | ||
|
||
export const PaginationNav = ({ | ||
currentPage, | ||
formatLink, | ||
maxVisible = DEFAULT_MAX_VISIBLE, | ||
minWidth = DEFAULT_MIN_WIDTH, | ||
totalPages, | ||
}: Props) => { | ||
if (totalPages <= 1) { | ||
return null; | ||
} | ||
|
||
// This rather magical solution is borrowed, with gratitude, from StackOverflow | ||
// https://stackoverflow.com/a/46385144 | ||
const leftWidth = (maxVisible - minWidth * 2 - 3) >> 1; | ||
const rightWidth = (maxVisible - minWidth * 2 - 2) >> 1; | ||
|
||
let numbers: PageSpecifier[] = []; | ||
if (totalPages <= maxVisible) { | ||
// No breaks in list | ||
numbers = range(FIRST_PAGE, totalPages); | ||
} else if (currentPage <= maxVisible - minWidth - 1 - rightWidth) { | ||
// No break on left side of page | ||
numbers = range(FIRST_PAGE, maxVisible - minWidth - 1).concat( | ||
null, | ||
range(totalPages - minWidth + 1, totalPages) | ||
); | ||
} else if (currentPage >= totalPages - minWidth - 1 - rightWidth) { | ||
// No break on right of page | ||
numbers = range(FIRST_PAGE, minWidth).concat( | ||
null, | ||
range(totalPages - minWidth - 1 - rightWidth - leftWidth, totalPages) | ||
); | ||
} else { | ||
// Breaks on both sides | ||
numbers = range(FIRST_PAGE, minWidth).concat( | ||
null, | ||
range(currentPage - leftWidth, currentPage + rightWidth), | ||
null, | ||
range(totalPages - minWidth + 1, totalPages) | ||
); | ||
} | ||
|
||
return ( | ||
<nav> | ||
<ul className={style.links}> | ||
{numbers.map((num: number | null, idx) => | ||
num === null ? ( | ||
<li key={`placeholder-${idx}`}> | ||
<div className={style.placeholder} /> | ||
</li> | ||
) : ( | ||
<li key={num}> | ||
<Link | ||
className={cx(style.link, { [style.activeLink]: num === currentPage })} | ||
to={formatLink(num)} | ||
> | ||
{num} | ||
</Link> | ||
</li> | ||
) | ||
)} | ||
</ul> | ||
</nav> | ||
); | ||
}; | ||
|
||
// lodash-es has a `range` function but it doesn't play nice | ||
// with the PageSpecifier[] return type (since it's a mixed array | ||
// of numbers and nulls). | ||
const range = (start: number, end: number): PageSpecifier[] => { | ||
if (isNaN(start) || isNaN(end)) return []; | ||
return Array.from(Array(end - start + 1), (_, i) => i + start); | ||
}; |
Oops, something went wrong.