diff --git a/src/components/TablePagination/TablePagination.scss b/src/components/TablePagination/TablePagination.scss new file mode 100644 index 000000000..a7756b72c --- /dev/null +++ b/src/components/TablePagination/TablePagination.scss @@ -0,0 +1,40 @@ +@import "~vanilla-framework/scss/settings"; + +.pagination { + align-items: baseline; + display: flex; + margin-top: 1.2rem; + + .description { + flex-grow: 1; + } + + .back { + margin: 0 $spv--large; + + .p-icon--chevron-down { + rotate: 90deg; + } + } + + .next { + margin: 0 $spv--large; + + .p-icon--chevron-down { + rotate: 270deg; + } + } + + .pagination-input { + margin-right: $spv--small; + min-width: 0; + width: 3rem; + } + + .pagination-select { + margin-bottom: 0; + margin-left: $spv--x-large; + min-width: 0; + width: 7rem; + } +} diff --git a/src/components/TablePagination/TablePagination.stories.mdx b/src/components/TablePagination/TablePagination.stories.mdx new file mode 100644 index 000000000..ed46becf9 --- /dev/null +++ b/src/components/TablePagination/TablePagination.stories.mdx @@ -0,0 +1,214 @@ +import { ArgsTable, Canvas, Meta, Story } from "@storybook/addon-docs"; + +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. + +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 ```dataPropOverride```. + +### Props + + + +### Default + + + + {Template.bind({})} + + + +### Custom page limit + + + + {Template.bind({})} + + + +### Custom display title + + + Hello there + }} + > + {Template.bind({})} + + + +### Render above + + + + {() => { + const data = [ + { + columns: [ + { content: "Ready", role: "rowheader" }, + { content: 1, className: "u-align--right" }, + { content: "1 GiB", className: "u-align--right" }, + { content: 2, className: "u-align--right" }, + { content: 42, className: "u-align--right" }, + ], + sortData: { + status: "ready", + cores: 2, + ram: 1, + disks: 2, + }, + }, + { + columns: [ + { content: "Idle", role: "rowheader" }, + { content: 1, className: "u-align--right" }, + { content: "1 GiB", className: "u-align--right" }, + { content: 2, className: "u-align--right" }, + { content: 23, className: "u-align--right" }, + ], + sortData: { + status: "idle", + cores: 1, + ram: 1, + disks: 2, + }, + }, + { + columns: [ + { content: "Waiting", role: "rowheader" }, + { content: 8, className: "u-align--right" }, + { content: "3.9 GiB", className: "u-align--right" }, + { content: 3, className: "u-align--right" }, + { content: 0, className: "u-align--right" }, + ], + sortData: { + status: "waiting", + cores: 8, + ram: 3.9, + disks: 3, + }, + }, + ]; + + const headers = [ + { content: "Status", sortKey: "status" }, + { content: "Cores", sortKey: "cores", className: "u-align--right" }, + { content: "RAM", sortKey: "ram", className: "u-align--right" }, + { content: "Disks", sortKey: "disks", className: "u-align--right" }, + { content: "Networks", className: "u-align--right" }, + ]; + + return ( + + + + ); + }} + + + +### Render below + + + + {() => { + const data = [ + { + columns: [ + { content: "Ready", role: "rowheader" }, + { content: 1, className: "u-align--right" }, + { content: "1 GiB", className: "u-align--right" }, + { content: 2, className: "u-align--right" }, + { content: 42, className: "u-align--right" }, + ], + sortData: { + status: "ready", + cores: 2, + ram: 1, + disks: 2, + }, + }, + { + columns: [ + { content: "Idle", role: "rowheader" }, + { content: 1, className: "u-align--right" }, + { content: "1 GiB", className: "u-align--right" }, + { content: 2, className: "u-align--right" }, + { content: 23, className: "u-align--right" }, + ], + sortData: { + status: "idle", + cores: 1, + ram: 1, + disks: 2, + }, + }, + { + columns: [ + { content: "Waiting", role: "rowheader" }, + { content: 8, className: "u-align--right" }, + { content: "3.9 GiB", className: "u-align--right" }, + { content: 3, className: "u-align--right" }, + { content: 0, className: "u-align--right" }, + ], + sortData: { + status: "waiting", + cores: 8, + ram: 3.9, + disks: 3, + }, + }, + ]; + + const headers = [ + { content: "Status", sortKey: "status" }, + { content: "Cores", sortKey: "cores", className: "u-align--right" }, + { content: "RAM", sortKey: "ram", className: "u-align--right" }, + { content: "Disks", sortKey: "disks", className: "u-align--right" }, + { content: "Networks", className: "u-align--right" }, + ]; + + return ( + + + + ); + }} + + diff --git a/src/components/TablePagination/TablePagination.test.tsx b/src/components/TablePagination/TablePagination.test.tsx new file mode 100644 index 000000000..44242ef6f --- /dev/null +++ b/src/components/TablePagination/TablePagination.test.tsx @@ -0,0 +1,70 @@ +/* eslint-disable testing-library/no-node-access */ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import TablePagination from "./TablePagination"; +import userEvent from "@testing-library/user-event"; + +const dummyData = [ + { id: "row-1" }, + { id: "row-2" }, + { id: "row-3" }, + { id: "row-4" }, + { id: "row-5" }, +]; + +describe("", () => { + // snapshot tests + it("renders table pagination and matches the snapshot", () => { + render(); + + expect(screen.getByRole("navigation")).toMatchSnapshot(); + }); + + // unit tests + it("renders default display title correctly when no pagination takes place", () => { + render(); + + expect(screen.getByRole("navigation")).toHaveTextContent( + "Showing all 5 items" + ); + }); + + it("renders default display title correctly when pagination takes place", () => { + render(); + + expect(screen.getByRole("navigation")).toHaveTextContent( + "Showing 1 out of 5 items" + ); + }); + + it("has correct per page setting when changed", () => { + render(); + + expect(screen.getByRole("navigation")).toHaveTextContent("2/page"); + userEvent.selectOptions( + screen.getByRole("combobox", { name: "Items per page" }), + "5" + ); + expect(screen.getByRole("navigation")).toHaveTextContent("5/page"); + }); + + it("should paginate correctly in incrementing or decrementing directions", async () => { + 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", + }); + expect(currentPageInput).toHaveValue(1); + await userEvent.click(decButton); + expect(currentPageInput).toHaveValue(1); + await userEvent.click(incButton); + expect(currentPageInput).toHaveValue(2); + await fireEvent.change(currentPageInput, { target: { value: 5 } }); + expect(currentPageInput).toHaveValue(5); + await userEvent.click(incButton); + expect(currentPageInput).toHaveValue(5); + await userEvent.click(decButton); + expect(currentPageInput).toHaveValue(4); + }); +}); diff --git a/src/components/TablePagination/TablePagination.tsx b/src/components/TablePagination/TablePagination.tsx new file mode 100644 index 000000000..67678bfd6 --- /dev/null +++ b/src/components/TablePagination/TablePagination.tsx @@ -0,0 +1,192 @@ +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"; + +/** + * 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 dataPropOverride + * for those child components. + * @param children - react node children to iterate + * @param dataPropOverride - the name of the prop from the children components to override + * @param data - actual data to be passed to the prop specified by @param dataPropOverride + */ +const renderChildren = ( + children: ReactNode, + dataPropOverride: string, + data: unknown[] +) => { + return Children.map(children, (child) => { + return cloneElement(child as ReactElement, { + [dataPropOverride]: 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<{ + /** + * 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 + * reuiqred by the child table component + */ + data: unknown[]; + /** + * this is the prop of the underlying table component that should be overwritten with + * paginated data. If now specified, the default value for this prop is set to @constant rows + * which is the data prop for the @func MainTable component + */ + dataPropOverride?: string; + /** + * the name of the item associated to each row within the table. + */ + itemName?: string; + /** + * custom styling for the pagination container + */ + containerClass?: string; + /** + * custom title to be displayed by the pagination + */ + displayTitle?: ReactNode; + /** + * custom per page limits express as an array of numbers. default is [50, 100, 200] + */ + pageLimits?: number[]; + /** + * render the pagination component below the table? default to false + */ + renderBelow?: boolean; + /** + * is the input data filtered? If this is set to @constant true, the default display + * title will include 'filtered' text + */ + dataFiltered?: boolean; +}> & + HTMLAttributes; + +const TablePagination = ({ + data, + containerClass, + itemName = "item", + displayTitle, + renderBelow = false, + dataPropOverride = "rows", + pageLimits = DEFAULT_PAGE_LIMITS, + dataFiltered = false, + 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 getDisplayTitle = () => { + if (displayTitle) { + return displayTitle; + } + + const visibleCount = pageData.length; + const filteredText = dataFiltered ? "filtered" : ""; + + if (isSmallScreen) { + return `${visibleCount} out of ${totalItems}`; + } + + if (visibleCount === totalItems && visibleCount > 1) { + return `Showing all ${totalItems} ${filteredText} ${itemName}s`; + } + + return `Showing ${visibleCount} out of ${totalItems} ${filteredText} ${itemName}${ + totalItems !== 1 ? "s" : "" + }`; + }; + + const getPageLimitOptions = () => { + return generatePagingOptions(pageLimits || DEFAULT_PAGE_LIMITS); + }; + + const totalPages = Math.ceil(data.length / pageSize); + + return ( + <> + {renderBelow && renderChildren(children, dataPropOverride, pageData)} +
+
+ {getDisplayTitle()} +
+ + {" "} + of {totalPages} + + + ); +}; + +export default TablePaginationControls; diff --git a/src/components/TablePagination/TablePaginationControls/__snapshots__/TablePaginationControls.test.tsx.snap b/src/components/TablePagination/TablePaginationControls/__snapshots__/TablePaginationControls.test.tsx.snap new file mode 100644 index 000000000..3fc41511a --- /dev/null +++ b/src/components/TablePagination/TablePaginationControls/__snapshots__/TablePaginationControls.test.tsx.snap @@ -0,0 +1,35 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders table pagination controls and matches the snapshot 1`] = ` +Array [ + , + , +] +`; + +exports[` renders table pagination controls and matches the snapshot 2`] = ` + +`; diff --git a/src/components/TablePagination/TablePaginationControls/index.ts b/src/components/TablePagination/TablePaginationControls/index.ts new file mode 100644 index 000000000..48aa68113 --- /dev/null +++ b/src/components/TablePagination/TablePaginationControls/index.ts @@ -0,0 +1,2 @@ +export { default } from "./TablePaginationControls"; +export type { Props as TablePaginationControlsProps } from "./TablePaginationControls"; diff --git a/src/components/TablePagination/__snapshots__/TablePagination.test.tsx.snap b/src/components/TablePagination/__snapshots__/TablePagination.test.tsx.snap new file mode 100644 index 000000000..7670acd10 --- /dev/null +++ b/src/components/TablePagination/__snapshots__/TablePagination.test.tsx.snap @@ -0,0 +1,98 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders table pagination and matches the snapshot 1`] = ` + +`; diff --git a/src/components/TablePagination/index.ts b/src/components/TablePagination/index.ts new file mode 100644 index 000000000..aac664179 --- /dev/null +++ b/src/components/TablePagination/index.ts @@ -0,0 +1,2 @@ +export { default } from "./TablePagination"; +export type { Props as TablePaginationProps } from "./TablePagination"; diff --git a/src/index.ts b/src/index.ts index 44ce5f406..f606e8ca5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -160,3 +160,5 @@ export type { ValueOf, } from "./types"; export { Theme } from "./enums"; + +export { default as TablePagination } from "./components/TablePagination";