Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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