From 88e478d9f6f3a74277b2be3eae8ac8a00e263c2d Mon Sep 17 00:00:00 2001 From: Nathan Vasse Date: Mon, 20 Feb 2023 16:27:37 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(react)=20add=20Pagination=20component?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In order to create a DataGrid we first need a fully working pagination component. It comes with multiples working examples in the documentation. --- apps/demo/src/App.tsx | 15 ++ apps/demo/src/main.tsx | 12 +- .../src/components/Pagination/index.scss | 19 ++ .../src/components/Pagination/index.spec.tsx | 204 ++++++++++++++++++ .../components/Pagination/index.stories.mdx | 67 ++++++ .../components/Pagination/index.stories.tsx | 84 ++++++++ .../react/src/components/Pagination/index.tsx | 171 +++++++++++++++ .../react/src/components/Pagination/utils.tsx | 16 ++ packages/react/src/index.scss | 1 + packages/react/src/index.ts | 1 + packages/react/vite.config.ts | 1 + 11 files changed, 584 insertions(+), 7 deletions(-) create mode 100644 apps/demo/src/App.tsx create mode 100644 packages/react/src/components/Pagination/index.scss create mode 100644 packages/react/src/components/Pagination/index.spec.tsx create mode 100644 packages/react/src/components/Pagination/index.stories.mdx create mode 100644 packages/react/src/components/Pagination/index.stories.tsx create mode 100644 packages/react/src/components/Pagination/index.tsx create mode 100644 packages/react/src/components/Pagination/utils.tsx diff --git a/apps/demo/src/App.tsx b/apps/demo/src/App.tsx new file mode 100644 index 0000000..9ed2f7e --- /dev/null +++ b/apps/demo/src/App.tsx @@ -0,0 +1,15 @@ +import { Button, usePagination, Pagination } from "@openfun/cunningham-react"; +import React from "react"; +import { tokens } from "./cunningham-tokens"; + +export const App = () => { + const pagination = usePagination({ defaultPage: 50, defaultPagesCount: 100 }); + return ( +
+

Cunningham Demo.

+ +

Primary-500 color is {tokens.theme.colors["primary-500"]}

+ +
+ ); +}; diff --git a/apps/demo/src/main.tsx b/apps/demo/src/main.tsx index e03e1de..afd17ff 100644 --- a/apps/demo/src/main.tsx +++ b/apps/demo/src/main.tsx @@ -1,15 +1,13 @@ import React from "react"; import ReactDOM from "react-dom/client"; import "./index.scss"; -import { Button } from "@openfun/cunningham-react"; -import { tokens } from "./cunningham-tokens"; +import { CunninghamProvider } from "@openfun/cunningham-react"; +import { App } from "./App"; ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( -
-

Cunningham Demo.

- -

Primary-500 color is {tokens.theme.colors["primary-500"]}

-
+ + +
); diff --git a/packages/react/src/components/Pagination/index.scss b/packages/react/src/components/Pagination/index.scss new file mode 100644 index 0000000..c7c810a --- /dev/null +++ b/packages/react/src/components/Pagination/index.scss @@ -0,0 +1,19 @@ +.c__pagination { + display: flex; + gap: 2rem; + + &__list { + display: flex; + align-items: center; + border: 1px var(--c--theme--colors--greyscale-200) solid; + border-radius: 2px; + padding: var(--c--theme--spacings--st); + background: var(--c--theme--colors--greyscale-000); + } + + &__goto { + display: flex; + align-items: center; + gap: 0.5rem; + } +} \ No newline at end of file diff --git a/packages/react/src/components/Pagination/index.spec.tsx b/packages/react/src/components/Pagination/index.spec.tsx new file mode 100644 index 0000000..3d264ac --- /dev/null +++ b/packages/react/src/components/Pagination/index.spec.tsx @@ -0,0 +1,204 @@ +import React from "react"; +import { act, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { fireEvent } from "@testing-library/dom"; +import { Pagination, usePagination } from "components/Pagination/index"; +import { expectPaginationList } from "components/Pagination/utils"; +import { CunninghamProvider } from "components/Provider"; + +describe("", () => { + const Wrapper = (params: Parameters[0]) => () => { + const pagination = usePagination(params); + return ( + + + + ); + }; + + it("does not render pagination when pagesCount is not set", async () => { + const Component = Wrapper({ defaultPage: 1 }); + render(); + expect(document.querySelector(".c__pagination")).toBeNull(); + }); + it("does not render pagination when pagesCount = 1", async () => { + const Component = Wrapper({ defaultPage: 1, defaultPagesCount: 1 }); + render(); + expect(document.querySelector(".c__pagination")).toBeNull(); + }); + it("renders pagination with 2 pages", async () => { + const Component = Wrapper({ defaultPage: 1, defaultPagesCount: 2 }); + render(); + expect(document.querySelector(".c__pagination")).toBeDefined(); + expectPaginationList([ + { text: "navigate_before", name: "Go to previous page" }, + { text: "1", name: "You are currently on page 1" }, + { text: "2", name: "Go to page 2" }, + { text: "navigate_next", name: "Go to next page" }, + ]); + }); + it("renders pagination with 10 pages", async () => { + const Component = Wrapper({ defaultPage: 1, defaultPagesCount: 10 }); + render(); + expect(document.querySelector(".c__pagination")).toBeDefined(); + expectPaginationList([ + { text: "navigate_before", name: "Go to previous page" }, + { text: "1", name: "You are currently on page 1" }, + { text: "2", name: "Go to page 2" }, + { text: "3", name: "Go to page 3" }, + { text: "..." }, + { text: "10", name: "Go to page 10" }, + { text: "navigate_next", name: "Go to next page" }, + ]); + }); + it("renders pagination with 100 pages with current = 50", async () => { + const Component = Wrapper({ defaultPage: 50, defaultPagesCount: 100 }); + render(); + expect(document.querySelector(".c__pagination")).toBeDefined(); + expectPaginationList([ + { text: "navigate_before", name: "Go to previous page" }, + { text: "1", name: "Go to page 1" }, + { text: "..." }, + { text: "48", name: "Go to page 48" }, + { text: "49", name: "Go to page 49" }, + { text: "50", name: "You are currently on page 50" }, + { text: "51", name: "Go to page 51" }, + { text: "52", name: "Go to page 52" }, + { text: "..." }, + { text: "100", name: "Go to page 100" }, + { text: "navigate_next", name: "Go to next page" }, + ]); + }); + it("navigates next and previous", async () => { + // Verify that next and previous can be disabled + const Component = Wrapper({ defaultPage: 1, defaultPagesCount: 3 }); + render(); + expect(document.querySelector(".c__pagination")).toBeDefined(); + + // Current page = 1 + expectPaginationList([ + { text: "navigate_before", name: "Go to previous page", disabled: true }, + { text: "1", name: "You are currently on page 1" }, + { text: "2", name: "Go to page 2" }, + { text: "3", name: "Go to page 3" }, + { text: "navigate_next", name: "Go to next page", disabled: false }, + ]); + + const nextButton = screen.getByRole("button", { + name: "Go to next page", + }); + + const user = userEvent.setup(); + // Go to page 2. + user.click(nextButton); + + await waitFor(() => + expectPaginationList([ + { + text: "navigate_before", + name: "Go to previous page", + disabled: false, + }, + { text: "1", name: "Go to page 1" }, + { text: "2", name: "You are currently on page 2" }, + { text: "3", name: "Go to page 3" }, + { text: "navigate_next", name: "Go to next page", disabled: false }, + ]) + ); + + // Go to page 3. + user.click(nextButton); + + await waitFor(() => + expectPaginationList([ + { + text: "navigate_before", + name: "Go to previous page", + disabled: false, + }, + { text: "1", name: "Go to page 1" }, + { text: "2", name: "Go to page 2" }, + { text: "3", name: "You are currently on page 3" }, + { text: "navigate_next", name: "Go to next page", disabled: true }, + ]) + ); + + const previousButton = screen.getByRole("button", { + name: "Go to previous page", + }); + + // Go to page 2. + user.click(previousButton); + + await waitFor(() => + expectPaginationList([ + { + text: "navigate_before", + name: "Go to previous page", + disabled: false, + }, + { text: "1", name: "Go to page 1" }, + { text: "2", name: "You are currently on page 2" }, + { text: "3", name: "Go to page 3" }, + { text: "navigate_next", name: "Go to next page", disabled: false }, + ]) + ); + + // Go to page 1. + user.click(previousButton); + + await waitFor(() => + expectPaginationList([ + { + text: "navigate_before", + name: "Go to previous page", + disabled: true, + }, + { text: "1", name: "You are currently on page 1" }, + { text: "2", name: "Go to page 2" }, + { text: "3", name: "Go to page 3" }, + { text: "navigate_next", name: "Go to next page", disabled: false }, + ]) + ); + }); + it("can goto page", async () => { + const Component = Wrapper({ defaultPage: 50, defaultPagesCount: 100 }); + render(); + screen.getByRole("button", { name: "You are currently on page 50" }); + const input = screen.getByRole("spinbutton", { name: "Go to any page" }); + const user = userEvent.setup(); + + // Go to page 60. + await act(async () => { + await user.type(input, "60"); + // We cannot use `user.type(input, "60{enter}")` due to the following bug: https://github.com/testing-library/user-event/issues/1074. + fireEvent.submit(input); + }); + + await waitFor(() => + screen.getByRole("button", { name: "You are currently on page 60" }) + ); + + // Try to go to page > 100 and verify that it goes to 100. + await act(async () => { + await user.type(input, "110"); + // We cannot use `user.type(input, "60{enter}")` due to the following bug: https://github.com/testing-library/user-event/issues/1074. + fireEvent.submit(input); + }); + + await waitFor(() => + screen.getByRole("button", { name: "You are currently on page 100" }) + ); + + // Try to go to page < 1 and verify that it goes to 1. + await act(async () => { + await user.type(input, "-10"); + // We cannot use `user.type(input, "60{enter}")` due to the following bug: https://github.com/testing-library/user-event/issues/1074. + fireEvent.submit(input); + }); + + await waitFor(() => + screen.getByRole("button", { name: "You are currently on page 1" }) + ); + }); +}); diff --git a/packages/react/src/components/Pagination/index.stories.mdx b/packages/react/src/components/Pagination/index.stories.mdx new file mode 100644 index 0000000..12ed574 --- /dev/null +++ b/packages/react/src/components/Pagination/index.stories.mdx @@ -0,0 +1,67 @@ +import { Canvas, Meta, Story, Source, ArgsTable } from '@storybook/addon-docs'; +import { Pagination, usePagination } from './index'; + + + +# Pagination + +The Pagination component can be used anywhere you have some data you want to split between pages, you can use it +for synchronous loading as well as asynchronous loading. You can paginate your already loaded data, but you can also +fetch it from a server, the component is really versatile. + + + + + + + + +## Usage + +The Pagination component comes with a hook called `usePagination` that handles the logic behind it. Pagination is a +controlled component, so, to make it more handy we provide you this hook. + +The most basic usage you can make of it is this one, defining a pagination with 10 pages. + +### Basic + + + + + + +### List of items + +But this won't make you really happy if you want to paginate your list of items, so you can wire things a bit better. +Let's make a component that paginate a list of random number. + + + + + +### Set page programmatically + +You can also set the page programmatically, for example, if you want to use a query parameter to set the page. + + + + + +### Things to know + +- The pagination will never render if the number of pages is less than 2. + +## Props + +### `` component + + + +### `usePagination` hook + + \ No newline at end of file diff --git a/packages/react/src/components/Pagination/index.stories.tsx b/packages/react/src/components/Pagination/index.stories.tsx new file mode 100644 index 0000000..f24f47d --- /dev/null +++ b/packages/react/src/components/Pagination/index.stories.tsx @@ -0,0 +1,84 @@ +import { ComponentMeta } from "@storybook/react"; +import React, { useEffect, useMemo, useState } from "react"; +import { Pagination, usePagination } from "components/Pagination/index"; +import { CunninghamProvider } from "components/Provider"; + +export default { + title: "Components/Pagination", + component: Pagination, +} as ComponentMeta; + +export const Basic = () => { + const pagination = usePagination({ + defaultPagesCount: 100, + defaultPage: 50, + }); + return ( + + + + ); +}; + +export const List = () => { + // Numbers from 0 to 99. + const database = useMemo(() => Array.from(Array(100).keys()), []); + // Items to display on the current page. + const [items, setItems] = useState([]); + const pagination = usePagination({ pageSize: 10 }); + + // On page change. + useEffect(() => { + // Simulate a HTTP request delay. + const timeout = setTimeout(() => { + // Sets the number of pages based on the number of items in the database. + pagination.setPagesCount( + Math.ceil(database.length / pagination.pageSize) + ); + // Sets the items to display on the current page. + setItems( + database.slice( + (pagination.page - 1) * pagination.pageSize, + pagination.page * pagination.pageSize + ) + ); + }, 500); + return () => { + clearTimeout(timeout); + }; + }, [pagination.page]); + + return ( + +
+
+ {items.map((item) => ( +
+ {item} +
+ ))} +
+ +
+
+ ); +}; + +export const ForcePage = () => { + const pagination = usePagination({ + defaultPagesCount: 10, + }); + useEffect(() => { + const timeout = setTimeout(() => { + pagination.setPage(5); + }, 500); + return () => { + clearTimeout(timeout); + }; + }, []); + return ( + + + + ); +}; diff --git a/packages/react/src/components/Pagination/index.tsx b/packages/react/src/components/Pagination/index.tsx new file mode 100644 index 0000000..977ff93 --- /dev/null +++ b/packages/react/src/components/Pagination/index.tsx @@ -0,0 +1,171 @@ +import React, { Fragment, useState } from "react"; +import { Button } from "components/Button"; +import { Input } from "components/Forms/Input"; +import { useCunningham } from "components/Provider"; + +export interface PaginationProps { + /** Current page */ + page: number; + /** Called when page need to change */ + onPageChange: (page: number) => void; + /** Total number of pages */ + pagesCount?: number; + /** Total number of items per page */ + // eslint-disable-next-line react/no-unused-prop-types + pageSize: number; +} + +export const usePagination = ({ + defaultPage = 1, + defaultPagesCount, + pageSize = 10, +}: { + /** Default current page */ + defaultPage?: number; + /** Default total number of pages */ + defaultPagesCount?: number; + /** Total number of items per page */ + pageSize?: number; +}) => { + const [page, setPage] = useState(defaultPage); + const [pagesCount, setPagesCount] = useState(defaultPagesCount); + return { + page, + setPage, + onPageChange: setPage, + pagesCount, + setPagesCount, + pageSize, + }; +}; + +export const Pagination = ({ + page, + onPageChange, + pagesCount = 0, +}: PaginationProps) => { + const { t } = useCunningham(); + const [gotoValue, setGotoValue] = useState(""); + + if (pagesCount <= 1) { + return null; + } + + // Create the default list of all the page numbers we intend to show + const pageList = [ + 1, + // If there is just one page between first page and currentPage - 2, + // we can display this page number instead of "..." + page - 2 === 3 ? page - 3 : -1, + page - 2, + page - 1, + page, + page + 1, + page + 2, + // If there is just one page between maxPage and currentPage + 2, + // we can display this page number instead of "..." + page + 3 === pagesCount - 1 ? page + 3 : -1, + pagesCount, + ] + // Filter out page numbers below 1 (when currentPage is 1 or 2) + .filter((_page) => _page > 0) + // Filter out page numbers above the max (they do not have anything to display) + .filter((_page) => _page <= pagesCount) + // Drop duplicates (this is trivial as our pageList is sorted) + .filter((_page, index, list) => _page !== list[index - 1]); + + const onPreviousClick = () => { + onPageChange(Math.max(page - 1, 0)); + }; + const onNextClick = () => { + onPageChange(Math.min(page + 1, pagesCount)); + }; + + const gotoPage = () => { + let value = +gotoValue; + if (value < 0) { + value = 1; + } + if (value > pagesCount) { + value = pagesCount; + } + onPageChange(value); + setGotoValue(""); + }; + + const canPrevious = page > 1; + const canNext = page < pagesCount; + + return ( +
+
+ + ) : ( + + )} + + ))} +
+
+
+ {t("components.pagination.goto_label")} +
+
{ + e.preventDefault(); + gotoPage(); + }} + > + setGotoValue(e.target.value)} + min={1} + max={pagesCount} + /> +
+
+
+ ); +}; diff --git a/packages/react/src/components/Pagination/utils.tsx b/packages/react/src/components/Pagination/utils.tsx new file mode 100644 index 0000000..d3bc640 --- /dev/null +++ b/packages/react/src/components/Pagination/utils.tsx @@ -0,0 +1,16 @@ +export const expectPaginationList = ( + expectations: { text: string; name?: string; disabled?: boolean }[] +) => { + const buttons = document.querySelectorAll(".c__pagination__list > *"); + expect(buttons.length).toEqual(expectations.length); + buttons.forEach((button, k) => { + const expected = expectations[k]; + if (expected.name) { + expect(button.getAttribute("aria-label")).toEqual(expected.name); + } + expect(button.textContent).toEqual(expected.text); + if (expected.disabled !== undefined) { + expect(button.hasAttribute("disabled")).toBe(expected.disabled); + } + }); +}; diff --git a/packages/react/src/index.scss b/packages/react/src/index.scss index 0054f81..06cdc1c 100644 --- a/packages/react/src/index.scss +++ b/packages/react/src/index.scss @@ -2,6 +2,7 @@ @import '@openfun/cunningham-tokens/default-tokens'; @import './components/Button'; @import './components/Forms/Input'; +@import './components/Pagination'; * { font-family: var(--c--theme--font--families--base); diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 368e96f..33c42a3 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,4 +1,5 @@ import "./index.scss"; export * from "./components/Button"; +export * from "./components/Pagination"; export * from "./components/Provider"; diff --git a/packages/react/vite.config.ts b/packages/react/vite.config.ts index e66d60c..4cbf663 100644 --- a/packages/react/vite.config.ts +++ b/packages/react/vite.config.ts @@ -30,6 +30,7 @@ export default defineConfig({ environment: "jsdom", reporters: "verbose", globals: true, + watchExclude: ["**/cunningham-tokens.js"], coverage: { all: true, include: ["src/**/*.{ts,tsx}"],