diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 25a2457f367c..96dd9ac4a30b 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -773,6 +773,10 @@ const ONYXKEYS = { /** List of transaction IDs used when navigating to prev/next transaction when viewing it in RHP */ TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS: 'transactionThreadNavigationTransactionIDs', + /** Hash of the search snapshot that holds the transactions referenced by TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS. + * Used to fall back to snapshot data when the live transaction collection hasn't loaded those transactions yet (e.g. opening an expense from the Spend page as an approver). */ + TRANSACTION_THREAD_NAVIGATION_SNAPSHOT_HASH: 'transactionThreadNavigationSnapshotHash', + /** Optional map of transactionID -> sibling descriptor for prev/next navigation in snapshot-backed flows (e.g. Home "Recently added"), where siblings may be absent from the main Onyx collections. When set, navigation resolves (and lazily creates) each sibling's thread on demand from its descriptor. */ TRANSACTION_THREAD_NAVIGATION_THREAD_REPORT_IDS: 'transactionThreadNavigationThreadReportIDs', @@ -1747,6 +1751,7 @@ type OnyxValuesMapping = { [ONYXKEYS.REPORT_NAVIGATION_LAST_SEARCH_QUERY]: OnyxTypes.LastSearchParams; [ONYXKEYS.NVP_LAST_ANDROID_LOGIN]: string; [ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS]: string[]; + [ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_SNAPSHOT_HASH]: number; [ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_THREAD_REPORT_IDS]: Record; [ONYXKEYS.NVP_INTEGRATION_SERVER_EXPORT_TEMPLATES]: OnyxTypes.ExportTemplate[]; [ONYXKEYS.ONBOARDING_USER_REPORTED_INTEGRATION]: OnboardingAccounting; diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index e835ce8d2e2f..308ae21a4637 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -27,6 +27,8 @@ import MoneyReportHeaderActions from './MoneyReportHeaderActions'; import {ExportDownloadStatusProvider} from './MoneyReportHeaderActions/ExportDownloadStatusContext'; import MoneyReportHeaderModals from './MoneyReportHeaderModals'; import MoneyReportHeaderMoreContent from './MoneyReportHeaderMoreContent'; +import MoneyRequestReportNavigation from './MoneyRequestReportView/MoneyRequestReportNavigation'; +import MoneyRequestReportTransactionsNavigation from './MoneyRequestReportView/MoneyRequestReportTransactionsNavigation'; import {PaymentAnimationsProvider} from './PaymentAnimationsContext'; import {useSearchSelectionActions} from './Search/SearchContext'; @@ -79,14 +81,23 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt const transactions = Object.values(reportTransactions); + const [activeTransactionIDs] = useOnyx(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS); + + const singleTransactionID = transactions.length === 1 ? transactions.at(0)?.transactionID : undefined; + + const anchorTransactionIDFromRoute = route.name === SCREENS.RIGHT_MODAL.SEARCH_REPORT ? route.params.anchorTransactionID : undefined; + const multiTxAnchorTransactionID = anchorTransactionIDFromRoute && activeTransactionIDs?.includes(anchorTransactionIDFromRoute) ? anchorTransactionIDFromRoute : undefined; + const carouselAnchorTransactionID = singleTransactionID ?? multiTxAnchorTransactionID; + const shouldShowTransactionNavigation = !!carouselAnchorTransactionID && !!activeTransactionIDs?.includes(carouselAnchorTransactionID); + const styles = useThemeStyles(); const {isWideRHPDisplayedOnWideLayout, isSuperWideRHPDisplayedOnWideLayout} = useResponsiveLayoutOnWideRHP(); const shouldShowHeaderButtonsInHeaderRow = isInLandscapeMode || !shouldDisplayNarrowVersion || isWideRHPDisplayedOnWideLayout || isSuperWideRHPDisplayedOnWideLayout; const isReportInRHP = route.name !== SCREENS.REPORT; - const shouldDisplaySearchRouter = !isReportInRHP || isSmallScreenWidth; const isReportInSearch = route.name === SCREENS.RIGHT_MODAL.SEARCH_REPORT || route.name === SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT; + const shouldDisplaySearchRouter = !isReportInRHP || (isSmallScreenWidth && !isReportInSearch); const backTo = (route.params as {backTo?: Route} | undefined)?.backTo; @@ -135,14 +146,18 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt shouldEnableDetailPageNavigation openParentReportInCurrentTab > - {shouldShowHeaderButtonsInHeaderRow && ( - - )} + {isReportInSearch && + (shouldShowTransactionNavigation && carouselAnchorTransactionID ? ( + + ) : ( + + ))} {!shouldShowHeaderButtonsInHeaderRow && ( )} - + ); diff --git a/src/components/MoneyReportHeaderMoreContent.tsx b/src/components/MoneyReportHeaderMoreContent.tsx index b8a5494fa99e..10ae6c5631fe 100644 --- a/src/components/MoneyReportHeaderMoreContent.tsx +++ b/src/components/MoneyReportHeaderMoreContent.tsx @@ -1,7 +1,5 @@ import useMoneyReportHeaderStatusBar from '@hooks/useMoneyReportHeaderStatusBar'; import useOnyx from '@hooks/useOnyx'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useResponsiveLayoutOnWideRHP from '@hooks/useResponsiveLayoutOnWideRHP'; import useThemeStyles from '@hooks/useThemeStyles'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; @@ -12,6 +10,7 @@ import {isInvoiceReport as isInvoiceReportUtil} from '@libs/ReportUtils'; import type CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {Route} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; @@ -22,20 +21,25 @@ import {useRoute} from '@react-navigation/native'; import React from 'react'; import {View} from 'react-native'; +import type {MoneyReportHeaderActionsProps} from './MoneyReportHeaderActions/types'; + +import MoneyReportHeaderActions from './MoneyReportHeaderActions'; import MoneyReportHeaderNextStep from './MoneyReportHeaderNextStep'; import MoneyReportHeaderStatusBarSection from './MoneyReportHeaderStatusBarSection'; import {useMoneyReportTransactionThread} from './MoneyReportTransactionThreadContext'; -import MoneyRequestReportNavigation from './MoneyRequestReportView/MoneyRequestReportNavigation'; type MoneyReportHeaderMoreContentProps = { reportID: string | undefined; + primaryAction: MoneyReportHeaderActionsProps['primaryAction']; + backTo: Route | undefined; + shouldShowHeaderButtonsInHeaderRow: boolean; }; /** * Cheap visibility gate — fetches minimal data to decide whether the more-content section * should render at all, avoiding expensive hooks in the body when nothing is shown. */ -function MoneyReportHeaderMoreContent({reportID}: MoneyReportHeaderMoreContentProps) { +function MoneyReportHeaderMoreContent({reportID, primaryAction, backTo, shouldShowHeaderButtonsInHeaderRow}: MoneyReportHeaderMoreContentProps) { const route = useRoute< | PlatformStackRouteProp | PlatformStackRouteProp @@ -50,8 +54,10 @@ function MoneyReportHeaderMoreContent({reportID}: MoneyReportHeaderMoreContentPr const isInvoiceReport = isInvoiceReportUtil(moneyRequestReport); const shouldShowNextStep = isGroupPolicy(policy) && !isInvoiceReport && !shouldShowStatusBar; + const hasStatusOrNextStep = shouldShowNextStep || !!statusBarType; + const shouldRenderActionsInRow = shouldShowHeaderButtonsInHeaderRow; - const shouldShowMoreContent = shouldShowNextStep || !!statusBarType || isReportInSearch; + const shouldShowMoreContent = hasStatusOrNextStep || shouldRenderActionsInRow; if (!shouldShowMoreContent) { return null; @@ -63,6 +69,9 @@ function MoneyReportHeaderMoreContent({reportID}: MoneyReportHeaderMoreContentPr statusBarType={statusBarType} isReportInSearch={isReportInSearch} shouldShowNextStep={shouldShowNextStep} + primaryAction={primaryAction} + backTo={backTo} + shouldRenderActionsInRow={shouldRenderActionsInRow} /> ); } @@ -72,20 +81,27 @@ type MoneyReportHeaderMoreContentBodyProps = { statusBarType: ValueOf | undefined; isReportInSearch: boolean; shouldShowNextStep: boolean; + primaryAction: MoneyReportHeaderActionsProps['primaryAction']; + backTo: Route | undefined; + shouldRenderActionsInRow: boolean; }; -function MoneyReportHeaderMoreContentBody({moneyRequestReport, statusBarType, isReportInSearch, shouldShowNextStep}: MoneyReportHeaderMoreContentBodyProps) { +function MoneyReportHeaderMoreContentBody({ + moneyRequestReport, + statusBarType, + isReportInSearch, + shouldShowNextStep, + primaryAction, + backTo, + shouldRenderActionsInRow, +}: MoneyReportHeaderMoreContentBodyProps) { const styles = useThemeStyles(); - const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); - const shouldDisplayNarrowVersion = shouldUseNarrowLayout || isMediumScreenWidth; - const {isWideRHPDisplayedOnWideLayout, isSuperWideRHPDisplayedOnWideLayout} = useResponsiveLayoutOnWideRHP(); - const shouldDisplayNarrowMoreButton = !shouldDisplayNarrowVersion || isWideRHPDisplayedOnWideLayout || isSuperWideRHPDisplayedOnWideLayout; const reportID = moneyRequestReport?.reportID; const {iouTransactionID} = useMoneyReportTransactionThread(); return ( - + {shouldShowNextStep && } - {isReportInSearch && ( - )} diff --git a/src/components/MoneyRequestHeader.tsx b/src/components/MoneyRequestHeader.tsx index def715741ef2..aae3f6accada 100644 --- a/src/components/MoneyRequestHeader.tsx +++ b/src/components/MoneyRequestHeader.tsx @@ -102,6 +102,7 @@ function MoneyRequestHeader({reportID: reportIDProp, onBackButtonPress}: MoneyRe const shouldDisplayTransactionNavigation = !!(reportID && isReportInRHP); const shouldOpenParentReportInCurrentTab = !isSelfDM(parentReport); const shouldDisplayButtonsInSeparateLine = useShouldDisplayButtonsInSeparateLine() && (wideRHPRouteKeys.length === 0 || isSmallScreenWidth); + const shouldDisplayNarrowVersion = shouldDisplayButtonsInSeparateLine; const getStatusIcon: (src: IconAsset) => ReactNode = (src) => ( - {!shouldDisplayButtonsInSeparateLine && ( + {!shouldDisplayButtonsInSeparateLine && !statusBarProps && ( )} @@ -199,11 +201,19 @@ function MoneyRequestHeader({reportID: reportIDProp, onBackButtonPress}: MoneyRe /> )} {!!statusBarProps && ( - - + + + + + {!shouldDisplayButtonsInSeparateLine && ( + + )} )} diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportNavigation.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportNavigation.tsx index d3506a09dd0f..4299d1b9890d 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportNavigation.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportNavigation.tsx @@ -3,6 +3,7 @@ import {useSearchResultsContext} from '@components/Search/SearchContext'; import Text from '@components/Text'; import useFilterPendingDeleteReports from '@hooks/useFilterPendingDeleteReports'; +import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useSearchSections from '@hooks/useSearchSections'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -21,6 +22,7 @@ import type LastSearchParams from '@src/types/onyx/ReportNavigation'; import type {OnyxEntry} from 'react-native-onyx'; +import {useIsFocused} from '@react-navigation/native'; import React, {startTransition, useEffect, useState} from 'react'; import {View} from 'react-native'; @@ -113,6 +115,8 @@ function MoneyRequestReportNavigationStandalone({onReportsChange}: MoneyRequestR function MoneyRequestReportNavigationContent({reportID, shouldDisplayNarrowVersion, contextReports}: MoneyRequestReportNavigationContentProps) { const styles = useThemeStyles(); + const {translate} = useLocalize(); + const isFocused = useIsFocused(); // Lightweight subscriptions only: the current search query and its loading flag. These never mount // the heavy useSearchSections subscription set, so the fast context path stays cheap. @@ -151,7 +155,7 @@ function MoneyRequestReportNavigationContent({reportID, shouldDisplayNarrowVersi const shouldDisplayNavigationArrows = effectiveAllReports.length > 1 && currentIndex !== -1 && !!lastSearchQuery?.queryJSON; useEffect(() => { - if (!lastSearchQuery?.queryJSON) { + if (!isFocused || !lastSearchQuery?.queryJSON) { return; } @@ -181,7 +185,7 @@ function MoneyRequestReportNavigationContent({reportID, shouldDisplayNarrowVersi ...lastSearchQuery, previousLengthOfResults: effectiveAllReports.length, }); - }, [currentIndex, allReportsCount, effectiveAllReports.length, lastSearchQuery?.queryJSON, lastSearchQuery]); + }, [isFocused, currentIndex, allReportsCount, effectiveAllReports.length, lastSearchQuery?.queryJSON, lastSearchQuery]); const goToReportId = (reportId?: string) => { if (!reportId) { @@ -242,7 +246,11 @@ function MoneyRequestReportNavigationContent({reportID, shouldDisplayNarrowVersi {!shouldUseContextReports && } {shouldDisplayNavigationArrows && ( - {!shouldDisplayNarrowVersion && {`${currentIndex + 1} of ${allReportsCount}`}} + {!shouldDisplayNarrowVersion && ( + + {translate('common.currentOfTotal', {current: currentIndex + 1, total: allReportsCount})} + + )} group.transactions.filter((transaction) => !isTransactionPendingDelete(transaction)).map((transaction) => transaction.transactionID)); }, [groupedTransactions, sortedTransactions, shouldGroupTransactions]); - // Primitive proxy for visualOrderTransactionIDs used as the effect dependency below. - // Other callers (e.g. TransactionDuplicateReview.onPreviewPressed) can write to the same - // Onyx key with a different ordering. Using the raw array reference would cause the effect - // to re-fire on every referential change and overwrite those IDs. The joined string ensures - // the effect only re-fires when the actual content changes. - const visualOrderTransactionIDsKey = useMemo(() => visualOrderTransactionIDs.join(','), [visualOrderTransactionIDs]); + const transactionIDsMembershipKey = useMemo(() => [...visualOrderTransactionIDs].sort().join(','), [visualOrderTransactionIDs]); + + const [latestActiveTransactionIDs] = useOnyx(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS); useEffect(() => { const focusedRoute = findFocusedRoute(navigationRef.getRootState()); if (focusedRoute?.name !== SCREENS.RIGHT_MODAL.SEARCH_REPORT) { return; } + + const anchorTransactionID = (focusedRoute?.params as {anchorTransactionID?: string} | undefined)?.anchorTransactionID; + if (anchorTransactionID && latestActiveTransactionIDs?.includes(anchorTransactionID)) { + return; + } // Don't take over a snapshot-backed carousel (identified by its sibling descriptors, e.g. the Home // "Recently added" flow) that belongs to the transaction thread sitting underneath this report. // Overwriting and then clearing it would drop that carousel when the user navigates back. Row presses @@ -589,12 +591,29 @@ function MoneyRequestReportTransactionList({ if (getActiveTransactionIDs().descriptors) { return; } + + if (visualOrderTransactionIDs.length < 2) { + return; + } + + // Don't take over (and, on unmount, clear) a carousel that already covers this report's transactions. + // It was seeded by a flow sitting underneath this report view (e.g. an expense opened from the Spend + // page), and that still-open RHP depends on it. This uses `>=` so the equal-set case is covered too: + // when the active list is exactly this report's transactions, overwriting is a no-op but the unmount + // cleanup would wipe the carousel the underlying RHP needs (regression #96545). + if ( + latestActiveTransactionIDs && + latestActiveTransactionIDs.length >= visualOrderTransactionIDs.length && + visualOrderTransactionIDs.every((id) => latestActiveTransactionIDs.includes(id)) + ) { + return; + } setActiveTransactionIDs(visualOrderTransactionIDs); return () => { clearActiveTransactionIDs(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps -- visualOrderTransactionIDsKey is a primitive proxy for the array to avoid re-firing on referential-only changes - }, [visualOrderTransactionIDsKey]); + // eslint-disable-next-line react-hooks/exhaustive-deps -- transactionIDsMembershipKey is a membership proxy for the array, and we intentionally don't depend on latestActiveTransactionIDs to avoid re-firing when the carousel changes elsewhere + }, [transactionIDsMembershipKey]); const groupSelectionState = useMemo(() => { const state = new Map(); diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx index 144546c0dc3f..ae6665bfe1e3 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx @@ -1,21 +1,28 @@ import PrevNextButtons from '@components/PrevNextButtons'; +import Text from '@components/Text'; import {useWideRHPActions} from '@components/WideRHPContextProvider'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; import {createTransactionThreadReport, setOptimisticTransactionThread} from '@libs/actions/Report'; import {clearActiveTransactionIDs} from '@libs/actions/TransactionThreadNavigation'; import type {RightModalNavigatorParamList} from '@libs/Navigation/types'; import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import {isOneTransactionReport} from '@libs/ReportUtils'; import {getReportIDToOpenForExpense} from '@libs/TransactionThreadNavigationUtils'; import Navigation from '@navigation/Navigation'; import navigationRef from '@navigation/navigationRef'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import SCREENS from '@src/SCREENS'; +import {hasCompletedGuidedSetupFlowSelector, hasSeenTourSelector} from '@src/selectors/Onboarding'; import type * as OnyxTypes from '@src/types/onyx'; +import {getEmptyObject} from '@src/types/utils/EmptyObject'; import getEmptyArray from '@src/types/utils/getEmptyArray'; import type {GestureResponderEvent} from 'react-native'; @@ -23,39 +30,53 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {findFocusedRoute} from '@react-navigation/native'; import React, {startTransition, useCallback, useEffect, useMemo} from 'react'; +import {View} from 'react-native'; type MoneyRequestReportRHPNavigationButtonsProps = { currentTransactionID: string; isFromReviewDuplicates?: boolean; + shouldDisplayNarrowVersion?: boolean; }; -const parentReportActionIDsSelector = (reportActions: OnyxEntry) => { - const parentActions = new Map(); +const collectParentReportActions = (reportActions: OnyxEntry, parentActions: Record) => { for (const action of Object.values(reportActions ?? {})) { const transactionID = isMoneyRequestAction(action) ? getOriginalMessage(action)?.IOUTransactionID : undefined; if (!transactionID) { continue; } - parentActions.set(transactionID, action); + // eslint-disable-next-line no-param-reassign + parentActions[transactionID] = action; } - return parentActions; }; -function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromReviewDuplicates}: MoneyRequestReportRHPNavigationButtonsProps) { +function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromReviewDuplicates, shouldDisplayNarrowVersion}: MoneyRequestReportRHPNavigationButtonsProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); const [transactionIDsList = getEmptyArray()] = useOnyx(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS); + // When the carousel is opened from a search (e.g. the Spend page), the sibling transactions may only exist + // in the search snapshot and not in the live collection yet. We keep the snapshot around to fall back to it + // so prev/next navigation resolves the correct report instead of breaking. + const [snapshotHash] = useOnyx(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_SNAPSHOT_HASH); + const [snapshot] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${snapshotHash}`); + // Snapshot-backed flows (e.g. Home "Recently added") seed a descriptor per sibling so the carousel can + // resolve (and lazily create) each sibling's thread on demand even when the sibling isn't in the live collection. const [siblingDescriptorsByTransactionID] = useOnyx(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_THREAD_REPORT_IDS); + const {markReportRHPWidth} = useWideRHPActions(); + // Values required to create a transaction thread on the fly when paging onto a multi-transaction + // (batched) parent report that has no existing thread yet (see onNext/onPrevious fallbacks). + const {accountID, email} = useCurrentUserPersonalDetails(); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [betas] = useOnyx(ONYXKEYS.BETAS); - const {email: currentUserEmail, accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); - const {markReportRHPWidth} = useWideRHPActions(); + const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); + const [hasCompletedGuidedSetupFlow] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasCompletedGuidedSetupFlowSelector}); + + const currentTransactionIndex = transactionIDsList.findIndex((id) => id === currentTransactionID); const {prevTransactionID, nextTransactionID} = useMemo(() => { if (!transactionIDsList || transactionIDsList.length < 2) { return {prevTransactionID: undefined, nextTransactionID: undefined}; } - const currentTransactionIndex = transactionIDsList.findIndex((id) => id === currentTransactionID); - const prevID = currentTransactionIndex > 0 ? transactionIDsList.at(currentTransactionIndex - 1) : undefined; const nextID = transactionIDsList.at(currentTransactionIndex + 1); @@ -63,12 +84,15 @@ function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromR prevTransactionID: prevID, nextTransactionID: nextID, }; - }, [currentTransactionID, transactionIDsList]); + }, [currentTransactionIndex, transactionIDsList]); const prevNextTransactionsSelector = useCallback( (allTransactions: OnyxCollection) => - [currentTransactionID, prevTransactionID, nextTransactionID].map((transactionID) => allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]), - [currentTransactionID, nextTransactionID, prevTransactionID], + [currentTransactionID, prevTransactionID, nextTransactionID].map((transactionID) => { + const key = `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}` as const; + return allTransactions?.[key] ?? snapshot?.data?.[key]; + }), + [currentTransactionID, nextTransactionID, prevTransactionID, snapshot], ); const [[currentTransaction, prevTransaction, nextTransaction] = getEmptyArray()] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, { @@ -77,16 +101,32 @@ function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromR const parentReportActionsSelector = useCallback( (allReportActions: OnyxCollection) => { - let reportActions = {}; + // Build the transactionID -> IOU action map in a single pass. We deliberately avoid merging the + // report actions into one intermediate object (repeated spreads are O(n²) and, with a snapshot, + // would copy every report's actions), since this selector re-runs on any report-action change. + // We return a plain object (not a Map) because useOnyx's deepEqual is very slow for Maps. + const parentActions: Record = {}; + // Reported transactions keep their IOU action on their own report (reportActions_). for (const transaction of [currentTransaction, prevTransaction, nextTransaction]) { - reportActions = {...reportActions, ...allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transaction?.reportID}`]}; + const key = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transaction?.reportID}` as const; + collectParentReportActions(allReportActions?.[key] ?? (snapshot?.data?.[key] as OnyxTypes.ReportActions | undefined), parentActions); + } + // Unreported transactions (reportID '0') keep their IOU action on a different report (e.g. a self-DM), + // so it isn't under reportActions_0. Scan every report's actions from the search snapshot so the action + // (and its childReportID thread) can still be located by IOUTransactionID. + if (snapshot?.data) { + for (const [key, reportActionsForReport] of Object.entries(snapshot.data)) { + if (key.startsWith(ONYXKEYS.COLLECTION.REPORT_ACTIONS)) { + collectParentReportActions(reportActionsForReport as OnyxTypes.ReportActions, parentActions); + } + } } - return parentReportActionIDsSelector(reportActions); + return parentActions; }, - [currentTransaction, nextTransaction, prevTransaction], + [currentTransaction, nextTransaction, prevTransaction, snapshot], ); - const [parentReportActions = new Map()] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS, { + const [parentReportActions = getEmptyObject>()] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS, { selector: parentReportActionsSelector, }); @@ -96,15 +136,27 @@ function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromR } return { - prevParentReportAction: prevTransactionID ? parentReportActions.get(prevTransactionID) : undefined, - nextParentReportAction: nextTransactionID ? parentReportActions.get(nextTransactionID) : undefined, + prevParentReportAction: prevTransactionID ? parentReportActions[prevTransactionID] : undefined, + nextParentReportAction: nextTransactionID ? parentReportActions[nextTransactionID] : undefined, }; }, [nextTransactionID, parentReportActions, prevTransactionID, transactionIDsList]); - const [prevParentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${prevTransaction?.reportID}`); - const [nextParentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${nextTransaction?.reportID}`); - const [prevThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${prevParentReportAction?.childReportID}`); - const [nextThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${nextParentReportAction?.childReportID}`); + // The "parent report" is where the transaction's IOU action lives: the expense report for reported transactions, + // or a self-DM for unreported ones (whose transaction.reportID is '0'). Derive it from the action so unreported + // transactions resolve to the correct parent instead of report '0'. Fall back to the transaction's reportID. + const prevParentReportID = prevParentReportAction?.reportID ?? prevTransaction?.reportID; + const nextParentReportID = nextParentReportAction?.reportID ?? nextTransaction?.reportID; + + const [livePrevThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${prevParentReportAction?.childReportID}`); + const [liveNextThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${nextParentReportAction?.childReportID}`); + const [livePrevTransactionParentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${prevParentReportID}`); + const [liveNextTransactionParentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${nextParentReportID}`); + + // Fall back to the search snapshot for reports that aren't in the live collection yet. + const prevThreadReport = livePrevThreadReport ?? snapshot?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${prevParentReportAction?.childReportID}`]; + const nextThreadReport = liveNextThreadReport ?? snapshot?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${nextParentReportAction?.childReportID}`]; + const prevTransactionParentReport = livePrevTransactionParentReport ?? snapshot?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${prevParentReportID}`]; + const nextTransactionParentReport = liveNextTransactionParentReport ?? snapshot?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${nextParentReportID}`]; /** * We clear the sibling transactionThreadIDs when unmounting this component @@ -124,15 +176,31 @@ function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromR return; } - const onNext = (e: GestureResponderEvent | KeyboardEvent | undefined) => { - e?.preventDefault(); - + const getBackTo = () => { let backTo = Navigation.getActiveRoute(); if (isFromReviewDuplicates) { const currentRoute = navigationRef.getCurrentRoute(); const params = currentRoute?.params as RightModalNavigatorParamList[typeof SCREENS.RIGHT_MODAL.SEARCH_REPORT] | undefined; backTo = params?.backTo ?? backTo; } + return backTo; + }; + + const onNext = (e: GestureResponderEvent | KeyboardEvent | undefined) => { + e?.preventDefault(); + const backTo = getBackTo(); + + // If the next expense's parent is a one-transaction report, navigate to the parent report instead of the + // thread. This keeps the view at the same level (parent) so report-level primary actions (Approve, etc.) + // are preserved when navigating back. Mirrors the open-from-list logic in Search/index.tsx#onSelectRow. + // Skip for unreported transactions (reportID '0'): they have no parent report to land on, so they must open + // their transaction thread (handled below). + if (isOneTransactionReport(nextTransactionParentReport) && nextTransaction?.reportID && nextTransaction.reportID !== CONST.REPORT.UNREPORTED_REPORT_ID) { + const targetReportID = nextTransaction.reportID; + markReportRHPWidth(targetReportID, 'wide'); + requestAnimationFrame(() => startTransition(() => Navigation.setParams({reportID: targetReportID, reportActionID: undefined, backTo}))); + return; + } // Snapshot-backed flows (e.g. Home "Recently added") seed a descriptor per sibling because the sibling // transactions may be absent from the main Onyx collections. Resolve the target sibling lazily here so @@ -140,100 +208,124 @@ function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromR // hydrate it on arrival. const nextDescriptor = nextTransactionID ? siblingDescriptorsByTransactionID?.[nextTransactionID] : undefined; if (nextDescriptor) { - requestAnimationFrame(() => { - const nextReportID = getReportIDToOpenForExpense(nextDescriptor, {introSelected, betas, currentUserEmail, currentUserAccountID}); - markReportRHPWidth(nextReportID, 'wide'); - requestAnimationFrame(() => startTransition(() => Navigation.setParams({reportID: nextReportID, reportActionID: undefined, backTo}))); - }); + const nextReportID = getReportIDToOpenForExpense(nextDescriptor, {introSelected, betas, currentUserEmail: email, currentUserAccountID: accountID}); + markReportRHPWidth(nextReportID, 'wide'); + requestAnimationFrame(() => startTransition(() => Navigation.setParams({reportID: nextReportID, reportActionID: undefined, backTo}))); return; } const nextThreadReportID = nextParentReportAction?.childReportID; const navigationParams = {reportID: nextThreadReportID, reportActionID: undefined, backTo}; - requestAnimationFrame(() => { - if (nextThreadReportID) { - markReportRHPWidth(nextThreadReportID, 'wide'); - } - // We know that the next thread report exists, it just wasn't fetched to Onyx yet, so we set it optimistically. - if (!nextThreadReport && nextThreadReportID) { - setOptimisticTransactionThread(nextThreadReportID, nextParentReport?.reportID, nextParentReportAction?.reportActionID, nextParentReport?.policyID); - } - // The transaction thread doesn't exist yet, so we should create it - if (!nextThreadReportID) { - const transactionThreadReport = createTransactionThreadReport({ - introSelected, - currentUserLogin: currentUserEmail ?? '', - currentUserAccountID, - betas, - iouReport: nextParentReport, - iouReportAction: nextParentReportAction, - transaction: nextTransaction, - }); - navigationParams.reportID = transactionThreadReport?.reportID; - } - // Wait for the next frame to ensure Onyx has processed the optimistic data updates from setOptimisticTransactionThread or createTransactionThreadReport before navigating - requestAnimationFrame(() => startTransition(() => Navigation.setParams(navigationParams))); - }); + // No existing transaction thread for this IOU action. We reach here only after the + // one-transaction-report branch above, so the parent is a MULTI-transaction (batched) report. + // Navigating to that parent reportID would render the whole report (several expenses) instead of a + // single expense. Create the transaction thread (the same way Search/index.tsx#onSelectRow does on + // first open) so we land on a single-expense view, then navigate to the new thread report. + // createTransactionThreadReport issues a real OpenReport with a server-recognized generated reportID, + // so it doesn't hit the optimistic-reportID race that setOptimisticTransactionThread + setParams does. + if (!nextThreadReportID && nextTransaction?.reportID && nextTransaction.reportID !== CONST.REPORT.UNREPORTED_REPORT_ID) { + const optimisticThread = createTransactionThreadReport({ + introSelected, + currentUserLogin: email ?? '', + currentUserAccountID: accountID, + betas, + iouReport: nextTransactionParentReport, + iouReportAction: nextParentReportAction, + transaction: nextTransaction, + isSelfTourViewed, + hasCompletedGuidedSetupFlow, + }); + const targetReportID = optimisticThread?.reportID ?? nextTransaction.reportID; + markReportRHPWidth(targetReportID, 'wide'); + requestAnimationFrame(() => startTransition(() => Navigation.setParams({reportID: targetReportID, reportActionID: undefined, anchorTransactionID: nextTransactionID, backTo}))); + return; + } + + if (nextThreadReportID) { + markReportRHPWidth(nextThreadReportID, 'wide'); + } + // We know that the next thread report exists, it just wasn't fetched to Onyx yet, so we set it optimistically. + // Important: use nextTransactionParentReport (the NEXT transaction's own parent), NOT parentReport + // (the CURRENT transaction's parent). Passing wrong linkage causes the OpenReport response to wipe + // the optimistic data, which trips useReportWasDeleted → ReportNavigateAwayHandler → Inbox/parent redirect. + if (!nextThreadReport && nextThreadReportID) { + setOptimisticTransactionThread(nextThreadReportID, nextTransactionParentReport?.reportID, nextParentReportAction?.reportActionID, nextTransactionParentReport?.policyID); + } + // Wait for the next frame to ensure Onyx has processed the optimistic data updates from setOptimisticTransactionThread before navigating + requestAnimationFrame(() => startTransition(() => Navigation.setParams(navigationParams))); }; const onPrevious = (e: GestureResponderEvent | KeyboardEvent | undefined) => { e?.preventDefault(); + const backTo = getBackTo(); - let backTo = Navigation.getActiveRoute(); - if (isFromReviewDuplicates) { - const currentRoute = navigationRef.getCurrentRoute(); - const params = currentRoute?.params as RightModalNavigatorParamList[typeof SCREENS.RIGHT_MODAL.SEARCH_REPORT] | undefined; - backTo = params?.backTo ?? backTo; + // See onNext for the rationale behind the one-transaction-parent branch (and the unreported skip). + if (isOneTransactionReport(prevTransactionParentReport) && prevTransaction?.reportID && prevTransaction.reportID !== CONST.REPORT.UNREPORTED_REPORT_ID) { + const targetReportID = prevTransaction.reportID; + markReportRHPWidth(targetReportID, 'wide'); + requestAnimationFrame(() => startTransition(() => Navigation.setParams({reportID: targetReportID, reportActionID: undefined, backTo}))); + return; } // See onNext: resolve the target sibling lazily from its descriptor when present. const prevDescriptor = prevTransactionID ? siblingDescriptorsByTransactionID?.[prevTransactionID] : undefined; if (prevDescriptor) { - requestAnimationFrame(() => { - const prevReportID = getReportIDToOpenForExpense(prevDescriptor, {introSelected, betas, currentUserEmail, currentUserAccountID}); - markReportRHPWidth(prevReportID, 'wide'); - requestAnimationFrame(() => startTransition(() => Navigation.setParams({reportID: prevReportID, reportActionID: undefined, backTo}))); - }); + const prevReportID = getReportIDToOpenForExpense(prevDescriptor, {introSelected, betas, currentUserEmail: email, currentUserAccountID: accountID}); + markReportRHPWidth(prevReportID, 'wide'); + requestAnimationFrame(() => startTransition(() => Navigation.setParams({reportID: prevReportID, reportActionID: undefined, backTo}))); return; } const prevThreadReportID = prevParentReportAction?.childReportID; const navigationParams = {reportID: prevThreadReportID, reportActionID: undefined, backTo}; - requestAnimationFrame(() => { - if (prevThreadReportID) { - markReportRHPWidth(prevThreadReportID, 'wide'); - } - // We know that the previous thread report exists, it just wasn't fetched to Onyx yet, so we set it optimistically. - if (!prevThreadReport && prevThreadReportID) { - setOptimisticTransactionThread(prevThreadReportID, prevParentReport?.reportID, prevParentReportAction?.reportActionID, prevParentReport?.policyID); - } - // The transaction thread doesn't exist yet, so we should create it - if (!prevThreadReportID) { - const transactionThreadReport = createTransactionThreadReport({ - introSelected, - currentUserLogin: currentUserEmail ?? '', - currentUserAccountID, - betas, - iouReport: prevParentReport, - iouReportAction: prevParentReportAction, - transaction: prevTransaction, - }); - navigationParams.reportID = transactionThreadReport?.reportID; - } - // Wait for the next frame to ensure Onyx has processed the optimistic data updates from setOptimisticTransactionThread or createTransactionThreadReport before navigating - requestAnimationFrame(() => startTransition(() => Navigation.setParams(navigationParams))); - }); + // See onNext for the rationale: the parent here is a MULTI-transaction (batched) report, so create the + // transaction thread to land on a single-expense view instead of navigating to the whole parent report. + if (!prevThreadReportID && prevTransaction?.reportID && prevTransaction.reportID !== CONST.REPORT.UNREPORTED_REPORT_ID) { + const optimisticThread = createTransactionThreadReport({ + introSelected, + currentUserLogin: email ?? '', + currentUserAccountID: accountID, + betas, + iouReport: prevTransactionParentReport, + iouReportAction: prevParentReportAction, + transaction: prevTransaction, + isSelfTourViewed, + hasCompletedGuidedSetupFlow, + }); + const targetReportID = optimisticThread?.reportID ?? prevTransaction.reportID; + markReportRHPWidth(targetReportID, 'wide'); + requestAnimationFrame(() => startTransition(() => Navigation.setParams({reportID: targetReportID, reportActionID: undefined, anchorTransactionID: prevTransactionID, backTo}))); + return; + } + + if (prevThreadReportID) { + markReportRHPWidth(prevThreadReportID, 'wide'); + } + // See onNext for the rationale: use prevTransactionParentReport (the PREV transaction's own parent) + // instead of parentReport (the CURRENT transaction's parent) so the optimistic linkage matches the server. + if (!prevThreadReport && prevThreadReportID) { + setOptimisticTransactionThread(prevThreadReportID, prevTransactionParentReport?.reportID, prevParentReportAction?.reportActionID, prevTransactionParentReport?.policyID); + } + // Wait for the next frame to ensure Onyx has processed the optimistic data updates from setOptimisticTransactionThread before navigating + requestAnimationFrame(() => startTransition(() => Navigation.setParams(navigationParams))); }; return ( - + + {!shouldDisplayNarrowVersion && currentTransactionIndex !== -1 && ( + + {translate('common.currentOfTotal', {current: currentTransactionIndex + 1, total: transactionIDsList.length})} + + )} + + ); } diff --git a/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx b/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx index 47746cb3dc2e..b76fc16623d6 100644 --- a/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx @@ -249,12 +249,12 @@ function TransactionGroupListExpandedImpl({ // When opening the transaction thread in RHP we need to find every other ID for the rest of transactions // to display prev/next arrows in RHP for navigation if (isModifiedMousePress(event)) { - setActiveTransactionIDs(siblingTransactionIDs); + setActiveTransactionIDs(siblingTransactionIDs, transactionsQueryJSON?.hash); navigateToTransactionThread(); return; } - setActiveTransactionIDs(siblingTransactionIDs).then(navigateToTransactionThread); + setActiveTransactionIDs(siblingTransactionIDs, transactionsQueryJSON?.hash).then(navigateToTransactionThread); }; const onShowMoreButtonPress = () => { diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 429c8b9434b6..bbf41a2947f5 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -23,6 +23,7 @@ import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; import {saveLastSearchParams} from '@libs/actions/ReportNavigation'; import type {TransactionPreviewData} from '@libs/actions/Search'; import {setOptimisticDataForTransactionThreadPreview} from '@libs/actions/Search'; +import {clearActiveTransactionIDs, setActiveTransactionIDs} from '@libs/actions/TransactionThreadNavigation'; import {flushDeferredWrite, hasDeferredWrite} from '@libs/deferredLayoutWrite'; import Log from '@libs/Log'; import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; @@ -60,7 +61,7 @@ import { } from '@libs/telemetry/navigateToReportsSpans'; import {cancelSubmitFollowUpActionSpan, getPendingSubmitFollowUpAction} from '@libs/telemetry/submitFollowUpAction'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; -import {isTransactionPendingDelete, shouldShowAttendees} from '@libs/TransactionUtils'; +import {isDeletedTransaction, isTransactionPendingDelete, shouldShowAttendees} from '@libs/TransactionUtils'; import Navigation, {navigationRef} from '@navigation/Navigation'; import type {SearchFullscreenNavigatorParamList} from '@navigation/types'; @@ -512,6 +513,23 @@ function Search({ const isTransactionItem = isTransactionListItemType(item); const backTo = Navigation.getActiveRoute(); + + // When opening an expense from the Spend page (flat transaction list), populate the carousel + // with all sibling transactions so prev/next navigation works in the RHP transaction view. + if (isTransactionItem) { + const siblingTransactionIDs = (filteredData as SearchListItem[]) + .filter( + (t): t is TransactionListItemType => + !!t && isTransactionListItemType(t) && t.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !isDeletedTransaction(t), + ) + .map((t) => t.transactionID); + if (siblingTransactionIDs.length > 1) { + setActiveTransactionIDs(siblingTransactionIDs, hash); + } else { + clearActiveTransactionIDs(); + } + } + // If we're trying to open a transaction without a transaction thread, let's create the thread and navigate the user if (isTransactionItem && !item?.reportAction?.childReportID) { // If the report is unreported (self DM), we want to open the track expense thread instead of a report with an ID of 0 @@ -667,9 +685,11 @@ function Search({ email, accountID, queryJSON, + hash, offset, searchResults?.search?.hasMoreResults, currentSearchKey, + filteredData, ], ); diff --git a/src/languages/de.ts b/src/languages/de.ts index 1e5a4131ca3a..b805f4fab4c0 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -520,6 +520,7 @@ const translations: TranslationDeepObject = { previousYear: 'Vorheriges Jahr', nextYear: 'Nächstes Jahr', avatar: 'Avatar', + currentOfTotal: ({current, total}: {current: number; total: number}) => `${current} von ${total}`, editor: 'Editor', restrictions: 'Beschränkungen', tryAgain: 'Erneut versuchen', diff --git a/src/languages/en.ts b/src/languages/en.ts index 75bfadee2ca7..12e2150dcdf2 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -380,6 +380,8 @@ const translations = { automatic: 'Automatic', showing: 'Showing', of: 'of', + // @context Carousel pagination counter showing the current item's position out of the total (e.g. "3 of 50"). + currentOfTotal: ({current, total}: {current: number; total: number}) => `${current} of ${total}`, default: 'Default', update: 'Update', member: 'Member', diff --git a/src/languages/es.ts b/src/languages/es.ts index 5e173ef30b78..d50506f9c387 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -475,6 +475,7 @@ const translations: TranslationDeepObject = { goToConcierge: 'Ir a Concierge', allSet: '¡Todo listo!', enterDigitLabel: ({digitIndex, totalDigits}: {digitIndex: number; totalDigits: number}) => `introducir dígito ${digitIndex} de ${totalDigits}`, + currentOfTotal: ({current, total}: {current: number; total: number}) => `${current} de ${total}`, apiKey: 'Clave API', editor: 'Editor', restrictions: 'Restricciones', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 664cee6663fb..b90affc227c2 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -520,6 +520,7 @@ const translations: TranslationDeepObject = { previousYear: 'Année précédente', nextYear: 'L’an prochain', avatar: 'Avatar', + currentOfTotal: ({current, total}: {current: number; total: number}) => `${current} sur ${total}`, editor: 'Éditeur', restrictions: 'Restrictions', tryAgain: 'Réessayer', diff --git a/src/languages/it.ts b/src/languages/it.ts index 6dc456a27c0c..ed2897fad4c8 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -520,6 +520,7 @@ const translations: TranslationDeepObject = { previousYear: 'Anno precedente', nextYear: "L'anno prossimo", avatar: 'Avatar', + currentOfTotal: ({current, total}: {current: number; total: number}) => `${current} di ${total}`, editor: 'Editor', restrictions: 'Restrizioni', tryAgain: 'Riprova', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 11db3c3def10..01ad739c36a2 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -519,6 +519,7 @@ const translations: TranslationDeepObject = { previousYear: '前年', nextYear: '来年', avatar: 'アバター', + currentOfTotal: ({current, total}: {current: number; total: number}) => `${total} 件中 ${current} 件目`, editor: '編集者', restrictions: '制限', tryAgain: '再試行', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index c774982e5ffe..f097cef85daf 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -519,6 +519,7 @@ const translations: TranslationDeepObject = { previousYear: 'Vorig jaar', nextYear: 'Volgend jaar', avatar: 'Avatar', + currentOfTotal: ({current, total}: {current: number; total: number}) => `${current} van ${total}`, editor: 'Editor', restrictions: 'Beperkingen', tryAgain: 'Probeer het opnieuw', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index ac30cc2c6efc..445c6f68e4a0 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -519,6 +519,7 @@ const translations: TranslationDeepObject = { previousYear: 'Poprzedni rok', nextYear: 'W przyszłym roku', avatar: 'Avatar', + currentOfTotal: ({current, total}: {current: number; total: number}) => `${current} z ${total}`, editor: 'Edytor', restrictions: 'Ograniczenia', tryAgain: 'Spróbuj ponownie', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index fe6a996bfccf..1316c4ba6400 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -518,6 +518,7 @@ const translations: TranslationDeepObject = { previousYear: 'Ano anterior', nextYear: 'Ano que vem', avatar: 'Avatar', + currentOfTotal: ({current, total}: {current: number; total: number}) => `${current} de ${total}`, editor: 'Editor', restrictions: 'Restrições', tryAgain: 'Tentar novamente', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 487d47fe7611..7633b0b288d6 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -515,6 +515,7 @@ const translations: TranslationDeepObject = { previousYear: '上一年', nextYear: '明年', avatar: '头像', + currentOfTotal: ({current, total}: {current: number; total: number}) => `第 ${current} 项(共 ${total} 项)`, editor: '编辑', restrictions: '限制', tryAgain: '重试', diff --git a/src/libs/ExportOnyxState/common.ts b/src/libs/ExportOnyxState/common.ts index 7080baabedaf..72dfe51282bd 100644 --- a/src/libs/ExportOnyxState/common.ts +++ b/src/libs/ExportOnyxState/common.ts @@ -305,6 +305,7 @@ const safeOnyxKeys = new Set([ ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_PENDING, ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_SUCCESSFUL, ONYXKEYS.TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE, + ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_SNAPSHOT_HASH, ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS, ONYXKEYS.TRAVEL_INVOICE_STATEMENT, ONYXKEYS.VALIDATE_DOMAIN_TWO_FACTOR_CODE, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 0cbaa1994b82..b8945d4bbe23 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -2699,6 +2699,10 @@ type RightModalNavigatorParamList = { [SCREENS.RIGHT_MODAL.SEARCH_REPORT]: { reportID: string; reportActionID?: string; + // Set by the transaction carousel when navigating into a multi-tx parent that lacks an + // existing thread. Tells MoneyReportHeader which of the parent's transactions to anchor + // the transaction carousel on, so the user can keep paging through the broader carousel. + anchorTransactionID?: string; shouldReplaceWithExpenseReportRHP?: string; // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md backTo?: Routes; diff --git a/src/libs/actions/TransactionThreadNavigation.ts b/src/libs/actions/TransactionThreadNavigation.ts index 4e87112d3579..ab0f1131fbb4 100644 --- a/src/libs/actions/TransactionThreadNavigation.ts +++ b/src/libs/actions/TransactionThreadNavigation.ts @@ -18,6 +18,7 @@ import Onyx from 'react-native-onyx'; */ let lastSetIDs: string[] | null = null; +let lastSetSnapshotHash: number | null = null; let lastSetDescriptors: Record | null = null; function areDescriptorMapsEqual(a: Record | null, b: Record | null) { @@ -45,19 +46,35 @@ function areDescriptorMapsEqual(a: Record sibling + * descriptor so the carousel resolves (and lazily creates) each sibling's thread on demand. */ -function setActiveTransactionIDs(ids: string[], siblingDescriptorsByTransactionID?: Record) { +function setActiveTransactionIDs(ids: string[], snapshotHash?: number, siblingDescriptorsByTransactionID?: Record) { + const nextSnapshotHash = snapshotHash ?? null; const nextDescriptors = siblingDescriptorsByTransactionID ?? null; - const sameIDs = lastSetIDs?.length === ids.length && lastSetIDs.every((id, i) => id === ids.at(i)); - if (sameIDs && areDescriptorMapsEqual(lastSetDescriptors, nextDescriptors)) { + // The comparison is positional on purpose: the array order defines the carousel's prev/next order, + // so the same IDs in a different order (e.g. after the source list is re-sorted) are a real change + // and must be written to Onyx. + const areIDsUnchanged = lastSetIDs?.length === ids.length && lastSetIDs.every((id, i) => id === ids.at(i)); + if (areIDsUnchanged && lastSetSnapshotHash === nextSnapshotHash && areDescriptorMapsEqual(lastSetDescriptors, nextDescriptors)) { return Promise.resolve(); } lastSetIDs = ids; + lastSetSnapshotHash = nextSnapshotHash; lastSetDescriptors = nextDescriptors; - return Promise.all([Onyx.set(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS, ids), Onyx.set(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_THREAD_REPORT_IDS, nextDescriptors)]); + return Promise.all([ + Onyx.set(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS, ids), + Onyx.set(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_SNAPSHOT_HASH, nextSnapshotHash), + Onyx.set(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_THREAD_REPORT_IDS, nextDescriptors), + ]); } /** @@ -71,8 +88,13 @@ function getActiveTransactionIDs(): {ids: string[] | null; descriptors: Record { + setActiveTransactionIDs(siblingTransactionIDs, undefined, siblingDescriptorsByTransactionID).then(() => { markReportRHPWidth(reportID, 'wide'); Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID, backTo: ROUTES.HOME})); }); diff --git a/src/pages/inbox/ReportNavigateAwayHandler.tsx b/src/pages/inbox/ReportNavigateAwayHandler.tsx index 85fd839b685a..fef241d25c66 100644 --- a/src/pages/inbox/ReportNavigateAwayHandler.tsx +++ b/src/pages/inbox/ReportNavigateAwayHandler.tsx @@ -170,15 +170,11 @@ function ReportNavigateAwayHandler() { const didReportClose = wasReportRemoved && prevReport.statusNum === CONST.REPORT.STATUS_NUM.OPEN && report?.statusNum === CONST.REPORT.STATUS_NUM.CLOSED; const isTopLevelPolicyRoomWithNoStatus = !report?.statusNum && !prevReport?.parentReportID && prevReport?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_ROOM; const isClosedTopLevelPolicyRoom = wasReportRemoved && prevReport.statusNum === CONST.REPORT.STATUS_NUM.OPEN && isTopLevelPolicyRoomWithNoStatus; + const userLeavingTriggered = !prevUserLeavingStatus && !!userLeavingStatus; + const deletedParentTriggered = prevDeletedParentAction && !deletedParentAction; + const shouldTrigger = userLeavingTriggered || didReportClose || isRemovalExpectedForReportType || isClosedTopLevelPolicyRoom || deletedParentTriggered; // Navigate to the Concierge chat if the room was removed from another device (e.g. user leaving a room or removed from a room) - if ( - // non-optimistic case - (!prevUserLeavingStatus && !!userLeavingStatus) || - didReportClose || - isRemovalExpectedForReportType || - isClosedTopLevelPolicyRoom || - (prevDeletedParentAction && !deletedParentAction) - ) { + if (shouldTrigger) { navigateAwayFromReport(prevOnyxReportID, prevReport?.parentReportID); } }, [ diff --git a/src/styles/utils/sizing.ts b/src/styles/utils/sizing.ts index 216a5e90c82d..4e8e41c484cc 100644 --- a/src/styles/utils/sizing.ts +++ b/src/styles/utils/sizing.ts @@ -92,6 +92,10 @@ export default { minWidth: 8, }, + mnw8: { + minWidth: 32, + }, + mnw25: { minWidth: '25%', }, diff --git a/tests/ui/components/MoneyReportHeaderMoreContentTest.tsx b/tests/ui/components/MoneyReportHeaderMoreContentTest.tsx index 0cb04c080d37..62dfed96b889 100644 --- a/tests/ui/components/MoneyReportHeaderMoreContentTest.tsx +++ b/tests/ui/components/MoneyReportHeaderMoreContentTest.tsx @@ -95,26 +95,54 @@ describe('MoneyReportHeaderMoreContent', () => { it('renders the next step bar for a Submit workspace', () => { mockPolicyType(CONST.POLICY.TYPE.SUBMIT); - render(); + render( + , + ); expect(mockedNextStepBar).toHaveBeenCalled(); }); it('renders the next step bar for a paid (team) workspace', () => { mockPolicyType(CONST.POLICY.TYPE.TEAM); - render(); + render( + , + ); expect(mockedNextStepBar).toHaveBeenCalled(); }); it('does not render the next step bar for a personal workspace', () => { mockPolicyType(CONST.POLICY.TYPE.PERSONAL); - render(); + render( + , + ); expect(mockedNextStepBar).not.toHaveBeenCalled(); }); it('does not render the next step bar when a status bar is shown', () => { mockPolicyType(CONST.POLICY.TYPE.SUBMIT); mockedStatusBar.mockReturnValue({shouldShowStatusBar: true, statusBarType: CONST.REPORT.STATUS_BAR_TYPE.ON_HOLD}); - render(); + render( + , + ); expect(mockedNextStepBar).not.toHaveBeenCalled(); }); }); diff --git a/tests/unit/components/MoneyRequestReportTransactionsNavigation.test.tsx b/tests/unit/components/MoneyRequestReportTransactionsNavigation.test.tsx new file mode 100644 index 000000000000..273a52c13704 --- /dev/null +++ b/tests/unit/components/MoneyRequestReportTransactionsNavigation.test.tsx @@ -0,0 +1,374 @@ +import {fireEvent, render, screen} from '@testing-library/react-native'; + +import MoneyRequestReportTransactionsNavigation from '@components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation'; + +import {createTransactionThreadReport} from '@libs/actions/Report'; +import {getReportIDToOpenForExpense} from '@libs/TransactionThreadNavigationUtils'; + +import Navigation from '@navigation/Navigation'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import React from 'react'; + +/** + * These tests verify the navigation resolution of MoneyRequestReportTransactionsNavigation: + * given a transaction list (and optionally a search snapshot), pressing prev/next should resolve + * and navigate to the correct target reportID for each direction. The heavy hooks are mocked so + * the real selectors and the onNext/onPrevious branching logic are exercised in isolation. + */ + +type MockOnyxState = { + transactionIDsList: string[] | undefined; + snapshotHash: string | undefined; + snapshot: {data: Record} | undefined; + siblingDescriptors: Record | undefined; + transactionsCollection: Record; + reportActionsCollection: Record; + reportsCollection: Record; +}; + +const mockState: MockOnyxState = { + transactionIDsList: undefined, + snapshotHash: undefined, + snapshot: undefined, + siblingDescriptors: undefined, + transactionsCollection: {}, + reportActionsCollection: {}, + reportsCollection: {}, +}; + +const mockUseOnyx = jest.fn(); +const mockMarkReportRHPWidth = jest.fn(); + +jest.mock('@hooks/useOnyx', () => ({ + __esModule: true, + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + default: (...args: unknown[]) => mockUseOnyx(...args), +})); + +jest.mock('@hooks/useThemeStyles', () => ({ + __esModule: true, + default: () => ({}), +})); + +jest.mock('@hooks/useLocalize', () => ({ + __esModule: true, + default: () => ({translate: (key: string) => key}), +})); + +jest.mock('@hooks/useCurrentUserPersonalDetails', () => ({ + __esModule: true, + default: () => ({accountID: 1, email: 'me@example.com'}), +})); + +jest.mock('@components/WideRHPContextProvider', () => ({ + useWideRHPActions: () => ({markReportRHPWidth: mockMarkReportRHPWidth}), +})); + +type ReactActual = {createElement: typeof React.createElement; Fragment: typeof React.Fragment}; +type ReactNativeActual = { + Pressable: React.ComponentType<{testID?: string; disabled?: boolean; onPress?: () => void}>; + Text: React.ComponentType<{children?: React.ReactNode}>; +}; + +jest.mock('@components/Text', () => { + const {Text} = jest.requireActual('react-native'); + return {__esModule: true, default: Text}; +}); + +jest.mock('@components/PrevNextButtons', () => { + const ReactLib = jest.requireActual('react'); + const {Pressable} = jest.requireActual('react-native'); + return { + __esModule: true, + default: (props: {onNext: () => void; onPrevious: () => void; isNextButtonDisabled?: boolean; isPrevButtonDisabled?: boolean}) => + ReactLib.createElement( + ReactLib.Fragment, + null, + ReactLib.createElement(Pressable, {testID: 'prev-button', disabled: props.isPrevButtonDisabled, onPress: () => props.onPrevious()}), + ReactLib.createElement(Pressable, {testID: 'next-button', disabled: props.isNextButtonDisabled, onPress: () => props.onNext()}), + ), + }; +}); + +jest.mock('@navigation/Navigation', () => ({ + __esModule: true, + default: { + setParams: jest.fn(), + getActiveRoute: jest.fn(() => 'active-route'), + }, +})); + +jest.mock('@navigation/navigationRef', () => ({ + __esModule: true, + default: { + getRootState: jest.fn(() => ({index: 0, routes: [{key: 'k', name: 'testRoute'}]})), + getCurrentRoute: jest.fn(() => undefined), + }, +})); + +jest.mock('@libs/actions/Report', () => ({ + createTransactionThreadReport: jest.fn(() => undefined), + setOptimisticTransactionThread: jest.fn(), +})); + +jest.mock('@libs/actions/TransactionThreadNavigation', () => ({ + clearActiveTransactionIDs: jest.fn(), +})); + +jest.mock('@libs/TransactionThreadNavigationUtils', () => ({ + getReportIDToOpenForExpense: jest.fn(() => 'resolved-descriptor-report'), +})); + +const makeIOUAction = (transactionID: string, {childReportID, reportID}: {childReportID?: string; reportID: string}) => ({ + reportActionID: `action_${transactionID}`, + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + originalMessage: {IOUTransactionID: transactionID, type: 'create'}, + childReportID, + reportID, +}); + +const CURRENT_ID = 'tCur'; +const PREV_ID = 'tPrev'; +const NEXT_ID = 'tNext'; + +const resetMockState = () => { + mockState.transactionIDsList = [PREV_ID, CURRENT_ID, NEXT_ID]; + mockState.snapshotHash = undefined; + mockState.snapshot = undefined; + mockState.siblingDescriptors = undefined; + mockState.transactionsCollection = {}; + mockState.reportActionsCollection = {}; + mockState.reportsCollection = {}; +}; + +const setupUseOnyx = () => { + mockUseOnyx.mockImplementation((key: string, options?: {selector?: (data: unknown) => unknown}) => { + const selector = options?.selector; + if (key === ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS) { + return [mockState.transactionIDsList]; + } + if (key === ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_SNAPSHOT_HASH) { + return [mockState.snapshotHash]; + } + if (key === `${ONYXKEYS.COLLECTION.SNAPSHOT}${mockState.snapshotHash}`) { + return [mockState.snapshot]; + } + if (key === ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_THREAD_REPORT_IDS) { + return [mockState.siblingDescriptors]; + } + if (key === ONYXKEYS.COLLECTION.TRANSACTION) { + return [selector ? selector(mockState.transactionsCollection) : undefined]; + } + if (key === ONYXKEYS.COLLECTION.REPORT_ACTIONS) { + return [selector ? selector(mockState.reportActionsCollection) : undefined]; + } + if (key.startsWith(ONYXKEYS.COLLECTION.REPORT)) { + return [mockState.reportsCollection[key]]; + } + // NVP_ONBOARDING (selector-based), NVP_INTRO_SELECTED, BETAS and anything else are not relevant to resolution. + return [undefined]; + }); +}; + +const renderNavigation = () => render(); + +// Navigation.setParams is deferred inside requestAnimationFrame. Run it synchronously so the resolved +// navigation happens during the press and can be asserted immediately afterwards. +const press = (testID: string) => { + global.requestAnimationFrame = (callback: FrameRequestCallback) => { + callback(0); + return 0; + }; + fireEvent.press(screen.getByTestId(testID)); +}; + +describe('MoneyRequestReportTransactionsNavigation', () => { + beforeEach(() => { + jest.clearAllMocks(); + resetMockState(); + setupUseOnyx(); + }); + + describe('one-transaction parent report', () => { + beforeEach(() => { + mockState.transactionsCollection = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${CURRENT_ID}`]: {transactionID: CURRENT_ID, reportID: 'rCur'}, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${PREV_ID}`]: {transactionID: PREV_ID, reportID: 'rPrev'}, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${NEXT_ID}`]: {transactionID: NEXT_ID, reportID: 'rNext'}, + }; + mockState.reportsCollection = { + [`${ONYXKEYS.COLLECTION.REPORT}rPrev`]: {reportID: 'rPrev', transactionCount: 1}, + [`${ONYXKEYS.COLLECTION.REPORT}rNext`]: {reportID: 'rNext', transactionCount: 1}, + }; + }); + + it('navigates next to the parent reportID', () => { + renderNavigation(); + + press('next-button'); + + expect(Navigation.setParams).toHaveBeenCalledWith(expect.objectContaining({reportID: 'rNext', reportActionID: undefined})); + expect(mockMarkReportRHPWidth).toHaveBeenCalledWith('rNext', 'wide'); + }); + + it('navigates previous to the parent reportID', () => { + renderNavigation(); + + press('prev-button'); + + expect(Navigation.setParams).toHaveBeenCalledWith(expect.objectContaining({reportID: 'rPrev', reportActionID: undefined})); + expect(mockMarkReportRHPWidth).toHaveBeenCalledWith('rPrev', 'wide'); + }); + }); + + describe('resolves siblings and parents from the search snapshot', () => { + beforeEach(() => { + // Live collections are intentionally empty; everything is only in the snapshot. + mockState.snapshotHash = 'hash1'; + mockState.snapshot = { + data: { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${CURRENT_ID}`]: {transactionID: CURRENT_ID, reportID: 'rCur'}, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${PREV_ID}`]: {transactionID: PREV_ID, reportID: 'rPrev'}, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${NEXT_ID}`]: {transactionID: NEXT_ID, reportID: 'rNext'}, + [`${ONYXKEYS.COLLECTION.REPORT}rPrev`]: {reportID: 'rPrev', transactionCount: 1}, + [`${ONYXKEYS.COLLECTION.REPORT}rNext`]: {reportID: 'rNext', transactionCount: 1}, + }, + }; + }); + + it('navigates next using snapshot-only data', () => { + renderNavigation(); + + press('next-button'); + + expect(Navigation.setParams).toHaveBeenCalledWith(expect.objectContaining({reportID: 'rNext'})); + }); + + it('navigates previous using snapshot-only data', () => { + renderNavigation(); + + press('prev-button'); + + expect(Navigation.setParams).toHaveBeenCalledWith(expect.objectContaining({reportID: 'rPrev'})); + }); + }); + + describe('multi-transaction parent with an existing thread', () => { + beforeEach(() => { + mockState.transactionsCollection = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${CURRENT_ID}`]: {transactionID: CURRENT_ID, reportID: 'rCur'}, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${PREV_ID}`]: {transactionID: PREV_ID, reportID: 'rPrev'}, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${NEXT_ID}`]: {transactionID: NEXT_ID, reportID: 'rNext'}, + }; + mockState.reportActionsCollection = { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}rPrev`]: {actionPrev: makeIOUAction(PREV_ID, {childReportID: 'threadPrev', reportID: 'rPrev'})}, + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}rNext`]: {actionNext: makeIOUAction(NEXT_ID, {childReportID: 'threadNext', reportID: 'rNext'})}, + }; + mockState.reportsCollection = { + [`${ONYXKEYS.COLLECTION.REPORT}rPrev`]: {reportID: 'rPrev', transactionCount: 2}, + [`${ONYXKEYS.COLLECTION.REPORT}rNext`]: {reportID: 'rNext', transactionCount: 2}, + }; + }); + + it('navigates next to the existing transaction thread reportID', () => { + renderNavigation(); + + press('next-button'); + + expect(Navigation.setParams).toHaveBeenCalledWith(expect.objectContaining({reportID: 'threadNext'})); + expect(mockMarkReportRHPWidth).toHaveBeenCalledWith('threadNext', 'wide'); + }); + + it('navigates previous to the existing transaction thread reportID', () => { + renderNavigation(); + + press('prev-button'); + + expect(Navigation.setParams).toHaveBeenCalledWith(expect.objectContaining({reportID: 'threadPrev'})); + expect(mockMarkReportRHPWidth).toHaveBeenCalledWith('threadPrev', 'wide'); + }); + }); + + describe('multi-transaction parent without a thread', () => { + beforeEach(() => { + mockState.transactionsCollection = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${CURRENT_ID}`]: {transactionID: CURRENT_ID, reportID: 'rCur'}, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${PREV_ID}`]: {transactionID: PREV_ID, reportID: 'rPrev'}, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${NEXT_ID}`]: {transactionID: NEXT_ID, reportID: 'rNext'}, + }; + mockState.reportActionsCollection = { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}rPrev`]: {actionPrev: makeIOUAction(PREV_ID, {reportID: 'rPrev'})}, + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}rNext`]: {actionNext: makeIOUAction(NEXT_ID, {reportID: 'rNext'})}, + }; + mockState.reportsCollection = { + [`${ONYXKEYS.COLLECTION.REPORT}rPrev`]: {reportID: 'rPrev', transactionCount: 2}, + [`${ONYXKEYS.COLLECTION.REPORT}rNext`]: {reportID: 'rNext', transactionCount: 2}, + }; + }); + + it('creates a thread and navigates next, anchoring on the target transaction', () => { + renderNavigation(); + + press('next-button'); + + expect(createTransactionThreadReport).toHaveBeenCalled(); + // createTransactionThreadReport is mocked to return undefined, so the target falls back to the transaction's own reportID. + expect(Navigation.setParams).toHaveBeenCalledWith(expect.objectContaining({reportID: 'rNext', anchorTransactionID: NEXT_ID})); + }); + + it('creates a thread and navigates previous, anchoring on the target transaction', () => { + renderNavigation(); + + press('prev-button'); + + expect(createTransactionThreadReport).toHaveBeenCalled(); + expect(Navigation.setParams).toHaveBeenCalledWith(expect.objectContaining({reportID: 'rPrev', anchorTransactionID: PREV_ID})); + }); + }); + + describe('snapshot-backed sibling descriptors', () => { + beforeEach(() => { + mockState.transactionsCollection = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${CURRENT_ID}`]: {transactionID: CURRENT_ID, reportID: 'rCur'}, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${PREV_ID}`]: {transactionID: PREV_ID, reportID: 'rPrev'}, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${NEXT_ID}`]: {transactionID: NEXT_ID, reportID: 'rNext'}, + }; + // No parent report present -> not a one-transaction report, so resolution uses the descriptor. + mockState.siblingDescriptors = { + [PREV_ID]: {reportID: 'rPrev'}, + [NEXT_ID]: {reportID: 'rNext'}, + }; + }); + + it('navigates next to the descriptor-resolved reportID', () => { + jest.mocked(getReportIDToOpenForExpense).mockReturnValue('descNext'); + renderNavigation(); + + press('next-button'); + + expect(getReportIDToOpenForExpense).toHaveBeenCalled(); + expect(Navigation.setParams).toHaveBeenCalledWith(expect.objectContaining({reportID: 'descNext'})); + }); + + it('navigates previous to the descriptor-resolved reportID', () => { + jest.mocked(getReportIDToOpenForExpense).mockReturnValue('descPrev'); + renderNavigation(); + + press('prev-button'); + + expect(getReportIDToOpenForExpense).toHaveBeenCalled(); + expect(Navigation.setParams).toHaveBeenCalledWith(expect.objectContaining({reportID: 'descPrev'})); + }); + }); + + it('does not render navigation when there are fewer than two transactions', () => { + mockState.transactionIDsList = [CURRENT_ID]; + + renderNavigation(); + + expect(screen.queryByTestId('next-button')).toBeNull(); + }); +});