Skip to content

Commit

Permalink
Merge pull request #743 from petermakowski/feat-add-usePagination-hook
Browse files Browse the repository at this point in the history
feat: add usePagination hook
  • Loading branch information
petermakowski authored Apr 22, 2022
2 parents 4b8be76 + faa1bc6 commit 84dcd00
Show file tree
Hide file tree
Showing 4 changed files with 217 additions and 68 deletions.
150 changes: 82 additions & 68 deletions src/components/MainTable/MainTable.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react";
import React, { useEffect, useMemo, useState } from "react";
import type { HTMLProps, ReactNode } from "react";

import type { ClassName, PropsWithSpread, SortDirection } from "types";
Expand All @@ -9,6 +9,7 @@ import TableRow from "../TableRow";
import TableHeader from "../TableHeader";
import TableCell from "../TableCell";
import type { TableCellProps } from "../TableCell";
import { usePagination } from "hooks";

export type MainTableHeader = PropsWithSpread<
{
Expand Down Expand Up @@ -190,60 +191,19 @@ const generateHeaders = (
);
};

const generateRows = (
currentSortDirection: SortDirection,
currentSortKey: MainTableHeader["sortKey"],
emptyStateMsg: ReactNode,
expanding: Props["expanding"],
responsive: Props["responsive"],
headers: Props["headers"],
paginate: Props["paginate"],
rows: Props["rows"],
currentPage: number,
setCurrentPage: (page: number) => void,
sortable: Props["sortable"],
sortFunction: Props["sortFunction"]
) => {
// If the table has no rows, return empty state message
if (Object.entries(rows).length === 0 && emptyStateMsg) {
return <caption>{emptyStateMsg}</caption>;
}
// Clone the rows so we can restore the original order.
const sortedRows = [...rows];
if (sortable && currentSortKey) {
if (!sortFunction) {
sortFunction = (a, b) => {
if (!a.sortData || !b.sortData) {
return 0;
}
if (a.sortData[currentSortKey] > b.sortData[currentSortKey]) {
return currentSortDirection === "ascending" ? 1 : -1;
} else if (a.sortData[currentSortKey] < b.sortData[currentSortKey]) {
return currentSortDirection === "ascending" ? -1 : 1;
}
return 0;
};
}
sortedRows.sort((a, b) =>
sortFunction(a, b, currentSortDirection, currentSortKey)
);
}
let slicedRows = sortedRows;
if (paginate) {
const startIndex = (currentPage - 1) * paginate;
if (startIndex > rows.length) {
// If the rows have changed e.g. when filtering and the user is on a page
// that no longer exists then send them to the start.
setCurrentPage(1);
}
slicedRows = sortedRows.slice(startIndex, startIndex + paginate);
}
const rowItems = slicedRows.map(
const generateRows = ({
rows,
headers,
responsive,
expanding,
}: Required<Pick<Props, "rows">> &
Pick<Props, "headers" | "responsive" | "expanding">) =>
rows.map(
(
{ columns, expanded, expandedContent, key, sortData, ...rowProps },
index
) => {
const cellItems = columns.map(({ content, ...cellProps }, index) => {
const cellItems = columns?.map(({ content, ...cellProps }, index) => {
const headerContent = headers && headers[index]["content"];
const headerReplacement = headers && headers[index]["heading"];

Expand Down Expand Up @@ -279,7 +239,41 @@ const generateRows = (
);
}
);
return <tbody>{rowItems}</tbody>;

const sortRows = ({
currentSortDirection,
currentSortKey,
rows,
sortable,
sortFunction,
}: Pick<Props, "rows" | "sortable" | "sortFunction"> & {
currentSortDirection: Props["defaultSortDirection"];
currentSortKey: Props["defaultSort"];
}): MainTableRow[] => {
if (!rows) {
return [];
}
// Clone the rows so we can restore the original order.
const sortedRows = [...rows];
if (sortable && currentSortKey) {
if (!sortFunction) {
sortFunction = (a, b) => {
if (!a.sortData || !b.sortData) {
return 0;
}
if (a.sortData[currentSortKey] > b.sortData[currentSortKey]) {
return currentSortDirection === "ascending" ? 1 : -1;
} else if (a.sortData[currentSortKey] < b.sortData[currentSortKey]) {
return currentSortDirection === "ascending" ? -1 : 1;
}
return 0;
};
}
sortedRows.sort((a, b) =>
sortFunction(a, b, currentSortDirection, currentSortKey)
);
}
return sortedRows;
};

const MainTable = ({
Expand All @@ -299,7 +293,6 @@ const MainTable = ({
const [currentSortKey, setSortKey] = useState(defaultSort);
const [currentSortDirection, setSortDirection] =
useState(defaultSortDirection);
const [currentPage, setCurrentPage] = useState(1);

// Update the current sort state if the prop changes.
useEffect(() => {
Expand All @@ -316,6 +309,27 @@ const MainTable = ({
onUpdateSort && onUpdateSort(newSort);
};

const sortedRows = useMemo(
() =>
sortRows({
currentSortDirection,
currentSortKey,
rows,
sortable,
sortFunction,
}),
[currentSortDirection, currentSortKey, rows, sortable, sortFunction]
);

const {
pageData: finalRows,
currentPage,
paginate: setCurrentPage,
} = usePagination(sortedRows, {
itemsPerPage: paginate,
autoResetPage: true,
});

return (
<>
<Table expanding={expanding} responsive={responsive} {...props}>
Expand All @@ -329,21 +343,21 @@ const MainTable = ({
updateSort,
setSortDirection
)}
{!!rows &&
generateRows(
currentSortDirection,
currentSortKey,
emptyStateMsg,
expanding,
responsive,
headers,
paginate,
rows,
currentPage,
setCurrentPage,
sortable,
sortFunction
)}
{
// If the table has no rows, return empty state message
Object.entries(finalRows).length === 0 && emptyStateMsg ? (
<caption>{emptyStateMsg}</caption>
) : (
<tbody>
{generateRows({
rows: finalRows,
headers,
responsive,
expanding,
})}
</tbody>
)
}
</Table>
{paginate && rows && rows.length > 0 && (
<Pagination
Expand Down
1 change: 1 addition & 0 deletions src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ export { useListener } from "./useListener";
export { usePrevious } from "./usePrevious";
export { useThrottle } from "./useThrottle";
export { useId } from "./useId";
export { usePagination } from "./usePagination";
export type { WindowFitment } from "./useWindowFitment";
61 changes: 61 additions & 0 deletions src/hooks/usePagination.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { renderHook } from "@testing-library/react-hooks";

import { usePagination } from "./usePagination";

it("returns correct data", () => {
const { result } = renderHook(() =>
usePagination([1, 2, 3], { itemsPerPage: 2 })
);
const { pageData, currentPage, paginate, itemsPerPage, totalItems } =
result.current;
expect(currentPage).toBe(1);
expect(pageData).toEqual([1, 2]);
expect(paginate).toBeInstanceOf(Function);
expect(itemsPerPage).toBe(2);
expect(totalItems).toBe(3);
});

it("correctly sets the initial page", () => {
const { result } = renderHook(() =>
usePagination([1, 2], { itemsPerPage: 1, initialPage: 2 })
);
const { pageData, currentPage } = result.current;
expect(currentPage).toBe(2);
expect(pageData).toEqual([2]);
});

it("goes to the last available page if the current page is out of bounds", () => {
const options = { itemsPerPage: 1, initialPage: 3 };
const { result, rerender } = renderHook(
({ data, options }) => usePagination(data, options),
{
initialProps: {
data: [1, 2, 3],
options,
},
}
);
expect(result.current.currentPage).toBe(3);
expect(result.current.pageData).toEqual([3]);
rerender({ data: [1, 2], options });
expect(result.current.currentPage).toBe(2);
expect(result.current.pageData).toEqual([2]);
});

it("go to the initial page if autoResetPage is true", () => {
const options = { itemsPerPage: 1, initialPage: 3, autoResetPage: true };
const { result, rerender } = renderHook(
({ data, options }) => usePagination(data, options),
{
initialProps: {
data: [1, 2, 3],
options,
},
}
);
expect(result.current.currentPage).toBe(3);
expect(result.current.pageData).toEqual([3]);
rerender({ data: [1, 2], options });
expect(result.current.currentPage).toBe(1);
expect(result.current.pageData).toEqual([1]);
});
73 changes: 73 additions & 0 deletions src/hooks/usePagination.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { useMemo, useEffect, useState } from "react";

/**
* A hook that handles pagination.
* @param data - The data array to paginate.
* @param {Object} options
* @param {number} [options.itemsPerPage] - Number of items per page. Returns all items if no value has been provided.
* @param {number} [options.initialPage=1] - Initial page number. Defaults to 1.
* @param {boolean} [options.autoResetPage=false] - Whether to reset the page number to 1 when the data changes.
*/

export function usePagination<D, I = number | null>(
data: Array<D>,
options?: {
itemsPerPage: I;
initialPage?: number;
autoResetPage?: boolean;
}
): {
pageData: Array<D>;
currentPage: number;
paginate: (pageNumber: number) => void;
itemsPerPage: I;
totalItems: number;
} {
const defaultOptions = {
initialPage: 1,
autoResetPage: false,
};
const { itemsPerPage, initialPage, autoResetPage } = Object.assign(
defaultOptions,
options
);
const totalItems = data?.length ?? 0;
const initialPageIndex = initialPage > 0 ? initialPage - 1 : 0;
const [pageIndex, setPageIndex] = useState(initialPageIndex);
const startIndex =
typeof itemsPerPage === "number" ? pageIndex * itemsPerPage : 0;
const paginate = (pageNumber: number) => setPageIndex(pageNumber - 1);

useEffect(() => {
if (typeof itemsPerPage === "number" && startIndex >= totalItems) {
!autoResetPage && Math.floor(totalItems / itemsPerPage) > 0
? // go to the last available page if the current page is out of bounds
setPageIndex(Math.floor(totalItems / itemsPerPage) - 1)
: // go to the initial page if autoResetPage is true
setPageIndex(0);
}
}, [
pageIndex,
startIndex,
setPageIndex,
totalItems,
itemsPerPage,
autoResetPage,
]);

const pageData = useMemo(
() =>
typeof itemsPerPage === "number"
? data?.slice(startIndex, startIndex + itemsPerPage)
: data,
[startIndex, data, itemsPerPage]
);

return {
pageData,
currentPage: pageIndex + 1,
paginate,
itemsPerPage,
totalItems,
};
}

0 comments on commit 84dcd00

Please sign in to comment.