From 8b5575bafbd24fe8d26f718fb371c903624a2f78 Mon Sep 17 00:00:00 2001 From: Mason Hu Date: Wed, 31 Jan 2024 18:11:40 +0200 Subject: [PATCH] feat: implement server pagination mode for TablePagination Signed-off-by: Mason Hu --- .../TablePagination/ClientPagination.tsx | 100 +++++++++++ .../TablePagination/ServerPagination.tsx | 119 +++++++++++++ .../TablePagination.stories.mdx | 82 +++++++-- .../TablePagination/TablePagination.test.tsx | 163 +++++++++++++++++- .../TablePagination/TablePagination.tsx | 157 ++--------------- src/components/TablePagination/utils.tsx | 97 +++++++++++ 6 files changed, 557 insertions(+), 161 deletions(-) create mode 100644 src/components/TablePagination/ClientPagination.tsx create mode 100644 src/components/TablePagination/ServerPagination.tsx create mode 100644 src/components/TablePagination/utils.tsx diff --git a/src/components/TablePagination/ClientPagination.tsx b/src/components/TablePagination/ClientPagination.tsx new file mode 100644 index 000000000..17e7b1033 --- /dev/null +++ b/src/components/TablePagination/ClientPagination.tsx @@ -0,0 +1,100 @@ +import React, { + ChangeEvent, + HTMLAttributes, + PropsWithChildren, + 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 { BasePaginationProps } from "./TablePagination"; +import { + DEFAULT_PAGE_LIMITS, + generatePagingOptions, + getDescription, + renderChildren, + useFigureSmallScreen, +} from "./utils"; + +export type ClientPaginationProps = BasePaginationProps & { + /** + * set pagination to be server based making the component purely presentational or + * set pagination to be client based which will delegate control to this component + */ + type?: "client-pagination"; +}; + +export type Props = PropsWithChildren & + HTMLAttributes; + +const ClientPagination = ({ + data, + className, + itemName = "item", + description, + position = "above", + dataForwardProp = "rows", + pageLimits = DEFAULT_PAGE_LIMITS, + children, + ...divProps +}: Props) => { + const descriptionRef = useRef(null); + const isSmallScreen = useFigureSmallScreen({ descriptionRef }); + const [pageSize, setPageSize] = useState(() => { + return generatePagingOptions(pageLimits)[0].value; + }); + const { paginate, currentPage, pageData, totalItems } = usePagination(data, { + itemsPerPage: pageSize, + autoResetPage: true, + }); + + const handlePageSizeChange = (e: ChangeEvent) => { + paginate(1); + setPageSize(parseInt(e.target.value)); + }; + + const totalPages = Math.ceil(data.length / pageSize); + const clonedChildren = renderChildren(children, dataForwardProp, pageData); + const descriptionDisplay = getDescription({ + description, + data: pageData, + isSmallScreen, + totalItems, + itemName, + }); + + return ( + <> + {position === "below" && clonedChildren} +
+
+ {descriptionDisplay} +
+ + +
+ {position === "above" && clonedChildren} + + ); +}; + +export default ServerPagination; diff --git a/src/components/TablePagination/TablePagination.stories.mdx b/src/components/TablePagination/TablePagination.stories.mdx index 6679f2546..93c54c5d6 100644 --- a/src/components/TablePagination/TablePagination.stories.mdx +++ b/src/components/TablePagination/TablePagination.stories.mdx @@ -1,20 +1,55 @@ -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 supports both client side and server side pagination techniques which are described in the subsequent two sections. -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```. +#### Server side pagination + +For Server side pagination, the control logic is delegated to the server 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 server side pagination mode for this component, set the `type` prop to `server-pagination`. 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. + +#### Client side pagination + +Client side pagination assumes that the input data is not paginated. The component will implement the control logic to page the input data +then inject it into direct child components. To enable client side pagination mode for this component, , set the `type` prop to `client-pagination`. ### Props @@ -26,7 +61,14 @@ This component will then pass the paged data to all direct child componen {Template.bind({})} @@ -39,8 +81,15 @@ This component will then pass the paged data to all direct child componen {Template.bind({})} @@ -53,8 +102,15 @@ 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, + type: "client-pagination" }} > {Template.bind({})} @@ -134,6 +190,7 @@ This component will then pass the paged data to all direct child componen ); }} + @@ -210,5 +267,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 225b5faf7..7a079028f 100644 --- a/src/components/TablePagination/TablePagination.test.tsx +++ b/src/components/TablePagination/TablePagination.test.tsx @@ -15,14 +15,14 @@ const dummyData = [ describe("", () => { // snapshot tests it("renders table pagination and matches the snapshot", () => { - render(); + render(); expect(screen.getByRole("navigation")).toMatchSnapshot(); }); // unit tests it("renders default display title correctly when no pagination takes place", () => { - render(); + render(); expect(screen.getByRole("navigation")).toHaveTextContent( "Showing all 5 items" @@ -30,15 +30,27 @@ describe("", () => { }); it("renders default display title correctly when pagination takes place", () => { - render(); + render( + + ); expect(screen.getByRole("navigation")).toHaveTextContent( - "Showing 1 out of 5 items" + "Showing 1 out of 5 items", ); }); it("has correct per page setting when changed", async () => { - render(); + render( + + ); expect(screen.getByRole("navigation")).toHaveTextContent("2/page"); await userEvent.selectOptions( @@ -49,7 +61,13 @@ describe("", () => { }); it("resets to first page when page size is changed", async () => { - render(); + render( + + ); expect(screen.getByRole("navigation")).toHaveTextContent("2/page"); await userEvent.selectOptions( @@ -63,7 +81,13 @@ describe("", () => { }); it("should paginate correctly in incrementing or decrementing directions", async () => { - render(); + render( + + ); const incButton = screen.getByRole("button", { name: "Next page" }); const decButton = screen.getByRole("button", { name: "Previous page" }); const currentPageInput = screen.getByRole("spinbutton", { @@ -87,4 +111,129 @@ describe("", () => { await userEvent.click(decButton); expect(currentPageInput).toHaveValue(2); }); + + it("should paginate correctly in server-pagination mode and control logic should be external to the component", 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); + }); }); diff --git a/src/components/TablePagination/TablePagination.tsx b/src/components/TablePagination/TablePagination.tsx index c6ec1f35d..e9adfb646 100644 --- a/src/components/TablePagination/TablePagination.tsx +++ b/src/components/TablePagination/TablePagination.tsx @@ -1,59 +1,9 @@ -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 React, { HTMLAttributes, PropsWithChildren, ReactNode } from "react"; import "./TablePagination.scss"; +import ServerPagination, { ServerPaginationProps } from "./ServerPagination"; +import ClientPagination, { ClientPaginationProps } from "./ClientPagination"; -/** - * 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 +12,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,96 +35,19 @@ export type Props = PropsWithChildren<{ * place the pagination component above or below the table? */ position?: "above" | "below"; -}> & - 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(() => { - return generatePagingOptions(pageLimits)[0].value; - }); - const { paginate, currentPage, pageData, totalItems } = usePagination(data, { - itemsPerPage: pageSize, - 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 getDescription = () => { - if (description) { - return description; - } - - const visibleCount = pageData.length; - - if (isSmallScreen) { - return `${visibleCount} out of ${totalItems}`; - } +}; - if (visibleCount === totalItems && visibleCount > 1) { - return `Showing all ${totalItems} ${itemName}s`; - } +export type Props = PropsWithChildren< + BasePaginationProps & (ClientPaginationProps | ServerPaginationProps) +> & + HTMLAttributes; - return `Showing ${visibleCount} out of ${totalItems} ${itemName}${ - totalItems !== 1 ? "s" : "" - }`; - }; +const TablePagination = ({ type = "client-pagination", ...props }: Props) => { + if (type === "server-pagination") { + return ; + } - const totalPages = Math.ceil(data.length / pageSize); - const clonedChildren = renderChildren(children, dataForwardProp, pageData); - return ( - <> - {position === "below" && clonedChildren} -
-
- {getDescription()} -
- -