diff --git a/.eslintrc.json b/.eslintrc.json index dff6579..d5bbb3a 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -4,18 +4,30 @@ "browser": true, "node": true }, - "plugins": ["@typescript-eslint", "react", "react-hooks", "jest"], + "plugins": ["@typescript-eslint", "react", "react-hooks"], "extends": [ + "eslint:recommended", "plugin:@typescript-eslint/recommended", + "plugin:react/recommended", + "plugin:react/jsx-runtime", "plugin:import/recommended", "plugin:import/typescript", - "plugin:jest/recommended", "plugin:prettier/recommended" ], "parserOptions": { "sourceType": "module", "ecmaVersion": 2018 }, + "settings": { + "react": { + "version": "detect" + }, + "import/resolver": { + "node": { + "paths": ["./"] + } + } + }, "rules": { "react-hooks/rules-of-hooks": "error", "react-hooks/exhaustive-deps": "error", diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 001b0a4..1f1532b 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -3,5 +3,4 @@ set -e set -o pipefail -npm run lint -npm run typecheck +npm run checks diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index db94f16..c704bf3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,6 +10,4 @@ jobs: node-version: '16.x' registry-url: 'https://registry.npmjs.org' - run: npm install - - run: npm run lint - - run: npm run typecheck - - run: npm run test + - run: npm run checks diff --git a/.gitignore b/.gitignore index 090fba5..1d33405 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ /package-lock.json /node_modules /dist + +# ESLint +.eslintcache diff --git a/jest.config.json b/jest.config.json deleted file mode 100644 index 060d8d1..0000000 --- a/jest.config.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "testEnvironment": "jsdom", - "transform": { - "^.+\\.(t|j)sx?$": "ts-jest" - }, - "testRegex": "(/__tests?__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$", - "moduleFileExtensions": ["ts", "tsx", "js", "jsx", "json", "node"], - "setupFilesAfterEnv": ["/jest-setup.ts"] -} diff --git a/package.json b/package.json index 47be365..4d8012c 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,43 @@ "name": "@aboutbits/react-pagination", "version": "1.0.2", "description": "Pagination hooks for React", - "sideEffects": false, + "exports": { + ".": { + "types": "./dist/types/engine/index.d.ts", + "import": "./dist/esm/engine/index.js", + "require": "./dist/cjs/engine/index.js" + }, + "./next-router": { + "types": "./dist/types/routers/nextRouter.d.ts", + "import": "./dist/esm/routers/nextRouter.js", + "require": "./dist/cjs/routers/nextRouter.js" + }, + "./in-memory": { + "types": "./dist/types/routers/inMemory.d.ts", + "import": "./dist/esm/routers/inMemory.js", + "require": "./dist/cjs/routers/inMemory.js" + }, + "./react-router": { + "types": "./dist/types/routers/reactRouter.d.ts", + "import": "./dist/esm/routers/reactRouter.js", + "require": "./dist/cjs/routers/reactRouter.js" + }, + "./next-router/zod": { + "types": "./dist/types/zod/routers/nextRouter.d.ts", + "import": "./dist/esm/zod/routers/nextRouter.js", + "require": "./dist/cjs/zod/routers/nextRouter.js" + }, + "./in-memory/zod": { + "types": "./dist/types/zod/routers/inMemory.d.ts", + "import": "./dist/esm/zod/routers/inMemory.js", + "require": "./dist/cjs/zod/routers/inMemory.js" + }, + "./react-router/zod": { + "types": "./dist/types/zod/routers/reactRouter.d.ts", + "import": "./dist/esm/zod/routers/reactRouter.js", + "require": "./dist/cjs/zod/routers/reactRouter.js" + } + }, "engines": { "npm": "^8", "node": "^16" @@ -12,13 +48,16 @@ "build:esm": "tsc -p tsconfig.esm.json", "build:cjs": "tsc -p tsconfig.cjs.json", "build:types": "tsc -p tsconfig.types.json", - "lint": "eslint --ext js,ts,tsx src", + "lint": "eslint --ext js,ts,tsx --cache .", "lint:fix": "npm run lint -- --fix", + "test": "vitest run --passWithNoTests", + "test:watch": "vitest watch --passWithNoTests", "typecheck": "tsc --noEmit", - "test": "jest --verbose --passWithNoTests", + "checks": "npm run test && npm run lint && npm run typecheck", + "prepublishOnly": "npm run checks", "prepare": "npm run build", - "prepublishOnly": "npm run test && npm run lint", - "version": "npm run test && npm run lint && git add -A src", + "preversion": "npm run checks", + "version": "git add -A src", "postversion": "git push && git push --tags" }, "repository": { @@ -28,8 +67,9 @@ "private": false, "keywords": [ "react", + "query", "pagination", - "hooks" + "zod" ], "author": "About Bits", "license": "MIT", @@ -47,7 +87,6 @@ "devDependencies": { "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^13.4.0", - "@types/jest": "^28.1.3", "@types/react": "^18.0.0", "@types/react-dom": "^18.0.0", "@types/react-router-dom": "^5.3.3", @@ -56,12 +95,10 @@ "eslint": "^8.18.0", "eslint-config-prettier": "^8.5.0", "eslint-plugin-import": "^2.26.0", - "eslint-plugin-jest": "^26.5.3", "eslint-plugin-prettier": "^4.1.0", "eslint-plugin-react": "^7.32.2", "eslint-plugin-react-hooks": "^4.6.0", - "jest": "^28.1.1", - "jest-environment-jsdom": "^28.1.1", + "jsdom": "^22.1.0", "next": "^13.1.3", "next-router-mock": "^0.7.4", "prettier": "^2.7.1", @@ -69,13 +106,15 @@ "react-dom": "^18.0.0", "react-router-dom": "^6.7.0", "rimraf": "^5.0.1", - "ts-jest": "^28.0.5", - "typescript": "^5.1.6" + "typescript": "^5.1.6", + "vite": "^4.4.4", + "vitest": "^0.33.0" }, "peerDependencies": { "next": "^12.0.0 || ^13.0.0", "react": "^16.0.0 || ^17.0.0 || ^18.0.0", - "react-router-dom": "^6.0.0" + "react-router-dom": "^6.0.0", + "zod": "^3.0.0" }, "peerDependenciesMeta": { "next": { @@ -83,6 +122,9 @@ }, "react-router-dom": { "optional": true + }, + "zod": { + "optional": true } } } diff --git a/readme.md b/readme.md index 9024ecb..ac28222 100644 --- a/readme.md +++ b/readme.md @@ -1,130 +1,178 @@ -React Pagination -============= +# React Pagination [![npm package](https://badge.fury.io/js/%40aboutbits%2Freact-pagination.svg)](https://badge.fury.io/js/%40aboutbits%2Freact-pagination) [![license](https://img.shields.io/github/license/aboutbits/react-pagination)](https://github.com/aboutbits/react-pagination/blob/main/license.md) -This package includes pagination hooks for React. The hooks support saving the query and pagination values in local -state or in the browser URL. +Query hooks for React with first-class support for TypeScript! Writing and reading the query, attached to the browser URL or in-memory, made easy! ## Table of content - [Usage](#usage) - - [useQueryAndPagination](#usequeryandpagination) -- [Supported Implementations](#supported-implementations) - - [In Memory Pagination](#in-memory-pagination) - - [React-Router based pagination](#react-router-based-pagination) - - [NextJS Router based pagination](#nextjs-router-based-pagination) + - [Example usage with Next.js](#example-usage-with-nextjs) + - [Example usage with React Router and zod](#example-usage-with-react-router-and-zod) - [Build & Publish](#build--publish) -- [Information](#information) +- [About](#about) ## Usage -First, you have to install the package: +Install the package: -```bash +```sh npm install @aboutbits/react-pagination ``` -Second, you can make use of the `useQueryAndPagination` hook. This package implements 3 versions of this hook: +There are a variety of entry points from which to import the hooks `useQuery`, `usePagination` and `useQueryAndPagination`: -- [In Memory](#in-memory-pagination): Use this hook where you don't want to modify browser history. e.g. Dialogs -- [React Router](#react-router-based-pagination): Use this hook if you want to keep track of the state in the URL and - your project is using React Router. -- [NextJS Router](#nextjs-router-based-pagination): Use this hook if you want to keep track of the state in the URL and - your project is using NextJS. +- For the [Next.js](https://nextjs.org/) router: + - `@aboutbits/react-pagination/next-router` + - `@aboutbits/react-pagination/next-router/zod` +- For [React Router](https://reactrouter.com): +- `@aboutbits/react-pagination/react-router` +- `@aboutbits/react-pagination/react-router/zod` +- For an in-memory router that does not modify the browser history: + - `@aboutbits/react-pagination/in-memory` + - `@aboutbits/react-pagination/in-memory/zod` -### useQueryAndPagination +The hooks exported from `@aboutbits/react-pagination/*/zod` are more convenient when using [zod](https://github.com/colinhacks/zod) for the validation of the query. -This hook supports the combination of query parameters and pagination and manages the state of the query parameter values and the -pagination values. +`useQueryAndPagination` merges the functionality of `useQuery` and `usePagination`. Changing the query resets the page, but changing the page does not reset the query. -#### The hook supports following configuration parameter object: +Some examples follow, but we recommend having a look at the type definitions for more details about the API. -|value|type|default|description| -|---|---|---|---| -|indexType|IndexType|IndexType.ZERO_BASED|It defines whether the pagination is zero or one based.| -|pageSize|number|15|Page size of the pagination.| -|defaultQueryParameters/Record|{}|It defines the default value for each query parameter. This is used to remove a query parameter from the URL and also to clear the query. - -#### The hook returns the following object: - -|value|type|description| -|---|---|---| -|queryParameters|object|values of your query parameters| -|page|number|value of the current page| -|size|number|max elements in a single page| -|actions|object|object with 3 functions: updateQuery, setPage, clear| - -#### Example usage with NextJS +### Example usage with Next.js ```tsx -import { useQueryAndPagination } from '@aboutbits/react-pagination/dist/nextRouterPagination' - -const users = [ - 'Alex', 'Simon', 'Natan', 'Nadia', 'Moritz', 'Marie' -] - -function UserList() { - const { page, size, queryParameters, actions } = useQueryAndPagination({pageSize: 2}) - - return ( -
- actions.updateQuery({search: value})}/> - - - -
    - {users.filter(user => user.startsWith(queryParameters.search)) - .slice(page, page + size) - .map(user =>
  • {user}
  • )} -
