From 3af050bb2098d27ee16e02ac491a91bd02b84d72 Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Tue, 12 Nov 2024 11:57:42 +0100 Subject: [PATCH 1/9] e2e: added money request flow e2e test --- src/CONST.ts | 3 + src/components/BigNumberPad.tsx | 1 + src/components/Button/index.tsx | 5 + .../Pressable/GenericPressable/index.e2e.tsx | 3 + src/components/SelectionList/BaseListItem.tsx | 2 + .../SelectionList/InviteMemberListItem.tsx | 1 + src/components/SelectionList/types.ts | 2 + src/libs/E2E/interactions/index.ts | 21 ++++ src/libs/E2E/reactNativeLaunchingTest.ts | 1 + src/libs/E2E/tests/moneyRequestTest.e2e.ts | 107 ++++++++++++++++++ src/pages/iou/MoneyRequestAmountForm.tsx | 1 + src/pages/iou/request/IOURequestStartPage.tsx | 5 + .../step/IOURequestStepConfirmation.tsx | 4 + .../step/IOURequestStepParticipants.tsx | 5 + tests/e2e/config.ts | 4 + .../nativeCommands/NativeCommandsAction.ts | 7 +- tests/e2e/nativeCommands/adbEnter.ts | 9 ++ tests/e2e/nativeCommands/index.ts | 3 + 18 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 src/libs/E2E/interactions/index.ts create mode 100644 src/libs/E2E/tests/moneyRequestTest.e2e.ts create mode 100644 tests/e2e/nativeCommands/adbEnter.ts diff --git a/src/CONST.ts b/src/CONST.ts index ed5f1837fe3b..a41c4f0d0d24 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -1304,6 +1304,9 @@ const CONST = { SIDEBAR_LOADED: 'sidebar_loaded', LOAD_SEARCH_OPTIONS: 'load_search_options', SEND_MESSAGE: 'send_message', + OPEN_SUBMIT_EXPENSE: 'open_submit_expense', + OPEN_SUBMIT_EXPENSE_CONTACT: 'open_submit_expense_contact', + OPEN_SUBMIT_EXPENSE_APPROVE: 'open_submit_expense_approve', COLD: 'cold', WARM: 'warm', REPORT_ACTION_ITEM_LAYOUT_DEBOUNCE_TIME: 1500, diff --git a/src/components/BigNumberPad.tsx b/src/components/BigNumberPad.tsx index 6b7a88ded690..3285dab5b644 100644 --- a/src/components/BigNumberPad.tsx +++ b/src/components/BigNumberPad.tsx @@ -97,6 +97,7 @@ function BigNumberPad({numberPressed, longPressHandlerStateChanged = () => {}, i e.preventDefault(); }} isLongPressDisabled={isLongPressDisabled} + nativeID={`button_${column}`} /> ); })} diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx index 07edd148778d..47937206e2df 100644 --- a/src/components/Button/index.tsx +++ b/src/components/Button/index.tsx @@ -142,6 +142,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 nativeID of the button */ + nativeID?: string; }; type KeyboardShortcutComponentProps = Pick; @@ -242,6 +245,7 @@ function Button( link = false, isContentCentered = false, isPressOnEnterActive, + nativeID, ...rest }: ButtonProps, ref: ForwardedRef, @@ -410,6 +414,7 @@ function Button( hoverDimmingValue={1} onHoverIn={() => setIsHovered(true)} onHoverOut={() => setIsHovered(false)} + nativeID={nativeID} > {renderContent()} {isLoading && ( diff --git a/src/components/Pressable/GenericPressable/index.e2e.tsx b/src/components/Pressable/GenericPressable/index.e2e.tsx index 5d997977a7e0..46bcc7aed40f 100644 --- a/src/components/Pressable/GenericPressable/index.e2e.tsx +++ b/src/components/Pressable/GenericPressable/index.e2e.tsx @@ -1,4 +1,5 @@ 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'; @@ -17,6 +18,8 @@ function E2EGenericPressableWrapper(props: PressableProps, ref: PressableRef) { } console.debug(`[E2E] E2EGenericPressableWrapper: Registering pressable with nativeID: ${nativeId}`); pressableRegistry.set(nativeId, props); + + DeviceEventEmitter.emit('onBecameVisible', nativeId); }, [props]); return ( diff --git a/src/components/SelectionList/BaseListItem.tsx b/src/components/SelectionList/BaseListItem.tsx index 6570ef020786..2fe9ffd36163 100644 --- a/src/components/SelectionList/BaseListItem.tsx +++ b/src/components/SelectionList/BaseListItem.tsx @@ -36,6 +36,7 @@ function BaseListItem({ onFocus = () => {}, hoverStyle, onLongPressRow, + nativeID, }: BaseListItemProps) { const theme = useTheme(); const styles = useThemeStyles(); @@ -106,6 +107,7 @@ function BaseListItem({ onMouseLeave={handleMouseLeave} tabIndex={item.tabIndex} wrapperStyle={pressableWrapperStyle} + nativeID={nativeID} > ({ onFocus={onFocus} shouldSyncFocus={shouldSyncFocus} shouldDisplayRBR={!shouldShowCheckBox} + nativeID={item.text} > {(hovered?: boolean) => ( <> diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index edf6ee955ecc..68b358767946 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -312,6 +312,8 @@ type BaseListItemProps = CommonListItemProps & { hoverStyle?: StyleProp; /** Errors that this user may contain */ shouldDisplayRBR?: boolean; + /** Native ID of the component */ + nativeID?: string; }; type UserListItemProps = ListItemProps & { diff --git a/src/libs/E2E/interactions/index.ts b/src/libs/E2E/interactions/index.ts new file mode 100644 index 000000000000..e753a8cab2bf --- /dev/null +++ b/src/libs/E2E/interactions/index.ts @@ -0,0 +1,21 @@ +import {DeviceEventEmitter} from 'react-native'; +import * as E2EGenericPressableWrapper from '@components/Pressable/GenericPressable/index.e2e'; + +const waitFor = (testID: string) => { + return new Promise((resolve) => { + const subscription = DeviceEventEmitter.addListener('onBecameVisible', (_testID: string) => { + if (_testID !== testID) { + return; + } + + subscription.remove(); + resolve(undefined); + }); + }); +}; + +const tap = (testID: string) => { + E2EGenericPressableWrapper.getPressableProps(testID)?.onPress?.(); +}; + +export {waitFor, tap}; 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..ca260a2c8ba4 --- /dev/null +++ b/src/libs/E2E/tests/moneyRequestTest.e2e.ts @@ -0,0 +1,107 @@ +import Config from 'react-native-config'; +import type {NativeConfig} from 'react-native-config'; +import * as E2EGenericPressableWrapper from '@components/Pressable/GenericPressable/index.e2e'; +import E2ELogin from '@libs/E2E/actions/e2eLogin'; +import waitForAppLoaded from '@libs/E2E/actions/waitForAppLoaded'; +import E2EClient from '@libs/E2E/client'; +import getConfigValueOrThrow from '@libs/E2E/utils/getConfigValueOrThrow'; +import getPromiseWithResolve from '@libs/E2E/utils/getPromiseWithResolve'; +import Navigation from '@libs/Navigation/Navigation'; +import Performance from '@libs/Performance'; +import * as ReportUtils from '@libs/ReportUtils'; +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import * as NativeCommands from '../../../../tests/e2e/nativeCommands/NativeCommandsAction'; +import {tap, waitFor} from '../interactions'; + +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(), + ); + } + + const [appearSubmitExpenseScreenPromise, appearSubmitExpenseScreenResolve] = getPromiseWithResolve(); + const [appearContactsScreenPromise, appearContactsScreenResolve] = getPromiseWithResolve(); + const [approveScreenPromise, approveScreenResolve] = getPromiseWithResolve(); + + Promise.all([appearSubmitExpenseScreenPromise, appearContactsScreenPromise, approveScreenPromise]) + .then(() => { + console.debug('[E2E] Test completed successfully, exiting…'); + E2EClient.submitTestDone(); + }) + .catch((err) => { + console.debug('[E2E] Error while submitting test results:', err); + }); + + console.debug('[E2E] Logged in, getting money request metrics and submitting them…'); + + waitFor('+66 65 490 0617').then(() => { + Performance.markStart(CONST.TIMING.OPEN_SUBMIT_EXPENSE_APPROVE); + tap('+66 65 490 0617'); + }); + + Performance.subscribeToMeasurements((entry) => { + if (entry.name === CONST.TIMING.SIDEBAR_LOADED) { + console.debug(`[E2E] Sidebar loaded, navigating to submit expense…`); + Performance.markStart(CONST.TIMING.OPEN_SUBMIT_EXPENSE); + Navigation.navigate( + ROUTES.MONEY_REQUEST_CREATE_TAB_MANUAL.getRoute(CONST.IOU.ACTION.CREATE, CONST.IOU.TYPE.SUBMIT, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, ReportUtils.generateReportID()), + ); + } + + if (entry.name === CONST.TIMING.OPEN_SUBMIT_EXPENSE) { + appearSubmitExpenseScreenResolve(); + E2EClient.submitTestResults({ + branch: Config.E2E_BRANCH, + name: `${name} - Open Manual Tracking`, + metric: entry.duration, + unit: 'ms', + }); + setTimeout(() => { + tap('button_2'); + }, 1000); + setTimeout(() => { + Performance.markStart(CONST.TIMING.OPEN_SUBMIT_EXPENSE_CONTACT); + tap('next-button'); + }, 4000); + /* E2EClient.sendNativeCommand(NativeCommands.makeTypeTextCommand('3')) + .then(() => E2EClient.sendNativeCommand(NativeCommands.makeEnterCommand())) + .then(() => { + const nextButton = E2EGenericPressableWrapper.getPressableProps('next-button'); + nextButton?.onPress?.(); + }); */ + } + + if (entry.name === CONST.TIMING.OPEN_SUBMIT_EXPENSE_CONTACT) { + E2EClient.submitTestResults({ + branch: Config.E2E_BRANCH, + name: `${name} - Open Contacts`, + metric: entry.duration, + unit: 'ms', + }); + appearContactsScreenResolve(); + console.log(111); + } + + if (entry.name === CONST.TIMING.OPEN_SUBMIT_EXPENSE_APPROVE) { + E2EClient.submitTestResults({ + branch: Config.E2E_BRANCH, + name: `${name} - Open Submit`, + metric: entry.duration, + unit: 'ms', + }); + approveScreenResolve(); + } + }); + }); +}; + +export default test; diff --git a/src/pages/iou/MoneyRequestAmountForm.tsx b/src/pages/iou/MoneyRequestAmountForm.tsx index ba406c3ddef6..6fc8e4a1b455 100644 --- a/src/pages/iou/MoneyRequestAmountForm.tsx +++ b/src/pages/iou/MoneyRequestAmountForm.tsx @@ -337,6 +337,7 @@ function MoneyRequestAmountForm( style={[styles.w100, canUseTouchScreen ? styles.mt5 : styles.mt3]} onPress={() => submitAndNavigateToNextPage()} text={buttonText} + nativeID="next-button" /> )} diff --git a/src/pages/iou/request/IOURequestStartPage.tsx b/src/pages/iou/request/IOURequestStartPage.tsx index f095fac4d6b1..edf82ff56075 100644 --- a/src/pages/iou/request/IOURequestStartPage.tsx +++ b/src/pages/iou/request/IOURequestStartPage.tsx @@ -14,6 +14,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import Navigation from '@libs/Navigation/Navigation'; import OnyxTabNavigator, {TabScreenWithFocusTrapWrapper, TopTab} from '@libs/Navigation/OnyxTabNavigator'; +import Performance from '@libs/Performance'; import * as ReportUtils from '@libs/ReportUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import type {IOURequestType} from '@userActions/IOU'; @@ -73,6 +74,10 @@ function IOURequestStartPage({ IOU.initMoneyRequest(reportID, policy, isFromGlobalCreate, transaction?.iouRequestType, transactionRequestType); }, [transaction, policy, reportID, iouType, isFromGlobalCreate, transactionRequestType, isLoadingSelectedTab]); + useEffect(() => { + Performance.markEnd(CONST.TIMING.OPEN_SUBMIT_EXPENSE); + }, []); + const navigateBack = () => { Navigation.closeRHPFlow(); }; diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index 06e4ed83d936..97b178ab023c 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -23,6 +23,7 @@ import * as IOUUtils from '@libs/IOUUtils'; import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import * as OptionsListUtils from '@libs/OptionsListUtils'; +import Performance from '@libs/Performance'; import * as PolicyUtils from '@libs/PolicyUtils'; import * as ReportUtils from '@libs/ReportUtils'; import playSound, {SOUNDS} from '@libs/Sound'; @@ -148,6 +149,9 @@ function IOURequestStepConfirmation({ useFetchRoute(transaction, transaction?.comment?.waypoints, action, IOUUtils.shouldUseTransactionDraft(action) ? CONST.TRANSACTION.STATE.DRAFT : CONST.TRANSACTION.STATE.CURRENT); + useEffect(() => { + Performance.markEnd(CONST.TIMING.OPEN_SUBMIT_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 c956acadb7b0..3fbde659b153 100644 --- a/src/pages/iou/request/step/IOURequestStepParticipants.tsx +++ b/src/pages/iou/request/step/IOURequestStepParticipants.tsx @@ -9,6 +9,7 @@ import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import HttpUtils from '@libs/HttpUtils'; import * as IOUUtils from '@libs/IOUUtils'; import Navigation from '@libs/Navigation/Navigation'; +import Performance from '@libs/Performance'; import * as ReportUtils from '@libs/ReportUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; import MoneyRequestParticipantsSelector from '@pages/iou/request/MoneyRequestParticipantsSelector'; @@ -73,6 +74,10 @@ function IOURequestStepParticipants({ const receiptPath = transaction?.receipt?.source; const receiptType = transaction?.receipt?.type; + useEffect(() => { + Performance.markEnd(CONST.TIMING.OPEN_SUBMIT_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. diff --git a/tests/e2e/config.ts b/tests/e2e/config.ts index c8e89721c998..5c26c6dae517 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', }; /** @@ -100,6 +101,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..ad2e00d75b3d 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', + enter: 'enter', } as const; const makeTypeTextCommand = (text: string): NativeCommand => ({ @@ -17,4 +18,8 @@ const makeBackspaceCommand = (): NativeCommand => ({ actionName: NativeCommandsAction.backspace, }); -export {NativeCommandsAction, makeTypeTextCommand, makeBackspaceCommand}; +const makeEnterCommand = (): NativeCommand => ({ + actionName: NativeCommandsAction.enter, +}); + +export {NativeCommandsAction, makeTypeTextCommand, makeBackspaceCommand, makeEnterCommand}; diff --git a/tests/e2e/nativeCommands/adbEnter.ts b/tests/e2e/nativeCommands/adbEnter.ts new file mode 100644 index 000000000000..ab8b14176ecf --- /dev/null +++ b/tests/e2e/nativeCommands/adbEnter.ts @@ -0,0 +1,9 @@ +import execAsync from '../utils/execAsync'; +import * as Logger from '../utils/logger'; + +const adbBackspace = (): Promise => { + Logger.log(`↳ Pressing enter`); + return execAsync(`adb shell input keyevent KEYCODE_ENTER`).then(() => true); +}; + +export default adbBackspace; diff --git a/tests/e2e/nativeCommands/index.ts b/tests/e2e/nativeCommands/index.ts index 310aa2ab3c22..a349cd26e0ef 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 adbEnter from './adbEnter'; 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.enter: + return adbEnter(); default: throw new Error(`Unknown action: ${actionName}`); } From c55d0de3cb398ead5a15e309424a88bc0981b080 Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Tue, 19 Nov 2024 11:57:24 +0100 Subject: [PATCH 2/9] fix: nativeID -> testID --- src/components/BigNumberPad.tsx | 2 +- src/components/Button/index.tsx | 8 ++++---- .../Pressable/GenericPressable/index.e2e.tsx | 14 +++++++------- .../Search/SearchRouter/SearchButton.tsx | 2 +- src/components/SelectionList/BaseListItem.tsx | 4 ++-- .../SelectionList/InviteMemberListItem.tsx | 2 +- src/components/SelectionList/types.ts | 4 ++-- src/pages/iou/MoneyRequestAmountForm.tsx | 2 +- 8 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/components/BigNumberPad.tsx b/src/components/BigNumberPad.tsx index 3285dab5b644..30d5c0f45d1d 100644 --- a/src/components/BigNumberPad.tsx +++ b/src/components/BigNumberPad.tsx @@ -97,7 +97,7 @@ function BigNumberPad({numberPressed, longPressHandlerStateChanged = () => {}, i e.preventDefault(); }} isLongPressDisabled={isLongPressDisabled} - nativeID={`button_${column}`} + testID={`button_${column}`} /> ); })} diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx index 47937206e2df..48f7bcbc5faf 100644 --- a/src/components/Button/index.tsx +++ b/src/components/Button/index.tsx @@ -143,8 +143,8 @@ type ButtonProps = Partial & { /** Whether the Enter keyboard listening is active whether or not the screen that contains the button is focused */ isPressOnEnterActive?: boolean; - /** The nativeID of the button */ - nativeID?: string; + /** The testID of the button. Used to locate this view in end-to-end tests. */ + testID?: string; }; type KeyboardShortcutComponentProps = Pick; @@ -245,7 +245,7 @@ function Button( link = false, isContentCentered = false, isPressOnEnterActive, - nativeID, + testID, ...rest }: ButtonProps, ref: ForwardedRef, @@ -414,7 +414,7 @@ function Button( hoverDimmingValue={1} onHoverIn={() => setIsHovered(true)} onHoverOut={() => setIsHovered(false)} - nativeID={nativeID} + testID={testID} > {renderContent()} {isLoading && ( diff --git a/src/components/Pressable/GenericPressable/index.e2e.tsx b/src/components/Pressable/GenericPressable/index.e2e.tsx index 46bcc7aed40f..e3e701912326 100644 --- a/src/components/Pressable/GenericPressable/index.e2e.tsx +++ b/src/components/Pressable/GenericPressable/index.e2e.tsx @@ -6,20 +6,20 @@ 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', nativeId); + DeviceEventEmitter.emit('onBecameVisible', testId); }, [props]); return ( diff --git a/src/components/Search/SearchRouter/SearchButton.tsx b/src/components/Search/SearchRouter/SearchButton.tsx index 90699e951998..51f4e7e49d10 100644 --- a/src/components/Search/SearchRouter/SearchButton.tsx +++ b/src/components/Search/SearchRouter/SearchButton.tsx @@ -26,7 +26,7 @@ function SearchButton({style}: SearchButtonProps) { return ( { diff --git a/src/components/SelectionList/BaseListItem.tsx b/src/components/SelectionList/BaseListItem.tsx index 2fe9ffd36163..52040f9770c7 100644 --- a/src/components/SelectionList/BaseListItem.tsx +++ b/src/components/SelectionList/BaseListItem.tsx @@ -36,7 +36,7 @@ function BaseListItem({ onFocus = () => {}, hoverStyle, onLongPressRow, - nativeID, + testID, }: BaseListItemProps) { const theme = useTheme(); const styles = useThemeStyles(); @@ -107,7 +107,7 @@ function BaseListItem({ onMouseLeave={handleMouseLeave} tabIndex={item.tabIndex} wrapperStyle={pressableWrapperStyle} - nativeID={nativeID} + testID={testID} > ({ onFocus={onFocus} shouldSyncFocus={shouldSyncFocus} shouldDisplayRBR={!shouldShowCheckBox} - nativeID={item.text} + testID={item.text} > {(hovered?: boolean) => ( <> diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index 68b358767946..39ca9d3f38ec 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -312,8 +312,8 @@ type BaseListItemProps = CommonListItemProps & { hoverStyle?: StyleProp; /** Errors that this user may contain */ shouldDisplayRBR?: boolean; - /** Native ID of the component */ - nativeID?: string; + /** Test ID of the component. Used to locate this view in end-to-end tests. */ + testID?: string; }; type UserListItemProps = ListItemProps & { diff --git a/src/pages/iou/MoneyRequestAmountForm.tsx b/src/pages/iou/MoneyRequestAmountForm.tsx index 6fc8e4a1b455..0d37a6777e64 100644 --- a/src/pages/iou/MoneyRequestAmountForm.tsx +++ b/src/pages/iou/MoneyRequestAmountForm.tsx @@ -337,7 +337,7 @@ function MoneyRequestAmountForm( style={[styles.w100, canUseTouchScreen ? styles.mt5 : styles.mt3]} onPress={() => submitAndNavigateToNextPage()} text={buttonText} - nativeID="next-button" + testID="next-button" /> )} From 0994d6fe3533ab5a306cc7e7379af183fe8cf140 Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Tue, 19 Nov 2024 12:04:40 +0100 Subject: [PATCH 3/9] refactor: removed unused code --- src/libs/E2E/tests/moneyRequestTest.e2e.ts | 8 -------- tests/e2e/nativeCommands/NativeCommandsAction.ts | 7 +------ tests/e2e/nativeCommands/adbEnter.ts | 9 --------- tests/e2e/nativeCommands/index.ts | 3 --- 4 files changed, 1 insertion(+), 26 deletions(-) delete mode 100644 tests/e2e/nativeCommands/adbEnter.ts diff --git a/src/libs/E2E/tests/moneyRequestTest.e2e.ts b/src/libs/E2E/tests/moneyRequestTest.e2e.ts index ca260a2c8ba4..14c438077737 100644 --- a/src/libs/E2E/tests/moneyRequestTest.e2e.ts +++ b/src/libs/E2E/tests/moneyRequestTest.e2e.ts @@ -1,6 +1,5 @@ import Config from 'react-native-config'; import type {NativeConfig} from 'react-native-config'; -import * as E2EGenericPressableWrapper from '@components/Pressable/GenericPressable/index.e2e'; import E2ELogin from '@libs/E2E/actions/e2eLogin'; import waitForAppLoaded from '@libs/E2E/actions/waitForAppLoaded'; import E2EClient from '@libs/E2E/client'; @@ -11,7 +10,6 @@ import Performance from '@libs/Performance'; import * as ReportUtils from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; -import * as NativeCommands from '../../../../tests/e2e/nativeCommands/NativeCommandsAction'; import {tap, waitFor} from '../interactions'; const test = (config: NativeConfig) => { @@ -72,12 +70,6 @@ const test = (config: NativeConfig) => { Performance.markStart(CONST.TIMING.OPEN_SUBMIT_EXPENSE_CONTACT); tap('next-button'); }, 4000); - /* E2EClient.sendNativeCommand(NativeCommands.makeTypeTextCommand('3')) - .then(() => E2EClient.sendNativeCommand(NativeCommands.makeEnterCommand())) - .then(() => { - const nextButton = E2EGenericPressableWrapper.getPressableProps('next-button'); - nextButton?.onPress?.(); - }); */ } if (entry.name === CONST.TIMING.OPEN_SUBMIT_EXPENSE_CONTACT) { diff --git a/tests/e2e/nativeCommands/NativeCommandsAction.ts b/tests/e2e/nativeCommands/NativeCommandsAction.ts index ad2e00d75b3d..17187ca66f1c 100644 --- a/tests/e2e/nativeCommands/NativeCommandsAction.ts +++ b/tests/e2e/nativeCommands/NativeCommandsAction.ts @@ -4,7 +4,6 @@ const NativeCommandsAction = { scroll: 'scroll', type: 'type', backspace: 'backspace', - enter: 'enter', } as const; const makeTypeTextCommand = (text: string): NativeCommand => ({ @@ -18,8 +17,4 @@ const makeBackspaceCommand = (): NativeCommand => ({ actionName: NativeCommandsAction.backspace, }); -const makeEnterCommand = (): NativeCommand => ({ - actionName: NativeCommandsAction.enter, -}); - -export {NativeCommandsAction, makeTypeTextCommand, makeBackspaceCommand, makeEnterCommand}; +export {NativeCommandsAction, makeTypeTextCommand, makeBackspaceCommand}; diff --git a/tests/e2e/nativeCommands/adbEnter.ts b/tests/e2e/nativeCommands/adbEnter.ts deleted file mode 100644 index ab8b14176ecf..000000000000 --- a/tests/e2e/nativeCommands/adbEnter.ts +++ /dev/null @@ -1,9 +0,0 @@ -import execAsync from '../utils/execAsync'; -import * as Logger from '../utils/logger'; - -const adbBackspace = (): Promise => { - Logger.log(`↳ Pressing enter`); - return execAsync(`adb shell input keyevent KEYCODE_ENTER`).then(() => true); -}; - -export default adbBackspace; diff --git a/tests/e2e/nativeCommands/index.ts b/tests/e2e/nativeCommands/index.ts index a349cd26e0ef..310aa2ab3c22 100644 --- a/tests/e2e/nativeCommands/index.ts +++ b/tests/e2e/nativeCommands/index.ts @@ -1,6 +1,5 @@ import type {NativeCommandPayload} from '@libs/E2E/client'; import adbBackspace from './adbBackspace'; -import adbEnter from './adbEnter'; import adbTypeText from './adbTypeText'; // eslint-disable-next-line rulesdir/prefer-import-module-contents import {NativeCommandsAction} from './NativeCommandsAction'; @@ -13,8 +12,6 @@ const executeFromPayload = (actionName?: string, payload?: NativeCommandPayload) return adbTypeText(payload?.text ?? ''); case NativeCommandsAction.backspace: return adbBackspace(); - case NativeCommandsAction.enter: - return adbEnter(); default: throw new Error(`Unknown action: ${actionName}`); } From 513340f2f08307a7deb160b71c20bf252e518d27 Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Tue, 19 Nov 2024 13:08:50 +0100 Subject: [PATCH 4/9] fix: flat test structure --- src/libs/E2E/interactions/index.ts | 17 +++++- src/libs/E2E/tests/moneyRequestTest.e2e.ts | 64 +++++++++------------- 2 files changed, 41 insertions(+), 40 deletions(-) diff --git a/src/libs/E2E/interactions/index.ts b/src/libs/E2E/interactions/index.ts index e753a8cab2bf..5a1b11b411f2 100644 --- a/src/libs/E2E/interactions/index.ts +++ b/src/libs/E2E/interactions/index.ts @@ -1,7 +1,8 @@ import {DeviceEventEmitter} from 'react-native'; import * as E2EGenericPressableWrapper from '@components/Pressable/GenericPressable/index.e2e'; +import Performance from '@libs/Performance'; -const waitFor = (testID: string) => { +const waitForElement = (testID: string) => { return new Promise((resolve) => { const subscription = DeviceEventEmitter.addListener('onBecameVisible', (_testID: string) => { if (_testID !== testID) { @@ -14,8 +15,20 @@ const waitFor = (testID: string) => { }); }; +const waitForEvent = (eventName: string): Promise => { + return new Promise((resolve) => { + Performance.subscribeToMeasurements((entry) => { + if (entry.name !== eventName) { + return; + } + + resolve(entry); + }); + }); +}; + const tap = (testID: string) => { E2EGenericPressableWrapper.getPressableProps(testID)?.onPress?.(); }; -export {waitFor, tap}; +export {waitForElement, tap, waitForEvent}; diff --git a/src/libs/E2E/tests/moneyRequestTest.e2e.ts b/src/libs/E2E/tests/moneyRequestTest.e2e.ts index 14c438077737..593da31e63e8 100644 --- a/src/libs/E2E/tests/moneyRequestTest.e2e.ts +++ b/src/libs/E2E/tests/moneyRequestTest.e2e.ts @@ -3,14 +3,13 @@ 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} from '@libs/E2E/interactions'; import getConfigValueOrThrow from '@libs/E2E/utils/getConfigValueOrThrow'; -import getPromiseWithResolve from '@libs/E2E/utils/getPromiseWithResolve'; import Navigation from '@libs/Navigation/Navigation'; import Performance from '@libs/Performance'; import * as ReportUtils from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; -import {tap, waitFor} from '../interactions'; const test = (config: NativeConfig) => { // check for login (if already logged in the action will simply resolve) @@ -26,37 +25,18 @@ const test = (config: NativeConfig) => { ); } - const [appearSubmitExpenseScreenPromise, appearSubmitExpenseScreenResolve] = getPromiseWithResolve(); - const [appearContactsScreenPromise, appearContactsScreenResolve] = getPromiseWithResolve(); - const [approveScreenPromise, approveScreenResolve] = getPromiseWithResolve(); - - Promise.all([appearSubmitExpenseScreenPromise, appearContactsScreenPromise, approveScreenPromise]) - .then(() => { - console.debug('[E2E] Test completed successfully, exiting…'); - E2EClient.submitTestDone(); - }) - .catch((err) => { - console.debug('[E2E] Error while submitting test results:', err); - }); - console.debug('[E2E] Logged in, getting money request metrics and submitting them…'); - waitFor('+66 65 490 0617').then(() => { - Performance.markStart(CONST.TIMING.OPEN_SUBMIT_EXPENSE_APPROVE); - tap('+66 65 490 0617'); - }); - - Performance.subscribeToMeasurements((entry) => { - if (entry.name === CONST.TIMING.SIDEBAR_LOADED) { + waitForEvent(CONST.TIMING.SIDEBAR_LOADED) + .then(() => { console.debug(`[E2E] Sidebar loaded, navigating to submit expense…`); Performance.markStart(CONST.TIMING.OPEN_SUBMIT_EXPENSE); Navigation.navigate( ROUTES.MONEY_REQUEST_CREATE_TAB_MANUAL.getRoute(CONST.IOU.ACTION.CREATE, CONST.IOU.TYPE.SUBMIT, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, ReportUtils.generateReportID()), ); - } - - if (entry.name === CONST.TIMING.OPEN_SUBMIT_EXPENSE) { - appearSubmitExpenseScreenResolve(); + }) + .then(() => waitForEvent(CONST.TIMING.OPEN_SUBMIT_EXPENSE)) + .then((entry) => { E2EClient.submitTestResults({ branch: Config.E2E_BRANCH, name: `${name} - Open Manual Tracking`, @@ -65,34 +45,42 @@ const test = (config: NativeConfig) => { }); setTimeout(() => { tap('button_2'); - }, 1000); + }, 2000); setTimeout(() => { Performance.markStart(CONST.TIMING.OPEN_SUBMIT_EXPENSE_CONTACT); tap('next-button'); }, 4000); - } - - if (entry.name === CONST.TIMING.OPEN_SUBMIT_EXPENSE_CONTACT) { + }) + .then(() => waitForEvent(CONST.TIMING.OPEN_SUBMIT_EXPENSE_CONTACT)) + .then((entry) => { E2EClient.submitTestResults({ branch: Config.E2E_BRANCH, name: `${name} - Open Contacts`, metric: entry.duration, unit: 'ms', }); - appearContactsScreenResolve(); - console.log(111); - } - - if (entry.name === CONST.TIMING.OPEN_SUBMIT_EXPENSE_APPROVE) { + }) + .then(() => waitForElement('+66 65 490 0617')) + .then(() => { + Performance.markStart(CONST.TIMING.OPEN_SUBMIT_EXPENSE_APPROVE); + tap('+66 65 490 0617'); + }) + .then(() => waitForEvent(CONST.TIMING.OPEN_SUBMIT_EXPENSE_APPROVE)) + .then((entry) => { E2EClient.submitTestResults({ branch: Config.E2E_BRANCH, name: `${name} - Open Submit`, metric: entry.duration, unit: 'ms', }); - approveScreenResolve(); - } - }); + }) + .then(() => { + console.debug('[E2E] Test completed successfully, exiting…'); + E2EClient.submitTestDone(); + }) + .catch((err) => { + console.debug('[E2E] Error while submitting test results:', err); + }); }); }; From f3ed8a1c0b366d45744b29685c5e99b25857cfc1 Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Tue, 19 Nov 2024 14:27:36 +0100 Subject: [PATCH 5/9] fix: clear previous field to make sure you always can type a new one --- src/libs/E2E/tests/moneyRequestTest.e2e.ts | 10 ++++++---- tests/e2e/nativeCommands/NativeCommandsAction.ts | 7 ++++++- tests/e2e/nativeCommands/adbClear.ts | 16 ++++++++++++++++ tests/e2e/nativeCommands/index.ts | 3 +++ 4 files changed, 31 insertions(+), 5 deletions(-) create mode 100644 tests/e2e/nativeCommands/adbClear.ts diff --git a/src/libs/E2E/tests/moneyRequestTest.e2e.ts b/src/libs/E2E/tests/moneyRequestTest.e2e.ts index 593da31e63e8..306a13a1745a 100644 --- a/src/libs/E2E/tests/moneyRequestTest.e2e.ts +++ b/src/libs/E2E/tests/moneyRequestTest.e2e.ts @@ -10,6 +10,7 @@ import Performance from '@libs/Performance'; import * as ReportUtils from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; +import * as NativeCommands from '../../../../tests/e2e/nativeCommands/NativeCommandsAction'; const test = (config: NativeConfig) => { // check for login (if already logged in the action will simply resolve) @@ -43,13 +44,14 @@ const test = (config: NativeConfig) => { metric: entry.duration, unit: 'ms', }); - setTimeout(() => { - tap('button_2'); - }, 2000); + }) + .then(() => E2EClient.sendNativeCommand(NativeCommands.makeClearCommand())) + .then(() => { + tap('button_2'); setTimeout(() => { Performance.markStart(CONST.TIMING.OPEN_SUBMIT_EXPENSE_CONTACT); tap('next-button'); - }, 4000); + }, 1000); }) .then(() => waitForEvent(CONST.TIMING.OPEN_SUBMIT_EXPENSE_CONTACT)) .then((entry) => { 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..3fd9b5c70b94 --- /dev/null +++ b/tests/e2e/nativeCommands/adbClear.ts @@ -0,0 +1,16 @@ +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 + adb shell input keyevent --longpress $(printf 'KEYCODE_DEL %.0s' {1..250}) + } + + 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}`); } From 2123207253aea27eb8e7e4a1d048186fa17b0725 Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Tue, 19 Nov 2024 16:46:55 +0100 Subject: [PATCH 6/9] fix: remove hardcoded intervals from the test --- src/components/MoneyRequestAmountInput.tsx | 5 + .../{ => implementation}/index.native.tsx | 0 .../BaseTextInput/implementation/index.tsx | 534 +++++++++++++++++ .../TextInput/BaseTextInput/index.e2e.tsx | 26 + .../TextInput/BaseTextInput/index.tsx | 535 +----------------- src/libs/E2E/interactions/index.ts | 15 +- src/libs/E2E/tests/moneyRequestTest.e2e.ts | 11 +- src/pages/iou/MoneyRequestAmountForm.tsx | 1 + 8 files changed, 588 insertions(+), 539 deletions(-) rename src/components/TextInput/BaseTextInput/{ => implementation}/index.native.tsx (100%) create mode 100644 src/components/TextInput/BaseTextInput/implementation/index.tsx create mode 100644 src/components/TextInput/BaseTextInput/index.e2e.tsx diff --git a/src/components/MoneyRequestAmountInput.tsx b/src/components/MoneyRequestAmountInput.tsx index 9ef33900bb00..1e49a730e118 100644 --- a/src/components/MoneyRequestAmountInput.tsx +++ b/src/components/MoneyRequestAmountInput.tsx @@ -91,6 +91,9 @@ type MoneyRequestAmountInputProps = { /** The width of inner content */ contentWidth?: number; + + /** The testID of the input. Used to locate this view in end-to-end tests. */ + testID?: string; }; type Selection = { @@ -127,6 +130,7 @@ function MoneyRequestAmountInput( shouldKeepUserInput = false, autoGrow = true, contentWidth, + testID, ...props }: MoneyRequestAmountInputProps, forwardedRef: ForwardedRef, @@ -337,6 +341,7 @@ function MoneyRequestAmountInput( onMouseDown={handleMouseDown} onMouseUp={handleMouseUp} contentWidth={contentWidth} + testID={testID} /> ); } diff --git a/src/components/TextInput/BaseTextInput/index.native.tsx b/src/components/TextInput/BaseTextInput/implementation/index.native.tsx similarity index 100% rename from src/components/TextInput/BaseTextInput/index.native.tsx rename to src/components/TextInput/BaseTextInput/implementation/index.native.tsx diff --git a/src/components/TextInput/BaseTextInput/implementation/index.tsx b/src/components/TextInput/BaseTextInput/implementation/index.tsx new file mode 100644 index 000000000000..e36ae60255fc --- /dev/null +++ b/src/components/TextInput/BaseTextInput/implementation/index.tsx @@ -0,0 +1,534 @@ +import {Str} from 'expensify-common'; +import type {ForwardedRef} from 'react'; +import React, {forwardRef, useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import type {GestureResponderEvent, LayoutChangeEvent, NativeSyntheticEvent, StyleProp, TextInputFocusEventData, ViewStyle} from 'react-native'; +import {ActivityIndicator, Animated, StyleSheet, View} from 'react-native'; +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 RNMarkdownTextInput 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 * as styleConst from '@components/TextInput/styleConst'; +import TextInputClearButton from '@components/TextInput/TextInputClearButton'; +import TextInputLabel from '@components/TextInput/TextInputLabel'; +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 * as Browser from '@libs/Browser'; +import isInputAutoFilled from '@libs/isInputAutoFilled'; +import useNativeDriver from '@libs/useNativeDriver'; +import variables from '@styles/variables'; +import CONST from '@src/CONST'; +import type {BaseTextInputProps, BaseTextInputRef} from './types'; + +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, + isMarkdownEnabled = false, + excludedMarkdownStyles = [], + shouldShowClearButton = false, + shouldUseDisabledStyles = true, + prefixContainerStyle = [], + prefixStyle = [], + suffixContainerStyle = [], + suffixStyle = [], + contentWidth, + loadingSpinnerStyle, + ...inputProps + }: BaseTextInputProps, + ref: ForwardedRef, +) { + const InputComponent = isMarkdownEnabled ? RNMarkdownTextInput : RNTextInput; + 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 saftiness 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 [height, setHeight] = useState(variables.componentSizeLarge); + const [width, setWidth] = useState(null); + + const labelScale = useRef(new Animated.Value(initialActiveLabel ? styleConst.ACTIVE_LABEL_SCALE : styleConst.INACTIVE_LABEL_SCALE)).current; + const labelTranslateY = useRef(new Animated.Value(initialActiveLabel ? styleConst.ACTIVE_LABEL_TRANSLATE_Y : styleConst.INACTIVE_LABEL_TRANSLATE_Y)).current; + const input = useRef(null); + const isLabelActive = useRef(initialActiveLabel); + + // 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) => { + Animated.parallel([ + Animated.spring(labelTranslateY, { + toValue: translateY, + useNativeDriver, + }), + Animated.spring(labelScale, { + toValue: scale, + useNativeDriver, + }), + ]).start(); + }, + [labelScale, labelTranslateY], + ); + + const activateLabel = useCallback(() => { + const newValue = value ?? ''; + + if (newValue.length < 0 || isLabelActive.current) { + return; + } + + animateLabel(styleConst.ACTIVE_LABEL_TRANSLATE_Y, styleConst.ACTIVE_LABEL_SCALE); + isLabelActive.current = true; + }, [animateLabel, value]); + + const deactivateLabel = useCallback(() => { + const newValue = value ?? ''; + + if (!!forceActiveLabel || newValue.length !== 0 || prefixCharacter || suffixCharacter) { + return; + } + + animateLabel(styleConst.INACTIVE_LABEL_TRANSLATE_Y, styleConst.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)); + setHeight((prevHeight: number) => (!multiline ? layout.height : prevHeight)); + }, + [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; + + /** + * To prevent text jumping caused by virtual DOM calculations on Safari and mobile Chrome, + * make sure to include the `lineHeight`. + * Reference: https://github.com/Expensify/App/issues/26735 + * For other platforms, explicitly remove `lineHeight` from single-line inputs + * to prevent long text from disappearing once it exceeds the input space. + * See https://github.com/Expensify/App/issues/13802 + */ + const lineHeight = useMemo(() => { + if (Browser.isSafari() || Browser.isMobileChrome()) { + const lineHeightValue = StyleSheet.flatten(inputStyle).lineHeight; + if (lineHeightValue !== undefined) { + return lineHeightValue; + } + } + + return undefined; + }, [inputStyle]); + + const inputPaddingLeft = !!prefixCharacter && StyleUtils.getPaddingLeft(StyleUtils.getCharacterPadding(prefixCharacter) + styles.pl1.paddingLeft); + const inputPaddingRight = !!suffixCharacter && StyleUtils.getPaddingRight(StyleUtils.getCharacterPadding(suffixCharacter) + styles.pr1.paddingRight); + + 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={theme.placeholderText} + underlineColorAndroid="transparent" + style={[ + styles.flex1, + styles.w100, + inputStyle, + (!hasLabel || isMultiline) && styles.pv0, + inputPaddingLeft, + inputPaddingRight, + inputProps.secureTextEntry && styles.secureInput, + + // Explicitly remove `lineHeight` from single line inputs so that long text doesn't disappear + // once it exceeds the input space (See https://github.com/Expensify/App/issues/13802) + !isMultiline && {height, lineHeight}, + + // 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 && Browser.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={inputProps.inputMode} + value={value} + selection={inputProps.selection} + readOnly={isReadOnly} + defaultValue={defaultValue} + markdownStyle={markdownStyle} + /> + {!!suffixCharacter && ( + + + {suffixCharacter} + + + )} + {isFocused && !isReadOnly && shouldShowClearButton && !!value && setValue('')} />} + {!!inputProps.isLoading && ( + + )} + {!!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 (Browser.isMobileSafari() || Browser.isSafari() || Browser.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..c940163a7de6 --- /dev/null +++ b/src/components/TextInput/BaseTextInput/index.e2e.tsx @@ -0,0 +1,26 @@ +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 e36ae60255fc..0df586b70057 100644 --- a/src/components/TextInput/BaseTextInput/index.tsx +++ b/src/components/TextInput/BaseTextInput/index.tsx @@ -1,534 +1,3 @@ -import {Str} from 'expensify-common'; -import type {ForwardedRef} from 'react'; -import React, {forwardRef, useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import type {GestureResponderEvent, LayoutChangeEvent, NativeSyntheticEvent, StyleProp, TextInputFocusEventData, ViewStyle} from 'react-native'; -import {ActivityIndicator, Animated, StyleSheet, View} from 'react-native'; -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 RNMarkdownTextInput 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 * as styleConst from '@components/TextInput/styleConst'; -import TextInputClearButton from '@components/TextInput/TextInputClearButton'; -import TextInputLabel from '@components/TextInput/TextInputLabel'; -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 * as Browser from '@libs/Browser'; -import isInputAutoFilled from '@libs/isInputAutoFilled'; -import useNativeDriver from '@libs/useNativeDriver'; -import variables from '@styles/variables'; -import CONST from '@src/CONST'; -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, - isMarkdownEnabled = false, - excludedMarkdownStyles = [], - shouldShowClearButton = false, - shouldUseDisabledStyles = true, - prefixContainerStyle = [], - prefixStyle = [], - suffixContainerStyle = [], - suffixStyle = [], - contentWidth, - loadingSpinnerStyle, - ...inputProps - }: BaseTextInputProps, - ref: ForwardedRef, -) { - const InputComponent = isMarkdownEnabled ? RNMarkdownTextInput : RNTextInput; - 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 saftiness 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 [height, setHeight] = useState(variables.componentSizeLarge); - const [width, setWidth] = useState(null); - - const labelScale = useRef(new Animated.Value(initialActiveLabel ? styleConst.ACTIVE_LABEL_SCALE : styleConst.INACTIVE_LABEL_SCALE)).current; - const labelTranslateY = useRef(new Animated.Value(initialActiveLabel ? styleConst.ACTIVE_LABEL_TRANSLATE_Y : styleConst.INACTIVE_LABEL_TRANSLATE_Y)).current; - const input = useRef(null); - const isLabelActive = useRef(initialActiveLabel); - - // 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) => { - Animated.parallel([ - Animated.spring(labelTranslateY, { - toValue: translateY, - useNativeDriver, - }), - Animated.spring(labelScale, { - toValue: scale, - useNativeDriver, - }), - ]).start(); - }, - [labelScale, labelTranslateY], - ); - - const activateLabel = useCallback(() => { - const newValue = value ?? ''; - - if (newValue.length < 0 || isLabelActive.current) { - return; - } - - animateLabel(styleConst.ACTIVE_LABEL_TRANSLATE_Y, styleConst.ACTIVE_LABEL_SCALE); - isLabelActive.current = true; - }, [animateLabel, value]); - - const deactivateLabel = useCallback(() => { - const newValue = value ?? ''; - - if (!!forceActiveLabel || newValue.length !== 0 || prefixCharacter || suffixCharacter) { - return; - } - - animateLabel(styleConst.INACTIVE_LABEL_TRANSLATE_Y, styleConst.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)); - setHeight((prevHeight: number) => (!multiline ? layout.height : prevHeight)); - }, - [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; - - /** - * To prevent text jumping caused by virtual DOM calculations on Safari and mobile Chrome, - * make sure to include the `lineHeight`. - * Reference: https://github.com/Expensify/App/issues/26735 - * For other platforms, explicitly remove `lineHeight` from single-line inputs - * to prevent long text from disappearing once it exceeds the input space. - * See https://github.com/Expensify/App/issues/13802 - */ - const lineHeight = useMemo(() => { - if (Browser.isSafari() || Browser.isMobileChrome()) { - const lineHeightValue = StyleSheet.flatten(inputStyle).lineHeight; - if (lineHeightValue !== undefined) { - return lineHeightValue; - } - } - - return undefined; - }, [inputStyle]); - - const inputPaddingLeft = !!prefixCharacter && StyleUtils.getPaddingLeft(StyleUtils.getCharacterPadding(prefixCharacter) + styles.pl1.paddingLeft); - const inputPaddingRight = !!suffixCharacter && StyleUtils.getPaddingRight(StyleUtils.getCharacterPadding(suffixCharacter) + styles.pr1.paddingRight); - - 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={theme.placeholderText} - underlineColorAndroid="transparent" - style={[ - styles.flex1, - styles.w100, - inputStyle, - (!hasLabel || isMultiline) && styles.pv0, - inputPaddingLeft, - inputPaddingRight, - inputProps.secureTextEntry && styles.secureInput, - - // Explicitly remove `lineHeight` from single line inputs so that long text doesn't disappear - // once it exceeds the input space (See https://github.com/Expensify/App/issues/13802) - !isMultiline && {height, lineHeight}, - - // 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 && Browser.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={inputProps.inputMode} - value={value} - selection={inputProps.selection} - readOnly={isReadOnly} - defaultValue={defaultValue} - markdownStyle={markdownStyle} - /> - {!!suffixCharacter && ( - - - {suffixCharacter} - - - )} - {isFocused && !isReadOnly && shouldShowClearButton && !!value && setValue('')} />} - {!!inputProps.isLoading && ( - - )} - {!!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 (Browser.isMobileSafari() || Browser.isSafari() || Browser.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/libs/E2E/interactions/index.ts b/src/libs/E2E/interactions/index.ts index 5a1b11b411f2..e9ad35388ed7 100644 --- a/src/libs/E2E/interactions/index.ts +++ b/src/libs/E2E/interactions/index.ts @@ -15,6 +15,19 @@ const waitForElement = (testID: string) => { }); }; +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) => { @@ -31,4 +44,4 @@ const tap = (testID: string) => { E2EGenericPressableWrapper.getPressableProps(testID)?.onPress?.(); }; -export {waitForElement, tap, waitForEvent}; +export {waitForElement, tap, waitForEvent, waitForTextInputValue}; diff --git a/src/libs/E2E/tests/moneyRequestTest.e2e.ts b/src/libs/E2E/tests/moneyRequestTest.e2e.ts index 306a13a1745a..cd456c496101 100644 --- a/src/libs/E2E/tests/moneyRequestTest.e2e.ts +++ b/src/libs/E2E/tests/moneyRequestTest.e2e.ts @@ -3,7 +3,7 @@ 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} from '@libs/E2E/interactions'; +import {tap, waitForElement, waitForEvent, waitForTextInputValue} from '@libs/E2E/interactions'; import getConfigValueOrThrow from '@libs/E2E/utils/getConfigValueOrThrow'; import Navigation from '@libs/Navigation/Navigation'; import Performance from '@libs/Performance'; @@ -48,10 +48,11 @@ const test = (config: NativeConfig) => { .then(() => E2EClient.sendNativeCommand(NativeCommands.makeClearCommand())) .then(() => { tap('button_2'); - setTimeout(() => { - Performance.markStart(CONST.TIMING.OPEN_SUBMIT_EXPENSE_CONTACT); - tap('next-button'); - }, 1000); + }) + .then(() => waitForTextInputValue('2', 'moneyRequestAmountInput')) + .then(() => { + Performance.markStart(CONST.TIMING.OPEN_SUBMIT_EXPENSE_CONTACT); + tap('next-button'); }) .then(() => waitForEvent(CONST.TIMING.OPEN_SUBMIT_EXPENSE_CONTACT)) .then((entry) => { diff --git a/src/pages/iou/MoneyRequestAmountForm.tsx b/src/pages/iou/MoneyRequestAmountForm.tsx index 0d37a6777e64..c5ea1c8c17ee 100644 --- a/src/pages/iou/MoneyRequestAmountForm.tsx +++ b/src/pages/iou/MoneyRequestAmountForm.tsx @@ -282,6 +282,7 @@ function MoneyRequestAmountForm( moneyRequestAmountInputRef={moneyRequestAmountInput} inputStyle={[styles.iouAmountTextInput]} containerStyle={[styles.iouAmountTextInputContainer]} + testID="moneyRequestAmountInput" /> {!!formError && ( Date: Tue, 19 Nov 2024 16:52:00 +0100 Subject: [PATCH 7/9] fix: TS types --- .../TextInput/BaseTextInput/implementation/index.native.tsx | 2 +- src/components/TextInput/BaseTextInput/implementation/index.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/TextInput/BaseTextInput/implementation/index.native.tsx b/src/components/TextInput/BaseTextInput/implementation/index.native.tsx index 9de6b6dd6d08..f762aac61897 100644 --- a/src/components/TextInput/BaseTextInput/implementation/index.native.tsx +++ b/src/components/TextInput/BaseTextInput/implementation/index.native.tsx @@ -13,6 +13,7 @@ import RNMarkdownTextInput from '@components/RNMarkdownTextInput'; import type {AnimatedTextInputRef} from '@components/RNTextInput'; import RNTextInput from '@components/RNTextInput'; import Text from '@components/Text'; +import type {BaseTextInputProps, BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; import * as styleConst from '@components/TextInput/styleConst'; import TextInputClearButton from '@components/TextInput/TextInputClearButton'; import TextInputLabel from '@components/TextInput/TextInputLabel'; @@ -26,7 +27,6 @@ import isInputAutoFilled from '@libs/isInputAutoFilled'; import useNativeDriver from '@libs/useNativeDriver'; import variables from '@styles/variables'; import CONST from '@src/CONST'; -import type {BaseTextInputProps, BaseTextInputRef} from './types'; function BaseTextInput( { diff --git a/src/components/TextInput/BaseTextInput/implementation/index.tsx b/src/components/TextInput/BaseTextInput/implementation/index.tsx index e36ae60255fc..69874be7703e 100644 --- a/src/components/TextInput/BaseTextInput/implementation/index.tsx +++ b/src/components/TextInput/BaseTextInput/implementation/index.tsx @@ -14,6 +14,7 @@ import type {AnimatedTextInputRef} from '@components/RNTextInput'; import RNTextInput from '@components/RNTextInput'; import SwipeInterceptPanResponder from '@components/SwipeInterceptPanResponder'; import Text from '@components/Text'; +import type {BaseTextInputProps, BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; import * as styleConst from '@components/TextInput/styleConst'; import TextInputClearButton from '@components/TextInput/TextInputClearButton'; import TextInputLabel from '@components/TextInput/TextInputLabel'; @@ -27,7 +28,6 @@ import isInputAutoFilled from '@libs/isInputAutoFilled'; import useNativeDriver from '@libs/useNativeDriver'; import variables from '@styles/variables'; import CONST from '@src/CONST'; -import type {BaseTextInputProps, BaseTextInputRef} from './types'; function BaseTextInput( { From 606ac258f8b347df8d6f8a696ea1705ec5749964 Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Tue, 19 Nov 2024 16:59:21 +0100 Subject: [PATCH 8/9] fix: ci --- src/components/TextInput/BaseTextInput/index.e2e.tsx | 1 + src/components/TextInputWithCurrencySymbol/types.ts | 3 +++ 2 files changed, 4 insertions(+) diff --git a/src/components/TextInput/BaseTextInput/index.e2e.tsx b/src/components/TextInput/BaseTextInput/index.e2e.tsx index c940163a7de6..154e16bf6d86 100644 --- a/src/components/TextInput/BaseTextInput/index.e2e.tsx +++ b/src/components/TextInput/BaseTextInput/index.e2e.tsx @@ -18,6 +18,7 @@ function BaseTextInputE2E(props: BaseTextInputProps, ref: ForwardedRef ); diff --git a/src/components/TextInputWithCurrencySymbol/types.ts b/src/components/TextInputWithCurrencySymbol/types.ts index 401af75b16cd..ff039894bb67 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 & { From 3b544454daa9076558fd43bbd0da59244f7af71c Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Thu, 21 Nov 2024 12:49:01 +0100 Subject: [PATCH 9/9] fix: speed up clear command --- tests/e2e/nativeCommands/adbClear.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/e2e/nativeCommands/adbClear.ts b/tests/e2e/nativeCommands/adbClear.ts index 3fd9b5c70b94..5e25739b73a7 100644 --- a/tests/e2e/nativeCommands/adbClear.ts +++ b/tests/e2e/nativeCommands/adbClear.ts @@ -6,7 +6,8 @@ const adbClear = (): Promise => { return execAsync(` function clear_input() { adb shell input keyevent KEYCODE_MOVE_END - adb shell input keyevent --longpress $(printf 'KEYCODE_DEL %.0s' {1..250}) + # 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