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}`); }