-
- ) +import { Query } from '@aboutbits/react-pagination' +import { useQueryAndPagination } from '@aboutbits/react-pagination/next-router' + +const users = ['Alex', 'Simon', 'Natan', 'Nadia', 'Moritz', 'Marie'] + +const parseSearch = (query: Query) => { + for (const [key, value] of Object.entries(query)) { + if (key === 'search' && !Array.isArray(value)) { + return { search: value } + } + } + return {} } -``` - -## Supported implementations - -This package includes 3 different implementations of the above hook. - -- [In Memory](#in-memory-pagination) -- [React Router](#react-router-based-pagination) -- [NextJS Router](#nextjs-router-based-pagination) -### In Memory Pagination - -Use this pagination hook if you want to keep track of the pagination in memory. This is very handy for dialogs. - -```tsx -import { useQueryAndPagination } from '@aboutbits/react-pagination/dist/inMemoryPagination' +export function UserList() { + const { page, size, query, setQuery, setPage, resetQuery } = + useQueryAndPagination({ search: '' }, parseSearch) + + return ( +
+ setQuery({ search: event.target.value })} + /> + + +
    + {users + .filter((user) => + user.toLowerCase().startsWith(query.search.toLowerCase()) + ) + .slice(page * size, (page + 1) * size) + .map((user) => ( +
  • {user}
  • + ))} +
+
+ ) +} ``` -### React-Router based pagination - -These are specific hooks for applications that use [React Router](https://reactrouter.com/) for routing. +### Example usage with React Router and zod ```tsx -import { useQueryAndPagination } from '@aboutbits/react-pagination/dist/reactRouterPagination' -``` +import { useQueryAndPagination } from '@aboutbits/react-pagination/react-router/zod' +import { z } from 'zod' + +const userSchema = z.object({ + name: z.string(), + // The input to the parser is going to be a string. + // We try to convert it to a number and default to undefined if the parsing fails. + // This continues the parsing of the remaining query. + // Another possibility would be to not catch errors, which would cancel the entire parsing + // if "age" cannot be converted to a number. + age: z.string().pipe(z.coerce.number().optional()).catch(undefined), +}) -### NextJS Router based pagination - -These are specific hooks for applications that use [NextJS Router](https://nextjs.org/docs/api-reference/next/router) -for routing. +const users = [ + { name: 'Alex', age: 10 }, + { name: 'Simon', age: 24 }, + { name: 'Natan', age: 88 }, + { name: 'Nadia', age: 42 }, + { name: 'Moritz', age: 35 }, + { name: 'Marie', age: 17 }, +] -```tsx -import { useQueryAndPagination } from '@aboutbits/react-pagination/dist/nextRouterPagination' +export function UserList() { + const { page, size, query, setQuery, setPage, resetQuery } = + useQueryAndPagination({ name: '', age: 0 }, userSchema, { + page: 0, + size: 4, + }) + + return ( +
+
+ Name: + setQuery({ name: event.target.value })} + /> +
+
+ Minimum age: + { + const value = event.target.value + const parsed = parseInt(value) + if (!isNaN(parsed)) { + setQuery({ age: parsed }) + } + }} + /> +
+ + +
    + {users + .filter( + (user) => + user.name.toLowerCase().startsWith(query.name.toLowerCase()) && + user.age >= query.age + ) + .slice(page * size, (page + 1) * size) + .map((user) => ( +
  • {user.name}
  • + ))} +
