diff --git a/contributingGuides/PERFORMANCE_METRICS.md b/contributingGuides/PERFORMANCE_METRICS.md
index d8bf79970006..84afc7daa8de 100644
--- a/contributingGuides/PERFORMANCE_METRICS.md
+++ b/contributingGuides/PERFORMANCE_METRICS.md
@@ -25,6 +25,9 @@ Project is using Firebase for tracking these metrics. However, not all of them a
| `open_report_thread` | ✅ | Time taken to open a thread in a report. **Platforms:** All | Starts when user presses Report Action Item. | Stops when the `ReportActionsList` finishes laying out. |
| `send_message` | ✅ | Time taken to send a message. **Platforms:** All | Starts when the new message is sent. | Stops when the message is being rendered in the chat. |
| `pusher_ping_pong` | ✅ | The time it takes to receive a PONG event through Pusher. **Platforms:** All | Starts every minute and repeats on the minute. | Stops when the event is received from the server. |
+| `open_create_expense` | ❌ | Time taken to open "Create expense" screen. **Platforms:** All | Starts when the `Create expense` is pressed. | Stops when the `IOURequestStartPage` finishes laying out. |
+| `open_create_expense_contact` | ❌ | Time taken to "Create expense" screen. **Platforms:** All | Starts when the `Next` button on `Create expense` screen is pressed. | Stops when the `IOURequestStepParticipants` finishes laying out. |
+| `open_create_expense_approve` | ❌ | Time taken to "Create expense" screen. **Platforms:** All | Starts when the `Contact` on `Choose recipient` screen is selected. | Stops when the `IOURequestStepConfirmation` finishes laying out. |
## Documentation Maintenance
@@ -38,7 +41,6 @@ To ensure this documentation remains accurate and useful, please adhere to the f
4. **Code Location Changes**: If the placement of a metric in the code changes, update the "Start time" and "End time" columns to reflect the new location.
-
## Additional Resources
- [Firebase Documentation](https://firebase.google.com/docs)
diff --git a/src/CONST.ts b/src/CONST.ts
index 673f484e4238..aac056a11cb5 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -1519,6 +1519,9 @@ const CONST = {
SIDEBAR_LOADED: 'sidebar_loaded',
LOAD_SEARCH_OPTIONS: 'load_search_options',
SEND_MESSAGE: 'send_message',
+ OPEN_CREATE_EXPENSE: 'open_create_expense',
+ OPEN_CREATE_EXPENSE_CONTACT: 'open_create_expense_contact',
+ OPEN_CREATE_EXPENSE_APPROVE: 'open_create_expense_approve',
APPLY_AIRSHIP_UPDATES: 'apply_airship_updates',
APPLY_PUSHER_UPDATES: 'apply_pusher_updates',
APPLY_HTTPS_UPDATES: 'apply_https_updates',
diff --git a/src/components/BigNumberPad.tsx b/src/components/BigNumberPad.tsx
index 6b7a88ded690..30d5c0f45d1d 100644
--- a/src/components/BigNumberPad.tsx
+++ b/src/components/BigNumberPad.tsx
@@ -97,6 +97,7 @@ function BigNumberPad({numberPressed, longPressHandlerStateChanged = () => {}, i
e.preventDefault();
}}
isLongPressDisabled={isLongPressDisabled}
+ testID={`button_${column}`}
/>
);
})}
diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx
index fe3e28dcf50c..1226fbc669d3 100644
--- a/src/components/Button/index.tsx
+++ b/src/components/Button/index.tsx
@@ -123,9 +123,6 @@ type ButtonProps = Partial & {
/** Id to use for this button */
id?: string;
- /** Used to locate this button in ui tests */
- testID?: string;
-
/** Accessibility label for the component */
accessibilityLabel?: string;
@@ -147,6 +144,9 @@ type ButtonProps = Partial & {
/** Whether the Enter keyboard listening is active whether or not the screen that contains the button is focused */
isPressOnEnterActive?: boolean;
+ /** The testID of the button. Used to locate this view in end-to-end tests. */
+ testID?: string;
+
/** Whether is a nested button inside other button, since nesting buttons isn't valid html */
isNested?: boolean;
diff --git a/src/components/FloatingActionButton.tsx b/src/components/FloatingActionButton.tsx
index 2cb3a25c58c6..e7c3ebcaad85 100644
--- a/src/components/FloatingActionButton.tsx
+++ b/src/components/FloatingActionButton.tsx
@@ -106,6 +106,7 @@ function FloatingActionButton({onPress, isActive, accessibilityLabel, role, isTo
onLongPress={() => {}}
role={role}
shouldUseHapticsOnLongPress={false}
+ testID="floating-action-button"
>
;
type Selection = {
@@ -107,7 +110,7 @@ const getNewSelection = (oldSelection: Selection, prevLength: number, newLength:
return {start: cursorPosition, end: cursorPosition};
};
-const defaultOnFormatAmount = (amount: number, currency?: string) => CurrencyUtils.convertToFrontendAmountAsString(amount, currency ?? CONST.CURRENCY.USD);
+const defaultOnFormatAmount = (amount: number, currency?: string) => convertToFrontendAmountAsString(amount, currency ?? CONST.CURRENCY.USD);
function MoneyRequestAmountInput(
{
@@ -129,6 +132,7 @@ function MoneyRequestAmountInput(
autoGrow = true,
autoGrowExtraSpace,
contentWidth,
+ testID,
...props
}: MoneyRequestAmountInputProps,
forwardedRef: ForwardedRef,
@@ -139,7 +143,7 @@ function MoneyRequestAmountInput(
const amountRef = useRef(undefined);
- const decimals = CurrencyUtils.getCurrencyDecimals(currency);
+ const decimals = getCurrencyDecimals(currency);
const selectedAmountAsString = amount ? onFormatAmount(amount, currency) : '';
const [currentAmount, setCurrentAmount] = useState(selectedAmountAsString);
@@ -161,13 +165,11 @@ function MoneyRequestAmountInput(
(newAmount: string) => {
// Remove spaces from the newAmount value because Safari on iOS adds spaces when pasting a copied value
// More info: https://github.com/Expensify/App/issues/16974
- const newAmountWithoutSpaces = MoneyRequestUtils.stripSpacesFromAmount(newAmount);
- const finalAmount = newAmountWithoutSpaces.includes('.')
- ? MoneyRequestUtils.stripCommaFromAmount(newAmountWithoutSpaces)
- : MoneyRequestUtils.replaceCommasWithPeriod(newAmountWithoutSpaces);
+ const newAmountWithoutSpaces = stripSpacesFromAmount(newAmount);
+ const finalAmount = newAmountWithoutSpaces.includes('.') ? stripCommaFromAmount(newAmountWithoutSpaces) : replaceCommasWithPeriod(newAmountWithoutSpaces);
// Use a shallow copy of selection to trigger setSelection
// More info: https://github.com/Expensify/App/issues/16385
- if (!MoneyRequestUtils.validateAmount(finalAmount, decimals)) {
+ if (!validateAmount(finalAmount, decimals)) {
setSelection((prevSelection) => ({...prevSelection}));
return;
}
@@ -176,7 +178,7 @@ function MoneyRequestAmountInput(
willSelectionBeUpdatedManually.current = true;
let hasSelectionBeenSet = false;
- const strippedAmount = MoneyRequestUtils.stripCommaFromAmount(finalAmount);
+ const strippedAmount = stripCommaFromAmount(finalAmount);
amountRef.current = strippedAmount;
setCurrentAmount((prevAmount) => {
const isForwardDelete = prevAmount.length > strippedAmount.length && forwardDeletePressedRef.current;
@@ -233,12 +235,12 @@ function MoneyRequestAmountInput(
// Modifies the amount to match the decimals for changed currency.
useEffect(() => {
// If the changed currency supports decimals, we can return
- if (MoneyRequestUtils.validateAmount(currentAmount, decimals)) {
+ if (validateAmount(currentAmount, decimals)) {
return;
}
// If the changed currency doesn't support decimals, we can strip the decimals
- setNewAmount(MoneyRequestUtils.stripDecimalsFromAmount(currentAmount));
+ setNewAmount(stripDecimalsFromAmount(currentAmount));
// we want to update only when decimals change (setNewAmount also changes when decimals change).
// eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
@@ -249,7 +251,7 @@ function MoneyRequestAmountInput(
*/
const textInputKeyPress = ({nativeEvent}: NativeSyntheticEvent) => {
const key = nativeEvent?.key.toLowerCase();
- if (Browser.isMobileSafari() && key === CONST.PLATFORM_SPECIFIC_KEYS.CTRL.DEFAULT) {
+ if (isMobileSafari() && key === CONST.PLATFORM_SPECIFIC_KEYS.CTRL.DEFAULT) {
// Optimistically anticipate forward-delete on iOS Safari (in cases where the Mac Accessiblity keyboard is being
// used for input). If the Control-D shortcut doesn't get sent, the ref will still be reset on the next key press.
forwardDeletePressedRef.current = true;
@@ -276,7 +278,7 @@ function MoneyRequestAmountInput(
});
}, [amount, currency, onFormatAmount, formatAmountOnBlur, maxLength]);
- const formattedAmount = MoneyRequestUtils.replaceAllDigits(currentAmount, toLocaleDigit);
+ const formattedAmount = replaceAllDigits(currentAmount, toLocaleDigit);
const {setMouseDown, setMouseUp} = useMouseContext();
const handleMouseDown = (e: React.MouseEvent) => {
@@ -340,6 +342,7 @@ function MoneyRequestAmountInput(
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
contentWidth={contentWidth}
+ testID={testID}
/>
);
}
diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx
index 96fe02d69ca2..4e5c9c6994fc 100644
--- a/src/components/PopoverMenu.tsx
+++ b/src/components/PopoverMenu.tsx
@@ -61,6 +61,9 @@ type PopoverMenuItem = MenuItemProps & {
rightIcon?: React.FC;
key?: string;
+
+ /** Test identifier used to find elements in unit and e2e tests */
+ testID?: string;
};
type PopoverModalProps = Pick &
@@ -198,8 +201,8 @@ function PopoverMenu({
shouldUpdateFocusedIndex = true,
shouldUseModalPaddingStyle,
shouldUseNewModal,
- testID,
shouldAvoidSafariException = false,
+ testID,
}: PopoverMenuProps) {
const styles = useThemeStyles();
const theme = useTheme();
@@ -278,7 +281,7 @@ function PopoverMenu({
};
const renderedMenuItems = currentMenuItems.map((item, menuIndex) => {
- const {text, onSelected, subMenuItems, shouldCallAfterModalHide, key, ...menuItemProps} = item;
+ const {text, onSelected, subMenuItems, shouldCallAfterModalHide, key, testID: menuItemTestID, ...menuItemProps} = item;
return (
selectItem(menuIndex)}
focused={focusedIndex === menuIndex}
diff --git a/src/components/Pressable/GenericPressable/index.e2e.tsx b/src/components/Pressable/GenericPressable/index.e2e.tsx
index 5d997977a7e0..e3e701912326 100644
--- a/src/components/Pressable/GenericPressable/index.e2e.tsx
+++ b/src/components/Pressable/GenericPressable/index.e2e.tsx
@@ -1,22 +1,25 @@
import React, {forwardRef, useEffect} from 'react';
+import {DeviceEventEmitter} from 'react-native';
import GenericPressable from './implementation';
import type {PressableRef} from './types';
import type PressableProps from './types';
const pressableRegistry = new Map();
-function getPressableProps(nativeID: string): PressableProps | undefined {
- return pressableRegistry.get(nativeID);
+function getPressableProps(testId: string): PressableProps | undefined {
+ return pressableRegistry.get(testId);
}
function E2EGenericPressableWrapper(props: PressableProps, ref: PressableRef) {
useEffect(() => {
- const nativeId = props.nativeID;
- if (!nativeId) {
+ const testId = props.testID;
+ if (!testId) {
return;
}
- console.debug(`[E2E] E2EGenericPressableWrapper: Registering pressable with nativeID: ${nativeId}`);
- pressableRegistry.set(nativeId, props);
+ console.debug(`[E2E] E2EGenericPressableWrapper: Registering pressable with testID: ${testId}`);
+ pressableRegistry.set(testId, props);
+
+ DeviceEventEmitter.emit('onBecameVisible', testId);
}, [props]);
return (
diff --git a/src/components/Search/SearchRouter/SearchButton.tsx b/src/components/Search/SearchRouter/SearchButton.tsx
index e9d0de8cb9b5..63ddbee71abb 100644
--- a/src/components/Search/SearchRouter/SearchButton.tsx
+++ b/src/components/Search/SearchRouter/SearchButton.tsx
@@ -28,7 +28,7 @@ function SearchButton({style}: SearchButtonProps) {
({
onFocus = () => {},
hoverStyle,
onLongPressRow,
+ testID,
}: BaseListItemProps) {
const theme = useTheme();
const styles = useThemeStyles();
@@ -112,6 +113,7 @@ function BaseListItem({
onMouseLeave={handleMouseLeave}
tabIndex={item.tabIndex}
wrapperStyle={pressableWrapperStyle}
+ testID={testID}
>
({
onFocus={onFocus}
shouldSyncFocus={shouldSyncFocus}
shouldDisplayRBR={!shouldShowCheckBox}
+ testID={item.text}
>
{(hovered?: boolean) => (
= CommonListItemProps & {
hoverStyle?: StyleProp;
/** Errors that this user may contain */
shouldDisplayRBR?: boolean;
+ /** Test ID of the component. Used to locate this view in end-to-end tests. */
+ testID?: string;
};
type UserListItemProps = ListItemProps & {
diff --git a/src/components/TabSelector/TabSelector.tsx b/src/components/TabSelector/TabSelector.tsx
index daa728e87978..f0867bd82beb 100644
--- a/src/components/TabSelector/TabSelector.tsx
+++ b/src/components/TabSelector/TabSelector.tsx
@@ -24,29 +24,30 @@ type TabSelectorProps = MaterialTopTabBarProps & {
shouldShowLabelWhenInactive?: boolean;
};
-type IconAndTitle = {
+type IconTitleAndTestID = {
icon: IconAsset;
title: string;
+ testID?: string;
};
-function getIconAndTitle(route: string, translate: LocaleContextProps['translate']): IconAndTitle {
+function getIconTitleAndTestID(route: string, translate: LocaleContextProps['translate']): IconTitleAndTestID {
switch (route) {
case CONST.TAB_REQUEST.MANUAL:
- return {icon: Expensicons.Pencil, title: translate('tabSelector.manual')};
+ return {icon: Expensicons.Pencil, title: translate('tabSelector.manual'), testID: 'manual'};
case CONST.TAB_REQUEST.SCAN:
- return {icon: Expensicons.ReceiptScan, title: translate('tabSelector.scan')};
+ return {icon: Expensicons.ReceiptScan, title: translate('tabSelector.scan'), testID: 'scan'};
case CONST.TAB.NEW_CHAT:
- return {icon: Expensicons.User, title: translate('tabSelector.chat')};
+ return {icon: Expensicons.User, title: translate('tabSelector.chat'), testID: 'chat'};
case CONST.TAB.NEW_ROOM:
- return {icon: Expensicons.Hashtag, title: translate('tabSelector.room')};
+ return {icon: Expensicons.Hashtag, title: translate('tabSelector.room'), testID: 'room'};
case CONST.TAB_REQUEST.DISTANCE:
- return {icon: Expensicons.Car, title: translate('common.distance')};
+ return {icon: Expensicons.Car, title: translate('common.distance'), testID: 'distance'};
case CONST.TAB.SHARE.SHARE:
- return {icon: Expensicons.UploadAlt, title: translate('common.share')};
+ return {icon: Expensicons.UploadAlt, title: translate('common.share'), testID: 'share'};
case CONST.TAB.SHARE.SUBMIT:
- return {icon: Expensicons.Receipt, title: translate('common.submit')};
+ return {icon: Expensicons.Receipt, title: translate('common.submit'), testID: 'submit'};
case CONST.TAB_REQUEST.PER_DIEM:
- return {icon: Expensicons.CalendarSolid, title: translate('common.perDiem')};
+ return {icon: Expensicons.CalendarSolid, title: translate('common.perDiem'), testID: 'perDiem'};
default:
throw new Error(`Route ${route} has no icon nor title set.`);
}
@@ -74,7 +75,7 @@ function TabSelector({state, navigation, onTabPress = () => {}, position, onFocu
const activeOpacity = getOpacity({routesLength: state.routes.length, tabIndex: index, active: true, affectedTabs: affectedAnimatedTabs, position, isActive});
const inactiveOpacity = getOpacity({routesLength: state.routes.length, tabIndex: index, active: false, affectedTabs: affectedAnimatedTabs, position, isActive});
const backgroundColor = getBackgroundColor({routesLength: state.routes.length, tabIndex: index, affectedTabs: affectedAnimatedTabs, theme, position, isActive});
- const {icon, title} = getIconAndTitle(route.name, translate);
+ const {icon, title, testID} = getIconTitleAndTestID(route.name, translate);
const onPress = () => {
if (isActive) {
return;
@@ -106,6 +107,7 @@ function TabSelector({state, navigation, onTabPress = () => {}, position, onFocu
inactiveOpacity={inactiveOpacity}
backgroundColor={backgroundColor}
isActive={isActive}
+ testID={testID}
shouldShowLabelWhenInactive={shouldShowLabelWhenInactive}
/>
);
diff --git a/src/components/TabSelector/TabSelectorItem.tsx b/src/components/TabSelector/TabSelectorItem.tsx
index b5d067d410c3..61e1f3d3e748 100644
--- a/src/components/TabSelector/TabSelectorItem.tsx
+++ b/src/components/TabSelector/TabSelectorItem.tsx
@@ -35,6 +35,9 @@ type TabSelectorItemProps = {
/** Whether to show the label when the tab is inactive */
shouldShowLabelWhenInactive?: boolean;
+
+ /** Test identifier used to find elements in unit and e2e tests */
+ testID?: string;
};
function TabSelectorItem({
@@ -46,6 +49,7 @@ function TabSelectorItem({
inactiveOpacity = 1,
isActive = false,
shouldShowLabelWhenInactive = true,
+ testID,
}: TabSelectorItemProps) {
const styles = useThemeStyles();
const [isHovered, setIsHovered] = useState(false);
@@ -64,6 +68,7 @@ function TabSelectorItem({
onHoverOut={() => setIsHovered(false)}
role={CONST.ROLE.BUTTON}
dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}}
+ testID={testID}
>
{},
+ shouldDelayFocus = false,
+ multiline = false,
+ shouldInterceptSwipe = false,
+ autoCorrect = true,
+ prefixCharacter = '',
+ suffixCharacter = '',
+ inputID,
+ type = 'default',
+ excludedMarkdownStyles = [],
+ shouldShowClearButton = false,
+ shouldUseDisabledStyles = true,
+ prefixContainerStyle = [],
+ prefixStyle = [],
+ suffixContainerStyle = [],
+ suffixStyle = [],
+ contentWidth,
+ loadingSpinnerStyle,
+ uncontrolled = false,
+ placeholderTextColor,
+ ...inputProps
+ }: BaseTextInputProps,
+ ref: ForwardedRef,
+) {
+ const InputComponent = InputComponentMap.get(type) ?? RNTextInput;
+ const isMarkdownEnabled = type === 'markdown';
+ const isAutoGrowHeightMarkdown = isMarkdownEnabled && autoGrowHeight;
+
+ const theme = useTheme();
+ const styles = useThemeStyles();
+ const markdownStyle = useMarkdownStyle(undefined, excludedMarkdownStyles);
+ const {hasError = false} = inputProps;
+ const StyleUtils = useStyleUtils();
+ const {translate} = useLocalize();
+
+ // Disabling this line for safeness as nullish coalescing works only if value is undefined or null
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ const initialValue = value || defaultValue || '';
+ const initialActiveLabel = !!forceActiveLabel || initialValue.length > 0 || !!prefixCharacter || !!suffixCharacter;
+
+ const [isFocused, setIsFocused] = useState(false);
+ const [passwordHidden, setPasswordHidden] = useState(inputProps.secureTextEntry);
+ const [textInputWidth, setTextInputWidth] = useState(0);
+ const [textInputHeight, setTextInputHeight] = useState(0);
+ const [width, setWidth] = useState(null);
+
+ const labelScale = useSharedValue(initialActiveLabel ? ACTIVE_LABEL_SCALE : INACTIVE_LABEL_SCALE);
+ const labelTranslateY = useSharedValue(initialActiveLabel ? ACTIVE_LABEL_TRANSLATE_Y : INACTIVE_LABEL_TRANSLATE_Y);
+
+ const input = useRef(null);
+ const isLabelActive = useRef(initialActiveLabel);
+ const didScrollToEndRef = useRef(false);
+
+ useHtmlPaste(input as MutableRefObject, undefined, isMarkdownEnabled);
+
+ // AutoFocus which only works on mount:
+ useEffect(() => {
+ // We are manually managing focus to prevent this issue: https://github.com/Expensify/App/issues/4514
+ if (!autoFocus || !input.current) {
+ return;
+ }
+
+ if (shouldDelayFocus) {
+ const focusTimeout = setTimeout(() => input?.current?.focus(), CONST.ANIMATED_TRANSITION);
+ return () => clearTimeout(focusTimeout);
+ }
+ input.current.focus();
+ // We only want this to run on mount
+ // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
+ }, []);
+
+ const animateLabel = useCallback(
+ (translateY: number, scale: number) => {
+ labelScale.set(withSpring(scale, {overshootClamping: false}));
+ labelTranslateY.set(withSpring(translateY, {overshootClamping: false}));
+ },
+ [labelScale, labelTranslateY],
+ );
+
+ const activateLabel = useCallback(() => {
+ const newValue = value ?? '';
+
+ if (newValue.length < 0 || isLabelActive.current) {
+ return;
+ }
+
+ animateLabel(ACTIVE_LABEL_TRANSLATE_Y, ACTIVE_LABEL_SCALE);
+ isLabelActive.current = true;
+ }, [animateLabel, value]);
+
+ const deactivateLabel = useCallback(() => {
+ const newValue = value ?? '';
+
+ if (!!forceActiveLabel || newValue.length !== 0 || prefixCharacter || suffixCharacter) {
+ return;
+ }
+
+ animateLabel(INACTIVE_LABEL_TRANSLATE_Y, INACTIVE_LABEL_SCALE);
+ isLabelActive.current = false;
+ }, [animateLabel, forceActiveLabel, prefixCharacter, suffixCharacter, value]);
+
+ const onFocus = (event: NativeSyntheticEvent) => {
+ inputProps.onFocus?.(event);
+ setIsFocused(true);
+ };
+
+ const onBlur = (event: NativeSyntheticEvent) => {
+ inputProps.onBlur?.(event);
+ setIsFocused(false);
+ };
+
+ const onPress = (event?: GestureResponderEvent | KeyboardEvent) => {
+ if (!!inputProps.disabled || !event) {
+ return;
+ }
+
+ inputProps.onPress?.(event);
+
+ if ('isDefaultPrevented' in event && !event?.isDefaultPrevented()) {
+ input.current?.focus();
+ }
+ };
+
+ const onLayout = useCallback(
+ (event: LayoutChangeEvent) => {
+ if (!autoGrowHeight && multiline) {
+ return;
+ }
+
+ const layout = event.nativeEvent.layout;
+
+ setWidth((prevWidth: number | null) => (autoGrowHeight ? layout.width : prevWidth));
+ },
+ [autoGrowHeight, multiline],
+ );
+
+ // The ref is needed when the component is uncontrolled and we don't have a value prop
+ const hasValueRef = useRef(initialValue.length > 0);
+ const inputValue = value ?? '';
+ const hasValue = inputValue.length > 0 || hasValueRef.current;
+
+ // Activate or deactivate the label when either focus changes, or for controlled
+ // components when the value prop changes:
+ useEffect(() => {
+ if (
+ hasValue ||
+ isFocused ||
+ // If the text has been supplied by Chrome autofill, the value state is not synced with the value
+ // as Chrome doesn't trigger a change event. When there is autofill text, keep the label activated.
+ isInputAutoFilled(input.current)
+ ) {
+ activateLabel();
+ } else {
+ deactivateLabel();
+ }
+ }, [activateLabel, deactivateLabel, hasValue, isFocused]);
+
+ // When the value prop gets cleared externally, we need to keep the ref in sync:
+ useEffect(() => {
+ // Return early when component uncontrolled, or we still have a value
+ if (value === undefined || value) {
+ return;
+ }
+ hasValueRef.current = false;
+ }, [value]);
+
+ /**
+ * Set Value & activateLabel
+ */
+ const setValue = (newValue: string) => {
+ onInputChange?.(newValue);
+
+ if (inputProps.onChangeText) {
+ Str.result(inputProps.onChangeText, newValue);
+ }
+ if (newValue && newValue.length > 0) {
+ hasValueRef.current = true;
+ // When the componment is uncontrolled, we need to manually activate the label:
+ if (value === undefined) {
+ activateLabel();
+ }
+ } else {
+ hasValueRef.current = false;
+ }
+ };
+
+ const togglePasswordVisibility = useCallback(() => {
+ setPasswordHidden((prevPasswordHidden: boolean | undefined) => !prevPasswordHidden);
+ }, []);
+
+ const hasLabel = !!label?.length;
+ const isReadOnly = inputProps.readOnly ?? inputProps.disabled;
+ // Disabling this line for safeness as nullish coalescing works only if the value is undefined or null, and errorText can be an empty string
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ const inputHelpText = errorText || hint;
+ const newPlaceholder = !!prefixCharacter || !!suffixCharacter || isFocused || !hasLabel || (hasLabel && forceActiveLabel) ? placeholder : undefined;
+ const newTextInputContainerStyles: StyleProp = StyleSheet.flatten([
+ styles.textInputContainer,
+ textInputContainerStyles,
+ (autoGrow || !!contentWidth) && StyleUtils.getWidthStyle(textInputWidth),
+ !hideFocusedState && isFocused && styles.borderColorFocus,
+ (!!hasError || !!errorText) && styles.borderColorDanger,
+ autoGrowHeight && {scrollPaddingTop: typeof maxAutoGrowHeight === 'number' ? 2 * maxAutoGrowHeight : undefined},
+ isAutoGrowHeightMarkdown && styles.pb2,
+ ]);
+ const isMultiline = multiline || autoGrowHeight;
+
+ const inputPaddingLeft = !!prefixCharacter && StyleUtils.getPaddingLeft(StyleUtils.getCharacterPadding(prefixCharacter) + styles.pl1.paddingLeft);
+ const inputPaddingRight = !!suffixCharacter && StyleUtils.getPaddingRight(StyleUtils.getCharacterPadding(suffixCharacter) + styles.pr1.paddingRight);
+ // This is workaround for https://github.com/Expensify/App/issues/47939: in case when user is using Chrome on Android we set inputMode to 'search' to disable autocomplete bar above the keyboard.
+ // If we need some other inputMode (eg. 'decimal'), then the autocomplete bar will show, but we can do nothing about it as it's a known Chrome bug.
+ const inputMode = inputProps.inputMode ?? (isMobileChrome() ? 'search' : undefined);
+
+ return (
+ <>
+
+
+
+ {hasLabel ? (
+ <>
+ {/* Adding this background to the label only for multiline text input,
+ to prevent text overlapping with label when scrolling */}
+ {isMultiline && }
+
+ >
+ ) : null}
+
+ {!!iconLeft && (
+
+
+
+ )}
+ {!!prefixCharacter && (
+
+
+ {prefixCharacter}
+
+
+ )}
+ {
+ const baseTextInputRef = element as BaseTextInputRef | null;
+ if (typeof ref === 'function') {
+ ref(baseTextInputRef);
+ } else if (ref && 'current' in ref) {
+ // eslint-disable-next-line no-param-reassign
+ ref.current = baseTextInputRef;
+ }
+
+ input.current = element as HTMLInputElement | null;
+ }}
+ // eslint-disable-next-line
+ {...inputProps}
+ autoCorrect={inputProps.secureTextEntry ? false : autoCorrect}
+ placeholder={newPlaceholder}
+ placeholderTextColor={placeholderTextColor ?? theme.placeholderText}
+ underlineColorAndroid="transparent"
+ style={[
+ styles.flex1,
+ styles.w100,
+ inputStyle,
+ (!hasLabel || isMultiline) && styles.pv0,
+ inputPaddingLeft,
+ inputPaddingRight,
+ inputProps.secureTextEntry && styles.secureInput,
+
+ // Explicitly change boxSizing attribute for mobile chrome in order to apply line-height
+ // for the issue mentioned here https://github.com/Expensify/App/issues/26735
+ // Set overflow property to enable the parent flexbox to shrink its size
+ // (See https://github.com/Expensify/App/issues/41766)
+ !isMultiline && isMobileChrome() && {boxSizing: 'content-box', height: undefined, ...styles.overflowAuto},
+
+ // Stop scrollbar flashing when breaking lines with autoGrowHeight enabled.
+ ...(autoGrowHeight && !isAutoGrowHeightMarkdown
+ ? [StyleUtils.getAutoGrowHeightInputStyle(textInputHeight, typeof maxAutoGrowHeight === 'number' ? maxAutoGrowHeight : 0), styles.verticalAlignTop]
+ : []),
+ isAutoGrowHeightMarkdown ? [StyleUtils.getMarkdownMaxHeight(maxAutoGrowHeight), styles.verticalAlignTop] : undefined,
+ // Add disabled color theme when field is not editable.
+ inputProps.disabled && shouldUseDisabledStyles && styles.textInputDisabled,
+ styles.pointerEventsAuto,
+ ]}
+ multiline={isMultiline}
+ maxLength={maxLength}
+ onFocus={onFocus}
+ onBlur={onBlur}
+ onChangeText={setValue}
+ secureTextEntry={passwordHidden}
+ onPressOut={inputProps.onPress}
+ showSoftInputOnFocus={!disableKeyboard}
+ inputMode={inputMode}
+ value={uncontrolled ? undefined : value}
+ selection={inputProps.selection}
+ readOnly={isReadOnly}
+ defaultValue={defaultValue}
+ markdownStyle={markdownStyle}
+ />
+ {!!suffixCharacter && (
+
+
+ {suffixCharacter}
+
+
+ )}
+ {isFocused && !isReadOnly && shouldShowClearButton && !!value && (
+ {
+ if (didScrollToEndRef.current || !input.current) {
+ return;
+ }
+ scrollToRight(input.current);
+ didScrollToEndRef.current = true;
+ }}
+ >
+ setValue('')} />
+
+ )}
+ {inputProps.isLoading !== undefined && (
+
+ )}
+ {!!inputProps.secureTextEntry && (
+ {
+ e.preventDefault();
+ }}
+ accessibilityLabel={translate('common.visible')}
+ >
+
+
+ )}
+ {!inputProps.secureTextEntry && !!icon && (
+
+
+
+ )}
+
+
+
+ {!!inputHelpText && (
+
+ )}
+
+ {!!contentWidth && (
+ {
+ if (e.nativeEvent.layout.width === 0 && e.nativeEvent.layout.height === 0) {
+ return;
+ }
+ setTextInputWidth(e.nativeEvent.layout.width);
+ setTextInputHeight(e.nativeEvent.layout.height);
+ }}
+ >
+
+ {/* \u200B added to solve the issue of not expanding the text input enough when the value ends with '\n' (https://github.com/Expensify/App/issues/21271) */}
+ {value ? `${value}${value.endsWith('\n') ? '\u200B' : ''}` : placeholder}
+
+
+ )}
+ {/*
+ Text input component doesn't support auto grow by default.
+ We're using a hidden text input to achieve that.
+ This text view is used to calculate width or height of the input value given textStyle in this component.
+ This Text component is intentionally positioned out of the screen.
+ */}
+ {(!!autoGrow || autoGrowHeight) && !isAutoGrowHeightMarkdown && (
+ // Add +2 to width on Safari browsers so that text is not cut off due to the cursor or when changing the value
+ // Reference: https://github.com/Expensify/App/issues/8158, https://github.com/Expensify/App/issues/26628
+ // For mobile Chrome, ensure proper display of the text selection handle (blue bubble down).
+ // Reference: https://github.com/Expensify/App/issues/34921
+ {
+ if (e.nativeEvent.layout.width === 0 && e.nativeEvent.layout.height === 0) {
+ return;
+ }
+ let additionalWidth = 0;
+ if (isMobileSafari() || isSafari() || isMobileChrome()) {
+ additionalWidth = 2;
+ }
+ setTextInputWidth(e.nativeEvent.layout.width + additionalWidth);
+ setTextInputHeight(e.nativeEvent.layout.height);
+ }}
+ >
+ {/* \u200B added to solve the issue of not expanding the text input enough when the value ends with '\n' (https://github.com/Expensify/App/issues/21271) */}
+ {value ? `${value}${value.endsWith('\n') ? '\u200B' : ''}` : placeholder}
+
+ )}
+ >
+ );
+}
+
+BaseTextInput.displayName = 'BaseTextInput';
+
+export default forwardRef(BaseTextInput);
diff --git a/src/components/TextInput/BaseTextInput/index.e2e.tsx b/src/components/TextInput/BaseTextInput/index.e2e.tsx
new file mode 100644
index 000000000000..154e16bf6d86
--- /dev/null
+++ b/src/components/TextInput/BaseTextInput/index.e2e.tsx
@@ -0,0 +1,27 @@
+import type {ForwardedRef} from 'react';
+import React, {forwardRef, useEffect} from 'react';
+import {DeviceEventEmitter} from 'react-native';
+import BaseTextInput from './implementation';
+import type {BaseTextInputProps, BaseTextInputRef} from './types';
+
+function BaseTextInputE2E(props: BaseTextInputProps, ref: ForwardedRef) {
+ useEffect(() => {
+ const testId = props.testID;
+ if (!testId) {
+ return;
+ }
+ console.debug(`[E2E] BaseTextInput: text-input with testID: ${testId} changed text to ${props.value}`);
+
+ DeviceEventEmitter.emit('onChangeText', {testID: testId, value: props.value});
+ }, [props.value, props.testID]);
+
+ return (
+
+ );
+}
+
+export default forwardRef(BaseTextInputE2E);
diff --git a/src/components/TextInput/BaseTextInput/index.tsx b/src/components/TextInput/BaseTextInput/index.tsx
index de49bc32bff7..0df586b70057 100644
--- a/src/components/TextInput/BaseTextInput/index.tsx
+++ b/src/components/TextInput/BaseTextInput/index.tsx
@@ -1,524 +1,3 @@
-import {Str} from 'expensify-common';
-import type {ForwardedRef, MutableRefObject} from 'react';
-import React, {forwardRef, useCallback, useEffect, useRef, useState} from 'react';
-import type {GestureResponderEvent, LayoutChangeEvent, NativeSyntheticEvent, StyleProp, TextInput, TextInputFocusEventData, ViewStyle} from 'react-native';
-import {ActivityIndicator, StyleSheet, View} from 'react-native';
-import {useSharedValue, withSpring} from 'react-native-reanimated';
-import Checkbox from '@components/Checkbox';
-import FormHelpMessage from '@components/FormHelpMessage';
-import Icon from '@components/Icon';
-import * as Expensicons from '@components/Icon/Expensicons';
-import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback';
-import type {AnimatedMarkdownTextInputRef} from '@components/RNMarkdownTextInput';
-import type {AnimatedTextInputRef} from '@components/RNTextInput';
-import RNTextInput from '@components/RNTextInput';
-import SwipeInterceptPanResponder from '@components/SwipeInterceptPanResponder';
-import Text from '@components/Text';
-import {ACTIVE_LABEL_SCALE, ACTIVE_LABEL_TRANSLATE_Y, INACTIVE_LABEL_SCALE, INACTIVE_LABEL_TRANSLATE_Y} from '@components/TextInput/styleConst';
-import TextInputClearButton from '@components/TextInput/TextInputClearButton';
-import TextInputLabel from '@components/TextInput/TextInputLabel';
-import useHtmlPaste from '@hooks/useHtmlPaste';
-import useLocalize from '@hooks/useLocalize';
-import useMarkdownStyle from '@hooks/useMarkdownStyle';
-import useStyleUtils from '@hooks/useStyleUtils';
-import useTheme from '@hooks/useTheme';
-import useThemeStyles from '@hooks/useThemeStyles';
-import {isMobileChrome, isMobileSafari, isSafari} from '@libs/Browser';
-import {scrollToRight} from '@libs/InputUtils';
-import isInputAutoFilled from '@libs/isInputAutoFilled';
-import variables from '@styles/variables';
-import CONST from '@src/CONST';
-import InputComponentMap from './implementations';
-import type {BaseTextInputProps, BaseTextInputRef} from './types';
+import BaseTextInput from './implementation';
-function BaseTextInput(
- {
- label = '',
- /**
- * To be able to function as either controlled or uncontrolled component we should not
- * assign a default prop value for `value` or `defaultValue` props
- */
- value = undefined,
- defaultValue = undefined,
- placeholder = '',
- errorText = '',
- icon = null,
- iconLeft = null,
- textInputContainerStyles,
- touchableInputWrapperStyle,
- containerStyles,
- inputStyle,
- forceActiveLabel = false,
- autoFocus = false,
- disableKeyboard = false,
- autoGrow = false,
- autoGrowHeight = false,
- maxAutoGrowHeight,
- hideFocusedState = false,
- maxLength = undefined,
- hint = '',
- onInputChange = () => {},
- shouldDelayFocus = false,
- multiline = false,
- shouldInterceptSwipe = false,
- autoCorrect = true,
- prefixCharacter = '',
- suffixCharacter = '',
- inputID,
- type = 'default',
- excludedMarkdownStyles = [],
- shouldShowClearButton = false,
- shouldUseDisabledStyles = true,
- prefixContainerStyle = [],
- prefixStyle = [],
- suffixContainerStyle = [],
- suffixStyle = [],
- contentWidth,
- loadingSpinnerStyle,
- uncontrolled = false,
- placeholderTextColor,
- ...inputProps
- }: BaseTextInputProps,
- ref: ForwardedRef,
-) {
- const InputComponent = InputComponentMap.get(type) ?? RNTextInput;
- const isMarkdownEnabled = type === 'markdown';
- const isAutoGrowHeightMarkdown = isMarkdownEnabled && autoGrowHeight;
-
- const theme = useTheme();
- const styles = useThemeStyles();
- const markdownStyle = useMarkdownStyle(undefined, excludedMarkdownStyles);
- const {hasError = false} = inputProps;
- const StyleUtils = useStyleUtils();
- const {translate} = useLocalize();
-
- // Disabling this line for safeness as nullish coalescing works only if value is undefined or null
- // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
- const initialValue = value || defaultValue || '';
- const initialActiveLabel = !!forceActiveLabel || initialValue.length > 0 || !!prefixCharacter || !!suffixCharacter;
-
- const [isFocused, setIsFocused] = useState(false);
- const [passwordHidden, setPasswordHidden] = useState(inputProps.secureTextEntry);
- const [textInputWidth, setTextInputWidth] = useState(0);
- const [textInputHeight, setTextInputHeight] = useState(0);
- const [width, setWidth] = useState(null);
-
- const labelScale = useSharedValue(initialActiveLabel ? ACTIVE_LABEL_SCALE : INACTIVE_LABEL_SCALE);
- const labelTranslateY = useSharedValue(initialActiveLabel ? ACTIVE_LABEL_TRANSLATE_Y : INACTIVE_LABEL_TRANSLATE_Y);
-
- const input = useRef(null);
- const isLabelActive = useRef(initialActiveLabel);
- const didScrollToEndRef = useRef(false);
-
- useHtmlPaste(input as MutableRefObject, undefined, isMarkdownEnabled);
-
- // AutoFocus which only works on mount:
- useEffect(() => {
- // We are manually managing focus to prevent this issue: https://github.com/Expensify/App/issues/4514
- if (!autoFocus || !input.current) {
- return;
- }
-
- if (shouldDelayFocus) {
- const focusTimeout = setTimeout(() => input?.current?.focus(), CONST.ANIMATED_TRANSITION);
- return () => clearTimeout(focusTimeout);
- }
- input.current.focus();
- // We only want this to run on mount
- // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
- }, []);
-
- const animateLabel = useCallback(
- (translateY: number, scale: number) => {
- labelScale.set(withSpring(scale, {overshootClamping: false}));
- labelTranslateY.set(withSpring(translateY, {overshootClamping: false}));
- },
- [labelScale, labelTranslateY],
- );
-
- const activateLabel = useCallback(() => {
- const newValue = value ?? '';
-
- if (newValue.length < 0 || isLabelActive.current) {
- return;
- }
-
- animateLabel(ACTIVE_LABEL_TRANSLATE_Y, ACTIVE_LABEL_SCALE);
- isLabelActive.current = true;
- }, [animateLabel, value]);
-
- const deactivateLabel = useCallback(() => {
- const newValue = value ?? '';
-
- if (!!forceActiveLabel || newValue.length !== 0 || prefixCharacter || suffixCharacter) {
- return;
- }
-
- animateLabel(INACTIVE_LABEL_TRANSLATE_Y, INACTIVE_LABEL_SCALE);
- isLabelActive.current = false;
- }, [animateLabel, forceActiveLabel, prefixCharacter, suffixCharacter, value]);
-
- const onFocus = (event: NativeSyntheticEvent) => {
- inputProps.onFocus?.(event);
- setIsFocused(true);
- };
-
- const onBlur = (event: NativeSyntheticEvent) => {
- inputProps.onBlur?.(event);
- setIsFocused(false);
- };
-
- const onPress = (event?: GestureResponderEvent | KeyboardEvent) => {
- if (!!inputProps.disabled || !event) {
- return;
- }
-
- inputProps.onPress?.(event);
-
- if ('isDefaultPrevented' in event && !event?.isDefaultPrevented()) {
- input.current?.focus();
- }
- };
-
- const onLayout = useCallback(
- (event: LayoutChangeEvent) => {
- if (!autoGrowHeight && multiline) {
- return;
- }
-
- const layout = event.nativeEvent.layout;
-
- setWidth((prevWidth: number | null) => (autoGrowHeight ? layout.width : prevWidth));
- },
- [autoGrowHeight, multiline],
- );
-
- // The ref is needed when the component is uncontrolled and we don't have a value prop
- const hasValueRef = useRef(initialValue.length > 0);
- const inputValue = value ?? '';
- const hasValue = inputValue.length > 0 || hasValueRef.current;
-
- // Activate or deactivate the label when either focus changes, or for controlled
- // components when the value prop changes:
- useEffect(() => {
- if (
- hasValue ||
- isFocused ||
- // If the text has been supplied by Chrome autofill, the value state is not synced with the value
- // as Chrome doesn't trigger a change event. When there is autofill text, keep the label activated.
- isInputAutoFilled(input.current)
- ) {
- activateLabel();
- } else {
- deactivateLabel();
- }
- }, [activateLabel, deactivateLabel, hasValue, isFocused]);
-
- // When the value prop gets cleared externally, we need to keep the ref in sync:
- useEffect(() => {
- // Return early when component uncontrolled, or we still have a value
- if (value === undefined || value) {
- return;
- }
- hasValueRef.current = false;
- }, [value]);
-
- /**
- * Set Value & activateLabel
- */
- const setValue = (newValue: string) => {
- onInputChange?.(newValue);
-
- if (inputProps.onChangeText) {
- Str.result(inputProps.onChangeText, newValue);
- }
- if (newValue && newValue.length > 0) {
- hasValueRef.current = true;
- // When the componment is uncontrolled, we need to manually activate the label:
- if (value === undefined) {
- activateLabel();
- }
- } else {
- hasValueRef.current = false;
- }
- };
-
- const togglePasswordVisibility = useCallback(() => {
- setPasswordHidden((prevPasswordHidden: boolean | undefined) => !prevPasswordHidden);
- }, []);
-
- const hasLabel = !!label?.length;
- const isReadOnly = inputProps.readOnly ?? inputProps.disabled;
- // Disabling this line for safeness as nullish coalescing works only if the value is undefined or null, and errorText can be an empty string
- // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
- const inputHelpText = errorText || hint;
- const newPlaceholder = !!prefixCharacter || !!suffixCharacter || isFocused || !hasLabel || (hasLabel && forceActiveLabel) ? placeholder : undefined;
- const newTextInputContainerStyles: StyleProp = StyleSheet.flatten([
- styles.textInputContainer,
- textInputContainerStyles,
- (autoGrow || !!contentWidth) && StyleUtils.getWidthStyle(textInputWidth),
- !hideFocusedState && isFocused && styles.borderColorFocus,
- (!!hasError || !!errorText) && styles.borderColorDanger,
- autoGrowHeight && {scrollPaddingTop: typeof maxAutoGrowHeight === 'number' ? 2 * maxAutoGrowHeight : undefined},
- isAutoGrowHeightMarkdown && styles.pb2,
- ]);
- const isMultiline = multiline || autoGrowHeight;
-
- const inputPaddingLeft = !!prefixCharacter && StyleUtils.getPaddingLeft(StyleUtils.getCharacterPadding(prefixCharacter) + styles.pl1.paddingLeft);
- const inputPaddingRight = !!suffixCharacter && StyleUtils.getPaddingRight(StyleUtils.getCharacterPadding(suffixCharacter) + styles.pr1.paddingRight);
- // This is workaround for https://github.com/Expensify/App/issues/47939: in case when user is using Chrome on Android we set inputMode to 'search' to disable autocomplete bar above the keyboard.
- // If we need some other inputMode (eg. 'decimal'), then the autocomplete bar will show, but we can do nothing about it as it's a known Chrome bug.
- const inputMode = inputProps.inputMode ?? (isMobileChrome() ? 'search' : undefined);
-
- return (
- <>
-
-
-
- {hasLabel ? (
- <>
- {/* Adding this background to the label only for multiline text input,
- to prevent text overlapping with label when scrolling */}
- {isMultiline && }
-
- >
- ) : null}
-
- {!!iconLeft && (
-
-
-
- )}
- {!!prefixCharacter && (
-
-
- {prefixCharacter}
-
-
- )}
- {
- const baseTextInputRef = element as BaseTextInputRef | null;
- if (typeof ref === 'function') {
- ref(baseTextInputRef);
- } else if (ref && 'current' in ref) {
- // eslint-disable-next-line no-param-reassign
- ref.current = baseTextInputRef;
- }
-
- input.current = element as HTMLInputElement | null;
- }}
- // eslint-disable-next-line
- {...inputProps}
- autoCorrect={inputProps.secureTextEntry ? false : autoCorrect}
- placeholder={newPlaceholder}
- placeholderTextColor={placeholderTextColor ?? theme.placeholderText}
- underlineColorAndroid="transparent"
- style={[
- styles.flex1,
- styles.w100,
- inputStyle,
- (!hasLabel || isMultiline) && styles.pv0,
- inputPaddingLeft,
- inputPaddingRight,
- inputProps.secureTextEntry && styles.secureInput,
-
- // Explicitly change boxSizing attribute for mobile chrome in order to apply line-height
- // for the issue mentioned here https://github.com/Expensify/App/issues/26735
- // Set overflow property to enable the parent flexbox to shrink its size
- // (See https://github.com/Expensify/App/issues/41766)
- !isMultiline && isMobileChrome() && {boxSizing: 'content-box', height: undefined, ...styles.overflowAuto},
-
- // Stop scrollbar flashing when breaking lines with autoGrowHeight enabled.
- ...(autoGrowHeight && !isAutoGrowHeightMarkdown
- ? [StyleUtils.getAutoGrowHeightInputStyle(textInputHeight, typeof maxAutoGrowHeight === 'number' ? maxAutoGrowHeight : 0), styles.verticalAlignTop]
- : []),
- isAutoGrowHeightMarkdown ? [StyleUtils.getMarkdownMaxHeight(maxAutoGrowHeight), styles.verticalAlignTop] : undefined,
- // Add disabled color theme when field is not editable.
- inputProps.disabled && shouldUseDisabledStyles && styles.textInputDisabled,
- styles.pointerEventsAuto,
- ]}
- multiline={isMultiline}
- maxLength={maxLength}
- onFocus={onFocus}
- onBlur={onBlur}
- onChangeText={setValue}
- secureTextEntry={passwordHidden}
- onPressOut={inputProps.onPress}
- showSoftInputOnFocus={!disableKeyboard}
- inputMode={inputMode}
- value={uncontrolled ? undefined : value}
- selection={inputProps.selection}
- readOnly={isReadOnly}
- defaultValue={defaultValue}
- markdownStyle={markdownStyle}
- />
- {!!suffixCharacter && (
-
-
- {suffixCharacter}
-
-
- )}
- {isFocused && !isReadOnly && shouldShowClearButton && !!value && (
- {
- if (didScrollToEndRef.current || !input.current) {
- return;
- }
- scrollToRight(input.current);
- didScrollToEndRef.current = true;
- }}
- >
- setValue('')} />
-
- )}
- {inputProps.isLoading !== undefined && (
-
- )}
- {!!inputProps.secureTextEntry && (
- {
- e.preventDefault();
- }}
- accessibilityLabel={translate('common.visible')}
- >
-
-
- )}
- {!inputProps.secureTextEntry && !!icon && (
-
-
-
- )}
-
-
-
- {!!inputHelpText && (
-
- )}
-
- {!!contentWidth && (
- {
- if (e.nativeEvent.layout.width === 0 && e.nativeEvent.layout.height === 0) {
- return;
- }
- setTextInputWidth(e.nativeEvent.layout.width);
- setTextInputHeight(e.nativeEvent.layout.height);
- }}
- >
-
- {/* \u200B added to solve the issue of not expanding the text input enough when the value ends with '\n' (https://github.com/Expensify/App/issues/21271) */}
- {value ? `${value}${value.endsWith('\n') ? '\u200B' : ''}` : placeholder}
-
-
- )}
- {/*
- Text input component doesn't support auto grow by default.
- We're using a hidden text input to achieve that.
- This text view is used to calculate width or height of the input value given textStyle in this component.
- This Text component is intentionally positioned out of the screen.
- */}
- {(!!autoGrow || autoGrowHeight) && !isAutoGrowHeightMarkdown && (
- // Add +2 to width on Safari browsers so that text is not cut off due to the cursor or when changing the value
- // Reference: https://github.com/Expensify/App/issues/8158, https://github.com/Expensify/App/issues/26628
- // For mobile Chrome, ensure proper display of the text selection handle (blue bubble down).
- // Reference: https://github.com/Expensify/App/issues/34921
- {
- if (e.nativeEvent.layout.width === 0 && e.nativeEvent.layout.height === 0) {
- return;
- }
- let additionalWidth = 0;
- if (isMobileSafari() || isSafari() || isMobileChrome()) {
- additionalWidth = 2;
- }
- setTextInputWidth(e.nativeEvent.layout.width + additionalWidth);
- setTextInputHeight(e.nativeEvent.layout.height);
- }}
- >
- {/* \u200B added to solve the issue of not expanding the text input enough when the value ends with '\n' (https://github.com/Expensify/App/issues/21271) */}
- {value ? `${value}${value.endsWith('\n') ? '\u200B' : ''}` : placeholder}
-
- )}
- >
- );
-}
-
-BaseTextInput.displayName = 'BaseTextInput';
-
-export default forwardRef(BaseTextInput);
+export default BaseTextInput;
diff --git a/src/components/TextInputWithCurrencySymbol/types.ts b/src/components/TextInputWithCurrencySymbol/types.ts
index 3988654584d0..8636f1959df1 100644
--- a/src/components/TextInputWithCurrencySymbol/types.ts
+++ b/src/components/TextInputWithCurrencySymbol/types.ts
@@ -77,6 +77,9 @@ type BaseTextInputWithCurrencySymbolProps = {
/** Hide the focus styles on TextInput */
hideFocusedState?: boolean;
+
+ /** The test ID of TextInput. Used to locate the view in end-to-end tests. */
+ testID?: string;
} & Pick;
type TextInputWithCurrencySymbolProps = Omit & {
diff --git a/src/libs/E2E/interactions/index.ts b/src/libs/E2E/interactions/index.ts
new file mode 100644
index 000000000000..d4ce9511d8bb
--- /dev/null
+++ b/src/libs/E2E/interactions/index.ts
@@ -0,0 +1,56 @@
+import type {GestureResponderEvent} from 'react-native';
+import {DeviceEventEmitter} from 'react-native';
+import * as E2EGenericPressableWrapper from '@components/Pressable/GenericPressable/index.e2e';
+import Performance from '@libs/Performance';
+
+const waitForElement = (testID: string) => {
+ console.debug(`[E2E] waitForElement: ${testID}`);
+
+ if (E2EGenericPressableWrapper.getPressableProps(testID)) {
+ return Promise.resolve();
+ }
+
+ return new Promise((resolve) => {
+ const subscription = DeviceEventEmitter.addListener('onBecameVisible', (_testID: string) => {
+ if (_testID !== testID) {
+ return;
+ }
+
+ subscription.remove();
+ resolve(undefined);
+ });
+ });
+};
+
+const waitForTextInputValue = (text: string, _testID: string): Promise => {
+ return new Promise((resolve) => {
+ const subscription = DeviceEventEmitter.addListener('onChangeText', ({testID, value}) => {
+ if (_testID !== testID || value !== text) {
+ return;
+ }
+
+ subscription.remove();
+ resolve(undefined);
+ });
+ });
+};
+
+const waitForEvent = (eventName: string): Promise => {
+ return new Promise((resolve) => {
+ Performance.subscribeToMeasurements((entry) => {
+ if (entry.name !== eventName) {
+ return;
+ }
+
+ resolve(entry);
+ });
+ });
+};
+
+const tap = (testID: string) => {
+ console.debug(`[E2E] Press on: ${testID}`);
+
+ E2EGenericPressableWrapper.getPressableProps(testID)?.onPress?.({} as unknown as GestureResponderEvent);
+};
+
+export {waitForElement, tap, waitForEvent, waitForTextInputValue};
diff --git a/src/libs/E2E/reactNativeLaunchingTest.ts b/src/libs/E2E/reactNativeLaunchingTest.ts
index fdd305baf88c..50a0b3063ba9 100644
--- a/src/libs/E2E/reactNativeLaunchingTest.ts
+++ b/src/libs/E2E/reactNativeLaunchingTest.ts
@@ -36,6 +36,7 @@ const tests: Tests = {
[E2EConfig.TEST_NAMES.ChatOpening]: require('./tests/chatOpeningTest.e2e').default,
[E2EConfig.TEST_NAMES.ReportTyping]: require('./tests/reportTypingTest.e2e').default,
[E2EConfig.TEST_NAMES.Linking]: require('./tests/linkingTest.e2e').default,
+ [E2EConfig.TEST_NAMES.MoneyRequest]: require('./tests/moneyRequestTest.e2e').default,
};
// Once we receive the TII measurement we know that the app is initialized and ready to be used:
diff --git a/src/libs/E2E/tests/moneyRequestTest.e2e.ts b/src/libs/E2E/tests/moneyRequestTest.e2e.ts
new file mode 100644
index 000000000000..b50b7fe34693
--- /dev/null
+++ b/src/libs/E2E/tests/moneyRequestTest.e2e.ts
@@ -0,0 +1,76 @@
+import Config from 'react-native-config';
+import type {NativeConfig} from 'react-native-config';
+import E2ELogin from '@libs/E2E/actions/e2eLogin';
+import waitForAppLoaded from '@libs/E2E/actions/waitForAppLoaded';
+import E2EClient from '@libs/E2E/client';
+import {tap, waitForElement, waitForEvent, waitForTextInputValue} from '@libs/E2E/interactions';
+import getConfigValueOrThrow from '@libs/E2E/utils/getConfigValueOrThrow';
+import CONST from '@src/CONST';
+import {makeClearCommand} from '../../../../tests/e2e/nativeCommands/NativeCommandsAction';
+
+const test = (config: NativeConfig) => {
+ // check for login (if already logged in the action will simply resolve)
+ console.debug('[E2E] Logging in for money request');
+
+ const name = getConfigValueOrThrow('name', config);
+
+ E2ELogin().then((neededLogin) => {
+ if (neededLogin) {
+ return waitForAppLoaded().then(() =>
+ // we don't want to submit the first login to the results
+ E2EClient.submitTestDone(),
+ );
+ }
+
+ console.debug('[E2E] Logged in, getting money request metrics and submitting them…');
+
+ waitForEvent(CONST.TIMING.SIDEBAR_LOADED)
+ .then(() => tap('floating-action-button'))
+ .then(() => waitForElement('create-expense'))
+ .then(() => tap('create-expense'))
+ .then(() => waitForEvent(CONST.TIMING.OPEN_CREATE_EXPENSE))
+ .then((entry) => {
+ E2EClient.submitTestResults({
+ branch: Config.E2E_BRANCH,
+ name: `${name} - Open Manual Tracking`,
+ metric: entry.duration,
+ unit: 'ms',
+ });
+ })
+ .then(() => waitForElement('manual'))
+ .then(() => tap('manual'))
+ .then(() => E2EClient.sendNativeCommand(makeClearCommand()))
+ .then(() => tap('button_2'))
+ .then(() => waitForTextInputValue('2', 'moneyRequestAmountInput'))
+ .then(() => tap('next-button'))
+ .then(() => waitForEvent(CONST.TIMING.OPEN_CREATE_EXPENSE_CONTACT))
+ .then((entry) => {
+ E2EClient.submitTestResults({
+ branch: Config.E2E_BRANCH,
+ name: `${name} - Open Contacts`,
+ metric: entry.duration,
+ unit: 'ms',
+ });
+ })
+ .then(() => waitForElement('+66 65 490 0617'))
+ .then(() => tap('+66 65 490 0617'))
+ .then(() => waitForEvent(CONST.TIMING.OPEN_CREATE_EXPENSE_APPROVE))
+ .then((entry) => {
+ E2EClient.submitTestResults({
+ branch: Config.E2E_BRANCH,
+ name: `${name} - Open Create`,
+ metric: entry.duration,
+ unit: 'ms',
+ });
+ })
+ .then(() => {
+ console.debug('[E2E] Test completed successfully, exiting…');
+ E2EClient.submitTestDone();
+ })
+ .catch((err) => {
+ console.debug('[E2E] Error while submitting test results:', err);
+ });
+ });
+};
+
+export default test;
diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts
index 3760042a100a..611000bf1da5 100644
--- a/src/libs/actions/IOU.ts
+++ b/src/libs/actions/IOU.ts
@@ -57,6 +57,7 @@ import {buildNextStep} from '@libs/NextStepUtils';
import {rand64} from '@libs/NumberUtils';
import {getManagerMcTestParticipant, getPersonalDetailsForAccountIDs} from '@libs/OptionsListUtils';
import {getCustomUnitID} from '@libs/PerDiemRequestUtils';
+import Performance from '@libs/Performance';
import {getAccountIDsByLogins} from '@libs/PersonalDetailsUtils';
import {addSMSDomainIfPhoneNumber} from '@libs/PhoneNumber';
import {
@@ -854,6 +855,7 @@ function clearMoneyRequest(transactionID: string, skipConfirmation = false) {
}
function startMoneyRequest(iouType: ValueOf, reportID: string, requestType?: IOURequestType, skipConfirmation = false) {
+ Performance.markStart(CONST.TIMING.OPEN_CREATE_EXPENSE);
clearMoneyRequest(CONST.IOU.OPTIMISTIC_TRANSACTION_ID, skipConfirmation);
switch (requestType) {
case CONST.IOU.REQUEST_TYPE.MANUAL:
diff --git a/src/pages/home/sidebar/FloatingActionButtonAndPopover.tsx b/src/pages/home/sidebar/FloatingActionButtonAndPopover.tsx
index 985a490f5e3f..1d3f5744405f 100644
--- a/src/pages/home/sidebar/FloatingActionButtonAndPopover.tsx
+++ b/src/pages/home/sidebar/FloatingActionButtonAndPopover.tsx
@@ -364,6 +364,7 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu, isT
{
icon: getIconForAction(CONST.IOU.TYPE.CREATE),
text: translate('iou.createExpense'),
+ testID: 'create-expense',
shouldCallAfterModalHide: shouldRedirectToExpensifyClassic || shouldUseNarrowLayout,
onSelected: () =>
interceptAnonymousUser(() => {
diff --git a/src/pages/iou/MoneyRequestAmountForm.tsx b/src/pages/iou/MoneyRequestAmountForm.tsx
index a948e43d8656..4caa4ad7e707 100644
--- a/src/pages/iou/MoneyRequestAmountForm.tsx
+++ b/src/pages/iou/MoneyRequestAmountForm.tsx
@@ -281,6 +281,7 @@ function MoneyRequestAmountForm(
moneyRequestAmountInputRef={moneyRequestAmountInput}
inputStyle={[styles.iouAmountTextInput]}
containerStyle={[styles.iouAmountTextInputContainer]}
+ testID="moneyRequestAmountInput"
/>
{!!formError && (
submitAndNavigateToNextPage()}
text={buttonText}
+ testID="next-button"
/>
)}
diff --git a/src/pages/iou/request/IOURequestStartPage.tsx b/src/pages/iou/request/IOURequestStartPage.tsx
index df9425666452..395942de99a3 100644
--- a/src/pages/iou/request/IOURequestStartPage.tsx
+++ b/src/pages/iou/request/IOURequestStartPage.tsx
@@ -1,5 +1,5 @@
import {useFocusEffect} from '@react-navigation/native';
-import React, {useCallback, useMemo, useState} from 'react';
+import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {View} from 'react-native';
import {useOnyx} from 'react-native-onyx';
import DragAndDropProvider from '@components/DragAndDrop/Provider';
@@ -13,6 +13,7 @@ import useThemeStyles from '@hooks/useThemeStyles';
import {canUseTouchScreen} from '@libs/DeviceCapabilities';
import Navigation from '@libs/Navigation/Navigation';
import OnyxTabNavigator, {TabScreenWithFocusTrapWrapper, TopTab} from '@libs/Navigation/OnyxTabNavigator';
+import Performance from '@libs/Performance';
import {getPerDiemCustomUnit, getPerDiemCustomUnits} from '@libs/PolicyUtils';
import {getPayeeName} from '@libs/ReportUtils';
import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper';
@@ -78,6 +79,10 @@ function IOURequestStartPage({
}, [transaction, policy, reportID, isFromGlobalCreate, transactionRequestType, isLoadingSelectedTab]),
);
+ useEffect(() => {
+ Performance.markEnd(CONST.TIMING.OPEN_CREATE_EXPENSE);
+ }, []);
+
const navigateBack = () => {
Navigation.closeRHPFlow();
};
diff --git a/src/pages/iou/request/step/IOURequestStepAmount.tsx b/src/pages/iou/request/step/IOURequestStepAmount.tsx
index 1ff5c25c46ca..89fd0e65e3fa 100644
--- a/src/pages/iou/request/step/IOURequestStepAmount.tsx
+++ b/src/pages/iou/request/step/IOURequestStepAmount.tsx
@@ -10,6 +10,7 @@ import {createDraftTransaction, removeDraftTransaction} from '@libs/actions/Tran
import {convertToBackendAmount, isValidCurrencyCode} from '@libs/CurrencyUtils';
import Navigation from '@libs/Navigation/Navigation';
import {getParticipantsOption, getReportOption} from '@libs/OptionsListUtils';
+import Performance from '@libs/Performance';
import {isPaidGroupPolicy} from '@libs/PolicyUtils';
import {getBankAccountRoute, getPolicyExpenseChat, getTransactionDetails, isArchivedReport, isPolicyExpenseChat} from '@libs/ReportUtils';
import playSound, {SOUNDS} from '@libs/Sound';
@@ -139,6 +140,8 @@ function IOURequestStepAmount({
};
const navigateToParticipantPage = () => {
+ Performance.markStart(CONST.TIMING.OPEN_CREATE_EXPENSE_CONTACT);
+
switch (iouType) {
case CONST.IOU.TYPE.REQUEST:
Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute(CONST.IOU.TYPE.SUBMIT, transactionID, reportID));
diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx
index 0de7c86f84ab..ce9ec520f85f 100644
--- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx
+++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx
@@ -29,6 +29,7 @@ import Log from '@libs/Log';
import navigateAfterInteraction from '@libs/Navigation/navigateAfterInteraction';
import Navigation from '@libs/Navigation/Navigation';
import {getParticipantsOption, getReportOption} from '@libs/OptionsListUtils';
+import Performance from '@libs/Performance';
import {generateReportID, getBankAccountRoute, isSelectedManagerMcTest} from '@libs/ReportUtils';
import playSound, {SOUNDS} from '@libs/Sound';
import {getDefaultTaxCode, getRateID, getRequestType, getValidWaypoints} from '@libs/TransactionUtils';
@@ -184,6 +185,9 @@ function IOURequestStepConfirmation({
useFetchRoute(transaction, transaction?.comment?.waypoints, action, shouldUseTransactionDraft(action) ? CONST.TRANSACTION.STATE.DRAFT : CONST.TRANSACTION.STATE.CURRENT);
+ useEffect(() => {
+ Performance.markEnd(CONST.TIMING.OPEN_CREATE_EXPENSE_APPROVE);
+ }, []);
useEffect(() => {
const policyExpenseChat = participants?.find((participant) => participant.isPolicyExpenseChat);
if (policyExpenseChat?.policyID && policy?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD) {
diff --git a/src/pages/iou/request/step/IOURequestStepParticipants.tsx b/src/pages/iou/request/step/IOURequestStepParticipants.tsx
index afd00a99373b..22703824c476 100644
--- a/src/pages/iou/request/step/IOURequestStepParticipants.tsx
+++ b/src/pages/iou/request/step/IOURequestStepParticipants.tsx
@@ -11,6 +11,7 @@ import getPlatform from '@libs/getPlatform';
import HttpUtils from '@libs/HttpUtils';
import {isMovingTransactionFromTrackExpense as isMovingTransactionFromTrackExpenseIOUUtils, navigateToStartMoneyRequestStep} from '@libs/IOUUtils';
import Navigation from '@libs/Navigation/Navigation';
+import Performance from '@libs/Performance';
import {createDraftWorkspaceAndNavigateToConfirmationScreen, findSelfDMReportID, isInvoiceRoomWithID} from '@libs/ReportUtils';
import {getRequestType} from '@libs/TransactionUtils';
import MoneyRequestParticipantsSelector from '@pages/iou/request/MoneyRequestParticipantsSelector';
@@ -85,6 +86,10 @@ function IOURequestStepParticipants({
const isAndroidNative = getPlatform() === CONST.PLATFORM.ANDROID;
const isMobileSafari = isMobileSafariBrowser();
+ useEffect(() => {
+ Performance.markEnd(CONST.TIMING.OPEN_CREATE_EXPENSE_CONTACT);
+ }, []);
+
// When the component mounts, if there is a receipt, see if the image can be read from the disk. If not, redirect the user to the starting step of the flow.
// This is because until the expense is saved, the receipt file is only stored in the browsers memory as a blob:// and if the browser is refreshed, then
// the image ceases to exist. The best way for the user to recover from this is to start over from the start of the expense process.
@@ -209,6 +214,7 @@ function IOURequestStepParticipants({
? ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute(action, iouType, transactionID, selectedReportID.current || reportID, iouConfirmationPageRoute)
: iouConfirmationPageRoute;
+ Performance.markStart(CONST.TIMING.OPEN_CREATE_EXPENSE_APPROVE);
waitForKeyboardDismiss(() => {
// If the backTo parameter is set, we should navigate back to the confirmation screen that is already on the stack.
if (backTo) {
diff --git a/tests/e2e/config.ts b/tests/e2e/config.ts
index 92c51164be04..e07042119c35 100644
--- a/tests/e2e/config.ts
+++ b/tests/e2e/config.ts
@@ -8,6 +8,7 @@ const TEST_NAMES = {
ReportTyping: 'Report typing',
ChatOpening: 'Chat opening',
Linking: 'Linking',
+ MoneyRequest: 'Money request',
};
/**
@@ -101,6 +102,9 @@ export default {
linkedReportID: '5421294415618529',
linkedReportActionID: '2845024374735019929',
},
+ [TEST_NAMES.MoneyRequest]: {
+ name: TEST_NAMES.MoneyRequest,
+ },
},
};
diff --git a/tests/e2e/nativeCommands/NativeCommandsAction.ts b/tests/e2e/nativeCommands/NativeCommandsAction.ts
index 17187ca66f1c..c26582161af9 100644
--- a/tests/e2e/nativeCommands/NativeCommandsAction.ts
+++ b/tests/e2e/nativeCommands/NativeCommandsAction.ts
@@ -4,6 +4,7 @@ const NativeCommandsAction = {
scroll: 'scroll',
type: 'type',
backspace: 'backspace',
+ clear: 'clear',
} as const;
const makeTypeTextCommand = (text: string): NativeCommand => ({
@@ -17,4 +18,8 @@ const makeBackspaceCommand = (): NativeCommand => ({
actionName: NativeCommandsAction.backspace,
});
-export {NativeCommandsAction, makeTypeTextCommand, makeBackspaceCommand};
+const makeClearCommand = (): NativeCommand => ({
+ actionName: NativeCommandsAction.clear,
+});
+
+export {NativeCommandsAction, makeTypeTextCommand, makeBackspaceCommand, makeClearCommand};
diff --git a/tests/e2e/nativeCommands/adbClear.ts b/tests/e2e/nativeCommands/adbClear.ts
new file mode 100644
index 000000000000..5e25739b73a7
--- /dev/null
+++ b/tests/e2e/nativeCommands/adbClear.ts
@@ -0,0 +1,17 @@
+import execAsync from '../utils/execAsync';
+import * as Logger from '../utils/logger';
+
+const adbClear = (): Promise => {
+ Logger.log(`🧹 Clearing the typed text`);
+ return execAsync(`
+ function clear_input() {
+ adb shell input keyevent KEYCODE_MOVE_END
+ # delete up to 2 characters per 1 press, so 1..3 will delete up to 6 characters
+ adb shell input keyevent --longpress $(printf 'KEYCODE_DEL %.0s' {1..3})
+ }
+
+ clear_input
+ `).then(() => true);
+};
+
+export default adbClear;
diff --git a/tests/e2e/nativeCommands/index.ts b/tests/e2e/nativeCommands/index.ts
index 310aa2ab3c22..6331bae463ba 100644
--- a/tests/e2e/nativeCommands/index.ts
+++ b/tests/e2e/nativeCommands/index.ts
@@ -1,5 +1,6 @@
import type {NativeCommandPayload} from '@libs/E2E/client';
import adbBackspace from './adbBackspace';
+import adbClear from './adbClear';
import adbTypeText from './adbTypeText';
// eslint-disable-next-line rulesdir/prefer-import-module-contents
import {NativeCommandsAction} from './NativeCommandsAction';
@@ -12,6 +13,8 @@ const executeFromPayload = (actionName?: string, payload?: NativeCommandPayload)
return adbTypeText(payload?.text ?? '');
case NativeCommandsAction.backspace:
return adbBackspace();
+ case NativeCommandsAction.clear:
+ return adbClear();
default:
throw new Error(`Unknown action: ${actionName}`);
}