diff --git a/src/hooks/useBeforeRemove.tsx b/src/hooks/useBeforeRemove.tsx index 908bb659960c..835d5a30babe 100644 --- a/src/hooks/useBeforeRemove.tsx +++ b/src/hooks/useBeforeRemove.tsx @@ -3,16 +3,13 @@ import type {EventListenerCallback, EventMapCore, NavigationState} from '@react- import {useEffect} from 'react'; // beforeRemove have some limitations. When the react-navigation is upgraded to 7.x, update this to use usePreventRemove hook. -const useBeforeRemove = (onBeforeRemove: EventListenerCallback, 'beforeRemove'>, isEnabled = true) => { +const useBeforeRemove = (onBeforeRemove: EventListenerCallback, 'beforeRemove'>) => { const navigation = useNavigation(); useEffect(() => { - if (!isEnabled) { - return undefined; - } const unsubscribe = navigation.addListener('beforeRemove', onBeforeRemove); return unsubscribe; - }, [navigation, onBeforeRemove, isEnabled]); + }, [navigation, onBeforeRemove]); }; export default useBeforeRemove; diff --git a/src/hooks/useDiscardChangesConfirmation/index.native.ts b/src/hooks/useDiscardChangesConfirmation/index.native.ts index 4a14cb0b66a7..f44dd33c6231 100644 --- a/src/hooks/useDiscardChangesConfirmation/index.native.ts +++ b/src/hooks/useDiscardChangesConfirmation/index.native.ts @@ -1,20 +1,20 @@ import type {NavigationAction} from '@react-navigation/native'; -import {useIsFocused, usePreventRemove} from '@react-navigation/native'; +import {usePreventRemove} from '@react-navigation/native'; import {useCallback, useRef, useState} from 'react'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import useConfirmModal from '@hooks/useConfirmModal'; import useLocalize from '@hooks/useLocalize'; +import Log from '@libs/Log'; import navigationRef from '@libs/Navigation/navigationRef'; import type UseDiscardChangesConfirmationOptions from './types'; -function useDiscardChangesConfirmation({getHasUnsavedChanges, onVisibilityChange, isEnabled = true}: UseDiscardChangesConfirmationOptions) { +function useDiscardChangesConfirmation({getHasUnsavedChanges, onCancel, onVisibilityChange, onConfirm}: UseDiscardChangesConfirmationOptions) { const {translate} = useLocalize(); - const isFocused = useIsFocused(); const {showConfirmModal} = useConfirmModal(); const [shouldAllowNavigation, setShouldAllowNavigation] = useState(false); const blockedNavigationAction = useRef(undefined); - const shouldPrevent = isEnabled && isFocused && !shouldAllowNavigation; + const shouldPrevent = !shouldAllowNavigation; usePreventRemove( shouldPrevent, @@ -36,18 +36,28 @@ function useDiscardChangesConfirmation({getHasUnsavedChanges, onVisibilityChange }).then((result) => { onVisibilityChange?.(false); if (result.action !== ModalActions.CONFIRM) { + onCancel?.(); return; } - setShouldAllowNavigation(true); - if (blockedNavigationAction.current) { - navigationRef.current?.dispatch(blockedNavigationAction.current); - blockedNavigationAction.current = undefined; - } else { - navigationRef.current?.goBack(); - } + const confirmNavigation = () => { + setShouldAllowNavigation(true); + if (blockedNavigationAction.current) { + navigationRef.current?.dispatch(blockedNavigationAction.current); + blockedNavigationAction.current = undefined; + } else { + navigationRef.current?.goBack(); + } + }; + Promise.resolve() + .then(() => onConfirm?.()) + .then(confirmNavigation) + .catch((error: unknown) => { + Log.warn('[useDiscardChangesConfirmation] Failed to run onConfirm callback', {error}); + blockedNavigationAction.current = undefined; + }); }); }, - [getHasUnsavedChanges, onVisibilityChange, showConfirmModal, translate], + [getHasUnsavedChanges, onCancel, onVisibilityChange, onConfirm, showConfirmModal, translate], ), ); } diff --git a/src/hooks/useDiscardChangesConfirmation/index.ts b/src/hooks/useDiscardChangesConfirmation/index.ts index 5f0f7b035506..4ec1f161722a 100644 --- a/src/hooks/useDiscardChangesConfirmation/index.ts +++ b/src/hooks/useDiscardChangesConfirmation/index.ts @@ -1,10 +1,11 @@ import type {NavigationAction} from '@react-navigation/native'; -import {useIsFocused, useNavigation} from '@react-navigation/native'; +import {useNavigation} from '@react-navigation/native'; import {useCallback, useEffect, useRef} from 'react'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import useBeforeRemove from '@hooks/useBeforeRemove'; import useConfirmModal from '@hooks/useConfirmModal'; import useLocalize from '@hooks/useLocalize'; +import Log from '@libs/Log'; import setNavigationActionToMicrotaskQueue from '@libs/Navigation/helpers/setNavigationActionToMicrotaskQueue'; import navigateAfterInteraction from '@libs/Navigation/navigateAfterInteraction'; import navigationRef from '@libs/Navigation/navigationRef'; @@ -12,14 +13,12 @@ import type {PlatformStackNavigationProp} from '@libs/Navigation/PlatformStackNa import type {RootNavigatorParamList} from '@libs/Navigation/types'; import type UseDiscardChangesConfirmationOptions from './types'; -function useDiscardChangesConfirmation({getHasUnsavedChanges, onCancel, onVisibilityChange, isEnabled = true}: UseDiscardChangesConfirmationOptions) { +function useDiscardChangesConfirmation({getHasUnsavedChanges, onCancel, onVisibilityChange, onConfirm}: UseDiscardChangesConfirmationOptions) { const navigation = useNavigation>(); - const isFocused = useIsFocused(); const {translate} = useLocalize(); - const {showConfirmModal, closeModal} = useConfirmModal(); + const {showConfirmModal} = useConfirmModal(); const blockedNavigationAction = useRef(undefined); const shouldNavigateBack = useRef(false); - const isDiscardModalOpenRef = useRef(false); const navigateBack = useCallback(() => { if (blockedNavigationAction.current) { @@ -34,7 +33,6 @@ function useDiscardChangesConfirmation({getHasUnsavedChanges, onCancel, onVisibi const showDiscardModal = useCallback(() => { onVisibilityChange?.(true); - isDiscardModalOpenRef.current = true; showConfirmModal({ title: translate('discardChangesConfirmation.title'), prompt: translate('discardChangesConfirmation.body'), @@ -43,33 +41,34 @@ function useDiscardChangesConfirmation({getHasUnsavedChanges, onCancel, onVisibi cancelText: translate('common.cancel'), shouldIgnoreBackHandlerDuringTransition: true, }).then((result) => { - isDiscardModalOpenRef.current = false; onVisibilityChange?.(false); if (result.action === ModalActions.CONFIRM) { - setNavigationActionToMicrotaskQueue(navigateBack); + Promise.resolve() + .then(() => onConfirm?.()) + .then(() => { + setNavigationActionToMicrotaskQueue(navigateBack); + }) + .catch((error: unknown) => { + Log.warn('[useDiscardChangesConfirmation] Failed to run onConfirm callback', {error}); + blockedNavigationAction.current = undefined; + shouldNavigateBack.current = false; + }); } else { blockedNavigationAction.current = undefined; shouldNavigateBack.current = false; onCancel?.(); } }); - }, [showConfirmModal, translate, navigateBack, onCancel, onVisibilityChange]); + }, [showConfirmModal, translate, navigateBack, onCancel, onConfirm, onVisibilityChange]); - useBeforeRemove( - useCallback( - (e) => { - if (!isEnabled || !isFocused || !getHasUnsavedChanges() || shouldNavigateBack.current) { - return; - } - - e.preventDefault(); - blockedNavigationAction.current = e.data.action; - navigateAfterInteraction(showDiscardModal); - }, - [getHasUnsavedChanges, isFocused, isEnabled, showDiscardModal], - ), - isEnabled && isFocused, - ); + useBeforeRemove((e) => { + if (!getHasUnsavedChanges() || shouldNavigateBack.current) { + return; + } + e.preventDefault(); + blockedNavigationAction.current = e.data.action; + navigateAfterInteraction(showDiscardModal); + }); /** * We cannot programmatically stop the browser's back navigation like react-navigation's beforeRemove. @@ -77,9 +76,6 @@ function useDiscardChangesConfirmation({getHasUnsavedChanges, onCancel, onVisibi * So we need to go forward to get back to the current page. */ useEffect(() => { - if (!isEnabled || !isFocused) { - return undefined; - } const unsubscribe = navigation.addListener('transitionStart', ({data: {closing}}) => { // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing if (!getHasUnsavedChanges()) { @@ -95,20 +91,7 @@ function useDiscardChangesConfirmation({getHasUnsavedChanges, onCancel, onVisibi }); return unsubscribe; - }, [navigation, getHasUnsavedChanges, isFocused, isEnabled, showDiscardModal]); - - /** - * When the screen loses focus (or is disabled) while the discard modal is open, - * close the modal and reset refs so we don't leave the modal visible or stale state. - */ - useEffect(() => { - if ((isFocused && isEnabled) || !isDiscardModalOpenRef.current) { - return; - } - closeModal(); - blockedNavigationAction.current = undefined; - shouldNavigateBack.current = false; - }, [isFocused, isEnabled, closeModal]); + }, [navigation, getHasUnsavedChanges, showDiscardModal]); } export default useDiscardChangesConfirmation; diff --git a/src/hooks/useDiscardChangesConfirmation/types.ts b/src/hooks/useDiscardChangesConfirmation/types.ts index 6ce984e471f4..abfcbc6b76fd 100644 --- a/src/hooks/useDiscardChangesConfirmation/types.ts +++ b/src/hooks/useDiscardChangesConfirmation/types.ts @@ -2,7 +2,7 @@ type UseDiscardChangesConfirmationOptions = { getHasUnsavedChanges: () => boolean; onCancel?: () => void; onVisibilityChange?: (visible: boolean) => void; - isEnabled?: boolean; + onConfirm?: () => void | Promise; }; export default UseDiscardChangesConfirmationOptions; diff --git a/src/libs/actions/TransactionEdit.ts b/src/libs/actions/TransactionEdit.ts index bd24c03731e7..cde3c6107bdd 100644 --- a/src/libs/actions/TransactionEdit.ts +++ b/src/libs/actions/TransactionEdit.ts @@ -54,12 +54,12 @@ function createBackupTransaction(transaction: OnyxEntry, isDraft: b /** * Removes a transaction from Onyx that was only used temporary in the edit flow */ -function removeBackupTransaction(transactionID: string | undefined) { +function removeBackupTransaction(transactionID: string | undefined): Promise { if (!transactionID) { - return; + return Promise.resolve(); } - Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_BACKUP}${transactionID}`, null); + return Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_BACKUP}${transactionID}`, null); } function restoreOriginalTransactionFromBackup(transactionID: string | undefined, isDraft: boolean) { @@ -187,7 +187,7 @@ function buildOptimisticTransactionAndCreateDraft({initialTransaction, currentUs return newTransaction; } -function removeBackupTransactionWithImageCleanup(transactionID: string | undefined, isDraft: boolean, onComplete?: () => void) { +function removeBackupTransactionWithImageCleanup(transactionID: string | undefined, isDraft: boolean) { if (!transactionID) { return; } @@ -202,33 +202,36 @@ function removeBackupTransactionWithImageCleanup(transactionID: string | undefin revokeOdometerImageUri(backupTransaction?.comment?.odometerStartImage, currentTransaction?.comment?.odometerStartImage); revokeOdometerImageUri(backupTransaction?.comment?.odometerEndImage, currentTransaction?.comment?.odometerEndImage); removeBackupTransaction(transactionID); - onComplete?.(); }, }); }, }); } -function restoreOriginalTransactionFromBackupWithImageCleanup(transactionID: string | undefined, isDraft: boolean, onComplete?: () => void) { +function restoreOriginalTransactionFromBackupWithImageCleanup(transactionID: string | undefined, isDraft: boolean): Promise { if (!transactionID) { - return; + return Promise.resolve(); } - connection = Onyx.connectWithoutView({ - key: `${ONYXKEYS.COLLECTION.TRANSACTION_BACKUP}${transactionID}`, - callback: (backupTransaction) => { - Onyx.disconnect(connection); - const currentConn = Onyx.connectWithoutView({ - key: `${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, - callback: (currentTransaction) => { - Onyx.disconnect(currentConn); - revokeOdometerImageUri(currentTransaction?.comment?.odometerStartImage, backupTransaction?.comment?.odometerStartImage); - revokeOdometerImageUri(currentTransaction?.comment?.odometerEndImage, backupTransaction?.comment?.odometerEndImage); - Onyx.set(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, backupTransaction ?? null); - removeBackupTransaction(transactionID); - onComplete?.(); - }, - }); - }, + + return new Promise((resolve, reject) => { + connection = Onyx.connectWithoutView({ + key: `${ONYXKEYS.COLLECTION.TRANSACTION_BACKUP}${transactionID}`, + callback: (backupTransaction) => { + Onyx.disconnect(connection); + const currentConn = Onyx.connectWithoutView({ + key: `${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + callback: (currentTransaction) => { + Onyx.disconnect(currentConn); + revokeOdometerImageUri(currentTransaction?.comment?.odometerStartImage, backupTransaction?.comment?.odometerStartImage); + revokeOdometerImageUri(currentTransaction?.comment?.odometerEndImage, backupTransaction?.comment?.odometerEndImage); + Onyx.set(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, backupTransaction ?? null) + .then(() => removeBackupTransaction(transactionID)) + .then(() => resolve()) + .catch((error) => reject(error)); + }, + }); + }, + }); }); } diff --git a/src/pages/MultifactorAuthentication/AuthorizeTransactionPage/index.tsx b/src/pages/MultifactorAuthentication/AuthorizeTransactionPage/index.tsx index c877b622ebef..a6ec0b900848 100644 --- a/src/pages/MultifactorAuthentication/AuthorizeTransactionPage/index.tsx +++ b/src/pages/MultifactorAuthentication/AuthorizeTransactionPage/index.tsx @@ -76,18 +76,15 @@ function MultifactorAuthenticationScenarioAuthorizeTransactionPage({route}: Mult setConfirmModalVisibility(false); }; - const onBeforeRemove: Parameters[0] = useCallback( - (e) => { - if (allowNavigatingAwayRef.current) { - return; - } - e.preventDefault(); - showConfirmModal(); - }, - [showConfirmModal], - ); + const onBeforeRemove: Parameters[0] = (e) => { + if (allowNavigatingAwayRef.current || !transaction || !!denyOutcomeScreen) { + return; + } + e.preventDefault(); + showConfirmModal(); + }; - useBeforeRemove(onBeforeRemove, !!transaction && !denyOutcomeScreen); + useBeforeRemove(onBeforeRemove); const onApproveTransaction = () => { addBreadcrumb('Approve tapped', {transactionID}); diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index ec2e9a46ec65..9cbbd784e63f 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -1,6 +1,7 @@ +import {useIsFocused} from '@react-navigation/native'; import lodashIsEmpty from 'lodash/isEmpty'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import {View} from 'react-native'; +import {InteractionManager, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import Button from '@components/Button'; import FormHelpMessage from '@components/FormHelpMessage'; @@ -79,12 +80,14 @@ function IOURequestStepDistanceOdometer({ const startReadingInputRef = useRef(null); const endReadingInputRef = useRef(null); + const lastFocusedInputRef = useRef(null); const [startReading, setStartReading] = useState(''); const [endReading, setEndReading] = useState(''); const [formError, setFormError] = useState(''); // Key to force TextInput remount when resetting state after tab switch const [inputKey, setInputKey] = useState(0); + const [isDiscardModalVisible, setIsDiscardModalVisible] = useState(false); // Track initial values for DiscardChangesConfirmation const initialStartReadingRef = useRef(''); @@ -96,7 +99,8 @@ function IOURequestStepDistanceOdometer({ const initialStartImageRef = useRef(undefined); const initialEndImageRef = useRef(undefined); const prevSelectedTabRef = useRef(undefined); - const transactionWasSaved = useRef(false); + const didSaveEditingConfirmationRef = useRef(false); + const shouldBypassDiscardConfirmationRef = useRef(false); const backupHandledManually = useRef(false); const isArchived = useReportIsArchived(report?.reportID); @@ -137,7 +141,7 @@ function IOURequestStepDistanceOdometer({ const isTransactionDraft = shouldUseTransactionDraft(action, iouType); const currentUserAccountIDParam = currentUserPersonalDetails.accountID; const currentUserEmailParam = currentUserPersonalDetails.login ?? ''; - const [shouldEnableDiscardConfirmation, setShouldEnableDiscardConfirmation] = useState(!isEditingConfirmation && !isEditing); + const isFocused = useIsFocused(); const shouldUseDefaultExpensePolicy = useMemo( () => shouldUseDefaultExpensePolicyUtil(iouType, defaultExpensePolicy, amountOwed, userBillingGracePeriodEnds, ownerBillingGracePeriodEnd), @@ -152,10 +156,6 @@ function IOURequestStepDistanceOdometer({ const confirmationRoute = ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(action, iouType, transactionID, reportID, backToReport); - useEffect(() => { - setShouldEnableDiscardConfirmation(!isEditingConfirmation && !isEditing); - }, [isEditing, isEditingConfirmation]); - // Get odometer images from transaction (only for display, not for initialization) const odometerStartImage = transaction?.comment?.odometerStartImage; const odometerEndImage = transaction?.comment?.odometerEndImage; @@ -246,7 +246,7 @@ function IOURequestStepDistanceOdometer({ if (backupHandledManually.current) { return; } - if (transactionWasSaved.current) { + if (didSaveEditingConfirmationRef.current) { removeBackupTransactionWithImageCleanup(transactionID, isTransactionDraft); return; } @@ -373,14 +373,11 @@ function IOURequestStepDistanceOdometer({ const navigateBack = useCallback(() => { if (isEditingConfirmation) { - backupHandledManually.current = true; - restoreOriginalTransactionFromBackupWithImageCleanup(transactionID, isTransactionDraft, () => { - Navigation.goBack(confirmationRoute); - }); + Navigation.goBack(confirmationRoute); return; } Navigation.goBack(); - }, [isEditingConfirmation, confirmationRoute, transactionID, isTransactionDraft]); + }, [confirmationRoute, isEditingConfirmation]); const handlePressStartImage = useCallback(() => { if (odometerStartImage) { @@ -458,13 +455,14 @@ function IOURequestStepDistanceOdometer({ } if (isEditingConfirmation) { - transactionWasSaved.current = true; + didSaveEditingConfirmationRef.current = true; Navigation.goBack(confirmationRoute); return; } if (shouldSkipConfirmation) { - setShouldEnableDiscardConfirmation(false); + // Skip-confirmation submit navigates away and should never be blocked by discard modal. + shouldBypassDiscardConfirmationRef.current = true; } startSpan(CONST.TELEMETRY.SPAN_ODOMETER_TO_CONFIRMATION, { @@ -557,12 +555,27 @@ function IOURequestStepDistanceOdometer({ }; useDiscardChangesConfirmation({ - isEnabled: shouldEnableDiscardConfirmation, + onCancel: () => { + // eslint-disable-next-line @typescript-eslint/no-deprecated + InteractionManager.runAfterInteractions(() => { + lastFocusedInputRef.current?.focus(); + }); + }, getHasUnsavedChanges: () => { + if (!isFocused || isEditing || shouldBypassDiscardConfirmationRef.current || didSaveEditingConfirmationRef.current) { + return false; + } const hasReadingChanges = startReadingRef.current !== initialStartReadingRef.current || endReadingRef.current !== initialEndReadingRef.current; const hasImageChanges = transaction?.comment?.odometerStartImage !== initialStartImageRef.current || transaction?.comment?.odometerEndImage !== initialEndImageRef.current; return hasReadingChanges || hasImageChanges; }, + onConfirm: isEditingConfirmation + ? async () => { + await restoreOriginalTransactionFromBackupWithImageCleanup(transactionID, isTransactionDraft); + backupHandledManually.current = true; + } + : undefined, + onVisibilityChange: setIsDiscardModalVisible, }); return ( @@ -588,6 +601,10 @@ function IOURequestStepDistanceOdometer({ onChangeText={handleStartReadingChange} keyboardType={CONST.KEYBOARD_TYPE.DECIMAL_PAD} inputMode={CONST.INPUT_MODE.DECIMAL} + editable={!isDiscardModalVisible} + onFocus={() => { + lastFocusedInputRef.current = startReadingInputRef.current; + }} /> {!isEditing && ( @@ -630,6 +647,10 @@ function IOURequestStepDistanceOdometer({ onChangeText={handleEndReadingChange} keyboardType={CONST.KEYBOARD_TYPE.DECIMAL_PAD} inputMode={CONST.INPUT_MODE.DECIMAL} + editable={!isDiscardModalVisible} + onFocus={() => { + lastFocusedInputRef.current = endReadingInputRef.current; + }} /> {!isEditing && (