From 94d32d1ff96f8b7e60eb92cf14255880dc6f5078 Mon Sep 17 00:00:00 2001 From: Simone Busoli Date: Thu, 31 Dec 2020 12:31:03 +0100 Subject: [PATCH] feat: feature complete --- .babelrc | 5 + .eslintignore | 4 +- .eslintrc | 9 +- .gitignore | 2 + .npmignore | 1 + README.md | 35 ++++ examples/.eslintrc | 17 ++ examples/components/Example.js | 30 ++++ examples/components/InternalState.js | 56 +++++++ examples/components/Output.js | 45 ++++++ examples/components/SimpleDeclarative.js | 53 +++++++ examples/components/SimpleImperative.js | 53 +++++++ examples/components/Source.js | 29 ++++ examples/index.html | 24 +++ examples/server.js | 18 +-- examples/simple.html | 19 --- examples/simple.js | 61 ------- jest.config.js | 194 +++++++++++++++++++++++ package.json | 21 ++- rollup.config.js | 8 + setupTests.js | 1 + src/controlled.js | 64 ++++++++ src/controlled.test.js | 138 ++++++++++++++++ src/index.js | 31 ++-- src/index.test.js | 33 ++++ src/uncontrolled.js | 34 ++++ src/uncontrolled.test.js | 46 ++++++ src/utils.js | 5 + 28 files changed, 922 insertions(+), 114 deletions(-) create mode 100644 .babelrc create mode 100644 .npmignore create mode 100644 README.md create mode 100644 examples/.eslintrc create mode 100644 examples/components/Example.js create mode 100644 examples/components/InternalState.js create mode 100644 examples/components/Output.js create mode 100644 examples/components/SimpleDeclarative.js create mode 100644 examples/components/SimpleImperative.js create mode 100644 examples/components/Source.js create mode 100644 examples/index.html delete mode 100644 examples/simple.html delete mode 100644 examples/simple.js create mode 100644 jest.config.js create mode 100644 setupTests.js create mode 100644 src/controlled.js create mode 100644 src/controlled.test.js create mode 100644 src/index.test.js create mode 100644 src/uncontrolled.js create mode 100644 src/uncontrolled.test.js create mode 100644 src/utils.js diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..5b55989 --- /dev/null +++ b/.babelrc @@ -0,0 +1,5 @@ +{ + "env": { + "test": { "presets": ["@babel/preset-env", "@babel/preset-react"] } + } +} diff --git a/.eslintignore b/.eslintignore index 6e1e15e..a986c5f 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,3 +1,5 @@ cjs/ es/ -umd.js +umd/ +coverage/ +examples/umd.js diff --git a/.eslintrc b/.eslintrc index df8f739..a54d261 100644 --- a/.eslintrc +++ b/.eslintrc @@ -8,7 +8,14 @@ "plugin:react-hooks/recommended" ], "env": { + "browser": true, "node": true, - "es6": true + "es6": true, + "jest": true + }, + "settings": { + "react": { + "version": "detect" + } } } diff --git a/.gitignore b/.gitignore index 056103a..ae1068d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ node_modules/ cjs/ es/ +umd/ +coverage/ .eslintcache package-lock.json diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..d838da9 --- /dev/null +++ b/.npmignore @@ -0,0 +1 @@ +examples/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..4de4252 --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +# token-pagination-hooks + +React Hooks library to use classic pagination in the frontend with a token-based paginatiom backend + +## Setup + +`npm i token-pagination-hooks` + +## Quickstart + +The API you're using + +- accepts a `pageToken` query string parameter to do pagination +- returns data in the format: + +```json +{ + "data": [...], + "nextPage": "some opaque string" +} +``` + +Assuming you're using a library like [axios-hooks](https://github.com/simoneb/axios-hooks/) to interact with the API: + +```js +import React, { useState } from 'react' +import useAxios from 'axios-hooks' +import useTokenPagination from 'token-pagination-hooks' + +function Pagination() { + const [pageNumber, setPageNumber] = useState(1) + const { currentToken, useUpdateToken } = useTokenPagination(pageNumber) + const [{ data }] = useAxios({ url: '/api', params: { pageToken: currentToken }) +} +``` diff --git a/examples/.eslintrc b/examples/.eslintrc new file mode 100644 index 0000000..eb24982 --- /dev/null +++ b/examples/.eslintrc @@ -0,0 +1,17 @@ +{ + "env": { + "browser": true, + "node": true + }, + "parserOptions": { "sourceType": "script" }, + "globals": { + "React": true, + "PropTypes": true, + "useTokenPagination": true, + "Output": true, + "Source": true, + "SimpleDeclarative": true, + "SimpleImperative": true, + "InternalState": true + } +} diff --git a/examples/components/Example.js b/examples/components/Example.js new file mode 100644 index 0000000..87e166d --- /dev/null +++ b/examples/components/Example.js @@ -0,0 +1,30 @@ +const choices = [SimpleDeclarative, SimpleImperative, InternalState].reduce( + (acc, c) => ({ ...acc, [c.name]: c }), + {} +) + +function Example() { + const [choice, setChoice] = React.useState(SimpleDeclarative.name) + + const Component = choices[choice] + + return ( +
+ +
+
+ +
+
+ +
+
+
+ ) +} + +Example.propTypes = {} diff --git a/examples/components/InternalState.js b/examples/components/InternalState.js new file mode 100644 index 0000000..a819018 --- /dev/null +++ b/examples/components/InternalState.js @@ -0,0 +1,56 @@ +const { useState, useEffect } = React + +function InternalState() { + const { + currentToken, + useUpdateToken, + changePageNumber, + changePageSize, + pageNumber, + pageSize, + } = useTokenPagination({ defaultPageNumber: 1, defaultPageSize: 5 }) + + const [data, setData] = useState() + + useEffect(() => { + async function fetchData() { + const params = new URLSearchParams({ pageSize }) + + if (currentToken) { + params.append('pageToken', currentToken) + } + + const res = await fetch(`/api?${params.toString()}`) + const data = await res.json() + + setData(data) + } + + fetchData() + }, [pageSize, currentToken]) + + useUpdateToken(data?.nextPage) + + function previousPage() { + changePageNumber(n => n - 1) + } + function nextPage() { + changePageNumber(n => n + 1) + } + function handleChangePageSize(e) { + changePageSize(+e.target.value) + } + + return ( + + ) +} + +InternalState.propTypes = {} diff --git a/examples/components/Output.js b/examples/components/Output.js new file mode 100644 index 0000000..7ac243f --- /dev/null +++ b/examples/components/Output.js @@ -0,0 +1,45 @@ +const T = PropTypes + +function Output({ + data, + pageNumber, + pageSize, + changePageSize, + previousPage, + nextPage, +}) { + return ( + <> +
{JSON.stringify(data, null, 2)}
+
+ + {' '} + + + {' '} + {pageNumber} + {' '} + +
+ + ) +} + +Output.propTypes = { + data: T.shape({ + nextPage: T.any, + }), + pageNumber: T.number.isRequired, + pageSize: T.number.isRequired, + changePageSize: T.func.isRequired, + previousPage: T.func.isRequired, + nextPage: T.func.isRequired, +} diff --git a/examples/components/SimpleDeclarative.js b/examples/components/SimpleDeclarative.js new file mode 100644 index 0000000..f268b7d --- /dev/null +++ b/examples/components/SimpleDeclarative.js @@ -0,0 +1,53 @@ +const { useState, useEffect } = React + +function SimpleDeclarative() { + const [{ pageNumber, pageSize }, setPagination] = useState({ + pageNumber: 1, + pageSize: 3, + }) + const { currentToken, useUpdateToken } = useTokenPagination(pageNumber) + + const [data, setData] = useState() + + useEffect(() => { + async function fetchData() { + const params = new URLSearchParams({ pageSize }) + + if (currentToken) { + params.append('pageToken', currentToken) + } + + const res = await fetch(`/api?${params.toString()}`) + const data = await res.json() + + setData(data) + } + + fetchData() + }, [pageSize, currentToken]) + + useUpdateToken(data?.nextPage) + + function previousPage() { + setPagination(s => ({ ...s, pageNumber: pageNumber - 1 })) + } + function nextPage() { + setPagination(s => ({ ...s, pageNumber: pageNumber + 1 })) + } + function changePageSize(e) { + setPagination({ pageSize: e.target.value, pageNumber: 1 }) + } + + return ( + + ) +} + +SimpleDeclarative.propTypes = {} diff --git a/examples/components/SimpleImperative.js b/examples/components/SimpleImperative.js new file mode 100644 index 0000000..f733b6f --- /dev/null +++ b/examples/components/SimpleImperative.js @@ -0,0 +1,53 @@ +const { useState, useEffect } = React + +function SimpleImperative() { + const [{ pageNumber, pageSize }, setPagination] = useState({ + pageNumber: 1, + pageSize: 3, + }) + const { currentToken, updateToken } = useTokenPagination(pageNumber) + + const [data, setData] = useState() + + useEffect(() => { + async function fetchData() { + const params = new URLSearchParams({ pageSize }) + + if (currentToken) { + params.append('pageToken', currentToken) + } + + const res = await fetch(`/api?${params.toString()}`) + const data = await res.json() + + updateToken(data.nextPage) + + setData(data) + } + + fetchData() + }, [pageSize, currentToken, updateToken]) + + function previousPage() { + setPagination(s => ({ ...s, pageNumber: pageNumber - 1 })) + } + function nextPage() { + setPagination(s => ({ ...s, pageNumber: pageNumber + 1 })) + } + function changePageSize(e) { + setPagination({ pageSize: e.target.value, pageNumber: 1 }) + } + + return ( + + ) +} + +SimpleImperative.propTypes = {} diff --git a/examples/components/Source.js b/examples/components/Source.js new file mode 100644 index 0000000..31ace01 --- /dev/null +++ b/examples/components/Source.js @@ -0,0 +1,29 @@ +const { useState, useEffect } = React +const T = PropTypes + +function Source({ fileName }) { + const [source, setSource] = useState() + + useEffect(() => { + async function fetchSource() { + const res = await fetch(`/components/${fileName}.js`) + + setSource(await res.text()) + } + + fetchSource() + }, [fileName]) + + return ( +
+

{fileName} source code

+
+        {source}
+      
+
+ ) +} + +Source.propTypes = { + fileName: T.string.isRequired, +} diff --git a/examples/index.html b/examples/index.html new file mode 100644 index 0000000..4cb1b3f --- /dev/null +++ b/examples/index.html @@ -0,0 +1,24 @@ + + + + + Example + + + + + + + + + + + + + +
+ + + diff --git a/examples/server.js b/examples/server.js index 3163540..ea68131 100644 --- a/examples/server.js +++ b/examples/server.js @@ -3,19 +3,19 @@ const fs = require('fs') const path = require('path') const { URL } = require('url') -const ENC = 'base64' +const tokenEncoding = 'base64' const data = Array(100) .fill(null) .map((_, i) => ({ id: i + 1, value: 'some value ' + (i + 1) })) -function handleApi(req, res) { +function api(req, res) { const search = new URL(req.url, `http://${req.headers.host}`).searchParams const pageSize = +search.get('pageSize') const pageToken = search.get('pageToken') const pageNumber = pageToken - ? +Buffer.from(pageToken, ENC).toString().split(':')[1] + ? +Buffer.from(pageToken, tokenEncoding).toString().split(':')[1] : 1 const start = pageSize * (pageNumber - 1) const end = start + pageSize @@ -24,29 +24,29 @@ function handleApi(req, res) { data: data.slice(start, end), nextPage: end < data.length - ? Buffer.from(`pageNumber:${pageNumber + 1}`).toString(ENC) + ? Buffer.from(`pageNumber:${pageNumber + 1}`).toString(tokenEncoding) : null, } res.end(JSON.stringify(result)) } -function handleStatic(req, res) { +function static(req, res) { const filePath = path.join(__dirname, req.url) if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) { return res.end(fs.readFileSync(filePath)) } - return res.end(fs.readFileSync(path.join(__dirname, 'simple.html'))) + return res.end(fs.readFileSync(path.join(__dirname, 'index.html'))) } http .createServer((req, res) => { if (/^\/api/.test(req.url)) { - return handleApi(req, res) + return api(req, res) } - handleStatic(req, res) + static(req, res) }) - .listen(4000) + .listen(4000, () => console.log(`listening on port 4000`)) diff --git a/examples/simple.html b/examples/simple.html deleted file mode 100644 index 7a941d5..0000000 --- a/examples/simple.html +++ /dev/null @@ -1,19 +0,0 @@ - - - - - Simple - - - - - - - -
- - - - diff --git a/examples/simple.js b/examples/simple.js deleted file mode 100644 index f7fb3c6..0000000 --- a/examples/simple.js +++ /dev/null @@ -1,61 +0,0 @@ -/* eslint-disable no-undef */ -/* eslint-disable no-unused-vars */ -/* eslint-disable react/react-in-jsx-scope */ - -function Simple() { - const [{ pageNumber, pageSize }, setPagination] = React.useState({ - pageNumber: 1, - pageSize: 3, - }) - const { currentToken, useUpdateToken } = useTokenPagination(pageNumber) - - const [data, setData] = React.useState() - - React.useEffect(() => { - async function fetchData() { - const res = await fetch( - `/api?pageSize=${pageSize}&pageToken=${currentToken}` - ) - setData(await res.json()) - } - - fetchData() - }, [pageSize, currentToken]) - - function previousPage() { - setPagination(s => ({ ...s, pageNumber: pageNumber - 1 })) - } - function nextPage() { - setPagination(s => ({ ...s, pageNumber: pageNumber + 1 })) - } - function changePageSize(e) { - setPagination({ pageSize: e.target.value, pageNumber: 1 }) - } - - useUpdateToken(data?.nextPage) - - return ( - <> -

hello world

-
{JSON.stringify(data, null, 2)}
-
- - {' '} - - - {' '} - {pageNumber} - {' '} - -
- - ) -} diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..0242448 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,194 @@ +/* + * For a detailed explanation regarding each configuration property, visit: + * https://jestjs.io/docs/en/configuration.html + */ + +module.exports = { + // All imported modules in your tests should be mocked automatically + // automock: false, + + // Stop running tests after `n` failures + // bail: 0, + + // The directory where Jest should store its cached dependency information + // cacheDirectory: "C:\\Users\\simone\\AppData\\Local\\Temp\\jest", + + // Automatically clear mock calls and instances between every test + clearMocks: true, + + // Indicates whether the coverage information should be collected while executing the test + // collectCoverage: false, + + // An array of glob patterns indicating a set of files for which coverage information should be collected + // collectCoverageFrom: undefined, + + // The directory where Jest should output its coverage files + coverageDirectory: 'coverage', + + // An array of regexp pattern strings used to skip coverage collection + // coveragePathIgnorePatterns: [ + // "\\\\node_modules\\\\" + // ], + + // Indicates which provider should be used to instrument code for coverage + // coverageProvider: "babel", + + // A list of reporter names that Jest uses when writing coverage reports + // coverageReporters: [ + // "json", + // "text", + // "lcov", + // "clover" + // ], + + // An object that configures minimum threshold enforcement for coverage results + // coverageThreshold: undefined, + + // A path to a custom dependency extractor + // dependencyExtractor: undefined, + + // Make calling deprecated APIs throw helpful error messages + // errorOnDeprecated: false, + + // Force coverage collection from ignored files using an array of glob patterns + // forceCoverageMatch: [], + + // A path to a module which exports an async function that is triggered once before all test suites + // globalSetup: undefined, + + // A path to a module which exports an async function that is triggered once after all test suites + // globalTeardown: undefined, + + // A set of global variables that need to be available in all test environments + // globals: {}, + + // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. + // maxWorkers: "50%", + + // An array of directory names to be searched recursively up from the requiring module's location + // moduleDirectories: [ + // "node_modules" + // ], + + // An array of file extensions your modules use + // moduleFileExtensions: [ + // "js", + // "json", + // "jsx", + // "ts", + // "tsx", + // "node" + // ], + + // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module + // moduleNameMapper: {}, + + // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader + // modulePathIgnorePatterns: [], + + // Activates notifications for test results + // notify: false, + + // An enum that specifies notification mode. Requires { notify: true } + // notifyMode: "failure-change", + + // A preset that is used as a base for Jest's configuration + // preset: undefined, + + // Run tests from one or more projects + // projects: undefined, + + // Use this configuration option to add custom reporters to Jest + // reporters: undefined, + + // Automatically reset mock state between every test + // resetMocks: false, + + // Reset the module registry before running each individual test + // resetModules: false, + + // A path to a custom resolver + // resolver: undefined, + + // Automatically restore mock state between every test + // restoreMocks: false, + + // The root directory that Jest should scan for tests and modules within + // rootDir: undefined, + + // A list of paths to directories that Jest should use to search for files in + // roots: [ + // "" + // ], + + // Allows you to use a custom runner instead of Jest's default test runner + // runner: "jest-runner", + + // The paths to modules that run some code to configure or set up the testing environment before each test + setupFiles: ['./setupTests.js'], + + // A list of paths to modules that run some code to configure or set up the testing framework before each test + // setupFilesAfterEnv: [], + + // The number of seconds after which a test is considered as slow and reported as such in the results. + // slowTestThreshold: 5, + + // A list of paths to snapshot serializer modules Jest should use for snapshot testing + // snapshotSerializers: [], + + // The test environment that will be used for testing + // testEnvironment: "jest-environment-jsdom", + + // Options that will be passed to the testEnvironment + // testEnvironmentOptions: {}, + + // Adds a location field to test results + // testLocationInResults: false, + + // The glob patterns Jest uses to detect test files + // testMatch: [ + // "**/__tests__/**/*.[jt]s?(x)", + // "**/?(*.)+(spec|test).[tj]s?(x)" + // ], + + // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped + // testPathIgnorePatterns: [ + // "\\\\node_modules\\\\" + // ], + + // The regexp pattern or array of patterns that Jest uses to detect test files + // testRegex: [], + + // This option allows the use of a custom results processor + // testResultsProcessor: undefined, + + // This option allows use of a custom test runner + // testRunner: "jasmine2", + + // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href + // testURL: "http://localhost", + + // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" + // timers: "real", + + // A map from regular expressions to paths to transformers + // transform: undefined, + + // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation + // transformIgnorePatterns: [ + // "\\\\node_modules\\\\", + // "\\.pnp\\.[^\\\\]+$" + // ], + + // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them + // unmockedModulePathPatterns: undefined, + + // Indicates whether each individual test should be reported during the run + // verbose: undefined, + + // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode + // watchPathIgnorePatterns: [], + + // Whether to use watchman for file crawling + // watchman: true, +} diff --git a/package.json b/package.json index f77f5a7..e02b23b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "token-pagination-hooks", - "version": "1.0.0", + "version": "0.0.0", "description": "React Hook for token based pagination", "author": "Simone Busoli ", "repository": "simoneb/token-pagination-hooks", @@ -9,11 +9,12 @@ "files": [ "cjs/", "es/", + "umd/", "src/" ], "scripts": { "dev": "npm run build:watch", - "clean": "rimraf cjs es", + "clean": "rimraf cjs es umd", "prepare": "npm run clean && npm run build", "build": "rollup -c", "build:watch": "rollup -c -w", @@ -21,15 +22,25 @@ "lint:fix": "eslint . --fix", "release": "dotenv -e .token release-it --", "toc": "markdown-toc README.md -i", - "test": "echo test", + "test": "jest", "example": "node examples/server.js" }, - "keywords": [], + "keywords": [ + "react", + "pagination", + "token pagination" + ], "license": "ISC", "devDependencies": { + "@babel/core": "^7.12.10", + "@babel/preset-env": "^7.12.11", + "@babel/preset-react": "^7.12.10", "@commitlint/cli": "^11.0.0", "@commitlint/config-conventional": "^11.0.0", + "@testing-library/react": "^11.2.2", + "@testing-library/react-hooks": "^3.7.0", "babel-eslint": "^10.1.0", + "babel-jest": "^26.6.3", "dotenv-cli": "^4.0.0", "eslint": "^7.16.0", "eslint-config-prettier": "^7.1.0", @@ -43,6 +54,8 @@ "markdown-toc": "^1.2.0", "prettier": "^2.2.1", "react": "^17.0.1", + "react-test-renderer": "^17.0.1", + "regenerator-runtime": "^0.13.7", "release-it": "^14.2.2", "rollup": "^2.35.1" }, diff --git a/rollup.config.js b/rollup.config.js index ecb7687..576eddf 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -10,6 +10,14 @@ export default { dir: 'es', format: 'es', }, + { + dir: 'umd', + format: 'umd', + name: 'useTokenPagination', + globals: { + react: 'React', + }, + }, { file: 'examples/umd.js', format: 'umd', diff --git a/setupTests.js b/setupTests.js new file mode 100644 index 0000000..abf6292 --- /dev/null +++ b/setupTests.js @@ -0,0 +1 @@ +import 'regenerator-runtime/runtime' diff --git a/src/controlled.js b/src/controlled.js new file mode 100644 index 0000000..738e8f5 --- /dev/null +++ b/src/controlled.js @@ -0,0 +1,64 @@ +import { useCallback, useState, useMemo } from 'react' +import useUncontrolledTokenPagination from './uncontrolled' +import { assertNumber } from './utils' + +const DEFAULTS = { + defaultPageNumber: 1, + resetPageNumberOnPageSizeChange: true, +} + +export default function useControlledTokenPagination(options) { + options = { ...DEFAULTS, ...options } + + assertNumber('defaultPageNumber', options.defaultPageNumber) + assertNumber('defaultPageSize', options.defaultPageSize) + + const [{ pageNumber, pageSize }, setPagination] = useState({ + pageNumber: options.defaultPageNumber, + pageSize: options.defaultPageSize, + }) + + const change = useCallback( + (property, changer) => { + const pageNumberReset = options.resetPageNumberOnPageSizeChange + ? { pageNumber: options.defaultPageNumber } + : {} + + switch (typeof changer) { + case 'function': + return setPagination(p => ({ + ...p, + ...pageNumberReset, + [property]: changer(p[property]), + })) + case 'number': + return setPagination(p => ({ + ...p, + ...pageNumberReset, + [property]: changer, + })) + default: + throw new Error( + `Unsupported value ${changer} of type ${typeof changer} for ${property}` + ) + } + }, + [options.defaultPageNumber, options.resetPageNumberOnPageSizeChange] + ) + + const changePageNumber = useCallback(c => change('pageNumber', c), [change]) + const changePageSize = useCallback(c => change('pageSize', c), [change]) + + const uncontrolled = useUncontrolledTokenPagination(pageNumber) + + return useMemo( + () => ({ + ...uncontrolled, + pageNumber, + pageSize, + changePageNumber, + changePageSize, + }), + [changePageNumber, changePageSize, pageNumber, pageSize, uncontrolled] + ) +} diff --git a/src/controlled.test.js b/src/controlled.test.js new file mode 100644 index 0000000..da6da3e --- /dev/null +++ b/src/controlled.test.js @@ -0,0 +1,138 @@ +import { renderHook } from '@testing-library/react-hooks' +import { act } from 'react-test-renderer' + +import useControlled from './controlled' + +describe('controlled', () => { + it('throws if a non numeric default page number is provided', () => { + const { result } = renderHook(() => + useControlled({ defaultPageNumber: '1' }) + ) + + expect(() => result.current).toThrow(/defaultPageNumber must be a number/) + }) + + it('throws if a non numeric default page size is provided', () => { + const { result } = renderHook(() => useControlled({ defaultPageSize: '1' })) + + expect(() => result.current).toThrow(/defaultPageSize must be a number/) + }) + + it('does not throw if a default page number is not provided', () => { + const { result } = renderHook(() => useControlled({ defaultPageSize: 1 })) + + expect(() => result.current).not.toThrow() + }) + + it('throws if a default page size is not provided', () => { + const { result } = renderHook(() => useControlled()) + + expect(() => result.current).toThrow(/defaultPageSize must be a number/) + }) + + it('defaults the page number to 1 if not provided', () => { + const { result } = renderHook(() => useControlled({ defaultPageSize: 1 })) + + expect(result.current.pageNumber).toBe(1) + }) + + it('returns the provided default page number', () => { + const { result } = renderHook(() => + useControlled({ defaultPageNumber: 5, defaultPageSize: 1 }) + ) + + expect(result.current.pageNumber).toBe(5) + }) + + it('returns the provided default page size', () => { + const { result } = renderHook(() => useControlled({ defaultPageSize: 1 })) + + expect(result.current.pageSize).toBe(1) + }) + + describe('changePageNumber', () => { + it('changes page number via raw value', () => { + const { result } = renderHook(() => useControlled({ defaultPageSize: 1 })) + + act(() => result.current.changePageNumber(2)) + + expect(result.current.pageNumber).toBe(2) + }) + + it('changes page number via updater', () => { + const { result } = renderHook(() => useControlled({ defaultPageSize: 1 })) + + act(() => result.current.changePageNumber(p => p + 1)) + + expect(result.current.pageNumber).toBe(2) + }) + }) + + describe('changePageSize', () => { + it('throws when changer type is not supported', () => { + const { result } = renderHook(() => useControlled({ defaultPageSize: 1 })) + + act(() => { + expect(() => result.current.changePageSize('2')).toThrow() + }) + }) + + describe('resetPageNumberOnPageSizeChange=true [default]', () => { + it('changes page size via raw value', () => { + const { result } = renderHook(() => + useControlled({ defaultPageSize: 1 }) + ) + + act(() => result.current.changePageNumber(2)) + act(() => result.current.changePageSize(2)) + + expect(result.current.pageNumber).toBe(1) + expect(result.current.pageSize).toBe(2) + }) + + it('changes page size via updater', () => { + const { result } = renderHook(() => + useControlled({ defaultPageSize: 1 }) + ) + + act(() => result.current.changePageNumber(2)) + act(() => result.current.changePageSize(p => p + 1)) + + expect(result.current.pageNumber).toBe(1) + expect(result.current.pageSize).toBe(2) + }) + }) + + describe('resetPageNumberOnPageSizeChange=false', () => { + it('changes page size via raw value', () => { + const { result } = renderHook(() => + useControlled({ + defaultPageSize: 1, + resetPageNumberOnPageSizeChange: false, + }) + ) + + act(() => result.current.changePageNumber(2)) + act(() => result.current.changePageSize(2)) + + expect(result.current.pageNumber).toBe(2) + expect(result.current.pageSize).toBe(2) + }) + + it('changes page size via updater', () => { + const { result } = renderHook(() => + useControlled({ + defaultPageSize: 1, + resetPageNumberOnPageSizeChange: false, + }) + ) + + act(() => result.current.changePageNumber(2)) + act(() => result.current.changePageSize(p => p + 1)) + + expect(result.current.pageNumber).toBe(2) + expect(result.current.pageSize).toBe(2) + }) + }) + }) +}) diff --git a/src/index.js b/src/index.js index b2fef29..c2a80ba 100644 --- a/src/index.js +++ b/src/index.js @@ -1,24 +1,17 @@ -import { useCallback, useEffect, useState } from 'react' +import useControlledTokenPagination from './controlled' +import useUncontrolledTokenPagination from './uncontrolled' -export default function useTokenPagination(pageNumber) { - const [state, setState] = useState({ - [pageNumber]: '', - }) +const variants = { + number: useUncontrolledTokenPagination, + object: useControlledTokenPagination, +} - const useUpdateToken = useCallback( - function useUpdateToken(nextToken = '') { - useEffect(() => { - setState(s => ({ - ...s, - [pageNumber + 1]: nextToken, - })) - }, [nextToken]) - }, - [pageNumber] - ) +export default function useTokenPagination(options) { + const variant = variants[typeof options] - return { - currentToken: state[pageNumber], - useUpdateToken, + if (!variant) { + throw new Error(`Unsupported options ${options} of type ${typeof options}`) } + + return variant(options) } diff --git a/src/index.test.js b/src/index.test.js new file mode 100644 index 0000000..7e09705 --- /dev/null +++ b/src/index.test.js @@ -0,0 +1,33 @@ +import { renderHook } from '@testing-library/react-hooks' +import useTokenPagination from './' + +jest.mock('./controlled', () => jest.fn(() => 'controlled')) +jest.mock('./uncontrolled', () => jest.fn(() => 'uncontrolled')) + +import controlled from './controlled' +import uncontrolled from './uncontrolled' + +describe('useTokenPagination', () => { + it('returns uncontrolled when a pageNumber is provided', async () => { + const { result } = renderHook(() => useTokenPagination(1)) + + expect(result.current).toBe('uncontrolled') + expect(uncontrolled).toHaveBeenCalledWith(1) + }) + + it('returns controlled when an object is provided', async () => { + const arg = {} + const { result } = renderHook(() => useTokenPagination(arg)) + + expect(result.current).toBe('controlled') + expect(controlled).toHaveBeenCalledWith(arg) + }) + + it('throws when an unknown input type is provided', async () => { + const { result } = renderHook(() => useTokenPagination('wrong')) + + expect(() => result.current).toThrow( + /Unsupported options wrong of type string/ + ) + }) +}) diff --git a/src/uncontrolled.js b/src/uncontrolled.js new file mode 100644 index 0000000..3547df7 --- /dev/null +++ b/src/uncontrolled.js @@ -0,0 +1,34 @@ +import { useCallback, useEffect, useState, useMemo } from 'react' +import { assertNumber } from './utils' + +export default function useUncontrolledTokenPagination(pageNumber) { + assertNumber('pageNumber', pageNumber) + + const [mapping, setMapping] = useState({}) + + const updateToken = useCallback( + nextToken => { + setMapping(m => ({ + ...m, + [pageNumber + 1]: nextToken, + })) + }, + [pageNumber] + ) + + const useUpdateToken = useCallback( + function useUpdateToken(nextToken) { + useEffect(() => updateToken(nextToken), [nextToken]) + }, + [updateToken] + ) + + return useMemo( + () => ({ + currentToken: mapping[pageNumber], + useUpdateToken, + updateToken, + }), + [mapping, pageNumber, updateToken, useUpdateToken] + ) +} diff --git a/src/uncontrolled.test.js b/src/uncontrolled.test.js new file mode 100644 index 0000000..b1bf6fe --- /dev/null +++ b/src/uncontrolled.test.js @@ -0,0 +1,46 @@ +import { renderHook } from '@testing-library/react-hooks' +import { act } from 'react-test-renderer' + +import useUncontrolled from './uncontrolled' + +describe('uncontrolled', () => { + it('throws if a non numeric page number is provided', () => { + const { result } = renderHook(() => useUncontrolled('1')) + + expect(() => result.current).toThrow(/pageNumber must be a number/) + }) + + it('sets the initial token to undefined', () => { + const { result } = renderHook(() => useUncontrolled(1)) + + expect(result.current.currentToken).toBe(undefined) + }) + + it('updates the token declaratively', async () => { + const { result, rerender } = renderHook(props => useUncontrolled(props), { + initialProps: 1, + }) + + const { useUpdateToken } = result.current + + renderHook(() => useUpdateToken('next')) + + rerender(2) + + expect(result.current.currentToken).toBe('next') + }) + + it('updates the token imperatively', async () => { + const { result, rerender } = renderHook(props => useUncontrolled(props), { + initialProps: 1, + }) + + const { updateToken } = result.current + + act(() => updateToken('next')) + + rerender(2) + + expect(result.current.currentToken).toBe('next') + }) +}) diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..a846cc0 --- /dev/null +++ b/src/utils.js @@ -0,0 +1,5 @@ +export function assertNumber(prop, value) { + if (typeof value !== 'number') { + throw new Error(`${prop} must be a number`) + } +}