Skip to content

Commit

Permalink
chore(webapp): add table pagination (#1803)
Browse files Browse the repository at this point in the history
  • Loading branch information
eh-am committed Jan 10, 2023
1 parent ca46d91 commit ba84f83
Show file tree
Hide file tree
Showing 4 changed files with 250 additions and 71 deletions.
43 changes: 43 additions & 0 deletions stories/Table.stories.tsx
@@ -0,0 +1,43 @@
/* eslint-disable react/jsx-props-no-spreading */
import React from 'react';
import Table from '../webapp/javascript/ui/Table';
import { randomId } from '../webapp/javascript/util/randomId';
import { ComponentStory, ComponentMeta } from '@storybook/react';
import '../webapp/sass/profile.scss';

export default {
title: 'Components/Table',
component: Table,
} as ComponentMeta<typeof Table>;

const items = Array.from({ length: 20 }).map((a, i) => {
return {
id: i,
value: randomId(),
};
});

export const MyTable = () => {
const headRow = [
{ name: '', label: 'Id', sortable: 1 },
{ name: '', label: 'Value', sortable: 1 },
];

const bodyRows = items.map((a) => {
return {
onClick: () => alert(`clicked on ${JSON.stringify(a)}`),
cells: [{ value: a.id }, { value: a.value }],
};
});

return (
<Table
itemsPerPage={5}
table={{
type: 'filled',
headRow,
bodyRows,
}}
/>
);
};
5 changes: 5 additions & 0 deletions webapp/javascript/ui/Table.module.scss
Expand Up @@ -75,3 +75,8 @@
text-align: center;
margin-top: 50px;
}

.pagination {
display: flex;
justify-content: flex-end;
}
62 changes: 60 additions & 2 deletions webapp/javascript/ui/Table.spec.tsx
@@ -1,6 +1,7 @@
import React from 'react';
import { renderHook, act } from '@testing-library/react-hooks';

import { useTableSort } from './Table';
import { render, within, screen } from '@testing-library/react';
import Table, { useTableSort } from './Table';

const mockHeadRow = [
{ name: 'self', label: 'test col2', sortable: 1 },
Expand Down Expand Up @@ -61,3 +62,60 @@ describe('Hook: useTableSort', () => {
});
});
});

describe('pagination', () => {
const header = [{ name: 'id', label: 'Id' }];
const rows = [
{ cells: [{ value: 1 }] },
{ cells: [{ value: 2 }] },
{ cells: [{ value: 3 }] },
];

it('does not paginate by default', async () => {
render(
<Table table={{ type: 'filled', headRow: header, bodyRows: rows }} />
);

const tbody = document.getElementsByTagName('tbody')[0];
const items = await within(tbody).findAllByRole('row');
expect(items).toHaveLength(rows.length);
});

it('paginates', async () => {
render(
<Table
itemsPerPage={1}
table={{
type: 'filled',
headRow: header,
bodyRows: rows,
}}
/>
);

const tbody = document.getElementsByTagName('tbody')[0];

// First page
expect(screen.getByLabelText('Previous Page')).toBeDisabled();
expect(screen.getByLabelText('Next Page')).toBeEnabled();
let items = await within(tbody).findAllByRole('row');
expect(items).toHaveLength(1);
expect(items[0]).toHaveTextContent('1');

// Second page
screen.getByLabelText('Next Page').click();
expect(screen.getByLabelText('Previous Page')).toBeEnabled();
expect(screen.getByLabelText('Next Page')).toBeEnabled();
items = await within(tbody).findAllByRole('row');
expect(items).toHaveLength(1);
expect(items[0]).toHaveTextContent('2');

// Third page
screen.getByLabelText('Next Page').click();
expect(screen.getByLabelText('Previous Page')).toBeEnabled();
expect(screen.getByLabelText('Next Page')).toBeDisabled();
items = await within(tbody).findAllByRole('row');
expect(items).toHaveLength(1);
expect(items[0]).toHaveTextContent('3');
});
});
211 changes: 142 additions & 69 deletions webapp/javascript/ui/Table.tsx
@@ -1,10 +1,13 @@
/* eslint-disable react/jsx-props-no-spreading */
import React, { useState, ReactNode, CSSProperties, RefObject } from 'react';
import { faChevronLeft } from '@fortawesome/free-solid-svg-icons/faChevronLeft';
import { faChevronRight } from '@fortawesome/free-solid-svg-icons/faChevronRight';
import clsx from 'clsx';

// eslint-disable-next-line css-modules/no-unused-class
import styles from './Table.module.scss';
import LoadingSpinner from './LoadingSpinner';
import Button from './Button';

