diff --git a/cspell.json b/cspell.json index 350cc0b49cb2..129140621689 100644 --- a/cspell.json +++ b/cspell.json @@ -826,6 +826,7 @@ "reimbursability", "reimbursementid", "reimbursible", + "reminted", "remotedesktop", "remotesync", "removeHiddenElems", diff --git a/src/hooks/useDiscardChangesConfirmation/getDiscardChangesModalConfig.ts b/src/hooks/useDiscardChangesConfirmation/getDiscardChangesModalConfig.ts new file mode 100644 index 000000000000..203ca42c42af --- /dev/null +++ b/src/hooks/useDiscardChangesConfirmation/getDiscardChangesModalConfig.ts @@ -0,0 +1,18 @@ +import type {LocaleContextProps} from '@components/LocaleContextProvider'; + +/** + * Single source of truth for the "Discard changes?" modal content, so every caller (nav-away and tab-switch) + * renders an identical modal instead of drifting apart. Behavioral flags like the web-only + * `shouldIgnoreBackHandlerDuringTransition` are added per caller, not here + */ +function getDiscardChangesModalConfig(translate: LocaleContextProps['translate']) { + return { + title: translate('discardChangesConfirmation.title'), + prompt: translate('discardChangesConfirmation.body'), + danger: true, + confirmText: translate('discardChangesConfirmation.confirmText'), + cancelText: translate('common.cancel'), + }; +} + +export default getDiscardChangesModalConfig; diff --git a/src/hooks/useDiscardChangesConfirmation/index.native.ts b/src/hooks/useDiscardChangesConfirmation/index.native.ts index f44dd33c6231..ca1f09f878ac 100644 --- a/src/hooks/useDiscardChangesConfirmation/index.native.ts +++ b/src/hooks/useDiscardChangesConfirmation/index.native.ts @@ -1,19 +1,26 @@ import type {NavigationAction} from '@react-navigation/native'; -import {usePreventRemove} from '@react-navigation/native'; +import {usePreventRemove, useRoute} 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 {useRegisterTabSwitchGuard} from '@libs/Navigation/TabSwitchGuardContext'; +import getDiscardChangesModalConfig from './getDiscardChangesModalConfig'; import type UseDiscardChangesConfirmationOptions from './types'; -function useDiscardChangesConfirmation({getHasUnsavedChanges, onCancel, onVisibilityChange, onConfirm}: UseDiscardChangesConfirmationOptions) { +function useDiscardChangesConfirmation({getHasUnsavedChanges, onCancel, onVisibilityChange, onConfirm, onTabSwitchDiscard}: UseDiscardChangesConfirmationOptions) { + const route = useRoute(); const {translate} = useLocalize(); const {showConfirmModal} = useConfirmModal(); const [shouldAllowNavigation, setShouldAllowNavigation] = useState(false); const blockedNavigationAction = useRef(undefined); + // 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 shouldPrevent = !shouldAllowNavigation; usePreventRemove( @@ -27,13 +34,7 @@ function useDiscardChangesConfirmation({getHasUnsavedChanges, onCancel, onVisibi } blockedNavigationAction.current = data.action; onVisibilityChange?.(true); - showConfirmModal({ - title: translate('discardChangesConfirmation.title'), - prompt: translate('discardChangesConfirmation.body'), - danger: true, - confirmText: translate('discardChangesConfirmation.confirmText'), - cancelText: translate('common.cancel'), - }).then((result) => { + showConfirmModal(getDiscardChangesModalConfig(translate)).then((result) => { onVisibilityChange?.(false); if (result.action !== ModalActions.CONFIRM) { onCancel?.(); diff --git a/src/hooks/useDiscardChangesConfirmation/index.ts b/src/hooks/useDiscardChangesConfirmation/index.ts index 2ca6c1a86724..e76aee5ee69e 100644 --- a/src/hooks/useDiscardChangesConfirmation/index.ts +++ b/src/hooks/useDiscardChangesConfirmation/index.ts @@ -1,5 +1,5 @@ import type {NavigationAction} from '@react-navigation/native'; -import {useNavigation} from '@react-navigation/native'; +import {useNavigation, useRoute} from '@react-navigation/native'; import {useCallback, useEffect, useRef} from 'react'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import useBeforeRemove from '@hooks/useBeforeRemove'; @@ -9,13 +9,20 @@ import Log from '@libs/Log'; import setNavigationActionToMicrotaskQueue from '@libs/Navigation/helpers/setNavigationActionToMicrotaskQueue'; import navigationRef from '@libs/Navigation/navigationRef'; import type {PlatformStackNavigationProp} from '@libs/Navigation/PlatformStackNavigation/types'; +import {useRegisterTabSwitchGuard} from '@libs/Navigation/TabSwitchGuardContext'; import type {RootNavigatorParamList} from '@libs/Navigation/types'; +import getDiscardChangesModalConfig from './getDiscardChangesModalConfig'; import type UseDiscardChangesConfirmationOptions from './types'; -function useDiscardChangesConfirmation({getHasUnsavedChanges, onCancel, onVisibilityChange, onConfirm}: UseDiscardChangesConfirmationOptions) { +function useDiscardChangesConfirmation({getHasUnsavedChanges, onCancel, onVisibilityChange, onConfirm, onTabSwitchDiscard}: UseDiscardChangesConfirmationOptions) { const navigation = useNavigation>(); + const route = useRoute(); const {translate} = useLocalize(); const {showConfirmModal} = 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 shouldIgnoreNextBeforeRemove = useRef(false); @@ -60,11 +67,7 @@ function useDiscardChangesConfirmation({getHasUnsavedChanges, onCancel, onVisibi isDiscardModalOpen.current = true; onVisibilityChange?.(true); showConfirmModal({ - title: translate('discardChangesConfirmation.title'), - prompt: translate('discardChangesConfirmation.body'), - danger: true, - confirmText: translate('discardChangesConfirmation.confirmText'), - cancelText: translate('common.cancel'), + ...getDiscardChangesModalConfig(translate), shouldIgnoreBackHandlerDuringTransition: true, }).then((result) => { markNextBeforeRemoveAsModalCleanup(); diff --git a/src/hooks/useDiscardChangesConfirmation/types.ts b/src/hooks/useDiscardChangesConfirmation/types.ts index abfcbc6b76fd..37e932b84274 100644 --- a/src/hooks/useDiscardChangesConfirmation/types.ts +++ b/src/hooks/useDiscardChangesConfirmation/types.ts @@ -3,6 +3,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; }; export default UseDiscardChangesConfirmationOptions; diff --git a/src/libs/Navigation/OnyxTabNavigator.tsx b/src/libs/Navigation/OnyxTabNavigator.tsx index b87a155c1653..8fd0735d718b 100644 --- a/src/libs/Navigation/OnyxTabNavigator.tsx +++ b/src/libs/Navigation/OnyxTabNavigator.tsx @@ -1,14 +1,20 @@ import type {MaterialTopTabNavigationEventMap} from '@react-navigation/material-top-tabs'; import {createMaterialTopTabNavigator} from '@react-navigation/material-top-tabs'; -import type {EventMapCore, NavigationState, ParamListBase, ScreenListeners} from '@react-navigation/native'; -import {useRoute} from '@react-navigation/native'; +import type {EventArg, EventMapCore, NavigationProp, NavigationState, ParamListBase, ScreenListeners} from '@react-navigation/native'; +import {TabActions, useRoute} from '@react-navigation/native'; import React, {useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; import {StyleSheet, View} from 'react-native'; import ActivityIndicator from '@components/ActivityIndicator'; import FocusTrapContainerElement from '@components/FocusTrap/FocusTrapContainerElement'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; import type {TabSelectorProps} from '@components/TabSelector/types'; +import useConfirmModal from '@hooks/useConfirmModal'; +import getDiscardChangesModalConfig from '@hooks/useDiscardChangesConfirmation/getDiscardChangesModalConfig'; +import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; +import Growl from '@libs/Growl'; +import Log from '@libs/Log'; import Tab from '@userActions/Tab'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -16,6 +22,8 @@ import type {SelectedTabRequest} from '@src/types/onyx'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import {defaultScreenOptions} from './OnyxTabNavigatorConfig'; +import TabSwitchGuardContext from './TabSwitchGuardContext'; +import type {RegisterTabSwitchGuard, TabSwitchGuard} from './TabSwitchGuardContext'; type OnyxTabNavigatorProps = ChildrenProps & { /** ID of the tab component to be saved in onyx */ @@ -133,6 +141,65 @@ function OnyxTabNavigator({ }); }, []); + const {translate} = useLocalize(); + const {showConfirmModal} = useConfirmModal(); + // Tab-switch discard guards, keyed by tab name. Tab screens register via `useDiscardChangesConfirmation`. + const guardsRef = useRef>(new Map()); + const isDiscardModalOpenRef = useRef(false); + + const registerTabGuard = useCallback((guard) => { + guardsRef.current.set(guard.tabName, guard); + return () => { + // Only clear if this exact guard is still registered, so a re-registration from another mount isn't wiped. + if (guardsRef.current.get(guard.tabName) !== guard) { + return; + } + guardsRef.current.delete(guard.tabName); + }; + }, []); + + const handleTabPress = useCallback( + (navigation: NavigationProp, event: EventArg<'tabPress', true, undefined>) => { + if (isDiscardModalOpenRef.current) { + event.preventDefault(); + return; + } + const navState = navigation.getState(); + const currentRouteName = navState.routes.at(navState.index)?.name; + const guard = currentRouteName ? guardsRef.current.get(currentRouteName) : undefined; + if (!guard || !guard.getHasUnsavedChanges()) { + return; + } + const targetRoute = navState.routes.find((tabRoute) => tabRoute.key === event.target); + if (!targetRoute || targetRoute.name === currentRouteName) { + return; + } + event.preventDefault(); + isDiscardModalOpenRef.current = true; + showConfirmModal({ + ...getDiscardChangesModalConfig(translate), + shouldIgnoreBackHandlerDuringTransition: true, + }).then((result) => { + isDiscardModalOpenRef.current = false; + if (result.action !== ModalActions.CONFIRM) { + guard.onCancel?.(); + return; + } + // User confirmed: always jump to the target tab, even if onDiscard fails, rather than stranding them with no feedback. + Promise.resolve() + .then(() => guard.onDiscard()) + .catch((error: unknown) => { + Log.warn('[OnyxTabNavigator] Failed to run tab-switch onDiscard callback', {error}); + Growl.error(translate('common.genericErrorMessage')); + }) + .then(() => { + navigation.dispatch(TabActions.jumpTo(targetRoute.name)); + }); + }); + }, + [showConfirmModal, translate], + ); + /** * This is a TabBar wrapper component that includes the focus trap container element callback. * In `TabSelector.tsx` component, the callback prop to register focus trap container element is supported out of the box @@ -161,46 +228,60 @@ function OnyxTabNavigator({ } return ( - - { - const event = e as unknown as EventMapCore['state']; - const state = event.data.state; - const index = state.index; - const routeNames = state.routeNames; - if (isFirstMountRef.current) { - onTabSelect?.({index}); - isFirstMountRef.current = false; - } - const newSelectedTab = routeNames.at(index); - if (selectedTab === newSelectedTab) { - return; - } - if (newSelectedTab) { - Tab.setSelectedTab(id, newSelectedTab as TTabName); - } - onTabSelected(newSelectedTab as TTabName); - }, - ...(screenListeners ?? {}), - }} - screenOptions={{ - ...defaultScreenOptions, - swipeEnabled: false, - lazy: lazyLoadEnabled, - lazyPlaceholder: LazyPlaceholder, - }} - > - {children} - - + + + }) => { + const callerListeners = screenListeners ?? {}; + return { + ...callerListeners, + state: (e) => { + callerListeners.state?.(e); + const event = e as unknown as EventMapCore['state']; + const state = event.data.state; + const index = state.index; + const routeNames = state.routeNames; + if (isFirstMountRef.current) { + onTabSelect?.({index}); + isFirstMountRef.current = false; + } + const newSelectedTab = routeNames.at(index); + if (selectedTab === newSelectedTab) { + return; + } + if (newSelectedTab) { + Tab.setSelectedTab(id, newSelectedTab as TTabName); + } + onTabSelected(newSelectedTab as TTabName); + }, + tabPress: (e) => { + // Let a caller's own tabPress run first; if it blocked the switch, don't also run the guard. + callerListeners.tabPress?.(e); + if (e.defaultPrevented) { + return; + } + handleTabPress(navigation, e); + }, + }; + }} + screenOptions={{ + ...defaultScreenOptions, + swipeEnabled: false, + lazy: lazyLoadEnabled, + lazyPlaceholder: LazyPlaceholder, + }} + > + {children} + + + ); } diff --git a/src/libs/Navigation/TabSwitchGuardContext.tsx b/src/libs/Navigation/TabSwitchGuardContext.tsx new file mode 100644 index 000000000000..b8076000fe23 --- /dev/null +++ b/src/libs/Navigation/TabSwitchGuardContext.tsx @@ -0,0 +1,45 @@ +/** + * Lets a tab screen register discard callbacks that `OnyxTabNavigator` reads to show the "Discard changes?" + * modal when the user presses a different tab with unsaved changes. Guards are keyed by tab name, so each + * tab in a navigator can register its own without clobbering the others. + */ +import {createContext, useContext, useEffect, useRef} from 'react'; + +type TabSwitchGuard = { + tabName: string; + getHasUnsavedChanges: () => boolean; + onDiscard: () => void | Promise; + onCancel?: () => void; +}; + +type RegisterTabSwitchGuard = (guard: TabSwitchGuard) => () => void; + +const TabSwitchGuardContext = createContext(null); + +function useRegisterTabSwitchGuard(tabName: string, getHasUnsavedChanges: () => boolean, onDiscard?: () => void | Promise, onCancel?: () => void) { + const register = useContext(TabSwitchGuardContext); + // Refresh the closures every render so the stable guard registered below always calls the latest ones. + const guardCallbacksRef = useRef({getHasUnsavedChanges, onDiscard, onCancel}); + + useEffect(() => { + guardCallbacksRef.current = {getHasUnsavedChanges, onDiscard, onCancel}; + }); + + // Opt-in: only register when there's a tab provider and an onDiscard to run, so callers outside a tabbed flow are unaffected. + const canRegister = !!register && !!onDiscard; + useEffect(() => { + if (!register || !canRegister) { + return undefined; + } + return register({ + tabName, + getHasUnsavedChanges: () => guardCallbacksRef.current.getHasUnsavedChanges(), + onDiscard: () => guardCallbacksRef.current.onDiscard?.(), + onCancel: () => guardCallbacksRef.current.onCancel?.(), + }); + }, [register, tabName, canRegister]); +} + +export default TabSwitchGuardContext; +export {useRegisterTabSwitchGuard}; +export type {TabSwitchGuard, RegisterTabSwitchGuard}; diff --git a/src/libs/OdometerImageUtils.ts b/src/libs/OdometerImageUtils.ts index 940036384f22..0a8813e8aa12 100644 --- a/src/libs/OdometerImageUtils.ts +++ b/src/libs/OdometerImageUtils.ts @@ -9,18 +9,29 @@ function getOdometerImageName(image: FileObject | string | null | undefined): st return typeof image === 'string' ? (image.split('/').pop() ?? '') : (image?.name ?? ''); } +/** + * A re-mint-invariant identity for an odometer image, used in the discard-changes baseline diff. + * `uri` changes on every re-mint, but `name|size|lastModified` is preserved across the draft round-trip, so it + * stays stable on resume/reload while still changing for a real swap (`lastModified` disambiguates a different + * file that happens to share name + size). Native images are stable `file://` strings, so we use them directly + */ +function getOdometerImageIdentity(image: FileObject | string | null | undefined): string { + if (!image) { + return ''; + } + if (typeof image === 'string') { + return image; + } + return `${image.name ?? ''}|${image.size ?? ''}|${image.lastModified ?? ''}`; +} + function getOdometerImageType(image: FileObject | string | null | undefined): string | undefined { return typeof image === 'string' ? getMimeTypeFromUri(image) : (image?.type ?? getMimeTypeFromUri(image?.uri ?? '')); } /** * Revokes a blob URL previously associated with an odometer image, but only when - * the image has actually changed (i.e. the old URL differs from the new one). - * - * Skips revocation when: - * - The `URL` API is not available (non-browser environments / native) - * - The URI is not a blob: URL (e.g. file:// on native, https:// for uploaded images) - * - The old and new URIs are identical (image was not replaced) + * the image has actually changed (i.e. the old URL differs from the new one) */ function revokeOdometerImageUri(image: FileObject | string | null | undefined, nextImage?: FileObject | string | null): void { if (typeof URL === 'undefined') { @@ -38,5 +49,5 @@ function revokeOdometerImageUri(image: FileObject | string | null | undefined, n URL.revokeObjectURL(currentUri); } -export {getOdometerImageUri, getOdometerImageName, getOdometerImageType}; +export {getOdometerImageUri, getOdometerImageName, getOdometerImageType, getOdometerImageIdentity}; export default revokeOdometerImageUri; diff --git a/src/libs/actions/OdometerTransactionUtils.ts b/src/libs/actions/OdometerTransactionUtils.ts index 231c660f882c..604bd76dea0c 100644 --- a/src/libs/actions/OdometerTransactionUtils.ts +++ b/src/libs/actions/OdometerTransactionUtils.ts @@ -20,9 +20,6 @@ type SaveOdometerDraftParams = { endImage?: FileObject | string | null; }; -/** - * Set the odometer readings for a transaction - */ function setMoneyRequestOdometerReading(transactionID: string, startReading: number | null, endReading: number | null, isDraft: boolean) { Onyx.merge(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, { comment: { @@ -50,6 +47,8 @@ function setMoneyRequestOdometerImage(transaction: OnyxEntry, image name: file.name, type: file.type, size: file.size, + // Part of the re-mint-invariant identity (getOdometerImageIdentity) so a swap to a same-name/same-size file is still detected + ...(file.lastModified !== undefined && {lastModified: file.lastModified}), }; const transactionID = transaction?.transactionID; const existingImage = transaction?.comment?.[imageKey]; @@ -123,7 +122,7 @@ async function serializeOdometerDraftImage(image: FileObject | string | null | u } } -function deserializeOdometerDraftImage(image: string | undefined, transactionID: string, imageType: OdometerImageType): FileObject | string | undefined { +function deserializeOdometerDraftImage(image: string | undefined, transactionID: string, imageType: OdometerImageType, lastModified?: number): FileObject | string | undefined { if (!image) { return undefined; } @@ -139,6 +138,8 @@ function deserializeOdometerDraftImage(image: string | undefined, transactionID: name: file.name, type: file.type, size: file.size, + // Restore the original `lastModified` (base64ToFile resets it to Date.now()) so the re-minted image keeps its identity + ...(lastModified !== undefined && {lastModified}), }; } catch (error) { Log.warn('Failed to deserialize odometer draft image from base64', {error}); @@ -155,11 +156,16 @@ async function saveOdometerDraft({startReading, endReading, startImage, endImage return; } + const startImageLastModified = typeof startImage === 'object' ? startImage?.lastModified : undefined; + const endImageLastModified = typeof endImage === 'object' ? endImage?.lastModified : undefined; + const odometerDraft: OdometerDraft = { ...(startReading !== undefined && {odometerStartReading: startReading}), ...(endReading !== undefined && {odometerEndReading: endReading}), ...(serializedStartImage && {odometerStartImage: serializedStartImage}), ...(serializedEndImage && {odometerEndImage: serializedEndImage}), + ...(serializedStartImage && startImageLastModified !== undefined && {odometerStartImageLastModified: startImageLastModified}), + ...(serializedEndImage && endImageLastModified !== undefined && {odometerEndImageLastModified: endImageLastModified}), }; await Onyx.set(ONYXKEYS.ODOMETER_DRAFT, odometerDraft); @@ -177,8 +183,9 @@ function buildOdometerCommentFromDraft(transactionID: string, odometerDraft: Ony return; } - const startImage = deserializeOdometerDraftImage(odometerDraft.odometerStartImage, transactionID, CONST.IOU.ODOMETER_IMAGE_TYPE.START) ?? null; - const endImage = deserializeOdometerDraftImage(odometerDraft.odometerEndImage, transactionID, CONST.IOU.ODOMETER_IMAGE_TYPE.END) ?? null; + const startImage = + deserializeOdometerDraftImage(odometerDraft.odometerStartImage, transactionID, CONST.IOU.ODOMETER_IMAGE_TYPE.START, odometerDraft.odometerStartImageLastModified) ?? null; + const endImage = deserializeOdometerDraftImage(odometerDraft.odometerEndImage, transactionID, CONST.IOU.ODOMETER_IMAGE_TYPE.END, odometerDraft.odometerEndImageLastModified) ?? null; // Free the previous blob URL before the merge drops the reference - covers both replace and wipe-to-null; helper no-ops if non-blob or unchanged. revokeOdometerImageUri(currentComment?.odometerStartImage, startImage); @@ -207,20 +214,63 @@ function hydrateOdometerDraftIntoTransaction(transactionID: string, odometerDraf } /** - * True when an ODOMETER_DRAFT exists but the active transaction comment hasn't yet been hydrated - * from it. Used to defer baseline snapshots that would otherwise treat post-hydration values as - * unsaved changes. + * True when an ODOMETER_DRAFT exists but the transaction comment hasn't received it yet (hydration pending) - + * used to defer the baseline snapshot and gate the hydration effect */ function isOdometerDraftPendingHydration(odometerDraft: OnyxEntry, comment: Partial | undefined): boolean { if (!odometerDraft) { return false; } - return ( - (odometerDraft.odometerStartReading ?? null) !== (comment?.odometerStart ?? null) || - (odometerDraft.odometerEndReading ?? null) !== (comment?.odometerEnd ?? null) || - !!odometerDraft.odometerStartImage !== !!comment?.odometerStartImage || - !!odometerDraft.odometerEndImage !== !!comment?.odometerEndImage - ); + const startPending = odometerDraft.odometerStartReading !== undefined && (comment?.odometerStart ?? null) === null; + const endPending = odometerDraft.odometerEndReading !== undefined && (comment?.odometerEnd ?? null) === null; + const draftCarriesReadings = odometerDraft.odometerStartReading !== undefined || odometerDraft.odometerEndReading !== undefined; + const startImagePending = !draftCarriesReadings && !!odometerDraft.odometerStartImage && !comment?.odometerStartImage; + const endImagePending = !draftCarriesReadings && !!odometerDraft.odometerEndImage && !comment?.odometerEndImage; + return startPending || endPending || startImagePending || endImagePending; +} + +type OdometerUnsavedChangesState = { + /** Discard guard active: focused && !editing && no bypass/save/manual-backup flags. */ + isGuardActive: boolean; + + /** Whether the reading text inputs hold values not yet written to the transaction (mid-edit typing). */ + isUserTyping: boolean; + + /** The active save-for-later draft, if any. */ + odometerDraft: OnyxEntry; + + /** The comment of currentTransaction (split draft when editing a split, else the transaction) - used ONLY for the directional hydration check. */ + currentComment: Partial | undefined; + + /** Re-mint-invariant start/end image identities (getOdometerImageIdentity) from the live transaction comment. */ + transactionStartImageUri: string; + transactionEndImageUri: string; + + /** Re-mint-invariant start/end image identities captured in the on-mount baseline refs. */ + baselineStartImageUri: string; + baselineEndImageUri: string; + + /** Whether the readings differ from the on-mount baseline. */ + hasReadingChanges: boolean; +}; + +/** + * Decide whether the odometer screen has unsaved changes worth a "Discard changes?" prompt. Both checks ignore + * non-user noise: images diff on a re-mint-invariant identity (name|size|lastModified), and the typing guard skips mid-edit readings + */ +function getOdometerHasUnsavedChanges(state: OdometerUnsavedChangesState): boolean { + if (!state.isGuardActive) { + return false; + } + const hasImageChanges = state.transactionStartImageUri !== state.baselineStartImageUri || state.transactionEndImageUri !== state.baselineEndImageUri; + // Suppress only when the transaction still EQUALS the draft (baseline drift is not a real edit). + const draftReadingsMatchTransaction = + (state.odometerDraft?.odometerStartReading ?? null) === (state.currentComment?.odometerStart ?? null) && + (state.odometerDraft?.odometerEndReading ?? null) === (state.currentComment?.odometerEnd ?? null); + if (!state.isUserTyping && !hasImageChanges && !!state.odometerDraft && draftReadingsMatchTransaction && !isOdometerDraftPendingHydration(state.odometerDraft, state.currentComment)) { + return false; + } + return state.hasReadingChanges || hasImageChanges; } export { @@ -231,5 +281,7 @@ export { saveOdometerDraft, hydrateOdometerDraftIntoTransaction, isOdometerDraftPendingHydration, + getOdometerHasUnsavedChanges, }; +export type {OdometerUnsavedChangesState}; export default clearOdometerDraftTransactionState; diff --git a/src/pages/iou/request/DistanceRequestStartPage.tsx b/src/pages/iou/request/DistanceRequestStartPage.tsx index f8641c64524a..c9c8bab8dd90 100644 --- a/src/pages/iou/request/DistanceRequestStartPage.tsx +++ b/src/pages/iou/request/DistanceRequestStartPage.tsx @@ -1,9 +1,10 @@ -import React, {useEffect, useMemo, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {View} from 'react-native'; import FocusTrapContainerElement from '@components/FocusTrap/FocusTrapContainerElement'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import TabSelector from '@components/TabSelector/TabSelector'; +import type {TabSelectorProps} from '@components/TabSelector/types'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePolicyForTransaction from '@hooks/usePolicyForTransaction'; @@ -97,6 +98,8 @@ function DistanceRequestStartPage({ return [headerWithBackBtnContainerElement, tabBarContainerElement, activeTabContainerElement].filter((element) => !!element); }, [headerWithBackBtnContainerElement, tabBarContainerElement, activeTabContainerElement]); + const tabBar = useCallback((tabBarProps: TabSelectorProps) => , []); + return ( ; - /** True when editing an existing odometer expense — re-sync runs whenever transaction values change. */ + /** True when editing an existing odometer expense - re-sync runs whenever transaction values change. */ isEditing: boolean; /** The currently selected distance-request-type tab; used to detect "switched away from odometer". */ selectedTab: string | undefined; - /** True while the selected-tab Onyx key is still loading — suppresses the tab-reset effect. */ + /** True while the selected-tab Onyx key is still loading - suppresses the tab-reset effect. */ isLoadingSelectedTab: boolean; /** True once `useRestartOnOdometerImagesFailure` has finished verifying any blob URIs in the transaction (gates the initial-refs snapshot). */ hasVerifiedBlobs: boolean; - /** Save-for-later draft, if any — used to defer the initial-refs snapshot until the draft has hydrated into the transaction. */ + /** Save-for-later draft, if any - used to defer the initial-refs snapshot until the draft has hydrated into the transaction. */ odometerDraft: OnyxEntry; }; @@ -53,22 +53,22 @@ type UseOdometerReadingsStateResult = { /** Tracks the latest `endReading`. */ endReadingRef: React.RefObject; - /** The start-reading value captured at mount — used by discard-changes confirmation. */ + /** The start-reading value captured at mount - used by discard-changes confirmation. */ initialStartReadingRef: React.RefObject; - /** The end-reading value captured at mount — used by discard-changes confirmation. */ + /** The end-reading value captured at mount - used by discard-changes confirmation. */ initialEndReadingRef: React.RefObject; - /** The start-odometer image captured at mount — used by discard-changes confirmation. */ + /** The start-odometer image captured at mount - used by discard-changes confirmation. */ initialStartImageRef: React.RefObject; - /** The end-odometer image captured at mount — used by discard-changes confirmation. */ + /** The end-odometer image captured at mount - used by discard-changes confirmation. */ initialEndImageRef: React.RefObject; /** Resets local form state and the initial refs back to their defaults. */ resetOdometerLocalState: () => void; - /** True once the initial baseline has been captured — gates discard-changes detection. */ + /** True once the initial baseline has been captured - gates discard-changes detection. */ hasInitializedRefs: React.RefObject; }; @@ -126,8 +126,7 @@ function useOdometerReadingsState({ prevSelectedTabRef.current = selectedTab; }, [selectedTab, isLoadingSelectedTab]); - // Initialize initial values refs on mount for DiscardChangesConfirmation - // These should never be updated after mount - they represent the "baseline" state + // Snapshot the baseline refs once on mount for the discard-changes diff; never update them after. useEffect(() => { if (hasInitializedRefs.current) { return; @@ -137,13 +136,11 @@ function useOdometerReadingsState({ if (!isEditing && !isOdometerTransaction) { return; } - // Wait for blob verification — otherwise Cmd+R would snapshot a stale blob URI before - // useRestartOnOdometerImagesFailure swaps in a fresh one, and the diff would look like an edit. + // Wait for blob verification, else Cmd+R snapshots a stale blob URI that later gets swapped, and the diff looks like an edit. if (!hasVerifiedBlobs) { return; } - // Wait for the save-for-later draft to land in the transaction; otherwise post-hydration - // values would later look like unsaved changes against this baseline. + // Wait for the save-for-later draft to land in the transaction, else hydrated values later look like unsaved changes. if (isOdometerDraftPendingHydration(odometerDraft, currentTransaction?.comment)) { return; } @@ -151,12 +148,14 @@ function useOdometerReadingsState({ const currentEnd = currentTransaction?.comment?.odometerEnd; const startValue = currentStart !== null && currentStart !== undefined ? currentStart.toString() : ''; const endValue = currentEnd !== null && currentEnd !== undefined ? currentEnd.toString() : ''; + // Snapshot the transaction as the baseline; the discard guard diffs current-vs-baseline, no edit tracking needed. initialStartReadingRef.current = startValue; initialEndReadingRef.current = endValue; initialStartImageRef.current = currentTransaction?.comment?.odometerStartImage; initialEndImageRef.current = currentTransaction?.comment?.odometerEndImage; hasInitializedRefs.current = true; }, [ + currentTransaction?.transactionID, currentTransaction?.iouRequestType, currentTransaction?.comment, currentTransaction?.comment?.odometerStart, @@ -168,16 +167,11 @@ function useOdometerReadingsState({ odometerDraft, ]); - // Initialize values from transaction when editing or when transaction has data (but not when switching tabs) - // This updates the current state, but NOT the initial refs (those are set only once on mount) + // Sync current state (not the mount refs) from the transaction useEffect(() => { const currentStart = currentTransaction?.comment?.odometerStart; const currentEnd = currentTransaction?.comment?.odometerEnd; - // Only initialize if: - // 1. We haven't initialized yet AND transaction has data, OR - // 2. We're editing and transaction has data (to load existing values), OR - // 3. Transaction has data but local state is empty (user navigated back from another page) const hasTransactionData = (currentStart !== null && currentStart !== undefined) || (currentEnd !== null && currentEnd !== undefined); const hasLocalState = startReadingRef.current || endReadingRef.current; diff --git a/src/pages/iou/request/step/IOURequestStepDistance/odometerResync.ts b/src/pages/iou/request/step/IOURequestStepDistance/odometerResync.ts new file mode 100644 index 000000000000..4de5385efcc4 --- /dev/null +++ b/src/pages/iou/request/step/IOURequestStepDistance/odometerResync.ts @@ -0,0 +1,78 @@ +/** + * Pure decision helpers for the "resync local odometer readings from the transaction" effect in + * `IOURequestStepDistanceOdometer` + * + * That effect syncs local readings from the transaction without clobbering in-progress typing, and slides the + * discard-changes baseline when the transaction changes elsewhere (e.g. an edit saved from the confirmation step). + * Keeping the branching here as pure predicates lets it be unit-tested apart from the effect's ref mutations + */ + +type OdometerResyncState = { + /** Transaction readings normalized to strings ('' when the transaction has no reading) */ + transactionStartValue: string; + transactionEndValue: string; + + /** Readings currently held in local component state */ + localStartValue: string; + localEndValue: string; + + /** Resolved image URIs from the transaction and from the on-mount baseline */ + transactionStartImageUri: string; + transactionEndImageUri: string; + baselineStartImageUri: string; + baselineEndImageUri: string; + + /** Whether the transaction carries any odometer reading */ + hasTransactionData: boolean; + + /** Whether local state already holds a reading */ + hasLocalState: boolean; + + /** Whether the on-mount initialization has already run */ + hasInitialized: boolean; + + /** Whether the user has typed changes that aren't written to the transaction yet */ + isUserTyping: boolean; + + /** Whether the screen is in edit mode */ + isEditing: boolean; +}; + +/** + * An "external resync": the transaction changed elsewhere (e.g. an edit saved from the confirmation step) while + * nothing is being typed here, so it becomes the new baseline. The typing guard avoids clobbering in-progress keystrokes. + */ +function isExternalOdometerResync(state: OdometerResyncState): boolean { + if (!state.hasTransactionData || !state.hasInitialized || state.isUserTyping) { + return false; + } + return ( + state.transactionStartValue !== state.localStartValue || + state.transactionEndValue !== state.localEndValue || + state.transactionStartImageUri !== state.baselineStartImageUri || + state.transactionEndImageUri !== state.baselineEndImageUri + ); +} + +/** + * Whether the local readings should be (re)initialized from the transaction: + * 1. first mount with transaction data, or + * 2. editing with transaction data and no local state yet, or + * 3. transaction has data but local state is empty (navigated back from another page), or + * 4. an external resync arrived + * + * Branches 2-4 carry a `!isUserTyping` guard (inside `isExternalOdometerResync` for branch 4) so they don't + * re-hydrate readings the user intentionally cleared - which leaves local state empty without writing the + * transaction, looking identical to "navigated back". Branch 1 is unguarded: a fresh mount must hydrate the baseline. + */ +function shouldInitializeOdometerFromTransaction(state: OdometerResyncState, isExternalResync: boolean): boolean { + return ( + (!state.hasInitialized && state.hasTransactionData) || + (state.isEditing && state.hasTransactionData && !state.hasLocalState && !state.isUserTyping) || + (state.hasTransactionData && !state.hasLocalState && state.hasInitialized && !state.isUserTyping) || + isExternalResync + ); +} + +export {isExternalOdometerResync, shouldInitializeOdometerFromTransaction}; +export type {OdometerResyncState}; diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index 0fb1ab5ba4c3..a69152333d1c 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -34,7 +34,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {setMoneyRequestDistance} from '@libs/actions/IOU/MoneyRequest'; import {setDraftSplitTransaction} from '@libs/actions/IOU/Split'; import {updateMoneyRequestDistance} from '@libs/actions/IOU/UpdateMoneyRequest'; -import {clearOdometerDraft, saveOdometerDraft, setMoneyRequestOdometerReading} from '@libs/actions/OdometerTransactionUtils'; +import {clearOdometerDraft, getOdometerHasUnsavedChanges, removeMoneyRequestOdometerImage, saveOdometerDraft, setMoneyRequestOdometerReading} from '@libs/actions/OdometerTransactionUtils'; import {restoreOriginalTransactionFromBackupWithImageCleanup} from '@libs/actions/TransactionEdit'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; @@ -42,7 +42,7 @@ import {shouldUseTransactionDraft} from '@libs/IOUUtils'; import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import {roundToTwoDecimalPlaces} from '@libs/NumberUtils'; -import {getOdometerImageUri} from '@libs/OdometerImageUtils'; +import {getOdometerImageIdentity} from '@libs/OdometerImageUtils'; import {isPolicyExpenseChat as isPolicyExpenseChatUtils} from '@libs/ReportUtils'; import shouldUseDefaultExpensePolicyUtil from '@libs/shouldUseDefaultExpensePolicy'; import {startSpan} from '@libs/telemetry/activeSpans'; @@ -58,6 +58,8 @@ import useOdometerImageHandlers from './IOURequestStepDistance/hooks/useOdometer import useOdometerNavigation from './IOURequestStepDistance/hooks/useOdometerNavigation'; import useOdometerReadingsState from './IOURequestStepDistance/hooks/useOdometerReadingsState'; import useOdometerTransactionBackup from './IOURequestStepDistance/hooks/useOdometerTransactionBackup'; +import type {OdometerResyncState} from './IOURequestStepDistance/odometerResync'; +import {isExternalOdometerResync, shouldInitializeOdometerFromTransaction} from './IOURequestStepDistance/odometerResync'; import StepScreenWrapper from './StepScreenWrapper'; import withFullTransactionOrNotFound from './withFullTransactionOrNotFound'; import type {WithWritableReportOrNotFoundProps} from './withWritableReportOrNotFound'; @@ -197,6 +199,63 @@ function IOURequestStepDistanceOdometer({ backupHandledManuallyRef: backupHandledManually, }); + // Initialize values from transaction when editing or when transaction has data (but not when switching tabs) + // This updates the current state, but NOT the initial refs (those are set only once on mount) + useEffect(() => { + const currentStart = currentTransaction?.comment?.odometerStart; + const currentEnd = currentTransaction?.comment?.odometerEnd; + const startValue = currentStart !== null && currentStart !== undefined ? currentStart.toString() : ''; + const endValue = currentEnd !== null && currentEnd !== undefined ? currentEnd.toString() : ''; + + const resyncState: OdometerResyncState = { + transactionStartValue: startValue, + transactionEndValue: endValue, + localStartValue: startReadingRef.current, + localEndValue: endReadingRef.current, + transactionStartImageUri: getOdometerImageIdentity(currentTransaction?.comment?.odometerStartImage), + transactionEndImageUri: getOdometerImageIdentity(currentTransaction?.comment?.odometerEndImage), + baselineStartImageUri: getOdometerImageIdentity(initialStartImageRef.current), + baselineEndImageUri: getOdometerImageIdentity(initialEndImageRef.current), + hasTransactionData: (currentStart !== null && currentStart !== undefined) || (currentEnd !== null && currentEnd !== undefined), + hasLocalState: !!(startReadingRef.current || endReadingRef.current), + hasInitialized: hasInitializedRefs.current, + isUserTyping: userHasUnsavedTypingRef.current, + isEditing, + }; + + const isExternalResync = isExternalOdometerResync(resyncState); + + // Sync the local readings up from the transaction (but never clobber in-progress typing) + if (shouldInitializeOdometerFromTransaction(resyncState, isExternalResync) && (startValue || endValue)) { + setStartReading(startValue); + setEndReading(endValue); + startReadingRef.current = startValue; + endReadingRef.current = endValue; + } + + // Slide the readings baseline on a non-user change (draft hydration, external save) so leaving doesn't flag it as unsaved. + // Images aren't slid: their re-mint-invariant diff already ignores re-mints, and sliding would absorb a genuine swap. + if (isExternalResync) { + initialStartReadingRef.current = startValue; + initialEndReadingRef.current = endValue; + } + }, [ + currentTransaction?.comment?.odometerStart, + currentTransaction?.comment?.odometerEnd, + currentTransaction?.comment?.odometerStartImage, + currentTransaction?.comment?.odometerEndImage, + isEditing, + setStartReading, + setEndReading, + startReadingRef, + endReadingRef, + initialStartReadingRef, + initialEndReadingRef, + initialStartImageRef, + initialEndImageRef, + hasInitializedRefs, + ]); + const navigateToNextStep = useOdometerNavigation({ iouType, action, @@ -268,11 +327,7 @@ function IOURequestStepDistanceOdometer({ return shouldShowSave ? translate('common.save') : translate('common.next'); })(); - // Per-keystroke validation: enforce format constraints and cap the max value. - // The max-value check allows edits that *reduce* the value (e.g. backspacing - // a legacy over-max reading) but rejects keystrokes that would increase - // beyond ODOMETER_MAX_VALUE. Submit-time validation in handleNext is the - // final safety net. + // Per-keystroke validation: enforce format constraints and cap the max value const isOdometerInputValid = (text: string, previousText: string): boolean => { if (!text) { return true; @@ -288,7 +343,7 @@ function IOURequestStepDistanceOdometer({ const value = parseFloat(stripped); // Allow edits that reduce the value (e.g. backspacing a legacy over-max reading), - // but reject keystrokes that would increase beyond the max. + // but reject keystrokes that would increase beyond the max if (!Number.isNaN(value) && value > CONST.IOU.ODOMETER_MAX_VALUE) { const previousValue = parseFloat(DistanceRequestUtils.normalizeOdometerText(previousText, fromLocaleDigit)); if (Number.isNaN(previousValue) || value >= previousValue) { @@ -424,7 +479,7 @@ function IOURequestStepDistanceOdometer({ } if (shouldSkipConfirmation) { - // Skip-confirmation submit navigates away and should never be blocked by discard modal. + // Skip-confirmation submit navigates away and should never be blocked by discard modal shouldBypassDiscardConfirmationRef.current = true; } @@ -483,6 +538,57 @@ function IOURequestStepDistanceOdometer({ navigateToNextPage(); }; + const getHasUnsavedChanges = () => + getOdometerHasUnsavedChanges({ + isGuardActive: + hasInitializedRefs.current && + isFocused && + !isEditing && + !shouldBypassDiscardConfirmationRef.current && + !didSaveEditingConfirmationRef.current && + !backupHandledManually.current, + isUserTyping: userHasUnsavedTypingRef.current, + odometerDraft, + currentComment: currentTransaction?.comment, + transactionStartImageUri: getOdometerImageIdentity(transaction?.comment?.odometerStartImage), + transactionEndImageUri: getOdometerImageIdentity(transaction?.comment?.odometerEndImage), + baselineStartImageUri: getOdometerImageIdentity(initialStartImageRef.current), + baselineEndImageUri: getOdometerImageIdentity(initialEndImageRef.current), + hasReadingChanges: startReadingRef.current !== initialStartReadingRef.current || endReadingRef.current !== initialEndReadingRef.current, + }); + + const handleTabSwitchDiscard = async () => { + if (isEditingConfirmation) { + await restoreOriginalTransactionFromBackupWithImageCleanup(transactionID, isTransactionDraft); + backupHandledManually.current = true; + } else { + setMoneyRequestOdometerReading(transactionID, null, null, isTransactionDraft); + removeMoneyRequestOdometerImage(transaction, CONST.IOU.ODOMETER_IMAGE_TYPE.START, isTransactionDraft, true); + removeMoneyRequestOdometerImage(transaction, CONST.IOU.ODOMETER_IMAGE_TYPE.END, isTransactionDraft, true); + } + resetOdometerLocalState(); + setFormError(''); + }; + + const restoreLastInputFocus = useCallback(() => { + InteractionManager.runAfterInteractions(() => { + lastFocusedInputRef.current?.focus(); + }); + }, []); + + useDiscardChangesConfirmation({ + getHasUnsavedChanges, + onCancel: restoreLastInputFocus, + onConfirm: isEditingConfirmation + ? async () => { + await restoreOriginalTransactionFromBackupWithImageCleanup(transactionID, isTransactionDraft); + backupHandledManually.current = true; + } + : undefined, + onTabSwitchDiscard: handleTabSwitchDiscard, + onVisibilityChange: setIsDiscardModalVisible, + }); + const handleSaveForLater = useCallback(async () => { shouldBypassDiscardConfirmationRef.current = true; @@ -516,38 +622,6 @@ function IOURequestStepDistanceOdometer({ Navigation.closeRHPFlow(); }, [fromLocaleDigit, startReading, endReading, odometerStartImage, odometerEndImage, translate, setFormError]); - useDiscardChangesConfirmation({ - onCancel: () => { - InteractionManager.runAfterInteractions(() => { - lastFocusedInputRef.current?.focus(); - }); - }, - getHasUnsavedChanges: () => { - if ( - !isFocused || - isEditing || - shouldBypassDiscardConfirmationRef.current || - didSaveEditingConfirmationRef.current || - !hasInitializedRefs.current || - backupHandledManually.current - ) { - return false; - } - const hasReadingChanges = startReadingRef.current !== initialStartReadingRef.current || endReadingRef.current !== initialEndReadingRef.current; - const hasImageChanges = - getOdometerImageUri(transaction?.comment?.odometerStartImage) !== getOdometerImageUri(initialStartImageRef.current) || - getOdometerImageUri(transaction?.comment?.odometerEndImage) !== getOdometerImageUri(initialEndImageRef.current); - return hasReadingChanges || hasImageChanges; - }, - onConfirm: isEditingConfirmation - ? async () => { - await restoreOriginalTransactionFromBackupWithImageCleanup(transactionID, isTransactionDraft); - backupHandledManually.current = true; - } - : undefined, - onVisibilityChange: setIsDiscardModalVisible, - }); - return ( & {getAsFile?: () => File | null}; +type FileObject = Partial & {getAsFile?: () => File | null; lastModified?: number}; export type {FileObject, ImagePickerResponse}; diff --git a/tests/actions/OdometerTransactionUtilsTest.ts b/tests/actions/OdometerTransactionUtilsTest.ts index 6bd75c60e426..7b77708077cb 100644 --- a/tests/actions/OdometerTransactionUtilsTest.ts +++ b/tests/actions/OdometerTransactionUtilsTest.ts @@ -2,8 +2,16 @@ import Onyx from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; import '@libs/actions/IOU/MoneyRequest'; -import {removeMoneyRequestOdometerImage, setMoneyRequestOdometerImage} from '@libs/actions/OdometerTransactionUtils'; +import { + getOdometerHasUnsavedChanges, + isOdometerDraftPendingHydration, + removeMoneyRequestOdometerImage, + saveOdometerDraft, + setMoneyRequestOdometerImage, +} from '@libs/actions/OdometerTransactionUtils'; +import type {OdometerUnsavedChangesState} from '@libs/actions/OdometerTransactionUtils'; import initOnyxDerivedValues from '@libs/actions/OnyxDerived'; +import {getOdometerImageIdentity} from '@libs/OdometerImageUtils'; import type * as PolicyUtils from '@libs/PolicyUtils'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; @@ -56,7 +64,7 @@ jest.mock('@libs/Navigation/helpers/isSearchTopmostFullScreenRoute', () => jest. jest.mock('@libs/Navigation/helpers/isReportTopmostSplitNavigator', () => jest.fn()); // In production, requestMoney defers its API.write() call until the target screen's // content lays out (or a safety timeout fires). In tests there is no target component -// to flush the deferred write, so we bypass the deferral by executing the callback immediately. +// to flush the deferred write, so we bypass the deferral by executing the callback immediately jest.mock('@libs/deferredLayoutWrite', () => ({ registerDeferredWrite: (_key: string, callback: () => void) => callback(), flushDeferredWrite: jest.fn(), @@ -139,72 +147,314 @@ describe('actions/OdometerTransactionUtils', () => { afterEach(() => { jest.unmock('@libs/OdometerImageUtils'); }); - it('should set odometer start image on a draft transaction', async () => { + it('should set odometer start image on a draft transaction', () => { const transaction = createRandomTransaction(1); const transactionID = transaction.transactionID; const file = {uri: 'image.uri', name: 'image.jpg', type: 'image/jpeg', size: 1234}; const imageType = CONST.IOU.ODOMETER_IMAGE_TYPE.START; - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, transaction); - - setMoneyRequestOdometerImage(transaction, imageType, file, true, false); - await waitForBatchedUpdates(); - - const draftTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`); - expect(draftTransaction?.comment?.odometerStartImage).toEqual(file); + return Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, transaction) + .then(() => { + setMoneyRequestOdometerImage(transaction, imageType, file, true, false); + return waitForBatchedUpdates(); + }) + .then(() => getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`)) + .then((draftTransaction) => { + expect(draftTransaction?.comment?.odometerStartImage).toEqual(file); + }); }); - it('should set odometer end image on a non-draft transaction', async () => { + it('should set odometer end image on a non-draft transaction', () => { const transaction = createRandomTransaction(1); const transactionID = transaction.transactionID; const file = {uri: 'image.uri', name: 'image.jpg', type: 'image/jpeg', size: 1234}; const imageType = CONST.IOU.ODOMETER_IMAGE_TYPE.END; - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, transaction); - - setMoneyRequestOdometerImage(transaction, imageType, file, false, false); - await waitForBatchedUpdates(); - - const updatedTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); - expect(updatedTransaction?.comment?.odometerEndImage).toEqual(file); + return Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, transaction) + .then(() => { + setMoneyRequestOdometerImage(transaction, imageType, file, false, false); + return waitForBatchedUpdates(); + }) + .then(() => getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`)) + .then((updatedTransaction) => { + expect(updatedTransaction?.comment?.odometerEndImage).toEqual(file); + }); }); - it('should remove odometer start image from a draft transaction', async () => { - const transaction = { - ...createRandomTransaction(1), + it('should remove odometer start image from a draft transaction', () => { + const base = createRandomTransaction(1); + const transaction: Transaction = { + ...base, comment: { + ...base.comment, odometerStartImage: {uri: 'image.uri'}, }, - } as Transaction; + }; const transactionID = transaction.transactionID; const imageType = CONST.IOU.ODOMETER_IMAGE_TYPE.START; - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, transaction); - - removeMoneyRequestOdometerImage(transaction, imageType, true, false); - await waitForBatchedUpdates(); - - const draftTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`); - expect(draftTransaction?.comment?.odometerStartImage).toBeUndefined(); + return Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, transaction) + .then(() => { + removeMoneyRequestOdometerImage(transaction, imageType, true, false); + return waitForBatchedUpdates(); + }) + .then(() => getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`)) + .then((draftTransaction) => { + expect(draftTransaction?.comment?.odometerStartImage).toBeUndefined(); + }); }); - it('should remove odometer end image from a non-draft transaction', async () => { - const transaction = { - ...createRandomTransaction(1), + it('should remove odometer end image from a non-draft transaction', () => { + const base = createRandomTransaction(1); + const transaction: Transaction = { + ...base, comment: { + ...base.comment, odometerEndImage: {uri: 'image.uri'}, }, - } as Transaction; + }; const transactionID = transaction.transactionID; const imageType = CONST.IOU.ODOMETER_IMAGE_TYPE.END; - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, transaction); + return Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, transaction) + .then(() => { + removeMoneyRequestOdometerImage(transaction, imageType, false, false); + return waitForBatchedUpdates(); + }) + .then(() => getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`)) + .then((updatedTransaction) => { + expect(updatedTransaction?.comment?.odometerEndImage).toBeUndefined(); + }); + }); + }); + + describe('saveOdometerDraft', () => { + afterEach(() => Onyx.set(ONYXKEYS.ODOMETER_DRAFT, null)); + + // The image's lastModified must be persisted alongside the serialized image so the re-minted image can + // restore it and keep its identity stable across the draft round-trip. + it('persists the image lastModified into the draft', () => { + const startImage = {uri: 'blob:start', name: 'a.jpg', type: 'image/jpeg', size: 1234, lastModified: 1700000000000}; + + return saveOdometerDraft({startImage}) + .then(() => getOnyxValue(ONYXKEYS.ODOMETER_DRAFT)) + .then((odometerDraft) => { + expect(odometerDraft?.odometerStartImageLastModified).toBe(1700000000000); + }); + }); + + // A native/string image (or one without lastModified) must not write a stray lastModified field. + it('omits lastModified when the image does not carry one', () => { + const startImage = {uri: 'blob:start', name: 'a.jpg', type: 'image/jpeg', size: 1234}; + + return saveOdometerDraft({startImage}) + .then(() => getOnyxValue(ONYXKEYS.ODOMETER_DRAFT)) + .then((odometerDraft) => { + expect(odometerDraft?.odometerStartImageLastModified).toBeUndefined(); + }); + }); + }); + + describe('isOdometerDraftPendingHydration (directional)', () => { + it('returns false when there is no draft (no save-for-later flow)', () => { + expect(isOdometerDraftPendingHydration(undefined, {odometerStart: 120, odometerEnd: 300})).toBe(false); + }); + + it('is pending while the transaction is still MISSING readings the draft carries (the hydration window)', () => { + const draft = {odometerStartReading: 100, odometerEndReading: 250}; + expect(isOdometerDraftPendingHydration(draft, undefined)).toBe(true); + expect(isOdometerDraftPendingHydration(draft, {})).toBe(true); + // Partially hydrated (start landed, end not yet) is still pending. + expect(isOdometerDraftPendingHydration(draft, {odometerStart: 100})).toBe(true); + }); + + // After Next the transaction holds new readings but the draft still holds the old ones (Next doesn't write + // the draft). A staler-than-transaction draft must read as NOT pending, else the baseline defers forever + it('is NOT pending once the transaction holds the readings, even if the draft is now staler', () => { + const staleDraft = {odometerStartReading: 100, odometerEndReading: 250}; + const newerComment = {odometerStart: 120, odometerEnd: 300}; + expect(isOdometerDraftPendingHydration(staleDraft, newerComment)).toBe(false); + }); + + // A readings-bearing draft hydrates atomically, so once the transaction holds the readings a still-missing + // image is a deliberate user removal, NOT pending hydration (else the baseline would defer forever) + it('is NOT pending when a readings-bearing draft is hydrated but its image was removed from the transaction', () => { + const draft = {odometerStartReading: 100, odometerEndReading: 250, odometerStartImage: 'data:image/png;base64,xxx'}; + expect(isOdometerDraftPendingHydration(draft, {odometerStart: 100, odometerEnd: 250})).toBe(false); + }); + + // A readings + image draft on a fresh/wiped transaction is still pending via the readings (the image + // rides along in the same atomic hydration merge), so it must hydrate after a refresh + it('is pending while a readings+image draft has not hydrated into an empty transaction', () => { + const draft = {odometerStartReading: 100, odometerEndReading: 250, odometerStartImage: 'data:image/png;base64,xxx'}; + expect(isOdometerDraftPendingHydration(draft, {})).toBe(true); + }); + + // An image-only draft (no readings) has no reading to mark the hydration window, so its image presence is + // what signals "still pending" - required so an image-only save-for-later restores after a page refresh + it('is pending while an image-only draft is missing from the transaction', () => { + const draft = {odometerStartImage: 'data:image/png;base64,xxx'}; + expect(isOdometerDraftPendingHydration(draft, {})).toBe(true); + expect(isOdometerDraftPendingHydration(draft, {odometerEndImage: {uri: 'other.uri'}})).toBe(true); + }); + + it('is NOT pending once an image-only draft has hydrated into the transaction', () => { + const draft = {odometerStartImage: 'data:image/png;base64,xxx'}; + expect(isOdometerDraftPendingHydration(draft, {odometerStartImage: {uri: 'image.uri'}})).toBe(false); + }); + + it('is NOT pending when the transaction already has an image the draft lacks (transaction is ahead)', () => { + const draft = {odometerStartReading: 100, odometerEndReading: 250}; + const comment = {odometerStart: 100, odometerEnd: 250, odometerStartImage: {uri: 'image.uri'}}; + expect(isOdometerDraftPendingHydration(draft, comment)).toBe(false); + }); + }); + + describe('getOdometerHasUnsavedChanges', () => { + // Guard active, not mid-typing, a save-for-later readings draft that has fully hydrated into the + // transaction, image URIs matching their baseline, no reading change => nothing unsaved + const STEADY: OdometerUnsavedChangesState = { + isGuardActive: true, + isUserTyping: false, + odometerDraft: {odometerStartReading: 100, odometerEndReading: 250}, + currentComment: {odometerStart: 100, odometerEnd: 250}, + transactionStartImageUri: '', + transactionEndImageUri: '', + baselineStartImageUri: '', + baselineEndImageUri: '', + hasReadingChanges: false, + }; - removeMoneyRequestOdometerImage(transaction, imageType, false, false); - await waitForBatchedUpdates(); + const buildState = (overrides: Partial = {}): OdometerUnsavedChangesState => ({...STEADY, ...overrides}); + + it('returns false in a steady state (draft hydrated, nothing changed)', () => { + expect(getOdometerHasUnsavedChanges(STEADY)).toBe(false); + }); + + it('returns false when the discard guard is inactive, even if everything else looks changed', () => { + expect( + getOdometerHasUnsavedChanges( + buildState({ + isGuardActive: false, + isUserTyping: true, + hasReadingChanges: true, + transactionStartImageUri: 'b.jpg', + baselineStartImageUri: 'a.jpg', + }), + ), + ).toBe(false); + }); + + // The user ADDS an image the draft lacked, so the transaction is ahead of the draft. The presence-only + // isOdometerDraftPendingHydration reports "not pending", so without the image guard the modal is suppressed + it('detects an added image when the draft carried none (the false-negative being fixed)', () => { + expect(getOdometerHasUnsavedChanges(buildState({transactionStartImageUri: 'new.jpg', baselineStartImageUri: ''}))).toBe(true); + }); + + // Mirror regression guard: a readings-bearing draft whose image is absent from the transaction (removed) + // has no transaction-vs-baseline image diff, so it must STAY silent (the previously-fixed false-positive) + it('stays silent when the image was removed (baseline and transaction both have no image)', () => { + expect( + getOdometerHasUnsavedChanges( + buildState({ + odometerDraft: {odometerStartReading: 100, odometerEndReading: 250, odometerStartImage: 'data:image/png;base64,xxx'}, + transactionStartImageUri: '', + baselineStartImageUri: '', + }), + ), + ).toBe(false); + }); + + it('detects a swapped image (content differs from the baseline)', () => { + expect(getOdometerHasUnsavedChanges(buildState({transactionStartImageUri: 'b.jpg', baselineStartImageUri: 'a.jpg'}))).toBe(true); + }); + + it('detects a reading change while the user is actively typing', () => { + expect(getOdometerHasUnsavedChanges(buildState({isUserTyping: true, hasReadingChanges: true}))).toBe(true); + }); + + // Proves we did NOT gate the clause on !hasReadingChanges: a readings diff that is NOT from live typing + // (e.g. baseline drift after an external resync) against a hydrated draft must stay suppressed + it('suppresses a non-typing reading diff against a hydrated draft (readings false-positive guard)', () => { + expect(getOdometerHasUnsavedChanges(buildState({isUserTyping: false, hasReadingChanges: true}))).toBe(false); + }); + + // User changes a reading + Next with a save-for-later draft, so the transaction is AHEAD of the draft. The + // directional check reports "not pending", but the change is genuinely unsaved -> leaving MUST still prompt + it('detects a reading change when the transaction is AHEAD of the draft (changed + Next + back)', () => { + expect( + getOdometerHasUnsavedChanges( + buildState({ + odometerDraft: {odometerStartReading: 100, odometerEndReading: 300}, + currentComment: {odometerStart: 100, odometerEnd: 400}, + hasReadingChanges: true, + }), + ), + ).toBe(true); + }); + + it('detects when both readings and an image changed', () => { + expect( + getOdometerHasUnsavedChanges( + buildState({ + hasReadingChanges: true, + transactionStartImageUri: 'b.jpg', + baselineStartImageUri: 'a.jpg', + }), + ), + ).toBe(true); + }); + + // No save-for-later draft => the draft clause never applies, so a genuine image change must prompt + it('detects an image change when there is no draft at all', () => { + expect(getOdometerHasUnsavedChanges(buildState({odometerDraft: undefined, transactionStartImageUri: 'b.jpg', baselineStartImageUri: ''}))).toBe(true); + }); + + it('stays silent when a saved-for-later draft is re-entered with no change', () => { + expect(getOdometerHasUnsavedChanges(buildState())).toBe(false); + }); + }); + + describe('getOdometerImageIdentity (re-mint-invariant)', () => { + it('is empty for a missing image', () => { + expect(getOdometerImageIdentity(undefined)).toBe(''); + expect(getOdometerImageIdentity(null)).toBe(''); + }); + + // A non-user blob re-mint (base64 -> blob on resume/reload) keeps name + size and only changes the uri, so + // the identity must stay equal - this is what lets the discard guard stay silent on a re-mint + it('is invariant under a blob re-mint (same name + size, new uri)', () => { + const original = {uri: 'blob:abc', name: 'a.jpg', type: 'image/jpeg', size: 1234}; + const reminted = {uri: 'blob:xyz', name: 'a.jpg', type: 'image/jpeg', size: 1234}; + expect(getOdometerImageIdentity(reminted)).toBe(getOdometerImageIdentity(original)); + }); + + // A genuine user swap is a different file (different name and/or size), so the identity must change - this is + // what lets the discard guard fire on an add/swap/remove + it('changes when the user swaps to a different file (different name and size)', () => { + const a = {uri: 'blob:abc', name: 'a.jpg', type: 'image/jpeg', size: 1234}; + const b = {uri: 'blob:xyz', name: 'b.jpg', type: 'image/jpeg', size: 5678}; + expect(getOdometerImageIdentity(b)).not.toBe(getOdometerImageIdentity(a)); + }); + + // Two genuinely different files can share the same filename AND byte size; lastModified disambiguates them so + // the discard guard still fires on the swap (the collision the name|size-only identity missed). + it('changes when the user swaps to a same-name/same-size file with a different lastModified', () => { + const a = {uri: 'blob:abc', name: 'a.jpg', type: 'image/jpeg', size: 1234, lastModified: 1000}; + const b = {uri: 'blob:xyz', name: 'a.jpg', type: 'image/jpeg', size: 1234, lastModified: 2000}; + expect(getOdometerImageIdentity(b)).not.toBe(getOdometerImageIdentity(a)); + }); + + // lastModified is preserved across the draft round-trip, so a re-mint that keeps name + size + lastModified + // (only the uri changes) must stay invariant - the discard guard must not fire on a resume/reload. + it('is invariant under a re-mint that keeps name + size + lastModified (new uri only)', () => { + const original = {uri: 'blob:abc', name: 'a.jpg', type: 'image/jpeg', size: 1234, lastModified: 1000}; + const reminted = {uri: 'blob:xyz', name: 'a.jpg', type: 'image/jpeg', size: 1234, lastModified: 1000}; + expect(getOdometerImageIdentity(reminted)).toBe(getOdometerImageIdentity(original)); + }); - const updatedTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); - expect(updatedTransaction?.comment?.odometerEndImage).toBeUndefined(); + it('uses the uri string directly for native images', () => { + expect(getOdometerImageIdentity('file:///path/to/a.jpg')).toBe('file:///path/to/a.jpg'); }); }); }); diff --git a/tests/actions/TransactionEditTest.ts b/tests/actions/TransactionEditTest.ts index b54cd376f636..c639609bf7f6 100644 --- a/tests/actions/TransactionEditTest.ts +++ b/tests/actions/TransactionEditTest.ts @@ -1,6 +1,11 @@ import Onyx from 'react-native-onyx'; import OnyxUpdateManager from '@libs/actions/OnyxUpdateManager'; -import {createBackupTransaction, removeDraftTransactionsByIDs, restoreOriginalTransactionFromBackup} from '@libs/actions/TransactionEdit'; +import { + createBackupTransaction, + removeDraftTransactionsByIDs, + restoreOriginalTransactionFromBackup, + restoreOriginalTransactionFromBackupWithImageCleanup, +} from '@libs/actions/TransactionEdit'; import initOnyxDerivedValues from '@userActions/OnyxDerived'; import CONST from '@src/CONST'; import * as SequentialQueue from '@src/libs/Network/SequentialQueue'; @@ -130,6 +135,54 @@ describe('actions/TransactionEdit', () => { expect(transactions).toBeUndefined(); }); }); + + describe('restoreOriginalTransactionFromBackupWithImageCleanup', () => { + const transactionOriginal = createRandomTransaction(1); + + it('should restore the transaction from backup and remove the backup', async () => { + const transactionBackup = {...transactionOriginal, amount: 200}; + const isDraft = false; + + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionOriginal.transactionID}`, {...transactionOriginal, amount: 100}); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_BACKUP}${transactionOriginal.transactionID}`, transactionBackup); + await waitForBatchedUpdates(); + + await restoreOriginalTransactionFromBackupWithImageCleanup(transactionOriginal.transactionID, isDraft); + await waitForBatchedUpdates(); + + const restoredTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionOriginal.transactionID}`); + const backupTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_BACKUP}${transactionOriginal.transactionID}`); + + expect(restoredTransaction).not.toBeNull(); + expect(restoredTransaction?.amount).toBe(transactionBackup.amount); + expect(backupTransaction).toBeUndefined(); + }); + + // Regression guard for the duplicate edit-from-confirmation backup effect: the first restore + // removes the backup, so a second restore reads a missing backup and nulls the transaction. + // This is why the cleanup must run exactly once (see IOURequestStepDistanceOdometer) + it('should null the transaction on a second restore after the backup is gone', async () => { + const transactionBackup = {...transactionOriginal, amount: 200}; + const isDraft = false; + + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionOriginal.transactionID}`, {...transactionOriginal, amount: 100}); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_BACKUP}${transactionOriginal.transactionID}`, transactionBackup); + await waitForBatchedUpdates(); + + // First restore: transaction is restored from backup, backup is removed + await restoreOriginalTransactionFromBackupWithImageCleanup(transactionOriginal.transactionID, isDraft); + await waitForBatchedUpdates(); + + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionOriginal.transactionID}`)).not.toBeNull(); + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_BACKUP}${transactionOriginal.transactionID}`)).toBeUndefined(); + + // Second restore: backup is gone, so the transaction is wiped + await restoreOriginalTransactionFromBackupWithImageCleanup(transactionOriginal.transactionID, isDraft); + await waitForBatchedUpdates(); + + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionOriginal.transactionID}`)).toBeUndefined(); + }); + }); }); describe('removeDraftTransactionsByIDs', () => { diff --git a/tests/ui/IOURequestStepDistanceOdometerBackupTest.tsx b/tests/ui/IOURequestStepDistanceOdometerBackupTest.tsx new file mode 100644 index 000000000000..dbab40734cc2 --- /dev/null +++ b/tests/ui/IOURequestStepDistanceOdometerBackupTest.tsx @@ -0,0 +1,379 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +import {act, render} from '@testing-library/react-native'; +import React from 'react'; +import Onyx from 'react-native-onyx'; +import {CurrentUserPersonalDetailsProvider} from '@components/CurrentUserPersonalDetailsProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import {removeMoneyRequestOdometerImage, setMoneyRequestOdometerImage} from '@libs/actions/OdometerTransactionUtils'; +import * as TransactionEdit from '@libs/actions/TransactionEdit'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import TabSwitchGuardContext from '@libs/Navigation/TabSwitchGuardContext'; +import type {RegisterTabSwitchGuard, TabSwitchGuard} from '@libs/Navigation/TabSwitchGuardContext'; +import type {MoneyRequestNavigatorParamList} from '@libs/Navigation/types'; +import IOURequestStepDistanceOdometer from '@pages/iou/request/step/IOURequestStepDistanceOdometer'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; +import type {Report, Transaction} from '@src/types/onyx'; +import type {FileObject} from '@src/types/utils/Attachment'; +import createRandomTransaction from '../utils/collections/transaction'; +import getOnyxValue from '../utils/getOnyxValue'; +import {signInWithTestUser} from '../utils/TestHelper'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +jest.mock('@rnmapbox/maps', () => ({ + default: jest.fn(), + MarkerView: jest.fn(), + setAccessToken: jest.fn(), +})); + +jest.mock('@components/LocaleContextProvider', () => { + const React2 = require('react'); + const defaultContextValue = { + translate: (path: string) => path, + numberFormat: (number: number) => String(number), + getLocalDateFromDatetime: () => new Date(), + datetimeToRelative: () => '', + datetimeToCalendarTime: () => '', + formatPhoneNumber: (phone: string) => phone, + toLocaleDigit: (digit: string) => digit, + toLocaleOrdinal: (number: number) => String(number), + fromLocaleDigit: (localeDigit: string) => localeDigit, + localeCompare: (a: string, b: string) => a.localeCompare(b), + formatTravelDate: () => '', + preferredLocale: 'en', + }; + const LocaleContext = React2.createContext(defaultContextValue); + return { + LocaleContext, + LocaleContextProvider: ({children}: {children: React.ReactNode}) => React2.createElement(LocaleContext.Provider, {value: defaultContextValue}, children), + }; +}); + +// Keep the real backup/restore logic but spy on the restore so we can assert it runs exactly once on unmount. +// The duplicate inline effect (now removed) made it run twice, which nulled the transaction on the second pass +jest.mock('@libs/actions/TransactionEdit', () => { + const actual = jest.requireActual('@libs/actions/TransactionEdit'); + return { + ...actual, + restoreOriginalTransactionFromBackupWithImageCleanup: jest.fn(actual.restoreOriginalTransactionFromBackupWithImageCleanup), + }; +}); + +jest.mock('@libs/actions/MapboxToken', () => ({ + init: jest.fn(), + stop: jest.fn(), +})); + +jest.mock('@components/ProductTrainingContext', () => ({ + useProductTrainingContext: () => [false], +})); + +jest.mock('@hooks/useShowNotFoundPageInIOUStep', () => () => false); +jest.mock('@src/hooks/useResponsiveLayout'); + +jest.mock('@hooks/useScreenWrapperTransitionStatus', () => ({ + __esModule: true, + default: () => ({didScreenTransitionEnd: true}), +})); + +jest.mock('@libs/Navigation/navigationRef', () => ({ + getCurrentRoute: jest.fn(() => ({name: 'Money_Request_Step_Distance_Odometer', params: {}})), + getState: jest.fn(() => ({})), +})); + +jest.mock('@libs/Navigation/Navigation', () => { + const mockRef = { + getCurrentRoute: jest.fn(() => ({name: 'Money_Request_Step_Distance_Odometer', params: {}})), + getState: jest.fn(() => ({})), + }; + return { + navigate: jest.fn(), + goBack: jest.fn(), + closeRHPFlow: jest.fn(), + dismissModalWithReport: jest.fn(), + navigationRef: mockRef, + setNavigationActionToMicrotaskQueue: jest.fn((callback: () => void) => callback()), + getActiveRoute: jest.fn(() => ''), + getActiveRouteWithoutParams: jest.fn(() => ''), + isNavigationReady: jest.fn(() => Promise.resolve()), + getReportRouteByID: jest.fn(() => undefined), + removeScreenByKey: jest.fn(), + }; +}); + +jest.mock('@react-navigation/native', () => { + const mockRef = { + getCurrentRoute: jest.fn(() => ({name: 'Money_Request_Step_Distance_Odometer', params: {}})), + getState: jest.fn(() => ({})), + }; + return { + createNavigationContainerRef: jest.fn(() => mockRef), + useIsFocused: () => true, + useNavigation: () => ({navigate: jest.fn(), addListener: jest.fn()}), + useFocusEffect: jest.fn(), + usePreventRemove: jest.fn(), + useRoute: jest.fn(() => ({key: 'distance-odometer', name: 'Money_Request_Step_Distance_Odometer', params: {}})), + }; +}); + +const ACCOUNT_ID = 1; +const ACCOUNT_LOGIN = 'test@user.com'; +const REPORT_ID = 'report-odometer-backup-1'; +const TRANSACTION_ID = 'txn-odometer-backup-1'; +const ODOMETER_START = 100; +const ODOMETER_END = 300; + +// Typed route for the odometer step, built against the single screen so `action`/`backToReport` need no casts. +function createOdometerRoute(): PlatformStackScreenProps['route'] { + return { + key: 'Money_Request_Step_Distance_Odometer-test', + name: SCREENS.MONEY_REQUEST.STEP_DISTANCE_ODOMETER, + params: { + action: CONST.IOU.ACTION.CREATE, + iouType: CONST.IOU.TYPE.SUBMIT, + reportID: REPORT_ID, + transactionID: TRANSACTION_ID, + }, + }; +} + +function createTestReport(): Report { + return { + reportID: REPORT_ID, + chatType: CONST.REPORT.CHAT_TYPE.DOMAIN_ALL, + ownerAccountID: ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + isPinned: false, + lastVisibleActionCreated: '', + lastReadTime: '', + participants: { + [ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, role: CONST.REPORT.ROLE.MEMBER}, + }, + }; +} + +// A populated odometer expense, as it exists when the user reaches the confirmation step and taps "Distance" +function createOdometerTransaction(): Transaction { + const transaction = createRandomTransaction(1); + return { + ...transaction, + transactionID: TRANSACTION_ID, + reportID: REPORT_ID, + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER, + comment: { + ...transaction.comment, + odometerStart: ODOMETER_START, + odometerEnd: ODOMETER_END, + customUnit: { + customUnitID: 'test-unit-id', + customUnitRateID: 'test-rate-id', + name: 'Distance', + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + }, + }, + }; +} + +// Edit-from-confirmation entry: route name !== DISTANCE_CREATE and action !== EDIT -> `isEditingConfirmation` is true +function renderEditFromConfirmationOdometer() { + return render( + + + + + , + ); +} + +describe('IOURequestStepDistanceOdometer - edit-from-confirmation backup is restored exactly once on plain back', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS, evictableKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS]}); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + await Onyx.clear(); + await waitForBatchedUpdates(); + await signInWithTestUser(ACCOUNT_ID, ACCOUNT_LOGIN); + }); + + // Regression: the edit-from-confirmation screen once ran two identical backup-restore effects. On a plain "back" + // the first restored + deleted the backup, the second found it missing and nulled the transaction. The + // `useOdometerTransactionBackup` hook must be the sole owner of this lifecycle + it('restores the transaction once and does not wipe it when unmounting without saving', async () => { + const transaction = createOdometerTransaction(); + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, createTestReport()); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`, transaction); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_BACKUP}${TRANSACTION_ID}`, transaction); + await Onyx.merge(ONYXKEYS.IS_LOADING_APP, false); + }); + + const {unmount} = renderEditFromConfirmationOdometer(); + await waitForBatchedUpdatesWithAct(); + + // Plain back / unmount without setting didSaveEditingConfirmationRef or backupHandledManually + unmount(); + await waitForBatchedUpdatesWithAct(); + await waitForBatchedUpdatesWithAct(); + + // The backup restore must run exactly once. With the duplicate inline effect it ran twice (the second + // restore nulled the transaction). This assertion is 2 on the buggy code + expect(jest.mocked(TransactionEdit.restoreOriginalTransactionFromBackupWithImageCleanup)).toHaveBeenCalledTimes(1); + + // The expense must survive the back navigation: the draft transaction is restored from the backup, not nulled + const restoredTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`); + expect(restoredTransaction).not.toBeNull(); + expect(restoredTransaction).not.toBeUndefined(); + expect(restoredTransaction?.comment?.odometerStart).toBe(ODOMETER_START); + expect(restoredTransaction?.comment?.odometerEnd).toBe(ODOMETER_END); + }); +}); + +// Integration tests for the discard guard: they capture the real closure registered via +// useRegisterTabSwitchGuard (through TabSwitchGuardContext) and call its getHasUnsavedChanges(). +// +// Setup is a save-for-later draft that holds the readings; the user then edits the image. The tests assert both +// directions: +// - a real image edit (add / swap / remove) -> getHasUnsavedChanges() is true (the discard modal must fire) +// - re-entering with nothing changed -> getHasUnsavedChanges() is false (no false prompt) +// +// The first case is the false-negative being fixed: the draft check is presence-only, so it missed an image that +// moved ahead of the draft. Unlike the unit tests, these run the whole path (resync effect + image baseline + +// getOdometerHasUnsavedChanges), not just the pure function. +describe('IOURequestStepDistanceOdometer - discard guard detects user image changes with an active save-for-later draft', () => { + const START_IMAGE_A: FileObject = {uri: 'a.jpg', name: 'a.jpg', type: 'image/jpeg', size: 1234}; + const START_IMAGE_B: FileObject = {uri: 'b.jpg', name: 'b.jpg', type: 'image/jpeg', size: 5678}; + + beforeAll(() => { + Onyx.init({keys: ONYXKEYS, evictableKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS]}); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + await Onyx.clear(); + await waitForBatchedUpdates(); + await signInWithTestUser(ACCOUNT_ID, ACCOUNT_LOGIN); + }); + + function renderWithCapturedGuard(register: RegisterTabSwitchGuard) { + return render( + + + + + + + , + ); + } + + // Seeds the edit-from-confirmation flow with an active save-for-later readings draft and renders, returning the + // captured tab guard. `startImage` optionally seeds an image already present at mount (for swap/remove) + async function setupAndRender(startImage?: FileObject): Promise<{getHasUnsavedChanges: () => boolean; transaction: Transaction}> { + const transaction = createOdometerTransaction(); + if (startImage) { + transaction.comment = {...transaction.comment, odometerStartImage: startImage}; + } + let capturedGuard: TabSwitchGuard | undefined; + const register: RegisterTabSwitchGuard = (guard) => { + capturedGuard = guard; + return () => {}; + }; + + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, createTestReport()); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`, transaction); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_BACKUP}${TRANSACTION_ID}`, transaction); + await Onyx.set(ONYXKEYS.ODOMETER_DRAFT, {odometerStartReading: ODOMETER_START, odometerEndReading: ODOMETER_END}); + await Onyx.merge(ONYXKEYS.IS_LOADING_APP, false); + }); + + renderWithCapturedGuard(register); + await waitForBatchedUpdatesWithAct(); + + return {getHasUnsavedChanges: () => capturedGuard?.getHasUnsavedChanges() ?? false, transaction}; + } + + it('flags an added image as unsaved (the false-negative being fixed)', async () => { + const {getHasUnsavedChanges, transaction} = await setupAndRender(); + + // Baseline captured from a transaction with no image; nothing changed yet + expect(getHasUnsavedChanges()).toBe(false); + + await act(async () => { + setMoneyRequestOdometerImage(transaction, CONST.IOU.ODOMETER_IMAGE_TYPE.START, START_IMAGE_A, true, false); + await waitForBatchedUpdates(); + }); + await waitForBatchedUpdatesWithAct(); + + expect(getHasUnsavedChanges()).toBe(true); + }); + + it('flags a swapped image as unsaved', async () => { + const {getHasUnsavedChanges, transaction} = await setupAndRender(START_IMAGE_A); + + expect(getHasUnsavedChanges()).toBe(false); + + await act(async () => { + setMoneyRequestOdometerImage(transaction, CONST.IOU.ODOMETER_IMAGE_TYPE.START, START_IMAGE_B, true, false); + await waitForBatchedUpdates(); + }); + await waitForBatchedUpdatesWithAct(); + + expect(getHasUnsavedChanges()).toBe(true); + }); + + it('flags a removed image as unsaved', async () => { + const {getHasUnsavedChanges, transaction} = await setupAndRender(START_IMAGE_A); + + expect(getHasUnsavedChanges()).toBe(false); + + await act(async () => { + removeMoneyRequestOdometerImage(transaction, CONST.IOU.ODOMETER_IMAGE_TYPE.START, true, false); + await waitForBatchedUpdates(); + }); + await waitForBatchedUpdatesWithAct(); + + expect(getHasUnsavedChanges()).toBe(true); + }); + + it('stays silent when the screen is re-entered with no image change', async () => { + const {getHasUnsavedChanges} = await setupAndRender(START_IMAGE_A); + + expect(getHasUnsavedChanges()).toBe(false); + }); + + // A NON-user image URI change (blob re-mint on reload, external save) keeps the file name + size and only changes + // the uri, so its re-mint-invariant identity (getOdometerImageIdentity = name|size) is unchanged and the guard + // stays silent - no "user edited" tracking needed + it('stays silent for a non-user image URI change (e.g. blob re-mint) because the identity is invariant', async () => { + const {getHasUnsavedChanges} = await setupAndRender(START_IMAGE_A); + + expect(getHasUnsavedChanges()).toBe(false); + + // Change the image URI WITHOUT going through the user-edit actions -> no marker is set + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`, { + comment: {odometerStartImage: {uri: 'a-reminted.jpg', name: 'a.jpg', type: 'image/jpeg', size: 1234}}, + }); + }); + await waitForBatchedUpdatesWithAct(); + + expect(getHasUnsavedChanges()).toBe(false); + }); +}); diff --git a/tests/ui/IOURequestStepDistanceOdometerNextSyncTest.tsx b/tests/ui/IOURequestStepDistanceOdometerNextSyncTest.tsx new file mode 100644 index 000000000000..8c6e31cc2a15 --- /dev/null +++ b/tests/ui/IOURequestStepDistanceOdometerNextSyncTest.tsx @@ -0,0 +1,328 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import {act, fireEvent, render, screen} from '@testing-library/react-native'; +import React from 'react'; +import Onyx from 'react-native-onyx'; +import {CurrentUserPersonalDetailsProvider} from '@components/CurrentUserPersonalDetailsProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import {isOdometerDraftPendingHydration} from '@libs/actions/OdometerTransactionUtils'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {MoneyRequestNavigatorParamList} from '@libs/Navigation/types'; +import IOURequestStepDistanceOdometer from '@pages/iou/request/step/IOURequestStepDistanceOdometer'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; +import type {OdometerDraft, Report, Transaction} from '@src/types/onyx'; +import createRandomTransaction from '../utils/collections/transaction'; +import getOnyxValue from '../utils/getOnyxValue'; +import {signInWithTestUser} from '../utils/TestHelper'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +jest.mock('@rnmapbox/maps', () => ({ + default: jest.fn(), + MarkerView: jest.fn(), + setAccessToken: jest.fn(), +})); + +jest.mock('@components/LocaleContextProvider', () => { + const React2 = require('react'); + const defaultContextValue = { + translate: (path: string) => path, + numberFormat: (number: number) => String(number), + getLocalDateFromDatetime: () => new Date(), + datetimeToRelative: () => '', + datetimeToCalendarTime: () => '', + formatPhoneNumber: (phone: string) => phone, + toLocaleDigit: (digit: string) => digit, + toLocaleOrdinal: (number: number) => String(number), + fromLocaleDigit: (localeDigit: string) => localeDigit, + localeCompare: (a: string, b: string) => a.localeCompare(b), + formatTravelDate: () => '', + preferredLocale: 'en', + }; + const LocaleContext = React2.createContext(defaultContextValue); + return { + LocaleContext, + LocaleContextProvider: ({children}: {children: React.ReactNode}) => React2.createElement(LocaleContext.Provider, {value: defaultContextValue}, children), + }; +}); + +// Neutralize the post-Next navigation so the test can inspect Onyx right after Next, in isolation +jest.mock('@pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation', () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mock('@libs/actions/MapboxToken', () => ({ + init: jest.fn(), + stop: jest.fn(), +})); + +jest.mock('@components/ProductTrainingContext', () => ({ + useProductTrainingContext: () => [false], +})); + +jest.mock('@hooks/useShowNotFoundPageInIOUStep', () => () => false); +jest.mock('@src/hooks/useResponsiveLayout'); + +jest.mock('@hooks/useScreenWrapperTransitionStatus', () => ({ + __esModule: true, + default: () => ({didScreenTransitionEnd: true}), +})); + +jest.mock('@libs/Navigation/navigationRef', () => ({ + getCurrentRoute: jest.fn(() => ({name: 'Money_Request_Distance_Create', params: {}})), + getState: jest.fn(() => ({})), +})); + +jest.mock('@libs/Navigation/Navigation', () => { + const mockRef = { + getCurrentRoute: jest.fn(() => ({name: 'Money_Request_Distance_Create', params: {}})), + getState: jest.fn(() => ({})), + }; + return { + navigate: jest.fn(), + goBack: jest.fn(), + closeRHPFlow: jest.fn(), + dismissModalWithReport: jest.fn(), + navigationRef: mockRef, + setNavigationActionToMicrotaskQueue: jest.fn((callback: () => void) => callback()), + getActiveRoute: jest.fn(() => ''), + getActiveRouteWithoutParams: jest.fn(() => ''), + isNavigationReady: jest.fn(() => Promise.resolve()), + getReportRouteByID: jest.fn(() => undefined), + removeScreenByKey: jest.fn(), + }; +}); + +jest.mock('@react-navigation/native', () => { + const mockRef = { + getCurrentRoute: jest.fn(() => ({name: 'Money_Request_Distance_Create', params: {}})), + getState: jest.fn(() => ({})), + }; + return { + createNavigationContainerRef: jest.fn(() => mockRef), + useIsFocused: () => true, + useNavigation: () => ({navigate: jest.fn(), addListener: jest.fn()}), + useFocusEffect: jest.fn(), + usePreventRemove: jest.fn(), + useRoute: jest.fn(() => ({key: 'distance-odometer', name: 'Money_Request_Distance_Create', params: {}})), + }; +}); + +const ACCOUNT_ID = 1; +const ACCOUNT_LOGIN = 'test@user.com'; +const REPORT_ID = 'report-odometer-1'; +const TRANSACTION_ID = 'txn-odometer-1'; + +function createTestReport(): Report { + return { + reportID: REPORT_ID, + chatType: CONST.REPORT.CHAT_TYPE.DOMAIN_ALL, + ownerAccountID: ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + isPinned: false, + lastVisibleActionCreated: '', + lastReadTime: '', + participants: { + [ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, role: CONST.REPORT.ROLE.MEMBER}, + }, + }; +} + +function createOdometerDraftTransaction(): Transaction { + const transaction = createRandomTransaction(1); + return { + ...transaction, + transactionID: TRANSACTION_ID, + reportID: REPORT_ID, + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER, + comment: { + ...transaction.comment, + // Start with no odometer readings - the user enters them in the form + odometerStart: undefined, + odometerEnd: undefined, + customUnit: { + customUnitID: 'test-unit-id', + customUnitRateID: 'test-rate-id', + name: 'Distance', + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + }, + }, + }; +} + +// The DISTANCE_CREATE route types `action`/`backTo` as `never` (unused for navigation but read at runtime here), +// so the params object can't be built without one assertion. +function createDistanceCreateRoute(): PlatformStackScreenProps['route'] { + return { + key: 'Money_Request_Distance_Create-test', + name: SCREENS.MONEY_REQUEST.DISTANCE_CREATE, + params: { + action: CONST.IOU.ACTION.CREATE, + iouType: CONST.IOU.TYPE.SUBMIT, + reportID: REPORT_ID, + transactionID: TRANSACTION_ID, + } as unknown as MoneyRequestNavigatorParamList[typeof SCREENS.MONEY_REQUEST.DISTANCE_CREATE], + }; +} + +function renderCreateOdometer() { + return render( + + + + + , + ); +} + +// Returns the underlying TextInput (not the floating-label ) for a given odometer field label +const odometerInput = (labelKey: string) => screen.getAllByLabelText(labelKey).find((element) => 'value' in element.props)!; + +const enterReadingsAndPressNext = async (start: string, end: string) => { + fireEvent.changeText(odometerInput('distance.odometer.startReading'), start); + fireEvent.changeText(odometerInput('distance.odometer.endReading'), end); + await waitForBatchedUpdatesWithAct(); + + fireEvent.press(screen.getByTestId('next-save-button')); + // Flush twice so any Onyx writes triggered by Next would land before we assert + await waitForBatchedUpdatesWithAct(); + await waitForBatchedUpdatesWithAct(); +}; + +describe('IOURequestStepDistanceOdometer - create-flow Next does not write the save-for-later draft', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS, evictableKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS]}); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + await Onyx.clear(); + await waitForBatchedUpdates(); + await signInWithTestUser(ACCOUNT_ID, ACCOUNT_LOGIN); + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, createTestReport()); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`, createOdometerDraftTransaction()); + await Onyx.merge(ONYXKEYS.IS_LOADING_APP, false); + }); + }); + + // "Next" is not a save action (only "Save for later" and the confirmation "Save" write the draft), so it must + // leave an existing draft untouched. The directional isOdometerDraftPendingHydration reports the staler draft + // as NOT pending, which is what prevents a false discard modal on re-entry + it('leaves an existing save-for-later draft untouched on Next, and is reported as not pending', async () => { + const existingDraft: OdometerDraft = {odometerStartReading: 50, odometerEndReading: 60}; + await act(async () => { + await Onyx.set(ONYXKEYS.ODOMETER_DRAFT, existingDraft); + }); + + renderCreateOdometer(); + await waitForBatchedUpdatesWithAct(); + + await enterReadingsAndPressNext('100', '300'); + + const draftAfter = await getOnyxValue(ONYXKEYS.ODOMETER_DRAFT); + const draftTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`); + + // Next did NOT touch ODOMETER_DRAFT - it still holds the originally saved readings + expect(draftAfter?.odometerStartReading).toBe(50); + expect(draftAfter?.odometerEndReading).toBe(60); + // The transaction still received the freshly entered readings + expect(draftTransaction?.comment?.odometerStart).toBe(100); + expect(draftTransaction?.comment?.odometerEnd).toBe(300); + // Even though the draft is now staler than the transaction, the directional predicate reports it as + // NOT pending hydration (the transaction already holds readings) -> no false discard modal on re-entry + expect(isOdometerDraftPendingHydration(draftAfter, draftTransaction?.comment)).toBe(false); + }); + + // The draft still holds an image the user has since removed from the transaction. Next leaves the draft + // untouched, and the directional check reports the staler-image draft as NOT pending -> no false discard modal + it('reports a readings+image draft as not pending after its image is removed from the transaction', async () => { + const existingDraft: OdometerDraft = {odometerStartReading: 50, odometerEndReading: 60, odometerStartImage: 'data:image/png;base64,xxx'}; + await act(async () => { + await Onyx.set(ONYXKEYS.ODOMETER_DRAFT, existingDraft); + }); + + renderCreateOdometer(); + await waitForBatchedUpdatesWithAct(); + + await enterReadingsAndPressNext('100', '300'); + + const draftAfter = await getOnyxValue(ONYXKEYS.ODOMETER_DRAFT); + const draftTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`); + + // Next did NOT touch ODOMETER_DRAFT - it still holds the image. + expect(draftAfter?.odometerStartImage).toBe('data:image/png;base64,xxx'); + // The transaction holds the freshly entered readings but no image (the user removed it before Next) + expect(draftTransaction?.comment?.odometerStart).toBe(100); + expect(draftTransaction?.comment?.odometerStartImage).toBeUndefined(); + // The staler-image draft is reported as NOT pending hydration -> no false discard modal on re-entry + expect(isOdometerDraftPendingHydration(draftAfter, draftTransaction?.comment)).toBe(false); + }); + + // Cleared readings must NOT reappear when an image is later deleted. The user clears both reading inputs (local + // state only), then deletes an image, which writes the transaction and re-fires the resync effect while + // isUserTyping=true. The `!isUserTyping` guard on the predicate's reading branches must keep them cleared + it('keeps readings cleared when an odometer image is deleted after clearing them', async () => { + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`, { + comment: {odometerStart: 100, odometerEnd: 300, odometerStartImage: 'data:image/png;base64,xxx'}, + }); + }); + + renderCreateOdometer(); + await waitForBatchedUpdatesWithAct(); + + const startInput = odometerInput('distance.odometer.startReading'); + const endInput = odometerInput('distance.odometer.endReading'); + + // The form hydrates from the transaction on mount + expect(startInput.props.value).toBe('100'); + expect(endInput.props.value).toBe('300'); + + // User clears BOTH inputs (updates local state + the typing flag; the transaction is NOT written) + fireEvent.changeText(startInput, ''); + fireEvent.changeText(endInput, ''); + await waitForBatchedUpdatesWithAct(); + expect(startInput.props.value).toBe(''); + expect(endInput.props.value).toBe(''); + + // User deletes the start image - mirrors removeMoneyRequestOdometerImage + goBack to the mounted step + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`, {comment: {odometerStartImage: null}}); + }); + await waitForBatchedUpdatesWithAct(); + + // The cleared readings must stay cleared - they must NOT be re-hydrated from the stale transaction + expect(startInput.props.value).toBe(''); + expect(endInput.props.value).toBe(''); + }); + + // Pressing Next never writes the draft in the plain create flow either, so an unrelated future expense + // is never left with an orphaned ODOMETER_DRAFT + it('does not create a draft on Next when no save-for-later draft exists', async () => { + expect(await getOnyxValue(ONYXKEYS.ODOMETER_DRAFT)).toBeUndefined(); + + renderCreateOdometer(); + await waitForBatchedUpdatesWithAct(); + + await enterReadingsAndPressNext('100', '300'); + + // Next does not write ODOMETER_DRAFT + expect(await getOnyxValue(ONYXKEYS.ODOMETER_DRAFT)).toBeUndefined(); + // The transaction itself still received the readings + const draftTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`); + expect(draftTransaction?.comment?.odometerStart).toBe(100); + expect(draftTransaction?.comment?.odometerEnd).toBe(300); + }); +}); diff --git a/tests/ui/IOURequestStepDistanceOdometerSflResumeTest.tsx b/tests/ui/IOURequestStepDistanceOdometerSflResumeTest.tsx new file mode 100644 index 000000000000..f76f0c863a8c --- /dev/null +++ b/tests/ui/IOURequestStepDistanceOdometerSflResumeTest.tsx @@ -0,0 +1,237 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import {act, fireEvent, render, screen} from '@testing-library/react-native'; +import React from 'react'; +import Onyx from 'react-native-onyx'; +import {CurrentUserPersonalDetailsProvider} from '@components/CurrentUserPersonalDetailsProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import * as OdometerTransactionUtils from '@libs/actions/OdometerTransactionUtils'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import TabSwitchGuardContext from '@libs/Navigation/TabSwitchGuardContext'; +import type {RegisterTabSwitchGuard, TabSwitchGuard} from '@libs/Navigation/TabSwitchGuardContext'; +import type {MoneyRequestNavigatorParamList} from '@libs/Navigation/types'; +import IOURequestStepDistanceOdometer from '@pages/iou/request/step/IOURequestStepDistanceOdometer'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; +import type {OdometerDraft, Report, Transaction} from '@src/types/onyx'; +import type {FileObject} from '@src/types/utils/Attachment'; +import createRandomTransaction from '../utils/collections/transaction'; +import {signInWithTestUser} from '../utils/TestHelper'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +jest.mock('@rnmapbox/maps', () => ({default: jest.fn(), MarkerView: jest.fn(), setAccessToken: jest.fn()})); + +// Keep the real module (getOdometerHasUnsavedChanges + the actions) but stub saveOdometerDraft so "Save for later" +// resolves deterministically in jsdom (the real one tries to read a blob: URI) +jest.mock('@libs/actions/OdometerTransactionUtils', () => { + const actual = jest.requireActual('@libs/actions/OdometerTransactionUtils'); + return { + ...actual, + saveOdometerDraft: jest.fn(() => Promise.resolve()), + }; +}); + +jest.mock('@components/LocaleContextProvider', () => { + const React2 = require('react'); + const defaultContextValue = { + translate: (path: string) => path, + numberFormat: (n: number) => String(n), + getLocalDateFromDatetime: () => new Date(), + datetimeToRelative: () => '', + datetimeToCalendarTime: () => '', + formatPhoneNumber: (p: string) => p, + toLocaleDigit: (d: string) => d, + toLocaleOrdinal: (n: number) => String(n), + fromLocaleDigit: (d: string) => d, + localeCompare: (a: string, b: string) => a.localeCompare(b), + formatTravelDate: () => '', + preferredLocale: 'en', + }; + const LocaleContext = React2.createContext(defaultContextValue); + return {LocaleContext, LocaleContextProvider: ({children}: {children: React.ReactNode}) => React2.createElement(LocaleContext.Provider, {value: defaultContextValue}, children)}; +}); +jest.mock('@pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation', () => ({__esModule: true, default: jest.fn()})); +jest.mock('@libs/actions/MapboxToken', () => ({init: jest.fn(), stop: jest.fn()})); +jest.mock('@components/ProductTrainingContext', () => ({useProductTrainingContext: () => [false]})); +jest.mock('@hooks/useShowNotFoundPageInIOUStep', () => () => false); +jest.mock('@src/hooks/useResponsiveLayout'); +jest.mock('@hooks/useScreenWrapperTransitionStatus', () => ({__esModule: true, default: () => ({didScreenTransitionEnd: true})})); +jest.mock('@libs/Navigation/navigationRef', () => ({getCurrentRoute: jest.fn(() => ({name: 'Money_Request_Distance_Create', params: {}})), getState: jest.fn(() => ({}))})); +jest.mock('@libs/Navigation/Navigation', () => ({ + navigate: jest.fn(), + goBack: jest.fn(), + closeRHPFlow: jest.fn(), + dismissModalWithReport: jest.fn(), + navigationRef: {getCurrentRoute: jest.fn(() => ({name: 'Money_Request_Distance_Create', params: {}})), getState: jest.fn(() => ({}))}, + setNavigationActionToMicrotaskQueue: jest.fn((cb: () => void) => cb()), + getActiveRoute: jest.fn(() => ''), + getActiveRouteWithoutParams: jest.fn(() => ''), + isNavigationReady: jest.fn(() => Promise.resolve()), + getReportRouteByID: jest.fn(() => undefined), + removeScreenByKey: jest.fn(), +})); +jest.mock('@react-navigation/native', () => ({ + createNavigationContainerRef: jest.fn(() => ({getCurrentRoute: jest.fn(() => ({name: 'Money_Request_Distance_Create', params: {}})), getState: jest.fn(() => ({}))})), + useIsFocused: () => true, + useNavigation: () => ({navigate: jest.fn(), addListener: jest.fn()}), + useFocusEffect: jest.fn(), + usePreventRemove: jest.fn(), + useRoute: jest.fn(() => ({key: 'distance-odometer', name: 'Money_Request_Distance_Create', params: {}})), +})); + +const ACCOUNT_ID = 1; +const REPORT_ID = 'report-sfl-resume'; +const TRANSACTION_ID = 'txn-sfl-resume'; +const START_IMAGE: FileObject = {uri: 'data:image/png;base64,sfl', name: 'a.png', type: 'image/png', size: 1234}; + +function createReport(): Report { + return { + reportID: REPORT_ID, + chatType: CONST.REPORT.CHAT_TYPE.DOMAIN_ALL, + ownerAccountID: ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + isPinned: false, + lastVisibleActionCreated: '', + lastReadTime: '', + participants: {[ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, role: CONST.REPORT.ROLE.MEMBER}}, + }; +} + +function createOdometerTransaction(withImage: boolean): Transaction { + const transaction = createRandomTransaction(1); + return { + ...transaction, + transactionID: TRANSACTION_ID, + reportID: REPORT_ID, + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER, + comment: { + ...transaction.comment, + odometerStart: withImage ? 100 : undefined, + odometerEnd: withImage ? 300 : undefined, + odometerStartImage: withImage ? START_IMAGE : undefined, + customUnit: {customUnitID: 'u', customUnitRateID: 'r', name: 'Distance', distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, + }, + }; +} + +// The DISTANCE_CREATE route types `action`/`backTo` as `never` (unused for navigation but read at runtime here), +// so the params object can't be built without one assertion. +function createDistanceCreateRoute(): PlatformStackScreenProps['route'] { + return { + key: 'Money_Request_Distance_Create-test', + name: SCREENS.MONEY_REQUEST.DISTANCE_CREATE, + params: { + action: CONST.IOU.ACTION.CREATE, + iouType: CONST.IOU.TYPE.SUBMIT, + reportID: REPORT_ID, + transactionID: TRANSACTION_ID, + } as unknown as MoneyRequestNavigatorParamList[typeof SCREENS.MONEY_REQUEST.DISTANCE_CREATE], + }; +} + +function renderCreateFlow(register: RegisterTabSwitchGuard) { + return render( + + + + + + + , + ); +} + +const odometerInput = (labelKey: string) => screen.getAllByLabelText(labelKey).find((element) => 'value' in element.props)!; + +describe('IOURequestStepDistanceOdometer - create-flow discard guard (no stored user-edit marks)', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS, evictableKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS]}); + }); + beforeEach(async () => { + jest.clearAllMocks(); + await Onyx.clear(); + await waitForBatchedUpdates(); + await signInWithTestUser(ACCOUNT_ID, 'test@user.com'); + }); + + // After capture + "Save for later" + same-session resume, the draft hydrates a re-minted image into + // the transaction. The baseline absorbs it and the re-mint-invariant identity (name|size) reports no change + it('Save for later then resume reports no unsaved changes', async () => { + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, createReport()); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`, createOdometerTransaction(false)); + await Onyx.merge(ONYXKEYS.IS_LOADING_APP, false); + }); + + // Session 1: type readings + capture a start image (the real marking path), then Save for later + const {unmount} = renderCreateFlow(() => () => {}); + await waitForBatchedUpdatesWithAct(); + fireEvent.changeText(odometerInput('distance.odometer.startReading'), '100'); + fireEvent.changeText(odometerInput('distance.odometer.endReading'), '300'); + await act(async () => { + OdometerTransactionUtils.setMoneyRequestOdometerImage(createOdometerTransaction(false), CONST.IOU.ODOMETER_IMAGE_TYPE.START, START_IMAGE, true, false); + await waitForBatchedUpdates(); + }); + await waitForBatchedUpdatesWithAct(); + + fireEvent.press(screen.getByTestId('save-for-later-button')); + await waitForBatchedUpdatesWithAct(); + await waitForBatchedUpdatesWithAct(); + + unmount(); + + // Session 2 (same JS session, no reload): resume with the draft + image hydrated into the transaction + const existingDraft: OdometerDraft = {odometerStartReading: 100, odometerEndReading: 300, odometerStartImage: 'data:image/png;base64,sfl'}; + await act(async () => { + await Onyx.set(ONYXKEYS.ODOMETER_DRAFT, existingDraft); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`, createOdometerTransaction(true)); + }); + let resumeGuard: TabSwitchGuard | undefined; + renderCreateFlow((guard) => { + resumeGuard = guard; + return () => {}; + }); + await waitForBatchedUpdatesWithAct(); + + expect(resumeGuard?.getHasUnsavedChanges() ?? false).toBe(false); + }); + + // In the create flow, Next then leaving must still prompt. The baseline is snapshotted EMPTY at the + // (empty) mount and survives Next -> back (screen stays mounted), so the committed readings differ from it + it('reports unsaved changes after Next in the create flow', async () => { + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, createReport()); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`, createOdometerTransaction(false)); + await Onyx.merge(ONYXKEYS.IS_LOADING_APP, false); + }); + + let guard: TabSwitchGuard | undefined; + renderCreateFlow((capturedGuard) => { + guard = capturedGuard; + return () => {}; + }); + await waitForBatchedUpdatesWithAct(); + + // Empty mount baseline -> nothing unsaved yet + expect(guard?.getHasUnsavedChanges() ?? false).toBe(false); + + // Type readings and press Next (writes the throwaway draft and lowers the typing flag, without remounting) + fireEvent.changeText(odometerInput('distance.odometer.startReading'), '100'); + fireEvent.changeText(odometerInput('distance.odometer.endReading'), '300'); + fireEvent.press(screen.getByTestId('next-save-button')); + await waitForBatchedUpdatesWithAct(); + + // The committed-but-unsaved readings differ from the surviving empty baseline -> leaving must prompt + expect(guard?.getHasUnsavedChanges() ?? false).toBe(true); + }); +}); diff --git a/tests/unit/hooks/useOdometerReadingsState.test.ts b/tests/unit/hooks/useOdometerReadingsState.test.ts index 077d9ad09f20..f3c4f07262f1 100644 --- a/tests/unit/hooks/useOdometerReadingsState.test.ts +++ b/tests/unit/hooks/useOdometerReadingsState.test.ts @@ -2,25 +2,33 @@ import {act, renderHook} from '@testing-library/react-native'; import useOdometerReadingsState from '@pages/iou/request/step/IOURequestStepDistance/hooks/useOdometerReadingsState'; import CONST from '@src/CONST'; import type * as OnyxTypes from '@src/types/onyx'; +import createRandomTransaction from '../../utils/collections/transaction'; const mockIsOdometerDraftPendingHydration = jest.fn(() => false); jest.mock('@libs/actions/OdometerTransactionUtils', () => ({ - isOdometerDraftPendingHydration: (...args: unknown[]) => mockIsOdometerDraftPendingHydration(...(args as Parameters)), + isOdometerDraftPendingHydration: () => mockIsOdometerDraftPendingHydration(), })); type Params = Parameters[0]; -const buildOdometerTransaction = (overrides: Partial = {}): OnyxTypes.Transaction => - ({ +const buildOdometerTransaction = ( + commentOverrides: Partial = {}, + iouRequestType: OnyxTypes.Transaction['iouRequestType'] = CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER, +): OnyxTypes.Transaction => { + const transaction = createRandomTransaction(1); + return { + ...transaction, transactionID: 't1', - iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER, + iouRequestType, comment: { + ...transaction.comment, odometerStart: 100, odometerEnd: 250, - ...overrides, + ...commentOverrides, }, - }) as unknown as OnyxTypes.Transaction; + }; +}; const baseParams: Params = { currentTransaction: buildOdometerTransaction(), @@ -39,7 +47,7 @@ describe('useOdometerReadingsState', () => { it('starts with empty form state, then hydrates startReading/endReading from the transaction', () => { const {result} = renderHook(() => useOdometerReadingsState(baseParams)); - // The sync-from-transaction effect runs synchronously after mount. + // The sync-from-transaction effect runs synchronously after mount expect(result.current.startReading).toBe('100'); expect(result.current.endReading).toBe('250'); expect(result.current.formError).toBe(''); @@ -67,19 +75,85 @@ describe('useOdometerReadingsState', () => { const {result} = renderHook(() => useOdometerReadingsState({ ...baseParams, - odometerDraft: {odometerStartReading: 999} as unknown as OnyxTypes.OdometerDraft, + odometerDraft: {odometerStartReading: 999}, }), ); expect(result.current.hasInitializedRefs.current).toBe(false); }); + // After Next the transaction holds new readings but the draft is staler. The directional check reports NOT + // pending, so the baseline snapshots from the transaction - re-entering no longer looks like an unsaved change + it('captures the baseline from the transaction when the (staler) draft is not pending hydration', () => { + mockIsOdometerDraftPendingHydration.mockReturnValue(false); + const {result} = renderHook(() => + useOdometerReadingsState({ + ...baseParams, + currentTransaction: buildOdometerTransaction({odometerStart: 120, odometerEnd: 300}), + odometerDraft: {odometerStartReading: 100, odometerEndReading: 250}, + }), + ); + + expect(result.current.hasInitializedRefs.current).toBe(true); + expect(result.current.initialStartReadingRef.current).toBe('120'); + expect(result.current.initialEndReadingRef.current).toBe('300'); + }); + + // Transaction has readings but no image (user deleted it) while the draft still holds the image. The directional + // check reports NOT pending, so the baseline snapshots the transaction's true no-image state, not an empty one + it('captures a no-image baseline when a readings draft is hydrated but its image was removed', () => { + mockIsOdometerDraftPendingHydration.mockReturnValue(false); + const {result} = renderHook(() => + useOdometerReadingsState({ + ...baseParams, + currentTransaction: buildOdometerTransaction(), + odometerDraft: {odometerStartReading: 100, odometerEndReading: 250, odometerStartImage: 'data:image/png;base64,xxx'}, + }), + ); + + expect(result.current.hasInitializedRefs.current).toBe(true); + expect(result.current.initialStartImageRef.current).toBeUndefined(); + expect(result.current.initialEndImageRef.current).toBeUndefined(); + }); + + // The ADD-image flow relies on an EMPTY image baseline: neither draft nor transaction has an image at mount, so + // a later add differs from this empty baseline (hasImageChanges => true) and lets the discard modal fire + it('captures an empty image baseline when neither the draft nor the transaction has an image', () => { + mockIsOdometerDraftPendingHydration.mockReturnValue(false); + const {result} = renderHook(() => + useOdometerReadingsState({ + ...baseParams, + currentTransaction: buildOdometerTransaction(), + odometerDraft: {odometerStartReading: 100, odometerEndReading: 250}, + }), + ); + + expect(result.current.hasInitializedRefs.current).toBe(true); + expect(result.current.initialStartImageRef.current).toBeUndefined(); + expect(result.current.initialEndImageRef.current).toBeUndefined(); + }); + + // The SWAP-image flow relies on the baseline capturing the transaction's CURRENT image, so a later swap is + // detected as a change. When the transaction already holds an image, the baseline must snapshot it, not undefined + it('captures the transaction image into the baseline when the transaction already has one', () => { + mockIsOdometerDraftPendingHydration.mockReturnValue(false); + const startImage = {uri: 'start.jpg'}; + const endImage = {uri: 'end.jpg'}; + const {result} = renderHook(() => + useOdometerReadingsState({ + ...baseParams, + currentTransaction: buildOdometerTransaction({odometerStartImage: startImage, odometerEndImage: endImage}), + odometerDraft: {odometerStartReading: 100, odometerEndReading: 250}, + }), + ); + + expect(result.current.hasInitializedRefs.current).toBe(true); + expect(result.current.initialStartImageRef.current).toEqual(startImage); + expect(result.current.initialEndImageRef.current).toEqual(endImage); + }); + it('skips initialization on a non-odometer transaction unless we are editing', () => { - const transaction = { - transactionID: 't1', - iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE, - comment: {odometerStart: 100, odometerEnd: 250}, - } as unknown as OnyxTypes.Transaction; + const transaction = buildOdometerTransaction({}, CONST.IOU.REQUEST_TYPE.DISTANCE); const {result} = renderHook(() => useOdometerReadingsState({...baseParams, currentTransaction: transaction, isEditing: false})); @@ -127,4 +201,17 @@ describe('useOdometerReadingsState', () => { expect(result.current.inputKey).toBe(initialKey); }); + + // Create flow at the unit level: a fresh mount snapshots an EMPTY baseline, so readings typed + committed + // via Next later differ from it and leaving prompts. The screen stays mounted across Next -> back, so it survives + it('captures an empty baseline on a fresh create mount with no readings yet', () => { + const emptyTransaction = buildOdometerTransaction({odometerStart: undefined, odometerEnd: undefined}); + const {result} = renderHook(() => useOdometerReadingsState({...baseParams, currentTransaction: emptyTransaction})); + + expect(result.current.hasInitializedRefs.current).toBe(true); + expect(result.current.initialStartReadingRef.current).toBe(''); + expect(result.current.initialEndReadingRef.current).toBe(''); + expect(result.current.initialStartImageRef.current).toBeUndefined(); + expect(result.current.initialEndImageRef.current).toBeUndefined(); + }); }); diff --git a/tests/unit/hooks/useOdometerTransactionBackup.test.ts b/tests/unit/hooks/useOdometerTransactionBackup.test.ts new file mode 100644 index 000000000000..21d81e3b3809 --- /dev/null +++ b/tests/unit/hooks/useOdometerTransactionBackup.test.ts @@ -0,0 +1,103 @@ +import {renderHook} from '@testing-library/react-native'; +import Onyx from 'react-native-onyx'; +import useOdometerTransactionBackup from '@pages/iou/request/step/IOURequestStepDistance/hooks/useOdometerTransactionBackup'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type * as OnyxTypes from '@src/types/onyx'; +import createRandomTransaction from '../../utils/collections/transaction'; +import getOnyxValue from '../../utils/getOnyxValue'; +import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; + +const TRANSACTION_ID = 'odometer-backup-test'; + +const buildOdometerTransaction = (commentOverrides: Partial = {}): OnyxTypes.Transaction => { + const transaction = createRandomTransaction(1); + return { + ...transaction, + transactionID: TRANSACTION_ID, + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER, + amount: 1234, + merchant: '5 mi', + comment: { + ...transaction.comment, + odometerStart: 100, + odometerEnd: 250, + ...commentOverrides, + }, + }; +}; + +type Params = Parameters[0]; + +const renderBackupHook = (overrides: Partial = {}) => { + const original = buildOdometerTransaction(); + const params: Params = { + transaction: original, + isEditingConfirmation: true, + isTransactionDraft: false, + transactionID: TRANSACTION_ID, + didSaveEditingConfirmationRef: {current: false}, + backupHandledManuallyRef: {current: false}, + ...overrides, + }; + return {original, ...renderHook(() => useOdometerTransactionBackup(params)), params}; +}; + +describe('useOdometerTransactionBackup', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + it('edit-from-confirmation header-back restores once and does not null the transaction', async () => { + const original = buildOdometerTransaction(); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${TRANSACTION_ID}`, original); + await waitForBatchedUpdates(); + + // Mount in edit-from-confirmation mode -> a backup of the original is created + const {unmount} = renderBackupHook({transaction: original}); + await waitForBatchedUpdates(); + + const backupAfterMount = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_BACKUP}${TRANSACTION_ID}`); + expect(backupAfterMount).toEqual(original); + + // Simulate the user editing the live transaction on the odometer screen + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${TRANSACTION_ID}`, {...original, comment: {odometerStart: 100, odometerEnd: 999}, merchant: '899 mi', amount: 99999}); + await waitForBatchedUpdates(); + + // Header back with no save -> the single cleanup restores from the backup + unmount(); + await waitForBatchedUpdates(); + + const restored = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${TRANSACTION_ID}`); + const backupAfterUnmount = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_BACKUP}${TRANSACTION_ID}`); + + // The transaction is restored exactly once: it equals the original and was NOT nulled + // (a second restore would read the now-removed backup and wipe the transaction) + expect(restored).toEqual(original); + expect(restored).not.toBeNull(); + expect(restored).not.toBeUndefined(); + expect(backupAfterUnmount).toBeUndefined(); + }); + + it('does not back up or restore when not editing from confirmation', async () => { + const original = buildOdometerTransaction(); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${TRANSACTION_ID}`, original); + await waitForBatchedUpdates(); + + const {unmount} = renderBackupHook({transaction: original, isEditingConfirmation: false}); + await waitForBatchedUpdates(); + + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_BACKUP}${TRANSACTION_ID}`)).toBeUndefined(); + + unmount(); + await waitForBatchedUpdates(); + + // The live transaction is untouched because no backup flow ran + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${TRANSACTION_ID}`)).toEqual(original); + }); +}); diff --git a/tests/unit/odometerResync.test.ts b/tests/unit/odometerResync.test.ts new file mode 100644 index 000000000000..285cd94ecff0 --- /dev/null +++ b/tests/unit/odometerResync.test.ts @@ -0,0 +1,89 @@ +import type {OdometerResyncState} from '@pages/iou/request/step/IOURequestStepDistance/odometerResync'; +import {isExternalOdometerResync, shouldInitializeOdometerFromTransaction} from '@pages/iou/request/step/IOURequestStepDistance/odometerResync'; + +// A steady state: initialized, transaction and local readings/images all match, nothing being typed. +const STEADY_STATE: OdometerResyncState = { + transactionStartValue: '100', + transactionEndValue: '200', + localStartValue: '100', + localEndValue: '200', + transactionStartImageUri: 'start.jpg', + transactionEndImageUri: 'end.jpg', + baselineStartImageUri: 'start.jpg', + baselineEndImageUri: 'end.jpg', + hasTransactionData: true, + hasLocalState: true, + hasInitialized: true, + isUserTyping: false, + isEditing: false, +}; + +function buildState(overrides: Partial = {}): OdometerResyncState { + return {...STEADY_STATE, ...overrides}; +} + +describe('isExternalOdometerResync', () => { + it('is false in a steady state (everything matches)', () => { + expect(isExternalOdometerResync(STEADY_STATE)).toBe(false); + }); + + it('is true when a reading was changed externally', () => { + expect(isExternalOdometerResync(buildState({transactionStartValue: '150'}))).toBe(true); + }); + + it('is true when only an image was changed externally', () => { + expect(isExternalOdometerResync(buildState({transactionStartImageUri: 'new-start.jpg'}))).toBe(true); + }); + + it('is false before on-mount initialization, even if values differ', () => { + expect(isExternalOdometerResync(buildState({hasInitialized: false, transactionStartValue: '150'}))).toBe(false); + }); + + it('is false while the user is typing, even if values differ', () => { + expect(isExternalOdometerResync(buildState({isUserTyping: true, transactionStartValue: '150'}))).toBe(false); + }); + + it('is false when the transaction has no reading data', () => { + expect(isExternalOdometerResync(buildState({hasTransactionData: false, transactionStartValue: '150'}))).toBe(false); + }); +}); + +describe('shouldInitializeOdometerFromTransaction', () => { + it('initializes on first mount when the transaction has data', () => { + expect(shouldInitializeOdometerFromTransaction(buildState({hasInitialized: false}), false)).toBe(true); + }); + + it('initializes when editing with transaction data and no local state yet', () => { + expect(shouldInitializeOdometerFromTransaction(buildState({isEditing: true, hasLocalState: false}), false)).toBe(true); + }); + + it('does not re-initialize when editing with empty local state while the user is typing (intentional clear)', () => { + expect(shouldInitializeOdometerFromTransaction(buildState({isEditing: true, hasLocalState: false, isUserTyping: true}), false)).toBe(false); + }); + + it('initializes when transaction has data but local state is empty (navigated back)', () => { + expect(shouldInitializeOdometerFromTransaction(buildState({hasLocalState: false}), false)).toBe(true); + }); + + it('still reloads when local state is empty and the user is not typing (navigated back, fresh ref)', () => { + expect(shouldInitializeOdometerFromTransaction(buildState({hasLocalState: false, isUserTyping: false}), false)).toBe(true); + }); + + it('does not re-hydrate cleared readings while the user is typing (clear-then-delete-image regression)', () => { + // User cleared both inputs (local state empty, typing flag set) then deleted an image; the + // transaction still holds the old readings. The third branch must NOT reload them. + expect(shouldInitializeOdometerFromTransaction(buildState({hasLocalState: false, isUserTyping: true}), false)).toBe(false); + }); + + it('initializes when an external resync arrived', () => { + expect(shouldInitializeOdometerFromTransaction(buildState({hasTransactionData: false}), true)).toBe(true); + }); + + it('does not initialize in a steady state', () => { + expect(shouldInitializeOdometerFromTransaction(STEADY_STATE, false)).toBe(false); + }); + + it('does not initialize when the transaction has no data and there is no resync', () => { + expect(shouldInitializeOdometerFromTransaction(buildState({hasTransactionData: false, hasLocalState: false}), false)).toBe(false); + }); +});