Skip to content

Commit

Permalink
Add Form validation onMount for controlled input forms (grommet#5881)
Browse files Browse the repository at this point in the history
* Form validate fields on mount

* Add a couple comments

Co-authored-by: Eric Soderberg <[email protected]>
  • Loading branch information
MikeKingdom and ericsoderberghp authored Jan 10, 2022
1 parent ca0ad80 commit 4eb84fa
Show file tree
Hide file tree
Showing 6 changed files with 330 additions and 144 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ node_modules
#IDEA
.idea/**
.vscode
*.code-workspace

# testing
coverage
Expand Down
222 changes: 106 additions & 116 deletions src/js/components/Form/Form.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,7 @@ const validateName =
(validationRules, required) => (name, formValue, format, messages) => {
const fieldValue = getFieldValue(name, formValue);
let validationResult;
// ValidateArg is something that gets passed in from a FormField component
// See 'validate' prop in FormField

if (
required &&
// false is for CheckBox
Expand All @@ -91,13 +90,12 @@ const validateName =
fieldValue === false ||
(Array.isArray(fieldValue) && !fieldValue.length))
) {
// There is no value at that name, and one is required
validationResult = format({ id: 'form.required', messages });
} else if (validationRules) {
if (Array.isArray(validationRules)) {
validationRules.some((validator) => {
validationRules.some((rule) => {
validationResult = validate(
validator,
rule,
fieldValue,
formValue,
format,
Expand All @@ -118,7 +116,6 @@ const validateName =
return validationResult;
};

// validations is an array from Object.entries()
// Validates all keys in formValue
const validateForm = (
validationRules,
Expand Down Expand Up @@ -177,29 +174,30 @@ const Form = forwardRef(
ref,
) => {
const { format } = useContext(MessageContext);

const [valueState, setValueState] = useState(valueProp || defaultValue);
const value = useMemo(
() => valueProp || valueState,
[valueProp, valueState],
);
const [touched, setTouched] = useState(defaultTouched);
const [validationResults, setValidationResults] = useState(
defaultValidationResults,
);
// when onBlur input validation is triggered, we need to complete any
// potential click events before running the onBlur validation.
// otherwise, click events like reset, etc. may not be registered.
// for a detailed scenario/discussion,
// see: https://github.com/grommet/grommet/issues/4863
// the value of pendingValidation is the name of the FormField
// awaiting validation.
const [pendingValidation, setPendingValidation] = useState(undefined);

const [validationResults, setValidationResults] = useState({
errors: errorsProp,
infos: infosProp,
});
// maintain a copy of validationResults in a ref for useEffects
// which can't depend on validationResults directly without
// causing infinite renders.
const validationResultsRef = useRef({});
// Simulated onMount state. Consider Form to be mounted once it has
// accounted for values originating from controlled inputs (available
// at second rendering).
const [mounted, setMounted] = useState('unmounted');
useEffect(() => {
setPendingValidation(undefined);
setValidationResults({ errors: errorsProp, infos: infosProp });
}, [errorsProp, infosProp]);
if (!mounted) setMounted('mounting');
else if (mounted === 'mounting') setMounted('mounted');
}, [mounted]);
// `pendingValidation` is the name of the FormField awaiting validation.
const [pendingValidation, setPendingValidation] = useState(undefined);

const validationRulesRef = useRef({});
const requiredFields = useRef([]);
Expand Down Expand Up @@ -233,114 +231,108 @@ const Form = forwardRef(
.forEach((n) => delete nextValidations[n]);
};

// On initial mount, when validateOn is change or blur,
// set validation results for any set fields and calculate whether
// the form is valid overall.
useEffect(() => {
const validationsForSetFields = Object.entries(
validationRulesRef.current,
).filter(([n]) => getFieldValue(n, value));
if (validationsForSetFields.length > 0 && validateOn !== 'submit') {
const [errors, infos] = validateForm(
validationsForSetFields,
const applyValidationRules = useCallback(
(validationRules) => {
const [validatedErrors, validatedInfos] = validateForm(
validationRules,
value,
format,
messages,
);
filterRemovedFields(errors);
filterRemovedFields(infos);

const nextValidationResults = {
errors,
infos,
valid: buildValid(errors),
};
if (onValidate) onValidate(nextValidationResults);
setValidationResults(nextValidationResults);
setValidationResults((prevValidationResults) => {
// Keep any previous errors and infos for untouched keys,
// these may have come from a Submit.
const nextErrors = {
...prevValidationResults.errors,
...validatedErrors,
};
const nextInfos = {
...prevValidationResults.infos,
...validatedInfos,
};
// Remove previous errors and infos for keys no longer in the
// form, these may have been fields removed from a dynamic form.
filterRemovedFields(nextErrors);
filterRemovedFields(nextInfos);
const nextValidationResults = {
errors: nextErrors,
infos: nextInfos,
};
if (onValidate)
onValidate({
...nextValidationResults,
valid: buildValid(nextErrors),
});
validationResultsRef.current = nextValidationResults;
return nextValidationResults;
});
},
[buildValid, format, messages, onValidate, value],
);

// Validate all fields holding values onMount if set to
// validate when blur or change.
useEffect(() => {
const validationRules = Object.entries(validationRulesRef.current);
// Use simulated onMount state to account for values provided by
// controlled inputs.
if (
mounted !== 'mounted' &&
['blur', 'change'].includes(validateOn) &&
Object.keys(value).length > 0 &&
Object.keys(touched).length === 0
) {
applyValidationRules(
validationRules
.filter(([n]) => value[n])
// Exlude empty arrays which may be initial values in
// an input such as DateInput.
.filter(
([n]) => !(Array.isArray(value[n]) && value[n].length === 0),
),
);
}
// We only want to run this for the value we have on initial mount.
// We don't want subsequent changes to the value to re-run this.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [applyValidationRules, mounted, touched, validateOn, value]);

// Currently, onBlur validation will trigger after a timeout of 120ms.
// Run validation against fields with pendingValidations from onBlur
// and/or onChange.
useEffect(() => {
const validationRules = Object.entries(validationRulesRef.current);
const timer = setTimeout(() => {
if (pendingValidation) {
// run validations on the pending one and any other touched fields
const [validatedErrors, validatedInfos] = validateForm(
Object.entries(validationRulesRef.current).filter(
if (pendingValidation && ['blur', 'change'].includes(validateOn)) {
applyValidationRules(
validationRules.filter(
([n]) => touched[n] || pendingValidation.includes(n),
),
value,
format,
messages,
);
setPendingValidation(undefined);

setValidationResults((prevValidationResults) => {
// keep any previous errors and infos for untouched keys,
// these may have come from a submit
const nextErrors = {
...prevValidationResults.errors,
...validatedErrors,
};
const nextInfos = {
...prevValidationResults.infos,
...validatedInfos,
};

filterRemovedFields(nextErrors);
filterRemovedFields(nextInfos);

const nextValidationResults = {
errors: nextErrors,
infos: nextInfos,
valid: buildValid(nextErrors),
};
if (onValidate) onValidate(nextValidationResults);
return nextValidationResults;
});
}
// a timeout is needed to ensure that a click event (like one on a reset
// button) completes prior to running the validation. without a timeout,
// the blur will always complete and trigger a validation prematurely
// The following values have been empirically tested, but 120 was
// selected because it is the largest value
// Complete any potential click events before running onBlur validation.
// Otherwise, click events like reset, etc. may not be registered. For a
// detailed scenario/discussion, see: https://github.com/grommet/grommet/issues/4863
// Values empirically tested; 120 was selected because it is the largest
// Chrome: 100, Safari: 120, Firefox: 80
}, 120);

return () => clearTimeout(timer);
}, [
buildValid,
format,
messages,
pendingValidation,
onValidate,
touched,
value,
requiredFields,
]);
}, [applyValidationRules, pendingValidation, touched, validateOn]);

// clear any errors when value changes
// Re-run validation rules for all fields with prior errors.
// if validate=blur this helps re-validate if there are errors
// as the user fixes them (basically act like validate=change for that)
useEffect(() => {
if (validateOn !== 'change') setPendingValidation(undefined);
setValidationResults((prevValidationResults) => {
const [nextErrors, nextInfos] = validateForm(
Object.entries(validationRulesRef.current).filter(
([n]) =>
prevValidationResults.errors[n] || prevValidationResults.infos[n],
const validationRules = Object.entries(validationRulesRef.current);
if (
validationResultsRef.current?.errors &&
Object.keys(validationResultsRef.current.errors).length > 0
) {
applyValidationRules(
validationRules.filter(
([n]) => touched[n] && validationResultsRef.current.errors[n],
),
value,
format,
messages,
);
return {
errors: { ...prevValidationResults.errors, ...nextErrors },
infos: { ...prevValidationResults.infos, ...nextInfos },
};
});
}, [format, messages, touched, validateOn, value]);
}
}, [applyValidationRules, touched]);

// There are three basic patterns of handling form input value state:
//
Expand Down Expand Up @@ -425,7 +417,8 @@ const Form = forwardRef(
// eslint-disable-next-line react-hooks/exhaustive-deps
[], // only run onmount and unmount
);


// Create validation rules for fields
useEffect(() => {
if (validateArg) {
if (!validationRulesRef.current[name]) {
Expand Down Expand Up @@ -467,11 +460,7 @@ const Form = forwardRef(
// we know to remove its value from the form if it is dynamically
// removed
if (!(name in value)) keyCreated.current = true;
const nextValue = setFieldValue(
name,
nextComponentValue,
value,
);
const nextValue = setFieldValue(name, nextComponentValue, value);
setValueState(nextValue);
if (onChange) onChange(nextValue, { touched: nextTouched });
}
Expand All @@ -493,6 +482,7 @@ const Form = forwardRef(
: errorArg || validationResults.errors[name];
const info = infoArg || validationResults.infos[name];

// Create validation rules for field
useEffect(() => {
const index = requiredFields.current.indexOf(name);
if (required) {
Expand Down Expand Up @@ -558,7 +548,6 @@ const Form = forwardRef(
}
setTouched(defaultTouched);
setValidationResults(defaultValidationResults);

if (onReset) {
event.persist(); // extract from React's synthetic event pool
const adjustedEvent = event;
Expand Down Expand Up @@ -588,6 +577,7 @@ const Form = forwardRef(
valid: buildValid(nextErrors),
};
if (onValidate) onValidate(nextValidationResults);
validationResultsRef.current = nextValidationResults;
return nextValidationResults;
});

Expand Down
Loading

0 comments on commit 4eb84fa

Please sign in to comment.