From 5cac92596acc69997be99a20be37753f426ca5b6 Mon Sep 17 00:00:00 2001 From: thelullabyy <182625428+thelullabyy@users.noreply.github.com> Date: Fri, 14 Aug 2026 00:53:33 +0700 Subject: [PATCH 1/3] Standardize Expense Report and Expense header with predictable button placement and carousel counter --- src/ONYXKEYS.ts | 5 + src/components/MoneyReportHeader.tsx | 74 ++- .../MoneyReportHeaderMoreContent.tsx | 70 ++- src/components/MoneyRequestHeader.tsx | 22 +- .../MoneyRequestReportNavigation.tsx | 14 +- .../MoneyRequestReportTransactionList.tsx | 31 +- ...neyRequestReportTransactionsNavigation.tsx | 329 +++++++------ src/components/PopoverMenu/index.tsx | 27 +- .../ListItem/TransactionGroupListExpanded.tsx | 4 +- src/components/Search/index.tsx | 93 ++-- ...eMoneyReportHeaderMoreContentVisibility.ts | 46 ++ src/hooks/useNavigateToTransactionThread.ts | 15 +- src/languages/de.ts | 1 + src/languages/el.ts | 6 +- src/languages/en.ts | 2 + src/languages/es.ts | 1 + src/languages/fr.ts | 1 + src/languages/it.ts | 1 + src/languages/ja.ts | 1 + src/languages/nl.ts | 1 + src/languages/pl.ts | 1 + src/languages/pt-BR.ts | 1 + src/languages/zh-hans.ts | 1 + src/libs/ExportOnyxState/common.ts | 1 + src/libs/Navigation/types.ts | 1 + .../actions/TransactionThreadNavigation.ts | 53 +- src/pages/home/RecentlyAddedSection/index.tsx | 2 +- src/pages/inbox/ReportNavigateAwayHandler.tsx | 12 +- src/styles/utils/sizing.ts | 4 + .../MoneyReportHeaderActionsPlacementTest.tsx | 315 ++++++++++++ .../MoneyReportHeaderMoreContentTest.tsx | 120 ----- ...yReportHeaderMoreContentVisibilityTest.tsx | 120 +++++ ...questReportTransactionsNavigation.test.tsx | 458 ++++++++++++++++++ .../useNavigateToTransactionThread.test.ts | 110 +++++ .../TransactionThreadNavigationTest.ts | 135 ++++++ 35 files changed, 1692 insertions(+), 386 deletions(-) create mode 100644 src/hooks/useMoneyReportHeaderMoreContentVisibility.ts create mode 100644 tests/ui/components/MoneyReportHeaderActionsPlacementTest.tsx delete mode 100644 tests/ui/components/MoneyReportHeaderMoreContentTest.tsx create mode 100644 tests/ui/components/MoneyReportHeaderMoreContentVisibilityTest.tsx create mode 100644 tests/unit/components/MoneyRequestReportTransactionsNavigation.test.tsx create mode 100644 tests/unit/hooks/useNavigateToTransactionThread.test.ts create mode 100644 tests/unit/libs/actions/TransactionThreadNavigationTest.ts diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index a108395f26e9..727fafedca9c 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -811,6 +811,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', @@ -1800,6 +1804,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 92f168746b29..205ca942b54d 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -1,5 +1,6 @@ import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; +import useMoneyReportHeaderMoreContentVisibility from '@hooks/useMoneyReportHeaderMoreContentVisibility'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useReportPrimaryAction from '@hooks/useReportPrimaryAction'; @@ -9,16 +10,21 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import type {ReportsSplitNavigatorParamList, RightModalNavigatorParamList} from '@libs/Navigation/types'; +import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; import 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'; + +import type {OnyxEntry} from 'react-native-onyx'; import {useRoute} from '@react-navigation/native'; -import React, {useEffect} from 'react'; +import React, {useCallback, useEffect} from 'react'; import {View} from 'react-native'; import HeaderLoadingBar from './HeaderLoadingBar'; @@ -27,6 +33,8 @@ import MoneyReportHeaderActions from './MoneyReportHeaderActions'; import {ExportDownloadStatusProvider} from './MoneyReportHeaderActions/ExportDownloadStatusProvider'; 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,15 +87,38 @@ 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 threadParentReportActionID = moneyRequestReport?.parentReportActionID; + const threadTransactionIDSelector = useCallback( + (parentReportActions: OnyxEntry) => { + const parentReportAction = threadParentReportActionID ? parentReportActions?.[threadParentReportActionID] : undefined; + return isMoneyRequestAction(parentReportAction) ? getOriginalMessage(parentReportAction)?.IOUTransactionID : undefined; + }, + [threadParentReportActionID], + ); + const [threadTransactionID] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(moneyRequestReport?.parentReportID)}`, {selector: threadTransactionIDSelector}); + + const anchorTransactionIDFromRoute = route.name === SCREENS.RIGHT_MODAL.SEARCH_REPORT ? route.params.anchorTransactionID : undefined; + const multiTxAnchorTransactionID = anchorTransactionIDFromRoute && activeTransactionIDs?.includes(anchorTransactionIDFromRoute) ? anchorTransactionIDFromRoute : undefined; + const carouselAnchorTransactionID = singleTransactionID ?? threadTransactionID ?? 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 {statusBarType, shouldShowNextStep, hasStatusOrNextStep} = useMoneyReportHeaderMoreContentVisibility(reportIDProp); + const shouldRenderActionsInHeaderRow = shouldShowHeaderButtonsInHeaderRow && !hasStatusOrNextStep; + const shouldDisplaySearchRouter = !isReportInRHP || (isSmallScreenWidth && !isReportInSearch); + const backTo = (route.params as {backTo?: Route} | undefined)?.backTo; const primaryAction = useReportPrimaryAction(reportIDProp); @@ -135,14 +166,28 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt shouldEnableDetailPageNavigation openParentReportInCurrentTab > - {shouldShowHeaderButtonsInHeaderRow && ( - - )} + + {shouldRenderActionsInHeaderRow && ( + + )} + {isReportInSearch && + (shouldShowTransactionNavigation && carouselAnchorTransactionID ? ( + + ) : ( + + ))} + {!shouldShowHeaderButtonsInHeaderRow && ( )} - + ); diff --git a/src/components/MoneyReportHeaderMoreContent.tsx b/src/components/MoneyReportHeaderMoreContent.tsx index b8a5494fa99e..168961dc0903 100644 --- a/src/components/MoneyReportHeaderMoreContent.tsx +++ b/src/components/MoneyReportHeaderMoreContent.tsx @@ -1,17 +1,12 @@ -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'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import type {ReportsSplitNavigatorParamList, RightModalNavigatorParamList} from '@libs/Navigation/types'; -import {isGroupPolicy} from '@libs/PolicyUtils'; -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 +17,37 @@ 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; + + /** The report's primary action, forwarded to the actions row */ + primaryAction: MoneyReportHeaderActionsProps['primaryAction']; + + /** Route to navigate back to */ + backTo: Route | undefined; + + /** Which status bar to render, resolved by the header via useMoneyReportHeaderMoreContentVisibility */ + statusBarType: ValueOf | undefined; + + /** Whether the next step bar should be rendered, resolved alongside `statusBarType` */ + shouldShowNextStep: boolean; + + /** Whether the report actions belong at the end of this row. The header renders them itself when this row is empty. */ + shouldRenderActionsInRow: 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. + * Cheap visibility gate — decides 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, statusBarType, shouldShowNextStep, shouldRenderActionsInRow}: MoneyReportHeaderMoreContentProps) { const route = useRoute< | PlatformStackRouteProp | PlatformStackRouteProp @@ -45,13 +57,9 @@ function MoneyReportHeaderMoreContent({reportID}: MoneyReportHeaderMoreContentPr const isReportInSearch = route.name === SCREENS.RIGHT_MODAL.SEARCH_REPORT || route.name === SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT; const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); - const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(moneyRequestReport?.policyID)}`); - const {shouldShowStatusBar, statusBarType} = useMoneyReportHeaderStatusBar(reportID, moneyRequestReport?.chatReportID); - - const isInvoiceReport = isInvoiceReportUtil(moneyRequestReport); - const shouldShowNextStep = isGroupPolicy(policy) && !isInvoiceReport && !shouldShowStatusBar; - const shouldShowMoreContent = shouldShowNextStep || !!statusBarType || isReportInSearch; + const hasStatusOrNextStep = shouldShowNextStep || !!statusBarType; + const shouldShowMoreContent = hasStatusOrNextStep || shouldRenderActionsInRow; if (!shouldShowMoreContent) { return null; @@ -63,6 +71,9 @@ function MoneyReportHeaderMoreContent({reportID}: MoneyReportHeaderMoreContentPr statusBarType={statusBarType} isReportInSearch={isReportInSearch} shouldShowNextStep={shouldShowNextStep} + primaryAction={primaryAction} + backTo={backTo} + shouldRenderActionsInRow={shouldRenderActionsInRow} /> ); } @@ -72,20 +83,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 0dadca3f4edf..884fb36c56fd 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. + // Order-sensitive proxy for visualOrderTransactionIDs used as the effect dependency below. It must stay + // order-sensitive: changing the report's sorting/grouping (without changing which transactions are present) + // reorders the list, and the carousel needs to be re-seeded so its counter and prev/next buttons match the + // new visual order. The active-list checks in the effect still prevent unrelated carousels from being overwritten. const visualOrderTransactionIDsKey = useMemo(() => visualOrderTransactionIDs.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 @@ -596,11 +602,23 @@ function MoneyRequestReportTransactionList({ if (getActiveTransactionIDs().descriptors) { return; } + + if (visualOrderTransactionIDs.length < 2) { + return; + } + + 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 + // eslint-disable-next-line react-hooks/exhaustive-deps -- visualOrderTransactionIDsKey is an order-sensitive proxy for the array, and we intentionally don't depend on latestActiveTransactionIDs to avoid re-firing when the carousel changes elsewhere }, [visualOrderTransactionIDsKey]); const groupSelectionState = useMemo(() => { @@ -658,6 +676,7 @@ function MoneyRequestReportTransactionList({ report, transaction: sortedTransactions.find((t) => t.transactionID === activeTransactionID), siblingTransactionIDs: visualOrderTransactionIDs, + shouldPreserveBroaderCarousel: true, }); }, [navigateToTransactionThread, reportActions, sortedTransactions, report, visualOrderTransactionIDs], diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx index 42731e90c0d7..5451fbda5875 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx @@ -1,22 +1,29 @@ import {usePersonalDetails} from '@components/OnyxListItemProvider'; 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'; @@ -24,41 +31,61 @@ 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'; + +const CAROUSEL_PRESERVING_SCREENS = [ + SCREENS.RIGHT_MODAL.SEARCH_REPORT, + SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT, + SCREENS.RIGHT_MODAL.EXPENSE_REPORT, + SCREENS.TRANSACTION_DUPLICATE.DYNAMIC_REVIEW, +] as const; 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 -- intentionally mutates the shared accumulator so callers can build the map in a single pass across multiple report-action sources + 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 [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); + const [hasCompletedGuidedSetupFlow] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasCompletedGuidedSetupFlowSelector}); const personalDetails = usePersonalDetails(); - const {email: currentUserEmail, accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); - const {markReportRHPWidth} = useWideRHPActions(); + 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); @@ -66,12 +93,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, { @@ -80,40 +110,64 @@ function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromR const parentReportActionsSelector = useCallback( (allReportActions: OnyxCollection) => { - let reportActions = {}; + const parentActions: Record = {}; 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); } - return parentReportActionIDsSelector(reportActions); + return parentActions; }, - [currentTransaction, nextTransaction, prevTransaction], + [currentTransaction, nextTransaction, prevTransaction, snapshot], ); - const [parentReportActions = new Map()] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS, { + const [reportedParentReportActions = getEmptyObject>()] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS, { selector: parentReportActionsSelector, }); + const snapshotData = snapshot?.data; + const snapshotParentReportActions = useMemo(() => { + const parentActions: Record = {}; + if (snapshotData) { + for (const [key, reportActionsForReport] of Object.entries(snapshotData)) { + if (key.startsWith(ONYXKEYS.COLLECTION.REPORT_ACTIONS)) { + collectParentReportActions(reportActionsForReport as OnyxTypes.ReportActions, parentActions); + } + } + } + return parentActions; + }, [snapshotData]); + + // Live report actions win over the snapshot: the snapshot only fills in transactionIDs the live pass couldn't + // resolve (i.e. unreported ones). A search snapshot is a point-in-time copy, so for a reported transaction it can + // hold an older copy of the same IOU action — e.g. one still missing the childReportID of a thread that has since + // been created. Letting that stale copy win would make prev/next believe the sibling has no thread and create a + // duplicate one instead of navigating to the existing thread. + const parentReportActions = useMemo(() => ({...snapshotParentReportActions, ...reportedParentReportActions}), [reportedParentReportActions, snapshotParentReportActions]); + const {prevParentReportAction, nextParentReportAction} = useMemo(() => { if (!transactionIDsList || transactionIDsList.length < 2) { - return { - prevParentReportAction: undefined, - nextParentReportAction: undefined, - }; + return {prevParentReportAction: undefined, nextParentReportAction: undefined}; } 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}`); + 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 @@ -122,7 +176,7 @@ function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromR useEffect(() => { return () => { const focusedRoute = findFocusedRoute(navigationRef.getRootState()); - if (focusedRoute?.name === SCREENS.RIGHT_MODAL.SEARCH_REPORT || focusedRoute?.name === SCREENS.TRANSACTION_DUPLICATE.DYNAMIC_REVIEW) { + if (focusedRoute?.name && (CAROUSEL_PRESERVING_SCREENS as readonly string[]).includes(focusedRoute.name)) { return; } clearActiveTransactionIDs(); @@ -133,15 +187,26 @@ 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 (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, anchorTransactionID: nextTransactionID, 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 @@ -149,138 +214,116 @@ 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, - personalDetails, - }); - markReportRHPWidth(nextReportID, 'wide'); - requestAnimationFrame(() => - startTransition(() => - Navigation.setParams({ - reportID: nextReportID, - reportActionID: undefined, - backTo, - }), - ), - ); - }); + const nextReportID = getReportIDToOpenForExpense(nextDescriptor, {introSelected, betas, currentUserEmail: email, currentUserAccountID: accountID, personalDetails}); + markReportRHPWidth(nextReportID, 'wide'); + requestAnimationFrame(() => startTransition(() => Navigation.setParams({reportID: nextReportID, reportActionID: undefined, anchorTransactionID: nextTransactionID, backTo}))); return; } const nextThreadReportID = nextParentReportAction?.childReportID; - const navigationParams = { - reportID: nextThreadReportID, - reportActionID: undefined, - backTo, - }; + const navigationParams = {reportID: nextThreadReportID, reportActionID: undefined, anchorTransactionID: nextTransactionID, backTo}; + + 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, + personalDetails, + isSelfTourViewed, + hasCompletedGuidedSetupFlow, + }); + const targetReportID = optimisticThread?.reportID ?? nextTransaction.reportID; + markReportRHPWidth(targetReportID, 'wide'); + requestAnimationFrame(() => startTransition(() => Navigation.setParams({reportID: targetReportID, reportActionID: undefined, anchorTransactionID: nextTransactionID, backTo}))); + return; + } - 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, - personalDetails, - }); - 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))); - }); + if (nextThreadReportID) { + markReportRHPWidth(nextThreadReportID, 'wide'); + } + + 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, anchorTransactionID: prevTransactionID, 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, - personalDetails, - }); - markReportRHPWidth(prevReportID, 'wide'); - requestAnimationFrame(() => - startTransition(() => - Navigation.setParams({ - reportID: prevReportID, - reportActionID: undefined, - backTo, - }), - ), - ); - }); + const prevReportID = getReportIDToOpenForExpense(prevDescriptor, {introSelected, betas, currentUserEmail: email, currentUserAccountID: accountID, personalDetails}); + markReportRHPWidth(prevReportID, 'wide'); + requestAnimationFrame(() => startTransition(() => Navigation.setParams({reportID: prevReportID, reportActionID: undefined, anchorTransactionID: prevTransactionID, backTo}))); return; } const prevThreadReportID = prevParentReportAction?.childReportID; - const navigationParams = { - reportID: prevThreadReportID, - reportActionID: undefined, - backTo, - }; + const navigationParams = {reportID: prevThreadReportID, reportActionID: undefined, anchorTransactionID: prevTransactionID, backTo}; + + // 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, + personalDetails, + }); + const targetReportID = optimisticThread?.reportID ?? prevTransaction.reportID; + markReportRHPWidth(targetReportID, 'wide'); + requestAnimationFrame(() => startTransition(() => Navigation.setParams({reportID: targetReportID, reportActionID: undefined, anchorTransactionID: prevTransactionID, backTo}))); + return; + } - 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, - personalDetails, - }); - 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))); - }); + 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/PopoverMenu/index.tsx b/src/components/PopoverMenu/index.tsx index 659076764ea7..c212eb57c94a 100644 --- a/src/components/PopoverMenu/index.tsx +++ b/src/components/PopoverMenu/index.tsx @@ -235,6 +235,17 @@ function getSelectedItemIndex(menuItems: PopoverMenuItem[]) { return menuItems.findIndex((option) => option.isSelected); } +function getAvailableHeightForAnchor(anchorVertical: number, verticalAlignment: AnchorAlignment['vertical'], windowHeight: number): number { + if (verticalAlignment === CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP) { + return windowHeight - anchorVertical; + } + if (verticalAlignment === CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM) { + return anchorVertical; + } + // CENTER alignment grows in both directions from the anchor, so the closer window edge bounds it. + return Math.min(anchorVertical, windowHeight - anchorVertical) * 2; +} + /** * Return a stable string key for a menu item. * Prefers explicit `key` property on the item. If missing, falls back to `text`. @@ -635,11 +646,23 @@ function BasePopoverMenu({ const stylesArray: ViewStyle[] = [StyleSheet.flatten(styles.createMenuContainer), {width: variables.compactPopoverMenuWidth}, styles.pv2]; if (shouldUseScrollView && shouldEnableMaxHeight && !isInLandscapeMode) { - stylesArray.push({maxHeight: Math.max(windowHeight - variables.compactPopoverMenuVerticalMargin, CONST.POPOVER_MENU_MAX_HEIGHT)}); + const availableHeight = getAvailableHeightForAnchor(anchorPosition.vertical, anchorAlignment.vertical, windowHeight) - variables.compactPopoverMenuVerticalMargin; + const minHeight = Math.min(CONST.POPOVER_MENU_MAX_HEIGHT, windowHeight - variables.compactPopoverMenuVerticalMargin); + stylesArray.push({maxHeight: Math.max(availableHeight, minHeight)}); } return stylesArray; - }, [isSmallScreenWidth, shouldEnableMaxHeight, styles.createMenuContainer, styles.pv2, shouldUseScrollView, windowHeight, isInLandscapeMode]); + }, [ + isSmallScreenWidth, + shouldEnableMaxHeight, + styles.createMenuContainer, + styles.pv2, + shouldUseScrollView, + windowHeight, + isInLandscapeMode, + anchorPosition.vertical, + anchorAlignment.vertical, + ]); const {paddingTop, paddingBottom, paddingVertical, ...restScrollContainerStyle} = (StyleSheet.flatten([isSmallScreenWidth ? styles.pv4 : styles.pv2, scrollContainerStyle]) as ViewStyle) ?? {}; diff --git a/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx b/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx index ec5c30274425..2edb95bfe187 100644 --- a/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx @@ -256,12 +256,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 637add171c79..b15939b30992 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -25,6 +25,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, shouldWriteActiveTransactionIDsForSearch} from '@libs/actions/TransactionThreadNavigation'; import {flushDeferredWrite, hasDeferredWrite} from '@libs/deferredLayoutWrite'; import Log from '@libs/Log'; import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; @@ -62,7 +63,7 @@ import { getNavigateToReportsSpans, } from '@libs/telemetry/navigateToReportsSpans'; import {cancelSubmitFollowUpActionSpan, getPendingSubmitFollowUpAction} from '@libs/telemetry/submitFollowUpAction'; -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'; @@ -160,12 +161,8 @@ function Search({ const [transactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [betas] = useOnyx(ONYXKEYS.BETAS); - const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, { - selector: hasSeenTourSelector, - }); - const [hasCompletedGuidedSetupFlow] = useOnyx(ONYXKEYS.NVP_ONBOARDING, { - selector: hasCompletedGuidedSetupFlowSelector, - }); + const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); + const [hasCompletedGuidedSetupFlow] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasCompletedGuidedSetupFlowSelector}); const previousTransactions = usePrevious(transactions); const [reportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS); const {accountID, email} = useCurrentUserPersonalDetails(); @@ -260,13 +257,7 @@ function Search({ hasPendingWriteOnMountRef, skipDeferralOnFocusRef, rearmTracking, - } = useSearchSnapshot({ - queryJSON, - searchResults, - newSearchResultKeys, - transactions, - reportActions, - }); + } = useSearchSnapshot({queryJSON, searchResults, newSearchResultKeys, transactions, reportActions}); // Mirror `hasQueuedHighlights` into a ref so the post-create-flow `useFocusEffect` // (which has empty deps) can read the latest value without re-creating its callback. @@ -382,14 +373,7 @@ function Search({ return; } - Log.info('[Search] Showing skeleton', false, { - isOffline, - isDataLoaded, - isCardFeedsLoading, - isSearchLoading: !!searchResults?.search?.isLoading, - hasErrors, - shouldUseLiveData, - }); + Log.info('[Search] Showing skeleton', false, {isOffline, isDataLoaded, isCardFeedsLoading, isSearchLoading: !!searchResults?.search?.isLoading, hasErrors, shouldUseLiveData}); }, [hasErrors, isCardFeedsLoading, isDataLoaded, isOffline, searchResults?.search?.isLoading, shouldShowLoadingState, shouldUseLiveData]); useEffect(() => { @@ -563,6 +547,16 @@ function Search({ }, 0); }, [areItemsGrouped, filteredData]); + const carouselSiblingTransactionIDs = useMemo( + () => + (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), + [filteredData], + ); + const onSelectRow = useCallback( (item: SearchListItem, transactionPreviewData?: TransactionPreviewData, event?: ModifiedMouseEvent) => { if (item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { @@ -571,6 +565,17 @@ 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) { + if (carouselSiblingTransactionIDs.length > 1) { + setActiveTransactionIDs(carouselSiblingTransactionIDs, 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 @@ -675,10 +680,7 @@ function Search({ allowPostSearchRecount: true, }); - const route = ROUTES.SEARCH_MONEY_REQUEST_REPORT.getRoute({ - reportID, - backTo, - }); + const route = ROUTES.SEARCH_MONEY_REQUEST_REPORT.getRoute({reportID, backTo}); if (openInternalRouteInNewTab(route, event)) { return; } @@ -693,11 +695,7 @@ function Search({ isCreatedTaskReportAction(reportActionItem) && (isOptimisticCreatedTaskAction || reportActionItem.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); const reportActionID = shouldSkipReportActionID ? undefined : reportActionItem.reportActionID; - const route = ROUTES.SEARCH_REPORT.getRoute({ - reportID, - reportActionID, - backTo, - }); + const route = ROUTES.SEARCH_REPORT.getRoute({reportID, reportActionID, backTo}); if (openInternalRouteInNewTab(route, event)) { return; } @@ -738,13 +736,30 @@ function Search({ email, accountID, queryJSON, + hash, offset, searchResults?.search?.hasMoreResults, currentSearchKey, + carouselSiblingTransactionIDs, getCurrencyDecimals, ], ); + const carouselSiblingsKey = carouselSiblingTransactionIDs.join(','); + const [activeCarouselSnapshotHash] = useOnyx(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_SNAPSHOT_HASH); + const [activeCarouselTransactionIDs] = useOnyx(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS); + + useEffect(() => { + if (shouldShowLoadingState) { + return; + } + if (!shouldWriteActiveTransactionIDsForSearch(activeCarouselTransactionIDs, activeCarouselSnapshotHash, hash, carouselSiblingTransactionIDs)) { + return; + } + setActiveTransactionIDs(carouselSiblingTransactionIDs, hash); + // eslint-disable-next-line react-hooks/exhaustive-deps -- carouselSiblingsKey is an order-sensitive proxy for the array, which is rebuilt on every search data change + }, [carouselSiblingsKey, activeCarouselSnapshotHash, activeCarouselTransactionIDs, hash, shouldShowLoadingState]); + // getColumnsToShow allocates a fresh array on every call; preserve the previous reference // when contents are equal so downstream consumers don't re-render on Onyx snapshot churn // (e.g. opening a report bumps searchResults.data) that doesn't actually change the columns. @@ -808,9 +823,7 @@ function Search({ const onLayoutBase = useCallback(() => { hasHadFirstLayout.current = true; onDestinationVisible?.(isSearchResultsEmptyRef.current, 'layout'); - endSpanWithAttributes(CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS, { - [CONST.TELEMETRY.ATTRIBUTE_IS_WARM]: true, - }); + endSpanWithAttributes(CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS, {[CONST.TELEMETRY.ATTRIBUTE_IS_WARM]: true}); endNavigateToReportsFirstPaint(CONST.TELEMETRY.NAVIGATE_TO_REPORTS_START_TYPE.WARM_FIRST); endNavigateToReportsContentLoad(); TransitionTracker.runAfterTransitions({ @@ -865,9 +878,7 @@ function Search({ const onLayoutChart = useCallback(() => { hasHadFirstLayout.current = true; - endSpanWithAttributes(CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS, { - [CONST.TELEMETRY.ATTRIBUTE_IS_WARM]: true, - }); + endSpanWithAttributes(CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS, {[CONST.TELEMETRY.ATTRIBUTE_IS_WARM]: true}); endNavigateToReportsFirstPaint(CONST.TELEMETRY.NAVIGATE_TO_REPORTS_START_TYPE.WARM_FIRST); endNavigateToReportsContentLoad(); }, []); @@ -956,13 +967,7 @@ function Search({ ); const amountIndicators = useMemo( - () => - searchResults?.data - ? getWideAmountIndicators(searchResults.data) - : { - shouldShowAmountInWideColumn: false, - shouldShowTaxAmountInWideColumn: false, - }, + () => (searchResults?.data ? getWideAmountIndicators(searchResults.data) : {shouldShowAmountInWideColumn: false, shouldShowTaxAmountInWideColumn: false}), [searchResults?.data], ); diff --git a/src/hooks/useMoneyReportHeaderMoreContentVisibility.ts b/src/hooks/useMoneyReportHeaderMoreContentVisibility.ts new file mode 100644 index 000000000000..8813fccddf5d --- /dev/null +++ b/src/hooks/useMoneyReportHeaderMoreContentVisibility.ts @@ -0,0 +1,46 @@ +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import {isGroupPolicy} from '@libs/PolicyUtils'; +import {isInvoiceReport as isInvoiceReportUtil} from '@libs/ReportUtils'; + +import type CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import type {ValueOf} from 'type-fest'; + +import useMoneyReportHeaderStatusBar from './useMoneyReportHeaderStatusBar'; +import useOnyx from './useOnyx'; + +type MoneyReportHeaderMoreContentVisibility = { + /** Which status bar to render below the header, if any */ + statusBarType: ValueOf | undefined; + + /** Whether the next step bar should be rendered below the header */ + shouldShowNextStep: boolean; + + /** Whether the more-content row has anything to show on its own */ + hasStatusOrNextStep: boolean; +}; + +/** + * Resolves what the money report header's more-content row (status bar / next step) will display. + * + * The header needs this before it renders, because it decides where the report actions go: they normally sit at + * the end of the more-content row so they line up with its text, but when that row has nothing to show they + * would be stranded alone under the title, and belong in the header row instead. + */ +function useMoneyReportHeaderMoreContentVisibility(reportID: string | undefined): MoneyReportHeaderMoreContentVisibility { + const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(moneyRequestReport?.policyID)}`); + const {shouldShowStatusBar, statusBarType} = useMoneyReportHeaderStatusBar(reportID, moneyRequestReport?.chatReportID); + + const isInvoiceReport = isInvoiceReportUtil(moneyRequestReport); + const shouldShowNextStep = isGroupPolicy(policy) && !isInvoiceReport && !shouldShowStatusBar; + + return { + statusBarType, + shouldShowNextStep, + hasStatusOrNextStep: shouldShowNextStep || !!statusBarType, + }; +} + +export default useMoneyReportHeaderMoreContentVisibility; diff --git a/src/hooks/useNavigateToTransactionThread.ts b/src/hooks/useNavigateToTransactionThread.ts index cc62eec3fb3b..922d1e68f325 100644 --- a/src/hooks/useNavigateToTransactionThread.ts +++ b/src/hooks/useNavigateToTransactionThread.ts @@ -2,7 +2,7 @@ import {usePersonalDetails} from '@components/OnyxListItemProvider'; import {useWideRHPActions} from '@components/WideRHPContextProvider'; import {createTransactionThreadReport, setOptimisticTransactionThread} from '@libs/actions/Report'; -import {setActiveTransactionIDs} from '@libs/actions/TransactionThreadNavigation'; +import {setActiveTransactionIDs, shouldPreserveActiveTransactionIDs} from '@libs/actions/TransactionThreadNavigation'; import Navigation from '@libs/Navigation/Navigation'; import {getIOUActionForTransactionID} from '@libs/ReportActionsUtils'; @@ -31,6 +31,9 @@ type NavigateToTransactionThreadParams = { /** Ordered list of sibling transaction IDs used to drive the prev/next carousel in the thread RHP */ siblingTransactionIDs: string[]; + /** When true, keep an already-active broader carousel (e.g. the Spend page's list) instead of re-seeding it with just this report's siblings */ + shouldPreserveBroaderCarousel?: boolean; + /** Route to return to when navigating back; defaults to the current active route */ backTo?: string; }; @@ -51,7 +54,7 @@ function useNavigateToTransactionThread() { const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [betas] = useOnyx(ONYXKEYS.BETAS); - return ({transactionID, reportActions, report, transaction, siblingTransactionIDs, backTo}: NavigateToTransactionThreadParams) => { + return ({transactionID, reportActions, report, transaction, siblingTransactionIDs, shouldPreserveBroaderCarousel = false, backTo}: NavigateToTransactionThreadParams) => { const iouAction = getIOUActionForTransactionID(reportActions, transactionID); const resolvedBackTo = backTo ?? Navigation.getActiveRoute(); let reportIDToNavigate = iouAction?.childReportID; @@ -81,8 +84,12 @@ function useNavigateToTransactionThread() { } // Single transaction report opens in RHP. We seed every sibling transaction ID so the RHP can - // display prev/next arrows for navigation between expenses. - setActiveTransactionIDs(siblingTransactionIDs).then(() => { + // display prev/next arrows for navigation between expenses. A broader carousel the user drilled in from is + // left untouched, so its list (and the snapshot hash backing prev/next) survive navigating back out to it. + const seedCarousel = + shouldPreserveBroaderCarousel && shouldPreserveActiveTransactionIDs(siblingTransactionIDs, transactionID) ? Promise.resolve() : setActiveTransactionIDs(siblingTransactionIDs); + + seedCarousel.then(() => { if (reportIDToNavigate) { markReportRHPWidth(reportIDToNavigate, 'wide'); } diff --git a/src/languages/de.ts b/src/languages/de.ts index 9dc76a1e4123..4d8c972839c2 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -487,6 +487,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/el.ts b/src/languages/el.ts index 6a92a3741f69..0773035dd2f5 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -318,6 +318,7 @@ const translations: TranslationDeepObject = { automatic: 'Αυτόματο', showing: 'Εμφανίζονται', of: 'του', + currentOfTotal: ({current, total}: {current: number; total: number}) => `${current} από ${total}`, default: 'Προεπιλογή', update: 'Ενημέρωση', member: 'Μέλος', @@ -1133,10 +1134,7 @@ const translations: TranslationDeepObject = { }), today: 'Σήμερα', }, - insightsSection: { - chartUnavailable: 'Το γράφημα δεν είναι διαθέσιμο', - notEnoughData: 'Δεν έχουμε ακόμη αρκετά δεδομένα για να συμπληρώσουμε αυτό το γράφημα', - }, + insightsSection: {chartUnavailable: 'Το γράφημα δεν είναι διαθέσιμο', notEnoughData: 'Δεν έχουμε ακόμη αρκετά δεδομένα για να συμπληρώσουμε αυτό το γράφημα'}, }, allSettingsScreen: { subscription: 'Συνδρομή', diff --git a/src/languages/en.ts b/src/languages/en.ts index 2f236444964a..bcdc47a4e7c2 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -345,6 +345,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 dccbd68d1f27..8e1014f9cb92 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -472,6 +472,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 09a9ff5a7be5..50b32eaa573b 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -487,6 +487,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 c4b872f2159e..35978ecb5882 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -487,6 +487,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 adaccf059f0b..b9f108613603 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -486,6 +486,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 40398c2fe6c8..6f80af47d8c6 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -486,6 +486,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 38a4dae9bad2..b3caedf45fee 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -486,6 +486,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 dcad147ca7d9..65e775748405 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -485,6 +485,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 d53d3e5603d2..cbcb0beaa20c 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -482,6 +482,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 6464d6eff450..141063734e14 100644 --- a/src/libs/ExportOnyxState/common.ts +++ b/src/libs/ExportOnyxState/common.ts @@ -314,6 +314,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 3cef807e1afb..aef2dc1f90ad 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -2759,6 +2759,7 @@ type RightModalNavigatorParamList = { [SCREENS.RIGHT_MODAL.SEARCH_REPORT]: { reportID: string; reportActionID?: string; + 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..964956e1c1ed 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,25 @@ function areDescriptorMapsEqual(a: 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)) { + 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), + ]); } /** @@ -69,10 +76,42 @@ function getActiveTransactionIDs(): {ids: string[] | null; descriptors: Record 1; + } + if (activeSnapshotHash !== searchSnapshotHash) { + return false; + } + const isUpToDate = activeIDs.length === searchTransactionIDs.length && activeIDs.every((id, index) => id === searchTransactionIDs.at(index)); + return !isUpToDate; +} + +function shouldPreserveActiveTransactionIDs(candidateIDs: string[], anchorTransactionID: string): boolean { + const activeIDs = lastSetIDs; + if (!activeIDs?.includes(anchorTransactionID)) { + return false; + } + return activeIDs.length > candidateIDs.length && candidateIDs.every((id) => activeIDs.includes(id)); +} + function clearActiveTransactionIDs() { lastSetIDs = null; + lastSetSnapshotHash = null; lastSetDescriptors = null; - return Promise.all([Onyx.set(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS, null), Onyx.set(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_THREAD_REPORT_IDS, null)]); + return Promise.all([ + Onyx.set(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS, null), + Onyx.set(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_SNAPSHOT_HASH, null), + Onyx.set(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_THREAD_REPORT_IDS, null), + ]); } -export {setActiveTransactionIDs, clearActiveTransactionIDs, getActiveTransactionIDs}; +export {setActiveTransactionIDs, clearActiveTransactionIDs, getActiveTransactionIDs, shouldWriteActiveTransactionIDsForSearch, shouldPreserveActiveTransactionIDs}; diff --git a/src/pages/home/RecentlyAddedSection/index.tsx b/src/pages/home/RecentlyAddedSection/index.tsx index 422b94cb9af9..0036cd4a86a1 100644 --- a/src/pages/home/RecentlyAddedSection/index.tsx +++ b/src/pages/home/RecentlyAddedSection/index.tsx @@ -89,7 +89,7 @@ function RecentlyAddedSection() { // Each row opens a single-expense view that always lands in (Wide) RHP on both layouts so the carousel // arrows are available. Marking the report as an expense lets the RHP open wide immediately, before its // data loads, instead of flickering from narrow to wide. - setActiveTransactionIDs(siblingTransactionIDs, siblingDescriptorsByTransactionID).then(() => { + 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 139219be3ed1..02ba70f04a15 100644 --- a/src/pages/inbox/ReportNavigateAwayHandler.tsx +++ b/src/pages/inbox/ReportNavigateAwayHandler.tsx @@ -171,15 +171,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/MoneyReportHeaderActionsPlacementTest.tsx b/tests/ui/components/MoneyReportHeaderActionsPlacementTest.tsx new file mode 100644 index 000000000000..d5cd4e8ea801 --- /dev/null +++ b/tests/ui/components/MoneyReportHeaderActionsPlacementTest.tsx @@ -0,0 +1,315 @@ +import {render} from '@testing-library/react-native'; + +import MoneyReportHeader from '@components/MoneyReportHeader'; +import MoneyReportHeaderActions from '@components/MoneyReportHeaderActions'; +import MoneyReportHeaderMoreContent from '@components/MoneyReportHeaderMoreContent'; + +import useMoneyReportHeaderMoreContentVisibility from '@hooks/useMoneyReportHeaderMoreContentVisibility'; +import useOnyx from '@hooks/useOnyx'; +import useReportPrimaryAction from '@hooks/useReportPrimaryAction'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; +import type {Report} from '@src/types/onyx'; + +import type * as ReactNavigationNative from '@react-navigation/native'; + +import {useRoute} from '@react-navigation/native'; +import React from 'react'; + +const REPORT_ID = '1001'; +const CHAT_REPORT_ID = '2001'; + +const HEADER_ROW_TEST_ID = 'header-row'; +const ACTIONS_TEST_ID = 'header-actions'; +const REPORT_CAROUSEL_TEST_ID = 'report-carousel'; +const TRANSACTIONS_CAROUSEL_TEST_ID = 'transactions-carousel'; + +const PARENT_REPORT_ID = '3001'; +const PARENT_ACTION_ID = 'parentAction1'; +const THREAD_TRANSACTION_ID = 'thread-tx-1'; + +/** A 1:1 DM IOU report: `isDM` is true for its chat report, and there is no next step or status bar to show. */ +const iouReport = {reportID: REPORT_ID, chatReportID: CHAT_REPORT_ID, type: CONST.REPORT.TYPE.IOU} as Report; +const dmChatReport = {reportID: CHAT_REPORT_ID, type: CONST.REPORT.TYPE.CHAT} as Report; + +jest.mock('@hooks/useOnyx', () => jest.fn()); +jest.mock('@hooks/useResponsiveLayout', () => jest.fn()); +jest.mock('@hooks/useReportPrimaryAction', () => jest.fn()); +jest.mock('@hooks/useMoneyReportHeaderMoreContentVisibility', () => jest.fn()); +jest.mock('@hooks/useResponsiveLayoutOnWideRHP', () => jest.fn(() => ({isWideRHPDisplayedOnWideLayout: false, isSuperWideRHPDisplayedOnWideLayout: false}))); +jest.mock('@hooks/useNetwork', () => jest.fn(() => ({isOffline: false}))); +jest.mock('@hooks/useMobileSelectionMode', () => jest.fn(() => false)); +jest.mock('@hooks/useTransactionsAndViolationsForReport', () => jest.fn(() => ({transactions: {}, violations: {}}))); +jest.mock('@hooks/useLocalize', () => jest.fn(() => ({translate: (key: string) => key}))); + +// useThemeStyles throws without a ; return a proxy that yields an empty style object +// for any key so the (mostly-mocked) tree renders without wiring up the full provider stack. +jest.mock('@hooks/useThemeStyles', () => { + const styleProxy = new Proxy({}, {get: () => ({})}); + return jest.fn(() => styleProxy); +}); + +// Only `useRoute` is stubbed: the rest of the module is used by the navigation imports ReportUtils pulls in. +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + __esModule: true, + useRoute: jest.fn(), +})); + +// The providers only supply context to the (mocked) leaves, so pass children straight through. +jest.mock('@components/MoneyReportHeaderModals', () => { + const reactModule = jest.requireActual('react'); + return jest.fn(({children}: {children: React.ReactNode}) => reactModule.createElement(reactModule.Fragment, null, children)); +}); +jest.mock('@components/MoneyReportHeaderActions/ExportDownloadStatusProvider', () => { + const reactModule = jest.requireActual('react'); + return { + __esModule: true, + ExportDownloadStatusProvider: jest.fn(({children}: {children: React.ReactNode}) => reactModule.createElement(reactModule.Fragment, null, children)), + }; +}); +jest.mock('@components/PaymentAnimationsContext', () => { + const reactModule = jest.requireActual('react'); + return { + __esModule: true, + PaymentAnimationsProvider: jest.fn(({children}: {children: React.ReactNode}) => reactModule.createElement(reactModule.Fragment, null, children)), + }; +}); +jest.mock('@components/Search/SearchContext', () => ({ + __esModule: true, + useSearchSelectionActions: jest.fn(() => ({clearSelectedTransactions: jest.fn()})), +})); + +// HeaderWithBackButton stands in for the title row: it renders only its children, tagged so the test can +// assert what landed on the title row and in which order. +jest.mock('@components/HeaderWithBackButton', () => { + const reactModule = jest.requireActual('react'); + const {View} = jest.requireActual<{View: React.ComponentType<{testID?: string; children?: React.ReactNode}>}>('react-native'); + return jest.fn(({children}: {children: React.ReactNode}) => reactModule.createElement(View, {testID: 'header-row'}, children)); +}); +jest.mock('@components/HeaderLoadingBar', () => jest.fn(() => null)); +jest.mock('@components/MoneyReportHeaderActions', () => { + const reactModule = jest.requireActual('react'); + const {View} = jest.requireActual<{View: React.ComponentType<{testID?: string; children?: React.ReactNode}>}>('react-native'); + return jest.fn(() => reactModule.createElement(View, {testID: 'header-actions'})); +}); +jest.mock('@components/MoneyReportHeaderMoreContent', () => jest.fn(() => null)); +jest.mock('@components/MoneyRequestReportView/MoneyRequestReportNavigation', () => { + const reactModule = jest.requireActual('react'); + const {View} = jest.requireActual<{View: React.ComponentType<{testID?: string; children?: React.ReactNode}>}>('react-native'); + return jest.fn(() => reactModule.createElement(View, {testID: 'report-carousel'})); +}); +jest.mock('@components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation', () => { + const reactModule = jest.requireActual('react'); + const {View} = jest.requireActual<{View: React.ComponentType<{testID?: string; children?: React.ReactNode}>}>('react-native'); + return jest.fn(() => reactModule.createElement(View, {testID: 'transactions-carousel'})); +}); + +const mockedUseOnyx = jest.mocked(useOnyx); +const mockedUseRoute = jest.mocked(useRoute); +const mockedUseResponsiveLayout = jest.mocked(useResponsiveLayout); +const mockedUseReportPrimaryAction = jest.mocked(useReportPrimaryAction); +const mockedMoreContentVisibility = jest.mocked(useMoneyReportHeaderMoreContentVisibility); +const mockedActions = jest.mocked(MoneyReportHeaderActions); +const mockedMoreContent = jest.mocked(MoneyReportHeaderMoreContent); + +/** Collects the tagged testIDs in render order so the test can assert left-to-right placement on the title row. */ +function getHeaderRowTestIDs(node: ReturnType['toJSON']>): string[] { + const found: string[] = []; + const walk = (current: unknown, isInsideHeaderRow: boolean) => { + if (!current || typeof current !== 'object') { + return; + } + if (Array.isArray(current)) { + for (const child of current) { + walk(child, isInsideHeaderRow); + } + return; + } + const element = current as {props?: Record; children?: unknown}; + const testID = element.props?.testID; + const isHeaderRow = testID === HEADER_ROW_TEST_ID; + if (isInsideHeaderRow && typeof testID === 'string' && testID !== HEADER_ROW_TEST_ID) { + found.push(testID); + } + walk(element.children, isInsideHeaderRow || isHeaderRow); + }; + walk(node, false); + return found; +} + +function renderHeader() { + return render( + , + ); +} + +/** + * Regression guard for https://github.com/Expensify/App/issues/98200: the report actions must sit on the title row + * whenever the more-content row would otherwise be blank, no matter whether the report was opened directly or from + * Search. Placement used to flip between the two entry points, moving the buttons onto a row of their own in Search. + */ +describe('MoneyReportHeader actions placement', () => { + beforeEach(() => { + jest.clearAllMocks(); + + // A wide layout, so the header-row placement is in play at all. + mockedUseResponsiveLayout.mockReturnValue({ + shouldUseNarrowLayout: false, + isSmallScreenWidth: false, + isInNarrowPaneModal: false, + isExtraSmallScreenHeight: false, + isMediumScreenWidth: false, + isLargeScreenWidth: true, + isExtraLargeScreenWidth: false, + isExtraSmallScreenWidth: false, + isSmallScreen: false, + onboardingIsMediumOrLargerScreenWidth: true, + isInLandscapeMode: false, + }); + mockedUseReportPrimaryAction.mockReturnValue(CONST.REPORT.PRIMARY_ACTIONS.PAY); + mockedMoreContentVisibility.mockReturnValue({statusBarType: undefined, shouldShowNextStep: false, hasStatusOrNextStep: false}); + mockedUseOnyx.mockImplementation((key) => { + if (key === `${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`) { + return [iouReport, {status: 'loaded'}]; + } + if (key === `${ONYXKEYS.COLLECTION.REPORT}${CHAT_REPORT_ID}`) { + return [dmChatReport, {status: 'loaded'}]; + } + return [undefined, {status: 'loaded'}]; + }); + }); + + it.each([ + ['opened directly', SCREENS.REPORT], + ['opened from Search', SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT], + ['opened from Search as an expense', SCREENS.RIGHT_MODAL.SEARCH_REPORT], + ])('keeps the actions on the title row when the report is %s', (_label, routeName) => { + mockedUseRoute.mockReturnValue({key: 'route-1', name: routeName, params: {}}); + + const {toJSON} = renderHeader(); + + expect(getHeaderRowTestIDs(toJSON())).toContain(ACTIONS_TEST_ID); + // The actions row under the title must stay empty, otherwise the buttons would render twice. + expect(mockedMoreContent).toHaveBeenCalled(); + expect(mockedMoreContent.mock.calls.at(-1)?.at(0)).toEqual(expect.objectContaining({shouldRenderActionsInRow: false})); + }); + + it.each([ + ['an invoice report, whose chat report is an invoice room', {reportID: CHAT_REPORT_ID, type: CONST.REPORT.TYPE.CHAT, chatType: CONST.REPORT.CHAT_TYPE.POLICY_ROOM} as Report], + [ + 'a workspace report, whose chat report is a policy expense chat', + {reportID: CHAT_REPORT_ID, type: CONST.REPORT.TYPE.CHAT, chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT} as Report, + ], + ])('keeps the actions on the title row for %s', (_label, chatReport) => { + mockedUseOnyx.mockImplementation((key) => { + if (key === `${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`) { + return [iouReport, {status: 'loaded'}]; + } + if (key === `${ONYXKEYS.COLLECTION.REPORT}${CHAT_REPORT_ID}`) { + return [chatReport, {status: 'loaded'}]; + } + return [undefined, {status: 'loaded'}]; + }); + mockedUseRoute.mockReturnValue({key: 'route-1', name: SCREENS.REPORT, params: {}}); + + const {toJSON} = renderHeader(); + + expect(getHeaderRowTestIDs(toJSON())).toContain(ACTIONS_TEST_ID); + expect(mockedMoreContent.mock.calls.at(-1)?.at(0)).toEqual(expect.objectContaining({shouldRenderActionsInRow: false})); + }); + + it('renders the actions before the carousel so the carousel stays pinned to the top right', () => { + mockedUseRoute.mockReturnValue({key: 'route-1', name: SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT, params: {}}); + + const {toJSON} = renderHeader(); + + expect(getHeaderRowTestIDs(toJSON())).toEqual([ACTIONS_TEST_ID, REPORT_CAROUSEL_TEST_ID]); + }); + + it('moves the actions down to the more-content row when that row has a status or next step', () => { + mockedMoreContentVisibility.mockReturnValue({statusBarType: CONST.REPORT.STATUS_BAR_TYPE.ON_HOLD, shouldShowNextStep: false, hasStatusOrNextStep: true}); + mockedUseRoute.mockReturnValue({key: 'route-1', name: SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT, params: {}}); + + const {toJSON} = renderHeader(); + + expect(getHeaderRowTestIDs(toJSON())).not.toContain(ACTIONS_TEST_ID); + expect(mockedActions).not.toHaveBeenCalled(); + expect(mockedMoreContent.mock.calls.at(-1)?.at(0)).toEqual(expect.objectContaining({shouldRenderActionsInRow: true})); + }); +}); + +describe('MoneyReportHeader transaction carousel anchor', () => { + const threadReport = { + reportID: REPORT_ID, + chatReportID: CHAT_REPORT_ID, + parentReportID: PARENT_REPORT_ID, + parentReportActionID: PARENT_ACTION_ID, + type: CONST.REPORT.TYPE.CHAT, + } as Report; + + const parentIOUAction = { + reportActionID: PARENT_ACTION_ID, + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + originalMessage: {IOUTransactionID: THREAD_TRANSACTION_ID, type: CONST.IOU.REPORT_ACTION_TYPE.CREATE}, + }; + + /** Mirrors the failing state: the derived transactions index has nothing for this thread. */ + function mockThread({activeIDs, parentActions}: {activeIDs: string[]; parentActions: Record | undefined}) { + // The anchor is read through a `selector`, so the mock has to apply it the way useOnyx does. + mockedUseOnyx.mockImplementation((key, options) => { + const rawValue = (() => { + if (key === `${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`) { + return threadReport; + } + if (key === ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS) { + return activeIDs; + } + if (key === `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${PARENT_REPORT_ID}`) { + return parentActions; + } + return undefined; + })(); + const {selector} = options ?? {}; + const value = selector ? selector(rawValue) : rawValue; + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- useOnyx's result type can't express "value depends on the key", and each branch above returns the shape its own key really holds + return [value as NonNullable | undefined, {status: 'loaded'}]; + }); + mockedUseRoute.mockReturnValue({key: 'route-1', name: SCREENS.RIGHT_MODAL.SEARCH_REPORT, params: {}}); + } + + beforeEach(() => { + mockedMoreContentVisibility.mockReturnValue({statusBarType: undefined, shouldShowNextStep: false, hasStatusOrNextStep: false}); + mockedUseReportPrimaryAction.mockReturnValue(CONST.REPORT.PRIMARY_ACTIONS.PAY); + }); + + it('renders the transaction carousel using the parent IOU action when the derived index is cold', () => { + mockThread({activeIDs: ['other-tx', THREAD_TRANSACTION_ID], parentActions: {[PARENT_ACTION_ID]: parentIOUAction}}); + + const {toJSON} = renderHeader(); + + expect(getHeaderRowTestIDs(toJSON())).toContain(TRANSACTIONS_CAROUSEL_TEST_ID); + }); + + it('does not render it when the resolved expense is not one of the carousel siblings', () => { + mockThread({activeIDs: ['other-tx', 'unrelated-tx'], parentActions: {[PARENT_ACTION_ID]: parentIOUAction}}); + + const {toJSON} = renderHeader(); + + expect(getHeaderRowTestIDs(toJSON())).not.toContain(TRANSACTIONS_CAROUSEL_TEST_ID); + }); + + it('does not render it when the parent action is unavailable, so there is no expense to anchor to', () => { + mockThread({activeIDs: ['other-tx', THREAD_TRANSACTION_ID], parentActions: undefined}); + + const {toJSON} = renderHeader(); + + expect(getHeaderRowTestIDs(toJSON())).not.toContain(TRANSACTIONS_CAROUSEL_TEST_ID); + }); +}); diff --git a/tests/ui/components/MoneyReportHeaderMoreContentTest.tsx b/tests/ui/components/MoneyReportHeaderMoreContentTest.tsx deleted file mode 100644 index 0cb04c080d37..000000000000 --- a/tests/ui/components/MoneyReportHeaderMoreContentTest.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import {render} from '@testing-library/react-native'; - -import MoneyReportHeaderMoreContent from '@components/MoneyReportHeaderMoreContent'; -import MoneyReportHeaderNextStep from '@components/MoneyReportHeaderNextStep'; - -import useMoneyReportHeaderStatusBar from '@hooks/useMoneyReportHeaderStatusBar'; -import useOnyx from '@hooks/useOnyx'; - -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {Policy, Report} from '@src/types/onyx'; - -import type {UseOnyxResult} from 'react-native-onyx'; - -import React from 'react'; - -import createRandomPolicy from '../../utils/collections/policies'; - -const TEST_REPORT_ID = '1001'; -const TEST_POLICY_ID = 'policy1'; - -const report = { - reportID: TEST_REPORT_ID, - policyID: TEST_POLICY_ID, - type: CONST.REPORT.TYPE.EXPENSE, -} as Report; - -function createOnyxResult(value: NonNullable | undefined): UseOnyxResult { - return [value, {status: 'loaded'}]; -} - -// `useRoute` is only used to detect Search routes; default to a regular report route so `isReportInSearch` is false. -// Spread the real module so navigation internals (e.g. createNavigationContainerRef) pulled in by the real -// PolicyUtils import chain keep working. -jest.mock('@react-navigation/native', () => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const actualNavigation = jest.requireActual('@react-navigation/native'); - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return { - ...actualNavigation, - __esModule: true, - useRoute: jest.fn(() => ({name: 'report'})), - }; -}); - -// The next-step bar is the element under test; render nothing but track whether it was mounted via the visibility gate. -jest.mock('@components/MoneyReportHeaderNextStep', () => ({__esModule: true, default: jest.fn(() => null)})); -jest.mock('@components/MoneyReportHeaderStatusBarSection', () => ({__esModule: true, default: () => null})); -jest.mock('@components/MoneyRequestReportView/MoneyRequestReportNavigation', () => ({__esModule: true, default: () => null})); -jest.mock('@components/MoneyReportTransactionThreadContext', () => ({__esModule: true, useMoneyReportTransactionThread: jest.fn(() => ({iouTransactionID: undefined}))})); - -// Spread the real module: the live PolicyUtils import chain relies on many other ReportUtils exports. -jest.mock('@libs/ReportUtils', () => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const actualReportUtils = jest.requireActual('@libs/ReportUtils'); - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return { - ...actualReportUtils, - __esModule: true, - isInvoiceReport: jest.fn(() => false), - }; -}); - -jest.mock('@hooks/useThemeStyles', () => ({__esModule: true, default: jest.fn(() => ({}))})); -jest.mock('@hooks/useResponsiveLayout', () => ({__esModule: true, default: jest.fn(() => ({shouldUseNarrowLayout: false, isMediumScreenWidth: false}))})); -jest.mock('@hooks/useResponsiveLayoutOnWideRHP', () => ({__esModule: true, default: jest.fn(() => ({isWideRHPDisplayedOnWideLayout: false, isSuperWideRHPDisplayedOnWideLayout: false}))})); -jest.mock('@hooks/useMoneyReportHeaderStatusBar', () => ({__esModule: true, default: jest.fn(() => ({shouldShowStatusBar: false, statusBarType: undefined}))})); -jest.mock('@hooks/useOnyx', () => jest.fn()); - -const mockedUseOnyx = jest.mocked(useOnyx); -const mockedStatusBar = jest.mocked(useMoneyReportHeaderStatusBar); -const mockedNextStepBar = jest.mocked(MoneyReportHeaderNextStep); - -let policyValue: Policy | undefined; - -function mockPolicyType(type: Policy['type']) { - policyValue = {...createRandomPolicy(1, type), id: TEST_POLICY_ID}; -} - -describe('MoneyReportHeaderMoreContent', () => { - beforeEach(() => { - jest.clearAllMocks(); - policyValue = undefined; - mockedStatusBar.mockReturnValue({shouldShowStatusBar: false, statusBarType: undefined}); - mockedUseOnyx.mockImplementation((key) => { - if (key === `${ONYXKEYS.COLLECTION.REPORT}${TEST_REPORT_ID}`) { - return createOnyxResult(report); - } - if (key === `${ONYXKEYS.COLLECTION.POLICY}${TEST_POLICY_ID}`) { - return createOnyxResult(policyValue); - } - return createOnyxResult(undefined); - }); - }); - - it('renders the next step bar for a Submit workspace', () => { - mockPolicyType(CONST.POLICY.TYPE.SUBMIT); - render(); - expect(mockedNextStepBar).toHaveBeenCalled(); - }); - - it('renders the next step bar for a paid (team) workspace', () => { - mockPolicyType(CONST.POLICY.TYPE.TEAM); - render(); - expect(mockedNextStepBar).toHaveBeenCalled(); - }); - - it('does not render the next step bar for a personal workspace', () => { - mockPolicyType(CONST.POLICY.TYPE.PERSONAL); - 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(); - expect(mockedNextStepBar).not.toHaveBeenCalled(); - }); -}); diff --git a/tests/ui/components/MoneyReportHeaderMoreContentVisibilityTest.tsx b/tests/ui/components/MoneyReportHeaderMoreContentVisibilityTest.tsx new file mode 100644 index 000000000000..d52e54138a91 --- /dev/null +++ b/tests/ui/components/MoneyReportHeaderMoreContentVisibilityTest.tsx @@ -0,0 +1,120 @@ +import {renderHook} from '@testing-library/react-native'; + +import useMoneyReportHeaderMoreContentVisibility from '@hooks/useMoneyReportHeaderMoreContentVisibility'; +import useMoneyReportHeaderStatusBar from '@hooks/useMoneyReportHeaderStatusBar'; +import useOnyx from '@hooks/useOnyx'; + +import {isInvoiceReport} from '@libs/ReportUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy, Report} from '@src/types/onyx'; + +import type {UseOnyxResult} from 'react-native-onyx'; + +import createRandomPolicy from '../../utils/collections/policies'; + +const TEST_REPORT_ID = '1001'; +const TEST_POLICY_ID = 'policy1'; + +const report = { + reportID: TEST_REPORT_ID, + policyID: TEST_POLICY_ID, + type: CONST.REPORT.TYPE.EXPENSE, +} as Report; + +function createOnyxResult(value: NonNullable | undefined): UseOnyxResult { + return [value, {status: 'loaded'}]; +} + +// Spread the real module: the live PolicyUtils import chain relies on many other ReportUtils exports. +jest.mock('@libs/ReportUtils', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const actualReportUtils = jest.requireActual('@libs/ReportUtils'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...actualReportUtils, + __esModule: true, + isInvoiceReport: jest.fn(() => false), + }; +}); + +jest.mock('@hooks/useMoneyReportHeaderStatusBar', () => ({__esModule: true, default: jest.fn(() => ({shouldShowStatusBar: false, statusBarType: undefined}))})); +jest.mock('@hooks/useOnyx', () => jest.fn()); + +const mockedUseOnyx = jest.mocked(useOnyx); +const mockedStatusBar = jest.mocked(useMoneyReportHeaderStatusBar); +const mockedIsInvoiceReport = jest.mocked(isInvoiceReport); + +let policyValue: Policy | undefined; + +function mockPolicyType(type: Policy['type']) { + policyValue = {...createRandomPolicy(1, type), id: TEST_POLICY_ID}; +} + +/** + * `hasStatusOrNextStep` is what the header uses to decide where the report actions go: at the end of the + * more-content row when that row has its own content, or in the header row when it would otherwise be blank. + */ +describe('useMoneyReportHeaderMoreContentVisibility', () => { + beforeEach(() => { + jest.clearAllMocks(); + policyValue = undefined; + mockedIsInvoiceReport.mockReturnValue(false); + mockedStatusBar.mockReturnValue({shouldShowStatusBar: false, statusBarType: undefined}); + mockedUseOnyx.mockImplementation((key) => { + if (key === `${ONYXKEYS.COLLECTION.REPORT}${TEST_REPORT_ID}`) { + return createOnyxResult(report); + } + if (key === `${ONYXKEYS.COLLECTION.POLICY}${TEST_POLICY_ID}`) { + return createOnyxResult(policyValue); + } + return createOnyxResult(undefined); + }); + }); + + it('shows the next step for a Submit workspace', () => { + mockPolicyType(CONST.POLICY.TYPE.SUBMIT); + const {result} = renderHook(() => useMoneyReportHeaderMoreContentVisibility(TEST_REPORT_ID)); + expect(result.current.shouldShowNextStep).toBe(true); + expect(result.current.hasStatusOrNextStep).toBe(true); + }); + + it('shows the next step for a paid (team) workspace', () => { + mockPolicyType(CONST.POLICY.TYPE.TEAM); + const {result} = renderHook(() => useMoneyReportHeaderMoreContentVisibility(TEST_REPORT_ID)); + expect(result.current.shouldShowNextStep).toBe(true); + expect(result.current.hasStatusOrNextStep).toBe(true); + }); + + it('does not show the next step for a personal workspace', () => { + mockPolicyType(CONST.POLICY.TYPE.PERSONAL); + const {result} = renderHook(() => useMoneyReportHeaderMoreContentVisibility(TEST_REPORT_ID)); + expect(result.current.shouldShowNextStep).toBe(false); + }); + + it('does not show the next step when a status bar is shown', () => { + mockPolicyType(CONST.POLICY.TYPE.SUBMIT); + mockedStatusBar.mockReturnValue({shouldShowStatusBar: true, statusBarType: CONST.REPORT.STATUS_BAR_TYPE.ON_HOLD}); + const {result} = renderHook(() => useMoneyReportHeaderMoreContentVisibility(TEST_REPORT_ID)); + expect(result.current.shouldShowNextStep).toBe(false); + // A status bar still fills the more-content row, so the actions stay in it. + expect(result.current.hasStatusOrNextStep).toBe(true); + }); + + it('reports an empty more-content row for a personal workspace with no status bar', () => { + mockPolicyType(CONST.POLICY.TYPE.PERSONAL); + const {result} = renderHook(() => useMoneyReportHeaderMoreContentVisibility(TEST_REPORT_ID)); + // This is the 1:1 DM IOU case: nothing to show, so the actions move up into the header row + // instead of sitting alone on a blank line under the title. + expect(result.current.hasStatusOrNextStep).toBe(false); + }); + + it('reports an empty more-content row for an invoice report, even on a paid workspace', () => { + mockPolicyType(CONST.POLICY.TYPE.TEAM); + mockedIsInvoiceReport.mockReturnValue(true); + const {result} = renderHook(() => useMoneyReportHeaderMoreContentVisibility(TEST_REPORT_ID)); + expect(result.current.shouldShowNextStep).toBe(false); + expect(result.current.hasStatusOrNextStep).toBe(false); + }); +}); diff --git a/tests/unit/components/MoneyRequestReportTransactionsNavigation.test.tsx b/tests/unit/components/MoneyRequestReportTransactionsNavigation.test.tsx new file mode 100644 index 000000000000..13b5622dc034 --- /dev/null +++ b/tests/unit/components/MoneyRequestReportTransactionsNavigation.test.tsx @@ -0,0 +1,458 @@ +import {fireEvent, render, screen} from '@testing-library/react-native'; + +import MoneyRequestReportTransactionsNavigation from '@components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation'; + +import {createTransactionThreadReport} from '@libs/actions/Report'; +import {clearActiveTransactionIDs} from '@libs/actions/TransactionThreadNavigation'; +import {getReportIDToOpenForExpense} from '@libs/TransactionThreadNavigationUtils'; + +import Navigation from '@navigation/Navigation'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; + +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}), +})); + +jest.mock('@components/OnyxListItemProvider', () => ({ + usePersonalDetails: () => ({}), +})); + +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'), + }, +})); + +const makeRootState = (focusedRouteName: string) => ({index: 0, routes: [{key: 'k', name: focusedRouteName}]}); +const mockGetRootState = jest.fn(() => makeRootState('testRoute')); + +jest.mock('@navigation/navigationRef', () => ({ + __esModule: true, + default: { + getRootState: () => mockGetRootState(), + 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 = {}; + mockGetRootState.mockReturnValue(makeRootState('testRoute')); +}; + +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, anchorTransactionID: NEXT_ID})); + 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, anchorTransactionID: PREV_ID})); + 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', anchorTransactionID: NEXT_ID})); + }); + + it('navigates previous using snapshot-only data', () => { + renderNavigation(); + + press('prev-button'); + + expect(Navigation.setParams).toHaveBeenCalledWith(expect.objectContaining({reportID: 'rPrev', anchorTransactionID: PREV_ID})); + }); + }); + + 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', anchorTransactionID: NEXT_ID})); + 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', anchorTransactionID: PREV_ID})); + expect(mockMarkReportRHPWidth).toHaveBeenCalledWith('threadPrev', 'wide'); + }); + }); + + describe('live report actions take precedence over a stale snapshot copy', () => { + 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}, + }; + mockState.snapshotHash = 'hash1'; + mockState.snapshot = { + data: { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}rPrev`]: {actionPrev: makeIOUAction(PREV_ID, {reportID: 'rPrev'})}, + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}rNext`]: {actionNext: makeIOUAction(NEXT_ID, {reportID: 'rNext'})}, + }, + }; + }); + + it('navigates next to the live thread instead of creating a duplicate', () => { + renderNavigation(); + + press('next-button'); + + expect(createTransactionThreadReport).not.toHaveBeenCalled(); + expect(Navigation.setParams).toHaveBeenCalledWith(expect.objectContaining({reportID: 'threadNext', anchorTransactionID: NEXT_ID})); + }); + + it('navigates previous to the live thread instead of creating a duplicate', () => { + renderNavigation(); + + press('prev-button'); + + expect(createTransactionThreadReport).not.toHaveBeenCalled(); + expect(Navigation.setParams).toHaveBeenCalledWith(expect.objectContaining({reportID: 'threadPrev', anchorTransactionID: PREV_ID})); + }); + }); + + 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', anchorTransactionID: NEXT_ID})); + }); + + 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', anchorTransactionID: PREV_ID})); + }); + }); + + it('does not render navigation when there are fewer than two transactions', () => { + mockState.transactionIDsList = [CURRENT_ID]; + + renderNavigation(); + + expect(screen.queryByTestId('next-button')).toBeNull(); + }); + + describe('clearing the carousel on unmount', () => { + const setFocusedRoute = (name: string) => { + mockGetRootState.mockReturnValue(makeRootState(name)); + }; + + // Unmounting onto one of these means we're still inside the expense-navigation flow, and a screen lower in + // the RHP stack may still depend on the carousel. In particular, opening the parent report from the + // subtitle link pushes an EXPENSE_REPORT / SEARCH_MONEY_REQUEST_REPORT RHP on top of the transaction + // thread, so backing out of an expense onto it must not wipe the underlying thread's carousel (#90366). + it.each([ + ['SEARCH_REPORT', SCREENS.RIGHT_MODAL.SEARCH_REPORT], + ['SEARCH_MONEY_REQUEST_REPORT', SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT], + ['EXPENSE_REPORT', SCREENS.RIGHT_MODAL.EXPENSE_REPORT], + ['TRANSACTION_DUPLICATE.DYNAMIC_REVIEW', SCREENS.TRANSACTION_DUPLICATE.DYNAMIC_REVIEW], + ])('keeps the active transaction IDs when unmounting onto %s', (_label, screenName) => { + setFocusedRoute(screenName); + + renderNavigation().unmount(); + + expect(clearActiveTransactionIDs).not.toHaveBeenCalled(); + }); + + it('clears the active transaction IDs when unmounting onto an unrelated screen', () => { + setFocusedRoute(SCREENS.SEARCH.ROOT); + + renderNavigation().unmount(); + + expect(clearActiveTransactionIDs).toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/unit/hooks/useNavigateToTransactionThread.test.ts b/tests/unit/hooks/useNavigateToTransactionThread.test.ts new file mode 100644 index 000000000000..7955d5a6f9e8 --- /dev/null +++ b/tests/unit/hooks/useNavigateToTransactionThread.test.ts @@ -0,0 +1,110 @@ +import {renderHook} from '@testing-library/react-native'; + +import useNavigateToTransactionThread from '@hooks/useNavigateToTransactionThread'; + +import {setActiveTransactionIDs, shouldPreserveActiveTransactionIDs} from '@libs/actions/TransactionThreadNavigation'; +import Navigation from '@libs/Navigation/Navigation'; +import {getIOUActionForTransactionID} from '@libs/ReportActionsUtils'; + +import type {Report} from '@src/types/onyx'; + +import createRandomReportAction from '../../utils/collections/reportActions'; +import {createExpenseReport} from '../../utils/collections/reports'; +import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; + +const TRANSACTION_ID = 'B2'; +const SIBLING_TRANSACTION_IDS = ['B1', 'B2']; +const THREAD_REPORT_ID = 'thread-1'; + +const report: Report = {...createExpenseReport(1), reportID: 'reportB'}; +// An IOU action that already has a thread, so the hook takes the short path to navigation. +const iouAction = {...createRandomReportAction(1), reportActionID: 'action1', childReportID: THREAD_REPORT_ID}; + +jest.mock('@libs/actions/TransactionThreadNavigation', () => ({ + setActiveTransactionIDs: jest.fn(() => Promise.resolve()), + shouldPreserveActiveTransactionIDs: jest.fn(() => false), +})); + +jest.mock('@libs/actions/Report', () => ({ + createTransactionThreadReport: jest.fn(), + setOptimisticTransactionThread: jest.fn(), +})); + +jest.mock('@libs/Navigation/Navigation', () => ({ + __esModule: true, + default: {getActiveRoute: jest.fn(() => '/search'), navigate: jest.fn()}, +})); + +jest.mock('@libs/ReportActionsUtils', () => ({getIOUActionForTransactionID: jest.fn()})); + +jest.mock('@components/WideRHPContextProvider', () => ({useWideRHPActions: jest.fn(() => ({markReportRHPWidth: jest.fn()}))})); + +// The personal details context is only forwarded to createTransactionThreadReport (mocked above), so an empty map is enough. +jest.mock('@components/OnyxListItemProvider', () => ({usePersonalDetails: jest.fn(() => ({}))})); + +jest.mock('@hooks/useCurrentUserPersonalDetails', () => jest.fn(() => ({email: 'a@b.com', accountID: 1}))); +jest.mock('@hooks/useOnyx', () => jest.fn(() => [undefined, {status: 'loaded'}])); + +const mockedGetIOUAction = jest.mocked(getIOUActionForTransactionID); +const mockedSetActiveTransactionIDs = jest.mocked(setActiveTransactionIDs); +const mockedShouldPreserve = jest.mocked(shouldPreserveActiveTransactionIDs); +const mockedNavigate = jest.mocked(Navigation.navigate); + +function callHook(overrides?: {shouldPreserveBroaderCarousel?: boolean}) { + const {result} = renderHook(() => useNavigateToTransactionThread()); + result.current({ + transactionID: TRANSACTION_ID, + reportActions: [iouAction], + report, + transaction: undefined, + siblingTransactionIDs: SIBLING_TRANSACTION_IDS, + ...overrides, + }); + return waitForBatchedUpdates(); +} + +/** + * Regression guard for https://github.com/Expensify/App/issues/98196: an expense row inside a report that was opened + * from a broader carousel (e.g. the Spend page) must not re-seed the carousel with just that report's expenses, which + * shrank the "x of y" counter and left the wrong list behind after navigating back. + */ +describe('useNavigateToTransactionThread', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockedGetIOUAction.mockReturnValue(iouAction); + mockedShouldPreserve.mockReturnValue(false); + mockedSetActiveTransactionIDs.mockReturnValue(Promise.resolve()); + }); + + it('seeds the carousel with the given siblings by default', async () => { + await callHook(); + + expect(mockedSetActiveTransactionIDs).toHaveBeenCalledWith(SIBLING_TRANSACTION_IDS); + expect(mockedShouldPreserve).not.toHaveBeenCalled(); + }); + + it('still seeds when preservation is requested but no broader carousel is active', async () => { + await callHook({shouldPreserveBroaderCarousel: true}); + + expect(mockedShouldPreserve).toHaveBeenCalledWith(SIBLING_TRANSACTION_IDS, TRANSACTION_ID); + expect(mockedSetActiveTransactionIDs).toHaveBeenCalledWith(SIBLING_TRANSACTION_IDS); + }); + + it('leaves a broader carousel untouched when preservation is requested', async () => { + mockedShouldPreserve.mockReturnValue(true); + + await callHook({shouldPreserveBroaderCarousel: true}); + + expect(mockedSetActiveTransactionIDs).not.toHaveBeenCalled(); + }); + + it('navigates to the thread whether or not the carousel was re-seeded', async () => { + mockedShouldPreserve.mockReturnValue(true); + + await callHook({shouldPreserveBroaderCarousel: true}); + + // Navigation used to be chained onto the seeding promise, so skipping the write must not skip the hop. + expect(mockedNavigate).toHaveBeenCalledTimes(1); + expect(mockedNavigate.mock.calls.at(0)?.at(0)).toContain(THREAD_REPORT_ID); + }); +}); diff --git a/tests/unit/libs/actions/TransactionThreadNavigationTest.ts b/tests/unit/libs/actions/TransactionThreadNavigationTest.ts new file mode 100644 index 000000000000..89771dc13a07 --- /dev/null +++ b/tests/unit/libs/actions/TransactionThreadNavigationTest.ts @@ -0,0 +1,135 @@ +import {clearActiveTransactionIDs, setActiveTransactionIDs, shouldPreserveActiveTransactionIDs, shouldWriteActiveTransactionIDsForSearch} from '@libs/actions/TransactionThreadNavigation'; + +import ONYXKEYS from '@src/ONYXKEYS'; + +import Onyx from 'react-native-onyx'; + +import waitForBatchedUpdates from '../../../utils/waitForBatchedUpdates'; + +const SEARCH_HASH = 959171759; +const OTHER_SEARCH_HASH = 123456; + +// The Spend page's expense list, as the carousel was seeded with it when a row was pressed. +const SEEDED_IDS = ['A1', 'A2', 'A3']; + +// The Spend page holds one expense from report A and two from report B; report B owns only its own two. +const SPEND_PAGE_IDS = ['A1', 'B1', 'B2']; +const REPORT_B_IDS = ['B1', 'B2']; + +describe('shouldWriteActiveTransactionIDsForSearch', () => { + it('refreshes when the search gained one expense, so y grows by one', () => { + expect(shouldWriteActiveTransactionIDsForSearch(SEEDED_IDS, SEARCH_HASH, SEARCH_HASH, ['A0', ...SEEDED_IDS])).toBe(true); + }); + + it('refreshes when the search gained two expenses, so y grows by two', () => { + expect(shouldWriteActiveTransactionIDsForSearch(SEEDED_IDS, SEARCH_HASH, SEARCH_HASH, ['A0', 'A00', ...SEEDED_IDS])).toBe(true); + }); + + it('refreshes when the search lost an expense but still has two to navigate between', () => { + expect(shouldWriteActiveTransactionIDsForSearch(SEEDED_IDS, SEARCH_HASH, SEARCH_HASH, ['A1', 'A3'])).toBe(true); + }); + + it('refreshes when the same expenses are re-sorted, because the counter index follows the list order', () => { + expect(shouldWriteActiveTransactionIDsForSearch(SEEDED_IDS, SEARCH_HASH, SEARCH_HASH, ['A3', 'A2', 'A1'])).toBe(true); + }); + + it('does nothing when the carousel already matches the search', () => { + expect(shouldWriteActiveTransactionIDsForSearch(SEEDED_IDS, SEARCH_HASH, SEARCH_HASH, [...SEEDED_IDS])).toBe(false); + }); + + it('leaves another search\u2019s carousel alone', () => { + expect(shouldWriteActiveTransactionIDsForSearch(SEEDED_IDS, OTHER_SEARCH_HASH, SEARCH_HASH, ['A0', ...SEEDED_IDS])).toBe(false); + }); + + it('leaves a report-scoped carousel alone, since a drill-in clears the snapshot hash', () => { + expect(shouldWriteActiveTransactionIDsForSearch(['B1', 'B2'], undefined, SEARCH_HASH, ['A0', ...SEEDED_IDS])).toBe(false); + }); + + it('adopts the carousel when nothing is active, so it comes back after a clear', () => { + expect(shouldWriteActiveTransactionIDsForSearch(undefined, undefined, SEARCH_HASH, SEEDED_IDS)).toBe(true); + expect(shouldWriteActiveTransactionIDsForSearch([], undefined, SEARCH_HASH, SEEDED_IDS)).toBe(true); + }); + + it('does not adopt a carousel for a single expense, which is nothing to navigate between', () => { + expect(shouldWriteActiveTransactionIDsForSearch(undefined, undefined, SEARCH_HASH, ['A1'])).toBe(false); + }); + + it('re-seeds down to a single remaining expense rather than retiring the carousel', () => { + expect(shouldWriteActiveTransactionIDsForSearch(SEEDED_IDS, SEARCH_HASH, SEARCH_HASH, ['A1'])).toBe(true); + }); + + it('restores the carousel once a shrunken search grows back to two expenses', () => { + expect(shouldWriteActiveTransactionIDsForSearch(['A1'], SEARCH_HASH, SEARCH_HASH, ['A1', 'A2'])).toBe(true); + }); + + // A merge deletes transactions through an unbatched Onyx.set, so the derived list momentarily empties. Writing that + // would leave nothing for the settled render to recover from. + it('ignores an empty search list, which is the shape a search takes mid-update', () => { + expect(shouldWriteActiveTransactionIDsForSearch(SEEDED_IDS, SEARCH_HASH, SEARCH_HASH, [])).toBe(false); + }); + + it('leaves another search\u2019s carousel alone even when this search is empty', () => { + expect(shouldWriteActiveTransactionIDsForSearch(SEEDED_IDS, OTHER_SEARCH_HASH, SEARCH_HASH, [])).toBe(false); + }); +}); + +/** + * Regression guard for https://github.com/Expensify/App/issues/98196: opening an expense from Spend seeds the carousel + * with every expense in the search, but drilling into the owning report and tapping a row there re-seeded it with just + * that report's expenses. Navigating back then left the counter showing the report's total instead of the search's. + */ +describe('shouldPreserveActiveTransactionIDs', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + await clearActiveTransactionIDs(); + await waitForBatchedUpdates(); + }); + + it('preserves a strictly broader carousel that contains the tapped expense', async () => { + await setActiveTransactionIDs(SPEND_PAGE_IDS, SEARCH_HASH); + + expect(shouldPreserveActiveTransactionIDs(REPORT_B_IDS, 'B2')).toBe(true); + }); + + it('does not preserve when no carousel is active', () => { + expect(shouldPreserveActiveTransactionIDs(REPORT_B_IDS, 'B2')).toBe(false); + }); + + it('does not preserve when the active carousel is missing the tapped expense', async () => { + await setActiveTransactionIDs(['C1', 'C2', 'C3'], SEARCH_HASH); + + expect(shouldPreserveActiveTransactionIDs(REPORT_B_IDS, 'B2')).toBe(false); + }); + + it('does not preserve when the active carousel does not cover every sibling', async () => { + // Broader by length, but B2 is absent, so prev/next would skip an expense the report shows. + await setActiveTransactionIDs(['A1', 'B1', 'C1'], SEARCH_HASH); + + expect(shouldPreserveActiveTransactionIDs(REPORT_B_IDS, 'B1')).toBe(false); + }); + + it('does not preserve an equally sized carousel, so a re-sorted report re-seeds in its new visual order', async () => { + await setActiveTransactionIDs(['B2', 'B1'], SEARCH_HASH); + + expect(shouldPreserveActiveTransactionIDs(REPORT_B_IDS, 'B1')).toBe(false); + }); + + it('does not preserve a narrower carousel', async () => { + await setActiveTransactionIDs(['B1'], SEARCH_HASH); + + expect(shouldPreserveActiveTransactionIDs(REPORT_B_IDS, 'B1')).toBe(false); + }); + + it('does not preserve a carousel that only exists in persisted Onyx state', async () => { + // The Onyx key is persisted, so on a fresh load it can hold a carousel from an earlier session or a search that + // no longer matches the screen. Preserving that made a drill-in show a stale total ("4 of 6" in a 2-expense + // report), so only a list seeded during this session counts. + await Onyx.set(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS, SPEND_PAGE_IDS); + await waitForBatchedUpdates(); + + expect(shouldPreserveActiveTransactionIDs(REPORT_B_IDS, 'B2')).toBe(false); + }); +}); From 727f769aa442d057a5e51f37a2bcc740c23abbf2 Mon Sep 17 00:00:00 2001 From: thelullabyy <182625428+thelullabyy@users.noreply.github.com> Date: Fri, 21 Aug 2026 07:25:09 +0700 Subject: [PATCH 2/3] resolve comments --- .../MoneyRequestReportTransactionList.tsx | 15 ++++++++------- .../MoneyRequestReportTransactionsNavigation.tsx | 11 +++++++++-- src/components/PopoverMenu/index.tsx | 5 +++++ .../useMoneyReportHeaderMoreContentVisibility.ts | 2 +- 4 files changed, 23 insertions(+), 10 deletions(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx index 0a584fa9d032..db7a51fae7b7 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx @@ -72,6 +72,7 @@ import SCREENS from '@src/SCREENS'; import type {StableReport} from '@src/selectors/Report'; import type * as OnyxTypes from '@src/types/onyx'; import type {PendingAction} from '@src/types/onyx/OnyxCommon'; +import getEmptyArray from '@src/types/utils/getEmptyArray'; import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle, ViewToken} from 'react-native'; @@ -583,7 +584,7 @@ function MoneyRequestReportTransactionList({ // new visual order. The active-list checks in the effect still prevent unrelated carousels from being overwritten. const visualOrderTransactionIDsKey = useMemo(() => visualOrderTransactionIDs.join(','), [visualOrderTransactionIDs]); - const [latestActiveTransactionIDs] = useOnyx(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS); + const [latestActiveTransactionIDs = getEmptyArray()] = useOnyx(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS); useEffect(() => { const focusedRoute = findFocusedRoute(navigationRef.getRootState()); @@ -592,7 +593,7 @@ function MoneyRequestReportTransactionList({ } const anchorTransactionID = (focusedRoute?.params as {anchorTransactionID?: string} | undefined)?.anchorTransactionID; - if (anchorTransactionID && latestActiveTransactionIDs?.includes(anchorTransactionID)) { + if (anchorTransactionID && latestActiveTransactionIDs.includes(anchorTransactionID)) { return; } // Don't take over a snapshot-backed carousel (identified by its sibling descriptors, e.g. the Home @@ -603,15 +604,15 @@ function MoneyRequestReportTransactionList({ return; } + // This report can't drive a carousel on its own: the carousel needs at least two transactions to page + // between. Writing a 0/1-entry list would clobber a broader carousel the user drilled in from (e.g. the + // Spend page's full transaction list) and would also make the header render the empty transaction + // carousel instead of the report-level prev/next buttons. if (visualOrderTransactionIDs.length < 2) { return; } - if ( - latestActiveTransactionIDs && - latestActiveTransactionIDs.length >= visualOrderTransactionIDs.length && - visualOrderTransactionIDs.every((id) => latestActiveTransactionIDs.includes(id)) - ) { + if (latestActiveTransactionIDs.length >= visualOrderTransactionIDs.length && visualOrderTransactionIDs.every((id) => latestActiveTransactionIDs.includes(id))) { return; } setActiveTransactionIDs(visualOrderTransactionIDs); diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx index 5451fbda5875..e992fd8bca2e 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx @@ -64,6 +64,10 @@ function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromR // 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. + // `useOnyx`'s automatic snapshot redirection doesn't cover this: it only kicks in inside `SearchScopeProvider` + // (which wraps the search list, not the RHP this header renders in), it reads the *currently displayed* + // search hash rather than the one the carousel was seeded from, and it returns snapshot data *instead of* + // live data — whereas here live data has to win over the snapshot (see the merge below). 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 @@ -113,17 +117,20 @@ function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromR const parentActions: Record = {}; for (const transaction of [currentTransaction, prevTransaction, nextTransaction]) { const key = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transaction?.reportID}` as const; - collectParentReportActions(allReportActions?.[key] ?? (snapshot?.data?.[key] as OnyxTypes.ReportActions | undefined), parentActions); + collectParentReportActions(allReportActions?.[key], parentActions); } return parentActions; }, - [currentTransaction, nextTransaction, prevTransaction, snapshot], + [currentTransaction, nextTransaction, prevTransaction], ); const [reportedParentReportActions = getEmptyObject>()] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS, { selector: parentReportActionsSelector, }); + // The live pass above can only look up `report_actions_{transaction.reportID}`, which never resolves an + // unreported (self-DM) sibling — its IOU action lives in the self-DM's report actions, not under reportID "0". + // Scanning the snapshot's report actions is how those siblings get a parent action at all. const snapshotData = snapshot?.data; const snapshotParentReportActions = useMemo(() => { const parentActions: Record = {}; diff --git a/src/components/PopoverMenu/index.tsx b/src/components/PopoverMenu/index.tsx index c212eb57c94a..d05a2de492a8 100644 --- a/src/components/PopoverMenu/index.tsx +++ b/src/components/PopoverMenu/index.tsx @@ -235,6 +235,11 @@ function getSelectedItemIndex(menuItems: PopoverMenuItem[]) { return menuItems.findIndex((option) => option.isSelected); } +/** + * How much room a scrollable popover actually has, given where it is anchored. Bounding the popover by the + * full window height instead lets it overflow the screen edge when the anchor sits away from the top, which is + * what happens to the header's "More" menu once the header buttons move down into the status/next-step row. + */ function getAvailableHeightForAnchor(anchorVertical: number, verticalAlignment: AnchorAlignment['vertical'], windowHeight: number): number { if (verticalAlignment === CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP) { return windowHeight - anchorVertical; diff --git a/src/hooks/useMoneyReportHeaderMoreContentVisibility.ts b/src/hooks/useMoneyReportHeaderMoreContentVisibility.ts index 8813fccddf5d..0bd91309b9d8 100644 --- a/src/hooks/useMoneyReportHeaderMoreContentVisibility.ts +++ b/src/hooks/useMoneyReportHeaderMoreContentVisibility.ts @@ -29,7 +29,7 @@ type MoneyReportHeaderMoreContentVisibility = { * would be stranded alone under the title, and belong in the header row instead. */ function useMoneyReportHeaderMoreContentVisibility(reportID: string | undefined): MoneyReportHeaderMoreContentVisibility { - const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportID)}`); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(moneyRequestReport?.policyID)}`); const {shouldShowStatusBar, statusBarType} = useMoneyReportHeaderStatusBar(reportID, moneyRequestReport?.chatReportID); From 910310dacabab494bd753c6d1ca9beab7e91572f Mon Sep 17 00:00:00 2001 From: thelullabyy <182625428+thelullabyy@users.noreply.github.com> Date: Mon, 24 Aug 2026 02:08:07 +0800 Subject: [PATCH 3/3] fix: re-run tests