-
Notifications
You must be signed in to change notification settings - Fork 1.2k
/
Copy pathuseFormValidationState.ts
268 lines (228 loc) · 9.14 KB
/
useFormValidationState.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
/*
* Copyright 2023 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
import {createContext, useContext, useEffect, useMemo, useRef, useState} from 'react';
import {Validation, ValidationErrors, ValidationFunction, ValidationResult} from '@react-types/shared';
export const VALID_VALIDITY_STATE: ValidityState = {
badInput: false,
customError: false,
patternMismatch: false,
rangeOverflow: false,
rangeUnderflow: false,
stepMismatch: false,
tooLong: false,
tooShort: false,
typeMismatch: false,
valueMissing: false,
valid: true
};
const CUSTOM_VALIDITY_STATE: ValidityState = {
...VALID_VALIDITY_STATE,
customError: true,
valid: false
};
export const DEFAULT_VALIDATION_RESULT: ValidationResult = {
isInvalid: false,
validationDetails: VALID_VALIDITY_STATE,
validationErrors: []
};
export const FormValidationContext = createContext<ValidationErrors>({});
export const privateValidationStateProp = '__formValidationState' + Date.now();
interface FormValidationProps<T> extends Validation<T> {
builtinValidation?: ValidationResult,
name?: string | string[],
value: T | null
}
export interface FormValidationState {
/** Realtime validation results, updated as the user edits the value. */
realtimeValidation: ValidationResult,
/** Currently displayed validation results, updated when the user commits their changes. */
displayValidation: ValidationResult,
/** Updates the current validation result. Not displayed to the user until `commitValidation` is called. */
updateValidation(result: ValidationResult): void,
/** Resets the displayed validation state to valid when the user resets the form. */
resetValidation(): void,
/** Commits the realtime validation so it is displayed to the user. */
commitValidation(): void
}
export function useFormValidationState<T>(props: FormValidationProps<T>): FormValidationState {
const privateProp = props[privateValidationStateProp];
let privateState = useMemo(() => {
if (!privateProp) {
return null;
}
let {realtimeValidation, displayValidation, updateValidation, resetValidation, commitValidation} = privateProp as FormValidationState;
return {realtimeValidation, displayValidation, updateValidation, resetValidation, commitValidation};
}, [privateProp]);
// Private prop for parent components to pass state to children.
if (privateState) {
return privateState;
}
// eslint-disable-next-line react-hooks/rules-of-hooks
return useFormValidationStateImpl(props);
}
function useFormValidationStateImpl<T>(props: FormValidationProps<T>): FormValidationState {
let {isInvalid, validationState, name, value, builtinValidation, validate, validationBehavior = 'aria'} = props;
// backward compatibility.
if (validationState) {
isInvalid ||= validationState === 'invalid';
}
// If the isInvalid prop is controlled, update validation result in realtime.
let controlledError: ValidationResult | null = isInvalid !== undefined ? {
isInvalid,
validationErrors: [],
validationDetails: CUSTOM_VALIDITY_STATE
} : null;
// Perform custom client side validation.
let clientError: ValidationResult | null = useMemo(() => {
if (!validate || value == null) {
return null;
}
let validateErrors = runValidate(validate, value);
return getValidationResult(validateErrors);
}, [validate, value]);
if (builtinValidation?.validationDetails.valid) {
builtinValidation = undefined;
}
// Get relevant server errors from the form.
let serverErrors = useContext(FormValidationContext);
let serverErrorMessages = useMemo(() => {
if (name) {
return Array.isArray(name) ? name.flatMap(name => asArray(serverErrors[name])) : asArray(serverErrors[name]);
}
return [];
}, [serverErrors, name]);
// Show server errors when the form gets a new value, and clear when the user changes the value.
let [lastServerErrors, setLastServerErrors] = useState(serverErrors);
let [isServerErrorCleared, setServerErrorCleared] = useState(false);
if (serverErrors !== lastServerErrors) {
setLastServerErrors(serverErrors);
setServerErrorCleared(false);
}
let serverError: ValidationResult | null = useMemo(() =>
getValidationResult(isServerErrorCleared ? [] : serverErrorMessages),
[isServerErrorCleared, serverErrorMessages]
);
// Track the next validation state in a ref until commitValidation is called.
let nextValidation = useRef(DEFAULT_VALIDATION_RESULT);
let [currentValidity, setCurrentValidity] = useState(DEFAULT_VALIDATION_RESULT);
let lastError = useRef(DEFAULT_VALIDATION_RESULT);
let commitValidation = () => {
if (!commitQueued) {
return;
}
setCommitQueued(false);
let error = clientError || builtinValidation || nextValidation.current;
if (!isEqualValidation(error, lastError.current)) {
lastError.current = error;
setCurrentValidity(error);
}
};
let [commitQueued, setCommitQueued] = useState(false);
useEffect(commitValidation);
// realtimeValidation is used to update the native input element's state based on custom validation logic.
// displayValidation is the currently displayed validation state that the user sees (e.g. on input change/form submit).
// With validationBehavior="aria", all errors are displayed in realtime rather than on submit.
let realtimeValidation = controlledError || serverError || clientError || builtinValidation || DEFAULT_VALIDATION_RESULT;
let displayValidation = validationBehavior === 'native'
? controlledError || serverError || currentValidity
: controlledError || serverError || clientError || builtinValidation || currentValidity;
return useMemo(() => ({
realtimeValidation,
displayValidation,
updateValidation(value) {
// If validationBehavior is 'aria', update in realtime. Otherwise, store in a ref until commit.
if (validationBehavior === 'aria' && !isEqualValidation(currentValidity, value)) {
setCurrentValidity(value);
} else {
nextValidation.current = value;
}
},
resetValidation() {
// Update the currently displayed validation state to valid on form reset,
// even if the native validity says it isn't. It'll show again on the next form submit.
let error = DEFAULT_VALIDATION_RESULT;
if (!isEqualValidation(error, lastError.current)) {
lastError.current = error;
setCurrentValidity(error);
}
// Do not commit validation after the next render. This avoids a condition where
// useSelect calls commitValidation inside an onReset handler.
if (validationBehavior === 'native') {
setCommitQueued(false);
}
setServerErrorCleared(true);
},
commitValidation() {
// Commit validation state so the user sees it on blur/change/submit. Also clear any server errors.
// Wait until after the next render to commit so that the latest value has been validated.
if (validationBehavior === 'native') {
setCommitQueued(true);
}
setServerErrorCleared(true);
}
}), [realtimeValidation, displayValidation, validationBehavior, currentValidity]);
}
function asArray<T>(v: T | T[]): T[] {
if (!v) {
return [];
}
return Array.isArray(v) ? v : [v];
}
function runValidate<T>(validate: ValidationFunction<T>, value: T): string[] {
if (typeof validate === 'function') {
let e = validate(value);
if (e && typeof e !== 'boolean') {
return asArray(e);
}
}
return [];
}
function getValidationResult(errors: string[]): ValidationResult | null {
return errors.length ? {
isInvalid: true,
validationErrors: errors,
validationDetails: CUSTOM_VALIDITY_STATE
} : null;
}
function isEqualValidation(a: ValidationResult | null, b: ValidationResult | null): boolean {
if (a === b) {
return true;
}
return !!a && !!b
&& a.isInvalid === b.isInvalid
&& a.validationErrors.length === b.validationErrors.length
&& a.validationErrors.every((a, i) => a === b.validationErrors[i])
&& Object.entries(a.validationDetails).every(([k, v]) => b.validationDetails[k] === v);
}
export function mergeValidation(...results: ValidationResult[]): ValidationResult {
let errors = new Set<string>();
let isInvalid = false;
let validationDetails = {
...VALID_VALIDITY_STATE
};
for (let v of results) {
for (let e of v.validationErrors) {
errors.add(e);
}
// Only these properties apply for checkboxes.
isInvalid ||= v.isInvalid;
for (let key in validationDetails) {
validationDetails[key] ||= v.validationDetails[key];
}
}
validationDetails.valid = !isInvalid;
return {
isInvalid,
validationErrors: [...errors],
validationDetails
};
}