Skip to content

Commit

Permalink
Merge pull request #7487 from tinyspeck/sarabee-vtadmin-data-table-pa…
Browse files Browse the repository at this point in the history
…gination

[vtadmin-web] Add DataTable component with URL pagination
  • Loading branch information
rohit-nayak-ps authored Feb 11, 2021
2 parents 38c5098 + a37ed6d commit 27c07af
Show file tree
Hide file tree
Showing 17 changed files with 844 additions and 136 deletions.
191 changes: 153 additions & 38 deletions web/vtadmin/package-lock.json

Large diffs are not rendered by default.

7 changes: 5 additions & 2 deletions web/vtadmin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@
"npm": ">=6.14.9"
},
"dependencies": {
"@testing-library/jest-dom": "^5.11.6",
"@testing-library/react": "^11.2.2",
"@testing-library/user-event": "^12.6.0",
"@types/classnames": "^2.2.11",
"@types/jest": "^26.0.19",
Expand All @@ -17,8 +15,10 @@
"@types/react-dom": "^16.9.10",
"@types/react-router-dom": "^5.1.7",
"classnames": "^2.2.6",
"history": "^5.0.0",
"lodash-es": "^4.17.20",
"node-sass": "^4.14.1",
"query-string": "^6.14.0",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"react-query": "^3.5.9",
Expand Down Expand Up @@ -60,6 +60,9 @@
]
},
"devDependencies": {
"@testing-library/jest-dom": "^5.11.9",
"@testing-library/react": "^11.2.5",
"@testing-library/react-hooks": "^5.0.3",
"@types/lodash-es": "^4.17.4",
"msw": "^0.24.4",
"prettier": "^2.2.1",
Expand Down
75 changes: 75 additions & 0 deletions web/vtadmin/src/components/dataTable/DataTable.tsx
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 web/vtadmin/src/components/dataTable/PaginationNav.module.scss
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 web/vtadmin/src/components/dataTable/PaginationNav.test.tsx
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');
}
}
});
});
});
119 changes: 119 additions & 0 deletions web/vtadmin/src/components/dataTable/PaginationNav.tsx
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);
};
Loading

0 comments on commit 27c07af

Please sign in to comment.