UNPKG

11.5 kBPlain TextView Raw
1import * as React from "react";
2import { ArrayValue } from "reakit-utils/types";
3import { useUpdateEffect } from "reakit-utils/useUpdateEffect";
4import { isPromise } from "reakit-utils/isPromise";
5import {
6 SealedInitialState,
7 useSealedState,
8} from "reakit-utils/useSealedState";
9import { isEmpty } from "reakit-utils/isEmpty";
10import { useLiveRef } from "reakit-utils/useLiveRef";
11import {
12 unstable_IdState,
13 unstable_IdActions,
14 unstable_IdInitialState,
15 unstable_useIdState,
16} from "../Id/IdState";
17import { DeepPartial, DeepMap, DeepPath, DeepPathValue } from "./__utils/types";
18import { filterAllEmpty } from "./__utils/filterAllEmpty";
19import { hasMessages } from "./__utils/hasMessages";
20import { unstable_setAllIn } from "./utils/setAllIn";
21import { unstable_getIn } from "./utils/getIn";
22import { unstable_setIn } from "./utils/setIn";
23
24type Messages<V> = DeepPartial<DeepMap<V, string | null | void>>;
25
26type ValidateOutput<V> = Messages<V> | null | void;
27type ValidateReturn<V> = Promise<ValidateOutput<V>> | ValidateOutput<V>;
28
29interface Update<V> {
30 <P extends DeepPath<V, P>>(name: P, value: DeepPathValue<V, P>): void;
31 <P extends DeepPath<V, P>>(
32 name: P,
33 value: (value: DeepPathValue<V, P>) => DeepPathValue<V, P>
34 ): void;
35}
36
37export type unstable_FormState<V> = unstable_IdState & {
38 /**
39 * Form values.
40 */
41 values: V;
42 /**
43 * An object with the same shape as `form.values` with `boolean` values.
44 * This keeps the touched state of each field. That is, whether a field has
45 * been blurred.
46 */
47 touched: DeepPartial<DeepMap<V, boolean>>;
48 /**
49 * An object with the same shape as `form.values` with string messages.
50 * This stores the messages returned by `onValidate` and `onSubmit`.
51 */
52 messages: Messages<V>;
53 /**
54 * An object with the same shape as `form.values` with string error messages.
55 * This stores the error messages throwed by `onValidate` and `onSubmit`.
56 */
57 errors: Messages<V>;
58 /**
59 * Whether form is validating or not.
60 */
61 validating: boolean;
62 /**
63 * Whether `form.errors` is empty or not.
64 */
65 valid: boolean;
66 /**
67 * Whether form is submitting or not.
68 */
69 submitting: boolean;
70 /**
71 * Stores the number of times that the form has been successfully submitted.
72 */
73 submitSucceed: number;
74 /**
75 * Stores the number of times that the form submission has failed.
76 */
77 submitFailed: number;
78};
79
80export type unstable_FormActions<V> = unstable_IdActions & {
81 /**
82 * Resets the form state.
83 */
84 reset: () => void;
85 /**
86 * Triggers form validation (calling `onValidate` underneath).
87 * Optionally, new `values` can be passed in.
88 */
89 validate: (values?: V) => ValidateReturn<V>;
90 /**
91 * Triggers form submission (calling `onValidate` and `onSubmit` underneath).
92 */
93 submit: () => void;
94 /**
95 * Updates a form value.
96 */
97 update: Update<V>;
98 /**
99 * Sets field's touched state to `true`.
100 */
101 blur: <P extends DeepPath<V, P>>(name: P) => void;
102 /**
103 * Pushes a new item into `form.values[name]`, which should be an array.
104 */
105 push: <P extends DeepPath<V, P>>(
106 name: P,
107 value?: ArrayValue<DeepPathValue<V, P>>
108 ) => void;
109 /**
110 * Removes `form.values[name][index]`.
111 */
112 remove: <P extends DeepPath<V, P>>(name: P, index: number) => void;
113};
114
115export type unstable_FormInitialState<V> = unstable_IdInitialState &
116 Partial<Pick<unstable_FormState<V>, "values">> & {
117 /**
118 * Whether the form should trigger `onValidate` on blur.
119 */
120 validateOnBlur?: boolean;
121 /**
122 * Whether the form should trigger `onValidate` on change.
123 */
124 validateOnChange?: boolean;
125 /**
126 * Whether the form should reset when it has been successfully submitted.
127 */
128 resetOnSubmitSucceed?: boolean;
129 /**
130 * Whether the form should reset when the component (which called
131 * `useFormState`) has been unmounted.
132 */
133 resetOnUnmount?: boolean;
134 /**
135 * A function that receives `form.values` and return or throw messages.
136 * If it returns, messages will be interpreted as successful messages.
137 * If it throws, they will be interpreted as errors.
138 * It can also return a promise for asynchronous validation.
139 */
140 onValidate?: (values: V) => ValidateReturn<V>;
141 /**
142 * A function that receives `form.values` and performs form submission.
143 * If it's triggered by `form.submit()`, `onValidate` will be called before.
144 * If `onValidate` throws, `onSubmit` will not be called.
145 * `onSubmit` can also return promises, messages and throw error messages
146 * just like `onValidate`. The only difference is that this validation will
147 * only occur on submit.
148 */
149 onSubmit?: (values: V) => ValidateReturn<V>;
150 };
151
152export type unstable_FormStateReturn<V> = unstable_FormState<V> &
153 unstable_FormActions<V>;
154
155type ReducerState<V> = Omit<unstable_FormState<V>, keyof unstable_IdState> & {
156 initialValues: V;
157};
158
159type ReducerAction =
160 | { type: "reset" }
161 | { type: "startValidate" }
162 | { type: "endValidate"; errors?: any; messages?: any }
163 | { type: "startSubmit" }
164 | { type: "endSubmit"; errors?: any; messages?: any }
165 | { type: "update"; name: any; value: any }
166 | { type: "blur"; name: any }
167 | { type: "push"; name: any; value: any }
168 | { type: "remove"; name: any; index: number };
169
170function getMessages<V>(
171 stateMessages: Messages<V>,
172 actionMessages: Messages<V>
173) {
174 return !isEmpty(actionMessages)
175 ? actionMessages
176 : isEmpty(stateMessages)
177 ? stateMessages
178 : {};
179}
180
181function reducer<V>(
182 state: ReducerState<V>,
183 action: ReducerAction
184): ReducerState<V> {
185 switch (action.type) {
186 case "reset": {
187 return {
188 ...state,
189 values: state.initialValues,
190 touched: {},
191 errors: {},
192 messages: {},
193 valid: true,
194 validating: false,
195 submitting: false,
196 submitFailed: 0,
197 submitSucceed: 0,
198 };
199 }
200 case "startValidate": {
201 return {
202 ...state,
203 validating: true,
204 };
205 }
206 case "endValidate": {
207 return {
208 ...state,
209 validating: false,
210 errors: getMessages(state.errors, action.errors),
211 messages: getMessages(state.messages, action.messages),
212 valid: !hasMessages(action.errors),
213 };
214 }
215 case "startSubmit": {
216 return {
217 ...state,
218 // @ts-ignore TS bug
219 touched: unstable_setAllIn(state.values, true),
220 submitting: true,
221 };
222 }
223 case "endSubmit": {
224 const valid = !hasMessages(action.errors);
225 return {
226 ...state,
227 valid,
228 submitting: false,
229 errors: getMessages(state.errors, action.errors),
230 messages: getMessages(state.messages, action.messages),
231 submitSucceed: valid ? state.submitSucceed + 1 : state.submitSucceed,
232 submitFailed: valid ? state.submitFailed : state.submitFailed + 1,
233 };
234 }
235 case "update": {
236 const { name, value } = action;
237 const nextValue =
238 typeof value === "function"
239 ? value(unstable_getIn(state.values, name))
240 : value;
241 return {
242 ...state,
243 values: unstable_setIn(
244 state.values,
245 name,
246 nextValue != null ? nextValue : ""
247 ),
248 };
249 }
250 case "blur": {
251 return {
252 ...state,
253 touched: unstable_setIn(state.touched, action.name, true),
254 };
255 }
256 case "push": {
257 const array = unstable_getIn(state.values, action.name, []);
258 return {
259 ...state,
260 values: unstable_setIn(state.values, action.name, [
261 ...array,
262 action.value,
263 ]),
264 };
265 }
266 case "remove": {
267 const array = unstable_getIn(state.values, action.name, []).slice();
268 delete array[action.index];
269 return {
270 ...state,
271 values: unstable_setIn(state.values, action.name, array),
272 };
273 }
274 default: {
275 throw new Error();
276 }
277 }
278}
279
280export function unstable_useFormState<V = Record<any, any>>(
281 initialState: SealedInitialState<unstable_FormInitialState<V>> = {}
282): unstable_FormStateReturn<V> {
283 const {
284 values: initialValues = {} as V,
285 validateOnBlur = true,
286 validateOnChange = true,
287 resetOnSubmitSucceed = false,
288 resetOnUnmount = true,
289 onValidate,
290 onSubmit,
291 ...sealed
292 } = useSealedState(initialState);
293 const onValidateRef = useLiveRef(
294 typeof initialState !== "function" ? initialState.onValidate : onValidate
295 );
296 const onSubmitRef = useLiveRef(
297 typeof initialState !== "function" ? initialState.onSubmit : onSubmit
298 );
299
300 const id = unstable_useIdState(sealed);
301
302 const [{ initialValues: _, ...state }, dispatch] = React.useReducer(reducer, {
303 initialValues,
304 values: initialValues,
305 touched: {},
306 errors: {},
307 messages: {},
308 valid: true,
309 validating: false,
310 submitting: false,
311 submitFailed: 0,
312 submitSucceed: 0,
313 });
314
315 const validate = React.useCallback(
316 (vals = state.values) =>
317 new Promise<any>((resolve) => {
318 if (onValidateRef.current) {
319 const response = onValidateRef.current(vals);
320 if (isPromise(response)) {
321 dispatch({ type: "startValidate" });
322 }
323
324 resolve(
325 Promise.resolve(response).then((messages) => {
326 dispatch({ type: "endValidate", messages });
327 return messages;
328 })
329 );
330 } else {
331 resolve(undefined);
332 }
333 }).catch((errors) => {
334 dispatch({ type: "endValidate", errors });
335 throw errors;
336 }),
337 [state.values]
338 );
339
340 useUpdateEffect(() => {
341 if (validateOnChange) {
342 validate().catch(() => {});
343 }
344 }, [validate, validateOnChange]);
345
346 React.useEffect(() => {
347 if (resetOnUnmount) {
348 return () => {
349 dispatch({ type: "reset" });
350 };
351 }
352 return undefined;
353 }, [resetOnUnmount]);
354
355 return {
356 ...id,
357 ...state,
358 values: state.values as V,
359 validate,
360 reset: React.useCallback(() => dispatch({ type: "reset" }), []),
361 submit: React.useCallback(() => {
362 dispatch({ type: "startSubmit" });
363 return validate()
364 .then((validateMessages) => {
365 if (onSubmitRef.current) {
366 return Promise.resolve(
367 onSubmitRef.current(filterAllEmpty(state.values as V))
368 ).then((submitMessages) => {
369 const messages = { ...validateMessages, ...submitMessages };
370 dispatch({ type: "endSubmit", messages });
371 });
372 }
373 return dispatch({ type: "endSubmit", messages: validateMessages });
374 })
375 .then(() => {
376 if (resetOnSubmitSucceed) {
377 dispatch({ type: "reset" });
378 }
379 })
380 .catch((errors) => {
381 dispatch({ type: "endSubmit", errors });
382 });
383 }, [validate]),
384 update: React.useCallback(
385 (name: any, value: any) => dispatch({ type: "update", name, value }),
386 []
387 ),
388 blur: React.useCallback(
389 (name) => {
390 dispatch({ type: "blur", name });
391 if (validateOnBlur) {
392 validate().catch(() => {});
393 }
394 },
395 [validate]
396 ),
397 push: React.useCallback(
398 (name, value) => dispatch({ type: "push", name, value }),
399 []
400 ),
401 remove: React.useCallback(
402 (name, index) => dispatch({ type: "remove", name, index }),
403 []
404 ),
405 };
406}
407
\No newline at end of file