on 2b8b988 help sign in
268 lines 7.7 kB history raw
1import { useCallback, useMemo } 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, clearValues] = useSetState(() =>
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, clearFocused] = 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, clearHasBeenFocused] = useSetState(
63 {}
64 );
66 // track whether or not the input has been touched
67 const [hasBeenTouched, setHasBeenTouched, clearHasBeenTouched] = useSetState(
68 {}
69 );
71 // build fn that transforms a value by input name
72 const transform = useCallback(
73 (name, value) => compose(...get(byName, [name, 'transformers'], []))(value),
74 [byName]
75 );
77 // set a value (and transform it)
78 const setValue = useCallback(
79 (name, value) => _setValues({ [name]: transform(name, value) }),
80 [_setValues, transform]
81 );
83 // reset the form
84 const reset = useCallback(() => {
85 clearFocused();
86 clearHasBeenFocused();
87 clearHasBeenTouched();
88 clearValues();
89 }, [clearFocused, clearHasBeenFocused, clearHasBeenTouched, clearValues]);
91 // build fn that validates a value by input name
92 const validate = useCallback(
93 (name, value) =>
94 compose(
95 ...get(byName, [name, 'validators'], []),
96 kDefaultValidator
97 )(value),
98 [byName]
99 );
101 const getValue = useCallback(
102 (name, e) => {
103 if (byName[name].type === 'checkbox') {
104 return e.target.checked;
105 }
107 return e.target.value;
108 },
109 [byName]
110 );
112 // on change, transform and set value
113 const onChange = useCallback(
114 name => e => {
115 setValue(name, getValue(name, e));
116 setHasBeenTouched({ [name]: true });
117 }, //
118 [setValue, getValue, setHasBeenTouched]
119 );
121 // on focus, update focus
122 const onFocus = useCallback(
123 name => e => setFocused({ [name]: true }), //
124 [setFocused]
125 );
127 // on blur, defocus and set has been focused
128 const onBlur = useCallback(
129 name => e => {
130 setFocused({ [name]: false });
131 setHasBeenFocused({ [name]: true });
132 },
133 [setFocused, setHasBeenFocused]
134 );
136 // memo-compute validations of curent values
137 const validations = useMemo(
138 () => configs.map(config => validate(config.name, values[config.name])), //
139 [configs, validate, values]
140 );
142 // memo-compute the set of (perhaps changed by validation) data
143 const datas = useMemo(() => validations.map(v => v.data), [validations]);
144 const syncPasses = useMemo(
145 () => validations.map(v => v.pass, [validations]),
146 [validations]
147 );
149 // the input has errored if it
150 // 1) did not pass validation and has an error text
151 // 2) or has a specific error from parent state
152 // and then we store that in errorTexts[i]
153 // <string | falsy>[]
154 const errorTexts = useMemo(
155 () =>
156 configs.map((config, i) => validations[i].error || config.error || false),
157 [configs, validations]
158 );
160 // we should hint at an error if it
161 // 1) has been focused before
162 // 2) and has an error
163 // boolean[]
164 const hintErrors = useMemo(
165 () =>
166 configs.map(
167 (config, i) => hasBeenFocused[config.name] && !!errorTexts[i]
168 ),
169 [configs, errorTexts, hasBeenFocused]
170 );
172 // we should block on error (with potential reflow) if it
173 // 1) has been touched
174 // 2) and has an error
175 // consumers should use this value to show error text and block on operations
176 // <string | falsy>[]
177 const errors = useMemo(
178 () =>
179 configs.map((config, i) => hasBeenTouched[config.name] && errorTexts[i]),
180 [configs, errorTexts, hasBeenTouched]
181 );
183 // this input has passed if
184 // 1) it has passed local validation
185 // 2) there are no errors
186 const passes = useMemo(
187 () => validations.map((v, i) => v.pass && !errorTexts[i]),
188 [validations, errorTexts]
189 );
191 // visibly tell the user that their input has passed if
192 // 1) they are or have interacted with the input before
193 // 2) and it has passed as defined above
194 const visiblePasses = useMemo(
195 () =>
196 configs.map(
197 (config, i) =>
198 (focused[config.name] || hasBeenFocused[config.name]) && //
199 passes[i]
200 ),
201 [configs, hasBeenFocused, focused, passes]
202 );
204 // generate the list of input states
205 const inputs = useMemo(
206 () =>
207 configs.map(
208 ({ name, error, autoFocus, disabled, initialValue, ...rest }, i) => {
209 const value =
210 datas[i] !== undefined
211 ? datas[i]
212 : initialValue !== undefined
213 ? initialValue
214 : '';
215 return {
216 // Input props
217 name,
218 value: value,
219 data: passes[i] ? datas[i] : undefined,
220 pass: passes[i],
221 syncPass: syncPasses[i],
222 visiblyPassed: visiblePasses[i],
223 error: errors[i],
224 hintError: hintErrors[i],
225 focused: focuses[i],
226 autoFocus: autoFocus && !disabled,
227 disabled,
228 ...rest,
229 // DOM Properties for <input />
230 bind: {
231 value,
232 checked: !!datas[i],
233 onChange: onChange(name),
234 onFocus: onFocus(name),
235 onBlur: onBlur(name),
236 autoFocus: autoFocus && !disabled,
237 },
238 };
239 }
240 ),
241 [
242 configs,
243 datas,
244 passes,
245 syncPasses,
246 visiblePasses,
247 errors,
248 hintErrors,
249 focuses,
250 onChange,
251 onFocus,
252 onBlur,
253 ]
254 );
256 // did all of the inputs pass validation?
257 const pass = useMemo(() => every(passes), [passes]);
258 // did any of the inputs error?
259 const error = useMemo(() => some(errors), [errors]);
261 return {
262 inputs,
263 pass,
264 error,
265 setValue,
266 reset,
267 };