+
+ ) +} ``` ## Build & Publish To publish the package commit all changes and push them to main. Then run one of the following commands locally: -```bash +```sh npm version patch npm version minor npm version major @@ -132,7 +180,7 @@ npm version major ## Information -About Bits is a company based in South Tyrol, Italy. You can find more information about us +AboutBits is a company based in South Tyrol, Italy. You can find more information about us on [our website](https://aboutbits.it). ### Support diff --git a/src/__test__/inMemoryPagination.test.ts b/src/__test__/inMemoryPagination.test.ts deleted file mode 100644 index 1c9955e..0000000 --- a/src/__test__/inMemoryPagination.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { act, renderHook } from '@testing-library/react' -import { useQueryAndPagination } from '../inMemoryPagination' -import { IndexType } from '../types' - -test('should initialize pagination', () => { - const { result } = renderHook(() => useQueryAndPagination()) - - expect(result.current.page).toBe(0) - expect(result.current.size).toBe(15) -}) - -test('should change page', () => { - const { result } = renderHook(() => useQueryAndPagination()) - - act(() => { - result.current.actions.setPage(2) - }) - - expect(result.current.page).toBe(2) -}) - -test('should change search', () => { - const { result } = renderHook(() => - useQueryAndPagination({ defaultQueryParameters: { search: '' } }) - ) - - act(() => { - result.current.actions.updateQuery({ search: 'Max' }) - }) - - expect(result.current.queryParameters.search).toBe('Max') -}) - -test('on search change -> page should be reset', () => { - const { result } = renderHook(() => - useQueryAndPagination({ defaultQueryParameters: { search: '' } }) - ) - - act(() => { - result.current.actions.setPage(2) - }) - - expect(result.current.page).toBe(2) - - act(() => { - result.current.actions.updateQuery({ search: 'Max' }) - }) - - expect(result.current.queryParameters.search).toBe('Max') - expect(result.current.page).toBe(0) -}) - -test('clear pagination should reset search and page', () => { - const { result } = renderHook(() => - useQueryAndPagination({ defaultQueryParameters: { search: '' } }) - ) - - act(() => { - result.current.actions.updateQuery({ search: 'Max' }) - }) - - act(() => { - result.current.actions.setPage(2) - }) - - expect(result.current.queryParameters.search).toBe('Max') - expect(result.current.page).toBe(2) - - act(() => { - result.current.actions.clear() - }) - - expect(result.current.queryParameters.search).toBe('') - expect(result.current.page).toBe(0) -}) - -test('change default parameters', () => { - const { result } = renderHook(() => - useQueryAndPagination({ indexType: IndexType.ONE_BASED, pageSize: 10 }) - ) - - expect(result.current.page).toBe(1) - expect(result.current.size).toBe(10) -}) - -test('query multiple different properties, should keep them all', () => { - const { result } = renderHook(() => - useQueryAndPagination({ - defaultQueryParameters: { search: '', department: '' }, - }) - ) - - act(() => { - result.current.actions.updateQuery({ search: 'Max' }) - result.current.actions.updateQuery({ department: 'IT' }) - }) - - expect(result.current.queryParameters.search).toBe('Max') - expect(result.current.queryParameters.department).toBe('IT') -}) - -test('query a property that is not configured, should do nothing', () => { - const { result } = renderHook(() => - useQueryAndPagination({ - defaultQueryParameters: { search: '' }, - }) - ) - - act(() => { - result.current.actions.updateQuery({ department: 'IT' }) - }) - - expect(result.current.queryParameters.search).toBe('') - expect(result.current.queryParameters.department).toBeUndefined() -}) - -test('query property with empty value and different default value', () => { - const { result } = renderHook(() => - useQueryAndPagination({ - defaultQueryParameters: { search: 'Default search' }, - }) - ) - - act(() => { - result.current.actions.updateQuery({ search: '' }) - }) - - expect(result.current.queryParameters.search).toBe('') -}) diff --git a/src/__test__/nextRouterPagination.test.tsx b/src/__test__/nextRouterPagination.test.tsx deleted file mode 100644 index 696e581..0000000 --- a/src/__test__/nextRouterPagination.test.tsx +++ /dev/null @@ -1,185 +0,0 @@ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import React from 'react' -import { act, renderHook } from '@testing-library/react' -import router from 'next/router' - -import { useQueryAndPagination } from '../nextRouterPagination' -import { IndexType } from '../types' - -jest.mock('next/router', () => require('next-router-mock')) - -beforeEach(() => { - router.query = {} -}) - -test('should initialize pagination', () => { - const { result } = renderHook(() => useQueryAndPagination()) - - expect(result.current.page).toBe(0) - expect(result.current.size).toBe(15) -}) - -test('should change page', () => { - const { result } = renderHook(() => useQueryAndPagination()) - - act(() => { - result.current.actions.setPage(2) - }) - - expect(result.current.page).toBe(2) - expect(router.query.page).toBe('2') -}) - -test('should change search', () => { - const { result } = renderHook(() => - useQueryAndPagination({ defaultQueryParameters: { search: '' } }) - ) - - act(() => { - result.current.actions.updateQuery({ search: 'Max' }) - }) - - expect(result.current.queryParameters.search).toBe('Max') - expect(router.query.search).toBe('Max') -}) - -test('clear pagination should reset search and page', () => { - const { result } = renderHook(() => - useQueryAndPagination({ defaultQueryParameters: { search: '' } }) - ) - - act(() => { - result.current.actions.updateQuery({ search: 'Max' }) - }) - - act(() => { - result.current.actions.setPage(2) - }) - - expect(router.query.search).toBe('Max') - expect(router.query.page).toBe('2') - - act(() => { - result.current.actions.clear() - }) - - expect(result.current.queryParameters.search).toBe('') - expect(result.current.page).toBe(0) - expect(router.query.search).toBeUndefined() - expect(router.query.page).toBeUndefined() -}) - -test('change default parameters', () => { - const { result } = renderHook(() => - useQueryAndPagination({ indexType: IndexType.ONE_BASED, pageSize: 10 }) - ) - - expect(result.current.page).toBe(1) - expect(result.current.size).toBe(10) -}) - -test('on search change -> page should be reset', () => { - const { result } = renderHook(() => - useQueryAndPagination({ defaultQueryParameters: { search: '' } }) - ) - - act(() => { - result.current.actions.setPage(2) - }) - - expect(result.current.page).toBe(2) - expect(router.query.page).toBe('2') - - act(() => { - result.current.actions.updateQuery({ search: 'Max' }) - }) - - expect(result.current.queryParameters.search).toBe('Max') - expect(result.current.page).toBe(0) - expect(router.query.search).toBe('Max') - expect(router.query.page).toBeUndefined() -}) - -test('query multiple different properties, should keep them all', () => { - const { result } = renderHook(() => - useQueryAndPagination({ - defaultQueryParameters: { search: '', department: '' }, - }) - ) - - act(() => { - result.current.actions.updateQuery({ search: 'Max' }) - }) - - act(() => { - result.current.actions.updateQuery({ department: 'IT' }) - }) - - expect(result.current.queryParameters.search).toBe('Max') - expect(result.current.queryParameters.department).toBe('IT') -}) - -test('query a property that is not configured, should do nothing', () => { - const { result } = renderHook(() => - useQueryAndPagination({ - defaultQueryParameters: { search: '' }, - }) - ) - - act(() => { - result.current.actions.updateQuery({ department: 'IT' }) - }) - - expect(result.current.queryParameters.search).toBe('') - expect(result.current.queryParameters.department).toBeUndefined() -}) - -test('properties in the URL, that are not part of the configuration should be left untouched', () => { - router.query = { greeting: 'hello' } - - const { result } = renderHook(() => - useQueryAndPagination({ - defaultQueryParameters: { search: '' }, - }) - ) - - act(() => { - result.current.actions.updateQuery({ search: 'Max' }) - }) - - expect(result.current.queryParameters.search).toBe('Max') - expect(result.current.queryParameters.greeting).toBeUndefined() - expect(router.query.greeting).toBe('hello') -}) - -test('query property with default value, should remove it from url', () => { - router.query = { search: 'Max' } - - const { result } = renderHook(() => - useQueryAndPagination({ - defaultQueryParameters: { search: '' }, - }) - ) - - act(() => { - result.current.actions.updateQuery({ search: '' }) - }) - - expect(result.current.queryParameters.search).toBe('') - expect(router.query.search).toBeUndefined() -}) - -test('query property with empty value and different default value', () => { - const { result } = renderHook(() => - useQueryAndPagination({ - defaultQueryParameters: { search: 'Default search' }, - }) - ) - - act(() => { - result.current.actions.updateQuery({ search: '' }) - }) - - expect(result.current.queryParameters.search).toBe('') - expect(router.query.search).toBe('') -}) diff --git a/src/__test__/reactRouterPagination.test.tsx b/src/__test__/reactRouterPagination.test.tsx deleted file mode 100644 index 19d4cce..0000000 --- a/src/__test__/reactRouterPagination.test.tsx +++ /dev/null @@ -1,177 +0,0 @@ -import React, { ReactNode } from 'react' -import { act, renderHook } from '@testing-library/react' -import { BrowserRouter as Router } from 'react-router-dom' -import { useQueryAndPagination } from '../reactRouterPagination' -import { IndexType } from '../types' - -function Wrapper({ children }: { children?: ReactNode }) { - return {children} -} - -beforeEach(() => { - window.history.pushState({}, '', '/') -}) - -test('should initialize pagination', () => { - const { result } = renderHook(() => useQueryAndPagination(), { - wrapper: Wrapper, - }) - - expect(result.current.page).toBe(0) - expect(result.current.size).toBe(15) -}) - -test('should change page', () => { - const { result } = renderHook(() => useQueryAndPagination(), { - wrapper: Wrapper, - }) - - act(() => { - result.current.actions.setPage(2) - }) - - expect(result.current.page).toBe(2) - expect(window.location.search).toBe('?page=2') -}) - -test('should change search', () => { - const { result } = renderHook( - () => useQueryAndPagination({ defaultQueryParameters: { search: '' } }), - { wrapper: Wrapper } - ) - - act(() => { - result.current.actions.updateQuery({ search: 'Max' }) - }) - - expect(result.current.queryParameters.search).toBe('Max') - expect(window.location.search).toBe('?search=Max') -}) - -test('clear pagination should reset search and page', () => { - const { result } = renderHook( - () => useQueryAndPagination({ defaultQueryParameters: { search: '' } }), - { wrapper: Wrapper } - ) - - act(() => { - result.current.actions.updateQuery({ search: 'Max' }) - }) - - act(() => { - result.current.actions.setPage(2) - }) - - expect(result.current.queryParameters.search).toBe('Max') - expect(result.current.page).toBe(2) - - act(() => { - result.current.actions.clear() - }) - - expect(result.current.queryParameters.search).toBe('') - expect(result.current.page).toBe(0) - expect(window.location.search).toBe('') -}) - -test('change default parameters', () => { - const { result } = renderHook( - () => - useQueryAndPagination({ indexType: IndexType.ONE_BASED, pageSize: 10 }), - { wrapper: Wrapper } - ) - - expect(result.current.page).toBe(1) - expect(result.current.size).toBe(10) -}) - -test('query multiple different properties, should keep them all', () => { - const { result } = renderHook( - () => - useQueryAndPagination({ - defaultQueryParameters: { search: '', department: '' }, - }), - { wrapper: Wrapper } - ) - - act(() => { - result.current.actions.updateQuery({ search: 'Max' }) - result.current.actions.updateQuery({ department: 'IT' }) - }) - - expect(result.current.queryParameters.search).toBe('Max') - expect(result.current.queryParameters.department).toBe('IT') -}) - -test('query a property that is not configured, should do nothing', () => { - const { result } = renderHook( - () => - useQueryAndPagination({ - defaultQueryParameters: { search: '' }, - }), - { wrapper: Wrapper } - ) - - act(() => { - result.current.actions.updateQuery({ department: 'IT' }) - }) - - expect(result.current.queryParameters.search).toBe('') - expect(result.current.queryParameters.department).toBeUndefined() -}) - -test('properties in the URL, that are not part of the configuration should be left untouched', () => { - window.history.pushState({}, '', '/?greeting=hello') - - const { result } = renderHook( - () => - useQueryAndPagination({ - defaultQueryParameters: { search: '' }, - }), - { wrapper: Wrapper } - ) - - act(() => { - result.current.actions.updateQuery({ search: 'Max' }) - }) - - expect(result.current.queryParameters.search).toBe('Max') - expect(result.current.queryParameters.greeting).toBeUndefined() - expect(window.location.search).toBe('?greeting=hello&search=Max') -}) - -test('query property with default value, should remove it from url', () => { - window.history.pushState({}, '', '/?search=Anton') - - const { result } = renderHook( - () => - useQueryAndPagination({ - defaultQueryParameters: { search: '' }, - }), - { wrapper: Wrapper } - ) - - act(() => { - result.current.actions.updateQuery({ search: '' }) - }) - - expect(result.current.queryParameters.search).toBe('') - expect(window.location.search).toBe('') -}) - -test('query property with empty value and different default value', () => { - const { result } = renderHook( - () => - useQueryAndPagination({ - defaultQueryParameters: { search: 'Default search' }, - }), - { wrapper: Wrapper } - ) - - act(() => { - result.current.actions.updateQuery({ search: '' }) - }) - - expect(result.current.queryParameters.search).toBe('') - expect(window.location.search).toBe('?search=') -}) diff --git a/src/engine/index.ts b/src/engine/index.ts new file mode 100644 index 0000000..19b3e14 --- /dev/null +++ b/src/engine/index.ts @@ -0,0 +1,2 @@ +export * from './query' +export * from './pagination' diff --git a/src/engine/pagination.ts b/src/engine/pagination.ts new file mode 100644 index 0000000..61a3ee0 --- /dev/null +++ b/src/engine/pagination.ts @@ -0,0 +1,111 @@ +import { queryValueToIntOrUndefined } from '../utils' +import { + Query, + AbstractQuery, + ParseQuery, + useAbstractQuery, + Router, + AbstractQueryOptions, +} from './query' + +export type PaginationQuery = { + /** + * The page index. + * + * @default 0 + */ + page: number + /** + * The size of one page. + * + * @default 15 + */ + size: number +} + +const DEFAULT_PAGINATION: PaginationQuery = { + page: 0, + size: 15, +} + +export type ChangeQueryOptions = { + /** + * Whether the page should be reset to its default value when the query is changed. + * + * @default true + */ + resetPage: boolean +} + +const DEFAULT_CHANGE_QUERY_OPTIONS: ChangeQueryOptions = { resetPage: true } + +const parsePagination = (query: Query): Partial => { + return { + page: queryValueToIntOrUndefined(query.page), + size: queryValueToIntOrUndefined(query.size), + } +} + +export const useAbstractQueryAndPagination = ( + defaultQuery: T, + parseQuery: ParseQuery, + router: Router, + defaultPagination?: PaginationQuery, + options?: Partial +) => { + const mergedDefaultPagination = { + ...DEFAULT_PAGINATION, + ...defaultPagination, + } + const mergedDefaultQueryAndPagination = { + ...defaultQuery, + ...mergedDefaultPagination, + } + + const { query, setQuery } = useAbstractQuery( + mergedDefaultQueryAndPagination, + parseQuery, + router, + options + ) + + const { + query: pagination, + setQuery: setPagination, + resetQuery: resetPagination, + } = useAbstractQuery( + mergedDefaultPagination, + parsePagination, + router, + options + ) + + return { + query, + setQuery: (query: Partial, options?: Partial) => { + const mergedOptions = { ...DEFAULT_CHANGE_QUERY_OPTIONS, ...options } + setQuery({ + ...query, + page: mergedOptions.resetPage + ? mergedDefaultPagination.page + : undefined, + }) + }, + resetQuery: (options?: Partial) => { + const mergedOptions = { ...DEFAULT_CHANGE_QUERY_OPTIONS, ...options } + setQuery({ + ...defaultQuery, + page: mergedOptions.resetPage + ? mergedDefaultPagination.page + : undefined, + }) + }, + page: pagination.page, + size: pagination.size, + setPage: (page: PaginationQuery['page']) => setPagination({ page }), + setSize: (size: PaginationQuery['size']) => setPagination({ size }), + setPagination, + resetPagination, + resetQueryAndPagination: () => setQuery(mergedDefaultQueryAndPagination), + } +} diff --git a/src/engine/query.ts b/src/engine/query.ts new file mode 100644 index 0000000..3c5cba6 --- /dev/null +++ b/src/engine/query.ts @@ -0,0 +1,117 @@ +import { useCallback, useMemo } from 'react' + +export type Query = Record + +export type AbstractQueryValueElement = + | string + | number + | boolean + | Date + | bigint + +export type AbstractQuery = Record< + string, + AbstractQueryValueElement | AbstractQueryValueElement[] +> + +/** + * Parses the query. + * + * @returns The parameters that could be parsed. An empty record if no parameter could be parsed. + * @throws If the parsing failed. + */ +export type ParseQuery = (query: Query) => Partial + +export type Router = { + /** + * @returns The current query. + */ + getQuery: (defaultQuery: Query) => Query + + /** + * Updates the query by merging the given query with the current query. + */ + setQuery: (query: Partial, defaultQuery: Query) => void +} + +export const useQuery = (defaultQuery: T, router: Router) => { + const resetQuery = useCallback( + () => router.setQuery(defaultQuery, defaultQuery), + [router, defaultQuery] + ) + + const query = router.getQuery(defaultQuery) + + return { + query, + setQuery: (query: Partial) => router.setQuery(query, defaultQuery), + resetQuery, + } +} + +export type AbstractQueryOptions = { + /** + * How the abstract query is converted to an actual query. + * + * @default Each value that is not undefined is converted to a string by calling `.toString()`. + */ + convertToQuery: (abstractQuery: Partial) => Query +} + +const DEFAULT_ABSTRACT_QUERY_OPTIONS: AbstractQueryOptions = { + convertToQuery: (abstractQuery) => { + const query: Query = {} + for (const [key, value] of Object.entries(abstractQuery)) { + if (value !== undefined) { + query[key] = value.toString() + } + } + return query + }, +} + +export const useAbstractQuery = ( + defaultQuery: T, + parseQuery: ParseQuery, + router: Router, + options?: Partial +) => { + const mergedOptions = useMemo( + () => ({ ...DEFAULT_ABSTRACT_QUERY_OPTIONS, ...options }), + [options] + ) + + const convertedDefaultQuery = useMemo( + () => mergedOptions.convertToQuery(defaultQuery), + [mergedOptions, defaultQuery] + ) + + const { query, setQuery, resetQuery } = useQuery( + convertedDefaultQuery, + router + ) + + const parsedQuery: T = useMemo(() => { + let parsed: Partial + try { + parsed = parseQuery(query) + } catch (e) { + parsed = {} + } + return { + ...defaultQuery, + ...parsed, + } + }, [defaultQuery, parseQuery, query]) + + const setAbstractQuery = useCallback( + (query: Partial) => setQuery(mergedOptions.convertToQuery(query)), + [setQuery, mergedOptions] + ) + + return { + query: parsedQuery, + setQuery: setAbstractQuery, + resetQuery, + } +} diff --git a/src/inMemoryPagination.tsx b/src/inMemoryPagination.tsx deleted file mode 100644 index 003d84b..0000000 --- a/src/inMemoryPagination.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { useCallback, useMemo, useState } from 'react' -import { IndexType, IUseQueryAndPagination, QueryParameters } from './types' - -export const useQueryAndPagination: IUseQueryAndPagination = function (config) { - const { indexType = IndexType.ZERO_BASED, pageSize = 15 } = config || {} - const firstPage = indexType === IndexType.ZERO_BASED ? 0 : 1 - const initialState = useMemo( - () => ({ - page: firstPage, - size: pageSize, - queryParameters: config?.defaultQueryParameters || {}, - }), - [firstPage, pageSize, config?.defaultQueryParameters] - ) - - const [state, setState] = useState(initialState) - - const updateQuery = useCallback( - (queryParameters: QueryParameters) => { - setState((currentState) => { - const updatedQueryParameters = { - ...currentState.queryParameters, - ...queryParameters, - } - - for (const parameter in queryParameters) { - if ( - !!config?.defaultQueryParameters && - (config.defaultQueryParameters[parameter] === undefined || - config.defaultQueryParameters[parameter] === - queryParameters[parameter]) - ) { - delete updatedQueryParameters[parameter] - } - } - - return { - ...currentState, - page: 0, - queryParameters: updatedQueryParameters, - } - }) - }, - [config?.defaultQueryParameters] - ) - - const clear = useCallback(() => { - setState(() => { - return initialState - }) - }, [initialState]) - - const setPage = useCallback((page: number) => { - setState((currentState) => { - return { - ...currentState, - page, - } - }) - }, []) - - return { - queryParameters: { - ...(config?.defaultQueryParameters ? config.defaultQueryParameters : {}), - ...state.queryParameters, - }, - actions: { - updateQuery, - clear, - setPage, - }, - page: state.page, - size: state.size, - } -} diff --git a/src/nextRouterPagination.tsx b/src/nextRouterPagination.tsx deleted file mode 100644 index 721e7dc..0000000 --- a/src/nextRouterPagination.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { ParsedUrlQuery } from 'querystring' -import { useRouter } from 'next/router' -import { useCallback } from 'react' -import { IndexType, IUseQueryAndPagination, QueryParameters } from './types' -import { convert } from './utils' - -function getSingleParameterValue( - parameter: string | string[] | undefined -): string | undefined { - return Array.isArray(parameter) ? parameter[0] : parameter -} - -function extractCurrentQueryParameters( - query: ParsedUrlQuery, - defaultQueryParameters?: QueryParameters -) { - if (!defaultQueryParameters) { - return {} - } - - const result: QueryParameters = { ...defaultQueryParameters } - - for (const parameter in defaultQueryParameters) { - const queryParameter = getSingleParameterValue(query[parameter]) - - if (typeof queryParameter !== 'undefined') { - result[parameter] = queryParameter - } - } - - return result -} - -export const useQueryAndPagination: IUseQueryAndPagination = function (config) { - const { indexType = IndexType.ZERO_BASED, pageSize = 15 } = config || {} - const router = useRouter() - - const setPage = useCallback( - (page: number) => { - const params = { - ...router.query, - page: page.toString(), - } - - router.push({ - query: params, - }) - }, - [router] - ) - - const updateQuery = useCallback( - (queryParameters: QueryParameters) => { - const params = { - ...router.query, - } - - for (const parameter in queryParameters) { - if ( - config?.defaultQueryParameters && - config.defaultQueryParameters[parameter] === - queryParameters[parameter] - ) { - delete params[parameter] - } else { - params[parameter] = queryParameters[parameter]?.toString() - } - } - - delete params['page'] - delete params['size'] - - router.push({ - query: params, - }) - }, - [router, config?.defaultQueryParameters] - ) - - const clear = useCallback(() => { - const params = { - ...router.query, - } - - delete params['page'] - delete params['size'] - - for (const parameter in config?.defaultQueryParameters) { - delete params[parameter] - } - - router.push({ - query: params, - }) - }, [router, config?.defaultQueryParameters]) - - return { - queryParameters: extractCurrentQueryParameters( - router.query, - config?.defaultQueryParameters - ), - page: convert( - getSingleParameterValue(router.query.page) || null, - indexType === IndexType.ZERO_BASED ? 0 : 1 - ), - size: convert(getSingleParameterValue(router.query.size) || null, pageSize), - actions: { - updateQuery, - clear, - setPage, - }, - } -} diff --git a/src/reactRouterPagination.tsx b/src/reactRouterPagination.tsx deleted file mode 100644 index 7e78ba0..0000000 --- a/src/reactRouterPagination.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { useCallback, useMemo } from 'react' -import { useNavigate, useLocation } from 'react-router-dom' - -import { IndexType, IUseQueryAndPagination, QueryParameters } from './types' -import { convert } from './utils' - -function extractCurrentQueryParameters( - query: URLSearchParams, - defaultQueryParameters?: QueryParameters -) { - if (!defaultQueryParameters) { - return {} - } - - const result: QueryParameters = { ...defaultQueryParameters } - - for (const parameter in defaultQueryParameters) { - const queryParameter = query.get(parameter) - - if (queryParameter !== null) { - result[parameter] = queryParameter - } - } - - return result -} - -export const useQueryAndPagination: IUseQueryAndPagination = function (config) { - const { indexType = IndexType.ZERO_BASED, pageSize = 15 } = config || {} - const navigate = useNavigate() - const { pathname: routerUrl, search: routeQuery } = useLocation() - - const params = useMemo(() => new URLSearchParams(routeQuery), [routeQuery]) - - const updateQuery = useCallback( - (queryParameters: QueryParameters) => { - for (const parameter in queryParameters) { - if ( - config?.defaultQueryParameters && - config.defaultQueryParameters[parameter] === - queryParameters[parameter] - ) { - params.delete(parameter) - } else { - params.set(parameter, queryParameters[parameter] as string) - } - } - - params.delete('page') - params.delete('size') - - navigate({ - pathname: routerUrl, - search: params.toString(), - }) - }, - [navigate, params, config?.defaultQueryParameters, routerUrl] - ) - - const clear = useCallback(() => { - for (const parameter in config?.defaultQueryParameters) { - params.delete(parameter) - } - - params.delete('page') - params.delete('size') - - navigate({ - pathname: routerUrl, - search: params.toString(), - }) - }, [navigate, params, config?.defaultQueryParameters, routerUrl]) - - const setPage = useCallback( - (page: number) => { - params.set('page', page.toString()) - navigate({ - pathname: routerUrl, - search: params.toString(), - }) - }, - [navigate, params, routerUrl] - ) - - return { - queryParameters: extractCurrentQueryParameters( - params, - config?.defaultQueryParameters - ), - page: convert( - params.get('page'), - indexType === IndexType.ZERO_BASED ? 0 : 1 - ), - size: convert(params.get('size'), pageSize), - actions: { - updateQuery, - clear, - setPage, - }, - } -} diff --git a/src/routers/__test__/inMemory.test.ts b/src/routers/__test__/inMemory.test.ts new file mode 100644 index 0000000..a8ea949 --- /dev/null +++ b/src/routers/__test__/inMemory.test.ts @@ -0,0 +1,190 @@ +import { act, renderHook } from '@testing-library/react' +import { z } from 'zod' +import { NonNullableRecord } from '../../utils' +import { useQuery, useQueryAndPagination } from '../../zod/routers/inMemory' +import { usePagination } from '../inMemory' + +describe('InMemory', () => { + const searchSchema = z.object({ + search: z.string().optional().catch(undefined), + }) + + const useInMemoryQueryWithSearch = ( + defaultQuery: NonNullableRecord> + ) => useQuery(defaultQuery, searchSchema) + + test('should set default page and size', () => { + const page = 0 + const size = 15 + const { result } = renderHook(() => usePagination({ page, size })) + + expect(result.current.page).toBe(page) + expect(result.current.size).toBe(size) + }) + + test('should change page', () => { + const page = 2 + + const { result } = renderHook(() => usePagination()) + + act(() => { + result.current.setPage(page) + }) + + expect(result.current.page).toBe(page) + }) + + test('should change search', () => { + const search = 'Max' + + const { result } = renderHook(() => + useInMemoryQueryWithSearch({ search: '' }) + ) + + act(() => { + result.current.setQuery({ search }) + }) + + expect(result.current.query?.search).toBe(search) + }) + + test('changing pagination should not reset the remaining query', () => { + const search = 'Max' + const page = 2 + + const { result } = renderHook(() => + useQueryAndPagination({ search: '' }, searchSchema) + ) + + act(() => { + result.current.setQuery({ search }) + }) + + act(() => { + result.current.setPage(page) + }) + + expect(result.current.query.search).toBe(search) + + act(() => { + result.current.resetPagination() + }) + + expect(result.current.query.search).toBe(search) + expect(result.current.page).toBe(0) + }) + + test('change default parameters', () => { + const page = 1 + const size = 10 + const { result } = renderHook(() => usePagination({ page, size })) + + expect(result.current.page).toBe(page) + expect(result.current.size).toBe(size) + }) + + test('changing the remaining query should reset the page', () => { + const defaultSearch = '' + const search1 = 'Max' + const search2 = 'Peter' + const page = 2 + + const { result } = renderHook(() => + useQueryAndPagination({ search: defaultSearch }, searchSchema) + ) + + act(() => { + result.current.setQuery({ search: search1 }) + }) + + act(() => { + result.current.setPage(page) + }) + + expect(result.current.query.search).toBe(search1) + expect(result.current.page).toBe(page) + + act(() => { + result.current.setQuery({ search: search2 }) + }) + + expect(result.current.query.search).toBe(search2) + expect(result.current.page).toBe(0) + + act(() => { + result.current.resetQuery() + }) + + expect(result.current.query.search).toBe(defaultSearch) + expect(result.current.page).toBe(0) + }) + + test('changing the remaining query with resetPage set to false should not reset the page', () => { + const defaultSearch = '' + const search = 'Max' + const page = 2 + + const { result } = renderHook(() => + useQueryAndPagination({ search: defaultSearch }, searchSchema) + ) + + act(() => { + result.current.setPage(page) + }) + + expect(result.current.page).toBe(page) + + act(() => { + result.current.setQuery({ search }, { resetPage: false }) + }) + + expect(result.current.query.search).toBe(search) + expect(result.current.page).toBe(page) + + act(() => { + result.current.resetQuery({ resetPage: false }) + }) + + expect(result.current.query.search).toBe(defaultSearch) + expect(result.current.page).toBe(page) + }) + + test('setting a query key should not overwrite other query keys', () => { + const search = 'Max' + const department = 'IT' + + const schema = z.object({ + search: z.string().optional().catch(undefined), + department: z.string().optional().catch(undefined), + }) + + const { result } = renderHook(() => + useQuery({ search: '', department: '' }, schema) + ) + + act(() => { + result.current.setQuery({ search }) + }) + + act(() => { + result.current.setQuery({ department }) + }) + + expect(result.current.query.search).toBe(search) + expect(result.current.query.department).toBe(department) + }) + + test('default query value should not need to be an empty string', () => { + const search = '' + + const { result } = renderHook(() => + useQuery({ search: 'Default search' }, searchSchema) + ) + + act(() => { + result.current.setQuery({ search }) + }) + + expect(result.current.query.search).toBe(search) + }) +}) diff --git a/src/routers/__test__/nextRouter.test.ts b/src/routers/__test__/nextRouter.test.ts new file mode 100644 index 0000000..dadc446 --- /dev/null +++ b/src/routers/__test__/nextRouter.test.ts @@ -0,0 +1,269 @@ +import { act, renderHook } from '@testing-library/react' +import router from 'next/router' +import { z } from 'zod' +import { vi } from 'vitest' +import { NonNullableRecord } from '../../utils' +import { useQuery, useQueryAndPagination } from '../../zod/routers/nextRouter' +import { usePagination } from '../nextRouter' + +vi.mock('next/router', () => require('next-router-mock')) + +describe('NextRouter', () => { + beforeEach(() => { + router.query = {} + }) + + const searchSchema = z.object({ + search: z.string().optional().catch(undefined), + }) + + const useNextRouterQueryWithSearch = ( + defaultQuery: NonNullableRecord> + ) => useQuery(defaultQuery, searchSchema) + + test('should set default page and size', () => { + const page = 0 + const size = 15 + const { result } = renderHook(() => usePagination({ page, size })) + + expect(result.current.page).toBe(page) + expect(result.current.size).toBe(size) + }) + + test('should change page', () => { + const page = 2 + + const { result } = renderHook(() => usePagination()) + + act(() => { + result.current.setPage(page) + }) + + expect(result.current.page).toBe(page) + expect(router.query.page).toBe(page.toString()) + }) + + test('should change search', () => { + const search = 'Max' + + const { result } = renderHook(() => + useNextRouterQueryWithSearch({ search: '' }) + ) + + act(() => { + result.current.setQuery({ search }) + }) + + expect(result.current.query?.search).toBe(search) + expect(router.query.search).toBe(search) + }) + + test('changing pagination should not reset the remaining query', () => { + const search = 'Max' + const page = 2 + + const { result } = renderHook(() => + useQueryAndPagination({ search: '' }, searchSchema) + ) + + act(() => { + result.current.setQuery({ search }) + }) + + act(() => { + result.current.setPage(page) + }) + + expect(router.query.search).toBe(search) + expect(router.query.page).toBe(page.toString()) + + act(() => { + result.current.resetPagination() + }) + + expect(result.current.query.search).toBe(search) + expect(router.query.search).toBe(search) + expect(result.current.page).toBe(0) + expect(router.query.page).toBeUndefined() + }) + + test('change default parameters', () => { + const page = 1 + const size = 10 + const { result } = renderHook(() => usePagination({ page, size })) + + expect(result.current.page).toBe(page) + expect(result.current.size).toBe(size) + }) + + test('changing the remaining query should reset the page', () => { + const defaultSearch = '' + const search1 = 'Max' + const search2 = 'Peter' + const page = 2 + + const { result } = renderHook(() => + useQueryAndPagination({ search: defaultSearch }, searchSchema) + ) + + act(() => { + result.current.setQuery({ search: search1 }) + }) + + act(() => { + result.current.setPage(page) + }) + + expect(result.current.query.search).toBe(search1) + expect(router.query.search).toBe(search1) + + expect(result.current.page).toBe(page) + expect(router.query.page).toBe(page.toString()) + + act(() => { + result.current.setQuery({ search: search2 }) + }) + + expect(result.current.query.search).toBe(search2) + expect(router.query.search).toBe(search2) + expect(result.current.page).toBe(0) + expect(router.query.page).toBeUndefined() + + act(() => { + result.current.resetQuery() + }) + + expect(result.current.query.search).toBe(defaultSearch) + expect(router.query.search).toBeUndefined() + expect(result.current.page).toBe(0) + expect(router.query.page).toBeUndefined() + }) + + test('changing the remaining query with resetPage set to false should not reset the page', () => { + const defaultSearch = '' + const search = 'Max' + const page = 2 + + const { result } = renderHook(() => + useQueryAndPagination({ search: defaultSearch }, searchSchema) + ) + + act(() => { + result.current.setPage(page) + }) + + expect(result.current.page).toBe(page) + expect(router.query.page).toBe(page.toString()) + + act(() => { + result.current.setQuery({ search }, { resetPage: false }) + }) + + expect(result.current.query.search).toBe(search) + expect(router.query.search).toBe(search) + expect(result.current.page).toBe(page) + expect(router.query.page).toBe(page.toString()) + + act(() => { + result.current.resetQuery({ resetPage: false }) + }) + + expect(result.current.query.search).toBe(defaultSearch) + expect(router.query.search).toBeUndefined() + expect(result.current.page).toBe(page) + expect(router.query.page).toBe(page.toString()) + }) + + test('setting a query key should not overwrite other query keys', () => { + const search = 'Max' + const department = 'IT' + const defaultAge = 42 + const defaultBirthDate = new Date() + defaultBirthDate.setMilliseconds(0) + const defaultNetWorth = BigInt(0xf8ffffffffffffffffffffffffff2fffn) + const defaultDarkMode: boolean | undefined = true + + const schema = z.object({ + search: z.string().optional().catch(undefined), + department: z.string().optional().catch(undefined), + age: z.string().pipe(z.coerce.number().optional()).catch(undefined), + birthDate: z.string().pipe(z.coerce.date().optional()).catch(undefined), + netWorth: z.string().pipe(z.coerce.bigint().optional()).catch(undefined), + darkMode: z.string().pipe(z.coerce.boolean().optional()).catch(undefined), + }) + + const { result } = renderHook(() => + useQuery( + { + search: '', + department: '', + age: defaultAge, + birthDate: defaultBirthDate, + netWorth: defaultNetWorth, + darkMode: defaultDarkMode, + }, + schema + ) + ) + + act(() => { + result.current.setQuery({ search }) + }) + + act(() => { + result.current.setQuery({ department }) + }) + + expect(result.current.query.search).toBe(search) + expect(result.current.query.department).toBe(department) + expect(result.current.query.age).toBe(defaultAge) + expect(result.current.query.birthDate).toEqual(defaultBirthDate) + expect(result.current.query.netWorth).toEqual(defaultNetWorth) + }) + + test('unspecified query keys should be left untouched', () => { + const greeting = 'hello' + router.query = { greeting } + + const { result } = renderHook(() => useQuery({ search: '' }, searchSchema)) + + act(() => { + result.current.setQuery({ search: 'Max' }) + }) + + expect(result.current.query.search).toBe('Max') + expect(router.query.greeting).toBe(greeting) + }) + + test('query keys with default value should not be stored in the url', () => { + const defaultSearch = '' + + router.query = { search: 'Max' } + + const { result } = renderHook(() => + useQuery({ search: defaultSearch }, searchSchema) + ) + + act(() => { + result.current.resetQuery() + }) + + expect(result.current.query.search).toBe(defaultSearch) + expect(router.query.search).toBeUndefined() + }) + + test('default query value should not need to be an empty string', () => { + const search = '' + + const { result } = renderHook(() => + useQuery({ search: 'Default search' }, searchSchema) + ) + + act(() => { + result.current.setQuery({ search }) + }) + + expect(result.current.query.search).toBe(search) + expect(router.query.search).toBe(search) + }) +}) diff --git a/src/routers/__test__/reactRouter.test.tsx b/src/routers/__test__/reactRouter.test.tsx new file mode 100644 index 0000000..f02a2ea --- /dev/null +++ b/src/routers/__test__/reactRouter.test.tsx @@ -0,0 +1,254 @@ +import { act, renderHook } from '@testing-library/react' +import { z } from 'zod' +import { BrowserRouter } from 'react-router-dom' +import { NonNullableRecord } from '../../utils' +import { useQuery, useQueryAndPagination } from '../../zod/routers/reactRouter' +import { usePagination } from '../reactRouter' + +const renderHookWithContext = ( + render: (initialProps: Props) => Result +) => + renderHook(render, { + wrapper: ({ children }) => { + return {children} + }, + }) + +describe('ReactRouter', () => { + beforeEach(() => { + window.history.pushState({}, '', '/') + }) + + const searchSchema = z.object({ + search: z.string().optional().catch(undefined), + }) + + const useReactRouterQueryWithSearch = ( + defaultQuery: NonNullableRecord> + ) => useQuery(defaultQuery, searchSchema) + + test('should set default page and size', () => { + const page = 0 + const size = 15 + const { result } = renderHookWithContext(() => + usePagination({ page, size }) + ) + + expect(result.current.page).toBe(page) + expect(result.current.size).toBe(size) + }) + + test('should change page', () => { + const page = 2 + + const { result } = renderHookWithContext(() => usePagination()) + + act(() => { + result.current.setPage(page) + }) + + expect(result.current.page).toBe(page) + expect(window.location.search).toBe(`?page=${page}`) + }) + + test('should change search', () => { + const search = 'Max' + + const { result } = renderHookWithContext(() => + useReactRouterQueryWithSearch({ search: '' }) + ) + + act(() => { + result.current.setQuery({ search }) + }) + + expect(result.current.query?.search).toBe(search) + expect(window.location.search).toBe(`?search=${search}`) + }) + + test('changing pagination should not reset the remaining query', () => { + const search = 'Max' + const page = 2 + + const { result } = renderHookWithContext(() => + useQueryAndPagination({ search: '' }, searchSchema) + ) + + act(() => { + result.current.setQuery({ search }) + }) + + act(() => { + result.current.setPage(page) + }) + + expect(window.location.search).toBe(`?search=${search}&page=${page}`) + + act(() => { + result.current.resetPagination() + }) + + expect(result.current.query.search).toBe(search) + expect(result.current.page).toBe(0) + expect(window.location.search).toBe(`?search=${search}`) + }) + + test('change default parameters', () => { + const page = 1 + const size = 10 + const { result } = renderHookWithContext(() => + usePagination({ page, size }) + ) + + expect(result.current.page).toBe(page) + expect(result.current.size).toBe(size) + }) + + test('changing the remaining query should reset the page', () => { + const defaultSearch = '' + const search1 = 'Max' + const search2 = 'Peter' + const page = 2 + + const { result } = renderHookWithContext(() => + useQueryAndPagination({ search: defaultSearch }, searchSchema) + ) + + act(() => { + result.current.setQuery({ search: search1 }) + }) + + act(() => { + result.current.setPage(page) + }) + + expect(result.current.query.search).toBe(search1) + expect(result.current.page).toBe(page) + expect(window.location.search).toBe(`?search=${search1}&page=${page}`) + + act(() => { + result.current.setQuery({ search: search2 }) + }) + + expect(result.current.query.search).toBe(search2) + expect(result.current.page).toBe(0) + expect(window.location.search).toBe(`?search=${search2}`) + + act(() => { + result.current.resetQuery() + }) + + expect(result.current.query.search).toBe(defaultSearch) + expect(result.current.page).toBe(0) + expect(window.location.search).toBe('') + }) + + test('changing the remaining query with resetPage set to false should not reset the page', () => { + const defaultSearch = '' + const search = 'Max' + const page = 2 + + const { result } = renderHookWithContext(() => + useQueryAndPagination({ search: defaultSearch }, searchSchema) + ) + + act(() => { + result.current.setPage(page) + }) + + expect(result.current.page).toBe(page) + expect(window.location.search).toBe(`?page=${page}`) + + act(() => { + result.current.setQuery({ search }, { resetPage: false }) + }) + + expect(result.current.query.search).toBe(search) + expect(result.current.page).toBe(page) + expect(window.location.search).toBe(`?page=${page}&search=${search}`) + + act(() => { + result.current.resetQuery({ resetPage: false }) + }) + + expect(result.current.query.search).toBe(defaultSearch) + expect(result.current.page).toBe(page) + expect(window.location.search).toBe(`?page=${page}`) + }) + + test('setting a query key should not overwrite other query keys', () => { + const search = 'Max' + const department = 'IT' + + const schema = z.object({ + search: z.string().optional().catch(undefined), + department: z.string().optional().catch(undefined), + }) + + const { result } = renderHookWithContext(() => + useQuery({ search: '', department: '' }, schema) + ) + + act(() => { + result.current.setQuery({ search }) + }) + + act(() => { + result.current.setQuery({ department }) + }) + + expect(result.current.query.search).toBe(search) + expect(result.current.query.department).toBe(department) + }) + + test('unspecified query keys should be left untouched', () => { + const greeting = 'hello' + const search = 'Max' + window.history.pushState({}, '', `/?greeting=${greeting}`) + + const { result } = renderHookWithContext(() => + useQuery({ search: '' }, searchSchema) + ) + + act(() => { + result.current.setQuery({ search }) + }) + + expect(result.current.query.search).toBe(search) + expect(window.location.search).toBe( + `?greeting=${greeting}&search=${search}` + ) + }) + + test('query keys with default value should not be stored in the url', () => { + const defaultSearch = '' + + window.history.pushState({}, '', `/?search=Max`) + + const { result } = renderHookWithContext(() => + useQuery({ search: defaultSearch }, searchSchema) + ) + + act(() => { + result.current.resetQuery() + }) + + expect(result.current.query.search).toBe(defaultSearch) + expect(window.location.search).toBe('') + }) + + test('default query value should not need to be an empty string', () => { + const search = '' + + const { result } = renderHookWithContext(() => + useQuery({ search: 'Default search' }, searchSchema) + ) + + act(() => { + result.current.setQuery({ search }) + }) + + expect(result.current.query.search).toBe(search) + expect(window.location.search).toBe(`?search=`) + }) +}) diff --git a/src/routers/inMemory.ts b/src/routers/inMemory.ts new file mode 100644 index 0000000..f18de91 --- /dev/null +++ b/src/routers/inMemory.ts @@ -0,0 +1,64 @@ +import { useState } from 'react' +import { + AbstractQuery, + AbstractQueryOptions, + PaginationQuery, + ParseQuery, + Query, + Router, + useAbstractQuery, + useAbstractQueryAndPagination, +} from '../engine' + +const useInMemoryRouter = (): Router => { + const [inMemoryQuery, setInMemoryQuery] = useState({}) + + return { + getQuery: (defaultQuery) => { + return { ...defaultQuery, ...inMemoryQuery } + }, + setQuery: (query) => { + const sanitizedQuery: Query = {} + for (const [key, value] of Object.entries(query)) { + if (value !== undefined) { + sanitizedQuery[key] = value + } + } + setInMemoryQuery({ ...inMemoryQuery, ...sanitizedQuery }) + }, + } +} + +export const useQuery = ( + defaultQuery: T, + parseQuery: ParseQuery, + options?: Partial +) => { + const router = useInMemoryRouter() + return useAbstractQuery(defaultQuery, parseQuery, router, options) +} + +export const useQueryAndPagination = ( + defaultQuery: T, + parseQuery: ParseQuery, + defaultPagination?: PaginationQuery, + options?: Partial +) => { + const router = useInMemoryRouter() + return useAbstractQueryAndPagination( + defaultQuery, + parseQuery, + router, + defaultPagination, + options + ) +} + +export const usePagination = ( + defaultPagination?: PaginationQuery, + options?: Partial +) => { + const { page, size, setPage, setSize, setPagination, resetPagination } = + useQueryAndPagination({}, () => ({}), defaultPagination, options) + return { page, size, setPage, setSize, setPagination, resetPagination } +} diff --git a/src/routers/nextRouter.ts b/src/routers/nextRouter.ts new file mode 100644 index 0000000..5ce2ff7 --- /dev/null +++ b/src/routers/nextRouter.ts @@ -0,0 +1,89 @@ +import { useRouter } from 'next/router' +import { useMemo } from 'react' +import { + Query, + ParseQuery, + useAbstractQuery, + Router, + AbstractQuery, + AbstractQueryOptions, +} from '../engine/query' +import { + PaginationQuery, + useAbstractQueryAndPagination, +} from '../engine/pagination' +import { RouterWithHistoryOptions } from './shared' + +const DEFAULT_NEXT_ROUTER_OPTIONS: RouterWithHistoryOptions = { + setQueryMethod: 'replace', +} + +const useNextRouter = ( + options: undefined | Partial +): Router => { + const nextRouter = useRouter() + + const mergedOptions = useMemo( + () => ({ ...DEFAULT_NEXT_ROUTER_OPTIONS, ...options }), + [options] + ) + + return { + getQuery: (defaultQuery) => { + const query: Query = {} + for (const [key, value] of Object.entries(nextRouter.query)) { + if (value !== undefined) { + query[key] = value + } + } + return { ...defaultQuery, ...query } + }, + setQuery: (query, defaultQuery) => { + const newQuery = { ...nextRouter.query, ...query } + for (const [key, value] of Object.entries(query)) { + if (value === defaultQuery[key]) { + delete newQuery[key] + } + } + if (mergedOptions.setQueryMethod === 'push') { + nextRouter.push({ query: newQuery }, undefined, { shallow: true }) + } else { + nextRouter.replace({ query: newQuery }, undefined, { shallow: true }) + } + }, + } +} + +export const useQuery = ( + defaultQuery: T, + parseQuery: ParseQuery, + options?: Partial +) => { + const router = useNextRouter(options) + return useAbstractQuery(defaultQuery, parseQuery, router, options) +} + +export const useQueryAndPagination = ( + defaultQuery: T, + parseQuery: ParseQuery, + defaultPagination?: PaginationQuery, + options?: Partial +) => { + const router = useNextRouter(options) + return useAbstractQueryAndPagination( + defaultQuery, + parseQuery, + router, + defaultPagination, + options + ) +} + +export const usePagination = ( + defaultPagination?: PaginationQuery, + options?: Partial +) => { + const { page, size, setPage, setSize, setPagination, resetPagination } = + useQueryAndPagination({}, () => ({}), defaultPagination, options) + return { page, size, setPage, setSize, setPagination, resetPagination } +} diff --git a/src/routers/reactRouter.ts b/src/routers/reactRouter.ts new file mode 100644 index 0000000..fee5529 --- /dev/null +++ b/src/routers/reactRouter.ts @@ -0,0 +1,102 @@ +import { useLocation, useNavigate } from 'react-router-dom' +import { useMemo } from 'react' +import { + Query, + AbstractQuery, + ParseQuery, + AbstractQueryOptions, + useAbstractQuery, + PaginationQuery, + useAbstractQueryAndPagination, + Router, +} from '../engine' +import { QUERY_ARRAY_SEPARATOR, RouterWithHistoryOptions } from './shared' + +const DEFAULT_REACT_ROUTER_OPTIONS: RouterWithHistoryOptions = { + setQueryMethod: 'replace', +} + +const useReactRouter = ( + options: undefined | Partial +): Router => { + const navigate = useNavigate() + const { pathname, search } = useLocation() + + const mergedOptions = useMemo( + () => ({ ...DEFAULT_REACT_ROUTER_OPTIONS, ...options }), + [options] + ) + + return { + getQuery: (defaultQuery) => { + const query: Query = {} + for (const [key, value] of new URLSearchParams(search).entries()) { + const decodedValues = value + .split(QUERY_ARRAY_SEPARATOR) + .map((v) => decodeURIComponent(v)) + query[key] = + decodedValues.length === 1 + ? (decodedValues[0] as string) + : decodedValues + } + return { ...defaultQuery, ...query } + }, + setQuery: (query, defaultQuery) => { + const urlSearchParams = new URLSearchParams(search) + for (const [key, value] of Object.entries(query)) { + if (value === defaultQuery[key]) { + urlSearchParams.delete(key) + } else if (value !== undefined) { + let values: string[] + if (Array.isArray(value)) { + values = value + } else { + values = [value] + } + const encodedValues = values + .map((v) => encodeURIComponent(v)) + .join(QUERY_ARRAY_SEPARATOR) + urlSearchParams.set(key, encodedValues) + } + } + navigate( + { pathname, search: urlSearchParams.toString() }, + { replace: mergedOptions.setQueryMethod === 'replace' } + ) + }, + } +} + +export const useQuery = ( + defaultQuery: T, + parseQuery: ParseQuery, + options?: Partial +) => { + const router = useReactRouter(options) + return useAbstractQuery(defaultQuery, parseQuery, router, options) +} + +export const useQueryAndPagination = ( + defaultQuery: T, + parseQuery: ParseQuery, + defaultPagination?: PaginationQuery, + options?: Partial +) => { + const router = useReactRouter(options) + return useAbstractQueryAndPagination( + defaultQuery, + parseQuery, + router, + defaultPagination, + options + ) +} + +export const usePagination = ( + defaultPagination?: PaginationQuery, + options?: Partial +) => { + const { page, size, setPage, setSize, setPagination, resetPagination } = + useQueryAndPagination({}, () => ({}), defaultPagination, options) + return { page, size, setPage, setSize, setPagination, resetPagination } +} diff --git a/src/routers/shared.ts b/src/routers/shared.ts new file mode 100644 index 0000000..13971dd --- /dev/null +++ b/src/routers/shared.ts @@ -0,0 +1,10 @@ +export const QUERY_ARRAY_SEPARATOR = ',' + +export type RouterWithHistoryOptions = { + /** + * Whether the router should push a new URL to the browser history or replace the current URL. + * + * @default 'replace' + */ + setQueryMethod: 'replace' | 'push' +} diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index 109ec7d..0000000 --- a/src/types.ts +++ /dev/null @@ -1,29 +0,0 @@ -export enum IndexType { - ZERO_BASED = 0, - ONE_BASED = 1, -} - -export type QueryParameters = Record - -export type Config = { - indexType?: IndexType - pageSize?: number - defaultQueryParameters?: QueryParameters -} - -export type Actions = { - updateQuery: (query: QueryParameters) => void - clear: () => void - setPage: (page: number) => void -} - -export type UseQueryAndPagination = { - queryParameters: QueryParameters - page: number - size: number - actions: Actions -} - -export interface IUseQueryAndPagination { - (config?: Config): UseQueryAndPagination -} diff --git a/src/utils.ts b/src/utils.ts index 51f41ad..c49ff18 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,11 +1,20 @@ -export function convert(parameter: string | null, fallback: number): number { - if (parameter !== null) { - const converted = parseInt(parameter) +import { Query } from './engine/query' - if (Number.isInteger(converted) && converted > 0) { - return converted - } - } +export type NonNullableRecord< + T extends Record +> = { + [Key in keyof T]-?: NonNullable +} - return fallback +export const queryValueToIntOrUndefined = ( + value: Query[keyof Query] | undefined +): number | undefined => { + if (value === undefined || Array.isArray(value)) { + return undefined + } + const parsed = parseInt(value) + if (isNaN(parsed)) { + return undefined + } + return parsed } diff --git a/jest-setup.ts b/src/vitest.ts similarity index 100% rename from jest-setup.ts rename to src/vitest.ts diff --git a/src/zod/routers/inMemory.ts b/src/zod/routers/inMemory.ts new file mode 100644 index 0000000..7eef00b --- /dev/null +++ b/src/zod/routers/inMemory.ts @@ -0,0 +1,41 @@ +import { ZodType, ZodTypeDef } from 'zod' +import { + PaginationQuery, + AbstractQuery, + AbstractQueryOptions, +} from '../../engine' +import { + useQuery as useQueryVanilla, + useQueryAndPagination as useQueryAndPaginationVanilla, +} from '../../routers/inMemory' +import { zodParser } from '../util' +import { NonNullableRecord } from '../../utils' + +export const useQuery = < + TQuery extends NonNullableRecord, + TSchemaOutput extends Partial, + TSchemaDef extends ZodTypeDef = ZodTypeDef, + TSchemaInput = TSchemaOutput +>( + defaultQuery: TQuery, + schemaQuery: ZodType, + options?: Partial +) => useQueryVanilla(defaultQuery, zodParser(schemaQuery), options) + +export const useQueryAndPagination = < + TQuery extends NonNullableRecord, + TSchemaOutput extends Partial, + TSchemaDef extends ZodTypeDef = ZodTypeDef, + TSchemaInput = TSchemaOutput +>( + defaultQuery: TQuery, + schemaQuery: ZodType, + defaultPagination?: PaginationQuery, + options?: Partial +) => + useQueryAndPaginationVanilla( + defaultQuery, + zodParser(schemaQuery), + defaultPagination, + options + ) diff --git a/src/zod/routers/nextRouter.ts b/src/zod/routers/nextRouter.ts new file mode 100644 index 0000000..7878d40 --- /dev/null +++ b/src/zod/routers/nextRouter.ts @@ -0,0 +1,42 @@ +import { ZodType, ZodTypeDef } from 'zod' +import { + PaginationQuery, + AbstractQuery, + AbstractQueryOptions, +} from '../../engine' +import { + useQuery as useQueryVanilla, + useQueryAndPagination as useQueryAndPaginationVanilla, +} from '../../routers/nextRouter' +import { zodParser } from '../util' +import { NonNullableRecord } from '../../utils' +import { RouterWithHistoryOptions } from '../../routers/shared' + +export const useQuery = < + TQuery extends NonNullableRecord, + TSchemaOutput extends Partial, + TSchemaDef extends ZodTypeDef = ZodTypeDef, + TSchemaInput = TSchemaOutput +>( + defaultQuery: TQuery, + schemaQuery: ZodType, + options?: Partial +) => useQueryVanilla(defaultQuery, zodParser(schemaQuery), options) + +export const useQueryAndPagination = < + TQuery extends NonNullableRecord, + TSchemaOutput extends Partial, + TSchemaDef extends ZodTypeDef = ZodTypeDef, + TSchemaInput = TSchemaOutput +>( + defaultQuery: TQuery, + schemaQuery: ZodType, + defaultPagination?: PaginationQuery, + options?: Partial +) => + useQueryAndPaginationVanilla( + defaultQuery, + zodParser(schemaQuery), + defaultPagination, + options + ) diff --git a/src/zod/routers/reactRouter.ts b/src/zod/routers/reactRouter.ts new file mode 100644 index 0000000..4ffe8fe --- /dev/null +++ b/src/zod/routers/reactRouter.ts @@ -0,0 +1,42 @@ +import { ZodType, ZodTypeDef } from 'zod' +import { + PaginationQuery, + AbstractQuery, + AbstractQueryOptions, +} from '../../engine' +import { + useQuery as useQueryVanilla, + useQueryAndPagination as useQueryAndPaginationVanilla, +} from '../../routers/reactRouter' +import { zodParser } from '../util' +import { NonNullableRecord } from '../../utils' +import { RouterWithHistoryOptions } from '../../routers/shared' + +export const useQuery = < + TQuery extends NonNullableRecord, + TSchemaOutput extends Partial, + TSchemaDef extends ZodTypeDef = ZodTypeDef, + TSchemaInput = TSchemaOutput +>( + defaultQuery: TQuery, + schemaQuery: ZodType, + options?: Partial +) => useQueryVanilla(defaultQuery, zodParser(schemaQuery), options) + +export const useQueryAndPagination = < + TQuery extends NonNullableRecord, + TSchemaOutput extends Partial, + TSchemaDef extends ZodTypeDef = ZodTypeDef, + TSchemaInput = TSchemaOutput +>( + defaultQuery: TQuery, + schemaQuery: ZodType, + defaultPagination?: PaginationQuery, + options?: Partial +) => + useQueryAndPaginationVanilla( + defaultQuery, + zodParser(schemaQuery), + defaultPagination, + options + ) diff --git a/src/zod/util.ts b/src/zod/util.ts new file mode 100644 index 0000000..625ef83 --- /dev/null +++ b/src/zod/util.ts @@ -0,0 +1,9 @@ +import { ZodType, ZodTypeDef } from 'zod' +import { AbstractQuery, ParseQuery } from '../engine/query' + +export const zodParser = + , Def extends ZodTypeDef, Input>( + schema: ZodType + ): ParseQuery => + (q) => + schema.parse(q) diff --git a/tsconfig.cjs.json b/tsconfig.cjs.json index 07f9e19..678c53c 100644 --- a/tsconfig.cjs.json +++ b/tsconfig.cjs.json @@ -8,6 +8,12 @@ "sourceMap": false, "removeComments": true }, - "exclude": ["node_modules", "**/__test__/*", "**/__tests__/*"], - "include": ["src"] + "exclude": [ + "src/vitest.ts", + "**/__test__/*", + "**/__tests__/*" + ], + "include": [ + "src" + ] } diff --git a/tsconfig.esm.json b/tsconfig.esm.json index d6a606e..999299b 100644 --- a/tsconfig.esm.json +++ b/tsconfig.esm.json @@ -8,6 +8,12 @@ "sourceMap": false, "removeComments": true }, - "exclude": ["node_modules", "**/__test__/*", "**/__tests__/*"], - "include": ["src"] + "exclude": [ + "src/vitest.ts", + "**/__test__/*", + "**/__tests__/*" + ], + "include": [ + "src" + ] } diff --git a/tsconfig.json b/tsconfig.json index 27e4d23..11c6705 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,10 +22,5 @@ }, "include": [ "src" - ], - "exclude": [ - "node_modules", - "**/__test__/*", - "**/__tests__/*" ] } diff --git a/tsconfig.types.json b/tsconfig.types.json index 5185a65..5825707 100644 --- a/tsconfig.types.json +++ b/tsconfig.types.json @@ -10,11 +10,9 @@ "exclude": [ "src/vitest.ts", "**/__test__/*", - "**/__tests__/*", - "**/*.stories.tsx", - "**/*.stories.ts", - "src/examples/*", - ".storybook/*" + "**/__tests__/*" ], - "include": ["src"] + "include": [ + "src" + ] } diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..b662c16 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,11 @@ +/// + +import { defineConfig } from 'vite' + +export default defineConfig({ + test: { + globals: true, + environment: 'jsdom', + setupFiles: './src/vitest.ts', + }, +})