on 82b5914 help sign in
220 lines 6.3 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 // build fn that transforms a value by input name
65 const transform = useCallback(
66 (name, value) => compose(...get(byName, [name, 'transformers'], []))(value),
67 [byName]
68 );
70 // set a value (and transform it)
71 const setValue = useCallback(
72 (name, value) =>
73 _setValues(values => ({ ...values, [name]: transform(name, value) })),
74 [_setValues, transform]
75 );
77 // build fn that validates a value by input name
78 const validate = useCallback(
79 (name, value) =>
80 compose(
81 ...get(byName, [name, 'validators'], []),
82 kDefaultValidator
83 )(value),
84 [byName]
85 );
87 const getValue = useCallback(
88 (name, e) => {
89 if (byName[name].type === 'checkbox') {
90 return e.target.checked;
91 }
93 return e.target.value;
94 },
95 [byName]
96 );
98 // on change, transform and set value
99 const onChange = useCallback(
100 name => e => setValue(name, getValue(name, e)), //
101 [setValue, getValue]
102 );
104 // on focus, update focus
105 const onFocus = useCallback(
106 name => e => setFocused({ [name]: true }), //
107 [setFocused]
108 );
110 // on blur, defocus and set has been focused
111 const onBlur = useCallback(
112 name => e => {
113 setFocused({ [name]: false });
114 setHasBeenFocused({ [name]: true });
115 },
116 [setFocused, setHasBeenFocused]
117 );
119 // memo-compute validations of curent values
120 const validations = useMemo(
121 () => configs.map(config => validate(config.name, values[config.name])), //
122 [configs, validate, values]
123 );
125 // memo-compute the set of (perhaps changed by validation) data
126 const datas = useMemo(() => validations.map(v => v.data), [validations]);
128 // the input has errored if it
129 // 1) has been focused before
130 // 2)
131 // a) did not pass validation and has an error text
132 // b) or has a specific error from parent state
133 // (and we implicitly return the error text at the end)
134 const errors = useMemo(
135 () =>
136 configs.map(
137 (config, i) =>
138 hasBeenFocused[config.name] && //
139 (validations[i].error || config.error)
140 ),
141 [configs, validations, hasBeenFocused]
142 );
144 // this input has passed if
145 // 1) it has passed local validation
146 // 2) there are no errors
147 const passes = useMemo(
148 () => validations.map((v, i) => v.pass && !errors[i]),
149 [validations, errors]
150 );
152 // visibly tell the user that their input has passed if
153 // 1) they are or have interacted with the input before
154 // 2) and it has passed as defined above
155 const visiblePasses = useMemo(
156 () =>
157 configs.map(
158 (config, i) =>
159 (focused[config.name] || hasBeenFocused[config.name]) && //
160 passes[i]
161 ),
162 [configs, hasBeenFocused, focused, passes]
163 );
165 // generate the list of input states
166 const inputs = useMemo(
167 () =>
168 configs.map(
169 ({ name, error, autoFocus, disabled, initialValue, ...rest }, i) => {
170 const value =
171 values[name] || (initialValue === undefined ? '' : initialValue);
172 return {
173 // Input props
174 name,
175 data: datas[i],
176 pass: passes[i],
177 visiblyPassed: visiblePasses[i],
178 error: errors[i],
179 focused: focuses[i],
180 autoFocus: autoFocus && !disabled,
181 disabled,
182 ...rest,
183 // dom properties below:
184 bind: {
185 value,
186 checked: !!values[name],
187 onChange: onChange(name),
188 onFocus: onFocus(name),
189 onBlur: onBlur(name),
190 autoFocus: autoFocus && !disabled,
191 },
192 };
193 }
194 ),
195 [
196 configs,
197 datas,
198 visiblePasses,
199 passes,
200 errors,
201 focuses,
202 values,
203 onBlur,
204 onChange,
205 onFocus,
206 ]
207 );
209 // did all of the inputs pass validation?
210 const pass = useMemo(() => every(passes), [passes]);
211 // did any of the inputs error?
212 const error = useMemo(() => some(errors), [errors]);
214 return {
215 inputs,
216 pass,
217 error,
218 setValue,
219 };