diff --git a/src/components/MainTable/MainTable.tsx b/src/components/MainTable/MainTable.tsx index b3fc4ed1c..e2aa71d55 100644 --- a/src/components/MainTable/MainTable.tsx +++ b/src/components/MainTable/MainTable.tsx @@ -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"; @@ -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< { @@ -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 {emptyStateMsg}; - } - // 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) => + 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"]; @@ -279,7 +239,41 @@ const generateRows = ( ); } ); - return {rowItems}; + +const sortRows = ({ + currentSortDirection, + currentSortKey, + rows, + sortable, + sortFunction, +}: Pick & { + 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 = ({ @@ -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(() => { @@ -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 ( <> @@ -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 ? ( + + ) : ( + + {generateRows({ + rows: finalRows, + headers, + responsive, + expanding, + })} + + ) + }
{emptyStateMsg}
{paginate && rows && rows.length > 0 && ( { + 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]); +}); diff --git a/src/hooks/usePagination.ts b/src/hooks/usePagination.ts new file mode 100644 index 000000000..67a482d9c --- /dev/null +++ b/src/hooks/usePagination.ts @@ -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( + data: Array, + options?: { + itemsPerPage: I; + initialPage?: number; + autoResetPage?: boolean; + } +): { + pageData: Array; + 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, + }; +}