Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Handle invisible characters in forms #27414

Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/CONST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1359,6 +1359,10 @@ const CONST = {
ILLEGAL_FILENAME_CHARACTERS: /\/|<|>|\*|"|:|\?|\\|\|/g,

ENCODE_PERCENT_CHARACTER: /%(25)+/g,

INVISIBLE_CHARACTERS_GROUPS: /[\p{C}\p{Z}]/gu,

OTHER_INVISIBLE_CHARACTERS: /[\u3164]/g,
},

PRONOUNS: {
Expand Down
40 changes: 28 additions & 12 deletions src/components/Form.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {withOnyx} from 'react-native-onyx';
import _ from 'underscore';
import compose from '@libs/compose';
import * as ErrorUtils from '@libs/ErrorUtils';
import removeInvisibleCharacters from '@libs/removeInvisibleCharacters';
import Visibility from '@libs/Visibility';
import stylePropTypes from '@styles/stylePropTypes';
import styles from '@styles/styles';
Expand Down Expand Up @@ -120,20 +121,32 @@ function Form(props) {

const hasServerError = useMemo(() => Boolean(props.formState) && !_.isEmpty(props.formState.errors), [props.formState]);

/**
* This function is used to remove invisible characters from strings before validation and submission.
*
* @param {Object} values - An object containing the value of each inputID, e.g. {inputID1: value1, inputID2: value2}
* @returns {Object} - An object containing the processed values of each inputID
*/
const prepareValues = useCallback((values) => {
kosmydel marked this conversation as resolved.
Show resolved Hide resolved
const trimmedStringValues = {};
_.each(values, (inputValue, inputID) => {
if (_.isString(inputValue)) {
trimmedStringValues[inputID] = removeInvisibleCharacters(inputValue);
} else {
trimmedStringValues[inputID] = inputValue;
}
});
return trimmedStringValues;
}, []);

/**
* @param {Object} values - An object containing the value of each inputID, e.g. {inputID1: value1, inputID2: value2}
* @returns {Object} - An object containing the errors for each inputID, e.g. {inputID1: error1, inputID2: error2}
*/
const onValidate = useCallback(
(values, shouldClearServerError = true) => {
const trimmedStringValues = {};
_.each(values, (inputValue, inputID) => {
if (_.isString(inputValue)) {
trimmedStringValues[inputID] = inputValue.trim();
} else {
trimmedStringValues[inputID] = inputValue;
}
});
// Trim all string values
const trimmedStringValues = prepareValues(values);

if (shouldClearServerError) {
FormActions.setErrors(props.formID, null);
Expand Down Expand Up @@ -191,7 +204,7 @@ function Form(props) {

return touchedInputErrors;
},
[errors, touchedInputs, props.formID, validate],
[prepareValues, props.formID, validate, errors],
);

useEffect(() => {
Expand Down Expand Up @@ -228,11 +241,14 @@ function Form(props) {
return;
}

// Trim all string values
const trimmedStringValues = prepareValues(inputValues);

// Touches all form inputs so we can validate the entire form
_.each(inputRefs.current, (inputRef, inputID) => (touchedInputs.current[inputID] = true));

// Validate form and return early if any errors are found
if (!_.isEmpty(onValidate(inputValues))) {
if (!_.isEmpty(onValidate(trimmedStringValues))) {
return;
}

Expand All @@ -242,8 +258,8 @@ function Form(props) {
}

// Call submit handler
onSubmit(inputValues);
}, [props.formState, onSubmit, inputRefs, inputValues, onValidate, touchedInputs, props.network.isOffline, props.enabledWhenOffline]);
onSubmit(trimmedStringValues);
}, [props.formState.isLoading, props.network.isOffline, props.enabledWhenOffline, prepareValues, inputValues, onValidate, onSubmit]);

/**
* Loops over Form's children and automatically supplies Form props to them
Expand Down
39 changes: 27 additions & 12 deletions src/components/Form/FormProvider.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import _ from 'underscore';
import networkPropTypes from '@components/networkPropTypes';
import {withNetwork} from '@components/OnyxProvider';
import compose from '@libs/compose';
import removeInvisibleCharacters from '@libs/removeInvisibleCharacters';
import Visibility from '@libs/Visibility';
import stylePropTypes from '@styles/stylePropTypes';
import * as FormActions from '@userActions/FormActions';
Expand Down Expand Up @@ -106,16 +107,27 @@ function FormProvider({validate, formID, shouldValidateOnBlur, shouldValidateOnC
const [errors, setErrors] = useState({});
const hasServerError = useMemo(() => Boolean(formState) && !_.isEmpty(formState.errors), [formState]);

/**
* This function is used to remove invisible characters from strings before validation and submission.
*
* @param {Object} values - An object containing the value of each inputID, e.g. {inputID1: value1, inputID2: value2}
* @returns {Object} - An object containing the processed values of each inputID
*/
const prepareValues = useCallback((values) => {
const trimmedStringValues = {};
_.each(values, (inputValue, inputID) => {
if (_.isString(inputValue)) {
trimmedStringValues[inputID] = removeInvisibleCharacters(inputValue);
} else {
trimmedStringValues[inputID] = inputValue;
}
});
return trimmedStringValues;
}, []);

const onValidate = useCallback(
(values, shouldClearServerError = true) => {
const trimmedStringValues = {};
_.each(values, (inputValue, inputID) => {
if (_.isString(inputValue)) {
trimmedStringValues[inputID] = inputValue.trim();
} else {
trimmedStringValues[inputID] = inputValue;
}
});
const trimmedStringValues = prepareValues(values);

if (shouldClearServerError) {
FormActions.setErrors(formID, null);
Expand Down Expand Up @@ -167,7 +179,7 @@ function FormProvider({validate, formID, shouldValidateOnBlur, shouldValidateOnC

return touchedInputErrors;
},
[errors, formID, validate],
[errors, formID, prepareValues, validate],
);

/**
Expand All @@ -186,11 +198,14 @@ function FormProvider({validate, formID, shouldValidateOnBlur, shouldValidateOnC
return;
}

// Prepare values before submitting
const trimmedStringValues = prepareValues(inputValues);

// Touches all form inputs so we can validate the entire form
_.each(inputRefs.current, (inputRef, inputID) => (touchedInputs.current[inputID] = true));

// Validate form and return early if any errors are found
if (!_.isEmpty(onValidate(inputValues))) {
if (!_.isEmpty(onValidate(trimmedStringValues))) {
return;
}

Expand All @@ -199,8 +214,8 @@ function FormProvider({validate, formID, shouldValidateOnBlur, shouldValidateOnC
return;
}

onSubmit(inputValues);
}, [enabledWhenOffline, formState.isLoading, inputValues, network.isOffline, onSubmit, onValidate]);
onSubmit(trimmedStringValues);
}, [enabledWhenOffline, formState.isLoading, inputValues, network.isOffline, onSubmit, onValidate, prepareValues]);

const registerInput = useCallback(
(inputID, propsToParse = {}) => {
Expand Down
3 changes: 2 additions & 1 deletion src/libs/ValidationUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import CONST from '@src/CONST';
import {Report} from '@src/types/onyx';
import * as OnyxCommon from '@src/types/onyx/OnyxCommon';
import * as CardUtils from './CardUtils';
import isEmptyString from './isEmptyString';
import * as LoginUtils from './LoginUtils';

/**
Expand Down Expand Up @@ -73,7 +74,7 @@ function isValidPastDate(date: string | Date): boolean {
*/
function isRequiredFulfilled(value: string | Date | unknown[] | Record<string, unknown>): boolean {
if (typeof value === 'string') {
return value.trim().length > 0;
return !isEmptyString(value);
}

if (isDate(value)) {
Expand Down
19 changes: 19 additions & 0 deletions src/libs/isEmptyString.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import CONST from '@src/CONST';

/**
* Check if the string would be empty if all invisible characters were removed.
*/
function isEmptyString(value: string): boolean {
kosmydel marked this conversation as resolved.
Show resolved Hide resolved
// \p{C} matches all 'Other' characters
// \p{Z} matches all separators (spaces etc.)
// Source: http://www.unicode.org/reports/tr18/#General_Category_Property
let transformed = value.replace(CONST.REGEX.INVISIBLE_CHARACTERS_GROUPS, '');

// Remove other invisible characters that are not in the above unicode categories
transformed = transformed.replace(CONST.REGEX.OTHER_INVISIBLE_CHARACTERS, '');

// Check if after removing invisible characters the string is empty
return transformed === '';
}

export default isEmptyString;
33 changes: 33 additions & 0 deletions src/libs/removeInvisibleCharacters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import isEmptyString from './isEmptyString';

/**
* Remove invisible characters from a string except for spaces and format characters for emoji, and trim it.
*/
function removeInvisibleCharacters(value: string): string {
kosmydel marked this conversation as resolved.
Show resolved Hide resolved
let result = value;

// Remove spaces:
// - \u200B: zero-width space
// - \u00A0: non-breaking space
// - \u2060: word joiner
result = result.replace(/[\u200B\u00A0\u2060]/g, '');

// Remove all characters from the 'Other' (C) category except for format characters (Cf)
// because some of them are used for emojis
result = result.replace(/[\p{Cc}\p{Cs}\p{Co}\p{Cn}]/gu, '');

// Remove characters from the (Cf) category that are not used for emojis
result = result.replace(/[\u200E-\u200F]/g, '');

// Remove all characters from the 'Separator' (Z) category except for Space Separator (Zs)
result = result.replace(/[\p{Zl}\p{Zp}]/gu, '');

// If the result consist of only invisible characters, return an empty string
if (isEmptyString(result)) {
return '';
}

return result.trim();
}

export default removeInvisibleCharacters;
3 changes: 2 additions & 1 deletion src/pages/workspace/WorkspaceSettingsPage.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import compose from '@libs/compose';
import Navigation from '@libs/Navigation/Navigation';
import * as ReportUtils from '@libs/ReportUtils';
import * as UserUtils from '@libs/UserUtils';
import * as ValidationUtils from '@libs/ValidationUtils';
import styles from '@styles/styles';
import * as Policy from '@userActions/Policy';
import CONST from '@src/CONST';
Expand Down Expand Up @@ -76,7 +77,7 @@ function WorkspaceSettingsPage({policy, currencyList, windowWidth, route}) {
const errors = {};
const name = values.name.trim();

if (!name || !name.length) {
if (!ValidationUtils.isRequiredFulfilled(name)) {
errors.name = 'workspace.editor.nameIsRequiredError';
} else if ([...name].length > CONST.WORKSPACE_NAME_CHARACTER_LIMIT) {
// Uses the spread syntax to count the number of Unicode code points instead of the number of UTF-16
Expand Down
17 changes: 9 additions & 8 deletions src/stories/Form.stories.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import StatePicker from '@components/StatePicker';
import Text from '@components/Text';
import TextInput from '@components/TextInput';
import NetworkConnection from '@libs/NetworkConnection';
import * as ValidationUtils from '@libs/ValidationUtils';
import styles from '@styles/styles';
import * as FormActions from '@userActions/FormActions';
import CONST from '@src/CONST';
Expand Down Expand Up @@ -177,28 +178,28 @@ const defaultArgs = {
submitButtonText: 'Submit',
validate: (values) => {
const errors = {};
if (!values.routingNumber) {
if (!ValidationUtils.isRequiredFulfilled(values.routingNumber)) {
errors.routingNumber = 'Please enter a routing number';
}
if (!values.accountNumber) {
if (!ValidationUtils.isRequiredFulfilled(values.accountNumber)) {
errors.accountNumber = 'Please enter an account number';
}
if (!values.street) {
if (!ValidationUtils.isRequiredFulfilled(values.street)) {
errors.street = 'Please enter an address';
}
if (!values.dob) {
if (!ValidationUtils.isRequiredFulfilled(values.dob)) {
errors.dob = 'Please enter your date of birth';
}
if (!values.pickFruit) {
if (!ValidationUtils.isRequiredFulfilled(values.pickFruit)) {
errors.pickFruit = 'Please select a fruit';
}
if (!values.pickAnotherFruit) {
if (!ValidationUtils.isRequiredFulfilled(values.pickAnotherFruit)) {
errors.pickAnotherFruit = 'Please select a fruit';
}
if (!values.state) {
if (!ValidationUtils.isRequiredFulfilled(values.state)) {
errors.state = 'Please select a state';
}
if (!values.checkbox) {
if (!ValidationUtils.isRequiredFulfilled(values.checkbox)) {
errors.checkbox = 'You must accept the Terms of Service to continue';
}
return errors;
Expand Down
Loading
Loading