Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',

Expand Down
10 changes: 10 additions & 0 deletions src/libs/StringUtils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,15 @@ function removePreCodeBlock(text = '') {
return text.replace(/<pre[^>]*>|<\/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,
Expand All @@ -153,4 +162,5 @@ export default {
sortStringArrayByLength,
dedent,
hash,
getUTF8ByteLength,
};
10 changes: 10 additions & 0 deletions src/libs/ValidationUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -708,4 +717,5 @@ export {
isValidZipCodeInternational,
isValidOwnershipPercentage,
isValidRegistrationNumber,
isValidInputLength,
};
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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();

Expand All @@ -33,9 +34,10 @@ function SearchFiltersMerchantPage() {
const validate = (values: FormOnyxValues<typeof ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM>) => {
const errors: FormInputErrors<typeof ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM> = {};
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;
Expand Down
8 changes: 5 additions & 3 deletions src/pages/iou/request/step/IOURequestStepMerchant.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -60,15 +61,16 @@ function IOURequestStepMerchant({
const validate = useCallback(
(value: FormOnyxValues<typeof ONYXKEYS.FORMS.MONEY_REQUEST_MERCHANT_FORM>) => {
const errors: FormInputErrors<typeof ONYXKEYS.FORMS.MONEY_REQUEST_MERCHANT_FORM> = {};
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,
});
}

Expand Down
99 changes: 99 additions & 0 deletions tests/unit/ValidationUtilsTest.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {addDays, format, startOfDay, subYears} from 'date-fns';
import {TextEncoder} from 'util';
Comment thread
ikevin127 marked this conversation as resolved.
import {translateLocal} from '@libs/Localize';
import CONST from '@src/CONST';
import {
Expand All @@ -7,6 +8,7 @@ import {
isValidAccountRoute,
isValidDate,
isValidExpirationDate,
isValidInputLength,
isValidLegalName,
isValidPastDate,
isValidPaymentZipCode,
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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
});
});
});
});