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 @@ Code Style: Prettier + 100% coverage 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 }); + }, []), }; }