28 |
29 | - [Motivation](#motivation)
30 | - [Getting Started](#getting-started)
31 | - [Examples](#examples)
32 | - [Basic Usage](#basic-usage)
33 | - [Initial State](#initial-state)
34 | - [Global Handlers](#global-handlers)
35 | - [Advanced Input Options](#advanced-input-options)
36 | - [Custom Input Validation](#custom-input-validation)
37 | - [Check If the Form State Is Pristine](#check-if-the-form-state-is-pristine)
38 | - [Without Using a `
` Element](#without-using-a-form--element)
39 | - [Labels](#labels)
40 | - [Custom Controls](#custom-controls)
41 | - [Updating Fields Manually](#updating-fields-manually)
42 | - [Resetting The Form State](#resetting-the-form-state)
43 | - [Working with TypeScript](#working-with-typescript)
44 | - [API](#api)
45 | - [`initialState`](#initialstate)
46 | - [`formOptions`](#formoptions)
47 | - [`formOptions.onBlur`](#formoptionsonblur)
48 | - [`formOptions.onChange`](#formoptionsonchange)
49 | - [`formOptions.onTouched`](#formoptionsontouched)
50 | - [`formOptions.onClear`](#formoptionsonclear)
51 | - [`formOptions.onReset`](#formoptionsonreset)
52 | - [`formOptions.validateOnBlur`](#formoptionsvalidateonblur)
53 | - [`formOptions.withIds`](#formoptionswithids)
54 | - [`[formState, inputs]`](#formstate-inputs)
55 | - [Form State](#form-state)
56 | - [Input Types](#input-types)
57 | - [Input Options](#input-options)
58 | - [License](#license)
59 |
60 |
61 |
62 |
63 | ## Motivation
64 |
65 | 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 require shipping a few extra bytes!
66 |
67 | Luckily, the recent introduction of [React Hooks](https://reactjs.org/docs/hooks-intro.html) and the ability to write custom hooks have enabled new possibilities when it comes sharing state logic. Form state is no exception!
68 |
69 | `react-use-form-state` is a small React Hook that attempts to [simplify managing form state](#examples), using the native form input elements you are familiar with!
70 |
71 | ## Getting Started
72 |
73 | To get it started, add `react-use-form-state` to your project:
74 |
75 | ```
76 | npm install --save react-use-form-state
77 | ```
78 |
79 | Please note that `react-use-form-state` requires `react@^16.8.0` as a peer dependency.
80 |
81 | ## Examples
82 |
83 | ### Basic Usage
84 |
85 | ```jsx
86 | import { useFormState } from 'react-use-form-state';
87 |
88 | export default function SignUpForm({ onSubmit }) {
89 | const [formState, { text, email, password, radio }] = useFormState();
90 |
91 | function handleSubmit(e) {
92 | // ...
93 | }
94 |
95 | return (
96 |
103 | );
104 | }
105 | ```
106 |
107 | From the example above, as the user fills in the form, the `formState` object will look something like this:
108 |
109 | ```js
110 | {
111 | values: {
112 | name: 'Mary Poppins',
113 | email: 'mary@example.com',
114 | password: '1234',
115 | plan: 'free',
116 | },
117 | touched: {
118 | name: true,
119 | email: true,
120 | password: true,
121 | plan: true,
122 | },
123 | validity: {
124 | name: true,
125 | email: true,
126 | password: false,
127 | plan: true,
128 | },
129 | errors: {
130 | password: 'Please lengthen this text to 8 characters or more',
131 | },
132 | clear: Function,
133 | clearField: Function,
134 | reset: Function,
135 | resetField: Function,
136 | setField: Function,
137 | }
138 | ```
139 |
140 | ### Initial State
141 |
142 | `useFormState` takes an initial state object with keys matching the names of the inputs.
143 |
144 | ```jsx
145 | export default function RentCarForm() {
146 | const [formState, { checkbox, radio, select }] = useFormState({
147 | trip: 'roundtrip',
148 | type: ['sedan', 'suv', 'van'],
149 | });
150 | return (
151 |
161 | );
162 | }
163 | ```
164 |
165 | ### Global Handlers
166 |
167 | `useFormState` supports [a variety of form-level event handlers](#formoptions) that you could use to perform certain actions:
168 |
169 | ```jsx
170 | export default function RentCarForm() {
171 | const [formState, { email, password }] = useFormState(null, {
172 | onChange(e, stateValues, nextStateValues) {
173 | const { name, value } = e.target;
174 | console.log(`the ${name} input has changed!`);
175 | },
176 | });
177 | return (
178 | <>
179 |
180 |
181 | >
182 | );
183 | }
184 | ```
185 |
186 | ### Advanced Input Options
187 |
188 | `useFormState` provides a quick and simple API to get started with building a form and managing its state. It also supports [HTML5 form validation](https://developer.mozilla.org/en-US/docs/Learn/HTML/Forms/Form_validation) out of the box.
189 |
190 | ```jsx
191 |
192 | ```
193 |
194 | While this covers that majority of validation cases, there are times when you need to attach custom event handlers or perform custom validation.
195 |
196 | For this, all [input functions](#input-types) provide an alternate API that allows you attach input-level event handlers such as `onChange` and `onBlur`, as well as providing custom validation logic.
197 |
198 | ```jsx
199 | export default function SignUpForm() {
200 | const [state, { text, password }] = useFormState();
201 | return (
202 | <>
203 |
204 | console.log('password input changed!'),
208 | onBlur: e => console.log('password input lost focus!'),
209 | validate: (value, values, e) => validatePassword(value),
210 | validateOnBlur: true,
211 | })}
212 | />
213 | >
214 | );
215 | }
216 | ```
217 |
218 | ### Custom Input Validation
219 |
220 | The example above [demonstrates](#advanced-input-options) how you can determine the validity of an input by passing a `validate()` method. You can also specify custom validation errors using the same method.
221 |
222 | The input is considered **valid** if this method returns `true` or `undefined`.
223 |
224 | Any [truthy value](https://developer.mozilla.org/en-US/docs/Glossary/Truthy) other than `true` returned from this method will make the input **invalid**. This returned value is used as a **custom validation error** that can be retrieved from [`state.errors`](#form-state).
225 |
226 | For convenience, empty collection values such as empty objects, empty arrays, empty maps, empty sets are not considered invalidation errors, and if returned the input will be valid.
227 |
228 | ```jsx
229 | {
235 | if (!value.trim()) {
236 | return 'Password is required';
237 | }
238 | if (!STRONG_PASSWORD_REGEX.test(value)) {
239 | return 'Password is not strong enough';
240 | }
241 | },
242 | })}
243 | />
244 | ```
245 |
246 | If the input's value is invalid based on the rules specified above, the form state will look similar to this:
247 |
248 | ```js
249 | {
250 | validity: {
251 | password: false,
252 | },
253 | errors: {
254 | password: 'Password is not strong enough',
255 | }
256 | }
257 | ```
258 |
259 | If the `validate()` method is not specified, `useFormState` will fallback to the HTML5 constraints validation to determine the validity of the input along with the appropriate error message.
260 |
261 | ### Check If the Form State Is Pristine
262 |
263 | `useFormState` exposes a `pristine` object, and an `isPristine()` helper via `formState` that you can use to check if the user has made any changes.
264 |
265 | This can be used for a Submit button, to disable it, if there are no actual changes to the form state:
266 |
267 | ```js
268 | function PristineForm() {
269 | const [formState, { text, password }] = useFormState();
270 | return (
271 |
272 |
273 |
274 |
277 |
278 | );
279 | }
280 | ```
281 |
282 | Checking if a field is pristine is done with simple equality `===`, with some exceptions. This can be overridden per field by providing a custom `compare` function.
283 |
284 | Note that a `compare` function is **required** for [`raw`](#custom-controls) inputs, otherwise, if not specified, the `pristine` value of a `raw` input will always be set to `false` after a change.
285 |
286 | ```jsx
287 |
296 | ```
297 |
298 | ### Without Using a `` Element
299 |
300 | `useFormState` is not limited to actual forms. It can be used anywhere inputs are used.
301 |
302 | ```jsx
303 | function LoginForm({ onSubmit }) {
304 | const [formState, { email, password }] = useFormState();
305 | return (
306 |
307 |
308 |
309 |
310 |
311 | );
312 | }
313 | ```
314 |
315 | ### Labels
316 |
317 | As a convenience, `useFormState` provides an optional API that helps with pairing a label to a specific input.
318 |
319 | When [`formOptions.withIds`](#formoptionswithids) is enabled, a label can be paired to an [input](#input-types) by using `input.label()`. This will populate the label's `htmlFor` attribute for an input with the same parameters.
320 |
321 | ```js
322 | const [formState, { label, text, radio }] = useFormState(initialState, {
323 | withIds: true, // enable automatic creation of id and htmlFor props
324 | });
325 |
326 | return (
327 |
337 | );
338 | ```
339 |
340 | Note that this will override any existing `id` prop if specified before calling the input functions. If you want the `id` to take precedence, it must be passed _after_ calling the input types like this:
341 |
342 | ```jsx
343 |
344 | ```
345 |
346 | ### Custom Controls
347 |
348 | `useFormState` provides a `raw` type for working with controls that do not use React's [`SyntheticEvent`](https://reactjs.org/docs/events.html) system. For example, controls like [react-select](https://react-select.com/home) or [react-datepicker](https://www.npmjs.com/package/react-datepicker) have `onChange` and `value` props that expect a custom value instead of an event.
349 |
350 | To use this, your custom component should support an `onChange()` event which takes the value as a parameter, and a `value` prop which is expected to contain the value. Note that if no initial value is given, the component will receive a `value` prop of an empty string, which might not be what you want. Therefore, you must provide an [initial value](#initial-state) for `raw()` inputs when working with custom controls.
351 |
352 | ```js
353 | import DatePicker from 'react-datepicker';
354 |
355 | function Widget() {
356 | const [formState, { raw }] = useFormState({ date: new Date() });
357 | return (
358 | <>
359 |
360 | >
361 | );
362 | }
363 | ```
364 |
365 | You can also provide an `onChange` option with a return value in order to map the value passed from the custom control's `onChange` to a different value in the form state.
366 |
367 | ```js
368 | function Widget() {
369 | const [formState, { raw }] = useFormState({ date: new Date() });
370 | return (
371 | <>
372 | date.toString();
376 | })}
377 | value={new Date(formState.date)}
378 | />
379 | >
380 | );
381 | }
382 | ```
383 |
384 | Note that `onChange()` for a `raw` value _must_ return a value.
385 |
386 | Many raw components do not support `onBlur()` correctly. For these components, you can use `touchOnChange` to mark a field as touched when it changes instead of on blur:
387 |
388 | ```js
389 | function Widget() {
390 | const [formState, { raw }] = useFormState({ date: new Date() });
391 | return (
392 | <>
393 |
399 | >
400 | );
401 | }
402 | ```
403 |
404 | ### Updating Fields Manually
405 |
406 | There are cases where you may want to update the value of an input manually without user interaction. To do so, the `formState.setField` method can be used.
407 |
408 | ```js
409 | function Form() {
410 | const [formState, { text }] = useFormState();
411 |
412 | function setNameField() {
413 | // manually setting the value of the "name" input
414 | formState.setField('name', 'Mary Poppins');
415 | }
416 |
417 | return (
418 | <>
419 |
420 |
421 | >
422 | );
423 | }
424 | ```
425 |
426 | Please note that when `formState.setField` is called, any existing errors that might have been set due to previous interactions from the user will be cleared, and both of the `validity` and the `touched` states of the input will be set to `true`.
427 |
428 | It's also possible to clear a single input's value or to reset it to its initial value, if provided, using `formState.clearField` and `formState.resetField` respectively.
429 |
430 | As a convenience you can also set the error value for a single input using `formState.setFieldError`.
431 |
432 | ### Resetting The Form State
433 |
434 | The form state can be cleared or reset back to its initial state if provided at any time using `formState.clear` and `formState.reset` respectively.
435 |
436 | ```js
437 | function Form() {
438 | const [formState, { text, email }] = useFormState({
439 | email: 'hello@example.com',
440 | });
441 | return (
442 | <>
443 |
444 |
445 |
446 |
447 |
448 | >
449 | );
450 | }
451 | ```
452 |
453 | ## Working with TypeScript
454 |
455 | When working with TypeScript, the compiler needs to know what values and inputs `useFormState` is expected to be working with.
456 |
457 | For this reason, `useFormState` accepts an optional type argument that defines the state of the form and its fields which you could use to enforce type safety.
458 |
459 | ```ts
460 | interface LoginFormFields {
461 | username: string;
462 | password: string;
463 | remember_me: boolean;
464 | }
465 |
466 | const [formState, { text }] = useFormState();
467 | ¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯
468 | // OK
469 |
470 | formState.values.username
471 |
472 | // Error
473 | formState.values.doesNotExist
474 |
475 | ```
476 |
477 | By default, `useFormState` will use the type `any` for the form state and its inputs if no type argument is provided. Therefore, it is recommended that you provide one.
478 |
479 | By default, the `errors` property will contain strings. If you return complex error objects from custom validation, you can provide an error type:
480 |
481 | ```ts
482 | interface I18nError {
483 | en: string;
484 | fr: string;
485 | }
486 |
487 | interface LoginFormErrors {
488 | username?: string | I18nError;
489 | password?: string;
490 | }
491 |
492 | const [formState, { text }] = useFormState();
493 |
494 | formState.errors.username; // Will be undefined, a string, or an I18nError.
495 | ```
496 |
497 | ## API
498 |
499 | ```js
500 | import { useFormState } from 'react-use-form-state';
501 |
502 | function FormComponent()
503 | const [formState, inputs] = useFormState(initialState, formOptions);
504 | return (
505 | // ...
506 | )
507 | }
508 | ```
509 |
510 | ### `initialState`
511 |
512 | `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`).
513 |
514 | ### `formOptions`
515 |
516 | `useFormState` also accepts an optional form options object as a second argument with following properties:
517 |
518 | #### `formOptions.onBlur`
519 |
520 | 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)
521 |
522 | ```js
523 | const [formState, inputs] = useFormState(null, {
524 | onBlur(e) {
525 | // accessing the inputs target that triggered the blur event
526 | const { name, value, ...target } = e.target;
527 | },
528 | });
529 | ```
530 |
531 | #### `formOptions.onChange`
532 |
533 | A function that gets triggered upon any `change` of the form's inputs, and before updating `formState`.
534 |
535 | 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.
536 |
537 | ```js
538 | const [formState, inputs] = useFormState(null, {
539 | onChange(e, stateValues, nextStateValues) {
540 | // accessing the actual inputs target that triggered the change event
541 | const { name, value, ...target } = e.target;
542 | // the state values prior to applying the change
543 | formState.values === stateValues; // true
544 | // the state values after applying the change
545 | nextStateValues;
546 | // the state value of the input. See Input Types below for more information.
547 | nextStateValues[name];
548 | },
549 | });
550 | ```
551 |
552 | #### `formOptions.onTouched`
553 |
554 | A function that gets called after an input inside the form has lost focus, and is 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).
555 |
556 | ```js
557 | const [formState, inputs] = useFormState(null, {
558 | onTouched(e) {
559 | // accessing the inputs target that triggered the blur event
560 | const { name, value, ...target } = e.target;
561 | },
562 | });
563 | ```
564 |
565 | #### `formOptions.onClear`
566 |
567 | A function that gets called after calling `formState.clear` indicating that all fields in the form state are cleared successfully.
568 |
569 | ```js
570 | const [formState, inputs] = useFormState(null, {
571 | onClear() {
572 | // form state was cleared successfully
573 | },
574 | });
575 |
576 | formState.clear(); // clearing the form state
577 | ```
578 |
579 | #### `formOptions.onReset`
580 |
581 | A function that gets called after calling `formState.reset` indicating that all fields in the form state are set to their initial values.
582 |
583 | ```js
584 | const [formState, inputs] = useFormState(null, {
585 | onReset() {
586 | // form state was reset successfully
587 | },
588 | });
589 | formState.reset(); // resetting the form state
590 | ```
591 |
592 | #### `formOptions.validateOnBlur`
593 |
594 | By default, input validation is performed on both of the `change` and the `blur` events. Setting `validateOnBlur` to `true` will limit input validation to be **only** performed on `blur` (when the input loses focus). When set to `false`, input validation will **only** be performed on `change`.
595 |
596 | #### `formOptions.withIds`
597 |
598 | Indicates whether `useFormState` should generate and pass an `id` attribute to its fields. This is helpful when [working with labels](#labels-and-ids).
599 |
600 | It can be one of the following:
601 |
602 | A `boolean` indicating whether [input types](#input-types) should pass an `id` attribute to the inputs (set to `false` by default).
603 |
604 | ```js
605 | const [formState, inputs] = useFormState(null, {
606 | withIds: true,
607 | });
608 | ```
609 |
610 | Or a custom id formatter: a function that gets called with the input's name and own value, and expected to return a unique string (using these parameters) that will be as the input id.
611 |
612 | ```js
613 | const [formState, inputs] = useFormState(null, {
614 | withIds: (name, ownValue) =>
615 | ownValue ? `MyForm-${name}-${ownValue}` : `MyForm-${name}`,
616 | });
617 | ```
618 |
619 | Note that when `withIds` is set to `false`, applying `input.label()` will be a no-op.
620 |
621 | ### `[formState, inputs]`
622 |
623 | 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.
624 |
625 | #### Form State
626 |
627 | The first item returned by `useFormState`.
628 |
629 | ```js
630 | const [formState, inputs] = useFormState();
631 | ```
632 |
633 | An object containing the form state that updates during subsequent re-renders. It also include methods to update the form state manually.
634 |
635 | ```ts
636 | formState = {
637 | // an object holding the values of all input being rendered
638 | values: {
639 | [name: string]: string | string[] | boolean,
640 | },
641 |
642 | // an object indicating whether the value of each input is valid
643 | validity: {
644 | [name: string]?: boolean,
645 | },
646 |
647 | // an object holding all errors resulting from input validations
648 | errors: {
649 | [name: string]?: any,
650 | },
651 |
652 | // an object indicating whether the input was touched (focused) by the user
653 | touched: {
654 | [name: string]?: boolean,
655 | },
656 |
657 | // an object indicating whether the value of each input is pristine
658 | pristine: {
659 | [name: string]: boolean,
660 | },
661 |
662 | // whether the form is pristine or not
663 | isPristine(): boolean,
664 |
665 | // clears all fields in the form
666 | clear(): void,
667 |
668 | // clears the state of an input
669 | clearField(name: string): void,
670 |
671 | // resets all fields the form back to their initial state if provided
672 | reset(): void,
673 |
674 | // resets the state of an input back to its initial state if provided
675 | resetField(name: string): void,
676 |
677 | // updates the value of an input
678 | setField(name: string, value: string): void,
679 |
680 | // sets the error of an input
681 | setFieldError(name: string, error: string): void,
682 | }
683 | ```
684 |
685 | #### Input Types
686 |
687 | The second item returned by `useFormState`.
688 |
689 | ```js
690 | const [formState, input] = useFormState();
691 | ```
692 |
693 | An object with keys as input types. Each type is a function that returns the appropriate props that can be spread on the corresponding input.
694 |
695 | The following types are currently supported:
696 |
697 | | Type and Usage | State Shape |
698 | | --------------------------------------------------------------- | -------------------------------------------------------------------------- |
699 | | `` | `{ [name: string]: string }` |
700 | | `` | `{ [name: string]: string }` |
701 | | `` | `{ [name: string]: string }` |
702 | | `` | `{ [name: string]: string }` |
703 | | `` | `{ [name: string]: string }` |
704 | | `` | `{ [name: string]: string }` |
705 | | `` | `{ [name: string]: string }` |
706 | | `` | `{ [name: string]: string }` |
707 | | `` | `{ [name: string]: string }` |
708 | | `` | `{ [name: string]: string }` |
709 | | `` | `{ [name: string]: Array }` |
710 | | `` | `{ [name: string]: boolean }` |
711 | | `` | `{ [name: string]: string }` |
712 | | `` | `{ [name: string]: string }` |
713 | | `` | `{ [name: string]: string }` |
714 | | `` | `{ [name: string]: string }` |
715 | | `` | `{ [name: string]: string }` |
716 | | `` | `{ [name: string]: Array }` |
717 | | `` | `{ [name: string]: string }` |
718 | | `` | N/A – `input.label()` is stateless and thus does not affect the form state |
719 | | `` | `{ [name: string]: any }` |
720 |
721 | #### Input Options
722 |
723 | Alternatively, input type functions can be called with an object as the first argument. This object is used to [extend the functionality](#advanced-input-options) of the input. This includes attaching event handlers and performing input-level custom validation.
724 |
725 | ```jsx
726 | validateUsername(value),
730 | validateOnBlur: true,
731 | })}
732 | />
733 | ```
734 |
735 | The following options can be passed:
736 |
737 | | key | Description |
738 | | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
739 | | `name: string` | Required. The name of the input. |
740 | | `value: string` | The input's own value. Only required by the `radio` input, and optional for the `checkbox` input. |
741 | | `onChange(e): void` | Optional. A change event handler that's called with the input's `change` [`SyntheticEvent`](https://reactjs.org/docs/events.html). |
742 | | `onBlur(e): void` | Optional. A blur event handler that's called with the input's `blur` [`SyntheticEvent`](https://reactjs.org/docs/events.html). |
743 | | `validate(value, values, e): any` | Optional (required for `raw` inputs). An input validation function that determines whether the input value is valid. It's called with the input value, all input values in the form, and the change/blur event (or the raw value of the control in the case of `.raw()`). The input is considered **valid** if this method returns `true` or `undefined`. Any [truthy value](https://developer.mozilla.org/en-US/docs/Glossary/Truthy) other than `true` returned from this method will make the input **invalid**. Such values are used a **custom validation errors** that can be retrieved from [`state.errors`](#form-state). HTML5 validation rules are ignored when this function is specified. |
744 | | `compare(initialValue, value): any` | Optional (required for `raw` inputs). A comparison function that determines whether the input value is pristine. It's called with the input's initial value, and the input's current value. It must return a boolean indicating whether the form is pristine. |
745 | | `validateOnBlur: boolean` | Optional. Unspecified by default. When unspecified, input validation is performed on both of the `change` and the `blur` events. Setting `validateOnBlur` to `true` will limit input validation to be **only** performed on `blur` (when the input loses focus). When set to `false`, input validation will **only** be performed on `change`. |
746 | | `touchOnChange: boolean` | Optional. `false` by default. When `false`, the input will be marked as touched when the `onBlur()` event handler is called. For custom controls that do not support `onBlur`, setting this to `true` will make it so inputs will be marked as touched when `onChange()` is called instead. |
747 |
748 | ## License
749 |
750 | MIT
751 |
--------------------------------------------------------------------------------
/jest.setup.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/no-extraneous-dependencies */
2 |
3 | import 'jest-dom/extend-expect';
4 | import 'react-testing-library/cleanup-after-each';
5 |
6 | /**
7 | * Mocking calls to console.warn to test against warnings and errors logged
8 | * in the development environment.
9 | */
10 | let consoleSpy;
11 | beforeEach(() => {
12 | consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
13 | global.__DEV__ = 'development';
14 | });
15 | afterEach(() => {
16 | consoleSpy.mockRestore();
17 | global.__DEV__ = process.env.NODE_ENV;
18 | });
19 |
--------------------------------------------------------------------------------
/logo/logo.svg:
--------------------------------------------------------------------------------
1 |
19 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-use-form-state",
3 | "version": "0.13.2",
4 | "description": "React hook for managing form and inputs state",
5 | "main": "dist/index.js",
6 | "module": "dist/index.es.js",
7 | "types": "dist/index.d.ts",
8 | "repository": "wsmd/react-use-form-state",
9 | "homepage": "http://react-use-form-state.now.sh",
10 | "bugs": {
11 | "url": "https://github.com/wsmd/react-use-form-state/issues"
12 | },
13 | "author": "Waseem Dahman ",
14 | "license": "MIT",
15 | "keywords": [
16 | "react",
17 | "form",
18 | "forms",
19 | "state",
20 | "hook"
21 | ],
22 | "scripts": {
23 | "build": "rollup -c",
24 | "build:dev": "rollup -c -w --environment=BUILD:development",
25 | "clean": "rm -rf dist",
26 | "coveralls": "cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js",
27 | "lint": "eslint src test",
28 | "prepack": "yarn clean && yarn build",
29 | "prepublishOnly": "yarn test:all",
30 | "test": "jest --coverage",
31 | "test:all": "yarn lint && yarn typecheck && yarn test",
32 | "typecheck": "tsc --noEmit"
33 | },
34 | "files": [
35 | "dist"
36 | ],
37 | "jest": {
38 | "watchPathIgnorePatterns": [
39 | "dist"
40 | ],
41 | "collectCoverageFrom": [
42 | "src/**.js"
43 | ],
44 | "coveragePathIgnorePatterns": [
45 | "src/index.js"
46 | ],
47 | "setupFilesAfterEnv": [
48 | "/jest.setup.js"
49 | ]
50 | },
51 | "peerDependencies": {
52 | "react": "^16.8.0",
53 | "react-dom": "^16.8.0"
54 | },
55 | "devDependencies": {
56 | "@babel/cli": "^7.1.2",
57 | "@babel/core": "^7.1.2",
58 | "@babel/plugin-transform-runtime": "^7.3.4",
59 | "@babel/preset-env": "^7.9.6",
60 | "@babel/preset-react": "^7.0.0",
61 | "@rollup/plugin-babel": "^5.0.0",
62 | "@rollup/plugin-replace": "^2.3.2",
63 | "@types/jest": "^24.0.11",
64 | "@types/react": "^16.8.4",
65 | "@wsmd/eslint-config": "^1.2.0",
66 | "babel-core": "^7.0.0-bridge.0",
67 | "babel-eslint": "^10.1.0",
68 | "babel-jest": "^23.6.0",
69 | "coveralls": "^3.0.9",
70 | "eslint": "^6.8.0",
71 | "jest": "^24.7.1",
72 | "jest-dom": "^2.1.0",
73 | "prettier": "^1.19.1",
74 | "react": "^16.13.1",
75 | "react-dom": "^16.13.1",
76 | "react-hooks-testing-library": "^0.3.7",
77 | "react-testing-library": "^6.0.0",
78 | "rollup": "^2.10.2",
79 | "typescript": "^3.7.4"
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/rollup.config.js:
--------------------------------------------------------------------------------
1 | import fs from 'fs';
2 | import babel from '@rollup/plugin-babel';
3 | import replace from '@rollup/plugin-replace';
4 | import pkg from './package.json';
5 |
6 | const isDevBuild = process.env.BUILD === 'development';
7 |
8 | export default {
9 | input: 'src/index.js',
10 | output: [
11 | !isDevBuild && {
12 | file: pkg.main,
13 | format: 'cjs',
14 | },
15 | {
16 | file: pkg.module,
17 | format: 'es',
18 | sourcemap: isDevBuild,
19 | },
20 | ],
21 | external: Object.keys(pkg.peerDependencies),
22 | plugins: [
23 | babel({
24 | babelHelpers: 'bundled',
25 | }),
26 | replace({
27 | __DEV__: "process.env.NODE_ENV === 'development'",
28 | }),
29 | copyFile('src/index.d.ts', 'dist/index.d.ts'),
30 | ],
31 | };
32 |
33 | function copyFile(source, dest) {
34 | let called = false;
35 | return {
36 | async writeBundle() {
37 | if (called) return;
38 | called = true;
39 | try {
40 | await fs.promises.copyFile(source, dest);
41 | console.log(`copied ${source} → ${dest}`);
42 | } catch (err) {
43 | console.log(err);
44 | process.exit(1);
45 | }
46 | },
47 | };
48 | }
49 |
--------------------------------------------------------------------------------
/src/constants.js:
--------------------------------------------------------------------------------
1 | export const CHECKBOX = 'checkbox';
2 | export const COLOR = 'color';
3 | export const DATE = 'date';
4 | export const EMAIL = 'email';
5 | export const MONTH = 'month';
6 | export const NUMBER = 'number';
7 | export const PASSWORD = 'password';
8 | export const RADIO = 'radio';
9 | export const RANGE = 'range';
10 | export const RAW = 'raw';
11 | export const SEARCH = 'search';
12 | export const SELECT = 'select';
13 | export const SELECT_MULTIPLE = 'selectMultiple';
14 | export const TEL = 'tel';
15 | export const TEXT = 'text';
16 | export const TEXTAREA = 'textarea';
17 | export const TIME = 'time';
18 | export const URL = 'url';
19 | export const WEEK = 'week';
20 | export const LABEL = 'label';
21 |
22 | /**
23 | * @todo add support for datetime-local
24 | */
25 | export const DATETIME_LOCAL = 'datetime-local';
26 |
27 | export const INPUT_TYPES = [
28 | CHECKBOX,
29 | COLOR,
30 | DATE,
31 | EMAIL,
32 | MONTH,
33 | NUMBER,
34 | PASSWORD,
35 | RADIO,
36 | RANGE,
37 | RAW,
38 | SEARCH,
39 | SELECT,
40 | SELECT_MULTIPLE,
41 | TEL,
42 | TEXT,
43 | TEXTAREA,
44 | TIME,
45 | URL,
46 | WEEK,
47 | ];
48 |
--------------------------------------------------------------------------------
/src/index.d.ts:
--------------------------------------------------------------------------------
1 | // Type definitions for react-use-form-state 0.12.1
2 | // Project: https://github.com/wsmd/react-use-form-state
3 | // Definitions by: Waseem Dahman
4 |
5 | type StateShape = { [key in keyof T]: any };
6 |
7 | // Even though we're accepting a number as a default value for numeric inputs
8 | // (e.g. type=number and type=range), the value stored in state for those
9 | // inputs will be a string
10 | type StateValues = {
11 | readonly [A in keyof T]: T[A] extends number ? string : T[A];
12 | };
13 |
14 | type StateErrors = {
15 | readonly [A in keyof T]?: E | string;
16 | };
17 |
18 | interface UseFormStateHook {
19 | (initialState?: Partial> | null, options?: FormOptions): [
20 | FormState,
21 | Inputs,
22 | ];
23 | , E = StateErrors>(
24 | initialState?: Partial | null,
25 | options?: FormOptions,
26 | ): [FormState, Inputs];
27 | }
28 |
29 | export const useFormState: UseFormStateHook;
30 |
31 | interface FormState> {
32 | values: StateValues;
33 | errors: E;
34 | validity: { readonly [A in keyof T]?: boolean };
35 | touched: { readonly [A in keyof T]?: boolean };
36 | pristine: { readonly [A in keyof T]: boolean };
37 | reset(): void;
38 | clear(): void;
39 | setField(name: K, value: T[K]): void;
40 | setFieldError(name: keyof T, error: any): void;
41 | clearField(name: keyof T): void;
42 | resetField(name: keyof T): void;
43 | isPristine(): boolean;
44 | }
45 |
46 | interface FormOptions {
47 | onChange?(
48 | event: React.ChangeEvent,
49 | stateValues: StateValues,
50 | nextStateValues: StateValues,
51 | ): void;
52 | onBlur?(event: React.FocusEvent): void;
53 | onClear?(): void;
54 | onReset?(): void;
55 | onTouched?(event: React.FocusEvent): void;
56 | validateOnBlur?: boolean;
57 | withIds?: boolean | ((name: string, value?: string) => string);
58 | }
59 |
60 | // Inputs
61 |
62 | interface Inputs {
63 | selectMultiple: InputInitializer>;
64 | select: InputInitializer>;
65 | email: InputInitializer>;
66 | color: InputInitializer>;
67 | password: InputInitializer>;
68 | text: InputInitializer>;
69 | textarea: InputInitializer>;
70 | url: InputInitializer>;
71 | search: InputInitializer>;
72 | number: InputInitializer>;
73 | range: InputInitializer>;
74 | tel: InputInitializer>;
75 | date: InputInitializer>;
76 | month: InputInitializer>;
77 | week: InputInitializer>;
78 | time: InputInitializer>;
79 | radio: InputInitializerWithOwnValue>;
80 | checkbox: InputInitializerWithOptionalOwnValue>;
81 | raw: RawInputInitializer;
82 | label(name: string, value?: string): LabelProps;
83 | id(name: string, value?: string): string;
84 | }
85 |
86 | interface InputInitializer {
87 | (options: InputOptions): InputProps;
88 | (name: K): InputProps;
89 | }
90 |
91 | interface InputInitializerWithOwnValue {
92 | (options: InputOptions): R;
93 | (name: K, value: OwnValueType): R;
94 | }
95 |
96 | interface InputInitializerWithOptionalOwnValue {
97 | (options: InputOptions): R;
98 | (name: K, value?: OwnValueType): R;
99 | }
100 |
101 | interface RawInputInitializer {
102 | (
103 | options: RawInputOptions,
104 | ): RawInputProps;
105 | (name: K): RawInputProps;
106 | }
107 |
108 | type InputOptions = {
109 | name: K;
110 | validateOnBlur?: boolean;
111 | touchOnChange?: boolean;
112 | onChange?(event: React.ChangeEvent): void;
113 | onBlur?(event: React.FocusEvent): void;
114 | compare?(initialValue: StateValues[K], value: StateValues[K]): boolean;
115 | validate?(
116 | value: string,
117 | values: StateValues,
118 | event: React.ChangeEvent | React.FocusEvent,
119 | ): any;
120 | } & OwnOptions;
121 |
122 | interface RawInputOptions {
123 | name: K;
124 | touchOnChange?: boolean;
125 | validateOnBlur?: boolean;
126 | onBlur?(...args: any[]): void;
127 | onChange?(rawValue: RawValue): StateValues[K];
128 | compare?(initialValue: StateValues[K], value: StateValues[K]): boolean;
129 | validate?(value: StateValues[K], values: StateValues, rawValue: RawValue): any;
130 | }
131 |
132 | interface RawInputProps {
133 | name: Extract;
134 | value: StateValues[K];
135 | onChange(rawValue: RawValue): any;
136 | onBlur(...args: any[]): any;
137 | }
138 |
139 | type InputElement = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
140 |
141 | type OwnValueType = string | number | boolean | string[];
142 |
143 | interface BaseInputProps {
144 | id: string;
145 | onChange(event: any): void;
146 | onBlur(event: any): void;
147 | value: string;
148 | name: Extract;
149 | type: string;
150 | }
151 |
152 | type TypeLessInputProps = Omit, 'type'>;
153 |
154 | interface CheckableInputProps extends BaseInputProps {
155 | checked: boolean;
156 | }
157 |
158 | interface SelectMultipleProps extends TypeLessInputProps {
159 | multiple: boolean;
160 | }
161 |
162 | interface LabelProps {
163 | htmlFor: string;
164 | }
165 |
166 | type Omit = Pick>;
167 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | export { default as useFormState } from './useFormState';
2 |
--------------------------------------------------------------------------------
/src/parseInputArgs.js:
--------------------------------------------------------------------------------
1 | import { identity, noop, toString } from './utils';
2 |
3 | const defaultInputOptions = {
4 | onChange: identity,
5 | onBlur: noop,
6 | validate: null,
7 | validateOnBlur: undefined,
8 | touchOnChange: false,
9 | compare: null,
10 | };
11 |
12 | export function parseInputArgs(args) {
13 | let name;
14 | let ownValue;
15 | let options;
16 | if (typeof args[0] === 'string' || typeof args[0] === 'number') {
17 | [name, ownValue] = args;
18 | } else {
19 | [{ name, value: ownValue, ...options }] = args;
20 | }
21 |
22 | ownValue = toString(ownValue);
23 |
24 | return {
25 | name,
26 | ownValue,
27 | hasOwnValue: !!ownValue,
28 | ...defaultInputOptions,
29 | ...options,
30 | };
31 | }
32 |
--------------------------------------------------------------------------------
/src/useFormState.js:
--------------------------------------------------------------------------------
1 | import { useRef } from 'react';
2 | import { parseInputArgs } from './parseInputArgs';
3 | import { useInputId } from './useInputId';
4 | import { useMap, useReferencedCallback, useWarnOnce } from './utils-hooks';
5 | import { useState } from './useState';
6 | import {
7 | noop,
8 | omit,
9 | isFunction,
10 | isEmpty,
11 | isEqual,
12 | testIsEqualCompatibility,
13 | } from './utils';
14 | import {
15 | INPUT_TYPES,
16 | SELECT,
17 | CHECKBOX,
18 | RADIO,
19 | RAW,
20 | TEXTAREA,
21 | SELECT_MULTIPLE,
22 | LABEL,
23 | } from './constants';
24 |
25 | const defaultFormOptions = {
26 | onBlur: noop,
27 | onChange: noop,
28 | onClear: noop,
29 | onReset: noop,
30 | onTouched: noop,
31 | withIds: false,
32 | };
33 |
34 | export default function useFormState(initialState, options) {
35 | const formOptions = { ...defaultFormOptions, ...options };
36 |
37 | const formState = useState({ initialState });
38 | const { getIdProp } = useInputId(formOptions.withIds);
39 | const { set: setDirty, get: isDirty } = useMap();
40 | const referencedCallback = useReferencedCallback();
41 | const warn = useWarnOnce();
42 |
43 | const createPropsGetter = type => (...args) => {
44 | const inputOptions = parseInputArgs(args);
45 | const { name, ownValue, hasOwnValue } = inputOptions;
46 |
47 | const isCheckbox = type === CHECKBOX;
48 | const isRadio = type === RADIO;
49 | const isSelectMultiple = type === SELECT_MULTIPLE;
50 | const isRaw = type === RAW;
51 | const hasValueInState = formState.current.values[name] !== undefined;
52 |
53 | // This is used to cache input props that shouldn't change across
54 | // re-renders. Note that for `raw` values, `ownValue`
55 | // will return '[object Object]'. This means we can't have multiple
56 | // raw inputs with the same name and different values, but this is
57 | // probably fine.
58 | const key = `${type}.${name}.${ownValue}`;
59 |
60 | function setDefaultValue() {
61 | /* istanbul ignore else */
62 | if (__DEV__) {
63 | if (isRaw) {
64 | warn(
65 | `missingInitialValue.${key}`,
66 | `The initial value for input "${name}" is missing. Custom inputs ` +
67 | 'controlled with raw() are expected to have an initial value ' +
68 | 'provided to useFormState(). To prevent React from treating ' +
69 | 'this input as uncontrolled, an empty string will be used instead.',
70 | );
71 | }
72 | }
73 |
74 | let value = '';
75 | if (isCheckbox) {
76 | /**
77 | * If a checkbox has a user-defined value, its value the form state
78 | * value will be an array. Otherwise it will be considered a toggle.
79 | */
80 | value = hasOwnValue ? [] : false;
81 | }
82 | if (isSelectMultiple) {
83 | value = [];
84 | }
85 | formState.setValues({ [name]: value });
86 | }
87 |
88 | function getNextCheckboxValue(e) {
89 | const { value, checked } = e.target;
90 | if (!hasOwnValue) {
91 | return checked;
92 | }
93 | const checkedValues = new Set(formState.current.values[name]);
94 | if (checked) {
95 | checkedValues.add(value);
96 | } else {
97 | checkedValues.delete(value);
98 | }
99 | return Array.from(checkedValues);
100 | }
101 |
102 | function getNextSelectMultipleValue(e) {
103 | return Array.from(e.target.options).reduce(
104 | (values, option) =>
105 | option.selected ? [...values, option.value] : values,
106 | [],
107 | );
108 | }
109 |
110 | function getCompareFn() {
111 | if (isFunction(inputOptions.compare)) {
112 | return inputOptions.compare;
113 | }
114 | return (value, other) => {
115 | /* istanbul ignore else */
116 | if (__DEV__) {
117 | if (isRaw && ![value, other].every(testIsEqualCompatibility)) {
118 | warn(
119 | `missingCompare.${key}`,
120 | `You used a raw input type for "${name}" without providing a ` +
121 | 'custom compare method. As a result, the pristine value of ' +
122 | 'this input will be calculated using strict equality check ' +
123 | '(====), which is insufficient. Please provide a custom ' +
124 | 'compare method for this input in order to get an accurate ' +
125 | 'pristine value.',
126 | );
127 | }
128 | }
129 | return isEqual(value, other);
130 | };
131 | }
132 |
133 | formState.comparators.set(name, getCompareFn());
134 |
135 | function getValidateOnBlur() {
136 | return formOptions.validateOnBlur ?? inputOptions.validateOnBlur;
137 | }
138 |
139 | function validate(
140 | e,
141 | value = isRaw ? formState.current.values[name] : e.target.value,
142 | values = formState.current.values,
143 | ) {
144 | let error;
145 | let isValid = true;
146 | /* istanbul ignore else */
147 | if (isFunction(inputOptions.validate)) {
148 | const result = inputOptions.validate(value, values, e);
149 | if (result !== true && result != null) {
150 | isValid = false;
151 | error = result !== false ? result : '';
152 | }
153 | } else if (!isRaw) {
154 | isValid = e.target.validity.valid;
155 | error = e.target.validationMessage;
156 | } else if (__DEV__) {
157 | warn(
158 | `missingValidate.${key}`,
159 | `You used a raw input type for "${name}" without providing a ` +
160 | 'custom validate method. As a result, validation of this input ' +
161 | 'will be set to "true" automatically. If you need to validate ' +
162 | 'this input, provide a custom validation option.',
163 | );
164 | }
165 | formState.setValidity({ [name]: isValid });
166 | formState.setError(isEmpty(error) ? omit(name) : { [name]: error });
167 | }
168 |
169 | function touch(e) {
170 | if (!formState.current.touched[name]) {
171 | formState.setTouched({ [name]: true });
172 | formOptions.onTouched(e);
173 | }
174 | }
175 |
176 | const inputProps = {
177 | name,
178 | get type() {
179 | if (type !== SELECT && type !== SELECT_MULTIPLE && type !== TEXTAREA) {
180 | return type;
181 | }
182 | },
183 | get multiple() {
184 | if (type === SELECT_MULTIPLE) {
185 | return true;
186 | }
187 | },
188 | get checked() {
189 | const { values } = formState.current;
190 | if (isRadio) {
191 | return values[name] === ownValue;
192 | }
193 | if (isCheckbox) {
194 | if (!hasOwnValue) {
195 | return values[name] || false;
196 | }
197 | /**
198 | * @todo Handle the case where two checkbox inputs share the same
199 | * name, but one has a value, the other doesn't (throws currently).
200 | *
201 | *
202 | */
203 | return hasValueInState ? values[name].includes(ownValue) : false;
204 | }
205 | },
206 | get value() {
207 | if (!hasValueInState) {
208 | // auto populating default values if an initial value is not provided
209 | setDefaultValue();
210 | } else if (!formState.initialValues.has(name)) {
211 | // keep track of user-provided initial values on first render
212 | formState.initialValues.set(name, formState.current.values[name]);
213 | }
214 |
215 | // auto populating default values of touched
216 | if (formState.current.touched[name] == null) {
217 | formState.setTouched({ [name]: false });
218 | }
219 |
220 | // auto populating default values of pristine
221 | if (formState.current.pristine[name] == null) {
222 | formState.setPristine({ [name]: true });
223 | }
224 |
225 | /**
226 | * Since checkbox and radio inputs have their own user-defined values,
227 | * and since checkbox inputs can be either an array or a boolean,
228 | * returning the value of input from the current form state is illogical
229 | */
230 | if (isCheckbox || isRadio) {
231 | return ownValue;
232 | }
233 |
234 | return hasValueInState ? formState.current.values[name] : '';
235 | },
236 | onChange: referencedCallback(`onChange.${key}`, e => {
237 | setDirty(name, true);
238 | let value;
239 | if (isRaw) {
240 | value = inputOptions.onChange(e);
241 | if (value === undefined) {
242 | // setting value to its current state if onChange does not return
243 | // value to prevent React from complaining about the input switching
244 | // from controlled to uncontrolled
245 | value = formState.current.values[name];
246 | /* istanbul ignore else */
247 | if (__DEV__) {
248 | warn(
249 | `onChangeUndefined.${key}`,
250 | `You used a raw input type for "${name}" with an onChange() ` +
251 | 'option without returning a value. The onChange callback ' +
252 | 'of raw inputs, when provided, is used to determine the ' +
253 | 'custom value that will be stored in the form state. ' +
254 | 'Therefore, a value must be returned from the onChange callback.',
255 | );
256 | }
257 | }
258 | } else {
259 | if (isCheckbox) {
260 | value = getNextCheckboxValue(e);
261 | } else if (isSelectMultiple) {
262 | value = getNextSelectMultipleValue(e);
263 | } else {
264 | value = e.target.value;
265 | }
266 | inputOptions.onChange(e);
267 | }
268 |
269 | // Mark raw fields as touched on change, since we might not get an
270 | // `onBlur` event from them.
271 | if (inputOptions.touchOnChange) {
272 | touch(e);
273 | }
274 |
275 | const partialNewState = { [name]: value };
276 | const newValues = { ...formState.current.values, ...partialNewState };
277 |
278 | formOptions.onChange(e, formState.current.values, newValues);
279 |
280 | if (!getValidateOnBlur()) {
281 | validate(e, value, newValues);
282 | }
283 |
284 | formState.updatePristine(name, value);
285 |
286 | formState.setValues(partialNewState);
287 | }),
288 | onBlur: referencedCallback(`onBlur.${key}`, e => {
289 | touch(e);
290 |
291 | inputOptions.onBlur(e);
292 | formOptions.onBlur(e);
293 |
294 | /**
295 | * Limiting input validation on blur to:
296 | * A) when it's either touched for the first time
297 | * B) when it's marked as dirty due to a value change
298 | */
299 | if (!formState.current.touched[name] || isDirty(name)) {
300 | setDirty(name, false);
301 | // http://github.com/wsmd/react-use-form-state/issues/127#issuecomment-597989364
302 | if (getValidateOnBlur() ?? true) {
303 | validate(e);
304 | }
305 | }
306 | }),
307 | ...getIdProp('id', name, ownValue),
308 | };
309 |
310 | if (isRaw) {
311 | return {
312 | onChange: inputProps.onChange,
313 | onBlur: inputProps.onBlur,
314 | value: inputProps.value,
315 | name: inputProps.name,
316 | };
317 | }
318 |
319 | return inputProps;
320 | };
321 |
322 | const formStateAPI = useRef({
323 | isPristine: formState.isPristine,
324 | clearField: formState.clearField,
325 | resetField: formState.resetField,
326 | setField: formState.setField,
327 | setFieldError(name, error) {
328 | formState.setValidity({ [name]: false });
329 | formState.setError({ [name]: error });
330 | },
331 | clear() {
332 | formState.forEach(formState.clearField);
333 | formOptions.onClear();
334 | },
335 | reset() {
336 | formState.forEach(formState.resetField);
337 | formOptions.onReset();
338 | },
339 | });
340 |
341 | // exposing current form state (e.g. values, touched, validity, etc)
342 | Object.keys(formState.current).forEach(key => {
343 | formStateAPI.current[key] = formState.current[key];
344 | });
345 |
346 | const inputPropsCreators = {
347 | [LABEL]: (name, ownValue) => getIdProp('htmlFor', name, ownValue),
348 | };
349 |
350 | INPUT_TYPES.forEach(type => {
351 | inputPropsCreators[type] = createPropsGetter(type);
352 | });
353 |
354 | return [formStateAPI.current, inputPropsCreators];
355 | }
356 |
--------------------------------------------------------------------------------
/src/useInputId.js:
--------------------------------------------------------------------------------
1 | import { useCallback } from 'react';
2 | import { toString, noop, isFunction } from './utils';
3 |
4 | const defaultCreateId = (name, value) =>
5 | ['__ufs', name, value].filter(Boolean).join('__');
6 |
7 | export function useInputId(implementation) {
8 | const getId = useCallback(
9 | (name, ownValue) => {
10 | let createId;
11 | if (!implementation) {
12 | createId = noop;
13 | } else if (isFunction(implementation)) {
14 | createId = implementation;
15 | } else {
16 | createId = defaultCreateId;
17 | }
18 | const value = toString(ownValue);
19 | return value ? createId(name, value) : createId(name);
20 | },
21 | [implementation],
22 | );
23 |
24 | const getIdProp = useCallback(
25 | (prop, name, value) => {
26 | const id = getId(name, value);
27 | return id === undefined ? {} : { [prop]: id };
28 | },
29 | [getId],
30 | );
31 |
32 | return { getIdProp };
33 | }
34 |
--------------------------------------------------------------------------------
/src/useState.js:
--------------------------------------------------------------------------------
1 | import { useReducer, useRef } from 'react';
2 | import { isFunction, isEqual } from './utils';
3 | import { useMap } from './utils-hooks';
4 |
5 | function stateReducer(state, newState) {
6 | return isFunction(newState) ? newState(state) : { ...state, ...newState };
7 | }
8 |
9 | export function useState({ initialState }) {
10 | const state = useRef();
11 | const initialValues = useMap();
12 | const comparators = useMap();
13 | const [values, setValues] = useReducer(stateReducer, initialState || {});
14 | const [touched, setTouched] = useReducer(stateReducer, {});
15 | const [validity, setValidity] = useReducer(stateReducer, {});
16 | const [errors, setError] = useReducer(stateReducer, {});
17 | const [pristine, setPristine] = useReducer(stateReducer, {});
18 |
19 | state.current = { values, touched, validity, errors, pristine };
20 |
21 | function getInitialValue(name) {
22 | return initialValues.has(name)
23 | ? initialValues.get(name)
24 | : initialState[name];
25 | }
26 |
27 | function updatePristine(name, value) {
28 | let comparator = comparators.get(name);
29 | // If comparator isn't available for an input, that means the input wasn't
30 | // mounted, or manually added via setField.
31 | comparator = isFunction(comparator) ? comparator : isEqual;
32 | setPristine({ [name]: !!comparator(getInitialValue(name), value) });
33 | }
34 |
35 | function setFieldState(name, value, inputValidity, inputTouched, inputError) {
36 | setValues({ [name]: value });
37 | setTouched({ [name]: inputTouched });
38 | setValidity({ [name]: inputValidity });
39 | setError({ [name]: inputError });
40 | updatePristine(name, value);
41 | }
42 |
43 | function setField(name, value) {
44 | // need to store the initial value via setField in case it's before the
45 | // input of the given name is rendered.
46 | if (!initialValues.has(name)) {
47 | initialValues.set(name, value);
48 | }
49 | setFieldState(name, value, true, true);
50 | }
51 |
52 | function clearField(name) {
53 | setField(name);
54 | }
55 |
56 | function resetField(name) {
57 | setField(name, getInitialValue(name));
58 | }
59 |
60 | function isPristine() {
61 | return Object.keys(state.current.pristine).every(
62 | key => !!state.current.pristine[key],
63 | );
64 | }
65 |
66 | function forEach(cb) {
67 | Object.keys(state.current.values).forEach(cb);
68 | }
69 |
70 | return {
71 | get current() {
72 | return state.current;
73 | },
74 | setValues,
75 | setTouched,
76 | setValidity,
77 | setError,
78 | setField,
79 | setPristine,
80 | updatePristine,
81 | initialValues,
82 | resetField,
83 | clearField,
84 | forEach,
85 | isPristine,
86 | comparators,
87 | };
88 | }
89 |
--------------------------------------------------------------------------------
/src/utils-hooks.js:
--------------------------------------------------------------------------------
1 | import { useRef } from 'react';
2 |
3 | export function useMap() {
4 | const map = useRef(new Map());
5 | return {
6 | set: (key, value) => map.current.set(key, value),
7 | has: key => map.current.has(key),
8 | get: key => map.current.get(key),
9 | };
10 | }
11 |
12 | export function useReferencedCallback() {
13 | const callbacks = useMap();
14 | return (key, current) => {
15 | if (!callbacks.has(key)) {
16 | const callback = (...args) => callback.current(...args);
17 | callbacks.set(key, callback);
18 | }
19 | callbacks.get(key).current = current;
20 | return callbacks.get(key);
21 | };
22 | }
23 |
24 | export function useWarnOnce() {
25 | const didWarnRef = useRef(new Set());
26 | return (key, message) => {
27 | if (!didWarnRef.current.has(key)) {
28 | didWarnRef.current.add(key);
29 | console.warn('[useFormState]', message);
30 | }
31 | };
32 | }
33 |
--------------------------------------------------------------------------------
/src/utils.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Returns a function that can be called with an object. The return value of the
3 | * new function is a copy of the object excluding the key passed initially.
4 | */
5 | export function omit(key) {
6 | return object => {
7 | const { [key]: toRemove, ...rest } = object;
8 | return rest;
9 | };
10 | }
11 |
12 | /**
13 | * An empty function. It does nothing.
14 | */
15 | export function noop() {}
16 |
17 | /**
18 | * Like `noop`, but passes through the first argument.
19 | */
20 | export function identity(val) {
21 | return val;
22 | }
23 |
24 | /**
25 | * Cast non-string values to a string, with the exception of functions, symbols,
26 | * and undefined.
27 | */
28 | export function toString(value) {
29 | switch (typeof value) {
30 | case 'function':
31 | case 'symbol':
32 | case 'undefined':
33 | return '';
34 | default:
35 | return '' + value; // eslint-disable-line prefer-template
36 | }
37 | }
38 |
39 | export function isFunction(value) {
40 | return typeof value === 'function';
41 | }
42 |
43 | const objectToString = value => Object.prototype.toString.call(value);
44 |
45 | /**
46 | * Determines if a value is an empty collection (object, array, string, map, set)
47 | * @note this returns false for anything else.
48 | */
49 | export function isEmpty(value) {
50 | if (value == null) {
51 | return true;
52 | }
53 | if (Array.isArray(value) || typeof value === 'string') {
54 | return !value.length;
55 | }
56 | if (
57 | objectToString(value) === '[object Map]' ||
58 | objectToString(value) === '[object Set]'
59 | ) {
60 | return !value.size;
61 | }
62 | if (objectToString(value) === '[object Object]') {
63 | return !Object.keys(value).length;
64 | }
65 | return false;
66 | }
67 |
68 | export function isEqual(value, other) {
69 | if (Array.isArray(value) && Array.isArray(other)) {
70 | return (
71 | value.length === other.length &&
72 | value.every(a => other.indexOf(a) > -1) &&
73 | other.every(b => value.indexOf(b) > -1)
74 | );
75 | }
76 | return value === other;
77 | }
78 |
79 | export function testIsEqualCompatibility(value) {
80 | /* istanbul ignore if */
81 | if (Array.isArray(value)) {
82 | return value.every(testIsEqualCompatibility);
83 | }
84 | return value == null || /^[sbn]/.test(typeof value); // basic primitives
85 | }
86 |
--------------------------------------------------------------------------------
/test/test-utils.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, fireEvent } from 'react-testing-library';
3 | import { useFormState } from '../src';
4 |
5 | export { renderHook } from 'react-hooks-testing-library';
6 |
7 | export { render as renderElement, fireEvent };
8 |
9 | export const InputTypes = {
10 | textLike: ['text', 'email', 'password', 'search', 'tel', 'url'],
11 | time: ['date', 'month', 'time', 'week'],
12 | numeric: ['number', 'range'],
13 | };
14 |
15 | export function renderWithFormState(renderFn, ...useFormStateArgs) {
16 | const formStateRef = { current: null };
17 |
18 | const Wrapper = ({ children }) => {
19 | const [state, inputs] = useFormState(...useFormStateArgs);
20 | formStateRef.current = state;
21 | return children([state, inputs]);
22 | };
23 |
24 | const { container } = render({renderFn});
25 |
26 | const fire = (type, target, node = container.firstChild) => {
27 | fireEvent[type](node, { target });
28 | };
29 |
30 | return {
31 | blur: (...args) => fire('blur', ...args),
32 | change: (...args) => fire('change', ...args),
33 | click: (...args) => fire('click', ...args),
34 | formState: formStateRef,
35 | root: container.firstChild,
36 | };
37 | }
38 |
--------------------------------------------------------------------------------
/test/types.tsx:
--------------------------------------------------------------------------------
1 | import React, { FC } from 'react';
2 | import { useFormState, FormState } from '../src';
3 |
4 | useFormState();
5 | useFormState({});
6 | useFormState(null);
7 |
8 | interface FormFields {
9 | name: string;
10 | colors: string[];
11 | power_level: number;
12 | remember_me: boolean;
13 | }
14 |
15 | const initialState = {
16 | name: 'wsmd',
17 | };
18 |
19 | const [formState, input] = useFormState(initialState, {
20 | onChange(e, stateValues, nextStateValues) {
21 | const { name, value } = e.target;
22 | if (name === 'name') {
23 | // string
24 | stateValues[name].toLowerCase();
25 | }
26 | if (name === 'colors') {
27 | // string[]
28 | stateValues[name].forEach(color => console.log(color));
29 | }
30 | },
31 | onBlur(e) {
32 | const { name, value } = e.target;
33 | },
34 | onTouched(e) {
35 | const { name, value } = e.target;
36 | },
37 | withIds: (name, value) => (value ? `${name}.${value.toLowerCase()}` : name),
38 | validateOnBlur: true,
39 | });
40 |
41 | let name: string = formState.values.name;
42 |
43 | formState.values.colors.forEach(color => console.log(color));
44 |
45 | /**
46 | * numeric values will be retrieved as strings
47 | */
48 | let level: string = formState.values.power_level;
49 |
50 | let rememberMe: boolean = formState.values.remember_me;
51 |
52 | /**
53 | * values of validity and touched will be determined via the blur event. Until
54 | * the even is fired, the values will be of type undefined
55 | */
56 | formState.touched.colors;
57 | formState.validity.name;
58 | formState.values.power_level.split('');
59 | if (formState.errors.colors) {
60 | // string
61 | formState.errors.colors.toLocaleLowerCase();
62 | }
63 |
64 | ;
65 | ;
66 | ;
67 | ;
68 | ;
69 | ;
70 | ;
71 | ;
72 | ;
73 | ;
74 | ;
75 | ;
76 | ;
77 | ;
78 | ;
79 | ;
80 | ;
81 | ;
82 | ;
83 |
84 | ;
85 | ;
86 |
87 | ;
88 |
89 | ;
90 | ;
91 | ;
92 | ;
93 |
94 |
99 | event.target.validity.valid &&
100 | value.length >= 3 &&
101 | parseInt(values.power_level) > 9000,
102 | onChange: e => console.log(e.target.value),
103 | onBlur: e => console.log(e.target.value),
104 | })}
105 | />;
106 |
107 | {
111 | const errors = {} as any;
112 | if (!value.trim()) {
113 | errors.required = 'Password is required';
114 | }
115 | if (!/foobar/.test(value)) {
116 | errors.weakPassword = 'Password is not strong enough';
117 | }
118 | return errors;
119 | },
120 | })}
121 | />;
122 |
123 | // Custom validation error types
124 | function CustomErrorTypes() {
125 | interface I18nError {
126 | en: string;
127 | fr: string;
128 | }
129 |
130 | interface FormErrors {
131 | name?: string;
132 | colors?: I18nError;
133 | power_level?: string;
134 | remember_me?: string;
135 | }
136 |
137 | const [formState, input] = useFormState(
138 | initialState,
139 | {},
140 | );
141 |
142 | if (formState.errors.colors && typeof formState.errors.colors !== 'string') {
143 | formState.errors.colors.en;
144 | }
145 | }
146 |
147 | function CustomErrorTypesWithStateErrors() {
148 | interface I18nError {
149 | en: string;
150 | fr: string;
151 | }
152 |
153 | interface FormFieldsErrors {
154 | colors?: string | I18nError;
155 | }
156 |
157 | const [formState, input] = useFormState(
158 | initialState,
159 | {},
160 | );
161 |
162 | if (formState.errors.colors && typeof formState.errors.colors !== 'string') {
163 | formState.errors.colors.en;
164 | }
165 | }
166 |
167 | ;
168 |
169 | input.id('name');
170 |
171 | // typed state
172 |
173 | interface ConnectFormState {
174 | user: string;
175 | host: string;
176 | password: string;
177 | database: string;
178 | port: number;
179 | }
180 |
181 | const [typedState] = useFormState({
182 | user: 'wsmd',
183 | port: 80,
184 | });
185 |
186 | const { port, host }: { port: string; host: string } = typedState.values;
187 |
188 | // untyped
189 |
190 | const [state, { raw, text, radio, checkbox }] = useFormState({
191 | foo: 1,
192 | });
193 |
194 | ;
195 | ;
196 | ;
197 | ;
198 | ;
199 | ;
200 | ;
201 |
202 | // Raw Input
203 |
204 | function RawInputTyped() {
205 | const DatePicker: FC<{ onChange(value: Date): void; value: string }> = () =>
206 | null;
207 | const [formState, { raw }] = useFormState<{ name: string; date: string }>();
208 | formState.values.date.split('/');
209 | return (
210 | <>
211 |
212 | e.toLocaleDateString(),
216 | validate(value, values, rawValue) {
217 | value.split('/');
218 | rawValue.toLocaleDateString();
219 | return true;
220 | },
221 | })}
222 | />
223 | >
224 | );
225 | }
226 |
227 | function RawInputUntyped() {
228 | const DatePicker: FC<{ onChange(value: Date): void; value: string }> = () =>
229 | null;
230 | const [formState, { raw }] = useFormState();
231 | return (
232 | <>
233 |
234 | e.toLocaleDateString(),
238 | validate(value, values, rawValue) {
239 | value.split('/');
240 | rawValue.toISOString();
241 | return true;
242 | },
243 | })}
244 | />
245 | >
246 | );
247 | }
248 |
249 | function RawValueInState() {
250 | const DatePicker: FC<{ onChange(value: Date): any; value: Date }> = () =>
251 | null;
252 | const [formState, input] = useFormState<{ date: Date; name: string }>();
253 | formState.values.date.toISOString();
254 | return (
255 | <>
256 |
257 |
258 | e,
262 | validate(value, values, rawValue) {
263 | value.toLocaleDateString();
264 | rawValue.toISOString();
265 | return true;
266 | },
267 | })}
268 | />
269 | >
270 | );
271 | }
272 |
273 | function PropsDelegation() {
274 | interface FormFields {
275 | email: string;
276 | }
277 | interface InputProps {
278 | formState: FormState;
279 | name: keyof FormFields;
280 | }
281 | const Input: React.FC = ({ formState, name }) => {
282 | formState.errors[name]; // value of 'name' should be inferred (e.g. "email")
283 | return null;
284 | };
285 | const [formState, input] = useFormState();
286 | return ;
287 | }
288 |
--------------------------------------------------------------------------------
/test/useFormState-formOptions.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { renderWithFormState } from './test-utils';
3 |
4 | describe('useFormState options', () => {
5 | it('calls options.onChange when an input changes', async () => {
6 | const changeHandler = jest.fn();
7 | const { change } = renderWithFormState(
8 | ([, { text }]) => ,
9 | null,
10 | { onChange: changeHandler },
11 | );
12 | change({ value: 'w' });
13 | expect(changeHandler).toHaveBeenCalledWith(
14 | expect.any(Object), // SyntheticEvent
15 | expect.objectContaining({ username: '' }),
16 | expect.objectContaining({ username: 'w' }),
17 | );
18 | });
19 |
20 | it('calls options.onBlur when an input changes', () => {
21 | const blurHandler = jest.fn();
22 | const { blur } = renderWithFormState(
23 | ([, { text }]) => ,
24 | null,
25 | { onBlur: blurHandler },
26 | );
27 | blur();
28 | expect(blurHandler).toHaveBeenCalledWith(expect.any(Object));
29 | blur();
30 | expect(blurHandler).toHaveBeenCalledTimes(2);
31 | });
32 |
33 | it('calls options.onTouched when an input changes', () => {
34 | const touchedHandler = jest.fn();
35 | const { blur } = renderWithFormState(
36 | ([, { text }]) => ,
37 | null,
38 | { onTouched: touchedHandler },
39 | );
40 | blur();
41 | expect(touchedHandler).toHaveBeenCalled();
42 | blur();
43 | expect(touchedHandler).toHaveBeenCalledTimes(1);
44 | });
45 | });
46 |
--------------------------------------------------------------------------------
/test/useFormState-ids.test.js:
--------------------------------------------------------------------------------
1 | import { useFormState } from '../src';
2 | import { renderHook } from './test-utils';
3 |
4 | describe('Input IDs', () => {
5 | /**
6 | * Label only needs a htmlFor
7 | */
8 | it('input method correct props from type "label"', () => {
9 | const { result } = renderHook(() => useFormState(null, { withIds: true }));
10 | const [, input] = result.current;
11 | expect(input.label('name')).toEqual({
12 | htmlFor: expect.any(String),
13 | });
14 | });
15 |
16 | it('input method has an "id" prop', () => {
17 | const { result } = renderHook(() => useFormState(null, { withIds: true }));
18 | const [, input] = result.current;
19 | expect(input.text('name')).toHaveProperty('id', expect.any(String));
20 | });
21 |
22 | it('generates unique IDs for inputs with different names', () => {
23 | const { result } = renderHook(() => useFormState(null, { withIds: true }));
24 | const [, input] = result.current;
25 | const { id: firstId } = input.text('firstName');
26 | const { id: lastId } = input.text('lastName');
27 | expect(firstId).not.toBe(lastId);
28 | });
29 |
30 | it('generates unique IDs for inputs with the same name and different values', () => {
31 | const { result } = renderHook(() => useFormState(null, { withIds: true }));
32 | const [, input] = result.current;
33 | const { id: freeId } = input.radio('plan', 'free');
34 | const { id: premiumId } = input.radio('plan', 'premium');
35 | expect(freeId).not.toBe(premiumId);
36 | });
37 |
38 | it('sets matching IDs for inputs and labels', () => {
39 | const { result } = renderHook(() => useFormState(null, { withIds: true }));
40 | const [, input] = result.current;
41 | const { id: inputId } = input.text('name');
42 | const { htmlFor: labelId } = input.label('name');
43 | expect(labelId).toBe(inputId);
44 | });
45 |
46 | it('sets matching IDs for inputs and labels with non string values', () => {
47 | const { result } = renderHook(() => useFormState(null, { withIds: true }));
48 | const [, input] = result.current;
49 | const { id: inputId } = input.checkbox('name', 0);
50 | const { htmlFor: labelId } = input.label('name', 0);
51 | expect(labelId).toBe(inputId);
52 | });
53 |
54 | it('sets a custom id when formOptions.withIds is set to a function', () => {
55 | const customInputFormat = jest.fn((name, value) =>
56 | value ? `form-${name}-${value}` : `form-${name}`,
57 | );
58 | const { result } = renderHook(() =>
59 | useFormState(null, { withIds: customInputFormat }),
60 | );
61 | const [, input] = result.current;
62 |
63 | // inputs with own values (e.g. radio button)
64 |
65 | const radioProps = input.radio('option', 0);
66 | expect(radioProps.id).toEqual('form-option-0');
67 | expect(customInputFormat).toHaveBeenCalledWith('option', '0');
68 |
69 | const radioLabelProps = input.label('option', 0);
70 | expect(radioLabelProps.htmlFor).toEqual('form-option-0');
71 | expect(customInputFormat).toHaveBeenNthCalledWith(2, 'option', '0');
72 |
73 | // inputs with no own values (e.g. text input)
74 |
75 | const textProps = input.text('name');
76 | expect(textProps.id).toEqual('form-name');
77 | expect(customInputFormat).toHaveBeenLastCalledWith('name');
78 |
79 | const textLabelProps = input.label('name');
80 | expect(textLabelProps.htmlFor).toEqual('form-name');
81 | expect(customInputFormat).toHaveBeenNthCalledWith(3, 'name');
82 | });
83 |
84 | it('does not return IDs when formOptions.withIds is set to false', () => {
85 | const { result } = renderHook(() => useFormState());
86 | const [, input] = result.current;
87 | const nameInputProps = input.checkbox('name', 0);
88 | const nameLabelProps = input.label('name', 0);
89 | expect(nameInputProps).not.toHaveProperty('id');
90 | expect(nameLabelProps).not.toHaveProperty('htmlFor');
91 | });
92 | });
93 |
--------------------------------------------------------------------------------
/test/useFormState-input.test.js:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { useFormState } from '../src';
3 | import {
4 | InputTypes,
5 | fireEvent,
6 | renderHook,
7 | renderElement,
8 | renderWithFormState,
9 | } from './test-utils';
10 |
11 | describe('input type methods return correct props object', () => {
12 | /**
13 | * Must return type and value
14 | */
15 | it.each([
16 | ...InputTypes.textLike,
17 | ...InputTypes.numeric,
18 | ...InputTypes.time,
19 | 'color',
20 | ])('returns props for type "%s"', type => {
21 | const { result } = renderHook(() => useFormState());
22 | expect(result.current[1][type]('input-name')).toEqual({
23 | type,
24 | name: 'input-name',
25 | value: '',
26 | onChange: expect.any(Function),
27 | onBlur: expect.any(Function),
28 | });
29 | });
30 |
31 | /**
32 | * Checkbox must have a type, value, and checked
33 | */
34 | it('returns props for type "checkbox"', () => {
35 | const { result } = renderHook(() => useFormState());
36 | expect(result.current[1].checkbox('option', 'option_1')).toEqual({
37 | type: 'checkbox',
38 | name: 'option',
39 | value: 'option_1',
40 | checked: false,
41 | onChange: expect.any(Function),
42 | onBlur: expect.any(Function),
43 | });
44 | });
45 |
46 | /**
47 | * Checkbox must have a type, value, and checked
48 | */
49 | it('returns props for type "checkbox" without a value', () => {
50 | const { result } = renderHook(() => useFormState());
51 | expect(result.current[1].checkbox('option')).toEqual({
52 | type: 'checkbox',
53 | name: 'option',
54 | checked: false,
55 | onChange: expect.any(Function),
56 | onBlur: expect.any(Function),
57 | value: '',
58 | });
59 | });
60 |
61 | it('returns props for type "raw"', () => {
62 | const { result } = renderHook(() => useFormState({ option: '' }));
63 | expect(result.current[1].raw('option')).toEqual({
64 | name: 'option',
65 | value: '',
66 | onChange: expect.any(Function),
67 | onBlur: expect.any(Function),
68 | });
69 | });
70 |
71 | it('returns props for type "raw" with touchOnChange support', () => {
72 | const { result } = renderHook(() => useFormState({ option: new Date() }));
73 | expect(
74 | result.current[1].raw({ name: 'option', touchOnChange: true }),
75 | ).toEqual({
76 | name: 'option',
77 | value: expect.any(Date),
78 | onChange: expect.any(Function),
79 | onBlur: expect.any(Function),
80 | });
81 | });
82 |
83 | /**
84 | * Radio must have a type, value, and checked
85 | */
86 | it('returns props for type "radio"', () => {
87 | const { result } = renderHook(() => useFormState());
88 | expect(result.current[1].radio('radio_name', 'radio_option')).toEqual({
89 | type: 'radio',
90 | name: 'radio_name',
91 | value: 'radio_option',
92 | checked: false,
93 | onChange: expect.any(Function),
94 | onBlur: expect.any(Function),
95 | });
96 | });
97 |
98 | /**
99 | * Stringify non-string ownValue of checkbox and radio
100 | */
101 | it.each`
102 | type | ownValue | expected
103 | ${'array'} | ${[1, 2]} | ${'1,2'}
104 | ${'boolean'} | ${false} | ${'false'}
105 | ${'number'} | ${1} | ${'1'}
106 | ${'object'} | ${{}} | ${'[object Object]'}
107 | ${'function'} | ${() => {}} | ${''}
108 | ${'Symbol'} | ${Symbol('')} | ${''}
109 | `(
110 | 'stringify ownValue of type $type for checkbox and radio',
111 | ({ ownValue, expected }) => {
112 | const { result } = renderHook(() => useFormState());
113 | const input = result.current[1];
114 | expect(input.checkbox('option1', ownValue).value).toEqual(expected);
115 | expect(input.radio('option2', ownValue).value).toEqual(expected);
116 | },
117 | );
118 |
119 | /**
120 | * Select doesn't need a type
121 | */
122 | it('returns props for type "select"', () => {
123 | const { result } = renderHook(() => useFormState());
124 | expect(result.current[1].select('select_name')).toEqual({
125 | name: 'select_name',
126 | value: expect.any(String),
127 | onChange: expect.any(Function),
128 | onBlur: expect.any(Function),
129 | });
130 | });
131 |
132 | /**
133 | * SelectMultiple doesn't need a type but must have a multiple
134 | */
135 | it('returns props for type "selectMultiple"', () => {
136 | const { result } = renderHook(() => useFormState());
137 | expect(result.current[1].selectMultiple('select_name')).toEqual({
138 | name: 'select_name',
139 | multiple: true,
140 | value: expect.any(String),
141 | onChange: expect.any(Function),
142 | onBlur: expect.any(Function),
143 | });
144 | });
145 |
146 | /**
147 | * Textarea doesn't need a type
148 | */
149 | it('returns props for type "textarea"', () => {
150 | const { result } = renderHook(() => useFormState());
151 | expect(result.current[1].textarea('name')).toEqual({
152 | name: 'name',
153 | value: '',
154 | onChange: expect.any(Function),
155 | onBlur: expect.any(Function),
156 | });
157 | });
158 |
159 | it('returns props for type "text" when passing an object', () => {
160 | const { result } = renderHook(() => useFormState({ username: 'wsmd' }));
161 | expect(result.current[1].text({ name: 'username' })).toEqual({
162 | type: 'text',
163 | name: 'username',
164 | value: 'wsmd',
165 | onChange: expect.any(Function),
166 | onBlur: expect.any(Function),
167 | });
168 | });
169 |
170 | it('returns props for type "checkbox" when passing an object', () => {
171 | const { result } = renderHook(() => useFormState());
172 | expect(result.current[1].checkbox({ name: 'options', value: 0 })).toEqual({
173 | type: 'checkbox',
174 | checked: false,
175 | name: 'options',
176 | value: '0',
177 | onChange: expect.any(Function),
178 | onBlur: expect.any(Function),
179 | });
180 | });
181 | });
182 |
183 | describe('inputs receive default values from initial state', () => {
184 | it.each([
185 | ...InputTypes.textLike,
186 | ...InputTypes.time,
187 | 'color',
188 | 'textarea',
189 | 'select',
190 | ])('sets initial "value" for type "%s"', type => {
191 | const initialState = { 'input-name': 'input-value' };
192 | const { result } = renderHook(() => useFormState(initialState));
193 | const [, input] = result.current;
194 | expect(input[type]('input-name').value).toEqual('input-value');
195 | });
196 |
197 | it.each(InputTypes.numeric)('sets initial "value" for type "%s"', type => {
198 | const initialState = { 'input-name': '101' };
199 | const { result } = renderHook(() => useFormState(initialState));
200 | const [, input] = result.current;
201 | expect(input[type]('input-name').value).toEqual('101');
202 | });
203 |
204 | it('sets initial "value" for type "selectMultiple"', () => {
205 | const value = ['option_1', 'option_2'];
206 | const initialState = { multiple: value };
207 | const { result } = renderHook(() => useFormState(initialState));
208 | const [, input] = result.current;
209 | expect(input.selectMultiple('multiple').value).toEqual(value);
210 | });
211 |
212 | it('sets initial "checked" for type "checkbox"', () => {
213 | const initialState = { options: ['option_1', 'option_2'] };
214 | const { result } = renderHook(() => useFormState(initialState));
215 | const [, input] = result.current;
216 | expect(input.checkbox('options', 'option_1').checked).toEqual(true);
217 | expect(input.checkbox('options', 'option_2').checked).toEqual(true);
218 | expect(input.checkbox('options', 'option_3').checked).toEqual(false);
219 | });
220 |
221 | it('sets initial "checked" for type "checkbox" without a value', () => {
222 | const initialState = { option1: true };
223 | const { result } = renderHook(() => useFormState(initialState));
224 | const [, input] = result.current;
225 | expect(input.checkbox('option1').checked).toEqual(true);
226 | expect(input.checkbox('option1').value).toEqual('');
227 | expect(input.checkbox('option2').checked).toEqual(false);
228 | expect(input.checkbox('option2').value).toEqual('');
229 | });
230 |
231 | it('sets initial "value" for type "raw"', () => {
232 | const value = { foo: 1 };
233 | const initialState = { raw: value };
234 | const { result } = renderHook(() => useFormState(initialState));
235 | const [, input] = result.current;
236 | expect(input.raw('raw').value).toEqual(value);
237 | });
238 |
239 | it('returns default initial value for type "raw" without a value', () => {
240 | const { result } = renderHook(() => useFormState({}));
241 | const [, input] = result.current;
242 | expect(input.raw('raw').value).toEqual('');
243 | });
244 |
245 | it('warns when initial value for type "raw" is not set', () => {
246 | const { result } = renderHook(() => useFormState({}));
247 | const [, input] = result.current;
248 |
249 | // triggering the value getter
250 | // eslint-disable-next-line no-unused-vars
251 | const { value } = input.raw('test');
252 |
253 | // eslint-disable-next-line no-console
254 | expect(console.warn).toHaveBeenCalledWith(
255 | expect.any(String),
256 | expect.stringContaining('The initial value for input "test" is missing'),
257 | );
258 | });
259 |
260 | it('sets initial "checked" for type "radio"', () => {
261 | const { result } = renderHook(() => useFormState({ option: 'no' }));
262 | const [, input] = result.current;
263 | expect(input.radio('option', 'yes').checked).toEqual(false);
264 | expect(input.radio('option', 'no').checked).toEqual(true);
265 | });
266 | });
267 |
268 | describe('onChange updates inputs value', () => {
269 | it.each([...InputTypes.textLike, 'textarea'])(
270 | 'updates value for type "%s"',
271 | type => {
272 | const { change, root } = renderWithFormState(([, inputs]) => (
273 |
274 | ));
275 | change({ value: `value for ${type}` });
276 | expect(root).toHaveAttribute('value', `value for ${type}`);
277 | },
278 | );
279 |
280 | it.each(InputTypes.numeric)('updates value for type "%s"', type => {
281 | const { change, root } = renderWithFormState(([, inputs]) => (
282 |
283 | ));
284 | change({ value: '10' });
285 | expect(root).toHaveAttribute('value', '10');
286 | });
287 |
288 | it('updates value for type "color"', () => {
289 | const { change, root } = renderWithFormState(([, { color }]) => (
290 |
291 | ));
292 | change({ value: '#ffffff' });
293 | expect(root).toHaveAttribute('value', '#ffffff');
294 | });
295 |
296 | it('updates value for type "raw"', () => {
297 | let onChange;
298 | const { formState } = renderWithFormState(([, { raw }]) => {
299 | const inputProps = raw('value');
300 | ({ onChange } = inputProps);
301 | return ;
302 | });
303 | onChange({ foo: 1 });
304 | expect(formState.current.values.value).toEqual({ foo: 1 });
305 | });
306 |
307 | it('maps value for type "raw" with custom onChange', () => {
308 | let onChange;
309 | const { formState } = renderWithFormState(([, { raw }]) => {
310 | const inputProps = raw({
311 | name: 'value',
312 | onChange: value => `foo${value}`,
313 | });
314 |
315 | ({ onChange } = inputProps);
316 | return ;
317 | });
318 | onChange('bar');
319 | expect(formState.current.values.value).toEqual('foobar');
320 | });
321 |
322 | it('warns when a custom onChange of "raw" does not return a value', () => {
323 | const { change } = renderWithFormState(([, { raw }]) => (
324 | {} })} />
325 | ));
326 | change({ value: 'test' });
327 | // eslint-disable-next-line no-console
328 | expect(console.warn).toHaveBeenCalledWith(
329 | expect.any(String),
330 | expect.stringContaining(
331 | 'You used a raw input type for "test" with an onChange() option ' +
332 | 'without returning a value',
333 | ),
334 | );
335 | });
336 |
337 | it('warns when the validate option of "raw" is not provided', () => {
338 | const { change } = renderWithFormState(([, { raw }]) => (
339 |
340 | ));
341 | change({ value: 'test' });
342 | // eslint-disable-next-line no-console
343 | expect(console.warn).toHaveBeenCalledWith(
344 | expect.any(String),
345 | expect.stringContaining(
346 | 'You used a raw input type for "test" without providing a custom validate method',
347 | ),
348 | );
349 | });
350 |
351 | it.each([
352 | ['week', '2018-W01'],
353 | ['date', '2018-11-01'],
354 | ['time', '02:00'],
355 | ['month', '2018-11'],
356 | ])('updates value for type %s', (type, value) => {
357 | const { change, root } = renderWithFormState(([, inputs]) => (
358 |
359 | ));
360 | change({ value });
361 | expect(root).toHaveAttribute('value', value);
362 | });
363 |
364 | it('updates value for type "checkbox"', () => {
365 | const name = 'collection';
366 | const value = 'item';
367 | const { click, formState } = renderWithFormState(([, { checkbox }]) => (
368 |
369 | ));
370 |
371 | click();
372 | expect(formState.current).toEqual(
373 | expect.objectContaining({ values: { [name]: [value] } }),
374 | );
375 |
376 | click();
377 | expect(formState.current).toEqual(
378 | expect.objectContaining({ values: { [name]: [] } }),
379 | );
380 | });
381 |
382 | it('updates value for type "checkbox" without a value', () => {
383 | const name = 'remember_me';
384 | const { click, formState } = renderWithFormState(([, { checkbox }]) => (
385 |
386 | ));
387 |
388 | click();
389 | expect(formState.current).toEqual(
390 | expect.objectContaining({ values: { [name]: true } }),
391 | );
392 |
393 | click();
394 | expect(formState.current).toEqual(
395 | expect.objectContaining({ values: { [name]: false } }),
396 | );
397 | });
398 |
399 | it('updates value for type "radio"', () => {
400 | const { formState, click } = renderWithFormState(([, { radio }]) => (
401 |
402 | ));
403 | expect(formState.current).toEqual(
404 | expect.objectContaining({ values: { radio: '' } }),
405 | );
406 | click();
407 | expect(formState.current).toEqual(
408 | expect.objectContaining({ values: { radio: 'option' } }),
409 | );
410 | });
411 |
412 | it('updates value for type "select"', () => {
413 | const { formState, change } = renderWithFormState(([, { select }]) => (
414 |
418 | ));
419 | expect(formState.current).toEqual(
420 | expect.objectContaining({ values: { select: '' } }),
421 | );
422 | change({ value: 'option_1' });
423 | expect(formState.current).toEqual(
424 | expect.objectContaining({ values: { select: 'option_1' } }),
425 | );
426 | });
427 |
428 | it('updates value for type "selectMultiple"', () => {
429 | const { formState, change, root: select } = renderWithFormState(
430 | ([, { selectMultiple }]) => (
431 |
436 | ),
437 | );
438 |
439 | expect(formState.current).toEqual(
440 | expect.objectContaining({ values: { select: [] } }),
441 | );
442 |
443 | select.options[0].selected = true; // selecting one options
444 | change();
445 | expect(formState.current).toEqual(
446 | expect.objectContaining({ values: { select: ['option_1'] } }),
447 | );
448 |
449 | select.options[1].selected = true; // selecting another option
450 | change();
451 | expect(formState.current).toEqual(
452 | expect.objectContaining({ values: { select: ['option_1', 'option_2'] } }),
453 | );
454 |
455 | select.options[0].selected = false; // deselecting an option
456 | change();
457 | expect(formState.current).toEqual(
458 | expect.objectContaining({ values: { select: ['option_2'] } }),
459 | );
460 | });
461 | });
462 |
463 | describe('passing an object to input type method', () => {
464 | it('calls input onChange', () => {
465 | const onChange = jest.fn();
466 | const { change } = renderWithFormState(([, { text }]) => (
467 |
468 | ));
469 | change({ value: 'test' });
470 | expect(onChange).toHaveBeenCalledWith(expect.any(Object));
471 | });
472 |
473 | it('calls input onBlur', () => {
474 | const onBlur = jest.fn();
475 | const { blur } = renderWithFormState(([, { text }]) => (
476 |
477 | ));
478 | blur();
479 | expect(onBlur).toHaveBeenCalledWith(expect.any(Object));
480 | });
481 | });
482 |
483 | describe('Input blur behavior', () => {
484 | it('marks input as touched on blur', () => {
485 | const { blur, formState } = renderWithFormState(([, { text }]) => (
486 |
487 | ));
488 | blur();
489 | expect(formState.current).toEqual(
490 | expect.objectContaining({
491 | values: { name: '' },
492 | validity: { name: true },
493 | errors: {},
494 | touched: { name: true },
495 | }),
496 | );
497 | });
498 |
499 | it('marks "raw" value as touched on change', () => {
500 | let onChange;
501 | const { formState } = renderWithFormState(([, { raw }]) => {
502 | const inputProps = raw({ name: 'value', touchOnChange: true });
503 | ({ onChange } = inputProps);
504 | return ;
505 | });
506 | onChange({ foo: 1 });
507 | expect(formState.current.values.value).toEqual({ foo: 1 });
508 | expect(formState.current.touched.value).toEqual(true);
509 | });
510 |
511 | it('marks "raw" value as touched on blur', () => {
512 | let onChange;
513 | let onBlur;
514 | const { formState } = renderWithFormState(([, { raw }]) => {
515 | const inputProps = raw({ name: 'value' });
516 | ({ onChange, onBlur } = inputProps);
517 | return ;
518 | });
519 |
520 | onChange({ foo: 1 });
521 | expect(formState.current.values.value).toEqual({ foo: 1 });
522 | expect(formState.current.touched.value).toEqual(false);
523 |
524 | onBlur();
525 | expect(formState.current.touched.value).toEqual(true);
526 | });
527 |
528 | it('marks input as invalid on blur', () => {
529 | const { blur, formState } = renderWithFormState(([, { text }]) => (
530 |
531 | ));
532 | blur();
533 | expect(formState.current).toEqual(
534 | expect.objectContaining({
535 | values: { name: '' },
536 | validity: { name: false },
537 | errors: {
538 | name: expect.any(String),
539 | },
540 | touched: { name: true },
541 | }),
542 | );
543 | });
544 | });
545 |
546 | describe('Input props are memoized', () => {
547 | it('does not cause re-render of memoized components', () => {
548 | const renderCheck = jest.fn(() => true);
549 | const MemoInput = React.memo(
550 | props => renderCheck() && ,
551 | );
552 | const { change, root } = renderWithFormState(([, { text }]) => (
553 |