@@ -1,9 +1,10 @@
1import { useCallback, useMemo, useState } from 'react';
1import { useCallback, useMemo, useState } from 'react';
2−import { keyBy, get, every, some } from 'lodash';
2+import { isEqual, keyBy, get, every, some } from 'lodash';
4import { compose } from 'lib/lib';
4import { compose } from 'lib/lib';
5import { kDefaultValidator } from 'lib/validators';
5import { kDefaultValidator } from 'lib/validators';
6import useSetState from 'lib/useSetState';
6import useSetState from 'lib/useSetState';
7+import usePreviousValue from 'lib/usePreviousValue';
8// interface InputConfig {
9// interface InputConfig {
9// name: string;
10// name: string;
@@ -24,12 +25,22 @@ const defaultsFor = (configs, mapper) =>
25 * useForm manages a set of inputs for rendering them in a loop
26 * useForm manages a set of inputs for rendering them in a loop
27−export default function useForm(configs = []) {
28+export default function useForm(inputConfigs = []) {
29+ // track the old value of the config set
30+ const previousConfigs = usePreviousValue(inputConfigs);
31+ // then compare equality
32+ const configsAreEqual = isEqual(inputConfigs, previousConfigs);
33+ // if equality changes, give `configs` a new identity
34+ // eslint-disable-next-line react-hooks/exhaustive-deps
35+ const configs = useMemo(() => inputConfigs, [configsAreEqual]);
28 const byName = useMemo(() => keyBy(configs, 'name'), [configs]);
37 const byName = useMemo(() => keyBy(configs, 'name'), [configs]);
30 // track values
39 // track values
31 const [values, _setValues] = useState(() =>
40 const [values, _setValues] = useState(() =>
32− defaultsFor(configs, config => config.initialValue || '')
41+ defaultsFor(configs, config =>
42+ config.initialValue === undefined ? '' : config.initialValue
34 // ^ NB(shrugs): because we're not syncing the initialValue of _additional_
45 // ^ NB(shrugs): because we're not syncing the initialValue of _additional_
35 // configs that are added, we won't be able to set an initialValue for a
46 // configs that are added, we won't be able to set an initialValue for a
@@ -38,11 +49,6 @@ export default function useForm(configs = []) {
38 // but for dynamically added input configs, we won't get validation, etc
49 // but for dynamically added input configs, we won't get validation, etc
39 // until there's a traditional state update.
50 // until there's a traditional state update.
41− const setValue = useCallback(
42− (name, value) => _setValues(values => ({ ...values, [name]: value })),
46 // track focused states
52 // track focused states
47 const [focused, setFocused] = useSetState(() =>
53 const [focused, setFocused] = useSetState(() =>
48 defaultsFor(configs, config => config.autoFocus && !config.disabled)
54 defaultsFor(configs, config => config.autoFocus && !config.disabled)
@@ -55,12 +61,22 @@ export default function useForm(configs = []) {
55 // track whether or not input has been focused
61 // track whether or not input has been focused
56 const [hasBeenFocused, setHasBeenFocused] = useSetState({});
62 const [hasBeenFocused, setHasBeenFocused] = useSetState({});
64+ // track whether or not the input has been touched
65+ const [hasBeenTouched, setHasBeenTouched] = useSetState({});
58 // build fn that transforms a value by input name
67 // build fn that transforms a value by input name
59 const transform = useCallback(
68 const transform = useCallback(
60 (name, value) => compose(...get(byName, [name, 'transformers'], []))(value),
69 (name, value) => compose(...get(byName, [name, 'transformers'], []))(value),
73+ // set a value (and transform it)
74+ const setValue = useCallback(
76+ _setValues(values => ({ ...values, [name]: transform(name, value) })),
77+ [_setValues, transform]
64 // build fn that validates a value by input name
80 // build fn that validates a value by input name
65 const validate = useCallback(
81 const validate = useCallback(
66 (name, value) =>
82 (name, value) =>
@@ -84,8 +100,11 @@ export default function useForm(configs = []) {
85 // on change, transform and set value
101 // on change, transform and set value
86 const onChange = useCallback(
102 const onChange = useCallback(
87− name => e => setValue(name, transform(name, getValue(name, e))), //
104+ setValue(name, getValue(name, e));
88− [setValue, transform, getValue]
105+ setHasBeenTouched({ [name]: true });
107+ [setValue, getValue, setHasBeenTouched]
91 // on focus, update focus
110 // on focus, update focus
@@ -113,27 +132,45 @@ export default function useForm(configs = []) {
113 const datas = useMemo(() => validations.map(v => v.data), [validations]);
132 const datas = useMemo(() => validations.map(v => v.data), [validations]);
115 // the input has errored if it
134 // the input has errored if it
135+ // 1) did not pass validation and has an error text
136+ // 2) or has a specific error from parent state
137+ // and then we store that in errorTexts[i]
138+ // <string | falsy>[]
139+ const errorTexts = useMemo(
141+ configs.map((config, i) => validations[i].error || config.error || false),
142+ [configs, validations]
145+ // we should hint at an error if it
116 // 1) has been focused before
146 // 1) has been focused before
118− // a) did not pass validation and has an error text
147+ // 2) and has an error
119− // b) or has a specific error from parent state
148+ // boolean[]
120− // (and we implicitly return the error text at the end)
121− const errors = useMemo(
149+ const hintErrors = useMemo(
123 configs.map(
151 configs.map(
125− hasBeenFocused[config.name] && //
152+ (config, i) => hasBeenFocused[config.name] && !!errorTexts[i]
126− (validations[i].error || config.error)
128− [configs, validations, hasBeenFocused]
154+ [configs, errorTexts, hasBeenFocused]
157+ // we should block on error (with potential reflow) if it
158+ // 1) has been touched
159+ // 2) and has an error
160+ // consumers should use this value to show error text and block on operations
161+ // <string | falsy>[]
162+ const errors = useMemo(
164+ configs.map((config, i) => hasBeenTouched[config.name] && errorTexts[i]),
165+ [configs, errorTexts, hasBeenTouched]
131 // this input has passed if
168 // this input has passed if
132 // 1) it has passed local validation
169 // 1) it has passed local validation
133 // 2) there are no errors
170 // 2) there are no errors
134 const passes = useMemo(
171 const passes = useMemo(
135− () => validations.map((v, i) => v.pass && !errors[i]),
172+ () => validations.map((v, i) => v.pass && !errorTexts[i]),
136− [validations, errors]
173+ [validations, errorTexts]
139 // visibly tell the user that their input has passed if
176 // visibly tell the user that their input has passed if
@@ -153,27 +190,32 @@ export default function useForm(configs = []) {
153 const inputs = useMemo(
190 const inputs = useMemo(
155 configs.map(
192 configs.map(
156− ({ name, error, autoFocus, disabled, initialValue, ...rest }, i) => ({
193+ ({ name, error, autoFocus, disabled, initialValue, ...rest }, i) => {
195+ values[name] || (initialValue === undefined ? '' : initialValue);
157− // Input props
197+ // Input props
159− data: datas[i],
199+ data: datas[i],
160− pass: passes[i],
200+ pass: passes[i],
161− visiblyPassed: visiblePasses[i],
201+ visiblyPassed: visiblePasses[i],
162− error: errors[i],
202+ error: errors[i],
203+ hintError: hintErrors[i],
163− focused: focuses[i],
204+ focused: focuses[i],
164− autoFocus: autoFocus && !disabled,
167− // dom properties below:
169− value: values[name] || initialValue || '',
170− checked: !!values[name],
171− onChange: onChange(name),
172− onFocus: onFocus(name),
173− onBlur: onBlur(name),
174 autoFocus: autoFocus && !disabled,
205 autoFocus: autoFocus && !disabled,
208+ // dom properties below:
211+ checked: !!values[name],
212+ onChange: onChange(name),
213+ onFocus: onFocus(name),
214+ onBlur: onBlur(name),
215+ autoFocus: autoFocus && !disabled,
@@ -181,6 +223,7 @@ export default function useForm(configs = []) {
181 visiblePasses,
223 visiblePasses,
@@ -198,5 +241,6 @@ export default function useForm(configs = []) {