Skip to content

Commit

Permalink
Paginate GET /resources
Browse files Browse the repository at this point in the history
  • Loading branch information
textbook committed Aug 5, 2023
1 parent 02e8a35 commit e747b23
Show file tree
Hide file tree
Showing 22 changed files with 620 additions and 41 deletions.
2 changes: 1 addition & 1 deletion client/src/components/Header/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export default function Header() {
 Resources
</Link>
</h1>
<nav>
<nav aria-label="site navigation">
<ul>
<li>{principal && <NavLink to="/suggest">Suggest</NavLink>}</li>
<li>
Expand Down
77 changes: 77 additions & 0 deletions client/src/components/Pagination/Pagination.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
nav[aria-label="pagination"] {
align-items: center;
display: flex;
justify-content: flex-end;
max-width: 720px;
margin: 0 auto;
width: 100%;

& > ul {
display: flex;
flex-direction: row;
list-style-type: none;
margin: 0;
padding: 0;

li {
> * {
border: 2px solid #1f891f;
border-radius: 5px;
color: #1f891f;
padding: 2px 8px;
margin: 0 8px;
}

> span {
border-color: #88e488;
color: #88e488;
cursor: not-allowed;
}

> a {
text-decoration: none;

&:hover,
&:focus {
background-color: #e8fae8;
}

&:active {
background-color: #d8f6d8;
}

&.active {
background-color: #1f891f;
color: white;

&:hover,
&:focus {
background-color: #1b791b;
border-color: #1b791b;
}

&:active {
background-color: #186918;
border-color: #186918;
}
}
}
}
}

label {
margin-left: 16px;

& > select {
background-color: white;
border: 1px solid #bdbdbd;
border-radius: 5px;
box-sizing: border-box;
font:
400 16px "Lato",
sans-serif;
margin-left: 16px;
padding: 8px;
}
}
}
91 changes: 91 additions & 0 deletions client/src/components/Pagination/Pagination.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, useLocation } from "react-router-dom";

import Pagination from "./index";

describe("Pagination", () => {
it("renders a link for each page", () => {
renderInRouter(3, { page: 2 });
const page1 = screen.getByRole("link", { name: /page 1 \(first page\)/i });
const page2 = screen.getByRole("link", { name: /page 2/i });
const page3 = screen.getByRole("link", { name: /page 3 \(last page\)/i });
expect(page1).toHaveAttribute("href", "/test");
expect(page1).not.toHaveClass("active");
expect(page2).toHaveAttribute("href", "/test?page=2");
expect(page2).toHaveClass("active");
expect(page2).toHaveAttribute("aria-current", "page");
expect(page3).toHaveAttribute("href", "/test?page=3");
expect(page3).not.toHaveClass("active");
});

it("renders links for next and previous pages", () => {
renderInRouter(3, { page: 2 });
expect(
screen.getByRole("link", { name: /previous page/i })
).toHaveAttribute("href", "/test");
expect(screen.getByRole("link", { name: /next page/i })).toHaveAttribute(
"href",
"/test?page=3"
);
});

it("does not show previous link on first page", () => {
renderInRouter(3, { page: 1 });
expect(
screen.queryByRole("link", { name: /previous page/i })
).not.toBeInTheDocument();
});

it("does not show next link on last page", () => {
renderInRouter(3, { page: 3 });
expect(
screen.queryByRole("link", { name: /next page/i })
).not.toBeInTheDocument();
});

it("shows page size options", () => {
const expectedSizes = ["10", "20", "50"];
renderInRouter(3, { perPage: 10 });
const pageSizeSelect = screen.getByRole("combobox", {
name: /items per page/i,
});
expect(pageSizeSelect).toHaveValue("10");
expect(within(pageSizeSelect).getAllByRole("option")).toHaveLength(
expectedSizes.length
);
expectedSizes.forEach((size) => {
expect(
within(pageSizeSelect).getByRole("option", { name: size })
).toHaveValue(size);
});
});

it("reset page when page size changes", async () => {
const user = userEvent.setup();
renderInRouter(3, { page: 2 });
await user.selectOptions(
screen.getByRole("combobox", { name: /items per page/i }),
"50"
);
expect(screen.getByRole("link", { name: /current page/i })).toHaveAttribute(
"href",
"/test?perPage=50"
);
});
});

function RevealLocation() {
const { pathname, search } = useLocation();
return <a href={`${pathname}${search}`}>Current page</a>;
}

const renderInRouter = (lastPage = 3, searchParams = {}) =>
render(
<MemoryRouter
initialEntries={[`/test?${new URLSearchParams(searchParams)}`]}
>
<RevealLocation />
<Pagination lastPage={lastPage} />
</MemoryRouter>
);
116 changes: 116 additions & 0 deletions client/src/components/Pagination/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import clsx from "clsx";
import PropTypes from "prop-types";
import { useCallback } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom";

import { useSearchParams } from "../../hooks";

import "./Pagination.scss";

const DEFAULT_PAGINATION = { page: 1, perPage: 20 };

