Skip to content

Commit

Permalink
add global event handlers (#19)
Browse files Browse the repository at this point in the history
* add global event handlers

* update types

* add tests

* rename types

* Update README.md

* update README.md

* update types

* add types for input.textarea
  • Loading branch information
wsmd authored Feb 24, 2019
1 parent e0be39e commit f30d58d
Show file tree
Hide file tree
Showing 5 changed files with 195 additions and 16 deletions.
113 changes: 107 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,31 @@
</a>
</p>

<details>
<summary>📖 Table of Contents</summary>
<p>

- [Motivation](#motivation)
- [Getting Started](#getting-started)
- [Examples](#examples)
- [Basic Usage](#basic-usage)
- [Initial State](#initial-state)
- [Global Handlers](#global-handlers)
- [Without Using a `<form />` Element](#without-using-a-form--element)
- [API](#api)
- [`initialState`](#initialstate)
- [`formOptions`](#formoptions)
- [`formOptions.onBlur`](#formoptionsonblur)
- [`formOptions.onChange`](#formoptionsonchange)
- [`formOptions.onTouched`](#formoptionsontouched)
- [`[formState, input]`](#formstate-input)
- [Form State](#form-state)
- [Input Types](#input-types)
- [License](#license)

</p>
</details>

## Motivation

Managing form state in React can be a bit unwieldy sometimes. There are [plenty of great solutions](https://www.npmjs.com/search?q=react%20forms&ranking=popularity) already available that make managing forms state a breeze. However, many of those solutions are opinionated, packed with tons of features that may end up not being used, and/or requires shipping a few extra bytes!
Expand Down Expand Up @@ -108,6 +133,27 @@ export default function RentCarForm() {
}
```

### Global Handlers

`useFormState` supports [a variety of form-level event handlers](#formoptions) that you could use to perform certain actions:

```jsx
export default function RentCarForm() {
const [formState, { email, password }] = useFormState(null, {
onChange(e, stateValues, nextStateValues) {
const { name, value } = e.target;
console.log(`the ${name} input has changed!`);
},
});
return (
<>
<input {...text('username')} />
<input {...password('password')} />
</>
);
}
```

### Without Using a `<form />` Element

`react-use-form-state` is not limited to actual forms. It can be used anywhere inputs are used.
Expand All @@ -131,17 +177,72 @@ function LoginForm({ onSubmit }) {
```js
import { useFormState } from 'react-use-form-state';

function SignupForm({ onSubmit }) {
const [formState, input] = useFormState(initialState);
function FormComponent()
const [formState, input] = useFormState(initialState, formOptions);
// ...
}
```

On initial render, `useFormState` takes an optional initial state object with keys as the name property of the form inputs, and values as the initial values of those inputs (similar to `defaultValue`/`defaultChecked`).
### `initialState`

`useFormState` takes an optional initial state object with keys as the name property of the form inputs, and values as the initial values of those inputs (similar to `defaultValue`/`defaultChecked`).

### `formOptions`

`useFormState` also accepts an optional form options object as a second argument with following properties:

#### `formOptions.onBlur`

A function that gets called upon any `blur` of the form's inputs. This functions provides access to the input's `blur` [`SyntheticEvent`](https://reactjs.org/docs/events.html)

```js
const [formState, inputs] = useFormState(null, {
onBlur(e) {
// accessing the inputs target that triggered the blur event
const { name, value, ...target } = e.target;
}
});
```

#### `formOptions.onChange`

A function that gets triggered upon any `change` of the form's inputs, and before updating `formState`.

This function gives you access to the input's `change` [`SyntheticEvent`](https://reactjs.org/docs/events.html), the current `formState`, the next state after the change is applied.

```js
const [formState, inputs] = useFormState(null, {
onChange(e, stateValues, nextStateValues) {
// accessing the actual inputs target that triggered the change event
const { name, value, ...target } = e.target;
// the state values prior to applying the change
formState.values === stateValues; // true
// the state values after applying the change
nextStateValues;
// the state value of the input. See Input Types below for more information.
nextStateValues[name];
}
});
```

#### `formOptions.onTouched`

A function that gets called after an input inside the form has lost focus, and marked as touched. It will be called once throughout the component life cycle. This functions provides access to the input's `blur` [`SyntheticEvent`](https://reactjs.org/docs/events.html).

```js
const [formState, inputs] = useFormState(null, {
onTouched(e) {
// accessing the inputs target that triggered the blur event
const { name, value, ...target } = e.target;
}
});
```

### `[formState, input]`

It returns an array of two items, the first is the [form state](#form-state), and the second an [input types](#input-types) object.
The return value of `useFormState`. An array of two items, the first is the [form state](#form-state), and the second an [input types](#input-types) object.

### Form State
#### Form State

The first item returned by `useFormState`.

Expand Down Expand Up @@ -171,7 +272,7 @@ formState = {
}
```

### Input Types
#### Input Types

The second item returned by `useFormState`.

Expand Down
20 changes: 18 additions & 2 deletions src/index.d.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,38 @@
// Type definitions for react-use-form-state 0.3
// Type definitions for react-use-form-state 0.6.0
// Project: https://github.com/wsmd/react-use-form-state
// Definitions by: Waseem Dahman <https://github.com/wsmd>

export function useFormState<
T extends { [key: string]: string | string[] | number }
>(initialState?: T): [FormState<T>, Inputs];
>(
initialState?: T | null,
options?: Partial<FormOptions<T>>,
): [FormState<T>, Inputs];

interface FormState<T> {
values: InputValues<T>;
validity: InputValuesValidity<T>;
touched: InputValuesValidity<T>;
}

interface FormOptions<T> {
onChange(
e: React.ChangeEvent<InputElement>,
stateValues: InputValues<T>,
nextStateValues: InputValues<T>,
): void;
onBlur(e: React.FocusEvent<InputElement>): void;
onTouched(e: React.FocusEvent<InputElement>): void;
}

interface Inputs {
selectMultiple(name: string): Omit<InputProps, 'type'> & MultipleProp;
select(name: string): Omit<InputProps, 'type'>;
email(name: string): InputProps;
color(name: string): InputProps;
password(name: string): InputProps;
text(name: string): InputProps;
textarea(name: string): Omit<InputProps, 'type'>;
url(name: string): InputProps;
search(name: string): InputProps;
number(name: string): InputProps;
Expand All @@ -45,6 +59,8 @@ type Maybe<T> = T | void;

type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;

type InputElement = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;

type InputValues<T> = { readonly [A in keyof T]: T[A] } & {
readonly [key: string]: Maybe<string | string[]>;
};
Expand Down
27 changes: 23 additions & 4 deletions src/useFormState.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,17 @@ import {
SELECT_MULTIPLE,
} from './constants';

export default function useFormState(initialState) {
function noop() {}

const defaultFromOptions = {
onChange: noop,
onBlur: noop,
onTouched: noop,
};

export default function useFormState(initialState, options) {
const formOptions = { ...defaultFromOptions, ...options };

const [state, setState] = useReducer(stateReducer, initialState || {});
const [touched, setTouchedState] = useReducer(stateReducer, {});
const [validity, setValidityState] = useReducer(stateReducer, {});
Expand Down Expand Up @@ -51,8 +61,7 @@ export default function useFormState(initialState) {
}

function getNextSelectMultipleValue(e) {
const { options } = e.target;
return Array.from(options).reduce(
return Array.from(e.target.options).reduce(
(values, option) =>
option.selected ? [...values, option.value] : values,
[],
Expand Down Expand Up @@ -108,9 +117,19 @@ export default function useFormState(initialState) {
if (isSelectMultiple) {
value = getNextSelectMultipleValue(e);
}
setState({ [name]: value });

const partialNewState = { [name]: value };
const newState = { ...state, ...partialNewState };

formOptions.onChange(e, state, newState);

setState(partialNewState);
},
onBlur(e) {
if (!touched[name]) {
formOptions.onTouched(e);
}
formOptions.onBlur(e);
setTouchedState({ [name]: true });
setValidityState({ [name]: e.target.validity.valid });
},
Expand Down
14 changes: 10 additions & 4 deletions test/test-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import { useFormState } from '../src';

const noop = () => {};

const InputForm = ({ onChange, name, value, type }) => {
const [formState, input] = useFormState();
const InputForm = ({ onChange, name, value, type, formOptions = {} }) => {
const [formState, input] = useFormState(null, formOptions);
onChange(formState);
return <input {...input[type](name, value)} required />;
};
Expand All @@ -32,10 +32,16 @@ SelectForm.defaultProps = {
onChange: noop,
};

export function renderInput(type, name, value) {
export function renderInput(type, name, value, formOptions) {
const onChangeMock = jest.fn();
const { container } = render(
<InputForm type={type} name={name} value={value} onChange={onChangeMock} />,
<InputForm
type={type}
name={name}
value={value}
onChange={onChangeMock}
formOptions={formOptions}
/>,
);
const input = container.firstChild;
return {
Expand Down
37 changes: 37 additions & 0 deletions test/useFormState.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,43 @@ describe('useFormState API', () => {
});
});

describe('useFormState options', () => {
it('calls options.onChange when an input changes', () => {
const changeHandler = jest.fn();
const { change } = renderInput('text', 'username', undefined, {
onChange: changeHandler,
});
change({ value: 'w' });
expect(changeHandler).toHaveBeenCalledWith(
expect.any(Object), // SyntheticEvent
expect.objectContaining({ username: '' }),
expect.objectContaining({ username: 'w' }),
);
});

it('calls options.onBlur when an input changes', () => {
const blurHandler = jest.fn();
const { blur } = renderInput('text', 'username', undefined, {
onBlur: blurHandler,
});
blur();
expect(blurHandler).toHaveBeenCalledWith(expect.any(Object));
blur();
expect(blurHandler).toHaveBeenCalledTimes(2);
});

it('calls options.onTouched when an input changes', () => {
const touchedHandler = jest.fn();
const { blur } = renderInput('text', 'username', undefined, {
onTouched: touchedHandler,
});
blur();
expect(touchedHandler).toHaveBeenCalled();
blur();
expect(touchedHandler).toHaveBeenCalledTimes(1);
});
});

describe('input type methods return correct props object', () => {
mockReactUseReducer();

Expand Down

0 comments on commit f30d58d

Please sign in to comment.