Skip to content

Commit

Permalink
feat: useMultiStateValidator
Browse files Browse the repository at this point in the history
  • Loading branch information
xobotyi committed Oct 16, 2019
1 parent 828e116 commit ae26988
Show file tree
Hide file tree
Showing 6 changed files with 274 additions and 0 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

<br />
<br />
Expand Down
55 changes: 55 additions & 0 deletions docs/useMultiStateValidator.md
Original file line number Diff line number Diff line change
@@ -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<number>(1);
const [state2, setState2] = React.useState<number>(1);
const [state3, setState3] = React.useState<number>(1);
const [[isValid]] = useMultiStateValidator([state1, state2, state3], DemoStateValidator);

return (
<div>
<div>Below fields will be valid if all of them is even</div>
<input type="number" min="1" max="10" value={state1}
onChange={(ev: React.ChangeEvent<HTMLInputElement>) => {
setState1((ev.target.value as unknown) as number);
}}
/>
<input type="number" min="1" max="10" value={state2}
onChange={(ev: React.ChangeEvent<HTMLInputElement>) => {
setState2((ev.target.value as unknown) as number);
}}
/>
<input type="number" min="1" max="10" value={state3}
onChange={(ev: React.ChangeEvent<HTMLInputElement>) => {
setState3((ev.target.value as unknown) as number);
}}
/>
{isValid !== null && <span>{isValid ? 'Valid!' : 'Invalid'}</span>}
</div>
);
};
```

## 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;
51 changes: 51 additions & 0 deletions src/__stories__/useMultiStateValidator.story.tsx
Original file line number Diff line number Diff line change
@@ -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<number>(1);
const [state2, setState2] = React.useState<number>(1);
const [state3, setState3] = React.useState<number>(1);
const [[isValid]] = useMultiStateValidator([state1, state2, state3], DemoStateValidator);

return (
<div>
<div>Below fields will be valid if all of them is even</div>
<br />
<input
type="number"
min="1"
max="10"
value={state1}
onChange={(ev: React.ChangeEvent<HTMLInputElement>) => {
setState1((ev.target.value as unknown) as number);
}}
/>
<input
type="number"
min="1"
max="10"
value={state2}
onChange={(ev: React.ChangeEvent<HTMLInputElement>) => {
setState2((ev.target.value as unknown) as number);
}}
/>
<input
type="number"
min="1"
max="10"
value={state3}
onChange={(ev: React.ChangeEvent<HTMLInputElement>) => {
setState3((ev.target.value as unknown) as number);
}}
/>
{isValid !== null && <span style={{ marginLeft: 24 }}>{isValid ? 'Valid!' : 'Invalid'}</span>}
</div>
);
};

storiesOf('State|useMultiStateValidator', module)
.add('Docs', () => <ShowDocs md={require('../../docs/useMultiStateValidator.md')} />)
.add('Demo', () => <Demo />);
125 changes: 125 additions & 0 deletions src/__tests__/useMultiStateValidator.ts
Original file line number Diff line number Diff line change
@@ -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<any, number[]> = jest.fn(defaultStatesValidator),
initialStates = [1, 2],
initialValidity = [false]
): [MultiStateValidator<any, number[]>, RenderHookResult<any, [Function, UseValidatorReturn<ValidityState>]>] {
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<any, number[]> = 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]);
});
});
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
41 changes: 41 additions & 0 deletions src/useMultiStateValidator.ts
Original file line number Diff line number Diff line change
@@ -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<V>): void;
}

export function useMultiStateValidator<
V extends ValidityState = ValidityState,
S extends MultiStateValidatorStates = MultiStateValidatorStates
>(states: S, validator: MultiStateValidator<V, S>, initialValidity: V = [undefined] as V): UseValidatorReturn<V> {
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];
}

0 comments on commit ae26988

Please sign in to comment.