diff --git a/src/hooks/useDiscardChangesConfirmation/index.native.ts b/src/hooks/useDiscardChangesConfirmation/index.native.ts index 9c973947fb49..d752872e42ef 100644 --- a/src/hooks/useDiscardChangesConfirmation/index.native.ts +++ b/src/hooks/useDiscardChangesConfirmation/index.native.ts @@ -3,7 +3,6 @@ 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 {useRegisterTabSwitchGuard} from '@libs/Navigation/TabSwitchGuardContext'; @@ -17,6 +16,7 @@ import type {DiscardChangesConfirmation} from './types'; import type UseDiscardChangesConfirmationOptions from './types'; import getDiscardChangesModalConfig from './getDiscardChangesModalConfig'; +import runDiscardConfirmation from './runDiscardConfirmation'; function useDiscardChangesConfirmation({ getHasUnsavedChanges, @@ -40,9 +40,7 @@ function useDiscardChangesConfirmation({ }); const hasUnsavedChanges = () => isFocused && !isSavingRef.current && getHasUnsavedChanges(); - // Also guard tab switches when this screen is an OnyxTabNavigator tab. - // Self-disables outside a tab navigator or without an onTabSwitchDiscard handler - useRegisterTabSwitchGuard(route.name, getHasUnsavedChanges, onTabSwitchDiscard, onCancel); + useRegisterTabSwitchGuard(route.name, hasUnsavedChanges, onTabSwitchDiscard, onCancel); const showDiscardModal = (blockedAction?: NavigationAction) => { blockedNavigationAction.current = blockedAction; @@ -66,13 +64,9 @@ function useDiscardChangesConfirmation({ } isReplayingBlockedNavigation.current = false; }; - Promise.resolve() - .then(() => onConfirm?.()) - .then(confirmNavigation) - .catch((error: unknown) => { - Log.warn('[useDiscardChangesConfirmation] Failed to run onConfirm callback', {error}); - blockedNavigationAction.current = undefined; - }); + runDiscardConfirmation(onConfirm, confirmNavigation, () => { + blockedNavigationAction.current = undefined; + }); }); }; @@ -104,11 +98,11 @@ function useDiscardChangesConfirmation({ return () => subscription.remove(); }); - const notifySaving = (isSaving = true) => { - isSavingRef.current = isSaving; + const suppressDiscardPrompt = (shouldSuppress = true) => { + isSavingRef.current = shouldSuppress; }; - return {notifySaving}; + return {suppressDiscardPrompt}; } export default useDiscardChangesConfirmation; diff --git a/src/hooks/useDiscardChangesConfirmation/index.ts b/src/hooks/useDiscardChangesConfirmation/index.ts index 1a9f3bebaacd..eb5409f63715 100644 --- a/src/hooks/useDiscardChangesConfirmation/index.ts +++ b/src/hooks/useDiscardChangesConfirmation/index.ts @@ -4,7 +4,6 @@ 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 navigationRef from '@libs/Navigation/navigationRef'; import {useRegisterTabSwitchGuard} from '@libs/Navigation/TabSwitchGuardContext'; @@ -18,6 +17,14 @@ import type {DiscardChangesConfirmation} from './types'; import type UseDiscardChangesConfirmationOptions from './types'; import getDiscardChangesModalConfig from './getDiscardChangesModalConfig'; +import runDiscardConfirmation from './runDiscardConfirmation'; + +/** + * Tracks the `history.go(1)` restore round-trip so its echo popstate isn't mistaken for a fresh back: `awaitingRestore` + * = a prevented reset awaiting its popstate; `restoring` = the `go(1)` in flight, awaiting its echo; `dismissModalOnRestore` + * = the back happened over the open prompt. + */ +type RestoreState = {phase: 'idle'} | {phase: 'awaitingRestore'; dismissModalOnRestore: boolean} | {phase: 'restoring'}; function useDiscardChangesConfirmation({ getHasUnsavedChanges, @@ -30,16 +37,6 @@ function useDiscardChangesConfirmation({ const {translate} = useLocalize(); const {showConfirmModal, closeModal} = useConfirmModal(); - // Also guard tab switches when this screen is an OnyxTabNavigator tab. - // Self-disables outside a tab navigator or without an onTabSwitchDiscard handler - useRegisterTabSwitchGuard(route.name, getHasUnsavedChanges, onTabSwitchDiscard, onCancel); - const blockedNavigationAction = useRef(undefined); - const shouldNavigateBack = useRef(false); - const isDiscardModalOpen = useRef(false); - const isRestoringHistory = useRef(false); - const didPreventResetOnPopstate = useRef(false); - const shouldDismissModalOnRestore = useRef(false); - // Only the focused screen should prompt — a flow-leave reset fires `beforeRemove` for hidden siblings too. const isFocused = useIsFocused(); const isSavingRef = useRef(false); @@ -48,6 +45,13 @@ function useDiscardChangesConfirmation({ }); const hasUnsavedChanges = () => isFocused && !isSavingRef.current && getHasUnsavedChanges(); + useRegisterTabSwitchGuard(route.name, hasUnsavedChanges, onTabSwitchDiscard, onCancel); + + const blockedNavigationAction = useRef(undefined); + const shouldNavigateBack = useRef(false); + const isDiscardModalOpen = useRef(false); + const restoreState = useRef({phase: 'idle'}); + const navigateBack = () => { if (!blockedNavigationAction.current) { return; @@ -66,30 +70,34 @@ function useDiscardChangesConfirmation({ shouldHandleNavigationBack: false, }).then((result) => { isDiscardModalOpen.current = false; - didPreventResetOnPopstate.current = false; - shouldDismissModalOnRestore.current = false; + // The awaiting-restore reservation is only meaningful until the prompt resolves; an in-flight `restoring` must survive it. + if (restoreState.current.phase === 'awaitingRestore') { + restoreState.current = {phase: 'idle'}; + } onVisibilityChange?.(false); - if (result.action === ModalActions.CONFIRM) { - 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 { + if (result.action !== ModalActions.CONFIRM) { blockedNavigationAction.current = undefined; shouldNavigateBack.current = false; onCancel?.(); + return; } + runDiscardConfirmation( + onConfirm, + () => setNavigationActionToMicrotaskQueue(navigateBack), + () => { + blockedNavigationAction.current = undefined; + shouldNavigateBack.current = false; + }, + ); }); }; useBeforeRemove((e) => { - if (isRestoringHistory.current) { + if (shouldNavigateBack.current) { + return; + } + + if (restoreState.current.phase === 'restoring') { // The `history.go(1)` restoring the browser entry can re-deliver a reset for the current state; swallow it without re-blocking e.preventDefault(); return; @@ -102,23 +110,24 @@ function useDiscardChangesConfirmation({ if (isDiscardModalOpen.current) { e.preventDefault(); if (e.data.action.type === 'RESET') { - didPreventResetOnPopstate.current = true; - shouldDismissModalOnRestore.current = true; + restoreState.current = { + phase: 'awaitingRestore', + dismissModalOnRestore: true, + }; return; } closeModal(); return; } - if (shouldNavigateBack.current) { - return; - } - e.preventDefault(); blockedNavigationAction.current = e.data.action; if (e.data.action.type === 'RESET') { // A prevented RESET comes from a browser back; the popstate listener must restore the URL - didPreventResetOnPopstate.current = true; + restoreState.current = { + phase: 'awaitingRestore', + dismissModalOnRestore: false, + }; } showDiscardModal(); }); @@ -134,18 +143,16 @@ function useDiscardChangesConfirmation({ * already moved — this listener restores it with `history.go(1)`, and dismisses the prompt as Cancel when the back happened over it. */ useEffect(() => { - // Register once: the listener reads the latest `closeModal` through `closeModalRef`, so it never needs to re-subscribe const handlePopState = () => { - if (isRestoringHistory.current) { - isRestoringHistory.current = false; + const restore = restoreState.current; + if (restore.phase === 'restoring') { + restoreState.current = {phase: 'idle'}; return; } - if (didPreventResetOnPopstate.current) { - didPreventResetOnPopstate.current = false; - isRestoringHistory.current = true; + if (restore.phase === 'awaitingRestore') { + restoreState.current = {phase: 'restoring'}; window.history.go(1); - if (shouldDismissModalOnRestore.current) { - shouldDismissModalOnRestore.current = false; + if (restore.dismissModalOnRestore) { closeModalRef.current(); } } @@ -155,11 +162,11 @@ function useDiscardChangesConfirmation({ return () => window.removeEventListener('popstate', handlePopState); }, []); - const notifySaving = (isSaving = true) => { - isSavingRef.current = isSaving; + const suppressDiscardPrompt = (shouldSuppress = true) => { + isSavingRef.current = shouldSuppress; }; - return {notifySaving}; + return {suppressDiscardPrompt}; } export default useDiscardChangesConfirmation; diff --git a/src/hooks/useDiscardChangesConfirmation/runDiscardConfirmation.ts b/src/hooks/useDiscardChangesConfirmation/runDiscardConfirmation.ts new file mode 100644 index 000000000000..5a9ef86facfa --- /dev/null +++ b/src/hooks/useDiscardChangesConfirmation/runDiscardConfirmation.ts @@ -0,0 +1,17 @@ +import Log from '@libs/Log'; + +/** + * Await `onConfirm`, then replay the blocked navigation — but if `onConfirm` rejects, log and run `onError` WITHOUT + * navigating, so a failed discard leaves the user in place rather than proceeding silently. + */ +function runDiscardConfirmation(onConfirm: (() => void | Promise) | undefined, navigate: () => void, onError: () => void): void { + Promise.resolve() + .then(() => onConfirm?.()) + .then(navigate) + .catch((error: unknown) => { + Log.warn('[useDiscardChangesConfirmation] Failed to run onConfirm callback', {error}); + onError(); + }); +} + +export default runDiscardConfirmation; diff --git a/src/hooks/useDiscardChangesConfirmation/types.ts b/src/hooks/useDiscardChangesConfirmation/types.ts index 5c35f489c4e4..fa42ee5b0d13 100644 --- a/src/hooks/useDiscardChangesConfirmation/types.ts +++ b/src/hooks/useDiscardChangesConfirmation/types.ts @@ -4,17 +4,12 @@ type UseDiscardChangesConfirmationOptions = { onCancel?: () => void; onVisibilityChange?: (visible: boolean) => void; onConfirm?: () => void | Promise; - - /** - * Discard action for confirming a tab switch. Provide it to guard tab switches inside an `OnyxTabNavigator`. - * Can differ from `onConfirm` (nav-away) - */ onTabSwitchDiscard?: () => void | Promise; }; type DiscardChangesConfirmation = { - /** Suppress the discard prompt while an intentional save navigates away. Pass `false` to clear it if the save aborts without navigating. */ - notifySaving: (isSaving?: boolean) => void; + /** Suppress the discard prompt during an intentional navigation (a save, or a redirect such as the billing restriction). Pass `false` to clear it if that navigation aborts without leaving. */ + suppressDiscardPrompt: (shouldSuppress?: boolean) => void; }; export default UseDiscardChangesConfirmationOptions; diff --git a/src/libs/MoneyRequestUtils.ts b/src/libs/MoneyRequestUtils.ts index ef4a20d7e5a4..32a2a629b03a 100644 --- a/src/libs/MoneyRequestUtils.ts +++ b/src/libs/MoneyRequestUtils.ts @@ -1,13 +1,14 @@ import CONST from '@src/CONST'; import type {Report, Transaction} from '@src/types/onyx'; +import type {WaypointCollection} from '@src/types/onyx/Transaction'; import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; -import {convertToFrontendAmountAsInteger} from './CurrencyUtils'; +import {convertToBackendAmount, convertToFrontendAmountAsInteger} from './CurrencyUtils'; import {isInvoiceReport, isIOUReport} from './ReportUtils'; import StringUtils from './StringUtils'; -import {isExpenseUnreported} from './TransactionUtils'; +import {doesMoneyRequestDraftHaveUserInput, haveWaypointAddressesChanged, isExpenseUnreported} from './TransactionUtils'; import {isInvalidMerchantValue} from './ValidationUtils'; /** @@ -208,6 +209,53 @@ function isValidMerchant(merchant: string | undefined, transaction?: OnyxEntry, + committedWaypoints: WaypointCollection | undefined, + currentWaypoints: WaypointCollection | undefined, + isCreateEntry: boolean, +): boolean { + if (isCreateEntry) { + return doesMoneyRequestDraftHaveUserInput(transaction); + } + // No committed baseline yet (splits skip the backup; a normal edit's async backup may not have landed) — treat as unchanged. + if (!committedWaypoints) { + return false; + } + return haveWaypointAddressesChanged(committedWaypoints, currentWaypoints); +} + /** * Determines whether the date field should be shown on the money request confirmation surface. * This is the single source of truth shared by the confirmation footer (where the date field is rendered) @@ -231,4 +279,7 @@ export { isValidMoneyRequestAmount, isTaxAmountInvalid, isValidMerchant, + getAmountHasUnsavedChanges, + getStringFieldHasUnsavedChanges, + getWaypointsHasUnsavedChanges, }; diff --git a/src/libs/Navigation/OnyxTabNavigatorConfig/index.ts b/src/libs/Navigation/OnyxTabNavigatorConfig/index.ts index 99f1ecded851..a57a7434a723 100644 --- a/src/libs/Navigation/OnyxTabNavigatorConfig/index.ts +++ b/src/libs/Navigation/OnyxTabNavigatorConfig/index.ts @@ -4,7 +4,11 @@ const defaultScreenOptions = { animation: 'default', } as const; -/** On native there is no browser history; hardware back returns to the initial tab first, per platform convention. */ -const backBehavior: NonNullable = 'initialRoute'; +/** + * `none` keeps the tab history at a single entry, so back — hardware or header — leaves the whole flow instead of + * returning to the initial tab first. Every OnyxTabNavigator is an RHP/modal flow where back should dismiss it, so + * this matches web and the iOS swipe gesture. + */ +const backBehavior: NonNullable = 'none'; export {defaultScreenOptions, backBehavior}; diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 221843b45246..c3aa63c1c2a2 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -1259,17 +1259,14 @@ function hasDisplayableMCC(mcc: number | string | null | undefined): boolean { return getMCCForDisplay(mcc) !== ''; } -/** - * Return the waypoints field from the transaction, return the modifiedWaypoints if present. - */ -/** - * Whether a draft holds tab-entered input that is lost when the flow is abandoned (drafts are not restored on the next open). - * Forward-navigation fields (amount, receipt, ...) are deliberately excluded; extend per-field as new tabs persist input to the draft. - */ +/** Whether the draft holds tab-entered input (waypoints) that is lost when the flow is abandoned. */ function doesMoneyRequestDraftHaveUserInput(transaction: OnyxEntry): boolean { return Object.keys(getValidWaypoints(getWaypoints(transaction))).length > 0; } +/** + * Return the waypoints field from the transaction, return the modifiedWaypoints if present. + */ function getWaypoints(transaction: OnyxEntry): WaypointCollection | undefined { return transaction?.modifiedWaypoints ?? transaction?.comment?.waypoints; } diff --git a/src/pages/iou/request/step/IOURequestStepAmount.tsx b/src/pages/iou/request/step/IOURequestStepAmount.tsx index 72b1839e1576..9cb63819fef7 100644 --- a/src/pages/iou/request/step/IOURequestStepAmount.tsx +++ b/src/pages/iou/request/step/IOURequestStepAmount.tsx @@ -14,10 +14,10 @@ import useReportOrReportDraft from '@hooks/useReportOrReportDraft'; import useShowNotFoundPageInIOUStep from '@hooks/useShowNotFoundPageInIOUStep'; import useSkipConfirmationPreInsert from '@hooks/useSkipConfirmationPreInsert'; -import {convertToBackendAmount} from '@libs/CurrencyUtils'; import {getIsP2PForAmount, submitAmount} from '@libs/IOUAmountSubmission'; import {isMovingTransactionFromTrackExpense} from '@libs/IOUUtils'; import Log from '@libs/Log'; +import {getAmountHasUnsavedChanges} from '@libs/MoneyRequestUtils'; import Navigation from '@libs/Navigation/Navigation'; import {getParticipantsOption, getReportOption} from '@libs/OptionsListUtils'; import {getTransactionDetails, isMoneyRequestReport, isPolicyExpenseChat, shouldEnableNegative} from '@libs/ReportUtils'; @@ -123,12 +123,16 @@ function IOURequestStepAmount({ const [selectedCurrency, setSelectedCurrency] = useState(originalCurrency); const decimals = getCurrencyDecimals(selectedCurrency || CONST.CURRENCY.USD); - const {notifySaving} = useDiscardChangesConfirmation({ - getHasUnsavedChanges: () => { - const typedAmount = amountFormRef.current?.getNumber() ?? ''; - const typedAmountInBackendUnits = typedAmount ? convertToBackendAmount(Number.parseFloat(typedAmount)) : 0; - return typedAmountInBackendUnits !== transactionAmount || selectedCurrency !== originalCurrency; - }, + const isAmountCreateEntry = !backTo && !isEditing; + const {suppressDiscardPrompt} = useDiscardChangesConfirmation({ + getHasUnsavedChanges: () => + getAmountHasUnsavedChanges({ + typedAmount: amountFormRef.current?.getNumber() ?? '', + committedAmount: transactionAmount, + isCreateEntry: isAmountCreateEntry, + selectedCurrency, + originalCurrency, + }), onCancel: () => { focusTimeoutRef.current = setTimeout(() => textInput.current?.focus(), CONST.ANIMATED_TRANSITION); }, @@ -201,7 +205,7 @@ function IOURequestStepAmount({ Log.hmmm('[IOURequestStepAmount] Skipping amount submit: submit data not ready'); return; } - notifySaving(); + suppressDiscardPrompt(); submitAmount({ translate, report, diff --git a/src/pages/iou/request/step/IOURequestStepDistance.tsx b/src/pages/iou/request/step/IOURequestStepDistance.tsx index 6042966b1e24..94e22441d16a 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistance.tsx @@ -35,11 +35,12 @@ import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import {getLatestErrorField} from '@libs/ErrorUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {shouldUseTransactionDraft} from '@libs/IOUUtils'; +import {getWaypointsHasUnsavedChanges} from '@libs/MoneyRequestUtils'; import Navigation from '@libs/Navigation/Navigation'; import OnyxTabNavigator, {TabScreenWithFocusTrapWrapper, TopTab} from '@libs/Navigation/OnyxTabNavigator'; import {roundToTwoDecimalPlaces} from '@libs/NumberUtils'; import {isPolicyExpenseChat as isPolicyExpenseChatUtil} from '@libs/ReportUtils'; -import {doesMoneyRequestDraftHaveUserInput, getDistanceInMeters, getRateID, getRequestType, haveWaypointAddressesChanged} from '@libs/TransactionUtils'; +import {getDistanceInMeters, getRateID, getRequestType, haveWaypointAddressesChanged} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import type {IOUType} from '@src/CONST'; @@ -164,10 +165,6 @@ function IOURequestStepDistance({ const shouldShowNotFoundPage = useShowNotFoundPageInIOUStep(action, iouType, reportActionID, report, currentTransaction); - const {notifySaving} = useDiscardChangesConfirmation({ - getHasUnsavedChanges: () => isCreatingNewRequest && doesMoneyRequestDraftHaveUserInput(transaction), - }); - const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); // Manual distance editing state @@ -196,6 +193,19 @@ function IOURequestStepDistance({ [distanceInMeters, distanceUnit], ); + const {suppressDiscardPrompt} = useDiscardChangesConfirmation({ + getHasUnsavedChanges: () => { + // Manual distance sits in `manualNumberFormRef` until Save — gate on the mounted ref so a cleared (empty) value still counts as dirty against a committed distance. + const manualForm = manualNumberFormRef.current; + const typedDistance = manualForm?.getNumber(); + const typedManualDistance = typedDistance ? roundToTwoDecimalPlaces(parseFloat(typedDistance)) : undefined; + const manualDistanceChanged = !!manualForm && typedManualDistance !== currentDistance; + // Split edits skip the transaction backup, so their pre-edit route lives in `originalSplitTransactionDraft`. + const committedWaypoints = isEditingSplit ? originalSplitTransactionDraft?.comment?.waypoints : transactionBackup?.comment?.waypoints; + return manualDistanceChanged || getWaypointsHasUnsavedChanges(transaction, committedWaypoints, waypoints, isCreatingNewRequest); + }, + }); + // Track whether the user has typed in the manual tab so route re-fetches don't clobber in-progress // input. Editing waypoints clears this (in the effect below) — a recalculated route supersedes a // manual value the same way it would on a fresh map expense (GH #90083). @@ -318,6 +328,8 @@ function IOURequestStepDistance({ }, [backTo]); const navigateBackAfterSave = useCallback(() => { + // Suppress the discard prompt — otherwise `beforeRemove` treats this post-save navigation as an abandoned dirty edit. + suppressDiscardPrompt(); // When editing an individual split, the previous RHP screen is the edit-split page the user // wants to return to — `closeRHPFlow` would tear it down too. Use `goBack` so the RHP stays // open at the edit-split page. @@ -326,13 +338,10 @@ function IOURequestStepDistance({ return; } Navigation.closeRHPFlow(); - }, [isEditingSplit, backTo]); + }, [isEditingSplit, backTo, suppressDiscardPrompt]); - // In the edit flow this page is rendered inside an OnyxTabNavigator. A plain `goBack()` with no - // target would be swallowed by that tab navigator (reverting to the Map tab) instead of leaving - // the page, so the header back must leave explicitly: honor an explicit `backTo` (e.g. the - // edit-split page) and otherwise close the RHP — matching other tabbed RHP pages like - // IOURequestStartPage. The browser/hardware back keeps the default tab behavior (revert tab first). + // In the edit flow this page is rendered inside an OnyxTabNavigator. The header back honors an + // explicit `backTo` (e.g. the edit-split page) and otherwise leaves the whole flow. const navigateBackFromEditFlow = useCallback(() => { if (backTo) { Navigation.goBack(backTo); @@ -400,13 +409,19 @@ function IOURequestStepDistance({ return getLatestErrorField(currentTransaction, 'route'); } if (isWaypointsNullIslandError) { - return {isWaypointsNullIslandError: `${translate('common.please')} ${translate('common.fixTheErrors')} ${translate('common.inTheFormBeforeContinuing')}.`} as Errors; + return { + isWaypointsNullIslandError: `${translate('common.please')} ${translate('common.fixTheErrors')} ${translate('common.inTheFormBeforeContinuing')}.`, + } as Errors; } if (duplicateWaypointsError) { - return {duplicateWaypointsError: translate('iou.error.duplicateWaypointsErrorMessage')} as Errors; + return { + duplicateWaypointsError: translate('iou.error.duplicateWaypointsErrorMessage'), + } as Errors; } if (atLeastTwoDifferentWaypointsError) { - return {atLeastTwoDifferentWaypointsError: translate('iou.error.atLeastTwoDifferentWaypoints')} as Errors; + return { + atLeastTwoDifferentWaypointsError: translate('iou.error.atLeastTwoDifferentWaypoints'), + } as Errors; } return {}; }, [hasRouteError, currentTransaction, isWaypointsNullIslandError, translate, duplicateWaypointsError, atLeastTwoDifferentWaypointsError]); @@ -461,7 +476,10 @@ function IOURequestStepDistance({ setDraftSplitTransaction( CONST.IOU.OPTIMISTIC_TRANSACTION_ID, originalSplitTransactionDraft, - {waypoints: currentTransaction?.comment?.waypoints, routes: currentTransaction?.routes}, + { + waypoints: currentTransaction?.comment?.waypoints, + routes: currentTransaction?.routes, + }, policy, personalPolicy?.outputCurrency, ); @@ -512,7 +530,7 @@ function IOURequestStepDistance({ return; } - notifySaving(); + suppressDiscardPrompt(); navigateToNextStep(); }, [ duplicateWaypointsError, @@ -524,7 +542,7 @@ function IOURequestStepDistance({ isCreatingNewRequest, navigateToNextStep, navigateBackAfterSave, - notifySaving, + suppressDiscardPrompt, isEditingSplit, originalSplitTransactionDraft, transactionBackup, @@ -678,7 +696,13 @@ function IOURequestStepDistance({ }, [manualFormError]); const errorState = useMemo( - () => ({shouldShowAtLeastTwoDifferentWaypointsError, atLeastTwoDifferentWaypointsError, duplicateWaypointsError, hasRouteError, getError}), + () => ({ + shouldShowAtLeastTwoDifferentWaypointsError, + atLeastTwoDifferentWaypointsError, + duplicateWaypointsError, + hasRouteError, + getError, + }), [shouldShowAtLeastTwoDifferentWaypointsError, atLeastTwoDifferentWaypointsError, duplicateWaypointsError, hasRouteError, getError], ); diff --git a/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx b/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx index d52baea3100a..b0851b390963 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx @@ -29,6 +29,7 @@ import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {shouldUseTransactionDraft} from '@libs/IOUUtils'; +import {getStringFieldHasUnsavedChanges} from '@libs/MoneyRequestUtils'; import Navigation from '@libs/Navigation/Navigation'; import {rand64, roundToTwoDecimalPlaces} from '@libs/NumberUtils'; import {generateReportID, isMoneyRequestReport as isMoneyRequestReportReportUtils, isPolicyExpenseChat as isPolicyExpenseChatUtils} from '@libs/ReportUtils'; @@ -153,10 +154,10 @@ function IOURequestStepDistanceManual({ const distanceInMeters = getDistanceInMeters(transaction, transaction?.comment?.customUnit?.distanceUnit ? transaction.comment.customUnit.distanceUnit : unit); const distance = typeof transaction?.comment?.customUnit?.quantity === 'number' ? roundToTwoDecimalPlaces(DistanceRequestUtils.convertDistanceUnit(distanceInMeters, unit)) : undefined; - const {notifySaving} = useDiscardChangesConfirmation({ + const {suppressDiscardPrompt} = useDiscardChangesConfirmation({ getHasUnsavedChanges: () => { const typedDistance = numberFormRef.current?.getNumber() ?? ''; - return typedDistance !== (distance?.toString() ?? ''); + return getStringFieldHasUnsavedChanges(typedDistance, distance?.toString() ?? '', isCreatingNewRequest); }, onCancel: () => { focusTimeoutRef.current = setTimeout(() => textInput.current?.focus(), CONST.ANIMATED_TRANSITION); @@ -308,7 +309,7 @@ function IOURequestStepDistanceManual({ return; } - notifySaving(); + suppressDiscardPrompt(); navigateToNextPage(value); }; diff --git a/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx b/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx index 9104e9591373..61f22c109c77 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx @@ -3,9 +3,7 @@ import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalD import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; import useDefaultExpensePolicy from '@hooks/useDefaultExpensePolicy'; -import useDelegateAccountID from '@hooks/useDelegateAccountID'; import useDiscardChangesConfirmation from '@hooks/useDiscardChangesConfirmation'; -import useDistanceRateOriginalPolicy from '@hooks/useDistanceRateOriginalPolicy'; import useFetchRoute from '@hooks/useFetchRoute'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -21,16 +19,13 @@ import useSelfDMReport from '@hooks/useSelfDMReport'; import useShowNotFoundPageInIOUStep from '@hooks/useShowNotFoundPageInIOUStep'; import useWaypointItems from '@hooks/useWaypointItems'; -import {setDraftSplitTransaction} from '@libs/actions/IOU/Split'; -import {updateMoneyRequestDistance} from '@libs/actions/IOU/UpdateMoneyRequest'; import {init, stop} from '@libs/actions/MapboxToken'; import {openDraftDistanceExpense, removeWaypoint, updateWaypoints as updateWaypointsUtil} from '@libs/actions/Transaction'; import {getLatestErrorField} from '@libs/ErrorUtils'; -import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {shouldUseTransactionDraft} from '@libs/IOUUtils'; import Navigation from '@libs/Navigation/Navigation'; import {isPolicyExpenseChat as isPolicyExpenseChatUtil} from '@libs/ReportUtils'; -import {doesMoneyRequestDraftHaveUserInput, getRateID, getRequestType, haveWaypointAddressesChanged} from '@libs/TransactionUtils'; +import {doesMoneyRequestDraftHaveUserInput, getRateID, getRequestType} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -48,7 +43,6 @@ import type {RenderItemParams} from 'react-native-draggable-flatlist/lib/typescr import type {OnyxEntry} from 'react-native-onyx'; import {deepEqual} from 'fast-equals'; -import isEmpty from 'lodash/isEmpty'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import type {WithWritableReportOrNotFoundProps} from './withWritableReportOrNotFound'; @@ -56,7 +50,6 @@ import type {WithWritableReportOrNotFoundProps} from './withWritableReportOrNotF import DistanceMapTabContent from './DistanceMapTabContent'; import useDistanceNavigation from './IOURequestStepDistance/hooks/useDistanceNavigation'; import useDistanceRequestData from './IOURequestStepDistance/hooks/useDistanceRequestData'; -import useDistanceTransactionBackup from './IOURequestStepDistance/hooks/useDistanceTransactionBackup'; import useWaypointValidation, {isWaypointEmpty} from './IOURequestStepDistance/hooks/useWaypointValidation'; import StepScreenWrapper from './StepScreenWrapper'; import withFullTransactionOrNotFound from './withFullTransactionOrNotFound'; @@ -81,26 +74,16 @@ function IOURequestStepDistanceMap({ const {isBetaEnabled} = usePermissions(); const {policyForMovingExpenses} = usePolicyForMovingExpenses(); const isArchived = useReportIsArchived(report?.reportID); - const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(report?.parentReportID)}`); - const [parentReportNextStep] = useOnyx(`${ONYXKEYS.COLLECTION.NEXT_STEP}${getNonEmptyStringOnyxID(report?.parentReportID)}`); - const [transactionBackup] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_BACKUP}${transactionID}`); - const [splitDraftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`); const selfDMReport = useSelfDMReport(); const policy = usePolicy(report?.policyID); - const distanceOriginalPolicy = useDistanceRateOriginalPolicy(transaction?.comment?.customUnit?.customUnitRateID); - const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policy?.id}`); - const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policy?.id}`); const personalPolicy = usePersonalPolicy(); const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); const defaultExpensePolicy = useDefaultExpensePolicy(); const [skipConfirmation] = useOnyx(`${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${transactionID}`); const [optimisticWaypoints, setOptimisticWaypoints] = useState(null); const [betas] = useOnyx(ONYXKEYS.BETAS); - const isEditing = action === CONST.IOU.ACTION.EDIT; - const isEditingSplit = (iouType === CONST.IOU.TYPE.SPLIT || iouType === CONST.IOU.TYPE.SPLIT_EXPENSE) && isEditing; - const currentTransaction = isEditingSplit && !isEmpty(splitDraftTransaction) ? splitDraftTransaction : transaction; - const transactionWaypoints = currentTransaction?.comment?.waypoints; + const transactionWaypoints = transaction?.comment?.waypoints; const areTransactionWaypointsEmpty = !transactionWaypoints || Object.values(transactionWaypoints).every((w) => isEmptyObject(w)); const waypoints = useMemo(() => { @@ -116,38 +99,27 @@ function IOURequestStepDistanceMap({ }, [optimisticWaypoints, transactionWaypoints, areTransactionWaypointsEmpty]); const reportAttributesDerived = useReportAttributes(); - let transactionState: TransactionStateType = CONST.TRANSACTION.STATE.CURRENT; - if (isEditingSplit) { - transactionState = CONST.TRANSACTION.STATE.SPLIT_DRAFT; - } else if (shouldUseTransactionDraft(action)) { - transactionState = CONST.TRANSACTION.STATE.DRAFT; - } - const backupWaypoints = transactionBackup?.pendingFields?.waypoints ? transactionBackup?.comment?.waypoints : undefined; - // When online, fetch the backup route to ensure the map is populated even if the user does not save the transaction. - // Fetch the backup route first to ensure the backup transaction map is updated before the main transaction map. - // This prevents a scenario where the main map loads, the user dismisses the map editor, and the backup map has not yet loaded due to delay. - useFetchRoute(transactionBackup, backupWaypoints, action, CONST.TRANSACTION.STATE.BACKUP); - const {shouldFetchRoute, validatedWaypoints} = useFetchRoute(currentTransaction, waypoints, action, transactionState); + const transactionState: TransactionStateType = shouldUseTransactionDraft(action) ? CONST.TRANSACTION.STATE.DRAFT : CONST.TRANSACTION.STATE.CURRENT; + const {shouldFetchRoute, validatedWaypoints} = useFetchRoute(transaction, waypoints, action, transactionState); const previousWaypoints = usePrevious(waypoints); const numberOfWaypoints = Object.keys(waypoints).length; const numberOfPreviousWaypoints = Object.keys(previousWaypoints).length; const scrollViewRef = useRef(null); - const isLoadingRoute = currentTransaction?.comment?.isLoading ?? false; - const isLoading = currentTransaction?.isLoading ?? false; + const isLoadingRoute = transaction?.comment?.isLoading ?? false; + const isLoading = transaction?.isLoading ?? false; const isSplitRequest = iouType === CONST.IOU.TYPE.SPLIT; - const hasRouteError = !!currentTransaction?.errorFields?.route; + const hasRouteError = !!transaction?.errorFields?.route; const [shouldShowAtLeastTwoDifferentWaypointsError, setShouldShowAtLeastTwoDifferentWaypointsError] = useState(false); const {nonEmptyWaypointsCount, isWaypointsNullIslandError, duplicateWaypointsError, atLeastTwoDifferentWaypointsError} = useWaypointValidation({waypoints, validatedWaypoints}); - const isCreatingNewRequest = !(backTo || isEditing); const [recentWaypoints, {status: recentWaypointsStatus}] = useOnyx(ONYXKEYS.NVP_RECENT_WAYPOINTS); - const iouRequestType = getRequestType(currentTransaction); - const customUnitRateID = getRateID(currentTransaction); + const iouRequestType = getRequestType(transaction); + const customUnitRateID = getRateID(transaction); - const shouldShowNotFoundPage = useShowNotFoundPageInIOUStep(action, iouType, reportActionID, report, currentTransaction); + const shouldShowNotFoundPage = useShowNotFoundPageInIOUStep(action, iouType, reportActionID, report, transaction); - const {notifySaving} = useDiscardChangesConfirmation({ - getHasUnsavedChanges: () => isCreatingNewRequest && doesMoneyRequestDraftHaveUserInput(transaction), + const {suppressDiscardPrompt} = useDiscardChangesConfirmation({ + getHasUnsavedChanges: () => doesMoneyRequestDraftHaveUserInput(transaction), }); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); @@ -155,7 +127,6 @@ function IOURequestStepDistanceMap({ const currentUserAccountIDParam = currentUserPersonalDetails.accountID; const currentUserEmailParam = currentUserPersonalDetails.login ?? ''; - const delegateAccountID = useDelegateAccountID(); const setDistanceRequestData = useDistanceRequestData({ policy, @@ -176,7 +147,7 @@ function IOURequestStepDistanceMap({ return iouType !== CONST.IOU.TYPE.SPLIT && !isArchived && !(isPolicyExpenseChatUtil(report) && ((policy?.requiresCategory ?? false) || (policy?.requiresTag ?? false))); }, [report, skipConfirmation, policy?.requiresCategory, policy?.requiresTag, isArchived, iouType]); - let buttonText = !isCreatingNewRequest ? translate('common.save') : translate('common.next'); + let buttonText = translate('common.next'); if (shouldSkipConfirmation) { if (iouType === CONST.IOU.TYPE.SPLIT) { buttonText = translate('iou.split'); @@ -214,17 +185,6 @@ function IOURequestStepDistanceMap({ setShouldShowAtLeastTwoDifferentWaypointsError(false); }, [atLeastTwoDifferentWaypointsError, duplicateWaypointsError, hasRouteError, isLoading, isLoadingRoute, nonEmptyWaypointsCount, transaction]); - const transactionWasSaved = useRef(false); - useDistanceTransactionBackup({ - transaction, - isCreatingNewRequest, - isEditingSplit: false, - isDraft: shouldUseTransactionDraft(action), - introSelected, - betas, - transactionWasSavedRef: transactionWasSaved, - }); - const navigateBack = useCallback(() => { Navigation.goBack(backTo); }, [backTo]); @@ -275,19 +235,25 @@ function IOURequestStepDistanceMap({ const getError = useCallback(() => { // Get route error if available else show the invalid number of waypoints error. if (hasRouteError) { - return getLatestErrorField(currentTransaction, 'route'); + return getLatestErrorField(transaction, 'route'); } if (isWaypointsNullIslandError) { - return {isWaypointsNullIslandError: `${translate('common.please')} ${translate('common.fixTheErrors')} ${translate('common.inTheFormBeforeContinuing')}.`} as Errors; + return { + isWaypointsNullIslandError: `${translate('common.please')} ${translate('common.fixTheErrors')} ${translate('common.inTheFormBeforeContinuing')}.`, + } as Errors; } if (duplicateWaypointsError) { - return {duplicateWaypointsError: translate('iou.error.duplicateWaypointsErrorMessage')} as Errors; + return { + duplicateWaypointsError: translate('iou.error.duplicateWaypointsErrorMessage'), + } as Errors; } if (atLeastTwoDifferentWaypointsError) { - return {atLeastTwoDifferentWaypointsError: translate('iou.error.atLeastTwoDifferentWaypoints')} as Errors; + return { + atLeastTwoDifferentWaypointsError: translate('iou.error.atLeastTwoDifferentWaypoints'), + } as Errors; } return {}; - }, [hasRouteError, isWaypointsNullIslandError, duplicateWaypointsError, atLeastTwoDifferentWaypointsError, currentTransaction, translate]); + }, [hasRouteError, isWaypointsNullIslandError, duplicateWaypointsError, atLeastTwoDifferentWaypointsError, transaction, translate]); type DataParams = { data: string[]; @@ -312,100 +278,26 @@ function IOURequestStepDistanceMap({ } setOptimisticWaypoints(newWaypoints); - const shouldPassSplitDraft = isEditingSplit && !isEmpty(splitDraftTransaction); Promise.all([ - removeWaypoint(currentTransaction, emptyWaypointIndex.toString(), shouldUseTransactionDraft(action), shouldPassSplitDraft ? splitDraftTransaction : undefined), + removeWaypoint(transaction, emptyWaypointIndex.toString(), shouldUseTransactionDraft(action), undefined), updateWaypointsUtil(transactionID, newWaypoints, transactionState), ]).then(() => { setOptimisticWaypoints(null); }); }, - [waypointItems, isEditingSplit, splitDraftTransaction, currentTransaction, action, transactionID, transactionState, getWaypoint, waypoints], + [waypointItems, transaction, action, transactionID, transactionState, getWaypoint, waypoints], ); const submitWaypoints = useCallback(() => { // If there is any error or loading state, don't let user go to next page. - if (duplicateWaypointsError || atLeastTwoDifferentWaypointsError || hasRouteError || isLoadingRoute || (!isEditing && isLoading)) { + if (duplicateWaypointsError || atLeastTwoDifferentWaypointsError || hasRouteError || isLoadingRoute || isLoading) { setShouldShowAtLeastTwoDifferentWaypointsError(true); return; } - if (!isCreatingNewRequest && !isEditing) { - transactionWasSaved.current = true; - } - if (isEditing) { - // In the split flow, when editing we use SPLIT_TRANSACTION_DRAFT to save draft value - if (isEditingSplit && transaction) { - setDraftSplitTransaction(transaction.transactionID, splitDraftTransaction, {waypoints}, policy, personalPolicy?.outputCurrency); - navigateBack(); - return; - } - - // If nothing was changed, simply go to transaction thread - // We compare only addresses because numbers are rounded while backup - const hasRouteChanged = !deepEqual(transactionBackup?.routes, transaction?.routes); - if (!haveWaypointAddressesChanged(transactionBackup?.comment?.waypoints, waypoints)) { - navigateBack(); - return; - } - if (transaction?.transactionID && report?.reportID) { - updateMoneyRequestDistance({ - transaction, - transactionThreadReport: report, - parentReport, - waypoints, - recentWaypoints, - ...(hasRouteChanged ? {routes: transaction?.routes} : {}), - policy, - distanceOriginalPolicy, - policyTagList: policyTags, - policyCategories, - transactionBackup, - currentUserAccountIDParam, - currentUserEmailParam, - isASAPSubmitBetaEnabled, - parentReportNextStep, - delegateAccountID, - personalPolicyOutputCurrency: personalPolicy?.outputCurrency, - }); - } - transactionWasSaved.current = true; - navigateBack(); - return; - } - - notifySaving(); + suppressDiscardPrompt(); navigateToNextStep(); - }, [ - duplicateWaypointsError, - atLeastTwoDifferentWaypointsError, - hasRouteError, - isLoadingRoute, - isEditing, - isLoading, - isCreatingNewRequest, - navigateToNextStep, - notifySaving, - isEditingSplit, - transaction, - transactionBackup, - waypoints, - report, - navigateBack, - splitDraftTransaction, - policy, - parentReport, - policyTags, - policyCategories, - currentUserAccountIDParam, - currentUserEmailParam, - isASAPSubmitBetaEnabled, - parentReportNextStep, - recentWaypoints, - distanceOriginalPolicy, - delegateAccountID, - personalPolicy?.outputCurrency, - ]); + }, [duplicateWaypointsError, atLeastTwoDifferentWaypointsError, hasRouteError, isLoadingRoute, isLoading, suppressDiscardPrompt, navigateToNextStep]); const renderItem = useCallback( ({item, drag, isActive, getIndex}: RenderItemParams) => { @@ -428,7 +320,13 @@ function IOURequestStepDistanceMap({ ); const errorState = useMemo( - () => ({shouldShowAtLeastTwoDifferentWaypointsError, atLeastTwoDifferentWaypointsError, duplicateWaypointsError, hasRouteError, getError}), + () => ({ + shouldShowAtLeastTwoDifferentWaypointsError, + atLeastTwoDifferentWaypointsError, + duplicateWaypointsError, + hasRouteError, + getError, + }), [shouldShowAtLeastTwoDifferentWaypointsError, atLeastTwoDifferentWaypointsError, duplicateWaypointsError, hasRouteError, getError], ); @@ -439,8 +337,8 @@ function IOURequestStepDistanceMap({ headerTitle={translate('common.distance')} onBackButtonPress={navigateBack} testID="IOURequestStepDistanceMap" - shouldShowNotFoundPage={(isEditing && !currentTransaction?.comment?.waypoints) || shouldShowNotFoundPage} - shouldShowWrapper={!isCreatingNewRequest} + shouldShowNotFoundPage={shouldShowNotFoundPage} + shouldShowWrapper={false} > { const typedCount = moneyRequestTimeInputRef.current?.getNumber() ?? ''; - return typedCount !== `${transaction?.comment?.units?.count ?? ''}`; + return getStringFieldHasUnsavedChanges(typedCount, `${transaction?.comment?.units?.count ?? ''}`, isEmbeddedInStartPage); }, onCancel: () => { focusTimeoutRef.current = setTimeout(() => textInputRef.current?.focus(), CONST.ANIMATED_TRANSITION); @@ -121,7 +122,7 @@ function IOURequestStepHours({ return; } - notifySaving(); + suppressDiscardPrompt(); setMoneyRequestAmount(transactionID, computeTimeAmount(rate, count), currency); setMoneyRequestMerchant(transactionID, formatTimeMerchant(count, rate, currency, translate, convertToDisplayString), isTransactionDraft); setMoneyRequestTimeCount(transactionID, count, isTransactionDraft); @@ -190,7 +191,7 @@ function IOURequestStepHours({ style={[styles.w100, canUseTouchScreen ? styles.mt5 : styles.mt0]} onPress={() => { if (policy && shouldRestrictUserBillableActions(policy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, accountID)) { - notifySaving(); + suppressDiscardPrompt(); Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); return; } diff --git a/src/pages/settings/Agents/Fields/EditAgentAvatarPage.tsx b/src/pages/settings/Agents/Fields/EditAgentAvatarPage.tsx index 00aa75929637..4b79273fdfff 100644 --- a/src/pages/settings/Agents/Fields/EditAgentAvatarPage.tsx +++ b/src/pages/settings/Agents/Fields/EditAgentAvatarPage.tsx @@ -77,7 +77,7 @@ function EditAgentAvatarContent({accountID, fallbackRoute, onSave, initialPreset const isDirty = selectedBotAvatar !== initialBotAvatar || imageData.uri !== ''; - const {notifySaving} = useDiscardChangesConfirmation({ + const {suppressDiscardPrompt} = useDiscardChangesConfirmation({ getHasUnsavedChanges: () => isDirty, }); @@ -119,7 +119,7 @@ function EditAgentAvatarContent({accountID, fallbackRoute, onSave, initialPreset if (!isDirty) { return; } - notifySaving(); + suppressDiscardPrompt(); if (imageData.file) { if (onSave) { diff --git a/src/pages/settings/Profile/Avatar/useProfileAvatarForm.ts b/src/pages/settings/Profile/Avatar/useProfileAvatarForm.ts index 2d6c66f9e9f1..7468a49cf04f 100644 --- a/src/pages/settings/Profile/Avatar/useProfileAvatarForm.ts +++ b/src/pages/settings/Profile/Avatar/useProfileAvatarForm.ts @@ -24,14 +24,13 @@ function useProfileAvatarForm() { const [imageData, setImageData] = useState({...EMPTY_FILE}); const avatarCaptureRef = useRef(null); - const isSavingRef = useRef(false); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const isDirty = imageData.uri !== '' || !!selected; - useDiscardChangesConfirmation({ - getHasUnsavedChanges: () => !isSavingRef.current && isDirty, + const {suppressDiscardPrompt} = useDiscardChangesConfirmation({ + getHasUnsavedChanges: () => isDirty, }); const setError = (error: TranslationPaths | null, phraseParam: Record) => { @@ -56,7 +55,7 @@ function useProfileAvatarForm() { }; const save = () => { - isSavingRef.current = true; + suppressDiscardPrompt(); const previousAvatar = { avatar: currentUserPersonalDetails?.avatar, @@ -86,7 +85,7 @@ function useProfileAvatarForm() { } if (!selected || !avatarCaptureRef.current) { - isSavingRef.current = false; + suppressDiscardPrompt(false); return; } @@ -99,7 +98,7 @@ function useProfileAvatarForm() { Navigation.dismissModal(); }) .catch(() => { - isSavingRef.current = false; + suppressDiscardPrompt(false); }); }; diff --git a/tests/unit/MoneyRequestUtilsTest.ts b/tests/unit/MoneyRequestUtilsTest.ts index 7086f9f05a5c..520c7e71488f 100644 --- a/tests/unit/MoneyRequestUtilsTest.ts +++ b/tests/unit/MoneyRequestUtilsTest.ts @@ -1,10 +1,21 @@ import {isValidPerDiemExpenseAmount} from '@libs/actions/IOU/PerDiem'; -import {handleNegativeAmountFlipping, isValidMerchant, isValidMoneyRequestAmount, validateAmount, validatePercentage} from '@libs/MoneyRequestUtils'; +import { + getAmountHasUnsavedChanges, + getStringFieldHasUnsavedChanges, + getWaypointsHasUnsavedChanges, + handleNegativeAmountFlipping, + isValidMerchant, + isValidMoneyRequestAmount, + validateAmount, + validatePercentage, +} from '@libs/MoneyRequestUtils'; import CONST from '@src/CONST'; import type Report from '@src/types/onyx/Report'; import type Transaction from '@src/types/onyx/Transaction'; -import type {TransactionCustomUnit} from '@src/types/onyx/Transaction'; +import type {TransactionCustomUnit, WaypointCollection} from '@src/types/onyx/Transaction'; + +import createRandomTransaction from '../utils/collections/transaction'; describe('ReportActionsUtils', () => { describe('validateAmount', () => { @@ -314,3 +325,172 @@ describe('ReportActionsUtils', () => { }); }); }); + +describe('getAmountHasUnsavedChanges', () => { + const sameCurrency = {selectedCurrency: 'USD', originalCurrency: 'USD'}; + + describe('create entry (any input counts)', () => { + it('flags a typed value', () => { + expect( + getAmountHasUnsavedChanges({ + ...sameCurrency, + typedAmount: '1', + committedAmount: 100, + isCreateEntry: true, + }), + ).toBe(true); + }); + + it('flags an explicit "0" even though it normalizes to the empty backend value', () => { + expect( + getAmountHasUnsavedChanges({ + ...sameCurrency, + typedAmount: '0', + committedAmount: 100, + isCreateEntry: true, + }), + ).toBe(true); + }); + + it('does not flag an empty field', () => { + expect( + getAmountHasUnsavedChanges({ + ...sameCurrency, + typedAmount: '', + committedAmount: 0, + isCreateEntry: true, + }), + ).toBe(false); + }); + + it('flags a cleared field when the draft already holds an amount (revisited after Next)', () => { + expect( + getAmountHasUnsavedChanges({ + ...sameCurrency, + typedAmount: '', + committedAmount: 100, + isCreateEntry: true, + }), + ).toBe(true); + }); + + it('flags a currency change even with no amount entered', () => { + expect( + getAmountHasUnsavedChanges({ + typedAmount: '', + committedAmount: 0, + isCreateEntry: true, + selectedCurrency: 'EUR', + originalCurrency: 'USD', + }), + ).toBe(true); + }); + }); + + describe('editing (only a real change counts)', () => { + it('does not flag formatting-only differences like "5" vs "5.00"', () => { + expect( + getAmountHasUnsavedChanges({ + ...sameCurrency, + typedAmount: '5', + committedAmount: 500, + isCreateEntry: false, + }), + ).toBe(false); + expect( + getAmountHasUnsavedChanges({ + ...sameCurrency, + typedAmount: '5.00', + committedAmount: 500, + isCreateEntry: false, + }), + ).toBe(false); + }); + + it('flags a real numeric change', () => { + expect( + getAmountHasUnsavedChanges({ + ...sameCurrency, + typedAmount: '6', + committedAmount: 500, + isCreateEntry: false, + }), + ).toBe(true); + }); + + it('flags a currency change even when the amount is unchanged', () => { + expect( + getAmountHasUnsavedChanges({ + typedAmount: '5', + committedAmount: 500, + isCreateEntry: false, + selectedCurrency: 'EUR', + originalCurrency: 'USD', + }), + ).toBe(true); + }); + }); +}); + +describe('getStringFieldHasUnsavedChanges (hours, manual distance)', () => { + describe('create entry (any input counts)', () => { + it('flags any typed value, including "0"', () => { + expect(getStringFieldHasUnsavedChanges('2', '', true)).toBe(true); + expect(getStringFieldHasUnsavedChanges('0', '', true)).toBe(true); + }); + + it('does not flag an empty field', () => { + expect(getStringFieldHasUnsavedChanges('', '', true)).toBe(false); + }); + + it('flags a cleared field when the draft already holds a value (revisited after Next)', () => { + expect(getStringFieldHasUnsavedChanges('', '5', true)).toBe(true); + }); + }); + + describe('editing (only a change counts)', () => { + it('flags only a change from the committed value', () => { + expect(getStringFieldHasUnsavedChanges('2', '2', false)).toBe(false); + expect(getStringFieldHasUnsavedChanges('3', '2', false)).toBe(true); + }); + }); +}); + +describe('getWaypointsHasUnsavedChanges (distance map)', () => { + const waypointsA: WaypointCollection = { + waypoint0: {address: 'Q Mall, Doha', lat: 25.3272762, lng: 51.4659325}, + waypoint1: {address: 'Qatar', lat: 25.354826, lng: 51.183884}, + }; + const waypointsB: WaypointCollection = { + waypoint0: {address: 'Q Mall, Doha', lat: 25.3272762, lng: 51.4659325}, + waypoint1: {address: 'West Bay, Doha', lat: 25.2510416, lng: 51.4699357}, + }; + + describe('create entry (any entered waypoint counts)', () => { + it('flags a draft that has valid waypoints', () => { + const transaction = createRandomTransaction(0); + transaction.modifiedWaypoints = undefined; + transaction.comment = {...transaction.comment, waypoints: waypointsA}; + expect(getWaypointsHasUnsavedChanges(transaction, undefined, waypointsA, true)).toBe(true); + }); + + it('does not flag an empty draft', () => { + expect(getWaypointsHasUnsavedChanges(undefined, undefined, undefined, true)).toBe(false); + }); + }); + + // The edit branch ignores the transaction and compares the committed vs current waypoints directly. + describe('editing (only a waypoint change counts)', () => { + it('flags when a waypoint address changed from the committed one', () => { + expect(getWaypointsHasUnsavedChanges(undefined, waypointsA, waypointsB, false)).toBe(true); + }); + + it('does not flag when the waypoints are unchanged', () => { + expect(getWaypointsHasUnsavedChanges(undefined, waypointsA, waypointsA, false)).toBe(false); + }); + + it('does not flag when the committed baseline is missing', () => { + expect(getWaypointsHasUnsavedChanges(undefined, undefined, waypointsA, false)).toBe(false); + }); + }); +}); diff --git a/tests/unit/hooks/useDiscardChangesConfirmation.test.ts b/tests/unit/hooks/useDiscardChangesConfirmation.test.ts index e3797fb2a272..7d457f80d2fe 100644 --- a/tests/unit/hooks/useDiscardChangesConfirmation.test.ts +++ b/tests/unit/hooks/useDiscardChangesConfirmation.test.ts @@ -197,6 +197,24 @@ describe('useDiscardChangesConfirmation (web)', () => { expect(mockShowConfirmModal).toHaveBeenCalledTimes(1); }); + it('lets the confirmed re-dispatch through even before the restore echo arrives', async () => { + renderDiscardHook(() => true); + + invokeBeforeRemove('RESET'); + dispatchPopstate(); + + let redeliveredEvent: MockBeforeRemoveEvent | undefined; + mockNavigationDispatch.mockImplementation(() => { + redeliveredEvent = createBeforeRemoveEvent('RESET'); + mockBeforeRemoveCallback?.(redeliveredEvent); + }); + + await resolveModalWith('CONFIRM'); + + expect(mockNavigationDispatch).toHaveBeenCalledWith({type: 'RESET'}); + expect(redeliveredEvent?.defaultPrevented).toBe(false); + }); + it('starts a fresh flow after cancelling', async () => { renderDiscardHook(() => true); @@ -283,13 +301,13 @@ describe('useDiscardChangesConfirmation (web)', () => { it('suppresses the prompt while a save is in progress, and re-arms when notified it ended', () => { const {result} = renderDiscardHook(() => true); - act(() => result.current.notifySaving()); + act(() => result.current.suppressDiscardPrompt()); const duringSave = invokeBeforeRemove('RESET'); expect(duringSave.defaultPrevented).toBe(false); expect(mockShowConfirmModal).not.toHaveBeenCalled(); - act(() => result.current.notifySaving(false)); + act(() => result.current.suppressDiscardPrompt(false)); const afterSave = invokeBeforeRemove('RESET'); expect(afterSave.defaultPrevented).toBe(true); diff --git a/tests/unit/hooks/useDiscardChangesConfirmationNative.test.ts b/tests/unit/hooks/useDiscardChangesConfirmationNative.test.ts index 37edfbf15636..c4946f3af59f 100644 --- a/tests/unit/hooks/useDiscardChangesConfirmationNative.test.ts +++ b/tests/unit/hooks/useDiscardChangesConfirmationNative.test.ts @@ -123,14 +123,14 @@ describe('useDiscardChangesConfirmation (native)', () => { expect(mockShowConfirmModal).not.toHaveBeenCalled(); }); - it('lets the back press through after notifySaving, and prompts again once the save ends', () => { + it('lets the back press through after suppressDiscardPrompt, and prompts again once the save ends', () => { const {result} = renderDiscardHook(() => true); - act(() => result.current.notifySaving()); + act(() => result.current.suppressDiscardPrompt()); expect(pressHardwareBack()).toBe(false); expect(mockShowConfirmModal).not.toHaveBeenCalled(); - act(() => result.current.notifySaving(false)); + act(() => result.current.suppressDiscardPrompt(false)); expect(pressHardwareBack()).toBe(true); expect(mockShowConfirmModal).toHaveBeenCalledTimes(1); }); @@ -180,7 +180,6 @@ describe('useDiscardChangesConfirmation (native)', () => { }); it('stays put on cancel and prompts again on the next back press', async () => { - renderDiscardHook(() => true); const onCancel = jest.fn(); renderHook(() => useDiscardChangesConfirmation({getHasUnsavedChanges: () => true, onCancel}));