const mergeWithoutDefaults = (...objects) =>
Object.entries(Object.assign({}, ...objects)).filter(
([key, value]) => DEFAULT_PAGINATION[key] !== value
);

/**
* Based on {@link https://design-system.w3.org/components/pagination.html W3C design system}.
* @param {number} lastPage
*/
export default function Pagination({ lastPage }) {
const location = useLocation();
const navigate = useNavigate();
const searchParams = useSearchParams(DEFAULT_PAGINATION);

const createRoute = useCallback(
(update) => {
return `${location.pathname}?${new URLSearchParams(
mergeWithoutDefaults(searchParams, update)
)}`;
},
[location, searchParams]
);

return (
<nav aria-label="pagination">
<ul>
<li>
{searchParams.page !== 1 ? (
<Link to={createRoute({ page: searchParams.page - 1 })}>
<span aria-hidden>&lt;</span>
<span className="visuallyhidden">previous page</span>
</Link>
) : (
<span aria-hidden>&lt;</span>
)}
</li>
{[...new Array(lastPage)].map((_, index) => (
<li key={index}>
<PageLink
currentPage={searchParams.page}
page={index + 1}
lastPage={lastPage}
to={createRoute({ page: index + 1 })}
/>
</li>
))}
<li>
{searchParams.page !== lastPage ? (
<Link to={createRoute({ page: searchParams.page + 1 })}>
<span aria-hidden>&gt;</span>
<span className="visuallyhidden">Next page</span>
</Link>
) : (
<span aria-hidden>&gt;</span>
)}
</li>
</ul>
<label>
Items per page
<select
onChange={({ target: { value } }) =>
navigate(createRoute({ page: 1, perPage: parseInt(value, 10) }))
}
value={searchParams.perPage}
>
{[10, 20, 50].map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
</label>
</nav>
);
}

Pagination.propTypes = {
lastPage: PropTypes.number.isRequired,
};

function PageLink({ currentPage, lastPage, page, to }) {
return (
<Link
aria-current={page === currentPage && "page"}
className={clsx(page === currentPage && "active")}
to={to}
>
<span className="visuallyhidden">Page </span>
{page}
{lastPage > 1 && page === 1 && (
<span className="visuallyhidden">(first page)</span>
)}
{lastPage > 1 && page === lastPage && (
<span className="visuallyhidden">(last page)</span>
)}
</Link>
);
}

PageLink.propTypes = {
currentPage: PropTypes.number.isRequired,
lastPage: PropTypes.number.isRequired,
page: PropTypes.number.isRequired,
to: PropTypes.string.isRequired,
};
1 change: 1 addition & 0 deletions client/src/components/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { default as Button } from "./Button";
export { default as Form, FormControls } from "./Form";
export { default as Header } from "./Header";
export { default as Pagination } from "./Pagination";
export { default as ResourceList } from "./ResourceList";
1 change: 1 addition & 0 deletions client/src/hooks/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as useSearchParams } from "./useSearchParams";
27 changes: 27 additions & 0 deletions client/src/hooks/useSearchParams.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { useMemo, useState } from "react";
import { useLocation } from "react-router-dom";

const INTEGER = /^\d+$/;

/**
* Expose the current URL search/query parameters as an object.
* @param {Record<string, string | number>} defaults
* @returns {Record<string, string | number>}
*/
export default function useSearchParams(defaults = {}) {
const { search } = useLocation();
const [initial] = useState(defaults);

return useMemo(() => {
const searchParams = new URLSearchParams(search);
return {
...initial,
...Object.fromEntries(
[...searchParams.entries()].map(([key, value]) => [
key,
INTEGER.test(value) ? parseInt(value, 10) : value,
])
),
};
}, [initial, search]);
}
32 changes: 32 additions & 0 deletions client/src/hooks/useSearchParams.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { renderHook } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";

import { useSearchParams } from "./index";

describe("useSearchParams", () => {
it("exposes current location query parameters as an object", () => {
const { current } = renderOnRoute("/page?foo=bar&baz=qux");
expect(current).toEqual({ foo: "bar", baz: "qux" });
});

it("parses numerical values", () => {
const { current } = renderOnRoute("/page?foo=123&baz=456");
expect(current).toEqual({ foo: 123, baz: 456 });
});

it("includes the specified defaults", () => {
const { current } = renderOnRoute("/page?bar=456&baz=used", () =>
useSearchParams({ foo: 123, baz: "overridden" })
);
expect(current).toEqual({ foo: 123, bar: 456, baz: "used" });
});
});

function renderOnRoute(route, hook = useSearchParams) {
const { result } = renderHook(hook, {
wrapper: ({ children }) => (
<MemoryRouter initialEntries={[route]}>{children}</MemoryRouter>
),
});
return result;
}
13 changes: 13 additions & 0 deletions client/src/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,17 @@ body {
font-family: "Raleway", sans-serif;
font-weight: 700;
}

.visuallyhidden {
position: absolute;

width: 1px;
height: 1px;
margin: -1px;
border: 0;
padding: 0;

clip: rect(0 0 0 0);
overflow: hidden;
}
}
Loading

0 comments on commit e747b23

Please sign in to comment.