From ae2698891be952f45d2ea9af0027497d1030f0ae Mon Sep 17 00:00:00 2001 From: xobotyi Date: Wed, 16 Oct 2019 12:14:41 +0300 Subject: [PATCH] feat: useMultiStateValidator --- README.md | 1 + docs/useMultiStateValidator.md | 55 ++++++++ .../useMultiStateValidator.story.tsx | 51 +++++++ src/__tests__/useMultiStateValidator.ts | 125 ++++++++++++++++++ src/index.ts | 1 + src/useMultiStateValidator.ts | 41 ++++++ 6 files changed, 274 insertions(+) create mode 100644 docs/useMultiStateValidator.md create mode 100644 src/__stories__/useMultiStateValidator.story.tsx create mode 100644 src/__tests__/useMultiStateValidator.ts create mode 100644 src/useMultiStateValidator.ts diff --git a/README.md b/README.md index ebe6c734db..33f88b1bcd 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,7 @@ - [`useList`](./docs/useList.md) and [`useUpsert`](./docs/useUpsert.md) — tracks state of an array. [![][img-demo]](https://codesandbox.io/s/wonderful-mahavira-1sm0w) - [`useMap`](./docs/useMap.md) — tracks state of an object. [![][img-demo]](https://codesandbox.io/s/quirky-dewdney-gi161) - [`useStateValidator`](./docs/useStateValidator.md) — tracks state of an object. [![][img-demo]](https://streamich.github.io/react-use/?path=/story/state-usestatevalidator--demo) + - [`useMultiStateValidator`](./docs/useMultiStateValidator.md) — tracks state of an object. [![][img-demo]](https://streamich.github.io/react-use/?path=/story/state-usemultistatevalidator--demo)

diff --git a/docs/useMultiStateValidator.md b/docs/useMultiStateValidator.md new file mode 100644 index 0000000000..42de5b1e88 --- /dev/null +++ b/docs/useMultiStateValidator.md @@ -0,0 +1,55 @@ +# `useMultiStateValidator` + +Each time any of given states changes - validator function is invoked. + +## Usage +```ts +import * as React from 'react'; +import { useMultiStateValidator } from 'react-use'; + +const DemoStateValidator = (s: number[]) => [s.every((num: number) => !(num % 2))] as [boolean]; +const Demo = () => { + const [state1, setState1] = React.useState(1); + const [state2, setState2] = React.useState(1); + const [state3, setState3] = React.useState(1); + const [[isValid]] = useMultiStateValidator([state1, state2, state3], DemoStateValidator); + + return ( +
+
Below fields will be valid if all of them is even
+ ) => { + setState1((ev.target.value as unknown) as number); + }} + /> + ) => { + setState2((ev.target.value as unknown) as number); + }} + /> + ) => { + setState3((ev.target.value as unknown) as number); + }} + /> + {isValid !== null && {isValid ? 'Valid!' : 'Invalid'}} +
+ ); +}; +``` + +## Reference +```ts +const [validity, revalidate] = useStateValidator( + state: any[] | { [p: string]: any } | { [p: number]: any }, + validator: (state, setValidity?)=>[boolean|null, ...any[]], + initialValidity: any = [undefined] +); +``` +- **`state`**_`: any[] | { [p: string]: any } | { [p: number]: any }`_ can be both an array or object. It's _values_ will be used as a deps for inner `useEffect`. +- **`validity`**_`: [boolean|null, ...any[]]`_ result of validity check. First element is strictly nullable boolean, but others can contain arbitrary data; +- **`revalidate`**_`: ()=>void`_ runs validator once again +- **`validator`**_`: (state, setValidity?)=>[boolean|null, ...any[]]`_ should return an array suitable for validity state described above; + - `states` - current states values as the've been passed to the hook; + - `setValidity` - if defined hook will not trigger validity change automatically. Useful for async validators; +- `initialValidity` - validity value which set when validity is nt calculated yet; diff --git a/src/__stories__/useMultiStateValidator.story.tsx b/src/__stories__/useMultiStateValidator.story.tsx new file mode 100644 index 0000000000..91eb619401 --- /dev/null +++ b/src/__stories__/useMultiStateValidator.story.tsx @@ -0,0 +1,51 @@ +import { storiesOf } from '@storybook/react'; +import * as React from 'react'; +import { useMultiStateValidator } from '../'; +import ShowDocs from './util/ShowDocs'; + +const DemoStateValidator = (s: number[]) => [s.every((num: number) => !(num % 2))] as [boolean]; +const Demo = () => { + const [state1, setState1] = React.useState(1); + const [state2, setState2] = React.useState(1); + const [state3, setState3] = React.useState(1); + const [[isValid]] = useMultiStateValidator([state1, state2, state3], DemoStateValidator); + + return ( +
+
Below fields will be valid if all of them is even
+
+ ) => { + setState1((ev.target.value as unknown) as number); + }} + /> + ) => { + setState2((ev.target.value as unknown) as number); + }} + /> + ) => { + setState3((ev.target.value as unknown) as number); + }} + /> + {isValid !== null && {isValid ? 'Valid!' : 'Invalid'}} +
+ ); +}; + +storiesOf('State|useMultiStateValidator', module) + .add('Docs', () => ) + .add('Demo', () => ); diff --git a/src/__tests__/useMultiStateValidator.ts b/src/__tests__/useMultiStateValidator.ts new file mode 100644 index 0000000000..392af53d1d --- /dev/null +++ b/src/__tests__/useMultiStateValidator.ts @@ -0,0 +1,125 @@ +import { act, renderHook, RenderHookResult } from '@testing-library/react-hooks'; +import { useState } from 'react'; +import { MultiStateValidator, useMultiStateValidator } from '../useMultiStateValidator'; +import { UseValidatorReturn, ValidityState } from '../useStateValidator'; + +interface Mock extends jest.Mock {} + +describe('useMultiStateValidator', () => { + it('should be defined', () => { + expect(useMultiStateValidator).toBeDefined(); + }); + + const defaultStatesValidator = (states: number[]) => [states.every(num => !(num % 2))]; + + function getHook( + fn: MultiStateValidator = jest.fn(defaultStatesValidator), + initialStates = [1, 2], + initialValidity = [false] + ): [MultiStateValidator, RenderHookResult]>] { + return [ + fn, + renderHook( + ({ initStates, validator, initValidity }) => { + const [states, setStates] = useState(initStates); + + return [setStates, useMultiStateValidator(states, validator, initValidity)]; + }, + { + initialProps: { + initStates: initialStates, + initValidity: initialValidity, + validator: fn, + }, + } + ), + ]; + } + + it('should return an array of two elements', () => { + const [, hook] = getHook(); + const res = hook.result.current[1]; + + expect(Array.isArray(res)).toBe(true); + expect(res.length).toBe(2); + }); + + it('should call validator on init', () => { + const [spy] = getHook(); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('should call validator on any of states changed', () => { + const [spy, hook] = getHook(); + + expect(spy).toHaveBeenCalledTimes(1); + act(() => hook.result.current[0]([4, 2])); + expect(spy).toHaveBeenCalledTimes(2); + }); + + it("should NOT call validator on it's change", () => { + const [spy, hook] = getHook(); + const newValidator: MultiStateValidator = jest.fn(states => [states!.every(num => !!(num % 2))]); + + expect(spy).toHaveBeenCalledTimes(1); + hook.rerender({ validator: newValidator }); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('should throw if states is not an object', () => { + try { + // @ts-ignore + getHook(defaultStatesValidator, 123); + } catch (err) { + expect(err).toBeDefined(); + expect(err instanceof Error).toBe(true); + expect(err.message).toBe('states expected to be an object or array, got number'); + } + }); + + it('first returned element should represent current validity state', () => { + const [, hook] = getHook(); + let [setState, [validity]] = hook.result.current; + expect(validity).toEqual([false]); + + act(() => setState([4, 2])); + [setState, [validity]] = hook.result.current; + expect(validity).toEqual([true]); + + act(() => setState([4, 5])); + [setState, [validity]] = hook.result.current; + expect(validity).toEqual([false]); + }); + + it('second returned element should re-call validation', () => { + const [spy, hook] = getHook(); + const [, [, revalidate]] = hook.result.current; + + expect(spy).toHaveBeenCalledTimes(1); + act(() => revalidate()); + expect(spy).toHaveBeenCalledTimes(2); + }); + + it('validator should receive states as a firs argument', () => { + const [spy, hook] = getHook(); + const [setState] = hook.result.current; + + expect((spy as Mock).mock.calls[0].length).toBe(1); + expect((spy as Mock).mock.calls[0][0]).toEqual([1, 2]); + act(() => setState([4, 6])); + expect((spy as Mock).mock.calls[1][0]).toEqual([4, 6]); + }); + + it('if validator expects 2nd parameters it should pass a validity setter there', () => { + const spy = (jest.fn((states: number[], done) => { + done([states.every(num => !!(num % 2))]); + }) as unknown) as MultiStateValidator; + const [, hook] = getHook(spy, [1, 3]); + const [, [validity]] = hook.result.current; + + expect((spy as Mock).mock.calls[0].length).toBe(2); + expect((spy as Mock).mock.calls[0][0]).toEqual([1, 3]); + expect(validity).toEqual([true]); + }); +}); diff --git a/src/index.ts b/src/index.ts index 65872134d6..69a7deefc8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -88,6 +88,7 @@ export { default as useUpdateEffect } from './useUpdateEffect'; export { default as useUpsert } from './useUpsert'; export { default as useVideo } from './useVideo'; export { default as useStateValidator } from './useStateValidator'; +export { useMultiStateValidator } from './useMultiStateValidator'; export { useWait, Waiter } from './useWait'; export { default as useWindowScroll } from './useWindowScroll'; export { default as useWindowSize } from './useWindowSize'; diff --git a/src/useMultiStateValidator.ts b/src/useMultiStateValidator.ts new file mode 100644 index 0000000000..19ed9f2108 --- /dev/null +++ b/src/useMultiStateValidator.ts @@ -0,0 +1,41 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { DispatchValidity, UseValidatorReturn, ValidityState } from './useStateValidator'; + +export type MultiStateValidatorStates = any[] | { [p: string]: any } | { [p: number]: any }; + +export interface MultiStateValidator< + V extends ValidityState = ValidityState, + S extends MultiStateValidatorStates = MultiStateValidatorStates +> { + (states: S): V; + + (states: S, done: DispatchValidity): void; +} + +export function useMultiStateValidator< + V extends ValidityState = ValidityState, + S extends MultiStateValidatorStates = MultiStateValidatorStates +>(states: S, validator: MultiStateValidator, initialValidity: V = [undefined] as V): UseValidatorReturn { + if (typeof states !== 'object') { + throw Error('states expected to be an object or array, got ' + typeof states); + } + + const validatorFn = useRef(validator); + + const [validity, setValidity] = useState(initialValidity); + + const deps = Array.isArray(states) ? states : Object.values(states); + const validate = useCallback(() => { + if (validatorFn.current.length === 2) { + validatorFn.current(states, setValidity); + } else { + setValidity(validatorFn.current(states)); + } + }, deps); + + useEffect(() => { + validate(); + }, deps); + + return [validity, validate]; +}