on 827d862 help sign in
246 lines 7.1 kB history raw
1import { useCallback, useMemo, useState } from 'react';
2import { isEqual, keyBy, get, every, some } from 'lodash';
4import { compose } from 'lib/lib';
5import { kDefaultValidator } from 'lib/validators';
6import useSetState from 'lib/useSetState';
7import usePreviousValue from 'lib/usePreviousValue';
9// interface InputConfig {
10// name: string;
11// validators: Function[];
12// transformers: Function[];
13// initialValue: string;
14// autoFocus: boolean;
15// disabled: boolean;
16// ...extra,
17// }
19const defaultsFor = (configs, mapper) =>
20 configs.reduce(
21 (memo, config) => ({ ...memo, [config.name]: mapper(config) }),
22 {}
23 );
25/**
26 * useForm manages a set of inputs for rendering them in a loop
27 */
28export 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]);
37 const byName = useMemo(() => keyBy(configs, 'name'), [configs]);
39 // track values
40 const [values, _setValues] = useState(() =>
41 defaultsFor(configs, config =>
42 config.initialValue === undefined ? '' : config.initialValue
43 )
44 );
45 // ^ NB(shrugs): because we're not syncing the initialValue of _additional_
46 // configs that are added, we won't be able to set an initialValue for a
47 // dynamic form that then triggers validation. for initial configs,
48 // initialValue can be a validated string and everything will be happy,
49 // but for dynamically added input configs, we won't get validation, etc
50 // until there's a traditional state update.
52 // track focused states
53 const [focused, setFocused] = useSetState(() =>
54 defaultsFor(configs, config => config.autoFocus && !config.disabled)
55 );
56 const focuses = useMemo(
57 () => configs.map(config => focused[config.name] || false), //
58 [configs, focused]
59 );
61 // track whether or not input has been focused
62 const [hasBeenFocused, setHasBeenFocused] = useSetState({});
64 // track whether or not the input has been touched
65 const [hasBeenTouched, setHasBeenTouched] = useSetState({});
67 // build fn that transforms a value by input name
68 const transform = useCallback(
69 (name, value) => compose(...get(byName, [name, 'transformers'], []))(value),
70 [byName]
71 );
73 // set a value (and transform it)
74 const setValue = useCallback(
75 (name, value) =>
76 _setValues(values => ({ ...values, [name]: transform(name, value) })),
77 [_setValues, transform]
78 );
80 // build fn that validates a value by input name
81 const validate = useCallback(
82 (name, value) =>
83 compose(
84 ...get(byName, [name, 'validators'], []),
85 kDefaultValidator
86 )(value),
87 [byName]
88 );
90 const getValue = useCallback(
91 (name, e) => {
92 if (byName[name].type === 'checkbox') {
93 return e.target.checked;
94 }
96 return e.target.value;
97 },
98 [byName]
99 );
101 // on change, transform and set value
102 const onChange = useCallback(
103 name => e => {
104 setValue(name, getValue(name, e));
105 setHasBeenTouched({ [name]: true });
106 }, //
107 [setValue, getValue, setHasBeenTouched]
108 );
110 // on focus, update focus
111 const onFocus = useCallback(
112 name => e => setFocused({ [name]: true }), //
113 [setFocused]
114 );
116 // on blur, defocus and set has been focused
117 const onBlur = useCallback(
118 name => e => {
119 setFocused({ [name]: false });
120 setHasBeenFocused({ [name]: true });
121 },
122 [setFocused, setHasBeenFocused]
123 );
125 // memo-compute validations of curent values
126 const validations = useMemo(
127 () => configs.map(config => validate(config.name, values[config.name])), //
128 [configs, validate, values]
129 );
131 // memo-compute the set of (perhaps changed by validation) data
132 const datas = useMemo(() => validations.map(v => v.data), [validations]);
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(
140 () =>
141 configs.map((config, i) => validations[i].error || config.error || false),
142 [configs, validations]
143 );
145 // we should hint at an error if it
146 // 1) has been focused before
147 // 2) and has an error
148 // boolean[]
149 const hintErrors = useMemo(
150 () =>
151 configs.map(
152 (config, i) => hasBeenFocused[config.name] && !!errorTexts[i]
153 ),
154 [configs, errorTexts, hasBeenFocused]
155 );
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(
163 () =>
164 configs.map((config, i) => hasBeenTouched[config.name] && errorTexts[i]),
165 [configs, errorTexts, hasBeenTouched]
166 );
168 // this input has passed if
169 // 1) it has passed local validation
170 // 2) there are no errors
171 const passes = useMemo(
172 () => validations.map((v, i) => v.pass && !errorTexts[i]),
173 [validations, errorTexts]
174 );
176 // visibly tell the user that their input has passed if
177 // 1) they are or have interacted with the input before
178 // 2) and it has passed as defined above
179 const visiblePasses = useMemo(
180 () =>
181 configs.map(
182 (config, i) =>
183 (focused[config.name] || hasBeenFocused[config.name]) && //
184 passes[i]
185 ),
186 [configs, hasBeenFocused, focused, passes]
187 );
189 // generate the list of input states
190 const inputs = useMemo(
191 () =>
192 configs.map(
193 ({ name, error, autoFocus, disabled, initialValue, ...rest }, i) => {
194 const value =
195 values[name] || (initialValue === undefined ? '' : initialValue);
196 return {
197 // Input props
198 name,
199 data: datas[i],
200 pass: passes[i],
201 visiblyPassed: visiblePasses[i],
202 error: errors[i],
203 hintError: hintErrors[i],
204 focused: focuses[i],
205 autoFocus: autoFocus && !disabled,
206 disabled,
207 ...rest,
208 // dom properties below:
209 bind: {
210 value,
211 checked: !!values[name],
212 onChange: onChange(name),
213 onFocus: onFocus(name),
214 onBlur: onBlur(name),
215 autoFocus: autoFocus && !disabled,
216 },
217 };
218 }
219 ),
220 [
221 configs,
222 datas,
223 visiblePasses,
224 passes,
225 errors,
226 hintErrors,
227 focuses,
228 values,
229 onBlur,
230 onChange,
231 onFocus,
232 ]
233 );
235 // did all of the inputs pass validation?
236 const pass = useMemo(() => every(passes), [passes]);
237 // did any of the inputs error?
238 const error = useMemo(() => some(errors), [errors]);
240 return {
241 inputs,
242 pass,
243 error,
244 setValue,
245 };