on f4fd597 help sign in
264 lines 7.4 kB history raw
1import { useCallback, useMemo } from 'react';
2import { keyBy, get, every, some } from 'lodash';
4import { compose } from 'lib/lib';
5import { kDefaultValidator } from 'lib/validators';
6import useSetState from 'lib/useSetState';
7import useDeepEqualReference from 'lib/useDeepEqualReference';
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 const configs = useDeepEqualReference(inputConfigs);
31 const byName = useMemo(() => keyBy(configs, 'name'), [configs]);
33 // track values
34 const [values, _setValues, clearValues] = useSetState(() =>
35 defaultsFor(configs, config =>
36 config.initialValue === undefined ? '' : config.initialValue
37 )
38 );
39 // ^ NB(shrugs): because we're not syncing the initialValue of _additional_
40 // configs that are added, we won't be able to set an initialValue for a
41 // dynamic form that then triggers validation. for initial configs,
42 // initialValue can be a validated string and everything will be happy,
43 // but for dynamically added input configs, we won't get validation, etc
44 // until there's a traditional state update.
46 // track focused states
47 const [focused, setFocused, clearFocused] = useSetState(() =>
48 defaultsFor(configs, config => config.autoFocus && !config.disabled)
49 );
50 const focuses = useMemo(
51 () => configs.map(config => focused[config.name] || false), //
52 [configs, focused]
53 );
55 // track whether or not input has been focused
56 const [hasBeenFocused, setHasBeenFocused, clearHasBeenFocused] = useSetState(
57 {}
58 );
60 // track whether or not the input has been touched
61 const [hasBeenTouched, setHasBeenTouched, clearHasBeenTouched] = useSetState(
62 {}
63 );
65 // build fn that transforms a value by input name
66 const transform = useCallback(
67 (name, value) => compose(...get(byName, [name, 'transformers'], []))(value),
68 [byName]
69 );
71 // set a value (and transform it)
72 const setValue = useCallback(
73 (name, value) => _setValues({ [name]: transform(name, value) }),
74 [_setValues, transform]
75 );
77 // reset the form
78 const reset = useCallback(() => {
79 clearFocused();
80 clearHasBeenFocused();
81 clearHasBeenTouched();
82 clearValues();
83 }, [clearFocused, clearHasBeenFocused, clearHasBeenTouched, clearValues]);
85 // build fn that validates a value by input name
86 const validate = useCallback(
87 (name, value) =>
88 compose(
89 ...get(byName, [name, 'validators'], []),
90 kDefaultValidator
91 )(value),
92 [byName]
93 );
95 const getValue = useCallback(
96 (name, e) => {
97 if (byName[name].type === 'checkbox') {
98 return e.target.checked;
99 }
101 return e.target.value;
102 },
103 [byName]
104 );
106 // on change, transform and set value
107 const onChange = useCallback(
108 name => e => {
109 setValue(name, getValue(name, e));
110 setHasBeenTouched({ [name]: true });
111 }, //
112 [setValue, getValue, setHasBeenTouched]
113 );
115 // on focus, update focus
116 const onFocus = useCallback(
117 name => e => setFocused({ [name]: true }), //
118 [setFocused]
119 );
121 // on blur, defocus and set has been focused
122 const onBlur = useCallback(
123 name => e => {
124 setFocused({ [name]: false });
125 setHasBeenFocused({ [name]: true });
126 },
127 [setFocused, setHasBeenFocused]
128 );
130 // memo-compute validations of curent values
131 const validations = useMemo(
132 () => configs.map(config => validate(config.name, values[config.name])), //
133 [configs, validate, values]
134 );
136 // memo-compute the set of (perhaps changed by validation) data
137 const datas = useMemo(() => validations.map(v => v.data), [validations]);
138 const syncPasses = useMemo(
139 () => validations.map(v => v.pass, [validations]),
140 [validations]
141 );
143 // the input has errored if it
144 // 1) did not pass validation and has an error text
145 // 2) or has a specific error from parent state
146 // and then we store that in errorTexts[i]
147 // <string | falsy>[]
148 const errorTexts = useMemo(
149 () =>
150 configs.map((config, i) => validations[i].error || config.error || false),
151 [configs, validations]
152 );
154 // we should hint at an error if it
155 // 1) has been focused before
156 // 2) and has an error
157 // boolean[]
158 const hintErrors = useMemo(
159 () =>
160 configs.map(
161 (config, i) => hasBeenFocused[config.name] && !!errorTexts[i]
162 ),
163 [configs, errorTexts, hasBeenFocused]
164 );
166 // we should block on error (with potential reflow) if it
167 // 1) has been touched
168 // 2) and has an error
169 // consumers should use this value to show error text and block on operations
170 // <string | falsy>[]
171 const errors = useMemo(
172 () =>
173 configs.map((config, i) => hasBeenTouched[config.name] && errorTexts[i]),
174 [configs, errorTexts, hasBeenTouched]
175 );
177 // this input has passed if
178 // 1) it has passed local validation
179 // 2) there are no errors
180 const passes = useMemo(
181 () => validations.map((v, i) => v.pass && !errorTexts[i]),
182 [validations, errorTexts]
183 );
185 // visibly tell the user that their input has passed if
186 // 1) they are or have interacted with the input before
187 // 2) and it has passed as defined above
188 const visiblePasses = useMemo(
189 () =>
190 configs.map(
191 (config, i) =>
192 (focused[config.name] || hasBeenFocused[config.name]) && //
193 passes[i]
194 ),
195 [configs, hasBeenFocused, focused, passes]
196 );
198 // generate the list of input states
199 const inputs = useMemo(
200 () =>
201 configs.map(
202 ({ name, error, autoFocus, disabled, initialValue, ...rest }, i) => {
203 const value =
204 datas[i] !== undefined
205 ? datas[i]
206 : initialValue !== undefined
207 ? initialValue
208 : '';
209 return {
210 // Input props
211 name,
212 value: value,
213 data: passes[i] ? datas[i] : undefined,
214 pass: passes[i],
215 syncPass: syncPasses[i],
216 visiblyPassed: visiblePasses[i],
217 error: errors[i],
218 hintError: hintErrors[i],
219 focused: focuses[i],
220 touched: hasBeenTouched[name],
221 autoFocus: autoFocus && !disabled,
222 disabled,
223 ...rest,
224 // DOM Properties for <input />
225 bind: {
226 value,
227 checked: !!datas[i],
228 onChange: onChange(name),
229 onFocus: onFocus(name),
230 onBlur: onBlur(name),
231 autoFocus: autoFocus && !disabled,
232 },
233 };
234 }
235 ),
236 [
237 configs,
238 datas,
239 passes,
240 syncPasses,
241 visiblePasses,
242 errors,
243 hintErrors,
244 focuses,
245 hasBeenTouched,
246 onChange,
247 onFocus,
248 onBlur,
249 ]
250 );
252 // did all of the inputs pass validation?
253 const pass = useMemo(() => every(passes), [passes]);
254 // did any of the inputs error?
255 const error = useMemo(() => some(errors), [errors]);
257 return {
258 inputs,
259 pass,
260 error,
261 setValue,
262 reset,
263 };