From d08913bdd0b8ad12fd069b65dbb3d8dcc86cabbe Mon Sep 17 00:00:00 2001 From: Abdelhafidh Belalia <16493223+s77rt@users.noreply.github.com> Date: Fri, 18 Oct 2024 23:53:54 +0100 Subject: [PATCH 1/4] Add regex to TextInput --- ...ve+0.75.2+018+Add-regex-to-TextInput.patch | 299 ++++++++++++++++++ src/components/AmountForm.tsx | 4 +- src/components/AmountTextInput.tsx | 2 +- src/components/AmountWithoutCurrencyForm.tsx | 7 +- src/components/MoneyRequestAmountInput.tsx | 4 +- .../TextInputWithCurrencySymbol/types.ts | 2 +- src/libs/MoneyRequestUtils.ts | 21 +- 7 files changed, 326 insertions(+), 13 deletions(-) create mode 100644 patches/react-native+0.75.2+018+Add-regex-to-TextInput.patch diff --git a/patches/react-native+0.75.2+018+Add-regex-to-TextInput.patch b/patches/react-native+0.75.2+018+Add-regex-to-TextInput.patch new file mode 100644 index 000000000000..86239ed077e8 --- /dev/null +++ b/patches/react-native+0.75.2+018+Add-regex-to-TextInput.patch @@ -0,0 +1,299 @@ +diff --git a/node_modules/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js b/node_modules/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js +index 5e58ec4..ab988e9 100644 +--- a/node_modules/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js ++++ b/node_modules/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js +@@ -329,6 +329,12 @@ export type NativeProps = $ReadOnly<{| + */ + returnKeyType?: WithDefault, + ++ /** ++ * Restricts the text value to match the specified regular expression. Use this ++ * instead of implementing the logic in JS to avoid flicker. ++ */ ++ regex?: ?string, ++ + /** + * Limits the maximum number of characters that can be entered. Use this + * instead of implementing the logic in JS to avoid flicker. +@@ -689,6 +695,7 @@ export const __INTERNAL_VIEW_CONFIG: PartialViewConfig = { + process: require('../../StyleSheet/processColor').default, + }, + maxLength: true, ++ regex: true, + selectTextOnFocus: true, + textShadowRadius: true, + underlineColorAndroid: { +diff --git a/node_modules/react-native/Libraries/Components/TextInput/RCTTextInputViewConfig.js b/node_modules/react-native/Libraries/Components/TextInput/RCTTextInputViewConfig.js +index 1cb122f..737030d 100644 +--- a/node_modules/react-native/Libraries/Components/TextInput/RCTTextInputViewConfig.js ++++ b/node_modules/react-native/Libraries/Components/TextInput/RCTTextInputViewConfig.js +@@ -151,6 +151,7 @@ const RCTTextInputViewConfig = { + autoFocus: true, + lineBreakStrategyIOS: true, + smartInsertDelete: true, ++ regex: true, + ...ConditionallyIgnoredEventHandlers({ + onChange: true, + onSelectionChange: true, +diff --git a/node_modules/react-native/Libraries/Components/TextInput/TextInput.d.ts b/node_modules/react-native/Libraries/Components/TextInput/TextInput.d.ts +index 20501f7..76f30b9 100644 +--- a/node_modules/react-native/Libraries/Components/TextInput/TextInput.d.ts ++++ b/node_modules/react-native/Libraries/Components/TextInput/TextInput.d.ts +@@ -701,6 +701,12 @@ export interface TextInputProps + */ + inputMode?: InputModeOptions | undefined; + ++ /** ++ * Restricts the text value to match the specified regular expression. Use this ++ * instead of implementing the logic in JS to avoid flicker. ++ */ ++ regex?: string | undefined; ++ + /** + * Limits the maximum number of characters that can be entered. + * Use this instead of implementing the logic in JS to avoid flicker. +diff --git a/node_modules/react-native/Libraries/Components/TextInput/TextInput.flow.js b/node_modules/react-native/Libraries/Components/TextInput/TextInput.flow.js +index 2f35731..5bb94bc 100644 +--- a/node_modules/react-native/Libraries/Components/TextInput/TextInput.flow.js ++++ b/node_modules/react-native/Libraries/Components/TextInput/TextInput.flow.js +@@ -697,6 +697,12 @@ export type Props = $ReadOnly<{| + */ + maxFontSizeMultiplier?: ?number, + ++ /** ++ * Restricts the text value to match the specified regular expression. Use this ++ * instead of implementing the logic in JS to avoid flicker. ++ */ ++ regex?: ?string, ++ + /** + * Limits the maximum number of characters that can be entered. Use this + * instead of implementing the logic in JS to avoid flicker. +diff --git a/node_modules/react-native/Libraries/Components/TextInput/TextInput.js b/node_modules/react-native/Libraries/Components/TextInput/TextInput.js +index 8cfde15..4f3345c 100644 +--- a/node_modules/react-native/Libraries/Components/TextInput/TextInput.js ++++ b/node_modules/react-native/Libraries/Components/TextInput/TextInput.js +@@ -731,6 +731,12 @@ export type Props = $ReadOnly<{| + */ + maxFontSizeMultiplier?: ?number, + ++ /** ++ * Restricts the text value to match the specified regular expression. Use this ++ * instead of implementing the logic in JS to avoid flicker. ++ */ ++ regex?: ?string, ++ + /** + * Limits the maximum number of characters that can be entered. Use this + * instead of implementing the logic in JS to avoid flicker. +diff --git a/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputViewManager.mm b/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputViewManager.mm +index e367394..95f21f2 100644 +--- a/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputViewManager.mm ++++ b/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputViewManager.mm +@@ -59,6 +59,7 @@ @implementation RCTBaseTextInputViewManager { + RCT_EXPORT_VIEW_PROPERTY(inputAccessoryViewID, NSString) + RCT_EXPORT_VIEW_PROPERTY(textContentType, NSString) + RCT_EXPORT_VIEW_PROPERTY(passwordRules, NSString) ++RCT_EXPORT_VIEW_PROPERTY(regex, NSString) + + RCT_EXPORT_VIEW_PROPERTY(onChange, RCTBubblingEventBlock) + RCT_EXPORT_VIEW_PROPERTY(onKeyPressSync, RCTDirectEventBlock) +diff --git a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm +index db7cba4..f85f95a 100644 +--- a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm ++++ b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm +@@ -34,6 +34,7 @@ @implementation RCTTextInputComponentView { + UIView *_backedTextInputView; + NSUInteger _mostRecentEventCount; + NSAttributedString *_lastStringStateWasUpdatedWith; ++ NSRegularExpression *_regex; + + /* + * UIKit uses either UITextField or UITextView as its UIKit element for . UITextField is for single line +@@ -224,6 +225,13 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & + if (newTextInputProps.inputAccessoryViewID != oldTextInputProps.inputAccessoryViewID) { + _backedTextInputView.inputAccessoryViewID = RCTNSStringFromString(newTextInputProps.inputAccessoryViewID); + } ++ ++ if (newTextInputProps.regex != oldTextInputProps.regex) { ++ _regex = [NSRegularExpression regularExpressionWithPattern:RCTNSStringFromString(newTextInputProps.regex) ++ options:0 ++ error:nil]; ++ } ++ + [super updateProps:props oldProps:oldProps]; + + [self setDefaultInputAccessoryView]; +@@ -359,6 +367,14 @@ - (NSString *)textInputShouldChangeText:(NSString *)text inRange:(NSRange)range + } + } + ++ if (_regex) { ++ NSMutableString *newString = [_backedTextInputView.attributedText.string mutableCopy]; ++ [newString replaceCharactersInRange:range withString:text]; ++ if ([_regex numberOfMatchesInString:newString options:0 range:NSMakeRange(0, newString.length)] == 0) { ++ return nil; ++ } ++ } ++ + if (props.maxLength) { + NSInteger allowedLength = props.maxLength - _backedTextInputView.attributedText.string.length + range.length; + +diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java +index 2cceb14..8fdc0c1 100644 +--- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java ++++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java +@@ -824,6 +824,47 @@ public class ReactTextInputManager extends BaseViewManager 0) { ++ LinkedList list = new LinkedList<>(); ++ for (InputFilter currentFilter : currentFilters) { ++ if (!(currentFilter instanceof RegexFilter)) { ++ list.add(currentFilter); ++ } ++ } ++ if (!list.isEmpty()) { ++ newFilters = (InputFilter[]) list.toArray(new InputFilter[list.size()]); ++ } ++ } ++ } else { ++ if (currentFilters.length > 0) { ++ newFilters = currentFilters; ++ boolean replaced = false; ++ for (int i = 0; i < currentFilters.length; i++) { ++ if (currentFilters[i] instanceof RegexFilter) { ++ currentFilters[i] = new RegexFilter(regex); ++ replaced = true; ++ } ++ } ++ if (!replaced) { ++ newFilters = new InputFilter[currentFilters.length + 1]; ++ System.arraycopy(currentFilters, 0, newFilters, 0, currentFilters.length); ++ newFilters[currentFilters.length] = new RegexFilter(regex); ++ } ++ } else { ++ newFilters = new InputFilter[1]; ++ newFilters[0] = new RegexFilter(regex); ++ } ++ } ++ ++ view.setFilters(newFilters); ++ } ++ + @ReactProp(name = "maxLength") + public void setMaxLength(ReactEditText view, @Nullable Integer maxLength) { + InputFilter[] currentFilters = view.getFilters(); +@@ -854,7 +895,7 @@ public class ReactTextInputManager extends BaseViewManager MoneyRequestUtils.amountRegex(decimals, amountMaxLength), [decimals, amountMaxLength]); const formattedAmount = MoneyRequestUtils.replaceAllDigits(currentAmount, toLocaleDigit); const canUseTouchScreen = DeviceCapabilities.canUseTouchScreen(); @@ -261,6 +261,7 @@ function AmountForm( keyboardType={CONST.KEYBOARD_TYPE.DECIMAL_PAD} inputMode={CONST.INPUT_MODE.DECIMAL} errorText={errorText} + regex={regex} // eslint-disable-next-line react/jsx-props-no-spreading {...rest} /> @@ -300,6 +301,7 @@ function AmountForm( isCurrencyPressable={isCurrencyPressable} style={[styles.iouAmountTextInput]} containerStyle={[styles.iouAmountTextInputContainer]} + regex={regex} // eslint-disable-next-line react/jsx-props-no-spreading {...rest} /> diff --git a/src/components/AmountTextInput.tsx b/src/components/AmountTextInput.tsx index 52c32ce1f584..2e0d3e62afa0 100644 --- a/src/components/AmountTextInput.tsx +++ b/src/components/AmountTextInput.tsx @@ -39,7 +39,7 @@ type AmountTextInputProps = { /** Hide the focus styles on TextInput */ hideFocusedState?: boolean; -} & Pick; +} & Pick; function AmountTextInput( { diff --git a/src/components/AmountWithoutCurrencyForm.tsx b/src/components/AmountWithoutCurrencyForm.tsx index 78b7c84ecb54..fb01b135257d 100644 --- a/src/components/AmountWithoutCurrencyForm.tsx +++ b/src/components/AmountWithoutCurrencyForm.tsx @@ -1,7 +1,7 @@ import React, {useCallback, useMemo} from 'react'; import type {ForwardedRef} from 'react'; import useLocalize from '@hooks/useLocalize'; -import {addLeadingZero, replaceAllDigits, replaceCommasWithPeriod, stripSpacesFromAmount, validateAmount} from '@libs/MoneyRequestUtils'; +import {addLeadingZero, amountRegex, replaceAllDigits, replaceCommasWithPeriod, stripSpacesFromAmount, validateAmount} from '@libs/MoneyRequestUtils'; import CONST from '@src/CONST'; import TextInput from './TextInput'; import type {BaseTextInputProps, BaseTextInputRef} from './TextInput/BaseTextInput/types'; @@ -21,6 +21,7 @@ function AmountWithoutCurrencyForm( const {toLocaleDigit} = useLocalize(); const currentAmount = useMemo(() => (typeof amount === 'string' ? amount : ''), [amount]); + const decimals = 2; /** * Sets the selection and the amount accordingly to the value passed to the input @@ -33,7 +34,7 @@ function AmountWithoutCurrencyForm( const newAmountWithoutSpaces = stripSpacesFromAmount(newAmount); const replacedCommasAmount = replaceCommasWithPeriod(newAmountWithoutSpaces); const withLeadingZero = addLeadingZero(replacedCommasAmount); - if (!validateAmount(withLeadingZero, 2)) { + if (!validateAmount(withLeadingZero, decimals)) { return; } onInputChange?.(withLeadingZero); @@ -41,6 +42,7 @@ function AmountWithoutCurrencyForm( [onInputChange], ); + const regex = useMemo(() => amountRegex(decimals), [decimals]); const formattedAmount = replaceAllDigits(currentAmount, toLocaleDigit); return ( @@ -55,6 +57,7 @@ function AmountWithoutCurrencyForm( role={role} ref={ref} keyboardType={CONST.KEYBOARD_TYPE.DECIMAL_PAD} + regex={regex} // eslint-disable-next-line react/jsx-props-no-spreading {...rest} /> diff --git a/src/components/MoneyRequestAmountInput.tsx b/src/components/MoneyRequestAmountInput.tsx index 702e6c384b58..f4ac1ccb79c9 100644 --- a/src/components/MoneyRequestAmountInput.tsx +++ b/src/components/MoneyRequestAmountInput.tsx @@ -1,5 +1,5 @@ import type {ForwardedRef} from 'react'; -import React, {useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; import type {NativeSyntheticEvent, StyleProp, TextInputSelectionChangeEventData, TextStyle, ViewStyle} from 'react-native'; import useLocalize from '@hooks/useLocalize'; import {useMouseContext} from '@hooks/useMouseContext'; @@ -271,6 +271,7 @@ function MoneyRequestAmountInput( }); }, [amount, currency, onFormatAmount, formatAmountOnBlur, maxLength]); + const regex = useMemo(() => MoneyRequestUtils.amountRegex(decimals), [decimals]); const formattedAmount = MoneyRequestUtils.replaceAllDigits(currentAmount, toLocaleDigit); const {setMouseDown, setMouseUp} = useMouseContext(); @@ -331,6 +332,7 @@ function MoneyRequestAmountInput( onMouseDown={handleMouseDown} onMouseUp={handleMouseUp} contentWidth={contentWidth} + regex={regex} /> ); } diff --git a/src/components/TextInputWithCurrencySymbol/types.ts b/src/components/TextInputWithCurrencySymbol/types.ts index 619ed0fd84e6..1d744e974be3 100644 --- a/src/components/TextInputWithCurrencySymbol/types.ts +++ b/src/components/TextInputWithCurrencySymbol/types.ts @@ -77,6 +77,6 @@ type TextInputWithCurrencySymbolProps = { /** Hide the focus styles on TextInput */ hideFocusedState?: boolean; -} & Pick; +} & Pick; export default TextInputWithCurrencySymbolProps; diff --git a/src/libs/MoneyRequestUtils.ts b/src/libs/MoneyRequestUtils.ts index 206bb8509af6..30682b8d703a 100644 --- a/src/libs/MoneyRequestUtils.ts +++ b/src/libs/MoneyRequestUtils.ts @@ -38,15 +38,21 @@ function addLeadingZero(amount: string): string { } /** - * Check if amount is a decimal up to 3 digits + * Get amount regex string + */ +function amountRegex(decimals: number, amountMaxLength: number = CONST.IOU.AMOUNT_MAX_LENGTH): string { + return decimals === 0 + ? `^\\d{0,${amountMaxLength}}$` // Don't allow decimal point if decimals === 0 + : `^\\d{0,${amountMaxLength}}(\\.\\d{0,${decimals}})?$`; // Allow the decimal point and the desired number of digits after the point +} + +/** + * Check if string is a valid amount */ function validateAmount(amount: string, decimals: number, amountMaxLength: number = CONST.IOU.AMOUNT_MAX_LENGTH): boolean { - const regexString = - decimals === 0 - ? `^\\d{1,${amountMaxLength}}$` // Don't allow decimal point if decimals === 0 - : `^\\d{1,${amountMaxLength}}(\\.\\d{0,${decimals}})?$`; // Allow the decimal point and the desired number of digits after the point - const decimalNumberRegex = new RegExp(regexString, 'i'); - return amount === '' || decimalNumberRegex.test(amount); + const regexString = amountRegex(decimals, amountMaxLength); + const decimalNumberRegex = new RegExp(regexString); + return decimalNumberRegex.test(amount); } /** @@ -98,6 +104,7 @@ export { stripDecimalsFromAmount, stripSpacesFromAmount, replaceCommasWithPeriod, + amountRegex, validateAmount, validatePercentage, }; From ea1a85739db155d445651215cb24eb6bbba5c0b5 Mon Sep 17 00:00:00 2001 From: Abdelhafidh Belalia <16493223+s77rt@users.noreply.github.com> Date: Mon, 21 Oct 2024 21:48:33 +0100 Subject: [PATCH 2/4] Add back empty line --- src/components/AmountForm.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/AmountForm.tsx b/src/components/AmountForm.tsx index fc9e64eb9154..de3a1fe39829 100644 --- a/src/components/AmountForm.tsx +++ b/src/components/AmountForm.tsx @@ -237,6 +237,7 @@ function AmountForm( const allowedOS: string[] = [CONST.OS.MAC_OS, CONST.OS.IOS]; forwardDeletePressedRef.current = key === 'delete' || (allowedOS.includes(operatingSystem ?? '') && event.nativeEvent.ctrlKey && key === 'd'); }; + const regex = useMemo(() => MoneyRequestUtils.amountRegex(decimals, amountMaxLength), [decimals, amountMaxLength]); const formattedAmount = MoneyRequestUtils.replaceAllDigits(currentAmount, toLocaleDigit); const canUseTouchScreen = DeviceCapabilities.canUseTouchScreen(); From 41a34c24dece219c1183f6bc89bc010f2243a6e6 Mon Sep 17 00:00:00 2001 From: Abdelhafidh Belalia <16493223+s77rt@users.noreply.github.com> Date: Tue, 22 Oct 2024 22:29:12 +0100 Subject: [PATCH 3/4] preserve selection if input is invalid --- src/components/AmountWithoutCurrencyForm.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/components/AmountWithoutCurrencyForm.tsx b/src/components/AmountWithoutCurrencyForm.tsx index fb01b135257d..4a898b009f11 100644 --- a/src/components/AmountWithoutCurrencyForm.tsx +++ b/src/components/AmountWithoutCurrencyForm.tsx @@ -1,5 +1,6 @@ -import React, {useCallback, useMemo} from 'react'; +import React, {useCallback, useMemo, useState} from 'react'; import type {ForwardedRef} from 'react'; +import {NativeSyntheticEvent, TextInputSelectionChangeEventData} from 'react-native'; import useLocalize from '@hooks/useLocalize'; import {addLeadingZero, amountRegex, replaceAllDigits, replaceCommasWithPeriod, stripSpacesFromAmount, validateAmount} from '@libs/MoneyRequestUtils'; import CONST from '@src/CONST'; @@ -21,6 +22,10 @@ function AmountWithoutCurrencyForm( const {toLocaleDigit} = useLocalize(); const currentAmount = useMemo(() => (typeof amount === 'string' ? amount : ''), [amount]); + const [selection, setSelection] = useState({ + start: currentAmount.length, + end: currentAmount.length, + }); const decimals = 2; /** @@ -35,6 +40,9 @@ function AmountWithoutCurrencyForm( const replacedCommasAmount = replaceCommasWithPeriod(newAmountWithoutSpaces); const withLeadingZero = addLeadingZero(replacedCommasAmount); if (!validateAmount(withLeadingZero, decimals)) { + // Use a shallow copy of selection to trigger setSelection + // More info: https://github.com/Expensify/App/issues/16385 + setSelection((prevSelection) => ({...prevSelection})); return; } onInputChange?.(withLeadingZero); @@ -49,6 +57,10 @@ function AmountWithoutCurrencyForm( ) => { + setSelection(e.nativeEvent.selection); + }} inputID={inputID} name={name} label={label} From 699aa24c758e0dac8fc7a2f9a943c430b02b5563 Mon Sep 17 00:00:00 2001 From: Abdelhafidh Belalia <16493223+s77rt@users.noreply.github.com> Date: Tue, 22 Oct 2024 22:45:49 +0100 Subject: [PATCH 4/4] fix lint --- src/components/AmountWithoutCurrencyForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/AmountWithoutCurrencyForm.tsx b/src/components/AmountWithoutCurrencyForm.tsx index 4a898b009f11..6a9fc22f68f8 100644 --- a/src/components/AmountWithoutCurrencyForm.tsx +++ b/src/components/AmountWithoutCurrencyForm.tsx @@ -1,6 +1,6 @@ import React, {useCallback, useMemo, useState} from 'react'; import type {ForwardedRef} from 'react'; -import {NativeSyntheticEvent, TextInputSelectionChangeEventData} from 'react-native'; +import type {NativeSyntheticEvent, TextInputSelectionChangeEventData} from 'react-native'; import useLocalize from '@hooks/useLocalize'; import {addLeadingZero, amountRegex, replaceAllDigits, replaceCommasWithPeriod, stripSpacesFromAmount, validateAmount} from '@libs/MoneyRequestUtils'; import CONST from '@src/CONST';