interface CustomProp {
[k: string]: string | CSSProperties | ReactNode | number | undefined;
Expand Down Expand Up @@ -84,6 +87,8 @@ interface TableProps {
tableBodyRef?: RefObject<HTMLTableSectionElement>;
className?: string;
isLoading?: boolean;
/* enables pagination */
itemsPerPage?: number;
}

function Table({
Expand All @@ -94,85 +99,153 @@ function Table({
tableBodyRef,
className,
isLoading,
itemsPerPage,
}: TableProps) {
const hasSort = sortByDirection && sortBy && updateSortParams;
const [currPage, setCurrPage] = useState(0);

return isLoading ? (
<div className={styles.loadingSpinner}>
<LoadingSpinner />
</div>
) : (
<table
className={clsx(styles.table, {
[className || '']: className,
})}
data-testid="table-ui"
>
<thead>
<tr>
{table.headRow.map(
({ sortable, label, name, ...rest }, idx: number) =>
!sortable || table.type === 'not-filled' || !hasSort ? (
// eslint-disable-next-line react/no-array-index-key
<th key={idx} {...rest}>
{label}
</th>
) : (
<th
{...rest}
<>
<table
className={clsx(styles.table, {
[className || '']: className,
})}
data-testid="table-ui"
>
<thead>
<tr>
{table.headRow.map(
({ sortable, label, name, ...rest }, idx: number) =>
!sortable || table.type === 'not-filled' || !hasSort ? (
// eslint-disable-next-line react/no-array-index-key
key={idx}
className={styles.sortable}
onClick={() => updateSortParams(name)}
>
{label}
<span
className={clsx(styles.sortArrow, {
[styles[sortByDirection]]: sortBy === name,
<th key={idx} {...rest}>
{label}
</th>
) : (
<th
{...rest}
// eslint-disable-next-line react/no-array-index-key
key={idx}
className={styles.sortable}
onClick={() => updateSortParams(name)}
>
{label}
<span
className={clsx(styles.sortArrow, {
[styles[sortByDirection]]: sortBy === name,
})}
/>
</th>
)
)}
</tr>
</thead>
<tbody ref={tableBodyRef}>
{table.type === 'not-filled' ? (
<tr className={table?.bodyClassName}>
<td colSpan={table.headRow.length}>{table.value}</td>
</tr>
) : (
paginate(table.bodyRows, currPage, itemsPerPage).map(
({ cells, isRowSelected, isRowDisabled, className, ...rest }) => {
// The problem is that when you switch apps or time-range and the function
// names stay the same it leads to an issue where rows don't get re-rendered
// So we force a rerender each time.
const renderID = Math.random();

return (
<tr
key={renderID}
{...rest}
className={clsx(className, {
[styles.isRowSelected]: isRowSelected,
[styles.isRowDisabled]: isRowDisabled,
})}
/>
</th>
)
>
{cells &&
cells.map(
({ style, value, ...rest }: Cell, index: number) => (
// eslint-disable-next-line react/no-array-index-key
<td key={renderID + index} style={style} {...rest}>
{value}
</td>
)
)}
</tr>
);
}
)
)}
</tr>
</thead>
<tbody ref={tableBodyRef}>
{table.type === 'not-filled' ? (
<tr className={table?.bodyClassName}>
<td colSpan={table.headRow.length}>{table.value}</td>
</tr>
) : (
table.bodyRows?.map(
({ cells, isRowSelected, isRowDisabled, className, ...rest }) => {
// The problem is that when you switch apps or time-range and the function
// names stay the same it leads to an issue where rows don't get re-rendered
// So we force a rerender each time.
const renderID = Math.random();

return (
<tr
key={renderID}
{...rest}
className={clsx(className, {
[styles.isRowSelected]: isRowSelected,
[styles.isRowDisabled]: isRowDisabled,
})}
>
{cells &&
cells.map(
({ style, value, ...rest }: Cell, index: number) => (
// eslint-disable-next-line react/no-array-index-key
<td key={renderID + index} style={style} {...rest}>
{value}
</td>
)
)}
</tr>
);
}
)
)}
</tbody>
</table>
</tbody>
</table>
<PaginationNavigation
bodyRows={table.type === 'filled' ? table.bodyRows : undefined}
itemsPerPage={itemsPerPage}
currPage={currPage}
setCurrPage={setCurrPage}
/>
</>
);
}

function paginate(
bodyRows: Extract<Table, { type: 'filled' }>['bodyRows'],
currPage: number,
itemsPerPage?: TableProps['itemsPerPage']
) {
if (!itemsPerPage) {
return bodyRows;
}

return bodyRows.slice(currPage * itemsPerPage, itemsPerPage * (currPage + 1));
}

interface PaginationNavigationProps {
bodyRows?: Extract<Table, { type: 'filled' }>['bodyRows'];
currPage: number;
itemsPerPage?: TableProps['itemsPerPage'];
setCurrPage: (i: number) => void;
}

function PaginationNavigation({
itemsPerPage,
currPage,
setCurrPage,
bodyRows,
}: PaginationNavigationProps) {
if (!itemsPerPage) {
return null;
}

const isThereNextPage = bodyRows
? paginate(bodyRows, currPage + 1, itemsPerPage).length > 0
: false;

const isTherePreviousPage = bodyRows
? paginate(bodyRows, currPage - 1, itemsPerPage).length > 0
: false;

return (
<nav className={styles.pagination}>
<Button
aria-label="Previous Page"
disabled={!isTherePreviousPage}
kind="float"
icon={faChevronLeft}
onClick={() => setCurrPage(currPage - 1)}
/>
<Button
disabled={!isThereNextPage}
aria-label="Next Page"
kind="float"
icon={faChevronRight}
onClick={() => setCurrPage(currPage + 1)}
/>
</nav>
);
}

Expand Down

0 comments on commit ba84f83

Please sign in to comment.