From 3af050bb2098d27ee16e02ac491a91bd02b84d72 Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Tue, 12 Nov 2024 11:57:42 +0100 Subject: [PATCH 01/22] 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 02/22] 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 03/22] 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 04/22] 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 05/22] 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 06/22] 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 07/22] 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 08/22] 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 09/22] 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 From 7e14df104bab8f2b9fa68ddce5873338ce3e9064 Mon Sep 17 00:00:00 2001 From: Taras Perun Date: Tue, 17 Dec 2024 16:45:37 +0100 Subject: [PATCH 10/22] update BaseTextInput --- .../BaseTextInput/implementation/index.tsx | 37 +++++++++++-------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/src/components/TextInput/BaseTextInput/implementation/index.tsx b/src/components/TextInput/BaseTextInput/implementation/index.tsx index 69874be7703e..e8562b4246b1 100644 --- a/src/components/TextInput/BaseTextInput/implementation/index.tsx +++ b/src/components/TextInput/BaseTextInput/implementation/index.tsx @@ -2,7 +2,8 @@ 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 {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'; @@ -24,8 +25,8 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import * as Browser from '@libs/Browser'; +import * as InputUtils from '@libs/InputUtils'; import isInputAutoFilled from '@libs/isInputAutoFilled'; -import useNativeDriver from '@libs/useNativeDriver'; import variables from '@styles/variables'; import CONST from '@src/CONST'; @@ -99,10 +100,12 @@ function BaseTextInput( 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 labelScale = useSharedValue(initialActiveLabel ? styleConst.ACTIVE_LABEL_SCALE : styleConst.INACTIVE_LABEL_SCALE); + const labelTranslateY = useSharedValue(initialActiveLabel ? styleConst.ACTIVE_LABEL_TRANSLATE_Y : styleConst.INACTIVE_LABEL_TRANSLATE_Y); + const input = useRef(null); const isLabelActive = useRef(initialActiveLabel); + const didScrollToEndRef = useRef(false); // AutoFocus which only works on mount: useEffect(() => { @@ -122,16 +125,8 @@ function BaseTextInput( const animateLabel = useCallback( (translateY: number, scale: number) => { - Animated.parallel([ - Animated.spring(labelTranslateY, { - toValue: translateY, - useNativeDriver, - }), - Animated.spring(labelScale, { - toValue: scale, - useNativeDriver, - }), - ]).start(); + labelScale.set(withSpring(scale, {overshootClamping: false})); + labelTranslateY.set(withSpring(translateY, {overshootClamping: false})); }, [labelScale, labelTranslateY], ); @@ -427,7 +422,19 @@ function BaseTextInput( )} - {isFocused && !isReadOnly && shouldShowClearButton && !!value && setValue('')} />} + {isFocused && !isReadOnly && shouldShowClearButton && !!value && ( + { + if (didScrollToEndRef.current || !input.current) { + return; + } + InputUtils.scrollToRight(input.current); + didScrollToEndRef.current = true; + }} + > + setValue('')} /> + + )} {!!inputProps.isLoading && ( Date: Wed, 18 Dec 2024 14:34:02 +0100 Subject: [PATCH 11/22] clean duplications --- src/components/Button/index.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx index 1bee9bdc24fd..07c70cfe10fd 100644 --- a/src/components/Button/index.tsx +++ b/src/components/Button/index.tsx @@ -122,9 +122,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; From a41b9bb3fd09c45df6dffea703447ba42ac60cd4 Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Wed, 18 Dec 2024 14:58:44 +0100 Subject: [PATCH 12/22] fix: duplicated identifiers --- src/components/Button/index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx index 07c70cfe10fd..6cae8b092748 100644 --- a/src/components/Button/index.tsx +++ b/src/components/Button/index.tsx @@ -246,7 +246,6 @@ function Button( link = false, isContentCentered = false, isPressOnEnterActive, - testID, ...rest }: ButtonProps, ref: ForwardedRef, From 335eb124824a1670aa0893009d38b545f9e03f4b Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Wed, 18 Dec 2024 15:24:40 +0100 Subject: [PATCH 13/22] fix: move OPEN_SUBMIT_EXPENSE_CONTACT start to app code --- src/libs/E2E/tests/moneyRequestTest.e2e.ts | 5 +---- src/pages/iou/request/step/IOURequestStepAmount.tsx | 3 +++ 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/libs/E2E/tests/moneyRequestTest.e2e.ts b/src/libs/E2E/tests/moneyRequestTest.e2e.ts index cd456c496101..cbcb148af350 100644 --- a/src/libs/E2E/tests/moneyRequestTest.e2e.ts +++ b/src/libs/E2E/tests/moneyRequestTest.e2e.ts @@ -50,10 +50,7 @@ const test = (config: NativeConfig) => { tap('button_2'); }) .then(() => waitForTextInputValue('2', 'moneyRequestAmountInput')) - .then(() => { - Performance.markStart(CONST.TIMING.OPEN_SUBMIT_EXPENSE_CONTACT); - tap('next-button'); - }) + .then(() => tap('next-button')) .then(() => waitForEvent(CONST.TIMING.OPEN_SUBMIT_EXPENSE_CONTACT)) .then((entry) => { E2EClient.submitTestResults({ diff --git a/src/pages/iou/request/step/IOURequestStepAmount.tsx b/src/pages/iou/request/step/IOURequestStepAmount.tsx index 2b3d639deaa9..c7b7ff661a2e 100644 --- a/src/pages/iou/request/step/IOURequestStepAmount.tsx +++ b/src/pages/iou/request/step/IOURequestStepAmount.tsx @@ -10,6 +10,7 @@ import * as TransactionEdit from '@libs/actions/TransactionEdit'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import Navigation from '@libs/Navigation/Navigation'; import * as OptionsListUtils from '@libs/OptionsListUtils'; +import Performance from '@libs/Performance'; import * as ReportUtils from '@libs/ReportUtils'; import playSound, {SOUNDS} from '@libs/Sound'; import * as TransactionUtils from '@libs/TransactionUtils'; @@ -122,6 +123,8 @@ function IOURequestStepAmount({ }; const navigateToParticipantPage = () => { + Performance.markStart(CONST.TIMING.OPEN_SUBMIT_EXPENSE_CONTACT); + switch (iouType) { case CONST.IOU.TYPE.REQUEST: Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute(CONST.IOU.TYPE.SUBMIT, transactionID, reportID)); From 04cade16e9b481a3dbea9a510a9384701f46eec8 Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Wed, 18 Dec 2024 15:41:34 +0100 Subject: [PATCH 14/22] fix: move OPEN_SUBMIT_EXPENSE_APPROVE start to app code --- src/libs/E2E/tests/moneyRequestTest.e2e.ts | 5 +---- src/pages/iou/request/step/IOURequestStepParticipants.tsx | 1 + 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/libs/E2E/tests/moneyRequestTest.e2e.ts b/src/libs/E2E/tests/moneyRequestTest.e2e.ts index cbcb148af350..ac1a4ba3523f 100644 --- a/src/libs/E2E/tests/moneyRequestTest.e2e.ts +++ b/src/libs/E2E/tests/moneyRequestTest.e2e.ts @@ -61,10 +61,7 @@ const test = (config: NativeConfig) => { }); }) .then(() => waitForElement('+66 65 490 0617')) - .then(() => { - Performance.markStart(CONST.TIMING.OPEN_SUBMIT_EXPENSE_APPROVE); - tap('+66 65 490 0617'); - }) + .then(() => tap('+66 65 490 0617')) .then(() => waitForEvent(CONST.TIMING.OPEN_SUBMIT_EXPENSE_APPROVE)) .then((entry) => { E2EClient.submitTestResults({ diff --git a/src/pages/iou/request/step/IOURequestStepParticipants.tsx b/src/pages/iou/request/step/IOURequestStepParticipants.tsx index 8dc70bedd4f6..cd312cce4477 100644 --- a/src/pages/iou/request/step/IOURequestStepParticipants.tsx +++ b/src/pages/iou/request/step/IOURequestStepParticipants.tsx @@ -136,6 +136,7 @@ function IOURequestStepParticipants({ transactionID, selectedReportID.current || reportID, ); + Performance.markStart(CONST.TIMING.OPEN_SUBMIT_EXPENSE_APPROVE); if (isCategorizing) { Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute(action, iouType, transactionID, selectedReportID.current || reportID, iouConfirmationPageRoute)); } else { From ab157c0b5d420b8c3b53fa2882518817c1e5e22f Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Wed, 18 Dec 2024 18:48:50 +0100 Subject: [PATCH 15/22] fix: no external navigation dispatches --- src/components/FloatingActionButton.tsx | 1 + src/components/MenuItem.tsx | 5 +++++ src/components/PopoverMenu.tsx | 3 +++ src/components/TabSelector/TabSelector.tsx | 6 ++++-- .../TabSelector/TabSelectorItem.tsx | 6 +++++- src/libs/E2E/interactions/index.ts | 11 +++++++++- src/libs/E2E/tests/moneyRequestTest.e2e.ts | 20 ++++++------------- src/libs/actions/IOU.ts | 2 ++ .../FloatingActionButtonAndPopover.tsx | 1 + 9 files changed, 37 insertions(+), 18 deletions(-) diff --git a/src/components/FloatingActionButton.tsx b/src/components/FloatingActionButton.tsx index e0f0ff4e6dcd..9a79b6a58dcc 100644 --- a/src/components/FloatingActionButton.tsx +++ b/src/components/FloatingActionButton.tsx @@ -136,6 +136,7 @@ function FloatingActionButton({onPress, isActive, accessibilityLabel, role}: Flo onLongPress={() => {}} role={role} shouldUseHapticsOnLongPress={false} + testID="floating-action-button" > {({pressed}) => ( diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx index 7432c683e0a7..b88eb4c3edab 100644 --- a/src/components/PopoverMenu.tsx +++ b/src/components/PopoverMenu.tsx @@ -54,6 +54,9 @@ type PopoverMenuItem = MenuItemProps & { shouldCloseAllModals?: boolean; pendingAction?: PendingAction; + + /** Test identifier used to find elements in unit and e2e tests */ + testID?: string; }; type PopoverModalProps = Pick; diff --git a/src/components/TabSelector/TabSelector.tsx b/src/components/TabSelector/TabSelector.tsx index b05e633842b1..7172a150b7f0 100644 --- a/src/components/TabSelector/TabSelector.tsx +++ b/src/components/TabSelector/TabSelector.tsx @@ -24,6 +24,7 @@ type TabSelectorProps = MaterialTopTabBarProps & { type IconAndTitle = { icon: IconAsset; title: string; + testID?: string; }; function getIconAndTitle(route: string, translate: LocaleContextProps['translate']): IconAndTitle { @@ -39,7 +40,7 @@ function getIconAndTitle(route: string, translate: LocaleContextProps['translate case CONST.DEBUG.TRANSACTION_VIOLATIONS: return {icon: Expensicons.Exclamation, title: translate('debug.violations')}; 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')}; case CONST.TAB.NEW_CHAT: @@ -75,7 +76,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} = getIconAndTitle(route.name, translate); const onPress = () => { if (isActive) { @@ -108,6 +109,7 @@ function TabSelector({state, navigation, onTabPress = () => {}, position, onFocu inactiveOpacity={inactiveOpacity} backgroundColor={backgroundColor} isActive={isActive} + testID={testID} /> ); })} diff --git a/src/components/TabSelector/TabSelectorItem.tsx b/src/components/TabSelector/TabSelectorItem.tsx index 274813d9a44b..54d44a8b88ac 100644 --- a/src/components/TabSelector/TabSelectorItem.tsx +++ b/src/components/TabSelector/TabSelectorItem.tsx @@ -30,9 +30,12 @@ type TabSelectorItemProps = { /** Whether this tab is active */ isActive?: boolean; + + /** Test identifier used to find elements in unit and e2e tests */ + testID?: string; }; -function TabSelectorItem({icon, title = '', onPress = () => {}, backgroundColor = '', activeOpacity = 0, inactiveOpacity = 1, isActive = false}: TabSelectorItemProps) { +function TabSelectorItem({icon, title = '', onPress = () => {}, backgroundColor = '', activeOpacity = 0, inactiveOpacity = 1, isActive = false, testID}: TabSelectorItemProps) { const styles = useThemeStyles(); const [isHovered, setIsHovered] = useState(false); @@ -46,6 +49,7 @@ function TabSelectorItem({icon, title = '', onPress = () => {}, backgroundColor onHoverOut={() => setIsHovered(false)} role={CONST.ROLE.BUTTON} dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}} + testID={testID} > { + 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) { @@ -41,7 +48,9 @@ const waitForEvent = (eventName: string): Promise => { }; const tap = (testID: string) => { - E2EGenericPressableWrapper.getPressableProps(testID)?.onPress?.(); + 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/tests/moneyRequestTest.e2e.ts b/src/libs/E2E/tests/moneyRequestTest.e2e.ts index ac1a4ba3523f..8caf502e4645 100644 --- a/src/libs/E2E/tests/moneyRequestTest.e2e.ts +++ b/src/libs/E2E/tests/moneyRequestTest.e2e.ts @@ -5,11 +5,7 @@ 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 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'; const test = (config: NativeConfig) => { @@ -29,13 +25,9 @@ const test = (config: NativeConfig) => { console.debug('[E2E] Logged in, getting money request metrics and submitting them…'); 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()), - ); - }) + .then(() => tap('floating-action-button')) + .then(() => waitForElement('create-expense')) + .then(() => tap('create-expense')) .then(() => waitForEvent(CONST.TIMING.OPEN_SUBMIT_EXPENSE)) .then((entry) => { E2EClient.submitTestResults({ @@ -45,10 +37,10 @@ const test = (config: NativeConfig) => { unit: 'ms', }); }) + .then(() => waitForElement('manual')) + .then(() => tap('manual')) .then(() => E2EClient.sendNativeCommand(NativeCommands.makeClearCommand())) - .then(() => { - tap('button_2'); - }) + .then(() => tap('button_2')) .then(() => waitForTextInputValue('2', 'moneyRequestAmountInput')) .then(() => tap('next-button')) .then(() => waitForEvent(CONST.TIMING.OPEN_SUBMIT_EXPENSE_CONTACT)) diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 696853f49fd7..0db9b5db8cb5 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -44,6 +44,7 @@ import Navigation from '@libs/Navigation/Navigation'; import * as NextStepUtils from '@libs/NextStepUtils'; import {rand64} from '@libs/NumberUtils'; import * as OptionsListUtils from '@libs/OptionsListUtils'; +import Performance from '@libs/Performance'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as PhoneNumber from '@libs/PhoneNumber'; import * as PolicyUtils from '@libs/PolicyUtils'; @@ -545,6 +546,7 @@ function clearMoneyRequest(transactionID: string, skipConfirmation = false) { } function startMoneyRequest(iouType: ValueOf, reportID: string, requestType?: IOURequestType, skipConfirmation = false) { + Performance.markStart(CONST.TIMING.OPEN_SUBMIT_EXPENSE); clearMoneyRequest(CONST.IOU.OPTIMISTIC_TRANSACTION_ID, skipConfirmation); switch (requestType) { case CONST.IOU.REQUEST_TYPE.MANUAL: diff --git a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx index 32327d031b9e..d55cbe2312af 100644 --- a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx +++ b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx @@ -384,6 +384,7 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu}: Fl { icon: getIconForAction(CONST.IOU.TYPE.CREATE), text: translate('iou.createExpense'), + testID: 'create-expense', shouldCallAfterModalHide: shouldRedirectToExpensifyClassic, onSelected: () => interceptAnonymousUser(() => { From 545e89632158d77fe2ee9241f0de3f5e4f838afa Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Thu, 19 Dec 2024 10:25:48 +0100 Subject: [PATCH 16/22] fix: ci --- src/components/Button/index.tsx | 1 - src/pages/iou/request/step/IOURequestStepConfirmation.tsx | 1 - 2 files changed, 2 deletions(-) diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx index 6cae8b092748..277f74429c5b 100644 --- a/src/components/Button/index.tsx +++ b/src/components/Button/index.tsx @@ -415,7 +415,6 @@ function Button( hoverDimmingValue={1} onHoverIn={() => setIsHovered(true)} onHoverOut={() => setIsHovered(false)} - testID={testID} > {renderContent()} {isLoading && ( diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index cb531595cea3..51132df2d032 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -24,7 +24,6 @@ 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'; import * as TransactionUtils from '@libs/TransactionUtils'; From 9d39e16f1728c717b570b64100aa7db2521c9745 Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Thu, 19 Dec 2024 10:35:43 +0100 Subject: [PATCH 17/22] docs: update documentation --- contributingGuides/PERFORMANCE_METRICS.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/contributingGuides/PERFORMANCE_METRICS.md b/contributingGuides/PERFORMANCE_METRICS.md index 9e942f21d918..565b17780027 100644 --- a/contributingGuides/PERFORMANCE_METRICS.md +++ b/contributingGuides/PERFORMANCE_METRICS.md @@ -24,6 +24,9 @@ Project is using Firebase for tracking these metrics. However, not all of them a | `open_report_from_preview` | ✅ | Time taken to open a report from preview.

(previously `switch_report_from_preview`)

**Platforms:** All | Starts when the user presses the Report Preview. | Stops when the `ReportActionsList` finishes laying out. | | `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. | +| `open_submit_expense` | ❌ | Time taken to open "Submit expense" screen.

**Platforms:** All | Starts when the `Create expense` is pressed. | Stops when the `IOURequestStartPage` finishes laying out. | +| `open_submit_expense_contact` | ❌ | Time taken to Submit expense screen.

**Platforms:** All | Starts when the `Next` button on `Create expense` screen is pressed. | Stops when the `IOURequestStepParticipants` finishes laying out. | +| `open_submit_expense_approve` | ❌ | Time taken to Submit expense screen.

**Platforms:** All | Starts when the `Contact` on `Choose recipient` screen is selected. | Stops when the `IOURequestStepConfirmation` finishes laying out. | ## Documentation Maintenance @@ -37,7 +40,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) From 1b5e9d7377d4d8ecee8d1b6daeaa5ab605ebcc10 Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Fri, 20 Dec 2024 10:48:02 +0100 Subject: [PATCH 18/22] fix: post merge fixes --- .../TextInput/BaseTextInput/implementation/index.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/components/TextInput/BaseTextInput/implementation/index.tsx b/src/components/TextInput/BaseTextInput/implementation/index.tsx index e8562b4246b1..176e223f72da 100644 --- a/src/components/TextInput/BaseTextInput/implementation/index.tsx +++ b/src/components/TextInput/BaseTextInput/implementation/index.tsx @@ -1,7 +1,7 @@ import {Str} from 'expensify-common'; -import type {ForwardedRef} from 'react'; +import type {ForwardedRef, MutableRefObject} from 'react'; import React, {forwardRef, useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import type {GestureResponderEvent, LayoutChangeEvent, NativeSyntheticEvent, StyleProp, TextInputFocusEventData, ViewStyle} from 'react-native'; +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'; @@ -19,6 +19,7 @@ import type {BaseTextInputProps, BaseTextInputRef} from '@components/TextInput/B import * as styleConst 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'; @@ -107,6 +108,8 @@ function BaseTextInput( 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 From 9e660ba7cc6e004192092798d77f24e39e550d41 Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Fri, 20 Dec 2024 13:33:31 +0100 Subject: [PATCH 19/22] fix: new eslint rules --- src/libs/actions/TransactionEdit.ts | 6 +++++- src/pages/iou/MoneyRequestAmountForm.tsx | 2 +- src/pages/iou/request/IOURequestStartPage.tsx | 2 +- src/pages/iou/request/step/IOURequestStepAmount.tsx | 10 +++++----- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/libs/actions/TransactionEdit.ts b/src/libs/actions/TransactionEdit.ts index a76cb8f25b75..1ababe20f4e7 100644 --- a/src/libs/actions/TransactionEdit.ts +++ b/src/libs/actions/TransactionEdit.ts @@ -58,7 +58,11 @@ function createDraftTransaction(transaction: OnyxEntry) { Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transaction.transactionID}`, newTransaction); } -function removeDraftTransaction(transactionID: string) { +function removeDraftTransaction(transactionID: string | undefined) { + if (!transactionID) { + return; + } + Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, null); } diff --git a/src/pages/iou/MoneyRequestAmountForm.tsx b/src/pages/iou/MoneyRequestAmountForm.tsx index c5ea1c8c17ee..7c8fc475d406 100644 --- a/src/pages/iou/MoneyRequestAmountForm.tsx +++ b/src/pages/iou/MoneyRequestAmountForm.tsx @@ -313,7 +313,7 @@ function MoneyRequestAmountForm( addBankAccountRoute={bankAccountRoute} addDebitCardRoute={ROUTES.IOU_SEND_ADD_DEBIT_CARD} currency={currency ?? CONST.CURRENCY.USD} - policyID={policyID ?? '-1'} + policyID={policyID} style={[styles.w100, canUseTouchScreen ? styles.mt5 : styles.mt3]} buttonSize={CONST.DROPDOWN_BUTTON_SIZE.LARGE} kycWallAnchorAlignment={{ diff --git a/src/pages/iou/request/IOURequestStartPage.tsx b/src/pages/iou/request/IOURequestStartPage.tsx index 90178f8ecc7d..8f968067e831 100644 --- a/src/pages/iou/request/IOURequestStartPage.tsx +++ b/src/pages/iou/request/IOURequestStartPage.tsx @@ -47,7 +47,7 @@ function IOURequestStartPage({ const [selectedTab = CONST.TAB_REQUEST.SCAN, selectedTabResult] = useOnyx(`${ONYXKEYS.COLLECTION.SELECTED_TAB}${CONST.TAB.IOU_REQUEST_TYPE}`); const isLoadingSelectedTab = shouldUseTab ? isLoadingOnyxValue(selectedTabResult) : false; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${route?.params.transactionID || -1}`); + const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${route?.params.transactionID}`); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const {canUseCombinedTrackSubmit} = usePermissions(); diff --git a/src/pages/iou/request/step/IOURequestStepAmount.tsx b/src/pages/iou/request/step/IOURequestStepAmount.tsx index c7b7ff661a2e..96bd1bb47dbc 100644 --- a/src/pages/iou/request/step/IOURequestStepAmount.tsx +++ b/src/pages/iou/request/step/IOURequestStepAmount.tsx @@ -57,9 +57,9 @@ function IOURequestStepAmount({ const focusTimeoutRef = useRef(null); const isSaveButtonPressed = useRef(false); const iouRequestType = getRequestType(transaction); - const policyID = report?.policyID ?? '-1'; + const policyID = report?.policyID; - const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID ?? -1}`); + const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID}`); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); const [draftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`); @@ -109,7 +109,7 @@ function IOURequestStepAmount({ if (isSaveButtonPressed.current) { return; } - TransactionEdit.removeDraftTransaction(transaction?.transactionID ?? '-1'); + TransactionEdit.removeDraftTransaction(transaction?.transactionID); }; // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, []); @@ -175,7 +175,7 @@ function IOURequestStepAmount({ if (report?.reportID && !ReportUtils.isArchivedRoom(report, reportNameValuePairs) && iouType !== CONST.IOU.TYPE.CREATE) { const selectedParticipants = IOU.setMoneyRequestParticipantsFromReport(transactionID, report); const participants = selectedParticipants.map((participant) => { - const participantAccountID = participant?.accountID ?? -1; + const participantAccountID = participant?.accountID; return participantAccountID ? OptionsListUtils.getParticipantsOption(participant, personalDetails) : OptionsListUtils.getReportOption(participant); }); const backendAmount = CurrencyUtils.convertToBackendAmount(Number.parseFloat(amount)); @@ -291,7 +291,7 @@ function IOURequestStepAmount({ amount={Math.abs(transactionAmount)} skipConfirmation={shouldSkipConfirmation ?? false} iouType={iouType} - policyID={policy?.id ?? '-1'} + policyID={policy?.id} bankAccountRoute={ReportUtils.getBankAccountRoute(report)} ref={(e) => (textInput.current = e)} shouldKeepUserInput={transaction?.shouldShowOriginalAmount} From e4c77b77b7ec29678db017e299fb4c000a05eba7 Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Fri, 20 Dec 2024 14:26:50 +0100 Subject: [PATCH 20/22] fix: new eslint rules --- src/pages/iou/request/step/IOURequestStepParticipants.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/iou/request/step/IOURequestStepParticipants.tsx b/src/pages/iou/request/step/IOURequestStepParticipants.tsx index 637bbb64cb12..f9036d23bc70 100644 --- a/src/pages/iou/request/step/IOURequestStepParticipants.tsx +++ b/src/pages/iou/request/step/IOURequestStepParticipants.tsx @@ -38,7 +38,7 @@ function IOURequestStepParticipants({ const {translate} = useLocalize(); const styles = useThemeStyles(); const isFocused = useIsFocused(); - const [skipConfirmation] = useOnyx(`${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${transactionID ?? -1}`); + const [skipConfirmation] = useOnyx(`${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${transactionID}`); // We need to set selectedReportID if user has navigated back from confirmation page and navigates to confirmation page with already selected participant const selectedReportID = useRef(participants?.length === 1 ? participants.at(0)?.reportID ?? reportID : reportID); From ef046555c0fe0af3f11d94f387d8bd86764941d2 Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Fri, 20 Dec 2024 15:50:11 +0100 Subject: [PATCH 21/22] fix: new eslint rules --- src/libs/DistanceRequestUtils.ts | 2 +- src/pages/iou/request/step/IOURequestStepParticipants.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index c41b33873a8a..333ddb87bc7b 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -289,7 +289,7 @@ function convertToDistanceInMeters(distance: number, unit: Unit): number { /** * Returns custom unit rate ID for the distance transaction */ -function getCustomUnitRateID(reportID: string) { +function getCustomUnitRateID(reportID: string | undefined) { const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`]; const policy = PolicyUtils.getPolicy(report?.policyID ?? parentReport?.policyID); diff --git a/src/pages/iou/request/step/IOURequestStepParticipants.tsx b/src/pages/iou/request/step/IOURequestStepParticipants.tsx index f9036d23bc70..a48ed856946d 100644 --- a/src/pages/iou/request/step/IOURequestStepParticipants.tsx +++ b/src/pages/iou/request/step/IOURequestStepParticipants.tsx @@ -91,7 +91,7 @@ function IOURequestStepParticipants({ (val: Participant[]) => { HttpUtils.cancelPendingRequests(READ_COMMANDS.SEARCH_FOR_REPORTS); - const firstParticipantReportID = val.at(0)?.reportID ?? ''; + const firstParticipantReportID = val.at(0)?.reportID; const rateID = DistanceRequestUtils.getCustomUnitRateID(firstParticipantReportID); const isInvoice = iouType === CONST.IOU.TYPE.INVOICE && ReportUtils.isInvoiceRoomWithID(firstParticipantReportID); numberOfParticipants.current = val.length; @@ -107,7 +107,7 @@ function IOURequestStepParticipants({ } // When a participant is selected, the reportID needs to be saved because that's the reportID that will be used in the confirmation step. - selectedReportID.current = firstParticipantReportID || reportID; + selectedReportID.current = firstParticipantReportID ?? reportID; }, [iouType, reportID, transactionID], ); From 6c69904fdab5ac4374ba3d9e63cfe30ca38b78a8 Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Fri, 3 Jan 2025 14:50:02 +0100 Subject: [PATCH 22/22] fix: prettier --- src/libs/actions/IOU.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 3f82d74c28ca..ebe95ae2ee78 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -45,8 +45,8 @@ import Navigation from '@libs/Navigation/Navigation'; import * as NextStepUtils from '@libs/NextStepUtils'; import {rand64} from '@libs/NumberUtils'; import * as OptionsListUtils from '@libs/OptionsListUtils'; -import Performance from '@libs/Performance'; import * as PerDiemRequestUtils from '@libs/PerDiemRequestUtils'; +import Performance from '@libs/Performance'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as PhoneNumber from '@libs/PhoneNumber'; import * as PolicyUtils from '@libs/PolicyUtils';