From 8fc31de8f80f2c4549049821dc59c82248893476 Mon Sep 17 00:00:00 2001 From: Mason Hu Date: Wed, 31 Jan 2024 18:11:40 +0200 Subject: [PATCH] feat: implement externally controlled mode for TablePagination Signed-off-by: Mason Hu --- .../TablePagination.stories.mdx | 80 +++++- .../TablePagination/TablePagination.test.tsx | 148 +++++++++- .../TablePagination/TablePagination.tsx | 256 ++++++++++-------- .../TablePaginationControls.test.tsx | 17 +- .../TablePaginationControls.tsx | 85 ++++-- .../TablePaginationControls.test.tsx.snap | 3 +- src/components/TablePagination/utils.tsx | 97 +++++++ 7 files changed, 530 insertions(+), 156 deletions(-) create mode 100644 src/components/TablePagination/utils.tsx diff --git a/src/components/TablePagination/TablePagination.stories.mdx b/src/components/TablePagination/TablePagination.stories.mdx index 6679f254..ed6f13b1 100644 --- a/src/components/TablePagination/TablePagination.stories.mdx +++ b/src/components/TablePagination/TablePagination.stories.mdx @@ -1,20 +1,56 @@ -import { ArgsTable, Canvas, Meta, Story } from "@storybook/addon-docs"; +import { ArgsTable, Canvas, Meta, Story } from "@storybook/blocks"; import TablePagination from "./TablePagination"; import MainTable from "../MainTable"; - + export const Template = (args) => ; ### TablePagination -This is an HOC [React](https://reactjs.org/) component for applying pagination to input data for direct child components. -This component is un-opinionated about the structure of the input data and can be used with any child component that displays -a list of data. However, the styling and behaviour of this component were designed to work nicely with the ```MainTable``` component. +This is an HOC [React](https://reactjs.org/) component for applying pagination to direct children components. This component is un-opinionated about +the structure of the input data and can be used with any child component that displays a list of data. However, the styling and behaviour of this component were designed +to work nicely with the `MainTable` component. To use this component, simply wrap a child component with it and provide the data that you want +to paginate to the `data` prop. This component will then pass the paged data to all direct child components via a child prop specified by `dataForwardProp`. +The component may be externally controlled, see following sections for detailed explanation. -To use this component, simply wrap a child component with it and provide the data that you want to paginate to the ```data``` prop. -This component will then pass the paged data to all direct child components via a child prop specified by ```dataForwardProp```. +#### Externally controlled pagination + +For externally controlled mode, you will be responsible for the pagination logic and therefore the component will be purely presentational. +The pagination behaviour is controlled outside of this component. Note the data injection to child components is essentially a passthrough in this case. +To enable externally controlled mode for this component, set the `externallyControlled` prop to `true`. From there, it is your responsibility +to ensure that the following props `totalItems`, `currentPage`, `pageSize`, `onPageChange` and `onPageSizeChange` are set properly. +You can refer to the props table below on how to set these props. + +#### Un-controlled pagination + +In this mode, the component assumes that the input data is not paginated. The component will implement the pagination logic and apply it to the input data +then inject the paged data into direct child components. This is the default mode of operations for the component where `externallyControlled` prop is set +to `false`. ### Props @@ -26,7 +62,13 @@ This component will then pass the paged data to all direct child componen {Template.bind({})} @@ -39,8 +81,14 @@ This component will then pass the paged data to all direct child componen {Template.bind({})} @@ -53,8 +101,14 @@ This component will then pass the paged data to all direct child componen Hello there + data: [ + { id: "row-1" }, + { id: "row-2" }, + { id: "row-3" }, + { id: "row-4" }, + { id: "row-5" }, + ], + description: Hello there, }} > {Template.bind({})} @@ -134,6 +188,7 @@ This component will then pass the paged data to all direct child componen ); }} + @@ -210,5 +265,6 @@ This component will then pass the paged data to all direct child componen ); }} + diff --git a/src/components/TablePagination/TablePagination.test.tsx b/src/components/TablePagination/TablePagination.test.tsx index 225b5faf..22fadff7 100644 --- a/src/components/TablePagination/TablePagination.test.tsx +++ b/src/components/TablePagination/TablePagination.test.tsx @@ -62,7 +62,7 @@ describe("", () => { expect(currentPageInput).toHaveValue(1); }); - it("should paginate correctly in incrementing or decrementing directions", async () => { + it("should paginate correctly in locally controlled mode", async () => { render(); const incButton = screen.getByRole("button", { name: "Next page" }); const decButton = screen.getByRole("button", { name: "Previous page" }); @@ -87,4 +87,150 @@ describe("", () => { await userEvent.click(decButton); expect(currentPageInput).toHaveValue(2); }); + + it("should paginate correctly in externally controlled mode", async () => { + const totalItems = 100; + let currentPage = 1; + let pageSize = 10; + const handlePageChange = (page: number) => { + currentPage = page; + }; + const handlePageSizeChange = (size: number) => { + currentPage = 1; + pageSize = size; + }; + const { rerender } = render( + + ); + + const incButton = screen.getByRole("button", { name: "Next page" }); + const decButton = screen.getByRole("button", { name: "Previous page" }); + const currentPageInput = screen.getByRole("spinbutton", { + name: "Page number", + }); + const pageSizeSelector = screen.getByRole("combobox", { + name: "Items per page", + }); + + expect(currentPageInput).toHaveValue(1); + await userEvent.click(decButton); + rerender( + + ); + expect(currentPageInput).toHaveValue(1); + + await userEvent.click(incButton); + rerender( + + ); + expect(currentPageInput).toHaveValue(2); + + await userEvent.selectOptions(pageSizeSelector, "20"); + rerender( + + ); + expect(currentPageInput).toHaveValue(1); + + fireEvent.change(currentPageInput, { target: { value: 5 } }); + rerender( + + ); + expect(currentPageInput).toHaveValue(5); + + await userEvent.click(incButton); + rerender( + + ); + expect(currentPageInput).toHaveValue(5); + + await userEvent.click(decButton); + rerender( + + ); + expect(currentPageInput).toHaveValue(4); + }); + + it("should throw an error if pageSize is not in pageLimits when externally controlled", () => { + // Don't print out massive error logs for this test + console.error = () => ""; + expect(() => + render( + + ) + ).toThrow( + "pageSize must be a valid option in pageLimits, pageLimits is set to [10,20,50]" + ); + }); }); diff --git a/src/components/TablePagination/TablePagination.tsx b/src/components/TablePagination/TablePagination.tsx index c6ec1f35..2d334ba7 100644 --- a/src/components/TablePagination/TablePagination.tsx +++ b/src/components/TablePagination/TablePagination.tsx @@ -1,59 +1,19 @@ import React, { - ChangeEvent, - Children, HTMLAttributes, PropsWithChildren, - ReactElement, ReactNode, - RefObject, - cloneElement, - useEffect, - useRef, useState, } from "react"; -import classnames from "classnames"; -import { usePagination } from "hooks"; -import Select from "components/Select"; -import TablePaginationControls from "./TablePaginationControls"; import "./TablePagination.scss"; +import TablePaginationControls from "./TablePaginationControls"; +import { + DEFAULT_PAGE_LIMITS, + generatePagingOptions, + renderChildren, +} from "./utils"; +import { usePagination } from "hooks"; -/** - * Determine if we are working with a small screen. - * 'small screen' in this case is relative to the width of the description div - */ -const figureSmallScreen = (descriptionRef: RefObject) => { - const descriptionElement = descriptionRef.current; - if (!descriptionElement) { - return true; - } - return descriptionElement.getBoundingClientRect().width < 230; -}; - -/** - * Iterate direct react child components and override the value of the prop specified by @param dataForwardProp - * for those child components. - * @param children - react node children to iterate - * @param dataForwardProp - the name of the prop from the children components to override - * @param data - actual data to be passed to the prop specified by @param dataForwardProp - */ -const renderChildren = ( - children: ReactNode, - dataForwardProp: string, - data: unknown[] -) => { - return Children.map(children, (child) => { - return cloneElement(child as ReactElement, { - [dataForwardProp]: data, - }); - }); -}; - -const DEFAULT_PAGE_LIMITS = [50, 100, 200]; -const generatePagingOptions = (pageLimits: number[]) => { - return pageLimits.map((limit) => ({ value: limit, label: `${limit}/page` })); -}; - -export type Props = PropsWithChildren<{ +export type BasePaginationProps = { /** * list of data elements to be paginated. This component is un-opinionated about * the structure of the data but it should be identical to the data structure @@ -62,7 +22,7 @@ export type Props = PropsWithChildren<{ data: unknown[]; /** * prop name of the child table component that receives paginated data. - * default value is set to @constant rows, which is the data prop for the @func MainTable component + * default value is set to `rows`, which is the data prop for the `MainTable` component */ dataForwardProp?: string; /** @@ -85,94 +45,150 @@ export type Props = PropsWithChildren<{ * place the pagination component above or below the table? */ position?: "above" | "below"; -}> & +}; + +export type ExternalControlProps = BasePaginationProps & { + /** + * Whether the component will be controlled via external state. + */ + externallyControlled?: true; + /** + * the total number of items available within the data. This prop is only relevant + * and will be required if `externallyControlled` is set to `true`. + */ + totalItems: number; + /** + * the current page that's showing. This prop is only relevant and will be required + * if `externallyControlled` is set to `true`. + */ + currentPage: number; + /** + * size per page. This prop is only relevant and will be required if + * `externallyControlled` is set to `true`. + */ + pageSize: number; + /** + * callback indicating a page change event to the parent component. + * This prop is only relevant and will be required if `externallyControlled` is set + * to `true`. + */ + onPageChange: (page: number) => void; + /** + * callback indicating a page size change event to the parent component. + * This prop is only relevant and will be required if `externallyControlled` is set + * to `true`. + */ + onPageSizeChange: (pageSize: number) => void; +}; + +export type InternalControlProps = BasePaginationProps & { + /** + * Whether the component will be controlled via external state. + */ + externallyControlled?: false; +}; + +export type Props = PropsWithChildren< + ExternalControlProps | InternalControlProps +> & HTMLAttributes; -const TablePagination = ({ - data, - className, - itemName = "item", - description, - position = "above", - dataForwardProp = "rows", - pageLimits = DEFAULT_PAGE_LIMITS, - children, - ...divProps -}: Props) => { - const descriptionRef = useRef(null); - const [isSmallScreen, setSmallScreen] = useState(false); - const [pageSize, setPageSize] = useState(() => { +const TablePagination = (props: Props) => { + const { + data, + dataForwardProp = "rows", + itemName = "item", + className, + description, + pageLimits = DEFAULT_PAGE_LIMITS, + position = "above", + externallyControlled, + children, + ...divProps + } = props; + + // Safety check to ensure pageSize is a valid option in + // pageLimits if the component is externally controlled + if (externallyControlled) { + let pageSizeFound = false; + for (const limit of pageLimits) { + if (limit === Number(props.pageSize)) { + pageSizeFound = true; + break; + } + } + + if (!pageSizeFound) { + throw new Error( + `pageSize must be a valid option in pageLimits, pageLimits is set to [${pageLimits}]` + ); + } + } + + const [internalPageSize, setInternalPageSize] = useState(() => { return generatePagingOptions(pageLimits)[0].value; }); - const { paginate, currentPage, pageData, totalItems } = usePagination(data, { - itemsPerPage: pageSize, + const { + paginate, + currentPage: internalCurrentPage, + pageData: internalData, + } = usePagination(externallyControlled ? [] : data, { + itemsPerPage: internalPageSize, autoResetPage: true, }); - useEffect(() => { - const handleResize = () => { - setSmallScreen(figureSmallScreen(descriptionRef)); - }; - window.addEventListener("resize", handleResize); - return () => { - window.removeEventListener("resize", handleResize); - }; - }, [isSmallScreen]); - - const handlePageSizeChange = (e: ChangeEvent) => { - paginate(1); - setPageSize(parseInt(e.target.value)); - }; + const controlData = externallyControlled ? data : internalData; + const controlPageSize = externallyControlled + ? props.pageSize + : internalPageSize; + const controlTotalItems = externallyControlled + ? props.totalItems + : data.length; + const controlCurrentPage = externallyControlled + ? props.currentPage + : internalCurrentPage; - const getDescription = () => { - if (description) { - return description; + const handlePageChange = (page: number) => { + if (externallyControlled) { + props.onPageChange(page); + return; } - const visibleCount = pageData.length; - - if (isSmallScreen) { - return `${visibleCount} out of ${totalItems}`; - } + paginate(page); + }; - if (visibleCount === totalItems && visibleCount > 1) { - return `Showing all ${totalItems} ${itemName}s`; + const handlePageSizeChange = (pageSize: number) => { + if (externallyControlled) { + props.onPageSizeChange(pageSize); + return; } - return `Showing ${visibleCount} out of ${totalItems} ${itemName}${ - totalItems !== 1 ? "s" : "" - }`; + paginate(1); + setInternalPageSize(pageSize); }; - const totalPages = Math.ceil(data.length / pageSize); - const clonedChildren = renderChildren(children, dataForwardProp, pageData); + const clonedChildren = renderChildren(children, dataForwardProp, controlData); + const controls = ( + + ); + return ( <> - {position === "below" && clonedChildren} -
-
- {getDescription()} -
- - +
); }; diff --git a/src/components/TablePagination/TablePaginationControls/__snapshots__/TablePaginationControls.test.tsx.snap b/src/components/TablePagination/TablePaginationControls/__snapshots__/TablePaginationControls.test.tsx.snap index eb5609f2..4446f864 100644 --- a/src/components/TablePagination/TablePaginationControls/__snapshots__/TablePaginationControls.test.tsx.snap +++ b/src/components/TablePagination/TablePaginationControls/__snapshots__/TablePaginationControls.test.tsx.snap @@ -5,7 +5,6 @@ exports[` renders table pagination controls and match