From bac6c8af8d5a3e3521f5a880c3edd42584b990af Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Thu, 27 Aug 2026 11:02:11 -0400 Subject: [PATCH] Revert "Merge pull request #98611 from thelullabyy/fix-03/90366" This reverts commit a29eb30142d79be6251894dbfd8d4443ecee1f31, reversing changes made to e49e54701dfb16095635da25e692ddd5bdb94e15. --- src/ONYXKEYS.ts | 5 - src/components/MoneyReportHeader.tsx | 74 +-- .../MoneyReportHeaderMoreContent.tsx | 70 +-- src/components/MoneyRequestHeader.tsx | 22 +- .../MoneyRequestReportNavigation.tsx | 14 +- .../MoneyRequestReportTransactionList.tsx | 32 +- ...neyRequestReportTransactionsNavigation.tsx | 381 ++++++--------- src/components/PopoverMenu/index.tsx | 32 +- .../ListItem/TransactionGroupListExpanded.tsx | 4 +- src/components/Search/index.tsx | 77 ++- ...eMoneyReportHeaderMoreContentVisibility.ts | 46 -- src/hooks/useNavigateToTransactionThread.ts | 15 +- src/languages/de.ts | 1 - src/languages/el.ts | 1 - 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, 374 insertions(+), 1741 deletions(-) delete mode 100644 src/hooks/useMoneyReportHeaderMoreContentVisibility.ts delete mode 100644 tests/ui/components/MoneyReportHeaderActionsPlacementTest.tsx create mode 100644 tests/ui/components/MoneyReportHeaderMoreContentTest.tsx delete mode 100644 tests/ui/components/MoneyReportHeaderMoreContentVisibilityTest.tsx delete mode 100644 tests/unit/components/MoneyRequestReportTransactionsNavigation.test.tsx delete mode 100644 tests/unit/hooks/useNavigateToTransactionThread.test.ts delete mode 100644 tests/unit/libs/actions/TransactionThreadNavigationTest.ts diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 4d8bdbdbeb51..63413dfe3931 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -830,10 +830,6 @@ 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', @@ -1839,7 +1835,6 @@ 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 205ca942b54d..92f168746b29 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -1,6 +1,5 @@ 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'; @@ -10,21 +9,16 @@ 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, {useCallback, useEffect} from 'react'; +import React, {useEffect} from 'react'; import {View} from 'react-native'; import HeaderLoadingBar from './HeaderLoadingBar'; @@ -33,8 +27,6 @@ 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'; @@ -87,38 +79,15 @@ 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); @@ -166,28 +135,14 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt shouldEnableDetailPageNavigation openParentReportInCurrentTab > - - {shouldRenderActionsInHeaderRow && ( - - )} - {isReportInSearch && - (shouldShowTransactionNavigation && carouselAnchorTransactionID ? ( - - ) : ( - - ))} - + {shouldShowHeaderButtonsInHeaderRow && ( + + )} {!shouldShowHeaderButtonsInHeaderRow && ( )} - + ); diff --git a/src/components/MoneyReportHeaderMoreContent.tsx b/src/components/MoneyReportHeaderMoreContent.tsx index 168961dc0903..b8a5494fa99e 100644 --- a/src/components/MoneyReportHeaderMoreContent.tsx +++ b/src/components/MoneyReportHeaderMoreContent.tsx @@ -1,12 +1,17 @@ +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'; @@ -17,37 +22,20 @@ 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 — decides whether the more-content section should render at all, - * avoiding expensive hooks in the body when nothing is shown. + * Cheap visibility gate — fetches minimal data to decide whether the more-content section + * should render at all, avoiding expensive hooks in the body when nothing is shown. */ -function MoneyReportHeaderMoreContent({reportID, primaryAction, backTo, statusBarType, shouldShowNextStep, shouldRenderActionsInRow}: MoneyReportHeaderMoreContentProps) { +function MoneyReportHeaderMoreContent({reportID}: MoneyReportHeaderMoreContentProps) { const route = useRoute< | PlatformStackRouteProp | PlatformStackRouteProp @@ -57,9 +45,13 @@ function MoneyReportHeaderMoreContent({reportID, primaryAction, backTo, statusBa 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 hasStatusOrNextStep = shouldShowNextStep || !!statusBarType; - const shouldShowMoreContent = hasStatusOrNextStep || shouldRenderActionsInRow; + const shouldShowMoreContent = shouldShowNextStep || !!statusBarType || isReportInSearch; if (!shouldShowMoreContent) { return null; @@ -71,9 +63,6 @@ function MoneyReportHeaderMoreContent({reportID, primaryAction, backTo, statusBa statusBarType={statusBarType} isReportInSearch={isReportInSearch} shouldShowNextStep={shouldShowNextStep} - primaryAction={primaryAction} - backTo={backTo} - shouldRenderActionsInRow={shouldRenderActionsInRow} /> ); } @@ -83,27 +72,20 @@ type MoneyReportHeaderMoreContentBodyProps = { statusBarType: ValueOf | undefined; isReportInSearch: boolean; shouldShowNextStep: boolean; - primaryAction: MoneyReportHeaderActionsProps['primaryAction']; - backTo: Route | undefined; - shouldRenderActionsInRow: boolean; }; -function MoneyReportHeaderMoreContentBody({ - moneyRequestReport, - statusBarType, - isReportInSearch, - shouldShowNextStep, - primaryAction, - backTo, - shouldRenderActionsInRow, -}: MoneyReportHeaderMoreContentBodyProps) { +function MoneyReportHeaderMoreContentBody({moneyRequestReport, statusBarType, isReportInSearch, shouldShowNextStep}: 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 && } - {shouldRenderActionsInRow && ( - )} diff --git a/src/components/MoneyRequestHeader.tsx b/src/components/MoneyRequestHeader.tsx index b0830aedddbe..915ae005b74c 100644 --- a/src/components/MoneyRequestHeader.tsx +++ b/src/components/MoneyRequestHeader.tsx @@ -103,7 +103,6 @@ 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 && !statusBarProps && ( + {!shouldDisplayButtonsInSeparateLine && ( )} @@ -204,19 +202,11 @@ function MoneyRequestHeader({reportID: reportIDProp, onBackButtonPress}: MoneyRe /> )} {!!statusBarProps && ( - - - - - {!shouldDisplayButtonsInSeparateLine && ( - - )} + + )} diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportNavigation.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportNavigation.tsx index 4299d1b9890d..d3506a09dd0f 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportNavigation.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportNavigation.tsx @@ -3,7 +3,6 @@ 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'; @@ -22,7 +21,6 @@ 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'; @@ -115,8 +113,6 @@ 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. @@ -155,7 +151,7 @@ function MoneyRequestReportNavigationContent({reportID, shouldDisplayNarrowVersi const shouldDisplayNavigationArrows = effectiveAllReports.length > 1 && currentIndex !== -1 && !!lastSearchQuery?.queryJSON; useEffect(() => { - if (!isFocused || !lastSearchQuery?.queryJSON) { + if (!lastSearchQuery?.queryJSON) { return; } @@ -185,7 +181,7 @@ function MoneyRequestReportNavigationContent({reportID, shouldDisplayNarrowVersi ...lastSearchQuery, previousLengthOfResults: effectiveAllReports.length, }); - }, [isFocused, currentIndex, allReportsCount, effectiveAllReports.length, lastSearchQuery?.queryJSON, lastSearchQuery]); + }, [currentIndex, allReportsCount, effectiveAllReports.length, lastSearchQuery?.queryJSON, lastSearchQuery]); const goToReportId = (reportId?: string) => { if (!reportId) { @@ -246,11 +242,7 @@ function MoneyRequestReportNavigationContent({reportID, shouldDisplayNarrowVersi {!shouldUseContextReports && } {shouldDisplayNavigationArrows && ( - {!shouldDisplayNarrowVersion && ( - - {translate('common.currentOfTotal', {current: currentIndex + 1, total: allReportsCount})} - - )} + {!shouldDisplayNarrowVersion && {`${currentIndex + 1} of ${allReportsCount}`}} group.transactions.filter((transaction) => !isTransactionPendingDelete(transaction)).map((transaction) => transaction.transactionID)); }, [groupedTransactions, sortedTransactions, shouldGroupTransactions]); - // 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. + // Primitive proxy for visualOrderTransactionIDs used as the effect dependency below. + // Other callers (e.g. TransactionDuplicateReview.onPreviewPressed) can write to the same + // Onyx key with a different ordering. Using the raw array reference would cause the effect + // to re-fire on every referential change and overwrite those IDs. The joined string ensures + // the effect only re-fires when the actual content changes. const visualOrderTransactionIDsKey = useMemo(() => visualOrderTransactionIDs.join(','), [visualOrderTransactionIDs]); - const [latestActiveTransactionIDs = getEmptyArray()] = 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 @@ -612,23 +605,11 @@ function MoneyRequestReportTransactionList({ if (getActiveTransactionIDs().descriptors) { 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.length >= visualOrderTransactionIDs.length && visualOrderTransactionIDs.every((id) => latestActiveTransactionIDs.includes(id))) { - return; - } setActiveTransactionIDs(visualOrderTransactionIDs); return () => { clearActiveTransactionIDs(); }; - // 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 + // eslint-disable-next-line react-hooks/exhaustive-deps -- visualOrderTransactionIDsKey is a primitive proxy for the array to avoid re-firing on referential-only changes }, [visualOrderTransactionIDsKey]); const groupSelectionState = useMemo(() => { @@ -686,7 +667,6 @@ 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 3afdfd942a9e..0d55e35a9250 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx @@ -1,116 +1,54 @@ 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 getEmptyArray from '@src/types/utils/getEmptyArray'; import type {GestureResponderEvent} from 'react-native'; -import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import type {OnyxCollection} 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; -}; - -type PrevNextParentReportActions = { - prevParentReportAction: OnyxTypes.ReportAction | undefined; - nextParentReportAction: OnyxTypes.ReportAction | undefined; }; -/** - * Only the prev/next parent actions are ever read, so resolve them while scanning instead of collecting every money - * request action on the parent reports (fast-equals compares the resulting collection on every Onyx update). - */ -const collectParentReportActions = ( - reportActions: OnyxEntry, - prevTransactionID: string | undefined, - nextTransactionID: string | undefined, - parentActions: PrevNextParentReportActions, -) => { - for (const action of Object.values(reportActions ?? {})) { - const transactionID = isMoneyRequestAction(action) ? getOriginalMessage(action)?.IOUTransactionID : undefined; - if (!transactionID) { - continue; - } - if (transactionID === prevTransactionID) { - // eslint-disable-next-line no-param-reassign -- intentionally mutates the shared accumulator so callers can resolve both actions in a single pass across multiple report-action sources - parentActions.prevParentReportAction = action; - } - if (transactionID === nextTransactionID) { - // eslint-disable-next-line no-param-reassign -- intentionally mutates the shared accumulator so callers can resolve both actions in a single pass across multiple report-action sources - parentActions.nextParentReportAction = action; - } - } -}; - -function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromReviewDuplicates, shouldDisplayNarrowVersion}: MoneyRequestReportRHPNavigationButtonsProps) { - const styles = useThemeStyles(); - const {translate} = useLocalize(); +function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromReviewDuplicates}: MoneyRequestReportRHPNavigationButtonsProps) { 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. - // `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 - // 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 [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); const personalDetails = usePersonalDetails(); - const currentTransactionIndex = transactionIDsList.findIndex((id) => id === currentTransactionID); + const {email: currentUserEmail, accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); + const {markReportRHPWidth} = useWideRHPActions(); 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); @@ -118,15 +56,12 @@ function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromR prevTransactionID: prevID, nextTransactionID: nextID, }; - }, [currentTransactionIndex, transactionIDsList]); + }, [currentTransactionID, transactionIDsList]); const prevNextTransactionsSelector = useCallback( (allTransactions: OnyxCollection) => - [currentTransactionID, prevTransactionID, nextTransactionID].map((transactionID) => { - const key = `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}` as const; - return allTransactions?.[key] ?? snapshot?.data?.[key]; - }), - [currentTransactionID, nextTransactionID, prevTransactionID, snapshot], + [currentTransactionID, prevTransactionID, nextTransactionID].map((transactionID) => allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]), + [currentTransactionID, nextTransactionID, prevTransactionID], ); const [[currentTransaction, prevTransaction, nextTransaction] = getEmptyArray()] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, { @@ -137,61 +72,42 @@ function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromR // a Map of every money request action on the three parent reports (fast-equals compares Maps in O(n^2)). const parentReportActionsSelector = useCallback( (allReportActions: OnyxCollection) => { - const parentActions: PrevNextParentReportActions = {prevParentReportAction: undefined, nextParentReportAction: undefined}; + let prevParentReportAction: OnyxTypes.ReportAction | undefined; + let nextParentReportAction: OnyxTypes.ReportAction | undefined; if (!prevTransactionID && !nextTransactionID) { - return parentActions; + return {prevParentReportAction, nextParentReportAction}; } const parentReportIDs = new Set([currentTransaction?.reportID, prevTransaction?.reportID, nextTransaction?.reportID]); for (const parentReportID of parentReportIDs) { - const key = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}` as const; - collectParentReportActions(allReportActions?.[key], prevTransactionID, nextTransactionID, parentActions); + for (const action of Object.values(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`] ?? {})) { + const transactionID = isMoneyRequestAction(action) ? getOriginalMessage(action)?.IOUTransactionID : undefined; + if (!transactionID) { + continue; + } + if (transactionID === prevTransactionID) { + prevParentReportAction = action; + } + if (transactionID === nextTransactionID) { + nextParentReportAction = action; + } + } } - return parentActions; + return {prevParentReportAction, nextParentReportAction}; }, [currentTransaction?.reportID, nextTransaction?.reportID, nextTransactionID, prevTransaction?.reportID, prevTransactionID], ); - const [reportedParentReportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS, { + const [parentReportActions] = 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: PrevNextParentReportActions = {prevParentReportAction: undefined, nextParentReportAction: undefined}; - if (snapshotData && (prevTransactionID ?? nextTransactionID)) { - for (const [key, reportActionsForReport] of Object.entries(snapshotData)) { - if (key.startsWith(ONYXKEYS.COLLECTION.REPORT_ACTIONS)) { - collectParentReportActions(reportActionsForReport as OnyxTypes.ReportActions, prevTransactionID, nextTransactionID, parentActions); - } - } - } - return parentActions; - }, [nextTransactionID, prevTransactionID, snapshotData]); - - // Live report actions win over the snapshot: the snapshot only fills in siblings 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 prevParentReportAction = reportedParentReportActions?.prevParentReportAction ?? snapshotParentReportActions.prevParentReportAction; - const nextParentReportAction = reportedParentReportActions?.nextParentReportAction ?? snapshotParentReportActions.nextParentReportAction; - - 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}`]; + const prevParentReportAction = parentReportActions?.prevParentReportAction; + const nextParentReportAction = parentReportActions?.nextParentReportAction; + + 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}`); /** * We clear the sibling transactionThreadIDs when unmounting this component @@ -200,7 +116,7 @@ function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromR useEffect(() => { return () => { const focusedRoute = findFocusedRoute(navigationRef.getRootState()); - if (focusedRoute?.name && (CAROUSEL_PRESERVING_SCREENS as readonly string[]).includes(focusedRoute.name)) { + if (focusedRoute?.name === SCREENS.RIGHT_MODAL.SEARCH_REPORT || focusedRoute?.name === SCREENS.TRANSACTION_DUPLICATE.DYNAMIC_REVIEW) { return; } clearActiveTransactionIDs(); @@ -211,26 +127,15 @@ function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromR return; } - const getBackTo = () => { + const onNext = (e: GestureResponderEvent | KeyboardEvent | undefined) => { + e?.preventDefault(); + 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 @@ -238,132 +143,142 @@ function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromR // hydrate it on arrival. const nextDescriptor = nextTransactionID ? siblingDescriptorsByTransactionID?.[nextTransactionID] : undefined; if (nextDescriptor) { - const nextReportID = getReportIDToOpenForExpense(nextDescriptor, { - introSelected, - conciergeChat, - betas, - currentUserEmail: email, - currentUserAccountID: accountID, - personalDetails, + requestAnimationFrame(() => { + const nextReportID = getReportIDToOpenForExpense(nextDescriptor, { + introSelected, + conciergeChat, + betas, + currentUserEmail, + currentUserAccountID, + personalDetails, + }); + markReportRHPWidth(nextReportID, 'wide'); + requestAnimationFrame(() => + startTransition(() => + Navigation.setParams({ + reportID: nextReportID, + reportActionID: undefined, + backTo, + }), + ), + ); }); - 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, anchorTransactionID: nextTransactionID, backTo}; - - if (!nextThreadReportID && nextTransaction?.reportID && nextTransaction.reportID !== CONST.REPORT.UNREPORTED_REPORT_ID) { - const optimisticThread = createTransactionThreadReport({ - introSelected, - conciergeChat, - 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; - } - - if (nextThreadReportID) { - markReportRHPWidth(nextThreadReportID, 'wide'); - } + const navigationParams = { + reportID: nextThreadReportID, + reportActionID: undefined, + backTo, + }; - 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))); + 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, + conciergeChat, + 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))); + }); }; const onPrevious = (e: GestureResponderEvent | KeyboardEvent | undefined) => { e?.preventDefault(); - const backTo = getBackTo(); - // 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; + 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: resolve the target sibling lazily from its descriptor when present. const prevDescriptor = prevTransactionID ? siblingDescriptorsByTransactionID?.[prevTransactionID] : undefined; if (prevDescriptor) { - const prevReportID = getReportIDToOpenForExpense(prevDescriptor, { - introSelected, - conciergeChat, - betas, - currentUserEmail: email, - currentUserAccountID: accountID, - personalDetails, + requestAnimationFrame(() => { + const prevReportID = getReportIDToOpenForExpense(prevDescriptor, { + introSelected, + conciergeChat, + betas, + currentUserEmail, + currentUserAccountID, + personalDetails, + }); + markReportRHPWidth(prevReportID, 'wide'); + requestAnimationFrame(() => + startTransition(() => + Navigation.setParams({ + reportID: prevReportID, + reportActionID: undefined, + backTo, + }), + ), + ); }); - 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, 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, - conciergeChat, - 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; - } + const navigationParams = { + reportID: prevThreadReportID, + reportActionID: undefined, + backTo, + }; - 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))); + 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, + conciergeChat, + 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))); + }); }; 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 1408ffd5457e..8e2a8336a339 100644 --- a/src/components/PopoverMenu/index.tsx +++ b/src/components/PopoverMenu/index.tsx @@ -247,22 +247,6 @@ 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; - } - 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`. @@ -673,23 +657,11 @@ function BasePopoverMenu({ const stylesArray: ViewStyle[] = [StyleSheet.flatten(styles.createMenuContainer), {width: variables.compactPopoverMenuWidth}, styles.pv2]; if (shouldUseScrollView && shouldEnableMaxHeight && !isInLandscapeMode) { - 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)}); + stylesArray.push({maxHeight: Math.max(windowHeight - variables.compactPopoverMenuVerticalMargin, CONST.POPOVER_MENU_MAX_HEIGHT)}); } return stylesArray; - }, [ - isSmallScreenWidth, - shouldEnableMaxHeight, - styles.createMenuContainer, - styles.pv2, - shouldUseScrollView, - windowHeight, - isInLandscapeMode, - anchorPosition.vertical, - anchorAlignment.vertical, - ]); + }, [isSmallScreenWidth, shouldEnableMaxHeight, styles.createMenuContainer, styles.pv2, shouldUseScrollView, windowHeight, isInLandscapeMode]); 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 122d83dedeee..b68c6466e5e3 100644 --- a/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx @@ -260,12 +260,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, transactionsQueryJSON?.hash); + setActiveTransactionIDs(siblingTransactionIDs); navigateToTransactionThread(); return; } - setActiveTransactionIDs(siblingTransactionIDs, transactionsQueryJSON?.hash).then(navigateToTransactionThread); + setActiveTransactionIDs(siblingTransactionIDs).then(navigateToTransactionThread); }; const onShowMoreButtonPress = () => { diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 39aaa9d22f86..13e67b10632b 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -25,7 +25,6 @@ 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'; @@ -63,7 +62,7 @@ import { getNavigateToReportsSpans, } from '@libs/telemetry/navigateToReportsSpans'; import {cancelSubmitFollowUpActionSpan, getPendingSubmitFollowUpAction} from '@libs/telemetry/submitFollowUpAction'; -import {isDeletedTransaction, isTransactionPendingDelete, shouldShowAttendees} from '@libs/TransactionUtils'; +import {isTransactionPendingDelete, shouldShowAttendees} from '@libs/TransactionUtils'; import Navigation, {navigationRef} from '@navigation/Navigation'; import type {SearchFullscreenNavigatorParamList} from '@navigation/types'; @@ -385,7 +384,14 @@ 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(() => { @@ -560,16 +566,6 @@ 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) { @@ -578,17 +574,6 @@ 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 @@ -695,7 +680,10 @@ 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; } @@ -710,7 +698,11 @@ 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; } @@ -751,31 +743,14 @@ function Search({ email, accountID, queryJSON, - hash, offset, searchResults?.search?.hasMoreResults, currentSearchKey, - carouselSiblingTransactionIDs, getCurrencyDecimals, conciergeChat, ], ); - 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. @@ -839,7 +814,9 @@ 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({ @@ -894,7 +871,9 @@ 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(); }, []); @@ -983,7 +962,13 @@ 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 deleted file mode 100644 index 0bd91309b9d8..000000000000 --- a/src/hooks/useMoneyReportHeaderMoreContentVisibility.ts +++ /dev/null @@ -1,46 +0,0 @@ -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}${getNonEmptyStringOnyxID(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 f4e3e1637980..f3f59199b9be 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, shouldPreserveActiveTransactionIDs} from '@libs/actions/TransactionThreadNavigation'; +import {setActiveTransactionIDs} from '@libs/actions/TransactionThreadNavigation'; import Navigation from '@libs/Navigation/Navigation'; import {getIOUActionForTransactionID} from '@libs/ReportActionsUtils'; @@ -31,9 +31,6 @@ 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; }; @@ -56,7 +53,7 @@ function useNavigateToTransactionThread() { const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); - return ({transactionID, reportActions, report, transaction, siblingTransactionIDs, shouldPreserveBroaderCarousel = false, backTo}: NavigateToTransactionThreadParams) => { + return ({transactionID, reportActions, report, transaction, siblingTransactionIDs, backTo}: NavigateToTransactionThreadParams) => { const iouAction = getIOUActionForTransactionID(reportActions, transactionID); const resolvedBackTo = backTo ?? Navigation.getActiveRoute(); let reportIDToNavigate = iouAction?.childReportID; @@ -87,12 +84,8 @@ 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. 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(() => { + // display prev/next arrows for navigation between expenses. + setActiveTransactionIDs(siblingTransactionIDs).then(() => { if (reportIDToNavigate) { markReportRHPWidth(reportIDToNavigate, 'wide'); } diff --git a/src/languages/de.ts b/src/languages/de.ts index e8d80f914839..88237b4c0d5f 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -499,7 +499,6 @@ 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 50e04721307a..fa914d069e6e 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -330,7 +330,6 @@ const translations: TranslationDeepObject = { automatic: 'Αυτόματο', showing: 'Εμφανίζονται', of: 'του', - currentOfTotal: ({current, total}: {current: number; total: number}) => `${current} από ${total}`, default: 'Προεπιλογή', update: 'Ενημέρωση', member: 'Μέλος', diff --git a/src/languages/en.ts b/src/languages/en.ts index 8c413235301f..c38b485abeb6 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -367,8 +367,6 @@ 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 71a31a3e30b7..6a6652872c24 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -485,7 +485,6 @@ 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 c9fbaac2dc7c..47a18f0ce344 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -499,7 +499,6 @@ 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 1ca001d6713f..efc722a148f8 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -499,7 +499,6 @@ 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 742e711618d6..009346f794a2 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -498,7 +498,6 @@ 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 8a0bdab327ba..28fd7d9ad85d 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -498,7 +498,6 @@ 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 56539e2854f3..00287d1685b4 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -502,7 +502,6 @@ 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 41d97949cecd..361133ed32fc 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -497,7 +497,6 @@ 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 2b33be697d52..cb89e0234f2b 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -494,7 +494,6 @@ 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 f22385a7c8a4..9722be1c9bf3 100644 --- a/src/libs/ExportOnyxState/common.ts +++ b/src/libs/ExportOnyxState/common.ts @@ -338,7 +338,6 @@ 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_BILLING_STATEMENT, ONYXKEYS.VALIDATE_DOMAIN_TWO_FACTOR_CODE, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 48449b383d2a..b4e28a3766e0 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -2766,7 +2766,6 @@ 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 964956e1c1ed..4e87112d3579 100644 --- a/src/libs/actions/TransactionThreadNavigation.ts +++ b/src/libs/actions/TransactionThreadNavigation.ts @@ -18,7 +18,6 @@ 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) { @@ -46,25 +45,19 @@ function areDescriptorMapsEqual(a: Record) { - const nextSnapshotHash = snapshotHash ?? null; +function setActiveTransactionIDs(ids: string[], siblingDescriptorsByTransactionID?: Record) { const nextDescriptors = siblingDescriptorsByTransactionID ?? null; - const areIDsUnchanged = lastSetIDs?.length === ids.length && lastSetIDs.every((id, i) => id === ids.at(i)); - if (areIDsUnchanged && lastSetSnapshotHash === nextSnapshotHash && areDescriptorMapsEqual(lastSetDescriptors, nextDescriptors)) { + const sameIDs = lastSetIDs?.length === ids.length && lastSetIDs.every((id, i) => id === ids.at(i)); + if (sameIDs && 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_SNAPSHOT_HASH, nextSnapshotHash), - 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_THREAD_REPORT_IDS, nextDescriptors)]); } /** @@ -76,42 +69,10 @@ 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_SNAPSHOT_HASH, 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_THREAD_REPORT_IDS, null)]); } -export {setActiveTransactionIDs, clearActiveTransactionIDs, getActiveTransactionIDs, shouldWriteActiveTransactionIDsForSearch, shouldPreserveActiveTransactionIDs}; +export {setActiveTransactionIDs, clearActiveTransactionIDs, getActiveTransactionIDs}; diff --git a/src/pages/home/RecentlyAddedSection/index.tsx b/src/pages/home/RecentlyAddedSection/index.tsx index 30eb3b1c7dac..3c72599e80ce 100644 --- a/src/pages/home/RecentlyAddedSection/index.tsx +++ b/src/pages/home/RecentlyAddedSection/index.tsx @@ -75,7 +75,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, undefined, siblingDescriptorsByTransactionID).then(() => { + setActiveTransactionIDs(siblingTransactionIDs, 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 4ef7605e2519..13247de98645 100644 --- a/src/pages/inbox/ReportNavigateAwayHandler.tsx +++ b/src/pages/inbox/ReportNavigateAwayHandler.tsx @@ -172,11 +172,15 @@ 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 (shouldTrigger) { + if ( + // non-optimistic case + (!prevUserLeavingStatus && !!userLeavingStatus) || + didReportClose || + isRemovalExpectedForReportType || + isClosedTopLevelPolicyRoom || + (prevDeletedParentAction && !deletedParentAction) + ) { navigateAwayFromReport(prevOnyxReportID, prevReport?.parentReportID); } }, [ diff --git a/src/styles/utils/sizing.ts b/src/styles/utils/sizing.ts index 4e8e41c484cc..216a5e90c82d 100644 --- a/src/styles/utils/sizing.ts +++ b/src/styles/utils/sizing.ts @@ -92,10 +92,6 @@ export default { minWidth: 8, }, - mnw8: { - minWidth: 32, - }, - mnw25: { minWidth: '25%', }, diff --git a/tests/ui/components/MoneyReportHeaderActionsPlacementTest.tsx b/tests/ui/components/MoneyReportHeaderActionsPlacementTest.tsx deleted file mode 100644 index d5cd4e8ea801..000000000000 --- a/tests/ui/components/MoneyReportHeaderActionsPlacementTest.tsx +++ /dev/null @@ -1,315 +0,0 @@ -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 new file mode 100644 index 000000000000..0cb04c080d37 --- /dev/null +++ b/tests/ui/components/MoneyReportHeaderMoreContentTest.tsx @@ -0,0 +1,120 @@ +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 deleted file mode 100644 index d52e54138a91..000000000000 --- a/tests/ui/components/MoneyReportHeaderMoreContentVisibilityTest.tsx +++ /dev/null @@ -1,120 +0,0 @@ -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 deleted file mode 100644 index 13b5622dc034..000000000000 --- a/tests/unit/components/MoneyRequestReportTransactionsNavigation.test.tsx +++ /dev/null @@ -1,458 +0,0 @@ -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 deleted file mode 100644 index 7955d5a6f9e8..000000000000 --- a/tests/unit/hooks/useNavigateToTransactionThread.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -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 deleted file mode 100644 index 89771dc13a07..000000000000 --- a/tests/unit/libs/actions/TransactionThreadNavigationTest.ts +++ /dev/null @@ -1,135 +0,0 @@ -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); - }); -});