diff --git a/src/CONST/index.ts b/src/CONST/index.ts index ecb92e13ca0e..bfaca9c1e580 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -527,7 +527,7 @@ const CONST = { // Prevents consecutive special characters or spaces like '--', '..', '((', '))', or ' '. REPEATED_SPECIAL_CHAR_PATTERN: /([-\s().])\1+/, - MERCHANT_NAME_MAX_LENGTH: 255, + MERCHANT_NAME_MAX_BYTES: 255, MASKED_PAN_PREFIX: 'XXXXXXXXXXXX', diff --git a/src/libs/StringUtils/index.ts b/src/libs/StringUtils/index.ts index 6d4f1c0ba1c6..f69163752402 100644 --- a/src/libs/StringUtils/index.ts +++ b/src/libs/StringUtils/index.ts @@ -139,6 +139,15 @@ function removePreCodeBlock(text = '') { return text.replace(/]*>|<\/pre>/g, ''); } +/** + * Returns the number of bytes required to encode a string in UTF-8. + */ +function getUTF8ByteLength(str: string) { + const encoder = new TextEncoder(); + const bytes = encoder.encode(str); + return bytes.length; +} + export default { sanitizeString, isEmptyString, @@ -153,4 +162,5 @@ export default { sortStringArrayByLength, dedent, hash, + getUTF8ByteLength, }; diff --git a/src/libs/ValidationUtils.ts b/src/libs/ValidationUtils.ts index a550a8f2f049..69570619da2e 100644 --- a/src/libs/ValidationUtils.ts +++ b/src/libs/ValidationUtils.ts @@ -657,6 +657,15 @@ function isValidRegistrationNumber(registrationNumber: string, country: Country } } +/** + * Checks if the `inputValue` byte length exceeds the specified byte length, + * returning `isValid` (boolean) and `byteLength` (number) to be used in dynamic error copy. + */ +function isValidInputLength(inputValue: string, byteLength: number) { + const valueByteLength = StringUtils.getUTF8ByteLength(inputValue); + return {isValid: valueByteLength <= byteLength, byteLength: valueByteLength}; +} + export { meetsMinimumAgeRequirement, meetsMaximumAgeRequirement, @@ -708,4 +717,5 @@ export { isValidZipCodeInternational, isValidOwnershipPercentage, isValidRegistrationNumber, + isValidInputLength, }; diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersMerchantPage.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersMerchantPage.tsx index 4a519d94aefc..a1ebf1f45020 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersMerchantPage.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersMerchantPage.tsx @@ -12,6 +12,7 @@ import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import {updateAdvancedFilters} from '@libs/actions/Search'; import Navigation from '@libs/Navigation/Navigation'; +import {isValidInputLength} from '@libs/ValidationUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -21,7 +22,7 @@ function SearchFiltersMerchantPage() { const styles = useThemeStyles(); const {translate} = useLocalize(); - const [searchAdvancedFiltersForm] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); + const [searchAdvancedFiltersForm] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM, {canBeMissing: true}); const merchant = searchAdvancedFiltersForm?.[FILTER_KEYS.MERCHANT]; const {inputCallbackRef} = useAutoFocusInput(); @@ -33,9 +34,10 @@ function SearchFiltersMerchantPage() { const validate = (values: FormOnyxValues) => { const errors: FormInputErrors = {}; const merchantValue = values.merchant.trim(); + const {isValid, byteLength} = isValidInputLength(merchantValue, CONST.MERCHANT_NAME_MAX_BYTES); - if (merchantValue.length > CONST.MERCHANT_NAME_MAX_LENGTH) { - errors.merchant = translate('common.error.characterLimitExceedCounter', {length: merchantValue.length, limit: CONST.MERCHANT_NAME_MAX_LENGTH}); + if (!isValid) { + errors.merchant = translate('common.error.characterLimitExceedCounter', {length: byteLength, limit: CONST.MERCHANT_NAME_MAX_BYTES}); } return errors; diff --git a/src/pages/iou/request/step/IOURequestStepMerchant.tsx b/src/pages/iou/request/step/IOURequestStepMerchant.tsx index 0d2980ac5fdc..af14bc01e941 100644 --- a/src/pages/iou/request/step/IOURequestStepMerchant.tsx +++ b/src/pages/iou/request/step/IOURequestStepMerchant.tsx @@ -11,6 +11,7 @@ import usePolicy from '@hooks/usePolicy'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import {getTransactionDetails, isExpenseRequest, isPolicyExpenseChat} from '@libs/ReportUtils'; +import {isValidInputLength} from '@libs/ValidationUtils'; import {setDraftSplitTransaction, setMoneyRequestMerchant, updateMoneyRequestMerchant} from '@userActions/IOU'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -60,15 +61,16 @@ function IOURequestStepMerchant({ const validate = useCallback( (value: FormOnyxValues) => { const errors: FormInputErrors = {}; + const {isValid, byteLength} = isValidInputLength(value.moneyRequestMerchant, CONST.MERCHANT_NAME_MAX_BYTES); if (isMerchantRequired && !value.moneyRequestMerchant) { errors.moneyRequestMerchant = translate('common.error.fieldRequired'); } else if (isMerchantRequired && value.moneyRequestMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT) { errors.moneyRequestMerchant = translate('iou.error.invalidMerchant'); - } else if (value.moneyRequestMerchant.length > CONST.MERCHANT_NAME_MAX_LENGTH) { + } else if (!isValid) { errors.moneyRequestMerchant = translate('common.error.characterLimitExceedCounter', { - length: value.moneyRequestMerchant.length, - limit: CONST.MERCHANT_NAME_MAX_LENGTH, + length: byteLength, + limit: CONST.MERCHANT_NAME_MAX_BYTES, }); } diff --git a/tests/unit/ValidationUtilsTest.ts b/tests/unit/ValidationUtilsTest.ts index 343e05ef2e52..e9e10dc4f138 100644 --- a/tests/unit/ValidationUtilsTest.ts +++ b/tests/unit/ValidationUtilsTest.ts @@ -1,4 +1,5 @@ import {addDays, format, startOfDay, subYears} from 'date-fns'; +import {TextEncoder} from 'util'; import {translateLocal} from '@libs/Localize'; import CONST from '@src/CONST'; import { @@ -7,6 +8,7 @@ import { isValidAccountRoute, isValidDate, isValidExpirationDate, + isValidInputLength, isValidLegalName, isValidPastDate, isValidPaymentZipCode, @@ -18,6 +20,8 @@ import { meetsMinimumAgeRequirement, } from '@src/libs/ValidationUtils'; +global.TextEncoder = TextEncoder; + describe('ValidationUtils', () => { describe('isValidDate', () => { test('Should return true for a valid date within the range', () => { @@ -396,4 +400,99 @@ describe('ValidationUtils', () => { expect(isValid).toBe(false); }); }); + + describe('isValidInputLength', () => { + // Test Latin alphabet characters (1 byte each in UTF-8) + describe('Latin alphabet characters', () => { + test('returns true and correct byte length when Latin string byte length exceeds limit', () => { + expect(isValidInputLength('abc', 2)).toEqual({isValid: false, byteLength: 3}); // 3 bytes > 2 + }); + + test('returns false and correct byte length when Latin string byte length equals limit', () => { + expect(isValidInputLength('abc', 3)).toEqual({isValid: true, byteLength: 3}); // 3 bytes ≤ 3 + }); + + test('returns false and correct byte length when Latin string byte length is less than limit', () => { + expect(isValidInputLength('ab', 3)).toEqual({isValid: true, byteLength: 2}); // 2 bytes ≤ 3 + }); + }); + + // Test Sanskrit characters (typically 3 bytes each in UTF-8) + describe('Sanskrit characters', () => { + test('returns true and correct byte length when Sanskrit string byte length exceeds limit', () => { + expect(isValidInputLength('कष', 5)).toEqual({isValid: false, byteLength: 6}); // 6 bytes > 5 + }); + + test('returns false and correct byte length when Sanskrit string byte length equals limit', () => { + expect(isValidInputLength('कष', 6)).toEqual({isValid: true, byteLength: 6}); // 6 bytes ≤ 6 + }); + + test('returns false and correct byte length when Sanskrit string byte length is less than limit', () => { + expect(isValidInputLength('क', 4)).toEqual({isValid: true, byteLength: 3}); // 3 bytes ≤ 4 + }); + }); + + // Test emojis (typically 4 bytes each in UTF-8) + describe('Emojis', () => { + test('returns true and correct byte length when emoji byte length exceeds limit', () => { + expect(isValidInputLength('😊', 3)).toEqual({isValid: false, byteLength: 4}); // 4 bytes > 3 + }); + + test('returns false and correct byte length when emoji byte length equals limit', () => { + expect(isValidInputLength('😊', 4)).toEqual({isValid: true, byteLength: 4}); // 4 bytes ≤ 4 + }); + + test('returns false and correct byte length when emoji byte length is less than limit', () => { + expect(isValidInputLength('😊', 5)).toEqual({isValid: true, byteLength: 4}); // 4 bytes ≤ 5 + }); + }); + + // Test empty strings and spaces + describe('Empty strings and spaces', () => { + test('returns false and correct byte length for empty string regardless of limit', () => { + expect(isValidInputLength('', 0)).toEqual({isValid: true, byteLength: 0}); // 0 bytes ≤ 0 + expect(isValidInputLength('', 1)).toEqual({isValid: true, byteLength: 0}); // 0 bytes ≤ 1 + }); + + test('returns true and correct byte length when space string byte length exceeds limit', () => { + expect(isValidInputLength(' ', 2)).toEqual({isValid: false, byteLength: 3}); // 3 bytes > 2 + }); + + test('returns false and correct byte length when space string byte length equals limit', () => { + expect(isValidInputLength(' ', 2)).toEqual({isValid: true, byteLength: 2}); // 2 bytes ≤ 2 + }); + }); + + // Test mixed characters + describe('Mixed characters', () => { + test('returns true and correct byte length when mixed string byte length exceeds limit', () => { + expect(isValidInputLength('aक😊', 6)).toEqual({isValid: false, byteLength: 8}); // 1 + 3 + 4 = 8 bytes > 6 + }); + + test('returns false and correct byte length when mixed string byte length equals limit', () => { + expect(isValidInputLength('aक😊', 8)).toEqual({isValid: true, byteLength: 8}); // 1 + 3 + 4 = 8 bytes ≤ 8 + }); + + test('returns false and correct byte length when mixed string byte length is less than limit', () => { + expect(isValidInputLength('aक', 5)).toEqual({isValid: true, byteLength: 4}); // 1 + 3 = 4 bytes ≤ 5 + }); + }); + + // Test edge cases + describe('Edge cases', () => { + test('handles negative length parameter', () => { + expect(isValidInputLength('abc', -1)).toEqual({isValid: false, byteLength: 3}); // 3 bytes > -1 + }); + + test('handles zero length parameter', () => { + expect(isValidInputLength('a', 0)).toEqual({isValid: false, byteLength: 1}); // 1 byte > 0 + expect(isValidInputLength('', 0)).toEqual({isValid: true, byteLength: 0}); // 0 bytes ≤ 0 + }); + + test('handles special characters (e.g., newlines, tabs)', () => { + expect(isValidInputLength('\n\t', 1)).toEqual({isValid: false, byteLength: 2}); // 2 bytes > 1 + expect(isValidInputLength('\n\t', 2)).toEqual({isValid: true, byteLength: 2}); // 2 bytes ≤ 2 + }); + }); + }); });