(react) add Pagination component

In order to create a DataGrid we first need a fully working pagination
component. It comes with multiples working examples in the documentation.
This commit is contained in:
Nathan Vasse
2023-02-20 16:27:37 +01:00
committed by NathanVss
parent 90feb4ba4a
commit 88e478d9f6
11 changed files with 584 additions and 7 deletions

View File

@@ -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;
}
}

View File

@@ -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("<Pagination/>", () => {
const Wrapper = (params: Parameters<typeof usePagination>[0]) => () => {
const pagination = usePagination(params);
return (
<CunninghamProvider>
<Pagination {...pagination} />
</CunninghamProvider>
);
};
it("does not render pagination when pagesCount is not set", async () => {
const Component = Wrapper({ defaultPage: 1 });
render(<Component />);
expect(document.querySelector(".c__pagination")).toBeNull();
});
it("does not render pagination when pagesCount = 1", async () => {
const Component = Wrapper({ defaultPage: 1, defaultPagesCount: 1 });
render(<Component />);
expect(document.querySelector(".c__pagination")).toBeNull();
});
it("renders pagination with 2 pages", async () => {
const Component = Wrapper({ defaultPage: 1, defaultPagesCount: 2 });
render(<Component />);
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(<Component />);
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(<Component />);
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(<Component />);
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(<Component />);
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" })
);
});
});

View File

@@ -0,0 +1,67 @@
import { Canvas, Meta, Story, Source, ArgsTable } from '@storybook/addon-docs';
import { Pagination, usePagination } from './index';
<Meta title="Components/Pagination/Doc" component={Pagination}/>
# 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.
<Canvas withSource={"none"}>
<Story id="components-pagination--basic"/>
</Canvas>
<Source
language='ts'
dark
format={false}
code={`import { Pagination, usePagination } from "@openfun/cunningham-react";`}
/>
## 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
<Canvas withSource="open">
<Story id="components-pagination--basic"/>
</Canvas>
### 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.
<Canvas withSource="open">
<Story id="components-pagination--list"/>
</Canvas>
### Set page programmatically
You can also set the page programmatically, for example, if you want to use a query parameter to set the page.
<Canvas withSource="open">
<Story id="components-pagination--force-page"/>
</Canvas>
### Things to know
- The pagination will never render if the number of pages is less than 2.
## Props
### `<Pagination/>` component
<ArgsTable of={Pagination} />
### `usePagination` hook
<ArgsTable of={usePagination} />

View File

@@ -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<typeof Pagination>;
export const Basic = () => {
const pagination = usePagination({
defaultPagesCount: 100,
defaultPage: 50,
});
return (
<CunninghamProvider>
<Pagination {...pagination} />
</CunninghamProvider>
);
};
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<number[]>([]);
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 (
<CunninghamProvider>
<div>
<div>
{items.map((item) => (
<div className="p-t bg-secondary-300 mb-t" key={item}>
{item}
</div>
))}
</div>
<Pagination {...pagination} />
</div>
</CunninghamProvider>
);
};
export const ForcePage = () => {
const pagination = usePagination({
defaultPagesCount: 10,
});
useEffect(() => {
const timeout = setTimeout(() => {
pagination.setPage(5);
}, 500);
return () => {
clearTimeout(timeout);
};
}, []);
return (
<CunninghamProvider>
<Pagination {...pagination} />
</CunninghamProvider>
);
};

View File

@@ -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 (
<div className="c__pagination">
<div className="c__pagination__list">
<Button
color="tertiary"
aria-label={t("components.pagination.previous_aria")}
onClick={onPreviousClick}
disabled={!canPrevious}
icon={<span className="material-icons">navigate_before</span>}
size="small"
/>
{pageList.map((_page, index) => (
<Fragment key={_page}>
{/* Prepend a cell with "..." when the page number we're rendering does not follow the previous one */}
{_page > (pageList[index - 1] || 0) + 1 && <span>...</span>}
{_page === page ? (
<Button
color="tertiary"
active={true}
aria-label={t("components.pagination.current_page_aria", {
page: _page,
})}
size="small"
>
{_page}
</Button>
) : (
<Button
color="tertiary"
aria-label={t("components.pagination.goto_page_aria", {
page: _page,
})}
onClick={() => onPageChange(_page)}
size="small"
>
{_page}
</Button>
)}
</Fragment>
))}
<Button
color="tertiary"
aria-label={t("components.pagination.next_aria")}
onClick={onNextClick}
disabled={!canNext}
icon={<span className="material-icons">navigate_next</span>}
size="small"
/>
</div>
<div className="c__pagination__goto">
<div className="fs-m clr-greyscale-700">
{t("components.pagination.goto_label")}
</div>
<form
onSubmit={(e) => {
e.preventDefault();
gotoPage();
}}
>
<Input
type="number"
aria-label={t("components.pagination.goto_label_aria")}
size={2}
value={gotoValue}
onChange={(e) => setGotoValue(e.target.value)}
min={1}
max={pagesCount}
/>
</form>
</div>
</div>
);
};

View File

@@ -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);
}
});
};

View File

@@ -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);

View File

@@ -1,4 +1,5 @@
import "./index.scss";
export * from "./components/Button";
export * from "./components/Pagination";
export * from "./components/Provider";

View File

@@ -30,6 +30,7 @@ export default defineConfig({
environment: "jsdom",
reporters: "verbose",
globals: true,
watchExclude: ["**/cunningham-tokens.js"],
coverage: {
all: true,
include: ["src/**/*.{ts,tsx}"],