From cfdd6ece204e15ca8000cc1bec5fc00b9b87ba1a Mon Sep 17 00:00:00 2001
From: erictooth <1023110+erictooth@users.noreply.github.com>
Date: Wed, 23 Jun 2021 14:59:43 -0700
Subject: [PATCH] refactor!: change Pagination from default to named export
feat: expose paginationStateReducer test: add 100% coverage refactor!: when
page size or total items is changed and a target page is not specified, the
current page is left as is instead of changing to 0 BREAKING CHANGE:
flowtypes are no exported
---
README.md | 1 +
package.json | 4 +-
src/__tests__/getPaginationMeta.test.ts | 32 ++++
src/__tests__/getPaginationState.test.ts | 49 ------
src/__tests__/paginationStateReducer.test.ts | 140 ++++++++++++++++++
src/__tests__/usePagination.test.ts | 79 ++++++++++
...aginationState.ts => getPaginationMeta.ts} | 19 ++-
src/index.ts | 2 +-
src/paginationStateReducer.ts | 77 ++++++++++
src/usePagination.ts | 92 ++++--------
10 files changed, 377 insertions(+), 118 deletions(-)
create mode 100644 src/__tests__/getPaginationMeta.test.ts
delete mode 100644 src/__tests__/getPaginationState.test.ts
create mode 100644 src/__tests__/paginationStateReducer.test.ts
create mode 100644 src/__tests__/usePagination.test.ts
rename src/{getPaginationState.ts => getPaginationMeta.ts} (81%)
create mode 100644 src/paginationStateReducer.ts
diff --git a/README.md b/README.md
index ce3498a..07adee6 100644
--- a/README.md
+++ b/README.md
@@ -14,6 +14,7 @@
+
diff --git a/package.json b/package.json
index d2452a2..5578d60 100644
--- a/package.json
+++ b/package.json
@@ -44,12 +44,14 @@
"@erictooth/eslint-config": "^3",
"@erictooth/prettier-config": "^4",
"@erictooth/semantic-release-npm-github-config": "^1",
+ "@testing-library/react-hooks": "^7.0.0",
"@types/jest": "^26",
"@types/react": "^17",
"esbuild": "^0.12.9",
"eslint": "^7",
"jest": "^27",
"prettier": "^2",
+ "react-test-renderer": "^17.0.2",
"ts-jest": "^27",
"typescript": "^4"
},
@@ -70,7 +72,7 @@
"jest": {
"preset": "ts-jest",
"collectCoverageFrom": [
- "src/**/*.{ts, tsx}"
+ "src/**/{!(index),}.{ts, tsx}"
]
}
}
diff --git a/src/__tests__/getPaginationMeta.test.ts b/src/__tests__/getPaginationMeta.test.ts
new file mode 100644
index 0000000..377c7ab
--- /dev/null
+++ b/src/__tests__/getPaginationMeta.test.ts
@@ -0,0 +1,32 @@
+import { getPaginationMeta, PaginationState } from "../getPaginationMeta";
+
+const MULTI_PAGE_FIRST_PAGE: PaginationState = {
+ totalItems: 100,
+ pageSize: 10,
+ currentPage: 0,
+};
+
+describe("getPaginationMeta", () => {
+ it("correctly calculates startIndex and lastIndex on the first page", () => {
+ const meta = getPaginationMeta(MULTI_PAGE_FIRST_PAGE);
+ expect(meta.startIndex).toBe(0);
+ expect(meta.endIndex).toBe(9);
+ });
+
+ it("correctly calculates startIndex and endIndex on the second page", () => {
+ const meta = getPaginationMeta({ ...MULTI_PAGE_FIRST_PAGE, currentPage: 1 });
+ expect(meta.startIndex).toBe(10);
+ expect(meta.endIndex).toBe(19);
+ });
+
+ it("correctly calculates startIndex and endIndex on the last page", () => {
+ const meta = getPaginationMeta({ ...MULTI_PAGE_FIRST_PAGE, currentPage: 9 });
+ expect(meta.startIndex).toBe(90);
+ expect(meta.endIndex).toBe(99);
+ });
+
+ it("correctly calculates endIndex on a half-full last page", () => {
+ const meta = getPaginationMeta({ totalItems: 92, pageSize: 10, currentPage: 9 });
+ expect(meta.endIndex).toBe(91);
+ });
+});
diff --git a/src/__tests__/getPaginationState.test.ts b/src/__tests__/getPaginationState.test.ts
deleted file mode 100644
index a2ac400..0000000
--- a/src/__tests__/getPaginationState.test.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-import { getPaginationState } from "../getPaginationState";
-
-const DEFAULT_STATE = {
- totalItems: 100,
- pageSize: 10,
- currentPage: 0,
-};
-
-/**
- * getPaginationState tests
- */
-it("correctly calculates the total number of pages when totalItems > pageSize", () => {
- const { totalPages } = getPaginationState({ ...DEFAULT_STATE, totalItems: 2, pageSize: 1 });
- expect(totalPages).toBe(2);
-});
-
-it("correctly calculates the total number of pages when totalItems < pageSize", () => {
- const { totalPages } = getPaginationState({ ...DEFAULT_STATE, totalItems: 1, pageSize: 2 });
- expect(totalPages).toBe(1);
-});
-
-it("returns 0 pages when there are no items", () => {
- const { totalPages } = getPaginationState({ ...DEFAULT_STATE, totalItems: 0 });
- expect(totalPages).toBe(0);
-});
-
-it("returns false for previousEnabled when on the first page", () => {
- const { previousEnabled } = getPaginationState(DEFAULT_STATE);
- expect(previousEnabled).toBe(false);
-});
-
-it("returns true for previousEnabled when not on the first page", () => {
- const { previousEnabled } = getPaginationState({
- pageSize: 1,
- totalItems: 2,
- currentPage: 1,
- });
- expect(previousEnabled).toBe(true);
-});
-
-it("returns true for nextEnabled when not on the last page", () => {
- const { nextEnabled } = getPaginationState(DEFAULT_STATE);
- expect(nextEnabled).toBe(true);
-});
-
-it("returns false for nextEnabled when on the last page", () => {
- const { nextEnabled } = getPaginationState({ pageSize: 1, totalItems: 2, currentPage: 1 });
- expect(nextEnabled).toBe(false);
-});
diff --git a/src/__tests__/paginationStateReducer.test.ts b/src/__tests__/paginationStateReducer.test.ts
new file mode 100644
index 0000000..3b56c61
--- /dev/null
+++ b/src/__tests__/paginationStateReducer.test.ts
@@ -0,0 +1,140 @@
+import { paginationStateReducer } from "../paginationStateReducer";
+import { PaginationState } from "../getPaginationMeta";
+
+const MULTI_PAGE_FIRST_PAGE: PaginationState = {
+ totalItems: 100,
+ pageSize: 10,
+ currentPage: 0,
+};
+
+describe("paginationStateReducer", () => {
+ it("sets the next page when not on the last page", () => {
+ const nextState = paginationStateReducer(MULTI_PAGE_FIRST_PAGE, { type: "NEXT_PAGE" });
+ expect(nextState.currentPage).toBe(1);
+ });
+
+ it("does not set the next page when on the last page", () => {
+ const nextState = paginationStateReducer(
+ { totalItems: 1, pageSize: 1, currentPage: 0 },
+ { type: "NEXT_PAGE" }
+ );
+ expect(nextState.currentPage).toBe(0);
+ });
+
+ it("sets the previous page when not on the first page", () => {
+ const nextState = paginationStateReducer(
+ { totalItems: 2, pageSize: 1, currentPage: 1 },
+ { type: "PREVIOUS_PAGE" }
+ );
+ expect(nextState.currentPage).toBe(0);
+ });
+
+ it("does not set the previous page when on the first page", () => {
+ const nextState = paginationStateReducer(MULTI_PAGE_FIRST_PAGE, { type: "PREVIOUS_PAGE" });
+ expect(nextState.currentPage).toBe(0);
+ });
+
+ it("allows totalPages to be set", () => {
+ const nextTotalItems = 12;
+ const nextState = paginationStateReducer(MULTI_PAGE_FIRST_PAGE, {
+ type: "SET_TOTALITEMS",
+ totalItems: nextTotalItems,
+ });
+
+ expect(nextState.totalItems).toBe(nextTotalItems);
+ });
+
+ it("allows pageSize to be set", () => {
+ const nextPageSize = 12;
+ const nextState = paginationStateReducer(MULTI_PAGE_FIRST_PAGE, {
+ type: "SET_PAGESIZE",
+ pageSize: nextPageSize,
+ });
+
+ expect(nextState.pageSize).toBe(nextPageSize);
+ });
+
+ it("allows currentPage to be set", () => {
+ const nextCurrentPage = 12;
+ const nextState = paginationStateReducer(
+ { totalItems: 100, pageSize: 1, currentPage: 0 },
+ {
+ type: "SET_PAGE",
+ page: nextCurrentPage,
+ }
+ );
+
+ expect(nextState.currentPage).toBe(nextCurrentPage);
+ });
+
+ it("disallows currentPage from being set below 0", () => {
+ const nextCurrentPage = -1;
+ const nextState = paginationStateReducer(MULTI_PAGE_FIRST_PAGE, {
+ type: "SET_PAGE",
+ page: nextCurrentPage,
+ });
+
+ expect(nextState.currentPage).toBe(0);
+ });
+
+ it("disallows currentPage from being set above totalPages", () => {
+ const nextCurrentPage = 1;
+ const nextState = paginationStateReducer(
+ { totalItems: 1, pageSize: 1, currentPage: 0 },
+ {
+ type: "SET_PAGE",
+ page: nextCurrentPage,
+ }
+ );
+
+ expect(nextState.currentPage).toBe(0);
+ });
+
+ it("limits currentPage within totalPages when pageSize is increased", () => {
+ const nextState = paginationStateReducer(
+ { totalItems: 100, pageSize: 10, currentPage: 9 },
+ {
+ type: "SET_PAGESIZE",
+ pageSize: 50,
+ }
+ );
+
+ expect(nextState.currentPage).toBe(1);
+ });
+
+ it("doesn't change currentPage if it wouldn't be out of bounds when pageSize is increased", () => {
+ const nextState = paginationStateReducer(
+ { totalItems: 100, pageSize: 10, currentPage: 2 },
+ {
+ type: "SET_PAGESIZE",
+ pageSize: 25,
+ }
+ );
+
+ expect(nextState.currentPage).toBe(2);
+ });
+
+ it("limits currentPage within totalPages when totalItems is decreased", () => {
+ const nextState = paginationStateReducer(
+ { totalItems: 100, pageSize: 10, currentPage: 9 },
+ {
+ type: "SET_TOTALITEMS",
+ totalItems: 20,
+ }
+ );
+
+ expect(nextState.currentPage).toBe(1);
+ });
+
+ it("doesn't change currentPage if it wouldn't be out of bounds when totalItems is decreased", () => {
+ const nextState = paginationStateReducer(
+ { totalItems: 100, pageSize: 10, currentPage: 9 },
+ {
+ type: "SET_TOTALITEMS",
+ totalItems: 95,
+ }
+ );
+
+ expect(nextState.currentPage).toBe(9);
+ });
+});
diff --git a/src/__tests__/usePagination.test.ts b/src/__tests__/usePagination.test.ts
new file mode 100644
index 0000000..b41887d
--- /dev/null
+++ b/src/__tests__/usePagination.test.ts
@@ -0,0 +1,79 @@
+import { renderHook, act } from "@testing-library/react-hooks";
+import { usePagination } from "../usePagination";
+
+const DEFAULT_STATE = { totalItems: 100, initialPageSize: 10, initialPage: 1 };
+
+describe("usePagination", () => {
+ it("correctly initializes the page state and contains the expected metadata", () => {
+ const { result } = renderHook(() => usePagination(DEFAULT_STATE));
+
+ expect(result.current.currentPage).toBe(DEFAULT_STATE.initialPage);
+ expect(result.current.totalItems).toBe(DEFAULT_STATE.totalItems);
+ expect(result.current.pageSize).toBe(DEFAULT_STATE.initialPageSize);
+ expect(result.current.startIndex).toBe(
+ DEFAULT_STATE.initialPage * DEFAULT_STATE.initialPageSize
+ );
+ expect(result.current.endIndex).toBe(
+ (DEFAULT_STATE.initialPage + 1) * DEFAULT_STATE.initialPageSize - 1
+ );
+ expect(result.current.nextEnabled).toBe(true);
+ expect(result.current.previousEnabled).toBe(true);
+ });
+
+ it("sets the next page when setNextPage is called", () => {
+ const { result } = renderHook(() => usePagination(DEFAULT_STATE));
+
+ expect(result.current.currentPage).toBe(DEFAULT_STATE.initialPage);
+
+ act(() => {
+ result.current.setNextPage();
+ });
+
+ expect(result.current.currentPage).toBe(DEFAULT_STATE.initialPage + 1);
+ });
+
+ it("sets the previous page when setPreviousPage is called", () => {
+ const { result } = renderHook(() => usePagination(DEFAULT_STATE));
+
+ expect(result.current.currentPage).toBe(DEFAULT_STATE.initialPage);
+
+ act(() => {
+ result.current.setPreviousPage();
+ });
+
+ expect(result.current.currentPage).toBe(DEFAULT_STATE.initialPage - 1);
+ });
+
+ it("sets the page when setPage is called", () => {
+ const { result } = renderHook(() => usePagination(DEFAULT_STATE));
+
+ expect(result.current.currentPage).toBe(DEFAULT_STATE.initialPage);
+
+ act(() => {
+ result.current.setPage(0);
+ });
+
+ expect(result.current.currentPage).toBe(0);
+ });
+
+ it("sets the pageSize when setPageSize is called", () => {
+ const { result } = renderHook(() => usePagination(DEFAULT_STATE));
+
+ expect(result.current.pageSize).toBe(DEFAULT_STATE.initialPageSize);
+
+ act(() => {
+ result.current.setPageSize(5);
+ });
+
+ expect(result.current.pageSize).toBe(5);
+ });
+
+ // This is required so that the hook can be rendered before server-provided data is available
+ it("initializes configurations to 0 when not provided", () => {
+ const { result } = renderHook(() => usePagination());
+
+ expect(result.current.totalItems).toBe(0);
+ expect(result.current.pageSize).toBe(0);
+ expect(result.current.currentPage).toBe(0);
+ });
+});
diff --git a/src/getPaginationState.ts b/src/getPaginationMeta.ts
similarity index 81%
rename from src/getPaginationState.ts
rename to src/getPaginationMeta.ts
index 2e8b9f3..eefa498 100644
--- a/src/getPaginationState.ts
+++ b/src/getPaginationMeta.ts
@@ -19,7 +19,18 @@ export const getEndIndex = (pageSize: number, currentPage: number, totalItems: n
return lastPageEndIndex - 1;
};
+export const limitPageBounds =
+ (totalItems: number, pageSize: number) =>
+ (page: number): number =>
+ Math.min(Math.max(page, 0), getTotalPages(totalItems, pageSize) - 1);
+
export type PaginationState = {
+ totalItems: number;
+ pageSize: number;
+ currentPage: number;
+};
+
+export type PaginationMeta = {
totalPages: number;
startIndex: number;
endIndex: number;
@@ -27,15 +38,11 @@ export type PaginationState = {
nextEnabled: boolean;
};
-export const getPaginationState = ({
+export const getPaginationMeta = ({
totalItems,
pageSize,
currentPage,
-}: {
- totalItems: number;
- pageSize: number;
- currentPage: number;
-}): PaginationState => {
+}: PaginationState): PaginationMeta => {
const totalPages = getTotalPages(totalItems, pageSize);
return {
totalPages,
diff --git a/src/index.ts b/src/index.ts
index 91e188f..2fcb5cb 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,3 +1,3 @@
-export * from "./getPaginationState";
+export * from "./getPaginationMeta";
export * from "./Pagination";
export * from "./usePagination";
diff --git a/src/paginationStateReducer.ts b/src/paginationStateReducer.ts
new file mode 100644
index 0000000..5b04515
--- /dev/null
+++ b/src/paginationStateReducer.ts
@@ -0,0 +1,77 @@
+import { limitPageBounds, PaginationState } from "./getPaginationMeta";
+
+type CurrentPageActions =
+ | { type: "NEXT_PAGE" }
+ | { type: "PREVIOUS_PAGE" }
+ | { type: "SET_PAGE"; page: number };
+
+type TotalItemsActions = {
+ type: "SET_TOTALITEMS";
+ totalItems: number;
+ nextPage?: number;
+};
+
+type PageSizeActions = {
+ type: "SET_PAGESIZE";
+ pageSize: number;
+ nextPage?: number;
+};
+
+type PaginationStateReducerActions = CurrentPageActions | TotalItemsActions | PageSizeActions;
+
+const getCurrentPageReducer = (rootState: PaginationState) =>
+ function currentPageReducer(
+ state: PaginationState["currentPage"],
+ action: PaginationStateReducerActions
+ ) {
+ switch (action.type) {
+ case "SET_PAGE":
+ return limitPageBounds(rootState.totalItems, rootState.pageSize)(action.page);
+ case "NEXT_PAGE":
+ return limitPageBounds(rootState.totalItems, rootState.pageSize)(state + 1);
+ case "PREVIOUS_PAGE":
+ return limitPageBounds(rootState.totalItems, rootState.pageSize)(state - 1);
+ case "SET_PAGESIZE":
+ return limitPageBounds(
+ rootState.totalItems,
+ action.pageSize
+ )(action.nextPage ?? state);
+ case "SET_TOTALITEMS":
+ return limitPageBounds(
+ action.totalItems,
+ rootState.pageSize
+ )(action.nextPage ?? state);
+ /* istanbul ignore next */
+ default:
+ return state;
+ }
+ };
+
+function totalItemsReducer(state: PaginationState["totalItems"], action: TotalItemsActions) {
+ switch (action.type) {
+ case "SET_TOTALITEMS":
+ return action.totalItems;
+ default:
+ return state;
+ }
+}
+
+function pageSizeReducer(state: PaginationState["pageSize"], action: PageSizeActions) {
+ switch (action.type) {
+ case "SET_PAGESIZE":
+ return action.pageSize;
+ default:
+ return state;
+ }
+}
+
+export function paginationStateReducer(
+ state: PaginationState,
+ action: PaginationStateReducerActions
+): PaginationState {
+ return {
+ currentPage: getCurrentPageReducer(state)(state.currentPage, action as CurrentPageActions),
+ totalItems: totalItemsReducer(state.totalItems, action as TotalItemsActions),
+ pageSize: pageSizeReducer(state.pageSize, action as PageSizeActions),
+ };
+}
diff --git a/src/usePagination.ts b/src/usePagination.ts
index 92ce28b..68330b0 100644
--- a/src/usePagination.ts
+++ b/src/usePagination.ts
@@ -1,13 +1,13 @@
-import { useCallback, useMemo, useState, useReducer } from "react";
+import { useCallback, useMemo, useReducer } from "react";
+import { getPaginationMeta, PaginationState, PaginationMeta } from "./getPaginationMeta";
+import { paginationStateReducer } from "./paginationStateReducer";
-import {
- getTotalPages,
- getNextEnabled,
- getPreviousEnabled,
- getPaginationState,
-} from "./getPaginationState";
-
-type CurrentPageReducerActions = { type: "SET"; page: number } | { type: "NEXT" | "PREV" };
+type PaginationActions = {
+ setPage: (page: number) => void;
+ setNextPage: () => void;
+ setPreviousPage: () => void;
+ setPageSize: (pageSize: number, nextPage?: number) => void;
+};
export function usePagination({
totalItems = 0,
@@ -17,62 +17,32 @@ export function usePagination({
totalItems?: number;
initialPage?: number;
initialPageSize?: number;
-} = {}) {
- const [pageSize, setPageSize] = useState(initialPageSize);
-
- const [currentPage, dispatch] = useReducer(
- (state = initialPage, action: CurrentPageReducerActions) => {
- switch (action.type) {
- case "SET":
- return action.page;
- case "NEXT":
- if (!getNextEnabled(state, getTotalPages(totalItems, pageSize))) {
- return state;
- }
- return state + 1;
- case "PREV":
- if (!getPreviousEnabled(state)) {
- return state;
- }
- return state - 1;
- default:
- return state;
- }
- },
- initialPage
- );
+} = {}): PaginationState & PaginationMeta & PaginationActions {
+ const initialState = {
+ totalItems,
+ pageSize: initialPageSize,
+ currentPage: initialPage,
+ };
- const paginationState = useMemo(
- () => getPaginationState({ totalItems, pageSize, currentPage }),
- [totalItems, pageSize, currentPage]
- );
+ const [paginationState, dispatch] = useReducer(paginationStateReducer, initialState);
return {
- setPage: useCallback(
- (page: number) => {
- dispatch({
- type: "SET",
- page,
- });
- },
- [dispatch]
- ),
+ ...paginationState,
+ ...useMemo(() => getPaginationMeta(paginationState), [paginationState]),
+ setPage: useCallback((page: number) => {
+ dispatch({
+ type: "SET_PAGE",
+ page,
+ });
+ }, []),
setNextPage: useCallback(() => {
- dispatch({ type: "NEXT" });
- }, [dispatch]),
+ dispatch({ type: "NEXT_PAGE" });
+ }, []),
setPreviousPage: useCallback(() => {
- dispatch({ type: "PREV" });
- }, [dispatch]),
- setPageSize: useCallback(
- (pageSize: number, nextPage = 0) => {
- setPageSize(pageSize);
- dispatch({ type: "SET", page: nextPage });
- },
- [setPageSize]
- ),
- currentPage,
- pageSize,
- totalItems,
- ...paginationState,
+ dispatch({ type: "PREVIOUS_PAGE" });
+ }, []),
+ setPageSize: useCallback((pageSize: number, nextPage = 0) => {
+ dispatch({ type: "SET_PAGESIZE", pageSize, nextPage });
+ }, []),
};
}