Skip to content

Commit 06ab1eb

Browse files
authored
Merge pull request #28821 from software-mansion-labs/add-better-form-validation
Improve `new form` validation
2 parents abfa6a5 + d99fa21 commit 06ab1eb

File tree

3 files changed

+91
-9
lines changed

3 files changed

+91
-9
lines changed

src/components/Form/FormProvider.js

+77-7
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import compose from '../../libs/compose';
1111
import {withNetwork} from '../OnyxProvider';
1212
import stylePropTypes from '../../styles/stylePropTypes';
1313
import networkPropTypes from '../networkPropTypes';
14+
import CONST from '../../CONST';
1415

1516
const propTypes = {
1617
/** A unique Onyx key identifying the form */
@@ -98,19 +99,75 @@ function getInitialValueByType(valueType) {
9899
}
99100
}
100101

101-
function FormProvider({validate, shouldValidateOnBlur, shouldValidateOnChange, children, formState, network, enabledWhenOffline, onSubmit, ...rest}) {
102+
function FormProvider({validate, formID, shouldValidateOnBlur, shouldValidateOnChange, children, formState, network, enabledWhenOffline, onSubmit, ...rest}) {
102103
const inputRefs = useRef(null);
103104
const touchedInputs = useRef({});
104105
const [inputValues, setInputValues] = useState({});
105106
const [errors, setErrors] = useState({});
107+
const hasServerError = useMemo(() => Boolean(formState) && !_.isEmpty(formState.errors), [formState]);
106108

107109
const onValidate = useCallback(
108-
(values) => {
110+
(values, shouldClearServerError = true) => {
111+
const trimmedStringValues = {};
112+
_.each(values, (inputValue, inputID) => {
113+
if (_.isString(inputValue)) {
114+
trimmedStringValues[inputID] = inputValue.trim();
115+
} else {
116+
trimmedStringValues[inputID] = inputValue;
117+
}
118+
});
119+
120+
if (shouldClearServerError) {
121+
FormActions.setErrors(formID, null);
122+
}
123+
FormActions.setErrorFields(formID, null);
124+
109125
const validateErrors = validate(values) || {};
110-
setErrors(validateErrors);
111-
return validateErrors;
126+
127+
// Validate the input for html tags. It should supercede any other error
128+
_.each(trimmedStringValues, (inputValue, inputID) => {
129+
// If the input value is empty OR is non-string, we don't need to validate it for HTML tags
130+
if (!inputValue || !_.isString(inputValue)) {
131+
return;
132+
}
133+
const foundHtmlTagIndex = inputValue.search(CONST.VALIDATE_FOR_HTML_TAG_REGEX);
134+
const leadingSpaceIndex = inputValue.search(CONST.VALIDATE_FOR_LEADINGSPACES_HTML_TAG_REGEX);
135+
136+
// Return early if there are no HTML characters
137+
if (leadingSpaceIndex === -1 && foundHtmlTagIndex === -1) {
138+
return;
139+
}
140+
141+
const matchedHtmlTags = inputValue.match(CONST.VALIDATE_FOR_HTML_TAG_REGEX);
142+
let isMatch = _.some(CONST.WHITELISTED_TAGS, (r) => r.test(inputValue));
143+
// Check for any matches that the original regex (foundHtmlTagIndex) matched
144+
if (matchedHtmlTags) {
145+
// Check if any matched inputs does not match in WHITELISTED_TAGS list and return early if needed.
146+
for (let i = 0; i < matchedHtmlTags.length; i++) {
147+
const htmlTag = matchedHtmlTags[i];
148+
isMatch = _.some(CONST.WHITELISTED_TAGS, (r) => r.test(htmlTag));
149+
if (!isMatch) {
150+
break;
151+
}
152+
}
153+
}
154+
// Add a validation error here because it is a string value that contains HTML characters
155+
validateErrors[inputID] = 'common.error.invalidCharacter';
156+
});
157+
158+
if (!_.isObject(validateErrors)) {
159+
throw new Error('Validate callback must return an empty object or an object with shape {inputID: error}');
160+
}
161+
162+
const touchedInputErrors = _.pick(validateErrors, (inputValue, inputID) => Boolean(touchedInputs.current[inputID]));
163+
164+
if (!_.isEqual(errors, touchedInputErrors)) {
165+
setErrors(touchedInputErrors);
166+
}
167+
168+
return touchedInputErrors;
112169
},
113-
[validate],
170+
[errors, formID, validate],
114171
);
115172

116173
/**
@@ -186,6 +243,18 @@ function FormProvider({validate, shouldValidateOnBlur, shouldValidateOnChange, c
186243
propsToParse.onTouched(event);
187244
}
188245
},
246+
onPress: (event) => {
247+
setTouchedInput(inputID);
248+
if (_.isFunction(propsToParse.onPress)) {
249+
propsToParse.onPress(event);
250+
}
251+
},
252+
onPressIn: (event) => {
253+
setTouchedInput(inputID);
254+
if (_.isFunction(propsToParse.onPressIn)) {
255+
propsToParse.onPressIn(event);
256+
}
257+
},
189258
onBlur: (event) => {
190259
// Only run validation when user proactively blurs the input.
191260
if (Visibility.isVisible() && Visibility.hasFocus()) {
@@ -195,7 +264,7 @@ function FormProvider({validate, shouldValidateOnBlur, shouldValidateOnChange, c
195264
setTimeout(() => {
196265
setTouchedInput(inputID);
197266
if (shouldValidateOnBlur) {
198-
onValidate(inputValues);
267+
onValidate(inputValues, !hasServerError);
199268
}
200269
}, 200);
201270
}
@@ -228,7 +297,7 @@ function FormProvider({validate, shouldValidateOnBlur, shouldValidateOnChange, c
228297
},
229298
};
230299
},
231-
[errors, formState, inputValues, onValidate, setTouchedInput, shouldValidateOnBlur, shouldValidateOnChange],
300+
[errors, formState, hasServerError, inputValues, onValidate, setTouchedInput, shouldValidateOnBlur, shouldValidateOnChange],
232301
);
233302
const value = useMemo(() => ({registerInput}), [registerInput]);
234303

@@ -237,6 +306,7 @@ function FormProvider({validate, shouldValidateOnBlur, shouldValidateOnChange, c
237306
{/* eslint-disable react/jsx-props-no-spreading */}
238307
<FormWrapper
239308
{...rest}
309+
formID={formID}
240310
onSubmit={submit}
241311
inputRefs={inputRefs}
242312
errors={errors}

src/components/Form/FormWrapper.js

+3-2
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import SafeAreaConsumer from '../SafeAreaConsumer';
1111
import ScrollViewWithContext from '../ScrollViewWithContext';
1212

1313
import stylePropTypes from '../../styles/stylePropTypes';
14+
import errorsPropType from './errorsPropType';
1415

1516
const propTypes = {
1617
/** A unique Onyx key identifying the form */
@@ -36,7 +37,7 @@ const propTypes = {
3637
isLoading: PropTypes.bool,
3738

3839
/** Server side errors keyed by microtime */
39-
errors: PropTypes.objectOf(PropTypes.oneOf([PropTypes.string, PropTypes.arrayOf(PropTypes.string)])),
40+
errors: errorsPropType,
4041

4142
/** Field-specific server side errors keyed by microtime */
4243
errorFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)),
@@ -59,7 +60,7 @@ const propTypes = {
5960
/** Custom content to display in the footer after submit button */
6061
footerContent: PropTypes.oneOfType([PropTypes.func, PropTypes.node]),
6162

62-
errors: PropTypes.objectOf(PropTypes.string).isRequired,
63+
errors: errorsPropType.isRequired,
6364

6465
inputRefs: PropTypes.objectOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object])).isRequired,
6566
};

src/components/Form/errorsPropType.js

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import PropTypes from 'prop-types';
2+
3+
export default PropTypes.oneOfType([
4+
PropTypes.string,
5+
PropTypes.objectOf(
6+
PropTypes.oneOfType([
7+
PropTypes.string,
8+
PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.objectOf(PropTypes.oneOfType([PropTypes.string, PropTypes.bool, PropTypes.number]))])),
9+
]),
10+
),
11+
]);

0 commit comments

Comments
 (0)