From e207d6fccf75e9739371a94872ec8ab742e7c2d7 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Tue, 1 Sep 2026 11:56:44 +0200 Subject: [PATCH 01/28] =?UTF-8?q?Reapply=20"Confirm=20new=20expenses=20wit?= =?UTF-8?q?h=20a=20=E2=80=9CView=E2=80=9D=20toast=20instead=20of=20scrolli?= =?UTF-8?q?ng=20to=20and=20highlighting=20the=20row"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 47ece0230e816b21513fa336453a86f31cd8bf31. --- config/eslint/eslint.seatbelt.tsv | 1 - src/GlobalModals.tsx | 4 + src/ONYXKEYS.ts | 7 +- src/components/ExpenseAddedGrowl.tsx | 164 +++++++++++ src/components/Search/ChatSearchView.tsx | 6 +- .../Search/ExpenseFlatSearchView.tsx | 15 +- .../Search/ExpenseGroupedSearchView.tsx | 26 +- .../Search/ExpenseReportSearchView.tsx | 6 +- .../ListItem/ExpenseReportListItem.tsx | 14 +- .../ListItem/GroupChildrenContainer.tsx | 23 +- .../SearchList/ListItem/GroupHeader.tsx | 10 +- .../ListItem/TransactionGroupListItem.tsx | 10 +- .../TransactionListItemNarrow.tsx | 47 +--- .../TransactionListItemWide.tsx | 17 +- src/components/Search/TaskSearchView.tsx | 6 +- .../Search/hooks/useSearchSnapshot.ts | 58 +--- src/components/Search/index.tsx | 30 +- src/components/Search/searchViewProps.ts | 11 +- ...htAndScroll.ts => useSearchAutoRefetch.ts} | 237 ++++------------ src/languages/de.ts | 2 + src/languages/el.ts | 2 + src/languages/en.ts | 2 + src/languages/es.ts | 2 + src/languages/fr.ts | 2 + src/languages/it.ts | 2 + src/languages/ja.ts | 2 + src/languages/nl.ts | 2 + src/languages/pl.ts | 2 + src/languages/pt-BR.ts | 2 + src/languages/zh-hans.ts | 2 + src/libs/ExportOnyxState/common.ts | 2 +- .../helpers/navigateAfterExpenseCreate.ts | 86 +++++- src/libs/actions/IOU/Duplicate.ts | 6 + src/libs/actions/IOU/NavigationHelpers.ts | 47 ---- src/libs/actions/IOU/PerDiem.ts | 8 +- src/libs/actions/IOU/SendInvoice.ts | 6 +- src/libs/actions/IOU/Split.ts | 6 +- .../actions/IOU/SplitTransactionUpdate.ts | 29 +- src/libs/actions/IOU/TrackExpense.ts | 16 +- .../actions/IOU/signalExpenseAddedGrowl.ts | 13 + src/libs/actions/Transaction.ts | 6 +- src/libs/telemetry/submitFollowUpAction.ts | 4 +- src/pages/Share/SubmitDetailsPage.tsx | 5 + src/setup/index.ts | 3 + src/styles/utils/index.ts | 4 + tests/actions/IOU/SplitReportTotalsTest.ts | 180 ++---------- tests/actions/IOUTest/SplitTest.ts | 6 +- tests/unit/ExpenseAddedGrowlTest.tsx | 156 +++++++++++ tests/unit/Search/useSearchSnapshotTest.ts | 28 -- tests/unit/actionLayerNavigationGuardTest.ts | 2 - tests/unit/navigateAfterExpenseCreateTest.ts | 120 +++++++- ...ollTest.ts => useSearchAutoRefetchTest.ts} | 261 ++++-------------- 52 files changed, 780 insertions(+), 928 deletions(-) create mode 100644 src/components/ExpenseAddedGrowl.tsx rename src/hooks/{useSearchHighlightAndScroll.ts => useSearchAutoRefetch.ts} (54%) delete mode 100644 src/libs/actions/IOU/NavigationHelpers.ts create mode 100644 src/libs/actions/IOU/signalExpenseAddedGrowl.ts create mode 100644 tests/unit/ExpenseAddedGrowlTest.tsx rename tests/unit/{useSearchHighlightAndScrollTest.ts => useSearchAutoRefetchTest.ts} (59%) diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index 53b428ef642f..3c559ad74a58 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -482,7 +482,6 @@ "../../src/hooks/useReviewDuplicatesNavigation.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/hooks/useReviewDuplicatesNavigation.tsx" "react-hooks/set-state-in-effect" 1 "../../src/hooks/useSearchBulkActions.ts" "@typescript-eslint/no-unsafe-type-assertion" 10 -"../../src/hooks/useSearchHighlightAndScroll.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../src/hooks/useSidebarOrderedReports.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/hooks/useSidebarOrderedReports.tsx" "react-hooks/refs" 5 "../../src/hooks/useSidebarOrderedReports.tsx" "react-hooks/set-state-in-effect" 1 diff --git a/src/GlobalModals.tsx b/src/GlobalModals.tsx index a29dca9321cc..435ce77e645b 100644 --- a/src/GlobalModals.tsx +++ b/src/GlobalModals.tsx @@ -2,8 +2,10 @@ import React, {startTransition, useEffect, useState} from 'react'; import DelegateNoAccessModalProvider from './components/DelegateNoAccessModalProvider'; import EmojiPicker from './components/EmojiPicker/EmojiPicker'; +import ExpenseAddedGrowl from './components/ExpenseAddedGrowl'; import GrowlNotification from './components/GrowlNotification'; import LazyModalSlot from './components/LazyModalSlot'; +import useIsAuthenticated from './hooks/useIsAuthenticated'; import * as EmojiPickerAction from './libs/actions/EmojiPickerAction'; import {growlRef} from './libs/Growl'; import * as ReportActionContextMenu from './pages/inbox/report/ContextMenu/ReportActionContextMenu'; @@ -24,6 +26,7 @@ const IDLE_CALLBACK_TIMEOUT_MS = 2000; function GlobalModals() { const [shouldRenderContextMenu, setShouldRenderContextMenu] = useState(false); const [shouldRenderDeferredModals, setShouldRenderDeferredModals] = useState(false); + const isAuthenticated = useIsAuthenticated(); // Defer loading the context menu and rare-condition modals until after startup to avoid // pulling in their dependencies (ContextMenuActions, ReportUtils, ModifiedExpenseMessage, @@ -50,6 +53,7 @@ function GlobalModals() { return ( <> + {isAuthenticated && } {shouldRenderContextMenu && ( diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index f06705ec09b5..d4bb5b4165ae 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -14,6 +14,7 @@ import type {Attendee, DistanceExpenseType, Participant} from './types/onyx/IOU' import type Onboarding from './types/onyx/Onboarding'; import type {AnyOnyxUpdate} from './types/onyx/Request'; import type {SavedCSVColumnLayoutList} from './types/onyx/SavedCSVColumnLayout'; +import type {SearchDataTypes} from './types/onyx/SearchResults'; import type AssertTypesEqual from './types/utils/AssertTypesEqual'; import type DeepValueOf from './types/utils/DeepValueOf'; @@ -870,8 +871,8 @@ const ONYXKEYS = { /** Whether the user has denied the contact import permission prompt */ HAS_DENIED_CONTACT_IMPORT_PROMPT: 'hasDeniedContactImportPrompt', - /** The transaction IDs to be highlighted when opening the Expenses search route page */ - TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE: 'transactionIdsHighlightOnSearchRoute', + /** Maps each newly-added transaction ID to its search data type, flagging it for the "Expense added" growl */ + EXPENSE_ADDED_GROWL_TRANSACTION_IDS: 'expenseAddedGrowlTransactionIDs', /** The report ID to be highlighted when returning to the workspace rooms page */ ROOM_ID_HIGHLIGHT_ON_ROOMS_PAGE: 'roomIDHighlightOnRoomsPage', @@ -1859,7 +1860,7 @@ type OnyxValuesMapping = { [ONYXKEYS.NVP_REPORT_DETAILS_COLUMNS]: string[]; [ONYXKEYS.HAS_DENIED_CONTACT_IMPORT_PROMPT]: boolean | undefined; [ONYXKEYS.PERSONAL_POLICY_ID]: string; - [ONYXKEYS.TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE]: Record>; + [ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS]: Record; [ONYXKEYS.ROOM_ID_HIGHLIGHT_ON_ROOMS_PAGE]: string | null; [ONYXKEYS.DOMAIN_GROUP_CREATE_PREFERRED_POLICY_ID]: string | undefined; }; diff --git a/src/components/ExpenseAddedGrowl.tsx b/src/components/ExpenseAddedGrowl.tsx new file mode 100644 index 000000000000..f880475951cf --- /dev/null +++ b/src/components/ExpenseAddedGrowl.tsx @@ -0,0 +1,164 @@ +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; + +import {createTransactionThreadReport, setOptimisticTransactionThread} from '@libs/actions/Report'; +import {mergeExpenseAddedGrowlTransactionIDs} from '@libs/actions/Transaction'; +import Log from '@libs/Log'; +import {navigateToCreatedExpense} from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; +import Navigation from '@libs/Navigation/Navigation'; +import {getIOUActionForTransactionID} from '@libs/ReportActionsUtils'; +import {findSelfDMReportID, isInvoiceReport, isMoneyRequestReport} from '@libs/ReportUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; + +import type {Dispatch, SetStateAction} from 'react'; + +import {useEffect, useRef, useState} from 'react'; + +import GrowlNotificationContent from './GrowlNotification/GrowlNotificationContent'; + +type ActiveGrowl = { + /** ID of the transaction the growl was raised for */ + transactionID: string; + + /** Search data type the growl belongs to (expense, invoice, etc.) */ + dataType: SearchDataTypes; + + /** Identifies each growl instance so a stale slide-out dismissal can't clear a newer growl */ + nonce: number; +}; + +type ExpenseAddedGrowlContentProps = { + /** Transaction to read for the growl: the active growl's transaction, or the latest pending signal ID */ + transactionID: string; + + /** Pending expense-added notifications, keyed by transaction ID with its search data type as the value */ + signal: Record | undefined; + + /** Currently displayed growl */ + active: ActiveGrowl | null; + + /** Setter for the active growl */ + setActive: Dispatch>; +}; + +/** Watches the "an expense was just added" Onyx signal and shows an "Expense added" growl with a "View" action. */ +function ExpenseAddedGrowl() { + const [active, setActive] = useState(null); + const [signal] = useOnyx(ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS); + const transactionID = active?.transactionID ?? Object.keys(signal ?? {}).at(-1); + + if (!transactionID) { + return null; + } + + return ( + + ); +} + +function ExpenseAddedGrowlContent({transactionID, signal, active, setActive}: ExpenseAddedGrowlContentProps) { + const nonceRef = useRef(0); + + const {translate} = useLocalize(); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); + const [betas] = useOnyx(ONYXKEYS.BETAS); + const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); + const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); + const reportID = transaction?.reportID; + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + + // A tracked expense's IOU action lives on the self-DM report, not on transaction.reportID, so read actions + // from the report that actually hosts it so "View" can resolve the real transaction thread. + const isUnreportedExpense = !reportID || reportID === CONST.REPORT.UNREPORTED_REPORT_ID; + const selfDMReportID = isUnreportedExpense ? findSelfDMReportID() : undefined; + const hostReportID = isUnreportedExpense ? selfDMReportID : reportID; + const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${hostReportID}`); + + // A tracked expense lives in a chat, not an expense report. Leaving iouReportID undefined tells + // navigateToCreatedExpense to open the transaction thread directly instead of an expense report RHP. + const iouReport = isMoneyRequestReport(report) || isInvoiceReport(report) ? report : undefined; + const iouReportID = iouReport?.reportID; + + useEffect(() => { + if (active) { + return; + } + const pendingTransactionIDs = Object.keys(signal ?? {}); + + // The last key is the last-created transaction: IDs are large (rand64), so they exceed the array-index + // range and JS preserves insertion order rather than sorting them numerically ascending. + const latestTransactionID = pendingTransactionIDs.at(-1); + const dataType = latestTransactionID ? signal?.[latestTransactionID] : undefined; + if (!latestTransactionID || !dataType || !transaction) { + return; + } + + // Clear every pending ID but surface only the latest: rapid creations that pile up before this runs + // collapse into a single growl for the newest expense. + mergeExpenseAddedGrowlTransactionIDs(Object.fromEntries(pendingTransactionIDs.map((id) => [id, null]))); + + // Suppress the growl when the user is already viewing the expense's money-request report + if (Navigation.getTopmostReportId() === transaction.reportID) { + return; + } + nonceRef.current += 1; + setActive({transactionID: latestTransactionID, dataType, nonce: nonceRef.current}); + }, [signal, active, transaction, setActive]); + + if (!active) { + return null; + } + + const isInvoice = active.dataType === CONST.SEARCH.DATA_TYPES.INVOICE; + + // Build the thread on press rather than when the growl shows, so it is only created if the user taps + // "View" and is built against the freshest Onyx data. + const navigateToExpense = () => { + const iouAction = getIOUActionForTransactionID(Object.values(reportActions ?? {}), active.transactionID); + let threadReportID = transaction?.transactionThreadReportID ?? iouAction?.childReportID; + if (threadReportID) { + setOptimisticTransactionThread(threadReportID, iouReport?.reportID, iouAction?.reportActionID, iouReport?.policyID); + } else { + const optimisticThread = createTransactionThreadReport({ + introSelected, + currentUserLogin: currentUserPersonalDetails?.login ?? '', + currentUserAccountID: currentUserPersonalDetails?.accountID ?? CONST.DEFAULT_NUMBER_ID, + betas, + iouReport, + iouReportAction: iouAction, + transaction, + personalDetails, + }); + threadReportID = optimisticThread?.reportID; + } + if (!threadReportID) { + Log.warn('[ExpenseAddedGrowl] Unable to resolve transaction thread reportID on View press.'); + return; + } + navigateToCreatedExpense({threadReportID, transactionID: active.transactionID, iouReportID}); + }; + + return ( + setActive((prev) => (prev?.nonce === dismissedNonce ? null : prev))} + /> + ); +} + +export default ExpenseAddedGrowl; diff --git a/src/components/Search/ChatSearchView.tsx b/src/components/Search/ChatSearchView.tsx index 21f390a3fa3e..361bb2aa734e 100644 --- a/src/components/Search/ChatSearchView.tsx +++ b/src/components/Search/ChatSearchView.tsx @@ -4,7 +4,7 @@ import CONST from '@src/CONST'; import type {NativeSyntheticEvent} from 'react-native'; -import React, {useImperativeHandle} from 'react'; +import React from 'react'; import type {SearchListItem} from './SearchList/ListItem/types'; import type {CommonSearchViewProps} from './searchViewProps'; @@ -49,7 +49,6 @@ function ChatSearchView({ onScroll, contentContainerStyle, containerStyle, - ref, }: ChatSearchViewProps) { const {type} = queryJSON; @@ -65,9 +64,6 @@ function ChatSearchView({ const selectedItemsLength = data.reduce((acc, item) => acc + (item.keyForList && selectedTransactions[item.keyForList]?.isSelected ? 1 : 0), 0); const totalItems = data.filter((item) => !isRowDeleted(item)).length; - // Flat data maps 1:1 to the rendered list, so highlight-scroll-to-index is the same as scroll-to-data-index. - useImperativeHandle(ref, () => ({scrollToIndex: scrollToListIndex}), [scrollToListIndex]); - const renderItem = (item: SearchListItem, index: number, isItemFocused: boolean, onFocus?: (event: NativeSyntheticEvent) => void) => ( // Chat rows never animate their exit (only grouped expenses do), so the wrapper just preserves the overflow clip. item.pendingAction === CONST.RED_ * `toggle`/`toggleAll`/`selectedTransactions`). The shared list state and interactions come from * `useSearchListViewState`, and the surrounding chrome (horizontal scroll, header bar, long-press menu) * from `SearchListViewLayout`; this view only owns the flat-expense specifics: the `TransactionListItem` - * renderer, single-pass visibility/selection counts, and the highlight-scroll imperative handle. - * `TransactionListItem` is the only row renderer here and rows always animate, so the - * group/sticky/chat/task branches of `SearchList` do not apply. Keyboard navigation is inherited from - * `BaseSearchList`; the post-create highlight stays in the router (the snapshot stamps - * `shouldAnimateInHighlight`, and `newTransactions` flows into `extraData`). + * renderer and single-pass visibility/selection counts. + * `TransactionListItem` is the only row renderer here, so the group/sticky/chat/task branches of + * `SearchList` do not apply. Keyboard navigation is inherited from `BaseSearchList`; `newTransactions` + * flows into `extraData` so the list re-renders when a freshly-created expense lands. */ function ExpenseFlatSearchView({ queryJSON, @@ -55,7 +54,6 @@ function ExpenseFlatSearchView({ onScroll, contentContainerStyle, containerStyle, - ref, }: ExpenseFlatSearchViewProps) { const {type} = queryJSON; @@ -89,9 +87,6 @@ function ExpenseFlatSearchView({ const selectedItemsLength = data.reduce((acc, item) => acc + (item.keyForList && selectedTransactions[item.keyForList]?.isSelected ? 1 : 0), 0); const totalItems = data.filter((item) => !isRowDeleted(item)).length; - // Flat data maps 1:1 to the rendered list, so highlight-scroll-to-index is the same as scroll-to-data-index. - useImperativeHandle(ref, () => ({scrollToIndex: scrollToListIndex}), [scrollToListIndex]); - const renderItem = (item: SearchListItem, index: number, isItemFocused: boolean, onFocus?: (event: NativeSyntheticEvent) => void) => { const isDisabled = isRowDeleted(item); // Only expense row exits animate; invoice and trip transaction lists do not (matches the legacy per-type gate). diff --git a/src/components/Search/ExpenseGroupedSearchView.tsx b/src/components/Search/ExpenseGroupedSearchView.tsx index 57db036fc5bd..9b59600cffea 100644 --- a/src/components/Search/ExpenseGroupedSearchView.tsx +++ b/src/components/Search/ExpenseGroupedSearchView.tsx @@ -15,7 +15,7 @@ import type {Transaction} from '@src/types/onyx'; import type {NativeSyntheticEvent} from 'react-native'; -import React, {useImperativeHandle, useState} from 'react'; +import React, {useState} from 'react'; import type {SearchListItem} from './SearchList/ListItem/types'; import type {CommonSearchViewProps, TransactionViewExtras} from './searchViewProps'; @@ -89,8 +89,7 @@ function buildNewTransactionIDMap(data: SearchListItem[], newTransactions: Trans * `useSearchListViewState`, and the surrounding chrome from `SearchListViewLayout`. This view owns the group * machinery: on wide web it splits each group into a sticky `GroupHeader` plus an expandable * `GroupChildrenContainer` (`shouldSplitGroups`); otherwise each group renders through `TransactionGroupListItem`. - * Selection counts are report-aware (flattened over child transactions plus empty groups), and the scroll - * handle remaps the router's data index to the split-list index. + * Selection counts are report-aware (flattened over child transactions plus empty groups). */ function ExpenseGroupedSearchView({ queryJSON, @@ -112,7 +111,6 @@ function ExpenseGroupedSearchView({ onScroll, contentContainerStyle, containerStyle, - ref, }: ExpenseGroupedSearchViewProps) { const {type, groupBy} = queryJSON; const {isLargeScreenWidth} = useResponsiveLayout(); @@ -172,26 +170,6 @@ function ExpenseGroupedSearchView({ const firstVisibleIndex = listData.findIndex(isItemVisible); const lastVisibleIndex = listData.findLastIndex(isItemVisible); - // The router highlights by source-data index; remap it to the split-list index before scrolling. - useImperativeHandle( - ref, - () => ({ - scrollToIndex: (index: number, animated = true) => { - if (!shouldSplit) { - scrollToListIndex(index, animated); - return; - } - const item = data.at(index); - if (!item) { - return; - } - const splitIndex = item.keyForList ? listData.findIndex((listItem) => listItem.keyForList === `header_${item.keyForList}`) : -1; - scrollToListIndex(splitIndex !== -1 ? splitIndex : index, animated); - }, - }), - [data, listData, shouldSplit, scrollToListIndex], - ); - const getItemType = (item: SearchListItem) => { if (!shouldSplit) { return undefined; diff --git a/src/components/Search/ExpenseReportSearchView.tsx b/src/components/Search/ExpenseReportSearchView.tsx index 1a8ba373e619..ed3b269d66b2 100644 --- a/src/components/Search/ExpenseReportSearchView.tsx +++ b/src/components/Search/ExpenseReportSearchView.tsx @@ -6,7 +6,7 @@ import CONST from '@src/CONST'; import type {NativeSyntheticEvent} from 'react-native'; -import React, {useImperativeHandle} from 'react'; +import React from 'react'; import type {SearchListItem} from './SearchList/ListItem/types'; import type {CommonSearchViewProps} from './searchViewProps'; @@ -55,7 +55,6 @@ function ExpenseReportSearchView({ onScroll, contentContainerStyle, containerStyle, - ref, }: ExpenseReportSearchViewProps) { const {type} = queryJSON; @@ -92,9 +91,6 @@ function ExpenseReportSearchView({ emptyReports.reduce((acc, item) => acc + (isRowSelected(item.keyForList, selectedTransactions) ? 1 : 0), 0); const totalItems = flattenedTransactions.filter((item) => !isRowDeleted(item)).length + emptyReports.filter((item) => !isRowDeleted(item)).length; - // Report data maps 1:1 to the rendered list, so highlight-scroll-to-index is the same as scroll-to-data-index. - useImperativeHandle(ref, () => ({scrollToIndex: scrollToListIndex}), [scrollToListIndex]); - const renderItem = (item: SearchListItem, index: number, isItemFocused: boolean, onFocus?: (event: NativeSyntheticEvent) => void) => ( // Report rows never animate their exit (only grouped expenses do), so the wrapper just preserves the overflow clip. ({ styles.selectionListPressableItemWrapper, isLargeScreenWidth && styles.pv3, isLargeScreenWidth && styles.ph3, - // Removing background style because they are added to the parent OpacityView via animatedHighlightStyle + // Background is applied on the parent wrapper, so keep this transparent styles.bgTransparent, isSelected && styles.activeComponentBG, styles.mh0, @@ -382,14 +381,6 @@ function ExpenseReportListItemInner({ [styles, isLargeScreenWidth], ); - const animatedHighlightStyle = useAnimatedHighlightStyle({ - borderRadius: 0, - shouldHighlight: item?.shouldAnimateInHighlight ?? false, - highlightColor: theme.messageHighlightBG, - backgroundColor: isSelected ? theme.activeComponentBG : theme.highlightBG, - shouldApplyOtherStyles: !isLargeScreenWidth, - }); - const shouldShowViolationDescription = isOpenExpenseReport(reportItem) || isProcessingReport(reportItem); // Show violation description if either: @@ -489,7 +480,8 @@ function ExpenseReportListItemInner({ hoverStyle={isSelected && styles.activeComponentBG} pressableWrapperStyle={[ styles.mh5, - animatedHighlightStyle, + StyleUtils.getSearchRowBackgroundStyle(isSelected), + !isLargeScreenWidth && styles.br0, isPendingDelete && styles.cursorDisabled, isLargeScreenWidth && isLastItem && [styles.tableBottomRadius, styles.overflowHidden], !isLargeScreenWidth && isFirstItem && styles.tableTopRadius, diff --git a/src/components/Search/SearchList/ListItem/GroupChildrenContainer.tsx b/src/components/Search/SearchList/ListItem/GroupChildrenContainer.tsx index a5e734772371..816019fd1c25 100644 --- a/src/components/Search/SearchList/ListItem/GroupChildrenContainer.tsx +++ b/src/components/Search/SearchList/ListItem/GroupChildrenContainer.tsx @@ -1,6 +1,5 @@ -import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle'; import useExpandCollapseAnimation from '@hooks/useExpandCollapseAnimation'; -import useTheme from '@hooks/useTheme'; +import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import React from 'react'; @@ -31,8 +30,8 @@ function GroupChildrenContainer({ isLastItem, newTransactionID, }: GroupChildrenContainerProps) { - const theme = useTheme(); const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); const {isRendered, animatedStyle, onLayout} = useExpandCollapseAnimation(isExpanded, false, item.keyForList); const isContentVisible = isExpanded || isRendered; const {isSelectAllChecked} = useGroupCheckboxState({groupKey: item.groupKeyForList, groupTransactions: item.transactions}); @@ -40,27 +39,13 @@ function GroupChildrenContainer({ // Only the rows this container holds decide its background, so a group still waiting for its first page is not painted as selected. const isSelected = !!item.isSelected || (item.transactions.length > 0 && isSelectAllChecked); - const animatedHighlightStyle = useAnimatedHighlightStyle({ - shouldHighlight: item?.shouldAnimateInHighlight ?? false, - highlightColor: theme.messageHighlightBG, - backgroundColor: isSelected ? theme.activeComponentBG : theme.highlightBG, - shouldApplyOtherStyles: false, - }); - // Rendering null in FlashList can cause heavy first-render work; use an empty placeholder instead (LHN pattern). if (!isExpanded && !isRendered) { return ; } return ( - + {isContentVisible ? ( ) : null} - + ); } diff --git a/src/components/Search/SearchList/ListItem/GroupHeader.tsx b/src/components/Search/SearchList/ListItem/GroupHeader.tsx index 5e19f7353758..ce99c5c95ba8 100644 --- a/src/components/Search/SearchList/ListItem/GroupHeader.tsx +++ b/src/components/Search/SearchList/ListItem/GroupHeader.tsx @@ -6,7 +6,6 @@ import SearchTableHeader from '@components/Search/SearchTableHeader'; import type {SearchColumnType, SearchCustomColumnIds, SearchGroupBy} from '@components/Search/types'; import type {ExtendedTargetedEvent} from '@components/SelectionList/ListItem/types'; -import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useExpandCollapseAnimation from '@hooks/useExpandCollapseAnimation'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; @@ -185,13 +184,6 @@ function GroupHeader({ keyForList: item.groupKeyForList, }); - const animatedHighlightStyle = useAnimatedHighlightStyle({ - shouldHighlight: item?.shouldAnimateInHighlight ?? false, - highlightColor: theme.messageHighlightBG, - backgroundColor: isItemSelected ? theme.activeComponentBG : theme.highlightBG, - shouldApplyOtherStyles: false, - }); - const handleSelectionButtonPress = () => { onCheckboxPress(withOriginalKey(item), isExpenseReportType ? undefined : groupItem.transactions); }; @@ -374,7 +366,7 @@ function GroupHeader({ ]} wrapperStyle={[ styles.mh5, - animatedHighlightStyle, + StyleUtils.getSearchRowBackgroundStyle(!!isItemSelected), styles.userSelectNone, isLargeScreenWidth ? [StyleUtils.getSearchTableGroupRowBorderStyle(isFirstItem, isLastItemCollapsed, isItemSelected), isLastItemCollapsed && styles.overflowHidden] diff --git a/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx b/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx index 0b68a51cd5de..bde3745ffe92 100644 --- a/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx @@ -7,7 +7,6 @@ import {useRowSelection} from '@components/Search/SearchSelectionProvider'; import type {SearchGroupBy} from '@components/Search/types'; import type {ListItem} from '@components/SelectionList/types'; -import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; @@ -187,13 +186,6 @@ function TransactionGroupListItemImpl({ const {isSelected: liveRowSelected} = useRowSelection(item?.keyForList); const isItemSelected = isSelectAllChecked || (liveRowSelected && (isExpenseReportType || transactionsWithoutPendingDelete.length === 0)); - const animatedHighlightStyle = useAnimatedHighlightStyle({ - shouldHighlight: item?.shouldAnimateInHighlight ?? false, - highlightColor: theme.messageHighlightBG, - backgroundColor: isItemSelected ? theme.activeComponentBG : theme.highlightBG, - shouldApplyOtherStyles: false, - }); - const pressableStyle = [ styles.transactionGroupListItemStyle, isLargeScreenWidth && { @@ -530,7 +522,7 @@ function TransactionGroupListItemImpl({ onFocus={onFocus} wrapperStyle={[ styles.mh5, - animatedHighlightStyle, + StyleUtils.getSearchRowBackgroundStyle(!!isItemSelected), styles.userSelectNone, isLargeScreenWidth ? [StyleUtils.getSearchTableGroupRowBorderStyle(isFirstItem, isLastItem, isItemSelected), isLastItem && styles.overflowHidden] diff --git a/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemNarrow.tsx b/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemNarrow.tsx index d1fbd1e9ed51..24b2cff27fb6 100644 --- a/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemNarrow.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemNarrow.tsx @@ -7,19 +7,16 @@ import {useRowSelection} from '@components/Search/SearchSelectionProvider'; import type {ListItem} from '@components/SelectionList/types'; import TransactionItemRow from '@components/TransactionItemRow'; -import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle'; import useStyleUtils from '@hooks/useStyleUtils'; import useSyncFocus from '@hooks/useSyncFocus'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import durationHighlightItem from '@libs/Navigation/helpers/getDurationHighlightItem'; - import CONST from '@src/CONST'; import type {View} from 'react-native'; -import React, {useEffect, useRef, useState} from 'react'; +import React, {useRef} from 'react'; import type {TransactionListItemNarrowProps} from './types'; @@ -70,43 +67,6 @@ function TransactionListItemNarrow({ const pressableStyle = [styles.transactionListItemStyle, styles.p4, styles.noBorderRadius, isSelected && styles.activeComponentBG, {...styles.flexColumn, ...styles.alignItemsStretch}]; - const animatedHighlightStyle = useAnimatedHighlightStyle({ - borderRadius: 0, - shouldHighlight: item?.shouldAnimateInHighlight ?? false, - highlightColor: theme.messageHighlightBG, - backgroundColor: isSelected ? theme.activeComponentBG : theme.highlightBG, - shouldApplyOtherStyles: true, - }); - - // The highlight animation is applied to the row wrapper, which sits behind this pressable. A focused - // row paints an opaque background on the pressable itself, which would cover the highlight - so after - // splitting an expense the newly-created row that receives focus never appears highlighted. Suppress - // the opaque focus background for the full highlight animation so the highlight shows. shouldAnimateInHighlight - // only stays true for the brief queue window, so latch it for durationHighlightItem. - const shouldAnimateInHighlight = !!item?.shouldAnimateInHighlight; - - // Initialize from the prop so a row that mounts already flagged (the split/search highlight case this - // fixes) latches immediately - otherwise the render-time guard below never fires on first mount. - const [isHighlighting, setIsHighlighting] = useState(shouldAnimateInHighlight); - const [wasAnimatingHighlight, setWasAnimatingHighlight] = useState(shouldAnimateInHighlight); - - // Start the latch during render (React's "storing information from previous renders" pattern) to avoid - // calling setState synchronously inside an effect. The effect below only clears it via an async timer. - if (shouldAnimateInHighlight !== wasAnimatingHighlight) { - setWasAnimatingHighlight(shouldAnimateInHighlight); - if (shouldAnimateInHighlight) { - setIsHighlighting(true); - } - } - useEffect(() => { - if (!isHighlighting) { - return; - } - const timer = setTimeout(() => setIsHighlighting(false), durationHighlightItem); - return () => clearTimeout(timer); - }, [isHighlighting]); - const shouldShowFocusBackground = !!isFocused && !isHighlighting; - return ( ({ sentryLabel={CONST.SENTRY_LABEL.SEARCH.TRANSACTION_LIST_ITEM} style={[ pressableStyle, - shouldShowFocusBackground && StyleUtils.getItemBackgroundColorStyle(isSelected, !!isFocused, !!item.isDisabled, theme.activeComponentBG, theme.hoverComponentBG), + isFocused && StyleUtils.getItemBackgroundColorStyle(isSelected, !!isFocused, !!item.isDisabled, theme.activeComponentBG, theme.hoverComponentBG), isDeletedTransaction && styles.cursorDefault, ]} onFocus={onFocus} wrapperStyle={[ styles.mh5, styles.flex1, - animatedHighlightStyle, + StyleUtils.getSearchRowBackgroundStyle(isSelected), + styles.br0, styles.userSelectNone, isFirstItem && styles.tableTopRadius, isLastItem && styles.tableBottomRadius, diff --git a/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemWide.tsx b/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemWide.tsx index 188488030802..b31a8054aadb 100644 --- a/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemWide.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemWide.tsx @@ -7,7 +7,6 @@ import type {ListItem} from '@components/SelectionList/types'; import TransactionItemRow from '@components/TransactionItemRow'; import {useEditingCellState} from '@components/TransactionItemRow/EditableCell'; -import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle'; import useStyleUtils from '@hooks/useStyleUtils'; import useSyncFocus from '@hooks/useSyncFocus'; import useTheme from '@hooks/useTheme'; @@ -144,14 +143,6 @@ function TransactionListItemWide({ }, ]; - const animatedHighlightStyle = useAnimatedHighlightStyle({ - borderRadius: 0, - shouldHighlight: item?.shouldAnimateInHighlight ?? false, - highlightColor: theme.messageHighlightBG, - backgroundColor: isSelected ? theme.activeComponentBG : theme.highlightBG, - shouldApplyOtherStyles: false, - }); - return ( ({ isDeletedTransaction && styles.cursorDefault, ]} onFocus={onFocus} - wrapperStyle={[styles.mh5, styles.flex1, animatedHighlightStyle, styles.userSelectNone, isLastItem && [styles.tableBottomRadius, styles.overflowHidden]]} + wrapperStyle={[ + styles.mh5, + styles.flex1, + StyleUtils.getSearchRowBackgroundStyle(isSelected), + styles.userSelectNone, + isLastItem && [styles.tableBottomRadius, styles.overflowHidden], + ]} > {({hovered}) => ( acc + (item.keyForList && selectedTransactions[item.keyForList]?.isSelected ? 1 : 0), 0); const totalItems = data.filter((item) => !isRowDeleted(item)).length; - // Flat data maps 1:1 to the rendered list, so highlight-scroll-to-index is the same as scroll-to-data-index. - useImperativeHandle(ref, () => ({scrollToIndex: scrollToListIndex}), [scrollToListIndex]); - const renderItem = (item: SearchListItem, index: number, isItemFocused: boolean, onFocus?: (event: NativeSyntheticEvent) => void) => ( // Task rows never animate their exit (only grouped expenses do), so the wrapper just preserves the overflow clip. ; /** The current search snapshot, owned by the ancestor and passed in. */ searchResults: SearchResults | undefined; - /** Keys flagged for the post-create highlight animation. */ - newSearchResultKeys: Set | null | undefined; /** Full TRANSACTION + REPORT_ACTIONS collections used by the optimistic-row tracking. Threaded in from - * the parent (which already subscribes to them for the highlight hook) so we don't open duplicate + * the parent (which already subscribes to them for the refetch hook) so we don't open duplicate * full-collection reads. */ transactions: OptimisticTrackingParams['transactions']; reportActions: OptimisticTrackingParams['reportActions']; @@ -89,11 +87,11 @@ const hashToString = (queryHash?: number) => (queryHash || queryHash === 0 ? Str * Single data layer for the Search screen. * * Owns the live inputs `getSections` needs for sort/group correctness, runs the deferral-gated - * sort/group/paginate projection, stamps the post-create highlight, enriches grouped views with their - * per-group sub-snapshots, and absorbs the optimistic-row resilience. Returns the sorted rows plus the - * list-level meta and the optimistic-tracking carriers that `` consumes. + * sort/group/paginate projection, enriches grouped views with their per-group sub-snapshots, and absorbs + * the optimistic-row resilience. Returns the sorted rows plus the list-level meta and the + * optimistic-tracking carriers that `` consumes. */ -function useSearchSnapshot({queryJSON, searchResults, newSearchResultKeys, transactions, reportActions}: UseSearchSnapshotParams): SearchSnapshotResult { +function useSearchSnapshot({queryJSON, searchResults, transactions, reportActions}: UseSearchSnapshotParams): SearchSnapshotResult { const {type, sortBy, sortOrder, hash, groupBy} = queryJSON; const {isOffline} = useNetwork(); @@ -309,8 +307,8 @@ function useSearchSnapshot({queryJSON, searchResults, newSearchResultKeys, trans exportReportActions, ]); - // Stage 3: sort the (enriched) data, then stamp the post-create highlight on each row. getSortedSections - // accepts the full section union; our SearchListItem[] is a compatible subset of that input. + // Stage 3: sort the (enriched) data. getSortedSections accepts the full section union; our + // SearchListItem[] is a compatible subset of that input. const chartData = useMemo(() => { if (!shouldComputeSections) { return EMPTY_DATA; @@ -321,47 +319,13 @@ function useSearchSnapshot({queryJSON, searchResults, newSearchResultKeys, trans policyTags, fallbackPolicyID: policyForMovingExpensesID, }).map((item) => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- chat variant rows are report actions - const reportActionID = (item as ReportActionListItemType).reportActionID; - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- non-chat variant rows carry a transactionID - const transactionID = (item as TransactionListItemType).transactionID; - const baseKey = isChat ? `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportActionID}` : `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`; - - const isBaseKeyMatch = !!newSearchResultKeys?.has(baseKey); - - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- group rows expose nested transactions - const groupTransactionsForHighlight = (item as TransactionGroupListItemType)?.transactions; - const isAnyTransactionMatch = - !isChat && - groupTransactionsForHighlight?.some((transaction) => { - const transactionKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`; - return !!newSearchResultKeys?.has(transactionKey); - }); - - const shouldAnimateInHighlight = isBaseKeyMatch || isAnyTransactionMatch; - - if (item.shouldAnimateInHighlight === shouldAnimateInHighlight && item.hash === hash) { + if (item.hash === hash) { return item; } - return {...item, shouldAnimateInHighlight, hash}; + return {...item, hash}; }); - }, [ - shouldComputeSections, - type, - filteredData, - localeCompare, - translate, - sortBy, - sortOrder, - validGroupBy, - policyCategories, - policyTags, - policyForMovingExpensesID, - isChat, - newSearchResultKeys, - hash, - ]); + }, [shouldComputeSections, type, filteredData, localeCompare, translate, sortBy, sortOrder, validGroupBy, policyCategories, policyTags, policyForMovingExpensesID, hash]); // Keep the optimistic row visible across a snapshot-replacement gap for up to // OPTIMISTIC_ROLLBACK_GRACE_MS until the new snapshot picks it up or the grace expires. diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index eb652bdbf479..3bf39240bd40 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -1,7 +1,6 @@ import FullPageErrorView from '@components/BlockingViews/FullPageErrorView'; import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; -import type {SelectionListHandle} from '@components/SelectionList/types'; import SearchRowSkeleton from '@components/Skeletons/SearchRowSkeleton'; import {useWideRHPActions} from '@components/WideRHPContextProvider'; @@ -16,7 +15,7 @@ import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSaveSortedReportIDs from '@hooks/useSaveSortedReportIDs'; -import useSearchHighlightAndScroll from '@hooks/useSearchHighlightAndScroll'; +import useSearchAutoRefetch from '@hooks/useSearchAutoRefetch'; import useSearchShouldCalculateTotals, {getSearchRequestOffsetForMissingAllMatchingCount} from '@hooks/useSearchShouldCalculateTotals'; import useStableArrayReference from '@hooks/useStableArrayReference'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -210,7 +209,6 @@ function Search({ const previousReportActions = usePrevious(reportActions); const {translate} = useLocalize(); const {getCurrencyDecimals} = useCurrencyListActions(); - const searchListRef = useRef | null>(null); const savedSearchSelector = useCallback((searches: OnyxEntry) => searches?.[hash], [hash]); const [savedSearch] = useOnyx(ONYXKEYS.SAVED_SEARCHES, { @@ -233,7 +231,7 @@ function Search({ clearSelectedTransactions(); }, [validGroupBy, prevValidGroupBy, clearSelectedTransactions]); - const {newSearchResultKeys, handleSelectionListScroll, newTransactions, hasQueuedHighlights} = useSearchHighlightAndScroll({ + const {newTransactions} = useSearchAutoRefetch({ searchResults, transactions, previousTransactions, @@ -243,7 +241,6 @@ function Search({ shouldCalculateTotals, reportActions, previousReportActions, - shouldUseLiveData, }); const { @@ -265,21 +262,10 @@ function Search({ } = useSearchSnapshot({ queryJSON, searchResults, - newSearchResultKeys, transactions, reportActions, }); - // Mirror `hasQueuedHighlights` into a ref so the post-create-flow `useFocusEffect` - // (which has empty deps) can read the latest value without re-creating its callback. - // Used to skip the deferral that would otherwise hide the freshly-added row from - // FlashList during the RHP dismiss transition, which would prevent the highlight - // animation from ever firing on it. - const hasQueuedHighlightsRef = useRef(hasQueuedHighlights); - useEffect(() => { - hasQueuedHighlightsRef.current = hasQueuedHighlights; - }, [hasQueuedHighlights]); - // There's a race condition in Onyx which makes it return data from the previous Search, so in addition to checking that the data is loaded // we also need to check that the searchResults matches the type and status of the current search const isDataLoaded = shouldUseLiveData || isSearchDataLoaded(searchResults, queryJSON); @@ -339,14 +325,6 @@ function Search({ return; } - // If the highlight hook already queued rows for the post-create animation, - // skip the skeleton-during-transition defer. Otherwise FlashList stays empty - // for ~1s while the RHP dismiss transition runs, the row never mounts inside - // the 300ms highlight window, and `useAnimatedHighlightStyle` never fires. - if (hasQueuedHighlightsRef.current) { - return; - } - // Show skeleton while the RHP dismiss animation plays. The transition // hasn't started yet when useFocusEffect fires (it begins after paint), // so waitForUpcomingTransition defers until the animation actually ends. @@ -867,9 +845,8 @@ function Search({ const onLayout = useCallback(() => { onLayoutBase(); - handleSelectionListScroll(stableSortedData, searchListRef.current); onContentReady?.(); - }, [onLayoutBase, handleSelectionListScroll, stableSortedData, onContentReady]); + }, [onLayoutBase, onContentReady]); // Must be a ref, not state: cancelNavigationSpans is called during render // (inside conditional returns), so using setState would trigger infinite re-renders. @@ -1211,7 +1188,6 @@ function Search({ const isTransactionListView = type !== CONST.SEARCH.DATA_TYPES.CHAT && type !== CONST.SEARCH.DATA_TYPES.TASK && type !== CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT; const commonViewProps: CommonSearchViewProps = { - ref: searchListRef, queryJSON, data: stableSortedData, columns: columnsToShow, diff --git a/src/components/Search/searchViewProps.ts b/src/components/Search/searchViewProps.ts index 40da64005c12..d11afac6b502 100644 --- a/src/components/Search/searchViewProps.ts +++ b/src/components/Search/searchViewProps.ts @@ -4,17 +4,11 @@ import type {ModifiedMouseEvent} from '@libs/Navigation/helpers/openInternalRout import type {CardList, Transaction} from '@src/types/onyx'; import type React from 'react'; -import type {ForwardedRef} from 'react'; import type {NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle} from 'react-native'; import type {SearchListItem} from './SearchList/ListItem/types'; import type {SearchColumnType, SearchQueryJSON} from './types'; -/** Imperative handle the router uses for highlight-driven scrolling. */ -type SearchListHandle = { - scrollToIndex: (index: number, animated?: boolean) => void; -}; - /** * Props shared by every dedicated Search view. The router (``) builds these once and spreads * them into whichever view renders; each view narrows to its own extras on top of this shape. @@ -47,7 +41,7 @@ type CommonSearchViewProps = { /** Whether everything has been loaded (gates the fully-checked select-all state). */ hasLoadedAllTransactions?: boolean; - /** Rows flagged for the post-create highlight animation (feeds BaseSearchList extraData). */ + /** Newly-added transactions (feeds BaseSearchList extraData and the grouped-view child-snapshot refetch). */ newTransactions: Transaction[]; /** The navigation handler for a row tap (owned by the router). */ @@ -70,9 +64,6 @@ type CommonSearchViewProps = { /** Outer container style for the list wrapper. */ containerStyle: StyleProp; - - /** Imperative handle for highlight-driven scrolling, set by the router. */ - ref?: ForwardedRef; }; /** Extra props specific to the transaction views (expense/invoice/trip): attendee tracking and card rendering. */ diff --git a/src/hooks/useSearchHighlightAndScroll.ts b/src/hooks/useSearchAutoRefetch.ts similarity index 54% rename from src/hooks/useSearchHighlightAndScroll.ts rename to src/hooks/useSearchAutoRefetch.ts index 77e057924e11..9efc4d727343 100644 --- a/src/hooks/useSearchHighlightAndScroll.ts +++ b/src/hooks/useSearchAutoRefetch.ts @@ -1,9 +1,7 @@ -import type {SearchListItem, TransactionGroupListItemType, TransactionListItemType} from '@components/Search/SearchList/ListItem/types'; +import type {TransactionListItemType} from '@components/Search/SearchList/ListItem/types'; import type {SearchQueryJSON} from '@components/Search/types'; -import type {SelectionListHandle} from '@components/SelectionList/types'; import {search} from '@libs/actions/Search'; -import {mergeTransactionIdsHighlightOnSearchRoute} from '@libs/actions/Transaction'; import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; import TransitionTracker from '@libs/Navigation/TransitionTracker'; import {isReportActionEntry} from '@libs/SearchUIUtils'; @@ -16,29 +14,44 @@ import type {ReportActions, SearchResults, Transaction} from '@src/types/onyx'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {useIsFocused} from '@react-navigation/native'; -import {useCallback, useEffect, useRef, useState} from 'react'; +import {useEffect, useRef} from 'react'; import useNetwork from './useNetwork'; -import useOnyx from './useOnyx'; -import usePrevious from './usePrevious'; -type UseSearchHighlightAndScroll = { +type UseSearchAutoRefetch = { + /** Current Search snapshot */ searchResults: OnyxEntry; + + /** Current transactions collection */ transactions: OnyxCollection; + + /** Previous transactions collection, compared against `transactions` to detect newly-added ones */ previousTransactions: OnyxCollection; + + /** Current report actions collection */ reportActions: OnyxCollection; + + /** Previous report actions collection, compared against `reportActions` to detect new entries */ previousReportActions: OnyxCollection; + + /** Parsed search query the refetch is issued for */ queryJSON: SearchQueryJSON; + + /** Key identifying the current search */ searchKey: SearchKey | undefined; + + /** Pagination offset for the refetch request */ offset: number; + + /** Whether the refetch should recalculate result totals */ shouldCalculateTotals: boolean; - shouldUseLiveData: boolean; }; /** - * Hook used to trigger a search when a new transaction or report action is added and handle highlighting and scrolling. + * Triggers a search when a transaction or report action is added so the snapshot reflects it. + * Returns the newly-added transactions, which the grouped views use to refetch each expanded group's child snapshot. */ -function useSearchHighlightAndScroll({ +function useSearchAutoRefetch({ searchResults, transactions, previousTransactions, @@ -48,25 +61,13 @@ function useSearchHighlightAndScroll({ searchKey, offset, shouldCalculateTotals, - shouldUseLiveData, -}: UseSearchHighlightAndScroll) { +}: UseSearchAutoRefetch) { const isFocused = useIsFocused(); const {isOffline} = useNetwork(); - // Ref to track if the search was triggered by this hook - const triggeredByHookRef = useRef(false); const searchTriggeredRef = useRef(false); - const hasNewItemsRef = useRef(false); - const previousSearchResults = usePrevious(searchResults?.data); - const [newSearchResultKeys, setNewSearchResultKeys] = useState | null>(null); - const highlightedIDs = useRef>(new Set()); - const initializedRef = useRef(false); const hasPendingSearchRef = useRef(false); const isChat = queryJSON.type === CONST.SEARCH.DATA_TYPES.CHAT; - const transactionIDsToHighlightSelector = useCallback((allTransactionIDs: OnyxEntry>>) => allTransactionIDs?.[queryJSON.type], [queryJSON.type]); - const [transactionIDsToHighlight] = useOnyx(ONYXKEYS.TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE, { - selector: transactionIDsToHighlightSelector, - }); const searchResultsData = searchResults?.data; const prevTransactionsIDs = Object.keys(previousTransactions ?? {}); @@ -153,13 +154,6 @@ function useSearchHighlightAndScroll({ return; } } - // We only want to highlight new items if the addition of transactions or report actions triggered the search. - // This is because, on deletion of items, the backend sometimes returns old items in place of the deleted ones. - // We don't want to highlight these old items, even if they appear new in the current search results. - hasNewItemsRef.current = isChat ? reportActionsIDs.length > previousReportActionsIDs.length : transactionsIDs.length > previousTransactionIDsLocal.length; - - // Set the flag indicating the search is triggered by the hook - triggeredByHookRef.current = true; // Trigger the search TransitionTracker.runAfterTransitions({ @@ -195,155 +189,23 @@ function useSearchHighlightAndScroll({ } searchTriggeredRef.current = false; - }, [searchResults?.search?.isLoading, shouldUseLiveData, searchResultsData]); - - // Initialize the set with existing IDs only once - useEffect(() => { - if (initializedRef.current || !searchResultsData) { - return; - } - - const initialIDs = isChat ? extractReportActionIDsFromSearchResults(searchResultsData) : extractTransactionIDsFromSearchResults(searchResultsData); - highlightedIDs.current = new Set(initialIDs); - initializedRef.current = true; - }, [searchResultsData, isChat]); - - // Detect new items (transactions or report actions) - useEffect(() => { - if (!previousSearchResults || !searchResults?.data) { - return; - } - if (isChat) { - const previousReportActionIDs = extractReportActionIDsFromSearchResults(previousSearchResults); - const currentReportActionIDs = extractReportActionIDsFromSearchResults(searchResults.data); - - // Find new report action IDs that are not in the previousReportActionIDs and not already highlighted - const newReportActionIDs = currentReportActionIDs.filter((id) => !previousReportActionIDs.includes(id) && !highlightedIDs.current.has(id)); - - if (!triggeredByHookRef.current || newReportActionIDs.length === 0 || !hasNewItemsRef.current) { - return; - } + }, [searchResults?.search?.isLoading, searchResultsData]); - const newKeys = new Set(); - for (const id of newReportActionIDs) { - const newReportActionKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${id}`; - highlightedIDs.current.add(newReportActionKey); - newKeys.add(newReportActionKey); - } - setNewSearchResultKeys(newKeys); - } else { - const previousTransactionIDs = extractTransactionIDsFromSearchResults(previousSearchResults); - const currentTransactionIDs = extractTransactionIDsFromSearchResults(searchResults.data); - const manualHighlightTransactionIDs = new Set(Object.keys(transactionIDsToHighlight ?? {}).filter((id) => !!transactionIDsToHighlight?.[id])); - - // Find new transaction IDs that are not in the previousTransactionIDs and not already highlighted - const newTransactionIDs = currentTransactionIDs.filter((id) => { - if (manualHighlightTransactionIDs.has(id)) { - return true; - } - if (!triggeredByHookRef.current || !hasNewItemsRef.current) { - return false; - } - return !previousTransactionIDs.includes(id) && !highlightedIDs.current.has(id); - }); - - if (newTransactionIDs.length === 0) { - return; - } - - const newKeys = new Set(); - const consumedManualIDs: string[] = []; - for (const id of newTransactionIDs) { - const newTransactionKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${id}`; - highlightedIDs.current.add(newTransactionKey); - newKeys.add(newTransactionKey); - if (manualHighlightTransactionIDs.has(id)) { - consumedManualIDs.push(id); - } - } - setNewSearchResultKeys(newKeys); - - // Clear consumed manual highlight flags so subsequent detect runs don't re-highlight the same IDs. - if (consumedManualIDs.length > 0) { - mergeTransactionIdsHighlightOnSearchRoute(queryJSON.type, Object.fromEntries(consumedManualIDs.map((id) => [id, false]))); - } - } - }, [searchResults?.data, previousSearchResults, isChat, transactionIDsToHighlight, queryJSON.type]); - - // Remove transactionIDsToHighlight when the user leaves the current search type - useEffect( - () => () => { - mergeTransactionIdsHighlightOnSearchRoute(queryJSON.type, null); - }, - [queryJSON.type], - ); - - // Reset newSearchResultKey after it's been used - useEffect(() => { - if (newSearchResultKeys === null) { - return; - } - - const timer = setTimeout(() => { - setNewSearchResultKeys(null); - }, CONST.ANIMATED_HIGHLIGHT_START_DURATION); - - return () => clearTimeout(timer); - }, [newSearchResultKeys]); - - /** - * Callback to handle scrolling to the new search result. - */ - const handleSelectionListScroll = (data: SearchListItem[], ref: SelectionListHandle | null) => { - // Early return if there's no ref, new transaction wasn't brought in by this hook - // or there's no new search result key - const newSearchResultKey = newSearchResultKeys?.values().next().value; - if (!ref || !triggeredByHookRef.current || !newSearchResultKey) { - return; - } - - // Extract the transaction/report action ID from the newSearchResultKey - const newID = newSearchResultKey.replace(isChat ? ONYXKEYS.COLLECTION.REPORT_ACTIONS : ONYXKEYS.COLLECTION.TRANSACTION, ''); - - // Find the index of the new transaction/report action in the data array - const indexOfNewItem = data.findIndex((item) => { - if (isChat) { - if ('reportActionID' in item && item.reportActionID === newID) { - return true; - } - } else { - // Handle TransactionListItemType - if ('transactionID' in item && item.transactionID === newID) { - return true; - } - - // Handle TransactionGroupListItemType with transactions array - if ('transactions' in item && Array.isArray(item.transactions)) { - return item.transactions.some((transaction) => transaction?.transactionID === newID); - } - } - - return false; - }); - - // Early return if the new item is not found in the data array - if (indexOfNewItem < 0) { - return; - } - - // Reset the trigger even when the item is already first so a later render cannot scroll or highlight it again. - triggeredByHookRef.current = false; - if (indexOfNewItem === 0) { - return; - } - - // Perform the scrolling action - ref.scrollToIndex(indexOfNewItem); - }; + return {newTransactions}; +} - const hasQueuedHighlights = newSearchResultKeys !== null && newSearchResultKeys.size > 0; +function getTransactionIDFromEntry(entry: unknown) { + if (typeof entry !== 'object' || entry === null || !('transactionID' in entry) || typeof entry.transactionID !== 'string') { + return undefined; + } + return entry.transactionID; +} - return {newSearchResultKeys, handleSelectionListScroll, newTransactions, hasQueuedHighlights}; +function hasNestedTransactions(entry: unknown): entry is {transactions: Array>} { + if (typeof entry !== 'object' || entry === null || !('transactions' in entry)) { + return false; + } + return Array.isArray(entry.transactions); } /** @@ -354,18 +216,21 @@ function extractTransactionIDsFromSearchResults(searchResultsData: Partial searchResultIDs.has(transactionID)); } -export default useSearchHighlightAndScroll; -export type {UseSearchHighlightAndScroll}; +export default useSearchAutoRefetch; +export type {UseSearchAutoRefetch}; diff --git a/src/languages/de.ts b/src/languages/de.ts index 089c0b00ea5d..590f95c285e8 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1302,6 +1302,8 @@ const translations: TranslationDeepObject = { : 'Bevor Sie Entfernungen erfassen, müssen Sie Ihre Privatadresse in Ihrem privaten Profil hinzufügen. Dieser Arbeitsbereich verwendet diese Adresse für Pendlerabzüge.', cta: 'Heimatadresse hinzufügen', }, + expenseAdded: 'Ausgabe hinzugefügt', + invoiceSent: 'Rechnung gesendet', amount: 'Betrag', percent: 'Prozent', date: 'Datum', diff --git a/src/languages/el.ts b/src/languages/el.ts index 03ac26ae8e13..d97d6f628976 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -1351,6 +1351,8 @@ const translations: TranslationDeepObject = { : 'Πριν καταγράψετε αποστάσεις, πρέπει να προσθέσετε τη διεύθυνση κατοικίας σας στο ιδιωτικό προφίλ σας. Αυτός ο χώρος εργασίας χρησιμοποιεί αυτή τη διεύθυνση για εκπτώσεις μετακίνησης.', cta: 'Προσθήκη οικιακής διεύθυνσης', }, + expenseAdded: 'Δαπάνη προστέθηκε', + invoiceSent: 'Τιμολόγιο στάλθηκε', amount: 'Ποσό', percent: 'Ποσοστό', date: 'Ημερομηνία', diff --git a/src/languages/en.ts b/src/languages/en.ts index 62367aea9fc4..fc9c276094db 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1386,6 +1386,8 @@ const translations = { : 'Before you track distance, you need to add your home address to your private profile. This workspace uses this address for commuter deductions.', cta: 'Add Home Address', }, + expenseAdded: 'Expense added', + invoiceSent: 'Invoice sent', amount: 'Amount', percent: 'Percent', date: 'Date', diff --git a/src/languages/es.ts b/src/languages/es.ts index 02c5e7274a8e..572b63f33641 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1299,6 +1299,8 @@ const translations: TranslationDeepObject = { : 'Antes de registrar distancias, tienes que añadir tu dirección de casa a tu perfil privado. Este espacio de trabajo utiliza esta dirección para las deducciones por desplazamientos al trabajo.', cta: 'Añadir dirección de domicilio', }, + expenseAdded: 'Gasto añadido', + invoiceSent: 'Factura enviada', amount: 'Importe', percent: 'Porcentaje', date: 'Fecha', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index eac4d3a0cbbb..807bc64c43bb 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1306,6 +1306,8 @@ const translations: TranslationDeepObject = { : 'Avant de suivre une distance, vous devez ajouter votre adresse personnelle à votre profil privé. Cet espace de travail utilise cette adresse pour les déductions liées aux trajets domicile-travail.', cta: 'Ajouter une adresse domicile', }, + expenseAdded: 'Dépense ajoutée', + invoiceSent: 'Facture envoyée', amount: 'Montant', percent: 'Pourcentage', date: 'Date', diff --git a/src/languages/it.ts b/src/languages/it.ts index 70a3ce7774b6..3e77f8cac53a 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1301,6 +1301,8 @@ const translations: TranslationDeepObject = { : 'Prima di poter registrare la distanza, devi aggiungere il tuo indirizzo di casa al tuo profilo privato. Questo spazio di lavoro utilizza questo indirizzo per le detrazioni per i pendolari.', cta: 'Aggiungi indirizzo di casa', }, + expenseAdded: 'Spesa aggiunta', + invoiceSent: 'Fattura inviata', amount: 'Importo', percent: 'Percentuale', date: 'Data', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index aac4bd66c132..d72539b91a7d 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1283,6 +1283,8 @@ const translations: TranslationDeepObject = { : '距離を記録する前に、プライベートプロフィールに自宅住所を追加する必要があります。このワークスペースでは、その住所を通勤控除に使用します。', cta: '自宅住所を追加', }, + expenseAdded: '経費を追加しました', + invoiceSent: '請求書を送信しました', amount: '金額', percent: 'パーセント', date: '日付', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 323f0f481fbb..b6ac5e6e0bae 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1300,6 +1300,8 @@ const translations: TranslationDeepObject = { : 'Voordat je afstand kunt bijhouden, moet je je huisadres toevoegen aan je privéprofiel. Deze werkruimte gebruikt dit adres voor woon-werkverkeeraftrek.', cta: 'Thuisadres toevoegen', }, + expenseAdded: 'Uitgave toegevoegd', + invoiceSent: 'Factuur verzonden', amount: 'Bedrag', percent: 'Procent', date: 'Datum', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 4bfe75168e53..844d3bf98a81 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1329,6 +1329,8 @@ const translations: TranslationDeepObject = { : 'Zanim zaczniesz śledzić dystans, musisz dodać swój adres domowy do prywatnego profilu. To miejsce pracy używa tego adresu do odliczeń dojazdów.', cta: 'Dodaj adres domowy', }, + expenseAdded: 'Dodano wydatek', + invoiceSent: 'Wysłano fakturę', amount: 'Kwota', percent: 'Procent', date: 'Data', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 7a48df8de53f..8d513a507b42 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1300,6 +1300,8 @@ const translations: TranslationDeepObject = { : 'Antes de registrar a distância, você precisa adicionar seu endereço residencial ao seu perfil privado. Este workspace usa esse endereço para deduções de deslocamento.', cta: 'Adicionar endereço residencial', }, + expenseAdded: 'Despesa adicionada', + invoiceSent: 'Fatura enviada', amount: 'Valor', percent: 'Porcentagem', date: 'Data', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index ee1353533cdd..105e1ea25a56 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1246,6 +1246,8 @@ const translations: TranslationDeepObject = { : '在开始记录里程之前,您需要在个人资料中添加您的家庭住址。此工作区会使用该地址计算通勤扣除。', cta: '添加家庭住址', }, + expenseAdded: '已添加支出', + invoiceSent: '已发送发票', amount: '金额', percent: '百分比', date: '日期', diff --git a/src/libs/ExportOnyxState/common.ts b/src/libs/ExportOnyxState/common.ts index 01031b7da696..dbc05f1e88ce 100644 --- a/src/libs/ExportOnyxState/common.ts +++ b/src/libs/ExportOnyxState/common.ts @@ -212,6 +212,7 @@ const safeOnyxKeys = new Set([ ONYXKEYS.CURRENCY_LIST, ONYXKEYS.CURRENT_DATE, ONYXKEYS.DOMAIN_GROUP_CREATE_PREFERRED_POLICY_ID, + ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS, ONYXKEYS.EXPENSIFY_CARD_STATEMENT, ONYXKEYS.FULLSCREEN_VISIBILITY, ONYXKEYS.GOVERNMENT_MILEAGE_RATES, @@ -341,7 +342,6 @@ const safeOnyxKeys = new Set([ ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_FAILED, ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_PENDING, ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_SUCCESSFUL, - ONYXKEYS.TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE, ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS, ONYXKEYS.TRAVEL_BILLING_STATEMENT, ONYXKEYS.VALIDATE_DOMAIN_TWO_FACTOR_CODE, diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts index 5a9673ca36b1..7af6321f3942 100644 --- a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts @@ -1,8 +1,10 @@ import {addPendingNewTransactionIDs} from '@libs/actions/IOU/PendingNewTransactions'; +import {setActiveTransactionIDs} from '@libs/actions/TransactionThreadNavigation'; import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import Log from '@libs/Log'; import {getPreservedNavigatorState} from '@libs/Navigation/AppNavigator/createSplitNavigator/usePreserveNavigatorState'; import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; +import {getReportTransactions} from '@libs/ReportUtils'; import {buildCannedSearchQuery, getCurrentSearchQueryJSON} from '@libs/SearchQueryUtils'; import {setPendingSubmitFollowUpAction} from '@libs/telemetry/submitFollowUpAction'; @@ -12,16 +14,31 @@ import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import dismissModalAndOpenReportInInboxTab from './dismissModalAndOpenReportInInboxTab'; +import isReportOpenInRHP from './isReportOpenInRHP'; import isReportTopmostSplitNavigator from './isReportTopmostSplitNavigator'; import isSearchTopmostFullScreenRoute from './isSearchTopmostFullScreenRoute'; +import setNavigationActionToMicrotaskQueue from './setNavigationActionToMicrotaskQueue'; type NavigateAfterExpenseCreateParams = { + /** Report the expense was created in */ activeReportID?: string; + + /** The created transaction's ID */ transactionID?: string; + + /** Whether the expense was started from the global create flow (FAB/no existing report) rather than from within a report */ isFromGlobalCreate?: boolean; + + /** Whether the created item is an invoice rather than a regular expense */ isInvoice?: boolean; + + /** Whether the destination report already contains transactions */ hasMultipleTransactions: boolean; + + /** Whether to record the transaction ID in the report's pendingNewTransactionIDs metadata, used to highlight the newly-added row */ shouldAddPendingNewTransactionIDs?: boolean; + + /** Whether to perform navigation, or only run the side effects */ shouldNavigate?: boolean; /** @@ -48,12 +65,78 @@ function getNavigateAfterCreateSearchNavigatorState() { return searchNavigatorRoute?.state ?? (searchNavigatorRoute?.key ? getPreservedNavigatorState(searchNavigatorRoute.key) : undefined); } +function getCurrentRouteBackTo() { + const params = navigationRef.current?.getCurrentRoute()?.params; + if (typeof params !== 'object' || params === null || !('backTo' in params) || typeof params.backTo !== 'string') { + return undefined; + } + return params.backTo; +} + +type NavigateToCreatedExpenseParams = { + /** The transaction thread report to open. */ + threadReportID: string; + + /** The created transaction's ID. */ + transactionID: string; + + /** IOU report the transaction landed in, used to decide whether to stack the expense report underneath. */ + iouReportID?: string; +}; + +/** + * Opens a just-created expense when "View" is pressed on the "Expense added" growl. The user may have + * switched tabs while the growl was up, so the destination follows wherever they are now. + */ +function navigateToCreatedExpense({threadReportID, transactionID, iouReportID}: NavigateToCreatedExpenseParams) { + const openOnInbox = isReportTopmostSplitNavigator() && !isSearchTopmostFullScreenRoute(); + + // When a report/expense is already open in the RHP the app's convention is to replace it rather than stack a second + // report RHP on top of it. + const forceReplace = isReportOpenInRHP(navigationRef.getRootState()); + const backTo = forceReplace ? getCurrentRouteBackTo() : Navigation.getActiveRoute(); + + if (!openOnInbox) { + setActiveTransactionIDs([transactionID]).then(() => { + Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: threadReportID, backTo}), {forceReplace}); + }); + return; + } + + if (getIsNarrowLayout()) { + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(threadReportID, undefined, undefined, backTo), {forceReplace}); + return; + } + if (iouReportID) { + Navigation.navigate(ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: iouReportID, backTo}), {forceReplace}); + + // A multi-transaction report opens super wide RHP, so stack the thread RHP on top of it. A single-transaction + // report collapses to the thread itself, so the navigation above already landed on it. + const hasMultipleReportTransactions = + getReportTransactions(iouReportID).filter((transaction) => transaction?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE).length > 1; + if (hasMultipleReportTransactions) { + // Defer so the thread RHP stacks on top of the expense report navigation above. This is always a + // push (never a replace) - it stacks on the report we just opened, not on the previously-open one. + setNavigationActionToMicrotaskQueue(() => { + setActiveTransactionIDs([transactionID]).then(() => { + Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: threadReportID, backTo: Navigation.getActiveRoute()})); + }); + }); + } + return; + } + + // A tracked expense has no expense report, so open the thread as a full report - the same way tapping + // the expense in its self-DM chat does. + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(threadReportID, undefined, undefined, backTo), {forceReplace}); +} + /** * Helper to navigate after an expense is created in order to standardize the post‑creation experience * when creating an expense from the global create button. * If the expense is created from the global create button then: * - If it is created on the inbox tab, it will open the chat report containing that expense. - * - If it is created elsewhere, it will navigate to Reports > Expense and highlight the newly created expense. + * - If it is created elsewhere, it will navigate to Reports > Expense and show the "Expense added" growl. */ function navigateAfterExpenseCreate({ activeReportID, @@ -130,3 +213,4 @@ function navigateAfterExpenseCreate({ } export default navigateAfterExpenseCreate; +export {navigateToCreatedExpense}; diff --git a/src/libs/actions/IOU/Duplicate.ts b/src/libs/actions/IOU/Duplicate.ts index eb886b0f9977..80fc72c478b6 100644 --- a/src/libs/actions/IOU/Duplicate.ts +++ b/src/libs/actions/IOU/Duplicate.ts @@ -67,6 +67,7 @@ import {getAllReports, getAllTransactions, getCurrentUserAccountIDFromSession} f import {getCleanUpTransactionThreadReportOnyxData} from './DeleteMoneyRequest'; import {getMoneyRequestParticipantsFromReport} from './MoneyRequest'; import {submitPerDiemExpense} from './PerDiem'; +import signalExpenseAddedGrowl from './signalExpenseAddedGrowl'; import {createDistanceRequest} from './Split'; import {requestMoney, trackExpense} from './TrackExpense'; @@ -1244,6 +1245,7 @@ function bulkDuplicateExpenses({ // A copy of an unreported expense is tracked, so only an expense that lands on the report can submit it. const lastReportBoundIndex = targetPolicy ? transactionsToDuplicate.findLastIndex((t) => !!t.reportID && t.reportID !== CONST.REPORT.UNREPORTED_REPORT_ID) : -1; + let lastDuplicateTransactionID: string | undefined; for (let i = 0; i < transactionsToDuplicate.length; i++) { const item = transactionsToDuplicate.at(i); @@ -1314,6 +1316,9 @@ function bulkDuplicateExpenses({ if (result?.iouReport) { optimisticIOUReport = result.iouReport; } + if (result?.transactionID) { + lastDuplicateTransactionID = result.transactionID; + } if (currentTargetReport && !currentTargetReport.iouReportID) { currentTargetReport = {...currentTargetReport, iouReportID: currentOptimisticIOUReportID}; @@ -1321,6 +1326,7 @@ function bulkDuplicateExpenses({ } playSound(SOUNDS.DONE); + signalExpenseAddedGrowl(lastDuplicateTransactionID, CONST.SEARCH.DATA_TYPES.EXPENSE); } type BulkDuplicateReportsParams = { diff --git a/src/libs/actions/IOU/NavigationHelpers.ts b/src/libs/actions/IOU/NavigationHelpers.ts deleted file mode 100644 index 913a80c8a8eb..000000000000 --- a/src/libs/actions/IOU/NavigationHelpers.ts +++ /dev/null @@ -1,47 +0,0 @@ -import isReportTopmostSplitNavigator from '@libs/Navigation/helpers/isReportTopmostSplitNavigator'; -import navigateAfterExpenseCreate from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; - -import {mergeTransactionIdsHighlightOnSearchRoute} from '@userActions/Transaction'; - -import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; - -import {getAllTransactions} from './index'; - -/** - * Marks a transaction for highlight on the Search page when the expense was created - * from the global create button and the user is not on the Inbox tab. - */ -function highlightTransactionOnSearchRouteIfNeeded(isFromGlobalCreate: boolean | undefined, transactionID: string | undefined, dataType: SearchDataTypes) { - if (!isFromGlobalCreate || isReportTopmostSplitNavigator() || !transactionID) { - return; - } - mergeTransactionIdsHighlightOnSearchRoute(dataType, {[transactionID]: true}); -} - -/** - * Helper to navigate after an expense is created in order to standardize the post‑creation experience - * when creating an expense from the global create button. - * If the expense is created from the global create button then: - * - If it is created on the inbox tab, it will open the chat report containing that expense. - * - If it is created elsewhere, it will navigate to Reports > Expense and highlight the newly created expense. - */ -function handleNavigateAfterExpenseCreate({ - activeReportID, - transactionID, - isFromGlobalCreate, - isInvoice, - shouldAddPendingNewTransactionIDs = false, - shouldNavigate = true, -}: { - activeReportID?: string; - transactionID?: string; - isFromGlobalCreate?: boolean; - isInvoice?: boolean; - shouldAddPendingNewTransactionIDs?: boolean; - shouldNavigate?: boolean; -}) { - const hasMultipleTransactions = Object.values(getAllTransactions()).filter((transaction) => transaction?.reportID === activeReportID).length > 0; - navigateAfterExpenseCreate({activeReportID, transactionID, isFromGlobalCreate, isInvoice, hasMultipleTransactions, shouldAddPendingNewTransactionIDs, shouldNavigate}); -} - -export {handleNavigateAfterExpenseCreate, highlightTransactionOnSearchRouteIfNeeded}; diff --git a/src/libs/actions/IOU/PerDiem.ts b/src/libs/actions/IOU/PerDiem.ts index 1eafb43f3d1d..967752704411 100644 --- a/src/libs/actions/IOU/PerDiem.ts +++ b/src/libs/actions/IOU/PerDiem.ts @@ -68,7 +68,7 @@ import { mergePolicyRecentlyUsedCategories, mergePolicyRecentlyUsedCurrencies, } from './MoneyRequestBuilder'; -import {highlightTransactionOnSearchRouteIfNeeded} from './NavigationHelpers'; +import signalExpenseAddedGrowl from './signalExpenseAddedGrowl'; function removeSubrate(transaction: OnyxEntry, currentIndex: string) { // Index comes from the route params and is a string @@ -1126,7 +1126,11 @@ function submitPerDiemExpense(submitPerDiemExpenseInformation: PerDiemExpenseInf onDeferred: () => addOptimization(CONST.TELEMETRY.SUBMIT_OPTIMIZATION.DEFERRED_WRITE), }); - highlightTransactionOnSearchRouteIfNeeded(isFromGlobalCreate, transaction.transactionID, CONST.SEARCH.DATA_TYPES.EXPENSE); + TransitionTracker.runAfterTransitions({callback: () => removeDraftTransaction(CONST.IOU.OPTIMISTIC_TRANSACTION_ID), waitForUpcomingTransition: true}); + + if (isFromGlobalCreate) { + signalExpenseAddedGrowl(transaction.transactionID, CONST.SEARCH.DATA_TYPES.EXPENSE); + } notifyNewAction(notifyReportID ?? chatReport.reportID, undefined, participantParams.payeeAccountID === currentUserAccountIDParam); diff --git a/src/libs/actions/IOU/SendInvoice.ts b/src/libs/actions/IOU/SendInvoice.ts index 68e8cd52b51b..1034b9e87083 100644 --- a/src/libs/actions/IOU/SendInvoice.ts +++ b/src/libs/actions/IOU/SendInvoice.ts @@ -44,8 +44,8 @@ import type BasePolicyParams from './types/BasePolicyParams'; import {getAllPersonalDetails} from '.'; import {getReceiptError, mergePolicyRecentlyUsedCategories, mergePolicyRecentlyUsedCurrencies} from './MoneyRequestBuilder'; -import {highlightTransactionOnSearchRouteIfNeeded} from './NavigationHelpers'; import {getSearchOnyxUpdate} from './SearchUpdate'; +import signalExpenseAddedGrowl from './signalExpenseAddedGrowl'; type SendInvoiceInformation = { senderWorkspaceID: string | undefined; @@ -892,7 +892,9 @@ function sendInvoice({ onDeferred: () => addOptimization(CONST.TELEMETRY.SUBMIT_OPTIMIZATION.DEFERRED_WRITE), }); - highlightTransactionOnSearchRouteIfNeeded(isFromGlobalCreate, transactionID, CONST.SEARCH.DATA_TYPES.INVOICE); + if (isFromGlobalCreate) { + signalExpenseAddedGrowl(transactionID, CONST.SEARCH.DATA_TYPES.INVOICE); + } notifyNewAction(invoiceRoom.reportID, undefined, true); } diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts index 10eb3e5da269..be1eae5a84f5 100644 --- a/src/libs/actions/IOU/Split.ts +++ b/src/libs/actions/IOU/Split.ts @@ -88,8 +88,8 @@ import { mergePolicyRecentlyUsedCategories, mergePolicyRecentlyUsedCurrencies, } from './MoneyRequestBuilder'; -import {highlightTransactionOnSearchRouteIfNeeded} from './NavigationHelpers'; import {addPendingNewTransactionIDs, isOneToTwoTransactionTransition} from './PendingNewTransactions'; +import signalExpenseAddedGrowl from './signalExpenseAddedGrowl'; type IOURequestType = ValueOf; @@ -2361,7 +2361,9 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest onDeferred: () => addOptimization(CONST.TELEMETRY.SUBMIT_OPTIMIZATION.DEFERRED_WRITE), }); - highlightTransactionOnSearchRouteIfNeeded(isFromGlobalCreate, parameters.transactionID, CONST.SEARCH.DATA_TYPES.EXPENSE); + if (isFromGlobalCreate) { + signalExpenseAddedGrowl(parameters.transactionID, CONST.SEARCH.DATA_TYPES.EXPENSE); + } if (!isMoneyRequestReport) { notifyNewAction(activeReportID, undefined, true); diff --git a/src/libs/actions/IOU/SplitTransactionUpdate.ts b/src/libs/actions/IOU/SplitTransactionUpdate.ts index 3d024da4c316..134a2a380759 100644 --- a/src/libs/actions/IOU/SplitTransactionUpdate.ts +++ b/src/libs/actions/IOU/SplitTransactionUpdate.ts @@ -45,7 +45,6 @@ import { navigateBackOnDeleteTransaction, updateOptimisticParentReportAction, } from '@libs/ReportUtils'; -import {getCurrentSearchQueryJSON} from '@libs/SearchQueryUtils'; import {isTracking, setPendingSubmitFollowUpAction} from '@libs/telemetry/submitFollowUpAction'; import { getChildTransactions, @@ -56,7 +55,6 @@ import { } from '@libs/TransactionUtils'; import {setDeleteTransactionNavigateBackUrl} from '@userActions/Report'; -import {mergeTransactionIdsHighlightOnSearchRoute} from '@userActions/Transaction'; import {removeDraftSplitTransaction} from '@userActions/TransactionEdit'; import CONST from '@src/CONST'; @@ -83,6 +81,7 @@ import {getCleanUpTransactionThreadReportOnyxData} from './DeleteMoneyRequest'; import {getAllReports} from './index'; import {getMoneyRequestParticipantsFromReport} from './MoneyRequest'; import {getMoneyRequestInformation, getReportPreviewReportAction} from './MoneyRequestBuilder'; +import signalExpenseAddedGrowl from './signalExpenseAddedGrowl'; import {getDeleteTrackExpenseInformation} from './TrackExpense'; import {getUpdateMoneyRequestParams} from './UpdateMoneyRequest'; @@ -2106,33 +2105,13 @@ function updateSplitTransactionsFromSplitExpensesFlow(params: UpdateSplitTransac const targetReportID = params.expenseReport?.reportID ?? String(CONST.DEFAULT_NUMBER_ID); - // Register newly created split transaction IDs so they briefly highlight on the Search/Spend page. - // The Search page reads TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE, which highlights matching rows - // optimistically without waiting for a server re-search. Unlike the auto-detect path in - // useSearchHighlightAndScroll (skipped while offline), this makes the highlight work offline too. - // Reverse splits create no new transactions, and existing children are already in the list, so both are skipped. - function registerSearchRouteHighlight() { - if (!isSearchPageTopmostFullScreenRoute || isReverseSplitOperation) { - return; - } - const currentSearchType = getCurrentSearchQueryJSON()?.type; - if (!currentSearchType) { - return; - } - const newTransactionIDsToHighlight: Record = {}; - for (const transactionID of getNewSplitTransactionIDs()) { - newTransactionIDsToHighlight[transactionID] = true; - } - if (isEmptyObject(newTransactionIDsToHighlight)) { - return; - } - mergeTransactionIdsHighlightOnSearchRoute(currentSearchType, newTransactionIDsToHighlight); + if (!isReverseSplitOperation) { + signalExpenseAddedGrowl(getNewSplitTransactionIDs().at(-1), CONST.SEARCH.DATA_TYPES.EXPENSE); } if (isSearchPageTopmostFullScreenRoute || !params.transactionReport?.parentReportID) { - registerSearchRouteHighlight(); // Returns to Search, not the expense report, so rail flags would sit unconsumed and highlight stale rows the - // next time that report is opened from the Inbox. registerSearchRouteHighlight above covers this page instead. + // next time that report is opened from the Inbox. updateSplitTransactions({...params, isFromSplitExpensesFlow: true, shouldSkipReportHighlightRail: true}); if (!isSelfDMSplit) { diff --git a/src/libs/actions/IOU/TrackExpense.ts b/src/libs/actions/IOU/TrackExpense.ts index 32216b419209..764945ec38a4 100644 --- a/src/libs/actions/IOU/TrackExpense.ts +++ b/src/libs/actions/IOU/TrackExpense.ts @@ -131,9 +131,9 @@ import { getReportPreviewReportAction, getTransactionWithPreservedLocalReceiptSource, } from './MoneyRequestBuilder'; -import {highlightTransactionOnSearchRouteIfNeeded} from './NavigationHelpers'; import {addPendingNewTransactionIDs, isOneToTwoTransactionTransition} from './PendingNewTransactions'; import {getSearchOnyxUpdate} from './SearchUpdate'; +import signalExpenseAddedGrowl from './signalExpenseAddedGrowl'; type TrackExpenseInformation = { createdWorkspaceParams?: CreateWorkspaceParams; @@ -1644,7 +1644,7 @@ function convertTrackedExpenseToRequest(convertTrackedExpenseParams: ConvertTrac /** * Submit expense to another user */ -function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouReport?: OnyxTypes.Report} { +function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouReport?: OnyxTypes.Report; transactionID?: string} { const { report, existingIOUReport, @@ -1949,8 +1949,8 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep }); } - if (!requestMoneyInformation.isRetry) { - highlightTransactionOnSearchRouteIfNeeded(isFromGlobalCreate, transaction.transactionID, CONST.SEARCH.DATA_TYPES.EXPENSE); + if (!requestMoneyInformation.isRetry && isFromGlobalCreate) { + signalExpenseAddedGrowl(transaction.transactionID, CONST.SEARCH.DATA_TYPES.EXPENSE); } if (activeReportID && !isMoneyRequestReport) { @@ -1961,7 +1961,7 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep ); } - return {iouReport}; + return {iouReport, transactionID: transaction.transactionID}; } /** @@ -2908,11 +2908,13 @@ function trackExpense(params: CreateTrackExpenseParams) { } } - if (!params.isRetry) { - highlightTransactionOnSearchRouteIfNeeded(isFromGlobalCreate, transaction?.transactionID, CONST.SEARCH.DATA_TYPES.EXPENSE); + if (!params.isRetry && isFromGlobalCreate) { + signalExpenseAddedGrowl(transaction?.transactionID, CONST.SEARCH.DATA_TYPES.EXPENSE); } notifyNewAction(activeReportID, undefined, payeeAccountID === currentUserAccountIDParam); + + return {iouReport, transactionID: transaction?.transactionID}; } /** diff --git a/src/libs/actions/IOU/signalExpenseAddedGrowl.ts b/src/libs/actions/IOU/signalExpenseAddedGrowl.ts new file mode 100644 index 000000000000..9ca2858f8dc3 --- /dev/null +++ b/src/libs/actions/IOU/signalExpenseAddedGrowl.ts @@ -0,0 +1,13 @@ +import {mergeExpenseAddedGrowlTransactionIDs} from '@userActions/Transaction'; + +import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; + +/** Queues the "Expense added" growl (toast) for a newly-created transaction. */ +function signalExpenseAddedGrowl(transactionID: string | undefined, dataType: SearchDataTypes) { + if (!transactionID) { + return; + } + mergeExpenseAddedGrowlTransactionIDs({[transactionID]: dataType}); +} + +export default signalExpenseAddedGrowl; diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts index 80f4b596e98e..1a9074fc4040 100644 --- a/src/libs/actions/Transaction.ts +++ b/src/libs/actions/Transaction.ts @@ -2035,8 +2035,8 @@ function getDefaultP2PMileageRate() { API.read(READ_COMMANDS.GET_DEFAULT_P2P_MILEAGE_RATE, null); } -function mergeTransactionIdsHighlightOnSearchRoute(type: SearchDataTypes, data: Record | null) { - return Onyx.merge(ONYXKEYS.TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE, {[type]: data}); +function mergeExpenseAddedGrowlTransactionIDs(data: Record) { + return Onyx.merge(ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS, data); } function getDuplicateTransactionDetails(transactionID?: string) { @@ -2073,7 +2073,7 @@ export { getChangeTransactionsReportOnyxData, setTransactionReport, getDefaultP2PMileageRate, - mergeTransactionIdsHighlightOnSearchRoute, + mergeExpenseAddedGrowlTransactionIDs, getDuplicateTransactionDetails, setSelectedRoute, }; diff --git a/src/libs/telemetry/submitFollowUpAction.ts b/src/libs/telemetry/submitFollowUpAction.ts index 8cbb576573cc..bb66919ebdae 100644 --- a/src/libs/telemetry/submitFollowUpAction.ts +++ b/src/libs/telemetry/submitFollowUpAction.ts @@ -87,7 +87,7 @@ function isSameFlowUpdate(pending: NonNullable, fol return true; } // The fast path (pre-insert) sets NAVIGATE_TO_SEARCH before createTransaction runs. - // handleNavigateAfterExpenseCreate may later call with DISMISS_MODAL_ONLY because it + // navigateAfterExpenseCreate may later call with DISMISS_MODAL_ONLY because it // sees the Search page as already on top. Treat this as same-flow - keep the original. return pending.followUpAction === CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.NAVIGATE_TO_SEARCH && followUpAction === CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.DISMISS_MODAL_ONLY; } @@ -105,7 +105,7 @@ function setPendingSubmitFollowUpAction(followUpAction: SubmitFollowUpAction, re if (pending !== null && span && isSameFlowUpdate(pending, followUpAction, reportID)) { // Same flow: only update when the new action is a genuine refinement (e.g. // DISMISS_MODAL_ONLY -> DISMISS_MODAL_AND_OPEN_REPORT). When the fast path set - // NAVIGATE_TO_SEARCH and handleNavigateAfterExpenseCreate later calls with + // NAVIGATE_TO_SEARCH and navigateAfterExpenseCreate later calls with // DISMISS_MODAL_ONLY (because Search is already on top), preserve the original // action so telemetry correctly reflects the pre-insert path. const isRefinement = diff --git a/src/pages/Share/SubmitDetailsPage.tsx b/src/pages/Share/SubmitDetailsPage.tsx index 9b3825bfc041..d2bef9264e4b 100644 --- a/src/pages/Share/SubmitDetailsPage.tsx +++ b/src/pages/Share/SubmitDetailsPage.tsx @@ -31,6 +31,7 @@ import { updateLastLocationPermissionPrompt, } from '@libs/actions/IOU/MoneyRequest'; import {setMoneyRequestReceipt} from '@libs/actions/IOU/Receipt'; +import signalExpenseAddedGrowl from '@libs/actions/IOU/signalExpenseAddedGrowl'; import {requestMoney, trackExpense} from '@libs/actions/IOU/TrackExpense'; import type {GPSPoint as GpsPoint} from '@libs/actions/IOU/types/TrackExpenseTransactionParams'; import {WRITE_COMMANDS} from '@libs/API/types'; @@ -467,6 +468,10 @@ function SubmitDetailsPage({ optimisticChatReportID: routeReportID, }); } + + // requestMoney/trackExpense only signal the growl for the global-create flow and the share + // extension isn't flagged as such, so signal it here instead. + signalExpenseAddedGrowl(optimisticTransactionID, CONST.SEARCH.DATA_TYPES.EXPENSE); }; const cleanupParams = { diff --git a/src/setup/index.ts b/src/setup/index.ts index 5af98e059ed8..2d1c148c4a11 100644 --- a/src/setup/index.ts +++ b/src/setup/index.ts @@ -61,6 +61,9 @@ export default function () { // Ensure the Supportal permission modal doesn't persist across reloads [ONYXKEYS.SUPPORTAL_PERMISSION_DENIED]: null, [ONYXKEYS.IS_OPEN_APP_FAILURE_MODAL_OPEN]: false, + // The "Expense added" growl confirms an expense created this session. Drop any signal left over + // from a prior session (e.g. force-quit before it was consumed) so it can't surface on next launch. + [ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS]: {}, }, skippableCollectionMemberIDs: CONST.SKIPPABLE_COLLECTION_MEMBER_IDS, snapshotMergeKeys: ['pendingAction', 'pendingFields'], diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index 489bafa8dfa1..3648eefd0ad0 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -1923,6 +1923,10 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({ borderColor: isSelected ? theme.buttonHoveredBG : theme.border, }), + getSearchRowBackgroundStyle: (isSelected?: boolean): ViewStyle => ({ + backgroundColor: isSelected ? theme.activeComponentBG : theme.highlightBG, + }), + getSearchTableHighlightBorderRadius: (isLargeScreenWidth: boolean): number => (isLargeScreenWidth ? 0 : variables.componentBorderRadius), getReportTableColumnStyles: (columnName: string, options: GetReportTableColumnStylesParams = {}): ViewStyle => { diff --git a/tests/actions/IOU/SplitReportTotalsTest.ts b/tests/actions/IOU/SplitReportTotalsTest.ts index 3bb94529effa..0cd68e66ca7f 100644 --- a/tests/actions/IOU/SplitReportTotalsTest.ts +++ b/tests/actions/IOU/SplitReportTotalsTest.ts @@ -1,9 +1,7 @@ -import {handleNavigateAfterExpenseCreate} from '@libs/actions/IOU/NavigationHelpers'; import '@libs/actions/IOU/MoneyRequest'; import {createSplitsAndOnyxData} from '@libs/actions/IOU/Split'; import {updateSplitTransactionsFromSplitExpensesFlow} from '@libs/actions/IOU/SplitTransactionUpdate'; import initOnyxDerivedValues from '@libs/actions/OnyxDerived'; -import isReportTopmostSplitNavigator from '@libs/Navigation/helpers/isReportTopmostSplitNavigator'; import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; import {rand64} from '@libs/NumberUtils'; import type * as PolicyUtils from '@libs/PolicyUtils'; @@ -24,6 +22,7 @@ import Onyx from 'react-native-onyx'; import currencyList from '../../unit/currencyList.json'; import createMock from '../../utils/createMock'; +import getOnyxValue from '../../utils/getOnyxValue'; import {getGlobalFetchMock, formatPhoneNumber, getCurrencyDecimalsLocal, getCurrencySymbolLocal} from '../../utils/TestHelper'; import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; @@ -433,33 +432,6 @@ describe('actions/IOU', () => { }); }); - it('handleNavigateAfterExpenseCreate', async () => { - const mockedIsReportTopmostSplitNavigator = jest.mocked(isReportTopmostSplitNavigator); - const spyOnMergeTransactionIdsHighlightOnSearchRoute = jest.spyOn(require('@libs/actions/Transaction'), 'mergeTransactionIdsHighlightOnSearchRoute'); - const activeReportID = '1'; - const transactionID = '1'; - mockedIsReportTopmostSplitNavigator.mockReturnValue(false); - - handleNavigateAfterExpenseCreate({activeReportID, isFromGlobalCreate: false}); - expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledTimes(0); - - handleNavigateAfterExpenseCreate({activeReportID, isFromGlobalCreate: true}); - expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledTimes(0); - - mockedIsReportTopmostSplitNavigator.mockReturnValue(true); - handleNavigateAfterExpenseCreate({activeReportID, isFromGlobalCreate: true, transactionID}); - expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledTimes(0); - - mockedIsReportTopmostSplitNavigator.mockReturnValue(false); - handleNavigateAfterExpenseCreate({activeReportID, isFromGlobalCreate: true, transactionID}); - expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledTimes(0); - - handleNavigateAfterExpenseCreate({activeReportID, isFromGlobalCreate: true, transactionID, isInvoice: true}); - expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledTimes(0); - - spyOnMergeTransactionIdsHighlightOnSearchRoute.mockReset(); - }); - describe('createSplitsAndOnyxData', () => { const mockPersonalDetails: PersonalDetailsList = { [RORY_ACCOUNT_ID]: {accountID: RORY_ACCOUNT_ID, login: RORY_EMAIL, displayName: 'Rory'}, @@ -781,6 +753,18 @@ describe('actions/IOU', () => { }; } + function getPendingNewTransactionIDsFromOnyx(reportID: string) { + return new Promise | undefined>((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`, + callback: (metadata) => { + Onyx.disconnect(connection); + resolve(metadata?.pendingNewTransactionIDs); + }, + }); + }); + } + it('skips registration during a reverse split operation', async () => { // Given one existing child transaction (triggers isReverseSplitOperation when splitExpenses.length === 1) const existingChildTx = { @@ -839,56 +823,6 @@ describe('actions/IOU', () => { expect(pendingNewTransactionIDs?.['new-tx-2']).toBeUndefined(); }); - it('registers the search-route highlight (not report metadata) when splitting from the Search/Spend page', async () => { - // Given the user is on the Search (Spend > Expenses) page, where the expense report is never opened - jest.mocked(isSearchTopmostFullScreenRoute).mockReturnValue(true); - const spyOnMergeTransactionIdsHighlightOnSearchRoute = jest.spyOn(require('@libs/actions/Transaction'), 'mergeTransactionIdsHighlightOnSearchRoute'); - const params = buildBaseParams({ - transactionData: { - reportID: EXPENSE_REPORT_ID, - originalTransactionID: ORIGINAL_TX_ID, - splitExpenses: [ - {transactionID: 'new-tx-1', reportID: EXPENSE_REPORT_ID, statusNum: 0, amount: 500, created: '2024-01-01'}, - {transactionID: 'new-tx-2', reportID: EXPENSE_REPORT_ID, statusNum: 0, amount: 500, created: '2024-01-01'}, - ], - splitExpensesTotal: 1000, - }, - }); - - // When saving the split from the Search page - updateSplitTransactionsFromSplitExpensesFlow(params); - await waitForBatchedUpdates(); - - // Then the new IDs are registered on the search-route highlight, keyed by the current search type. - // (The report-metadata rail is asserted separately, in the REPORT_METADATA test below.) - // This mechanism highlights optimistically without a server re-search, so it works offline too. - expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledWith( - 'expense', - Object.fromEntries([ - ['new-tx-1', true], - ['new-tx-2', true], - ]), - ); - - spyOnMergeTransactionIdsHighlightOnSearchRoute.mockRestore(); - }); - - /** - * Reads REPORT_METADATA directly: the flags are written as Onyx optimisticData, not through the mocked - * addPendingNewTransactionIDs, so mock-only assertions cannot observe them - which is how this regressed. - */ - function getPendingNewTransactionIDsFromOnyx(reportID: string) { - return new Promise | undefined>((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`, - callback: (metadata) => { - Onyx.disconnect(connection); - resolve(metadata?.pendingNewTransactionIDs); - }, - }); - }); - } - it('does not write pendingNewTransactionIDs into report metadata when splitting from the Search/Spend page', async () => { // Given the user is on the Search (Spend > Expenses) page and the expense report already holds a transaction, // so the new splits would otherwise satisfy the "add makes the report multi-transaction" highlight condition @@ -963,10 +897,7 @@ describe('actions/IOU', () => { expect(pendingNewTransactionIDs?.['existing-tx-2']).toBeUndefined(); }); - it('skips the search-route highlight during a reverse split from the Search/Spend page', async () => { - // Given the user is on the Search page and this is a reverse split (1 expense, existing child present) - jest.mocked(isSearchTopmostFullScreenRoute).mockReturnValue(true); - const spyOnMergeTransactionIdsHighlightOnSearchRoute = jest.spyOn(require('@libs/actions/Transaction'), 'mergeTransactionIdsHighlightOnSearchRoute'); + it('does not signal the "Expense added" growl during a reverse split operation', async () => { const existingChildTx = { transactionID: 'child-tx-1', reportID: EXPENSE_REPORT_ID, @@ -982,90 +913,11 @@ describe('actions/IOU', () => { }, }); - // When saving the reverse split - updateSplitTransactionsFromSplitExpensesFlow(params); - await waitForBatchedUpdates(); - - // Then nothing is highlighted — reverse splits create no new transactions - expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).not.toHaveBeenCalled(); - - spyOnMergeTransactionIdsHighlightOnSearchRoute.mockRestore(); - }); - - it('registers the search-route highlight while offline, so the Spend page still highlights', async () => { - // Given the user is offline on the Search page. The auto-detect path in useSearchHighlightAndScroll is - // skipped while offline (it waits for a server re-search), so this rail is the only thing that can - // highlight the new rows - a reviewer caught the highlight silently disappearing offline. - jest.mocked(isSearchTopmostFullScreenRoute).mockReturnValue(true); - const spyOnMergeTransactionIdsHighlightOnSearchRoute = jest.spyOn(require('@libs/actions/Transaction'), 'mergeTransactionIdsHighlightOnSearchRoute'); - const params = buildBaseParams({ - isOffline: true, - transactionData: { - reportID: EXPENSE_REPORT_ID, - originalTransactionID: ORIGINAL_TX_ID, - splitExpenses: [ - {transactionID: 'offline-tx-1', reportID: EXPENSE_REPORT_ID, statusNum: 0, amount: 500, created: '2024-01-01'}, - {transactionID: 'offline-tx-2', reportID: EXPENSE_REPORT_ID, statusNum: 0, amount: 500, created: '2024-01-01'}, - ], - splitExpensesTotal: 1000, - }, - }); - - // When saving the split offline - updateSplitTransactionsFromSplitExpensesFlow(params); - await waitForBatchedUpdates(); - - // Then BOTH new IDs are registered - a reviewer also caught only one of the two rows highlighting - expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledWith( - 'expense', - Object.fromEntries([ - ['offline-tx-1', true], - ['offline-tx-2', true], - ]), - ); - - // And the report-metadata rail stays clean offline too - const pendingNewTransactionIDs = await getPendingNewTransactionIDsFromOnyx(EXPENSE_REPORT_ID); - expect(pendingNewTransactionIDs?.['offline-tx-1']).toBeUndefined(); - expect(pendingNewTransactionIDs?.['offline-tx-2']).toBeUndefined(); - - spyOnMergeTransactionIdsHighlightOnSearchRoute.mockRestore(); - }); - - it('leaves no highlight flags anywhere when splits move out to a different report', async () => { - // Given splits that leave the expense report entirely (each lands in its own other report). Nothing should - // be flagged on the source report, and the destination reports are never opened by this flow either, so - // no rail entry may be left behind for any of them. - jest.mocked(isSearchTopmostFullScreenRoute).mockReturnValue(false); - const expenseReport = {reportID: EXPENSE_REPORT_ID, type: CONST.REPORT.TYPE.EXPENSE, parentReportID: 'parent-report-1', chatReportID: 'chat-report-1'} as Report; - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${EXPENSE_REPORT_ID}`, expenseReport); - const existingTx = {transactionID: 'existing-tx-3', reportID: EXPENSE_REPORT_ID, amount: 1000}; - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}existing-tx-3`, existingTx); - const params = buildBaseParams({ - expenseReport, - allReportsList: {[`${ONYXKEYS.COLLECTION.REPORT}${EXPENSE_REPORT_ID}`]: expenseReport}, - allTransactionsList: {[`${ONYXKEYS.COLLECTION.TRANSACTION}existing-tx-3`]: existingTx}, - transactionData: { - reportID: EXPENSE_REPORT_ID, - originalTransactionID: ORIGINAL_TX_ID, - splitExpenses: [ - {transactionID: 'moved-tx-1', reportID: 'other-report-1', statusNum: 0, amount: 500, created: '2024-01-01'}, - {transactionID: 'moved-tx-2', reportID: 'other-report-2', statusNum: 0, amount: 500, created: '2024-01-01'}, - ], - splitExpensesTotal: 1000, - }, - }); - - // When saving the split updateSplitTransactionsFromSplitExpensesFlow(params); await waitForBatchedUpdates(); - // Then neither the source report nor the destination reports carry stranded highlight flags - const sourceRail = await getPendingNewTransactionIDsFromOnyx(EXPENSE_REPORT_ID); - expect(sourceRail?.['moved-tx-1']).toBeUndefined(); - expect(sourceRail?.['moved-tx-2']).toBeUndefined(); - expect((await getPendingNewTransactionIDsFromOnyx('other-report-1'))?.['moved-tx-1']).toBeUndefined(); - expect((await getPendingNewTransactionIDsFromOnyx('other-report-2'))?.['moved-tx-2']).toBeUndefined(); + const growlTransactionIDs = await getOnyxValue(ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS); + expect(growlTransactionIDs?.['new-merged-tx']).toBeUndefined(); }); }); }); diff --git a/tests/actions/IOUTest/SplitTest.ts b/tests/actions/IOUTest/SplitTest.ts index ec996875cf91..1ac8b50fe852 100644 --- a/tests/actions/IOUTest/SplitTest.ts +++ b/tests/actions/IOUTest/SplitTest.ts @@ -9815,7 +9815,7 @@ describe('createDistanceRequest', () => { expect(result.transactionID).toBeTruthy(); }); - it('flags the created transaction for Search highlight on a global-create distance request', async () => { + it('flags the created transaction for the "Expense added" growl on a global-create distance request', async () => { const recentWaypoints = (await getOnyxValue(ONYXKEYS.NVP_RECENT_WAYPOINTS)) ?? []; const result = createDistanceRequest({ @@ -9825,8 +9825,8 @@ describe('createDistanceRequest', () => { await waitForBatchedUpdates(); - const highlight = await getOnyxValue(ONYXKEYS.TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE); - expect(Object.keys(highlight?.[CONST.SEARCH.DATA_TYPES.EXPENSE] ?? {})).toContain(result.transactionID); + const growlSignal = await getOnyxValue(ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS); + expect(growlSignal?.[result.transactionID]).toBe(CONST.SEARCH.DATA_TYPES.EXPENSE); }); it('flags the new transaction for the 1→2 highlight fallback when added to an expense report that already has one transaction', async () => { diff --git a/tests/unit/ExpenseAddedGrowlTest.tsx b/tests/unit/ExpenseAddedGrowlTest.tsx new file mode 100644 index 000000000000..b2513c16c2e6 --- /dev/null +++ b/tests/unit/ExpenseAddedGrowlTest.tsx @@ -0,0 +1,156 @@ +import {act, render} from '@testing-library/react-native'; + +import ExpenseAddedGrowl from '@components/ExpenseAddedGrowl'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; + +import Onyx from 'react-native-onyx'; + +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +type GrowlContentProps = {bodyText: string; type: string}; + +const mockGetTopmostReportId = jest.fn(); + +const mockGrowlContent = jest.fn(); +jest.mock('@components/GrowlNotification/GrowlNotificationContent', () => (props: GrowlContentProps) => { + mockGrowlContent(props); + return null; +}); + +jest.mock('@libs/Navigation/Navigation', () => ({ + getTopmostReportId: () => mockGetTopmostReportId(), + getActiveRoute: () => '', +})); +jest.mock('@libs/Navigation/helpers/navigateAfterExpenseCreate', () => ({ + navigateToCreatedExpense: jest.fn(), +})); +jest.mock('@libs/actions/Report', () => ({ + createTransactionThreadReport: jest.fn(), + setOptimisticTransactionThread: jest.fn(), +})); +jest.mock('@hooks/useLocalize', () => () => ({translate: (key: string) => key})); +jest.mock('@hooks/useCurrentUserPersonalDetails', () => () => ({accountID: 1, login: 'me@example.com'})); + +const EXPENSE = CONST.SEARCH.DATA_TYPES.EXPENSE; +const INVOICE = CONST.SEARCH.DATA_TYPES.INVOICE; + +function flush(mutate: () => Promise) { + return act(async () => { + await mutate(); + await waitForBatchedUpdates(); + }); +} + +function seedExpense(transactionID: string, reportID: string, dataType: SearchDataTypes = EXPENSE) { + return flush(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {transactionID, reportID}); + await Onyx.merge(ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS, {[transactionID]: dataType}); + }); +} + +function lastGrowlProps() { + return mockGrowlContent.mock.calls.at(-1)?.[0]; +} + +describe('ExpenseAddedGrowl', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + mockGetTopmostReportId.mockReturnValue(undefined); + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + it('shows the "Expense added" growl for a pending transaction when the user is not viewing its report', async () => { + // Given the growl is mounted and the user is not viewing any report + render(); + + // When an expense is created + await seedExpense('1', 'report-1'); + + // Then the "Expense added" success growl shows + expect(mockGrowlContent).toHaveBeenCalled(); + expect(lastGrowlProps()?.bodyText).toBe('iou.expenseAdded'); + expect(lastGrowlProps()?.type).toBe(CONST.GROWL.SUCCESS); + }); + + it('uses the invoice copy for an invoice', async () => { + // Given the growl is mounted and the user is not viewing any report + render(); + + // When an invoice is created + await seedExpense('1', 'report-1', INVOICE); + + // Then the invoice copy is used instead of the expense one + expect(lastGrowlProps()?.bodyText).toBe('iou.invoiceSent'); + }); + + it("suppresses the growl when the user is already viewing the expense's report", async () => { + // Given the user is already viewing report-1 + mockGetTopmostReportId.mockReturnValue('report-1'); + render(); + + // When an expense is created in that same report + await seedExpense('1', 'report-1'); + + // Then no growl shows, because the report already highlights the new expense + expect(mockGrowlContent).not.toHaveBeenCalled(); + }); + + it('still shows for a tracked/unreported (self-DM) expense even when a report is open, since its reportID is UNREPORTED', async () => { + // Given the user is viewing some report + mockGetTopmostReportId.mockReturnValue('some-open-report'); + render(); + + // When a tracked expense is created, which has no report to be viewed in + await seedExpense('1', CONST.REPORT.UNREPORTED_REPORT_ID); + + // Then the growl still shows + expect(mockGrowlContent).toHaveBeenCalled(); + }); + + it('shows a single growl for a batch of new expenses and clears the whole signal', async () => { + // Given the growl is mounted + render(); + + // When two expenses are created in the same batch + await flush(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}1`, {transactionID: '1', reportID: 'report-1'}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}2`, {transactionID: '2', reportID: 'report-1'}); + const signal: Record = {}; + signal['1'] = EXPENSE; + signal['2'] = EXPENSE; + await Onyx.merge(ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS, signal); + }); + + // Then exactly one growl shows for the batch + expect(mockGrowlContent).toHaveBeenCalledTimes(1); + + // And the whole signal is consumed so it can't re-fire + const remaining = await new Promise | undefined>((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS, + callback: (value) => { + Onyx.disconnect(connection); + resolve(value); + }, + }); + }); + expect(remaining ?? {}).toEqual({}); + }); + + it('does not show a growl when there is no pending signal', () => { + // Given no expense was created + // When the growl mounts + render(); + + // Then the growl doesn't show + expect(mockGrowlContent).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/Search/useSearchSnapshotTest.ts b/tests/unit/Search/useSearchSnapshotTest.ts index a2c0b2531f1f..a482fced92e0 100644 --- a/tests/unit/Search/useSearchSnapshotTest.ts +++ b/tests/unit/Search/useSearchSnapshotTest.ts @@ -179,7 +179,6 @@ describe('useSearchSnapshot', () => { useSearchSnapshot({ queryJSON: makeQueryJSON(), searchResults, - newSearchResultKeys: undefined, transactions: undefined, reportActions: undefined, }), @@ -205,7 +204,6 @@ describe('useSearchSnapshot', () => { useSearchSnapshot({ queryJSON: makeQueryJSON(), searchResults: undefined, - newSearchResultKeys: undefined, transactions: undefined, reportActions: undefined, }), @@ -224,7 +222,6 @@ describe('useSearchSnapshot', () => { useSearchSnapshot({ queryJSON: makeQueryJSON(), searchResults, - newSearchResultKeys: undefined, transactions: undefined, reportActions: undefined, }), @@ -244,7 +241,6 @@ describe('useSearchSnapshot', () => { useSearchSnapshot({ queryJSON: makeQueryJSON(), searchResults, - newSearchResultKeys: undefined, transactions: undefined, reportActions: undefined, }), @@ -267,7 +263,6 @@ describe('useSearchSnapshot', () => { groupBy: CONST.SEARCH.GROUP_BY.FROM, }), searchResults, - newSearchResultKeys: undefined, transactions: undefined, reportActions: undefined, }), @@ -288,7 +283,6 @@ describe('useSearchSnapshot', () => { useSearchSnapshot({ queryJSON: makeQueryJSON(), searchResults, - newSearchResultKeys: undefined, transactions: undefined, reportActions: undefined, }), @@ -313,7 +307,6 @@ describe('useSearchSnapshot', () => { useSearchSnapshot({ queryJSON: makeQueryJSON(), searchResults, - newSearchResultKeys: undefined, transactions: undefined, reportActions: undefined, }), @@ -341,7 +334,6 @@ describe('useSearchSnapshot', () => { useSearchSnapshot({ queryJSON: makeQueryJSON(), searchResults, - newSearchResultKeys: undefined, transactions: undefined, reportActions: undefined, }), @@ -351,24 +343,6 @@ describe('useSearchSnapshot', () => { expect(result.current.hasCachedOptimisticItem).toBe(true); }); - it('stamps the post-create highlight on matching rows (newSearchResultKeys)', () => { - const searchResults = makeSearchResults(); - mockUseOptimisticSearchTracking.mockReturnValue(trackingReturn(searchResults.data)); - mockGetSortedSections.mockReturnValue([{transactionID: '7', keyForList: '7'}]); - - const {result} = renderHook(() => - useSearchSnapshot({ - queryJSON: makeQueryJSON(), - searchResults, - newSearchResultKeys: new Set([`${ONYXKEYS.COLLECTION.TRANSACTION}7`]), - transactions: undefined, - reportActions: undefined, - }), - ); - - expect(result.current.chartData.at(0)).toEqual(expect.objectContaining({shouldAnimateInHighlight: true})); - }); - it('passes the query type through to getSortedSections for each variant shape', () => { const variants = [ CONST.SEARCH.DATA_TYPES.CHAT, @@ -386,7 +360,6 @@ describe('useSearchSnapshot', () => { useSearchSnapshot({ queryJSON: makeQueryJSON({type}), searchResults, - newSearchResultKeys: undefined, transactions: undefined, reportActions: undefined, }), @@ -414,7 +387,6 @@ describe('useSearchSnapshot', () => { const props = { queryJSON: makeQueryJSON(), searchResults, - newSearchResultKeys: undefined, transactions: undefined, reportActions: undefined, }; diff --git a/tests/unit/actionLayerNavigationGuardTest.ts b/tests/unit/actionLayerNavigationGuardTest.ts index f7b99adfbf93..55f34565dff7 100644 --- a/tests/unit/actionLayerNavigationGuardTest.ts +++ b/tests/unit/actionLayerNavigationGuardTest.ts @@ -4,8 +4,6 @@ import path from 'path'; /** * Architecture guard for issue #84631: navigation belongs in the view layer, not in `src/libs/actions/`. * `submitWithDismissFirst` is the view-layer dismiss-first orchestrator; no action file may import it. - * (PR 9 will additionally enforce this via an ESLint `no-restricted-imports` rule once the legacy - * `NavigationHelpers.ts` value-imports of the other view helpers are removed.) */ const ACTIONS_DIR = path.join(__dirname, '..', '..', 'src', 'libs', 'actions'); const FORBIDDEN_IMPORT = '@libs/Navigation/helpers/submitWithDismissFirst'; diff --git a/tests/unit/navigateAfterExpenseCreateTest.ts b/tests/unit/navigateAfterExpenseCreateTest.ts index 26ec395ea9af..e08952eaf7a1 100644 --- a/tests/unit/navigateAfterExpenseCreateTest.ts +++ b/tests/unit/navigateAfterExpenseCreateTest.ts @@ -1,12 +1,15 @@ import type isReportOpenInRHP from '@libs/Navigation/helpers/isReportOpenInRHP'; import type isReportOpenInSuperWideRHP from '@libs/Navigation/helpers/isReportOpenInSuperWideRHP'; -import navigateAfterExpenseCreate from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; +import navigateAfterExpenseCreate, {navigateToCreatedExpense} from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; import Navigation from '@libs/Navigation/Navigation'; import type {getCurrentSearchQueryJSON} from '@libs/SearchQueryUtils'; import type {setPendingSubmitFollowUpAction} from '@libs/telemetry/submitFollowUpAction'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; +import type {Transaction} from '@src/types/onyx'; + +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; const mockIsReportTopmostSplitNavigator = jest.fn(); const mockIsSearchTopmostFullScreenRoute = jest.fn(); @@ -17,6 +20,7 @@ const mockGetTrackingState = jest.fn(); // Declared but assigned after jest.mock hoisting - use require() to access the mock in tests let mockSetPendingSubmitFollowUpAction: jest.MockedFunction; const mockGetCurrentSearchQueryJSON = jest.fn, Parameters>(); +const mockGetReportTransactions = jest.fn>, [string | undefined]>(); jest.mock('@libs/Navigation/helpers/isReportTopmostSplitNavigator', () => () => mockIsReportTopmostSplitNavigator()); jest.mock('@libs/Navigation/helpers/isSearchTopmostFullScreenRoute', () => () => mockIsSearchTopmostFullScreenRoute()); @@ -35,6 +39,15 @@ jest.mock('@libs/SearchQueryUtils', () => ({ buildCannedSearchQuery: jest.fn(({type}: {type: string}) => `type:${type}`), getCurrentSearchQueryJSON: mockGetCurrentSearchQueryJSON, })); +jest.mock('@libs/ReportUtils', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- partial mock of the real module + const actual = jest.requireActual('@libs/ReportUtils'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return -- spread the real module and override one export + return {...actual, __esModule: true, getReportTransactions: (reportID: string | undefined) => mockGetReportTransactions(reportID)}; +}); +jest.mock('@libs/actions/TransactionThreadNavigation', () => ({ + setActiveTransactionIDs: jest.fn(() => Promise.resolve()), +})); jest.mock('@libs/Navigation/Navigation', () => ({ dismissModal: jest.fn(), @@ -42,6 +55,7 @@ jest.mock('@libs/Navigation/Navigation', () => ({ dismissModalWithReport: jest.fn(), pop: jest.fn(), navigate: jest.fn(), + getActiveRoute: jest.fn(() => ''), revealRouteBeforeDismissingModal: jest.fn(), isNavigationReady: jest.fn(() => Promise.resolve()), getIsFullscreenPreInsertedUnderRHP: jest.fn(() => false), @@ -69,6 +83,7 @@ describe('navigateAfterExpenseCreate', () => { mockIsReportOpenInRHP.mockReturnValue(false); mockGetTrackingState.mockReturnValue(false); mockGetCurrentSearchQueryJSON.mockReturnValue(undefined); + mockGetReportTransactions.mockReturnValue([]); }); it('should dismiss to report when not from global create', () => { @@ -220,4 +235,107 @@ describe('navigateAfterExpenseCreate', () => { expect(Navigation.dismissModal).toHaveBeenCalled(); expect(Navigation.navigate).not.toHaveBeenCalled(); }); + + describe('navigateToCreatedExpense', () => { + it('should open the transaction thread in the Spend RHP when the user is on the Spend tab', async () => { + // Given the user is on the Spend tab + mockIsReportTopmostSplitNavigator.mockReturnValue(false); + mockIsSearchTopmostFullScreenRoute.mockReturnValue(true); + + // When they open a newly-created expense + navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'}); + await waitForBatchedUpdates(); + + // Then the transaction thread opens in the Spend RHP + expect(Navigation.navigate).toHaveBeenCalledTimes(1); + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SEARCH_REPORT.getRoute({reportID: 'thread-1', backTo: ''}), {forceReplace: false}); + }); + + it('should replace the currently-open report instead of stacking when one is already open in the RHP', async () => { + // Given a report is already open in the RHP + mockIsReportTopmostSplitNavigator.mockReturnValue(false); + mockIsSearchTopmostFullScreenRoute.mockReturnValue(true); + mockIsReportOpenInRHP.mockReturnValue(true); + + // When the user opens a newly-created expense + navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'}); + await waitForBatchedUpdates(); + + // Then the open report is replaced rather than stacked on + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SEARCH_REPORT.getRoute({reportID: 'thread-1', backTo: ''}), {forceReplace: true}); + }); + + it('should open the transaction thread as a full report when the user is on the Inbox tab on a narrow layout', () => { + // Given the user is on the Inbox tab on a narrow layout + mockIsReportTopmostSplitNavigator.mockReturnValue(true); + mockIsSearchTopmostFullScreenRoute.mockReturnValue(false); + mockGetIsNarrowLayout.mockReturnValue(true); + + // When they open a newly-created expense + navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'}); + + // Then the transaction thread opens as a full report + expect(Navigation.navigate).toHaveBeenCalledTimes(1); + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute('thread-1', undefined, undefined, ''), {forceReplace: false}); + }); + + it('should open the expense report then stack the thread RHP when the user is on the Inbox tab on a wide layout and the report has multiple transactions', async () => { + // Given the user is on the Inbox tab on a wide layout and the expense report holds several transactions + mockIsReportTopmostSplitNavigator.mockReturnValue(true); + mockIsSearchTopmostFullScreenRoute.mockReturnValue(false); + mockGetIsNarrowLayout.mockReturnValue(false); + mockGetReportTransactions.mockReturnValue([{transactionID: 'txn-1'}, {transactionID: 'txn-2'}]); + + // When they open a newly-created expense + navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'}); + await waitForBatchedUpdates(); + + // Then the expense report opens with the thread RHP stacked on top of it + expect(Navigation.navigate).toHaveBeenNthCalledWith(1, ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: 'iou-1', backTo: ''}), {forceReplace: false}); + expect(Navigation.navigate).toHaveBeenNthCalledWith(2, ROUTES.SEARCH_REPORT.getRoute({reportID: 'thread-1', backTo: ''})); + }); + + it('should open the expense report when the user is on the Inbox tab on a wide layout and the report has a single transaction', () => { + // Given the user is on the Inbox tab on a wide layout and the expense report holds one transaction + mockIsReportTopmostSplitNavigator.mockReturnValue(true); + mockIsSearchTopmostFullScreenRoute.mockReturnValue(false); + mockGetIsNarrowLayout.mockReturnValue(false); + mockGetReportTransactions.mockReturnValue([{transactionID: 'txn-1'}]); + + // When they open a newly-created expense + navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'}); + + // Then only the expense report opens, since it collapses to the thread itself + expect(Navigation.navigate).toHaveBeenCalledTimes(1); + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: 'iou-1', backTo: ''}), {forceReplace: false}); + }); + + it('should open the transaction thread as a full report when there is no expense report (tracked/unreported self-DM expense) on the Inbox tab', () => { + // Given the user is on the Inbox tab on a wide layout + mockIsReportTopmostSplitNavigator.mockReturnValue(true); + mockIsSearchTopmostFullScreenRoute.mockReturnValue(false); + mockGetIsNarrowLayout.mockReturnValue(false); + + // When they open a newly-created tracked expense, which has no expense report + navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: undefined}); + + // Then the thread opens as a full report, matching how tapping it in its self-DM chat does + expect(Navigation.navigate).toHaveBeenCalledTimes(1); + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute('thread-1', undefined, undefined, ''), {forceReplace: false}); + }); + + it('should open the transaction thread in the Spend RHP for a tracked/unreported expense when the user is on the Spend tab', async () => { + // Given the user is on the Spend tab + mockIsReportTopmostSplitNavigator.mockReturnValue(false); + mockIsSearchTopmostFullScreenRoute.mockReturnValue(true); + + // When they open a newly-created tracked expense, which has no expense report + navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: undefined}); + await waitForBatchedUpdates(); + + // Then the transaction thread still opens in the Spend RHP + expect(Navigation.navigate).toHaveBeenCalledTimes(1); + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SEARCH_REPORT.getRoute({reportID: 'thread-1', backTo: ''}), {forceReplace: false}); + }); + }); }); diff --git a/tests/unit/useSearchHighlightAndScrollTest.ts b/tests/unit/useSearchAutoRefetchTest.ts similarity index 59% rename from tests/unit/useSearchHighlightAndScrollTest.ts rename to tests/unit/useSearchAutoRefetchTest.ts index d34623d5f160..676384d8be82 100644 --- a/tests/unit/useSearchHighlightAndScrollTest.ts +++ b/tests/unit/useSearchAutoRefetchTest.ts @@ -1,10 +1,8 @@ /* eslint-disable @typescript-eslint/naming-convention */ import {renderHook} from '@testing-library/react-native'; -import type {SearchListItem} from '@components/Search/SearchList/ListItem/types'; - -import useSearchHighlightAndScroll from '@hooks/useSearchHighlightAndScroll'; -import type {UseSearchHighlightAndScroll} from '@hooks/useSearchHighlightAndScroll'; +import useSearchAutoRefetch from '@hooks/useSearchAutoRefetch'; +import type {UseSearchAutoRefetch} from '@hooks/useSearchAutoRefetch'; import {search} from '@libs/actions/Search'; @@ -37,15 +35,14 @@ afterEach(() => { mockIsOffline = false; }); -describe('useSearchHighlightAndScroll', () => { +describe('useSearchAutoRefetch', () => { beforeAll(async () => { Onyx.init({ keys: ONYXKEYS, }); }); - const baseProps: UseSearchHighlightAndScroll = { - shouldUseLiveData: false, + const baseProps: UseSearchAutoRefetch = { searchResults: { data: { personalDetailsList: {}, @@ -83,22 +80,22 @@ describe('useSearchHighlightAndScroll', () => { }; it('should not trigger search when collections are empty', () => { - renderHook(() => useSearchHighlightAndScroll(baseProps)); + renderHook(() => useSearchAutoRefetch(baseProps)); expect(search).not.toHaveBeenCalled(); }); it('should trigger search when new transaction added and focused', () => { - const initialProps = createMock({ + const initialProps = createMock({ ...baseProps, transactions: {transactions_1: {transactionID: '1'}}, previousTransactions: {transactions_1: {transactionID: '1'}}, }); - const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), { + const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), { initialProps, }); - const updatedProps = createMock({ + const updatedProps = createMock({ ...baseProps, transactions: { transactions_1: {transactionID: '1'}, @@ -114,11 +111,11 @@ describe('useSearchHighlightAndScroll', () => { it('should not trigger search when not focused', () => { mockUseIsFocused.mockReturnValue(false); - const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), { + const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), { initialProps: baseProps, }); - const updatedProps = createMock({ + const updatedProps = createMock({ ...baseProps, transactions: {transactions_1: {transactionID: '1'}}, }); @@ -130,7 +127,7 @@ describe('useSearchHighlightAndScroll', () => { it('should trigger search for chat when report actions added and focused', () => { mockUseIsFocused.mockReturnValue(true); - const chatProps = createMock({ + const chatProps = createMock({ ...baseProps, queryJSON: {...baseProps.queryJSON, type: 'chat' as const}, reportActions: { @@ -145,11 +142,11 @@ describe('useSearchHighlightAndScroll', () => { }, }); - const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), { + const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), { initialProps: chatProps, }); - const updatedProps = createMock({ + const updatedProps = createMock({ ...chatProps, reportActions: { reportActions_1: { @@ -164,7 +161,7 @@ describe('useSearchHighlightAndScroll', () => { }); it('should not trigger search when new transaction removed and focused', () => { - const initialProps = createMock({ + const initialProps = createMock({ ...baseProps, transactions: { transactions_1: {transactionID: '1'}, @@ -176,11 +173,11 @@ describe('useSearchHighlightAndScroll', () => { }, }); - const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), { + const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), { initialProps, }); - const updatedProps = createMock({ + const updatedProps = createMock({ ...baseProps, transactions: { transactions_1: {transactionID: '1'}, @@ -192,7 +189,7 @@ describe('useSearchHighlightAndScroll', () => { }); it('should trigger search when a transaction that is absent from the results is added', () => { - const initialProps = createMock({ + const initialProps = createMock({ ...baseProps, searchResults: { ...baseProps.searchResults, @@ -208,11 +205,11 @@ describe('useSearchHighlightAndScroll', () => { }, }); - const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), { + const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), { initialProps, }); - const updatedProps = createMock({ + const updatedProps = createMock({ ...initialProps, transactions: { transactions_1: {transactionID: '1'}, @@ -228,7 +225,7 @@ describe('useSearchHighlightAndScroll', () => { // Onyx keeps one value object per collection member, so an unedited transaction has to keep its // reference across renders — the hook reads that identity to tell an edit from an untouched row. const transaction1 = createMock({transactionID: '1'}); - const initialProps = createMock({ + const initialProps = createMock({ ...baseProps, searchResults: { ...baseProps.searchResults, @@ -245,11 +242,11 @@ describe('useSearchHighlightAndScroll', () => { }, }); - const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), { + const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), { initialProps, }); - const updatedProps = createMock({ + const updatedProps = createMock({ ...initialProps, transactions: { transactions_1: transaction1, @@ -263,7 +260,7 @@ describe('useSearchHighlightAndScroll', () => { it('should trigger search when a transaction that is already in the results is edited', () => { const transaction = createMock({transactionID: '1', amount: 100}); - const initialProps = createMock({ + const initialProps = createMock({ ...baseProps, searchResults: { ...baseProps.searchResults, @@ -275,11 +272,11 @@ describe('useSearchHighlightAndScroll', () => { previousTransactions: {transactions_1: transaction}, }); - const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), { + const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), { initialProps, }); - const updatedProps = createMock({ + const updatedProps = createMock({ ...initialProps, transactions: {transactions_1: {transactionID: '1', amount: 250}}, }); @@ -291,7 +288,7 @@ describe('useSearchHighlightAndScroll', () => { it('should not trigger search when the edited transaction is absent from the results', () => { const visibleTransaction = createMock({transactionID: '1', amount: 100}); const filteredOutTransaction = createMock({transactionID: '99', amount: 100}); - const initialProps = createMock({ + const initialProps = createMock({ ...baseProps, searchResults: { ...baseProps.searchResults, @@ -303,11 +300,11 @@ describe('useSearchHighlightAndScroll', () => { previousTransactions: {transactions_1: visibleTransaction, transactions_99: filteredOutTransaction}, }); - const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), { + const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), { initialProps, }); - const updatedProps = createMock({ + const updatedProps = createMock({ ...initialProps, transactions: {transactions_1: visibleTransaction, transactions_99: {transactionID: '99', amount: 250}}, }); @@ -318,7 +315,7 @@ describe('useSearchHighlightAndScroll', () => { it('should trigger search when a transaction moves into a report the results display', () => { const movedTransaction = createMock({transactionID: '99', reportID: '5'}); - const initialProps = createMock({ + const initialProps = createMock({ ...baseProps, searchResults: { ...baseProps.searchResults, @@ -330,11 +327,11 @@ describe('useSearchHighlightAndScroll', () => { previousTransactions: {transactions_99: movedTransaction}, }); - const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), { + const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), { initialProps, }); - const updatedProps = createMock({ + const updatedProps = createMock({ ...initialProps, transactions: {transactions_99: {transactionID: '99', reportID: '2'}}, }); @@ -345,7 +342,7 @@ describe('useSearchHighlightAndScroll', () => { it('should not trigger search when a transaction moves between reports the results do not display', () => { const movedTransaction = createMock({transactionID: '99', reportID: '5'}); - const initialProps = createMock({ + const initialProps = createMock({ ...baseProps, searchResults: { ...baseProps.searchResults, @@ -357,11 +354,11 @@ describe('useSearchHighlightAndScroll', () => { previousTransactions: {transactions_99: movedTransaction}, }); - const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), { + const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), { initialProps, }); - const updatedProps = createMock({ + const updatedProps = createMock({ ...initialProps, transactions: {transactions_99: {transactionID: '99', reportID: '7'}}, }); @@ -373,7 +370,7 @@ describe('useSearchHighlightAndScroll', () => { it('should trigger the deferred search once Search is active again, after previousTransactions caught up', () => { const transaction = createMock({transactionID: '1', amount: 100}); const editedTransaction = createMock({transactionID: '1', amount: 250}); - const initialProps = createMock({ + const initialProps = createMock({ ...baseProps, searchResults: { ...baseProps.searchResults, @@ -386,17 +383,17 @@ describe('useSearchHighlightAndScroll', () => { }); mockIsOffline = true; - const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), { + const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), { initialProps, }); - const editedProps = createMock({...initialProps, transactions: {transactions_1: editedTransaction}}); + const editedProps = createMock({...initialProps, transactions: {transactions_1: editedTransaction}}); rerender(editedProps); expect(search).not.toHaveBeenCalled(); // `usePrevious` catches up while Search is inactive, so the edit is no longer visible to the comparisons. - const settledProps = createMock({...editedProps, previousTransactions: {transactions_1: editedTransaction}}); + const settledProps = createMock({...editedProps, previousTransactions: {transactions_1: editedTransaction}}); rerender(settledProps); expect(search).not.toHaveBeenCalled(); @@ -410,7 +407,7 @@ describe('useSearchHighlightAndScroll', () => { it('should not trigger search on a non-chat search when a report action was added and Onyx holds a transaction the query filters out', () => { const transaction1 = createMock({transactionID: '1'}); const transaction99 = createMock({transactionID: '99'}); - const initialProps = createMock({ + const initialProps = createMock({ ...baseProps, searchResults: { ...baseProps.searchResults, @@ -439,11 +436,11 @@ describe('useSearchHighlightAndScroll', () => { }, }); - const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), { + const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), { initialProps, }); - const updatedProps = createMock({ + const updatedProps = createMock({ ...initialProps, reportActions: { reportActions_1: { @@ -460,7 +457,7 @@ describe('useSearchHighlightAndScroll', () => { it('should not trigger search when the added transaction is already in the results and Onyx holds one the query filters out', () => { const transaction1 = createMock({transactionID: '1'}); const transaction99 = createMock({transactionID: '99'}); - const initialProps = createMock({ + const initialProps = createMock({ ...baseProps, searchResults: { ...baseProps.searchResults, @@ -479,11 +476,11 @@ describe('useSearchHighlightAndScroll', () => { }, }); - const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), { + const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), { initialProps, }); - const updatedProps = createMock({ + const updatedProps = createMock({ ...initialProps, transactions: { transactions_1: transaction1, @@ -499,7 +496,7 @@ describe('useSearchHighlightAndScroll', () => { it('should not trigger search for chat when report actions removed and focused', () => { mockUseIsFocused.mockReturnValue(true); - const chatProps = createMock({ + const chatProps = createMock({ ...baseProps, queryJSON: {...baseProps.queryJSON, type: 'chat' as const}, reportActions: { @@ -516,11 +513,11 @@ describe('useSearchHighlightAndScroll', () => { }, }); - const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), { + const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), { initialProps: chatProps, }); - const updatedProps = createMock({ + const updatedProps = createMock({ ...chatProps, reportActions: { reportActions_1: { @@ -532,168 +529,4 @@ describe('useSearchHighlightAndScroll', () => { rerender(updatedProps); expect(search).not.toHaveBeenCalled(); }); - - it('should clear the scroll trigger when the first new expense is already first', () => { - const {rerender, result} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), { - initialProps: baseProps, - }); - const updatedProps = createMock({ - ...baseProps, - searchResults: { - ...baseProps.searchResults, - data: { - transactions_1: { - transactionID: '1', - }, - transactions_2: { - transactionID: '2', - }, - }, - }, - transactions: { - transactions_1: {transactionID: '1'}, - transactions_2: {transactionID: '2'}, - transactions_3: {transactionID: '3'}, - }, - previousTransactions: { - transactions_1: {transactionID: '1'}, - }, - }); - rerender(updatedProps); - expect(result.current.newSearchResultKeys?.size).toBe(2); - - const scrollToIndex = jest.fn(); - const ref: NonNullable[1]> = { - scrollAndHighlightItem: jest.fn(), - scrollToIndex, - updateFocusedIndex: jest.fn(), - scrollToFocusedInput: jest.fn(), - focusTextInput: jest.fn(), - }; - - const listItem1 = createMock({transactionID: '1'}); - const listItem2 = createMock({transactionID: '2'}); - - result.current.handleSelectionListScroll([listItem1, listItem2], ref); - result.current.handleSelectionListScroll([listItem2, listItem1], ref); - - expect(scrollToIndex).not.toHaveBeenCalled(); - }); - - it('should return new search result keys for manually highlighted expenses', async () => { - const spyOnMergeTransactionIdsHighlightOnSearchRoute = jest - .spyOn(require('@libs/actions/Transaction'), 'mergeTransactionIdsHighlightOnSearchRoute') - .mockImplementationOnce(jest.fn()); - // We need to mock requestAnimationFrame to mimic long Onyx merge overhead - jest.spyOn(global, 'requestAnimationFrame').mockImplementation((callback: FrameRequestCallback) => { - callback(performance.now()); - return 0; - }); - - await Onyx.merge(ONYXKEYS.TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE, {[baseProps.queryJSON.type]: {'3': true}}); - - const {rerender, result} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), { - initialProps: baseProps, - }); - const updatedProps1 = createMock({ - ...baseProps, - searchResults: { - ...baseProps.searchResults, - data: { - transactions_1: { - transactionID: '1', - }, - transactions_2: { - transactionID: '2', - }, - }, - }, - transactions: { - transactions_1: {transactionID: '1'}, - transactions_2: {transactionID: '2'}, - transactions_3: {transactionID: '3'}, - }, - previousTransactions: { - transactions_1: {transactionID: '1'}, - }, - }); - - // When there is no data yet, even if the transactionID has been added to manual highlight transactionIDs, - // it still will not be included in newSearchResultKeys. - rerender(updatedProps1); - expect(result.current.newSearchResultKeys?.size).toBe(2); - expect([...(result.current.newSearchResultKeys ?? new Set())]).not.toContain('transactions_3'); - - // When the data contains the highlight transactionID, it will be highlighted. - const updatedProps2 = createMock({ - ...updatedProps1, - searchResults: { - ...updatedProps1.searchResults, - data: { - transactions_1: { - transactionID: '1', - }, - transactions_2: { - transactionID: '2', - }, - transactions_3: { - transactionID: '3', - }, - }, - }, - }); - - rerender(updatedProps2); - expect(result.current.newSearchResultKeys?.size).toBe(1); - expect([...(result.current.newSearchResultKeys ?? new Set())]).toContain('transactions_3'); - - expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledWith(baseProps.queryJSON.type, {'3': false}); - }); - - it('should return multiple new search result keys when there are multiple new chats', () => { - const chatProps = createMock({ - ...baseProps, - queryJSON: {...baseProps.queryJSON, type: 'chat' as const}, - reportActions: { - reportActions_1: { - '1': {actionName: 'CREATED', reportActionID: '1'}, - }, - }, - }); - const {rerender, result} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), { - initialProps: chatProps, - }); - const updatedProps = createMock({ - ...chatProps, - searchResults: { - ...baseProps.searchResults, - data: { - reportActions_1: { - '1': {actionName: 'CREATED', reportActionID: '1'}, - }, - reportActions_2: { - '2': {actionName: 'CREATED', reportActionID: '2'}, - }, - }, - }, - reportActions: { - reportActions_1: { - '1': {actionName: 'CREATED', reportActionID: '1'}, - }, - reportActions_2: { - '2': {actionName: 'CREATED', reportActionID: '2'}, - }, - reportActions_3: { - '3': {actionName: 'CREATED', reportActionID: '3'}, - }, - }, - previousReportActions: { - reportActions_1: { - '1': {actionName: 'CREATED', reportActionID: '1'}, - }, - }, - }); - rerender(updatedProps); - expect(result.current.newSearchResultKeys?.size).toBe(2); - }); }); From fd766b67f266c4a90b4f633a680f3fee2dcc0066 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Tue, 1 Sep 2026 12:14:02 +0200 Subject: [PATCH 02/28] Fix conflict resolution --- src/components/ExpenseAddedGrowl.tsx | 3 + .../SearchList/ListItem/ChatListItem.tsx | 24 +++-- .../SearchList/ListItem/TaskListItem.tsx | 30 ++++-- .../hooks/useSearchTableItemHighlight.ts | 56 ----------- .../hooks/useListItemHighlight.ts | 38 -------- .../SelectionList/ListItemComposed/index.tsx | 1 - src/libs/actions/IOU/PerDiem.ts | 2 - tests/unit/useListItemHighlightTest.ts | 65 ------------- tests/unit/useSearchTableItemHighlightTest.ts | 93 ------------------- 9 files changed, 42 insertions(+), 270 deletions(-) delete mode 100644 src/components/Search/SearchList/ListItem/hooks/useSearchTableItemHighlight.ts delete mode 100644 src/components/SelectionList/ListItemComposed/hooks/useListItemHighlight.ts delete mode 100644 tests/unit/useListItemHighlightTest.ts delete mode 100644 tests/unit/useSearchTableItemHighlightTest.ts diff --git a/src/components/ExpenseAddedGrowl.tsx b/src/components/ExpenseAddedGrowl.tsx index f880475951cf..1c4aeeb28af5 100644 --- a/src/components/ExpenseAddedGrowl.tsx +++ b/src/components/ExpenseAddedGrowl.tsx @@ -73,6 +73,8 @@ function ExpenseAddedGrowlContent({transactionID, signal, active, setActive}: Ex const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); const [betas] = useOnyx(ONYXKEYS.BETAS); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); + const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); const reportID = transaction?.reportID; const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); @@ -131,6 +133,7 @@ function ExpenseAddedGrowlContent({transactionID, signal, active, setActive}: Ex } else { const optimisticThread = createTransactionThreadReport({ introSelected, + conciergeChat, currentUserLogin: currentUserPersonalDetails?.login ?? '', currentUserAccountID: currentUserPersonalDetails?.accountID ?? CONST.DEFAULT_NUMBER_ID, betas, diff --git a/src/components/Search/SearchList/ListItem/ChatListItem.tsx b/src/components/Search/SearchList/ListItem/ChatListItem.tsx index 605103f7bcc1..9fea29a1b49e 100644 --- a/src/components/Search/SearchList/ListItem/ChatListItem.tsx +++ b/src/components/Search/SearchList/ListItem/ChatListItem.tsx @@ -1,9 +1,9 @@ import {useRowSelection} from '@components/Search/SearchSelectionProvider'; import BaseListItem from '@components/SelectionList/ListItem/BaseListItem'; -import {useListItemHighlight} from '@components/SelectionList/ListItemComposed'; import type {ListItem} from '@components/SelectionList/types'; import useOnyx from '@hooks/useOnyx'; +import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import FS from '@libs/Fullstory'; @@ -11,6 +11,8 @@ import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import ReportActionItem from '@pages/inbox/report/ReportActionItem'; +import variables from '@styles/variables'; + import ONYXKEYS from '@src/ONYXKEYS'; import {getStableReportSelector} from '@src/selectors/Report'; @@ -38,11 +40,19 @@ function ChatListItem({ const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportActionItem?.childReportID}`); const [chatReportStable] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportStable?.chatReportID)}`, {selector: getStableReportSelector}); const styles = useThemeStyles(); + const theme = useTheme(); const {isSelected} = useRowSelection(item.keyForList); - const {pressableStyle, pressableWrapperStyle} = useListItemHighlight({ - shouldHighlight: item?.shouldAnimateInHighlight ?? false, - isSelected, - }); + const pressableStyle = [ + styles.selectionListPressableItemWrapper, + styles.p0, + styles.textAlignLeft, + styles.overflowHidden, + // Background is applied on the parent wrapper, so keep this transparent + styles.bgTransparent, + isSelected && styles.activeComponentBG, + styles.mh0, + item.cursorStyle, + ]; const fsClass = FS.getChatFSClass(reportStable); @@ -51,7 +61,7 @@ function ChatListItem({ return ( ({ keyForList={item.keyForList} onFocus={onFocus} shouldSyncFocus={shouldSyncFocus} - pressableWrapperStyle={pressableWrapperStyle} + pressableWrapperStyle={[styles.mh5, {backgroundColor: theme.highlightBG, borderRadius: variables.componentBorderRadius}]} hoverStyle={isSelected && styles.activeComponentBG} forwardedFSClass={fsClass} > diff --git a/src/components/Search/SearchList/ListItem/TaskListItem.tsx b/src/components/Search/SearchList/ListItem/TaskListItem.tsx index 892b1377d2d2..341c2480a1a1 100644 --- a/src/components/Search/SearchList/ListItem/TaskListItem.tsx +++ b/src/components/Search/SearchList/ListItem/TaskListItem.tsx @@ -4,10 +4,14 @@ import type {ListItem} from '@components/SelectionList/types'; import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import FS from '@libs/Fullstory'; +import variables from '@styles/variables'; + import ONYXKEYS from '@src/ONYXKEYS'; import type {ReportAttributesDerivedValue} from '@src/types/onyx'; @@ -17,7 +21,6 @@ import React from 'react'; import type {TaskListItemProps, TaskListItemType} from './types'; -import useSearchTableItemHighlight from './hooks/useSearchTableItemHighlight'; import TaskListItemRow from './TaskListItemRow'; /** @@ -46,15 +49,22 @@ function TaskListItem({ const liveTaskItem: TaskListItemType = liveParentReportAttributeName && liveParentReportAttributeName !== taskItem.parentReportName ? {...taskItem, parentReportName: liveParentReportAttributeName} : taskItem; const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const theme = useTheme(); const {isLargeScreenWidth} = useResponsiveLayout(); const {isSelected} = useRowSelection(item.keyForList); - const {pressableStyle, pressableWrapperStyle} = useSearchTableItemHighlight({ - shouldHighlight: item?.shouldAnimateInHighlight ?? false, - isSelected, - isLastItem: !!isLastItem, - }); + const listItemPressableStyle = [ + styles.selectionListPressableItemWrapper, + styles.pv3, + styles.ph3, + // Background is applied on the parent wrapper, so keep this transparent + styles.bgTransparent, + isSelected && styles.activeComponentBG, + styles.mh0, + isLargeScreenWidth && StyleUtils.getSearchTableRowPressableStyle(!!isLastItem, isSelected, {vertical: variables.tableRowPaddingVertical}), + ]; const listItemWrapperStyle = [ styles.flex1, @@ -67,7 +77,7 @@ function TaskListItem({ return ( ({ onLongPressRow={onLongPressRow} shouldSyncFocus={shouldSyncFocus} hoverStyle={isSelected && styles.activeComponentBG} - pressableWrapperStyle={pressableWrapperStyle} + pressableWrapperStyle={[ + styles.mh5, + {backgroundColor: theme.highlightBG, ...(!isLargeScreenWidth && {borderRadius: StyleUtils.getSearchTableHighlightBorderRadius(isLargeScreenWidth)})}, + isLargeScreenWidth && isLastItem && [styles.tableBottomRadius, styles.overflowHidden], + ]} forwardedFSClass={fsClass} > addOptimization(CONST.TELEMETRY.SUBMIT_OPTIMIZATION.DEFERRED_WRITE), }); - TransitionTracker.runAfterTransitions({callback: () => removeDraftTransaction(CONST.IOU.OPTIMISTIC_TRANSACTION_ID), waitForUpcomingTransition: true}); - if (isFromGlobalCreate) { signalExpenseAddedGrowl(transaction.transactionID, CONST.SEARCH.DATA_TYPES.EXPENSE); } diff --git a/tests/unit/useListItemHighlightTest.ts b/tests/unit/useListItemHighlightTest.ts deleted file mode 100644 index 8b8ddd735dd0..000000000000 --- a/tests/unit/useListItemHighlightTest.ts +++ /dev/null @@ -1,65 +0,0 @@ -import {renderHook} from '@testing-library/react-native'; - -import useListItemHighlight from '@components/SelectionList/ListItemComposed/hooks/useListItemHighlight'; - -import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle'; -import useTheme from '@hooks/useTheme'; -import useThemeStyles from '@hooks/useThemeStyles'; - -const animatedHighlightStyleMock = {backgroundColor: 'animated-highlight'}; -jest.mock('@hooks/useAnimatedHighlightStyle', () => jest.fn(() => animatedHighlightStyleMock)); - -const mockUseAnimatedHighlightStyle = jest.mocked(useAnimatedHighlightStyle); - -type HookParams = Parameters[0]; - -function renderHighlightHook(params?: HookParams) { - const {result} = renderHook(() => ({ - styles: useThemeStyles(), - theme: useTheme(), - highlight: useListItemHighlight(params), - })); - return result.current; -} - -describe('useListItemHighlight', () => { - beforeEach(() => { - mockUseAnimatedHighlightStyle.mockClear(); - }); - - it('configures the animation with the selection list border radius and full style application', () => { - const {styles, theme} = renderHighlightHook({shouldHighlight: true}); - - expect(mockUseAnimatedHighlightStyle).toHaveBeenCalledWith({ - borderRadius: styles.selectionListPressableItemWrapper.borderRadius, - shouldHighlight: true, - highlightColor: theme.messageHighlightBG, - backgroundColor: theme.highlightBG, - shouldApplyOtherStyles: true, - }); - }); - - it('rests on the wrapper style and margins, staying transparent for the animated background', () => { - const {styles, highlight} = renderHighlightHook(); - - expect(highlight.pressableStyle).toContain(styles.selectionListPressableItemWrapper); - expect(highlight.pressableStyle).toContain(styles.mh0); - // Unconditional even when not highlighting: the highlight flag resets mid-animation, so an opaque - // background here would mask the still-running animation on the wrapper underneath. - expect(highlight.pressableStyle).toContain(styles.bgTransparent); - expect(highlight.pressableWrapperStyle).toContain(styles.mh5); - expect(highlight.pressableWrapperStyle).toContain(animatedHighlightStyleMock); - }); - - it('paints the selected background when selected', () => { - const {styles, highlight} = renderHighlightHook({isSelected: true}); - - expect(highlight.pressableStyle).toContain(styles.activeComponentBG); - }); - - it('returns the animated highlight style for rows that own their pressable layout', () => { - const {highlight} = renderHighlightHook(); - - expect(highlight.animatedHighlightStyle).toBe(animatedHighlightStyleMock); - }); -}); diff --git a/tests/unit/useSearchTableItemHighlightTest.ts b/tests/unit/useSearchTableItemHighlightTest.ts deleted file mode 100644 index edd1d518b937..000000000000 --- a/tests/unit/useSearchTableItemHighlightTest.ts +++ /dev/null @@ -1,93 +0,0 @@ -import {renderHook} from '@testing-library/react-native'; - -import useSearchTableItemHighlight from '@components/Search/SearchList/ListItem/hooks/useSearchTableItemHighlight'; - -import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle'; -import useStyleUtils from '@hooks/useStyleUtils'; -import useTheme from '@hooks/useTheme'; -import useThemeStyles from '@hooks/useThemeStyles'; - -import variables from '@styles/variables'; - -const animatedHighlightStyleMock = {backgroundColor: 'animated-highlight'}; -jest.mock('@hooks/useAnimatedHighlightStyle', () => jest.fn(() => animatedHighlightStyleMock)); - -let mockIsLargeScreenWidth = false; -jest.mock('@hooks/useResponsiveLayout', () => () => ({isLargeScreenWidth: mockIsLargeScreenWidth})); - -const mockUseAnimatedHighlightStyle = jest.mocked(useAnimatedHighlightStyle); - -type HookParams = Parameters[0]; - -function renderHighlightHook(params?: HookParams) { - const {result} = renderHook(() => ({ - styles: useThemeStyles(), - StyleUtils: useStyleUtils(), - theme: useTheme(), - highlight: useSearchTableItemHighlight(params), - })); - return result.current; -} - -describe('useSearchTableItemHighlight', () => { - beforeEach(() => { - mockUseAnimatedHighlightStyle.mockClear(); - mockIsLargeScreenWidth = false; - }); - - it.each([ - ['wide', true], - ['narrow', false], - ])('configures the animation on %s screens with the table border radius and layout styles only off-table', (_name, isLargeScreenWidth) => { - mockIsLargeScreenWidth = isLargeScreenWidth; - const {StyleUtils, theme} = renderHighlightHook(); - - expect(mockUseAnimatedHighlightStyle).toHaveBeenCalledWith({ - borderRadius: StyleUtils.getSearchTableHighlightBorderRadius(isLargeScreenWidth), - shouldHighlight: false, - highlightColor: theme.messageHighlightBG, - backgroundColor: theme.highlightBG, - shouldApplyOtherStyles: !isLargeScreenWidth, - }); - }); - - it('adds table paddings and stays transparent for the animated background', () => { - const {styles, highlight} = renderHighlightHook(); - - expect(highlight.pressableStyle).toContain(styles.pv3); - expect(highlight.pressableStyle).toContain(styles.ph3); - expect(highlight.pressableStyle).toContain(styles.bgTransparent); - }); - - it('paints the selected background when selected', () => { - const {styles, highlight} = renderHighlightHook({isSelected: true}); - - expect(highlight.pressableStyle).toContain(styles.activeComponentBG); - }); - - it('applies the table row pressable style on wide screens', () => { - mockIsLargeScreenWidth = true; - const {StyleUtils, highlight} = renderHighlightHook({isSelected: true, isLastItem: true}); - - expect(highlight.pressableStyle).toContainEqual(StyleUtils.getSearchTableRowPressableStyle(true, true, {vertical: variables.tableRowPaddingVertical})); - }); - - it('rounds and clips the last row on wide screens', () => { - mockIsLargeScreenWidth = true; - const {styles, highlight} = renderHighlightHook({isLastItem: true}); - - expect(highlight.pressableWrapperStyle).toContainEqual([styles.tableBottomRadius, styles.overflowHidden]); - }); - - it('skips the last-row rounding on narrow screens', () => { - const {styles, highlight} = renderHighlightHook({isLastItem: true}); - - expect(highlight.pressableWrapperStyle).not.toContainEqual([styles.tableBottomRadius, styles.overflowHidden]); - }); - - it('returns the animated highlight style for rows that own their pressable layout', () => { - const {highlight} = renderHighlightHook(); - - expect(highlight.animatedHighlightStyle).toBe(animatedHighlightStyleMock); - }); -}); From b90f5bfbed3ae8fff8111ac5cc43f27dcb9d284f Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Tue, 1 Sep 2026 15:18:07 +0200 Subject: [PATCH 03/28] fix: Expense - Report on central pane changes to not here page after deleting same report on RHP --- .../helpers/navigateAfterExpenseCreate.ts | 2 +- tests/unit/navigateAfterExpenseCreateTest.ts | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts index 7af6321f3942..2fddceb26b6a 100644 --- a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts @@ -108,7 +108,7 @@ function navigateToCreatedExpense({threadReportID, transactionID, iouReportID}: return; } if (iouReportID) { - Navigation.navigate(ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: iouReportID, backTo}), {forceReplace}); + Navigation.navigate(ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: iouReportID, backTo: forceReplace ? undefined : backTo}), {forceReplace}); // A multi-transaction report opens super wide RHP, so stack the thread RHP on top of it. A single-transaction // report collapses to the thread itself, so the navigation above already landed on it. diff --git a/tests/unit/navigateAfterExpenseCreateTest.ts b/tests/unit/navigateAfterExpenseCreateTest.ts index e08952eaf7a1..20564dfc9187 100644 --- a/tests/unit/navigateAfterExpenseCreateTest.ts +++ b/tests/unit/navigateAfterExpenseCreateTest.ts @@ -21,6 +21,7 @@ const mockGetTrackingState = jest.fn(); let mockSetPendingSubmitFollowUpAction: jest.MockedFunction; const mockGetCurrentSearchQueryJSON = jest.fn, Parameters>(); const mockGetReportTransactions = jest.fn>, [string | undefined]>(); +const mockGetCurrentRoute = jest.fn<{params?: Record} | undefined, []>(); jest.mock('@libs/Navigation/helpers/isReportTopmostSplitNavigator', () => () => mockIsReportTopmostSplitNavigator()); jest.mock('@libs/Navigation/helpers/isSearchTopmostFullScreenRoute', () => () => mockIsSearchTopmostFullScreenRoute()); @@ -65,6 +66,9 @@ jest.mock('@libs/Navigation/Navigation', () => ({ routes: [], })), isReady: jest.fn(() => true), + current: { + getCurrentRoute: () => mockGetCurrentRoute(), + }, }, })); @@ -84,6 +88,7 @@ describe('navigateAfterExpenseCreate', () => { mockGetTrackingState.mockReturnValue(false); mockGetCurrentSearchQueryJSON.mockReturnValue(undefined); mockGetReportTransactions.mockReturnValue([]); + mockGetCurrentRoute.mockReturnValue(undefined); }); it('should dismiss to report when not from global create', () => { @@ -295,6 +300,23 @@ describe('navigateAfterExpenseCreate', () => { expect(Navigation.navigate).toHaveBeenNthCalledWith(2, ROUTES.SEARCH_REPORT.getRoute({reportID: 'thread-1', backTo: ''})); }); + it('should open the expense report without the replaced RHP backTo, so deleting the report falls back to its chat', async () => { + // Given the user is on the Inbox tab on a wide layout with another report already open in the RHP + mockIsReportTopmostSplitNavigator.mockReturnValue(true); + mockIsSearchTopmostFullScreenRoute.mockReturnValue(false); + mockGetIsNarrowLayout.mockReturnValue(false); + mockGetReportTransactions.mockReturnValue([{transactionID: 'txn-1'}, {transactionID: 'txn-2'}]); + mockIsReportOpenInRHP.mockReturnValue(true); + mockGetCurrentRoute.mockReturnValue({params: {backTo: '/home'}}); + + // When they open a newly-created expense + navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'}); + await waitForBatchedUpdates(); + + // Then the expense report opens with no backTo instead of inheriting the replaced RHP's origin + expect(Navigation.navigate).toHaveBeenNthCalledWith(1, ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: 'iou-1'}), {forceReplace: true}); + }); + it('should open the expense report when the user is on the Inbox tab on a wide layout and the report has a single transaction', () => { // Given the user is on the Inbox tab on a wide layout and the expense report holds one transaction mockIsReportTopmostSplitNavigator.mockReturnValue(true); From f8c10c32a7576a13307946a4489c43e873b1b03a Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Tue, 1 Sep 2026 16:04:35 +0200 Subject: [PATCH 04/28] fix: Expense added growl is not shown after creating per diem expense when submissions are disabled --- src/libs/actions/IOU/PerDiem.ts | 6 +++- tests/actions/IOU/PerDiemTest.ts | 52 ++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/libs/actions/IOU/PerDiem.ts b/src/libs/actions/IOU/PerDiem.ts index b7bd1445a180..b444c2c9230f 100644 --- a/src/libs/actions/IOU/PerDiem.ts +++ b/src/libs/actions/IOU/PerDiem.ts @@ -1152,7 +1152,7 @@ function submitPerDiemExpenseForSelfDM(submitPerDiemExpenseInformation: PerDiemE isTrackIntentUser, getCurrencyDecimals, } = submitPerDiemExpenseInformation; - const {currency, comment = '', category, tag, created, customUnit, attendees, billable, reimbursable} = transactionParams; + const {currency, comment = '', category, tag, created, customUnit, attendees, billable, reimbursable, isFromGlobalCreate} = transactionParams; if (isEmptyObject(policy) || !hasCompletePerDiemCustomUnit(customUnit)) { return; @@ -1215,6 +1215,10 @@ function submitPerDiemExpenseForSelfDM(submitPerDiemExpenseInformation: PerDiemE onDeferred: () => addOptimization(CONST.TELEMETRY.SUBMIT_OPTIMIZATION.DEFERRED_WRITE), }); + if (isFromGlobalCreate) { + signalExpenseAddedGrowl(transaction.transactionID, CONST.SEARCH.DATA_TYPES.EXPENSE); + } + notifyNewAction(chatReport.reportID, undefined, true); } diff --git a/tests/actions/IOU/PerDiemTest.ts b/tests/actions/IOU/PerDiemTest.ts index 76c2e6c47b74..bd1016060582 100644 --- a/tests/actions/IOU/PerDiemTest.ts +++ b/tests/actions/IOU/PerDiemTest.ts @@ -8,6 +8,7 @@ import { hasCompletePerDiemCustomUnit, removeSubrate, submitPerDiemExpense, + submitPerDiemExpenseForSelfDM, updateSubrate, } from '@libs/actions/IOU/PerDiem'; import type RequestMoneyParticipantParams from '@libs/actions/IOU/types/RequestMoneyParticipantParams'; @@ -1169,6 +1170,57 @@ describe('PerDiem', () => { }); }); + describe('submitPerDiemExpenseForSelfDM', () => { + const selfDMCustomUnit: TransactionCustomUnit = { + customUnitID: 'unit-1', + customUnitRateID: 'rate-1', + subRates: [{id: 'sub-1', name: 'Meals', quantity: 1, rate: 2500}], + attributes: {dates: {start: '2026-04-24', end: '2026-04-24'}}, + }; + + const submitToSelfDM = (isFromGlobalCreate: boolean) => + submitPerDiemExpenseForSelfDM({ + dateFnsLocale: undefined, + getCurrencyDecimals: getCurrencyDecimalsLocal, + selfDMReport: undefined, + policy: {...createRandomPolicy(1)}, + transactionParams: { + created: DateUtils.getDBTime(), + currency: CONST.CURRENCY.USD, + customUnit: selfDMCustomUnit, + isFromGlobalCreate, + }, + currentUserAccountIDParam: currentUserPersonalDetails.accountID, + currentUserEmailParam: currentUserPersonalDetails.login ?? '', + quickAction: undefined, + optimisticChatReportID: 'self-dm-1', + delegateAccountID: undefined, + isTrackIntentUser: false, + }); + + it('should signal the expense added growl when submitted from global create', async () => { + // Given a per diem expense headed for the self-DM, started from the global create flow + // When it is submitted + submitToSelfDM(true); + await waitForBatchedUpdates(); + + // Then the new transaction is queued for the "Expense added" growl + const signal = await getOnyxValue(ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS); + expect(Object.values(signal ?? {})).toEqual([CONST.SEARCH.DATA_TYPES.EXPENSE]); + }); + + it('should not signal the growl when submitted from within a report', async () => { + // Given a per diem expense headed for the self-DM, started from within a report + // When it is submitted + submitToSelfDM(false); + await waitForBatchedUpdates(); + + // Then nothing is queued for the growl + const signal = await getOnyxValue(ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS); + expect(signal).toBeUndefined(); + }); + }); + describe('hasCompletePerDiemCustomUnit', () => { const completeCustomUnit: TransactionCustomUnit = { customUnitID: 'unit-1', From aaed79a828fad1833a13f81db7c2692f90315fc6 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Tue, 1 Sep 2026 16:53:26 +0200 Subject: [PATCH 05/28] fix: Android - Expense - Report is opened a second time when tapping on growl after opening it --- .../helpers/navigateAfterExpenseCreate.ts | 11 +++- tests/unit/navigateAfterExpenseCreateTest.ts | 65 +++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts index 2fddceb26b6a..905a04b5a96a 100644 --- a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts @@ -89,6 +89,15 @@ type NavigateToCreatedExpenseParams = { * switched tabs while the growl was up, so the destination follows wherever they are now. */ function navigateToCreatedExpense({threadReportID, transactionID, iouReportID}: NavigateToCreatedExpenseParams) { + // Don't reopen an expense the user is already looking at + const hasMultipleReportTransactions = iouReportID + ? getReportTransactions(iouReportID).filter((transaction) => transaction?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE).length > 1 + : false; + const focusedReportID = Navigation.getFocusedReportId(); + if (focusedReportID === threadReportID || (!hasMultipleReportTransactions && !!iouReportID && focusedReportID === iouReportID)) { + return; + } + const openOnInbox = isReportTopmostSplitNavigator() && !isSearchTopmostFullScreenRoute(); // When a report/expense is already open in the RHP the app's convention is to replace it rather than stack a second @@ -112,8 +121,6 @@ function navigateToCreatedExpense({threadReportID, transactionID, iouReportID}: // A multi-transaction report opens super wide RHP, so stack the thread RHP on top of it. A single-transaction // report collapses to the thread itself, so the navigation above already landed on it. - const hasMultipleReportTransactions = - getReportTransactions(iouReportID).filter((transaction) => transaction?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE).length > 1; if (hasMultipleReportTransactions) { // Defer so the thread RHP stacks on top of the expense report navigation above. This is always a // push (never a replace) - it stacks on the report we just opened, not on the previously-open one. diff --git a/tests/unit/navigateAfterExpenseCreateTest.ts b/tests/unit/navigateAfterExpenseCreateTest.ts index 20564dfc9187..46e025226856 100644 --- a/tests/unit/navigateAfterExpenseCreateTest.ts +++ b/tests/unit/navigateAfterExpenseCreateTest.ts @@ -22,6 +22,7 @@ let mockSetPendingSubmitFollowUpAction: jest.MockedFunction, Parameters>(); const mockGetReportTransactions = jest.fn>, [string | undefined]>(); const mockGetCurrentRoute = jest.fn<{params?: Record} | undefined, []>(); +const mockGetFocusedReportId = jest.fn(); jest.mock('@libs/Navigation/helpers/isReportTopmostSplitNavigator', () => () => mockIsReportTopmostSplitNavigator()); jest.mock('@libs/Navigation/helpers/isSearchTopmostFullScreenRoute', () => () => mockIsSearchTopmostFullScreenRoute()); @@ -57,6 +58,7 @@ jest.mock('@libs/Navigation/Navigation', () => ({ pop: jest.fn(), navigate: jest.fn(), getActiveRoute: jest.fn(() => ''), + getFocusedReportId: () => mockGetFocusedReportId(), revealRouteBeforeDismissingModal: jest.fn(), isNavigationReady: jest.fn(() => Promise.resolve()), getIsFullscreenPreInsertedUnderRHP: jest.fn(() => false), @@ -89,6 +91,7 @@ describe('navigateAfterExpenseCreate', () => { mockGetCurrentSearchQueryJSON.mockReturnValue(undefined); mockGetReportTransactions.mockReturnValue([]); mockGetCurrentRoute.mockReturnValue(undefined); + mockGetFocusedReportId.mockReturnValue(undefined); }); it('should dismiss to report when not from global create', () => { @@ -242,6 +245,68 @@ describe('navigateAfterExpenseCreate', () => { }); describe('navigateToCreatedExpense', () => { + it('should do nothing when the user already has the transaction thread open', async () => { + // Given the user opened the expense themselves before pressing "View" + mockIsReportTopmostSplitNavigator.mockReturnValue(true); + mockIsSearchTopmostFullScreenRoute.mockReturnValue(false); + mockGetIsNarrowLayout.mockReturnValue(true); + mockGetFocusedReportId.mockReturnValue('thread-1'); + + // When they press "View" + navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'}); + await waitForBatchedUpdates(); + + // Then no navigation happens, so the report is not pushed a second time + expect(Navigation.navigate).not.toHaveBeenCalled(); + }); + + it('should do nothing when the user already has the collapsed expense report open', async () => { + // Given the user opened the single-transaction expense report, which renders the thread itself + mockIsReportTopmostSplitNavigator.mockReturnValue(true); + mockIsSearchTopmostFullScreenRoute.mockReturnValue(false); + mockGetIsNarrowLayout.mockReturnValue(true); + mockGetReportTransactions.mockReturnValue([{transactionID: 'txn-1'}]); + mockGetFocusedReportId.mockReturnValue('iou-1'); + + // When they press "View" + navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'}); + await waitForBatchedUpdates(); + + // Then no navigation happens, so the same expense is not opened a second time + expect(Navigation.navigate).not.toHaveBeenCalled(); + }); + + it('should still navigate when the focused expense report lists several transactions', async () => { + // Given the user is on an expense report holding more than one expense, so it shows a list rather than the thread + mockIsReportTopmostSplitNavigator.mockReturnValue(true); + mockIsSearchTopmostFullScreenRoute.mockReturnValue(false); + mockGetIsNarrowLayout.mockReturnValue(true); + mockGetReportTransactions.mockReturnValue([{transactionID: 'txn-1'}, {transactionID: 'txn-2'}]); + mockGetFocusedReportId.mockReturnValue('iou-1'); + + // When they press "View" + navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'}); + await waitForBatchedUpdates(); + + // Then the transaction thread still opens, since the list does not show the expense itself + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute('thread-1', undefined, undefined, ''), {forceReplace: false}); + }); + + it('should still navigate when the focused report is a different one', async () => { + // Given the user is viewing some other report + mockIsReportTopmostSplitNavigator.mockReturnValue(true); + mockIsSearchTopmostFullScreenRoute.mockReturnValue(false); + mockGetIsNarrowLayout.mockReturnValue(true); + mockGetFocusedReportId.mockReturnValue('some-other-report'); + + // When they press "View" + navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'}); + await waitForBatchedUpdates(); + + // Then the transaction thread still opens + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute('thread-1', undefined, undefined, ''), {forceReplace: false}); + }); + it('should open the transaction thread in the Spend RHP when the user is on the Spend tab', async () => { // Given the user is on the Spend tab mockIsReportTopmostSplitNavigator.mockReturnValue(false); From 1bb1dc82a2cfdf9b3ec06e35e1bd27a5d5eb90d9 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Tue, 1 Sep 2026 17:10:18 +0200 Subject: [PATCH 06/28] fix: Expense - Expense added growl is hidden behind Concierge side panel --- .../GrowlNotificationContent.tsx | 91 ++++++++++--------- .../GrowlNotificationPortal/index.native.tsx | 8 ++ .../GrowlNotificationPortal/index.tsx | 13 +++ src/styles/index.ts | 2 +- src/styles/variables.ts | 1 + 5 files changed, 70 insertions(+), 45 deletions(-) create mode 100644 src/components/GrowlNotification/GrowlNotificationPortal/index.native.tsx create mode 100644 src/components/GrowlNotification/GrowlNotificationPortal/index.tsx diff --git a/src/components/GrowlNotification/GrowlNotificationContent.tsx b/src/components/GrowlNotification/GrowlNotificationContent.tsx index 0c3d79223f25..f168c11abcad 100644 --- a/src/components/GrowlNotification/GrowlNotificationContent.tsx +++ b/src/components/GrowlNotification/GrowlNotificationContent.tsx @@ -22,6 +22,7 @@ import {useSharedValue, withSpring} from 'react-native-reanimated'; import {scheduleOnRN} from 'react-native-worklets'; import GrowlNotificationContainer from './GrowlNotificationContainer'; +import GrowlNotificationPortal from './GrowlNotificationPortal'; const INACTIVE_OFFSET = CONST.GROWL.OFFSCREEN_OFFSET; const INACTIVE_POSITION_Y = -INACTIVE_OFFSET; @@ -130,51 +131,53 @@ function GrowlNotificationContent({bodyText, type, duration, action, nonce, onDi .onStart(triggerDismiss); return ( - - - - - {/* The dismiss target covers only the icon + text; the action Button is a sibling - (not nested inside a pressable) so it stays independently focusable for screen - readers and its press can't bubble into a dismiss. */} - - - {bodyText} - - {!!action && ( - - )} - - - - + + {bodyText} + + {!!action && ( + + )} + + + + + ); } diff --git a/src/components/GrowlNotification/GrowlNotificationPortal/index.native.tsx b/src/components/GrowlNotification/GrowlNotificationPortal/index.native.tsx new file mode 100644 index 000000000000..1878c9c379c1 --- /dev/null +++ b/src/components/GrowlNotification/GrowlNotificationPortal/index.native.tsx @@ -0,0 +1,8 @@ +import type React from 'react'; + +/** Native has no shared DOM stacking context to escape, so growls render where they are mounted. */ +function GrowlNotificationPortal({children}: React.PropsWithChildren) { + return children; +} + +export default GrowlNotificationPortal; diff --git a/src/components/GrowlNotification/GrowlNotificationPortal/index.tsx b/src/components/GrowlNotification/GrowlNotificationPortal/index.tsx new file mode 100644 index 000000000000..535fb208d6d4 --- /dev/null +++ b/src/components/GrowlNotification/GrowlNotificationPortal/index.tsx @@ -0,0 +1,13 @@ +import type React from 'react'; + +import {createPortal} from 'react-dom'; + +/** + * Renders growls at the document root. Inside the app's own stacking context they are covered by overlays that + * portal to the body themselves, such as the Side Panel, no matter how high their z-index is. + */ +function GrowlNotificationPortal({children}: React.PropsWithChildren) { + return createPortal(children, document.body); +} + +export default GrowlNotificationPortal; diff --git a/src/styles/index.ts b/src/styles/index.ts index f82b91beffa8..f02f758afb48 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -3529,7 +3529,7 @@ const staticStyles = (theme: ThemeColors) => }, growlNotificationWrapper: { - zIndex: 2, + zIndex: variables.growlNotificationZIndex, }, growlNotificationContainer: { diff --git a/src/styles/variables.ts b/src/styles/variables.ts index e9911c48fffd..8a8ee1c4778a 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -282,6 +282,7 @@ export default { reportActionImagesDoubleImageHeight: 138, reportActionImagesMultipleImageHeight: 110, reportActionItemImagesMoreCornerTriangleWidth: 40, + growlNotificationZIndex: 10001, popoverZIndex: 10000, modalBaseZIndex: 9999, sidePanelZIndex: 9998, From 2dc4d7b52218cfbdc7101eb1842b8fecb60c7184 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Wed, 2 Sep 2026 14:20:39 +0200 Subject: [PATCH 07/28] Fix after merge --- .../SearchList/ListItem/GroupChildrenContainer.tsx | 9 +-------- .../TransactionListItemNarrow.tsx | 12 +++++++++++- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/components/Search/SearchList/ListItem/GroupChildrenContainer.tsx b/src/components/Search/SearchList/ListItem/GroupChildrenContainer.tsx index 53020b3afbb3..047b9d8000ec 100644 --- a/src/components/Search/SearchList/ListItem/GroupChildrenContainer.tsx +++ b/src/components/Search/SearchList/ListItem/GroupChildrenContainer.tsx @@ -48,14 +48,7 @@ function GroupChildrenContainer({ } return ( - + {isContentVisible ? ( ({ onSelectRow(item, transactionPreviewData, event); }; - const pressableStyle = [styles.transactionListItemStyle, styles.p4, styles.noBorderRadius, isSelected && styles.activeComponentBG, {...styles.flexColumn, ...styles.alignItemsStretch}]; + const pressableStyle = [ + styles.transactionListItemStyle, + styles.p4, + styles.noBorderRadius, + isSelected && styles.activeComponentBG, + // A selected row paints an opaque background here, on top of the rounded wrapper below, so the outer + // corners have to be rounded on this element too or the list's top/bottom corners look square. + isFirstItem && styles.tableTopRadius, + isLastItem && styles.tableBottomRadius, + {...styles.flexColumn, ...styles.alignItemsStretch}, + ]; return ( From 92431c550ef4d031790b784681b325d9a5290687 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Wed, 2 Sep 2026 15:10:02 +0200 Subject: [PATCH 08/28] fix: Android - Workspace chat opens briefly after opening expense from growl and splitting expense --- src/libs/actions/IOU/SplitTransactionUpdate.ts | 16 ++++++++++------ tests/actions/IOUTest/SplitTest.ts | 2 +- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/libs/actions/IOU/SplitTransactionUpdate.ts b/src/libs/actions/IOU/SplitTransactionUpdate.ts index 134a2a380759..fcde30b040cb 100644 --- a/src/libs/actions/IOU/SplitTransactionUpdate.ts +++ b/src/libs/actions/IOU/SplitTransactionUpdate.ts @@ -2105,14 +2105,13 @@ function updateSplitTransactionsFromSplitExpensesFlow(params: UpdateSplitTransac const targetReportID = params.expenseReport?.reportID ?? String(CONST.DEFAULT_NUMBER_ID); - if (!isReverseSplitOperation) { - signalExpenseAddedGrowl(getNewSplitTransactionIDs().at(-1), CONST.SEARCH.DATA_TYPES.EXPENSE); - } - if (isSearchPageTopmostFullScreenRoute || !params.transactionReport?.parentReportID) { // Returns to Search, not the expense report, so rail flags would sit unconsumed and highlight stale rows the // next time that report is opened from the Inbox. updateSplitTransactions({...params, isFromSplitExpensesFlow: true, shouldSkipReportHighlightRail: true}); + if (!isReverseSplitOperation) { + signalExpenseAddedGrowl(getNewSplitTransactionIDs().at(-1), CONST.SEARCH.DATA_TYPES.EXPENSE); + } if (!isSelfDMSplit) { Navigation.navigateBackToLastSuperWideRHPScreen(); @@ -2156,11 +2155,16 @@ function updateSplitTransactionsFromSplitExpensesFlow(params: UpdateSplitTransac setPendingSubmitFollowUpAction(CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.DISMISS_MODAL_AND_OPEN_REPORT, targetReportID); } + // When the transaction thread is the topmost report (e.g. it was opened directly from the "Expense added" growl, + // without the expense report beneath it), replace it instead of removing it and pushing the expense report after the + // dismissal animation - otherwise the stack empties down to the chat report and it flashes into view in between. + const shouldReplaceTransactionThread = !!transactionThreadReportID && Navigation.getTopmostReportId() === transactionThreadReportID; + popReportsSplitNavigatorToReport(targetReportID); - Navigation.dismissModalWithReport({reportID: targetReportID}); + Navigation.dismissModalWithReport({reportID: targetReportID}, navigationRef, {forceReplace: shouldReplaceTransactionThread}); requestAnimationFrame(() => { updateSplitTransactions({...params, isFromSplitExpensesFlow: true}); - if (!transactionThreadReportScreen?.key) { + if (shouldReplaceTransactionThread || !transactionThreadReportScreen?.key) { return; } Navigation.removeScreenByKey(transactionThreadReportScreen.key); diff --git a/tests/actions/IOUTest/SplitTest.ts b/tests/actions/IOUTest/SplitTest.ts index 1ac8b50fe852..418fa6f2dbe8 100644 --- a/tests/actions/IOUTest/SplitTest.ts +++ b/tests/actions/IOUTest/SplitTest.ts @@ -4303,7 +4303,7 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { // Then navigation should go to the expense report since it still exists - expect(Navigation.dismissModalWithReport).toHaveBeenCalledWith({reportID: expenseReport.reportID}); + expect(Navigation.dismissModalWithReport).toHaveBeenCalledWith({reportID: expenseReport.reportID}, expect.anything(), {forceReplace: false}); }); }); From 208954f55887bebfce6945f47ef7c3a77cdcb3fd Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Wed, 2 Sep 2026 15:57:30 +0200 Subject: [PATCH 09/28] fix: Report - WS avatar disappears when moving expense opened via growl to another report offline --- src/libs/actions/Transaction.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts index 1a9074fc4040..c2b1c7074a3e 100644 --- a/src/libs/actions/Transaction.ts +++ b/src/libs/actions/Transaction.ts @@ -1572,6 +1572,7 @@ function getChangeTransactionsReportOnyxData({ key: `${ONYXKEYS.COLLECTION.REPORT}${newIOUAction.childReportID}`, value: { parentReportID: targetReportID, + chatReportID: targetReportID, parentReportActionID: optimisticMoneyRequestReportActionID, policyID: reportID !== CONST.REPORT.UNREPORTED_REPORT_ID && newReport ? newReport.policyID : CONST.POLICY.ID_FAKE, participants: isUnreported && shouldRemoveOtherParticipants ? {[accountID]: participants?.[accountID]} : participants, @@ -1585,6 +1586,7 @@ function getChangeTransactionsReportOnyxData({ key: `${ONYXKEYS.COLLECTION.REPORT}${oldIOUAction.childReportID}`, value: { parentReportID: isUnreportedExpense ? selfDMReportID : oldReportID, + chatReportID: reports?.[`${ONYXKEYS.COLLECTION.REPORT}${oldIOUAction.childReportID}`]?.chatReportID, parentReportActionID: oldIOUAction.reportActionID, policyID: reports?.[`${ONYXKEYS.COLLECTION.REPORT}${oldIOUAction.childReportID}`]?.policyID, }, From 2eaed471fbb3bbb0f674f4bc866852325830645c Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Thu, 3 Sep 2026 21:19:19 +0200 Subject: [PATCH 10/28] Add more tests for expense added growls --- src/components/ExpenseAddedGrowl.tsx | 2 +- tests/unit/ExpenseAddedGrowlTest.tsx | 114 ++++++++++++++++++++++++++- 2 files changed, 113 insertions(+), 3 deletions(-) diff --git a/src/components/ExpenseAddedGrowl.tsx b/src/components/ExpenseAddedGrowl.tsx index 1c4aeeb28af5..89ad2683a9d8 100644 --- a/src/components/ExpenseAddedGrowl.tsx +++ b/src/components/ExpenseAddedGrowl.tsx @@ -45,7 +45,7 @@ type ExpenseAddedGrowlContentProps = { setActive: Dispatch>; }; -/** Watches the "an expense was just added" Onyx signal and shows an "Expense added" growl with a "View" action. */ +/** Watches the "an expense was just added" Onyx signal and shows an "Expense added" growl with a "View" action. */ function ExpenseAddedGrowl() { const [active, setActive] = useState(null); const [signal] = useOnyx(ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS); diff --git a/tests/unit/ExpenseAddedGrowlTest.tsx b/tests/unit/ExpenseAddedGrowlTest.tsx index b2513c16c2e6..a1e215f8bdf8 100644 --- a/tests/unit/ExpenseAddedGrowlTest.tsx +++ b/tests/unit/ExpenseAddedGrowlTest.tsx @@ -1,16 +1,24 @@ import {act, render} from '@testing-library/react-native'; import ExpenseAddedGrowl from '@components/ExpenseAddedGrowl'; +import type GrowlNotificationContent from '@components/GrowlNotification/GrowlNotificationContent'; + +import {createTransactionThreadReport, setOptimisticTransactionThread} from '@libs/actions/Report'; +import {navigateToCreatedExpense} from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; +import {getOriginalMessage} from '@libs/ReportActionsUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; +import type {ComponentProps} from 'react'; + import Onyx from 'react-native-onyx'; +import {actionR14932} from '../../__mocks__/reportData/actions'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; -type GrowlContentProps = {bodyText: string; type: string}; +type GrowlContentProps = ComponentProps; const mockGetTopmostReportId = jest.fn(); @@ -34,10 +42,14 @@ jest.mock('@libs/actions/Report', () => ({ jest.mock('@hooks/useLocalize', () => () => ({translate: (key: string) => key})); jest.mock('@hooks/useCurrentUserPersonalDetails', () => () => ({accountID: 1, login: 'me@example.com'})); +const mockCreateTransactionThreadReport = jest.mocked(createTransactionThreadReport); +const mockSetOptimisticTransactionThread = jest.mocked(setOptimisticTransactionThread); +const mockNavigateToCreatedExpense = jest.mocked(navigateToCreatedExpense); + const EXPENSE = CONST.SEARCH.DATA_TYPES.EXPENSE; const INVOICE = CONST.SEARCH.DATA_TYPES.INVOICE; -function flush(mutate: () => Promise) { +function flush(mutate: () => unknown) { return act(async () => { await mutate(); await waitForBatchedUpdates(); @@ -62,6 +74,7 @@ describe('ExpenseAddedGrowl', () => { beforeEach(async () => { jest.clearAllMocks(); + mockCreateTransactionThreadReport.mockReset(); mockGetTopmostReportId.mockReturnValue(undefined); await Onyx.clear(); await waitForBatchedUpdates(); @@ -78,6 +91,7 @@ describe('ExpenseAddedGrowl', () => { expect(mockGrowlContent).toHaveBeenCalled(); expect(lastGrowlProps()?.bodyText).toBe('iou.expenseAdded'); expect(lastGrowlProps()?.type).toBe(CONST.GROWL.SUCCESS); + expect(lastGrowlProps()?.action?.label).toBe('common.view'); }); it('uses the invoice copy for an invoice', async () => { @@ -145,6 +159,102 @@ describe('ExpenseAddedGrowl', () => { expect(remaining ?? {}).toEqual({}); }); + it('shows the next queued expense after dismissal and ignores a stale dismissal', async () => { + // Given one growl is active and another expense is queued + render(); + await seedExpense('1', 'report-1'); + const firstGrowl = lastGrowlProps(); + expect(firstGrowl).toBeDefined(); + + await seedExpense('2', 'report-2'); + expect(lastGrowlProps()?.nonce).toBe(firstGrowl?.nonce); + + // When the active growl is dismissed + await flush(() => firstGrowl?.onDismissed(firstGrowl.nonce)); + const secondGrowl = lastGrowlProps(); + + // Then the queued expense is shown + expect(secondGrowl?.nonce).toBeGreaterThan(firstGrowl?.nonce ?? 0); + + // When the old growl reports another, stale dismissal and a third expense is queued + await flush(() => firstGrowl?.onDismissed(firstGrowl.nonce)); + await seedExpense('3', 'report-3'); + + // Then the current growl remains active until its own dismissal + expect(lastGrowlProps()?.nonce).toBe(secondGrowl?.nonce); + await flush(() => secondGrowl?.onDismissed(secondGrowl.nonce)); + expect(lastGrowlProps()?.nonce).toBeGreaterThan(secondGrowl?.nonce ?? 0); + }); + + it('navigates to an existing transaction thread when View is pressed', async () => { + // Given an expense already has a transaction thread + render(); + await flush(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}1`, { + transactionID: '1', + reportID: 'report-1', + transactionThreadReportID: 'thread-1', + }); + const signal: Record = {}; + signal['1'] = EXPENSE; + await Onyx.merge(ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS, signal); + }); + + // When View is pressed + lastGrowlProps()?.action?.onPress(); + + // Then the existing thread is initialized and opened without creating a replacement + expect(mockSetOptimisticTransactionThread).toHaveBeenCalledWith('thread-1', undefined, undefined, undefined); + expect(mockCreateTransactionThreadReport).not.toHaveBeenCalled(); + expect(mockNavigateToCreatedExpense).toHaveBeenCalledWith({threadReportID: 'thread-1', transactionID: '1', iouReportID: undefined}); + }); + + it('resolves an existing transaction thread from the IOU action when View is pressed', async () => { + // Given the transaction thread is recorded on its IOU action + const transactionID = getOriginalMessage(actionR14932)?.IOUTransactionID; + if (!transactionID) { + throw new Error('Expected the IOU action fixture to contain a transaction ID'); + } + const reportID = actionR14932.reportID; + render(); + await flush(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {transactionID, reportID}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {[actionR14932.reportActionID]: actionR14932}); + const signal: Record = {}; + signal[transactionID] = EXPENSE; + await Onyx.merge(ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS, signal); + }); + + // When View is pressed + lastGrowlProps()?.action?.onPress(); + + // Then the IOU action's thread is initialized and opened + expect(mockSetOptimisticTransactionThread).toHaveBeenCalledWith(actionR14932.childReportID, undefined, actionR14932.reportActionID, undefined); + expect(mockCreateTransactionThreadReport).not.toHaveBeenCalled(); + expect(mockNavigateToCreatedExpense).toHaveBeenCalledWith({threadReportID: actionR14932.childReportID, transactionID, iouReportID: undefined}); + }); + + it('creates and navigates to a transaction thread when View is pressed without an existing thread', async () => { + // Given an expense does not have a transaction thread yet + mockCreateTransactionThreadReport.mockReturnValue({reportID: 'created-thread'}); + render(); + await seedExpense('1', 'report-1'); + + // When View is pressed + lastGrowlProps()?.action?.onPress(); + + // Then a thread is created from the current expense data and opened + expect(mockCreateTransactionThreadReport).toHaveBeenCalledWith( + expect.objectContaining({ + currentUserLogin: 'me@example.com', + currentUserAccountID: 1, + transaction: expect.objectContaining({transactionID: '1', reportID: 'report-1'}), + }), + ); + expect(mockSetOptimisticTransactionThread).not.toHaveBeenCalled(); + expect(mockNavigateToCreatedExpense).toHaveBeenCalledWith({threadReportID: 'created-thread', transactionID: '1', iouReportID: undefined}); + }); + it('does not show a growl when there is no pending signal', () => { // Given no expense was created // When the growl mounts From edaf067e62268e94d321931572e0718ea9240144 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Thu, 3 Sep 2026 21:46:21 +0200 Subject: [PATCH 11/28] Fix eslint error --- src/components/ExpenseAddedGrowl.tsx | 4 +- .../helpers/navigateAfterExpenseCreate.ts | 9 ++- tests/unit/ExpenseAddedGrowlTest.tsx | 11 +++- tests/unit/navigateAfterExpenseCreateTest.ts | 60 ++++++++++--------- 4 files changed, 49 insertions(+), 35 deletions(-) diff --git a/src/components/ExpenseAddedGrowl.tsx b/src/components/ExpenseAddedGrowl.tsx index 89ad2683a9d8..d9ea18195785 100644 --- a/src/components/ExpenseAddedGrowl.tsx +++ b/src/components/ExpenseAddedGrowl.tsx @@ -1,6 +1,7 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import useReportTransactions from '@hooks/useReportTransactions'; import {createTransactionThreadReport, setOptimisticTransactionThread} from '@libs/actions/Report'; import {mergeExpenseAddedGrowlTransactionIDs} from '@libs/actions/Transaction'; @@ -90,6 +91,7 @@ function ExpenseAddedGrowlContent({transactionID, signal, active, setActive}: Ex // navigateToCreatedExpense to open the transaction thread directly instead of an expense report RHP. const iouReport = isMoneyRequestReport(report) || isInvoiceReport(report) ? report : undefined; const iouReportID = iouReport?.reportID; + const reportTransactions = useReportTransactions(iouReportID); useEffect(() => { if (active) { @@ -148,7 +150,7 @@ function ExpenseAddedGrowlContent({transactionID, signal, active, setActive}: Ex Log.warn('[ExpenseAddedGrowl] Unable to resolve transaction thread reportID on View press.'); return; } - navigateToCreatedExpense({threadReportID, transactionID: active.transactionID, iouReportID}); + navigateToCreatedExpense({threadReportID, transactionID: active.transactionID, iouReportID, reportTransactions}); }; return ( diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts index 905a04b5a96a..87abf82cdb1a 100644 --- a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts @@ -4,7 +4,6 @@ import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import Log from '@libs/Log'; import {getPreservedNavigatorState} from '@libs/Navigation/AppNavigator/createSplitNavigator/usePreserveNavigatorState'; import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; -import {getReportTransactions} from '@libs/ReportUtils'; import {buildCannedSearchQuery, getCurrentSearchQueryJSON} from '@libs/SearchQueryUtils'; import {setPendingSubmitFollowUpAction} from '@libs/telemetry/submitFollowUpAction'; @@ -12,6 +11,7 @@ import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; +import type {Transaction} from '@src/types/onyx'; import dismissModalAndOpenReportInInboxTab from './dismissModalAndOpenReportInInboxTab'; import isReportOpenInRHP from './isReportOpenInRHP'; @@ -82,16 +82,19 @@ type NavigateToCreatedExpenseParams = { /** IOU report the transaction landed in, used to decide whether to stack the expense report underneath. */ iouReportID?: string; + + /** Transactions currently belonging to the IOU report. */ + reportTransactions: Transaction[]; }; /** * Opens a just-created expense when "View" is pressed on the "Expense added" growl. The user may have * switched tabs while the growl was up, so the destination follows wherever they are now. */ -function navigateToCreatedExpense({threadReportID, transactionID, iouReportID}: NavigateToCreatedExpenseParams) { +function navigateToCreatedExpense({threadReportID, transactionID, iouReportID, reportTransactions}: NavigateToCreatedExpenseParams) { // Don't reopen an expense the user is already looking at const hasMultipleReportTransactions = iouReportID - ? getReportTransactions(iouReportID).filter((transaction) => transaction?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE).length > 1 + ? reportTransactions.filter((transaction) => transaction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE).length > 1 : false; const focusedReportID = Navigation.getFocusedReportId(); if (focusedReportID === threadReportID || (!hasMultipleReportTransactions && !!iouReportID && focusedReportID === iouReportID)) { diff --git a/tests/unit/ExpenseAddedGrowlTest.tsx b/tests/unit/ExpenseAddedGrowlTest.tsx index a1e215f8bdf8..e8032110f60c 100644 --- a/tests/unit/ExpenseAddedGrowlTest.tsx +++ b/tests/unit/ExpenseAddedGrowlTest.tsx @@ -206,7 +206,7 @@ describe('ExpenseAddedGrowl', () => { // Then the existing thread is initialized and opened without creating a replacement expect(mockSetOptimisticTransactionThread).toHaveBeenCalledWith('thread-1', undefined, undefined, undefined); expect(mockCreateTransactionThreadReport).not.toHaveBeenCalled(); - expect(mockNavigateToCreatedExpense).toHaveBeenCalledWith({threadReportID: 'thread-1', transactionID: '1', iouReportID: undefined}); + expect(mockNavigateToCreatedExpense).toHaveBeenCalledWith({threadReportID: 'thread-1', transactionID: '1', iouReportID: undefined, reportTransactions: []}); }); it('resolves an existing transaction thread from the IOU action when View is pressed', async () => { @@ -231,7 +231,12 @@ describe('ExpenseAddedGrowl', () => { // Then the IOU action's thread is initialized and opened expect(mockSetOptimisticTransactionThread).toHaveBeenCalledWith(actionR14932.childReportID, undefined, actionR14932.reportActionID, undefined); expect(mockCreateTransactionThreadReport).not.toHaveBeenCalled(); - expect(mockNavigateToCreatedExpense).toHaveBeenCalledWith({threadReportID: actionR14932.childReportID, transactionID, iouReportID: undefined}); + expect(mockNavigateToCreatedExpense).toHaveBeenCalledWith({ + threadReportID: actionR14932.childReportID, + transactionID, + iouReportID: undefined, + reportTransactions: [], + }); }); it('creates and navigates to a transaction thread when View is pressed without an existing thread', async () => { @@ -252,7 +257,7 @@ describe('ExpenseAddedGrowl', () => { }), ); expect(mockSetOptimisticTransactionThread).not.toHaveBeenCalled(); - expect(mockNavigateToCreatedExpense).toHaveBeenCalledWith({threadReportID: 'created-thread', transactionID: '1', iouReportID: undefined}); + expect(mockNavigateToCreatedExpense).toHaveBeenCalledWith({threadReportID: 'created-thread', transactionID: '1', iouReportID: undefined, reportTransactions: []}); }); it('does not show a growl when there is no pending signal', () => { diff --git a/tests/unit/navigateAfterExpenseCreateTest.ts b/tests/unit/navigateAfterExpenseCreateTest.ts index 46e025226856..249004d34552 100644 --- a/tests/unit/navigateAfterExpenseCreateTest.ts +++ b/tests/unit/navigateAfterExpenseCreateTest.ts @@ -20,10 +20,13 @@ const mockGetTrackingState = jest.fn(); // Declared but assigned after jest.mock hoisting - use require() to access the mock in tests let mockSetPendingSubmitFollowUpAction: jest.MockedFunction; const mockGetCurrentSearchQueryJSON = jest.fn, Parameters>(); -const mockGetReportTransactions = jest.fn>, [string | undefined]>(); const mockGetCurrentRoute = jest.fn<{params?: Record} | undefined, []>(); const mockGetFocusedReportId = jest.fn(); +function buildTransaction(transactionID: string): Transaction { + return {transactionID, reportID: 'iou-1', amount: 0, created: '', currency: CONST.CURRENCY.USD, merchant: '', comment: {}}; +} + jest.mock('@libs/Navigation/helpers/isReportTopmostSplitNavigator', () => () => mockIsReportTopmostSplitNavigator()); jest.mock('@libs/Navigation/helpers/isSearchTopmostFullScreenRoute', () => () => mockIsSearchTopmostFullScreenRoute()); jest.mock('@libs/Navigation/helpers/isReportOpenInRHP', () => (state: Parameters[0]) => mockIsReportOpenInRHP(state)); @@ -41,12 +44,6 @@ jest.mock('@libs/SearchQueryUtils', () => ({ buildCannedSearchQuery: jest.fn(({type}: {type: string}) => `type:${type}`), getCurrentSearchQueryJSON: mockGetCurrentSearchQueryJSON, })); -jest.mock('@libs/ReportUtils', () => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- partial mock of the real module - const actual = jest.requireActual('@libs/ReportUtils'); - // eslint-disable-next-line @typescript-eslint/no-unsafe-return -- spread the real module and override one export - return {...actual, __esModule: true, getReportTransactions: (reportID: string | undefined) => mockGetReportTransactions(reportID)}; -}); jest.mock('@libs/actions/TransactionThreadNavigation', () => ({ setActiveTransactionIDs: jest.fn(() => Promise.resolve()), })); @@ -89,7 +86,6 @@ describe('navigateAfterExpenseCreate', () => { mockIsReportOpenInRHP.mockReturnValue(false); mockGetTrackingState.mockReturnValue(false); mockGetCurrentSearchQueryJSON.mockReturnValue(undefined); - mockGetReportTransactions.mockReturnValue([]); mockGetCurrentRoute.mockReturnValue(undefined); mockGetFocusedReportId.mockReturnValue(undefined); }); @@ -161,7 +157,7 @@ describe('navigateAfterExpenseCreate', () => { it('should NOT route a LOOKING_AROUND user to search when the destination is a real report (not the self-DM)', () => { // A LOOKING_AROUND user who later has a workspace and submits to a real report/friend from the Inbox must open that - // report, not be permanently misrouted to Search. isSelfDMDestination is false, so they are treated as "on inbox". + // report, not be permanently routed to Search. isSelfDMDestination is false, so they are treated as "on inbox". mockIsReportTopmostSplitNavigator.mockReturnValue(true); mockGetIsNarrowLayout.mockReturnValue(true); @@ -253,7 +249,7 @@ describe('navigateAfterExpenseCreate', () => { mockGetFocusedReportId.mockReturnValue('thread-1'); // When they press "View" - navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'}); + navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1', reportTransactions: []}); await waitForBatchedUpdates(); // Then no navigation happens, so the report is not pushed a second time @@ -265,11 +261,10 @@ describe('navigateAfterExpenseCreate', () => { mockIsReportTopmostSplitNavigator.mockReturnValue(true); mockIsSearchTopmostFullScreenRoute.mockReturnValue(false); mockGetIsNarrowLayout.mockReturnValue(true); - mockGetReportTransactions.mockReturnValue([{transactionID: 'txn-1'}]); mockGetFocusedReportId.mockReturnValue('iou-1'); // When they press "View" - navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'}); + navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1', reportTransactions: [buildTransaction('txn-1')]}); await waitForBatchedUpdates(); // Then no navigation happens, so the same expense is not opened a second time @@ -281,11 +276,15 @@ describe('navigateAfterExpenseCreate', () => { mockIsReportTopmostSplitNavigator.mockReturnValue(true); mockIsSearchTopmostFullScreenRoute.mockReturnValue(false); mockGetIsNarrowLayout.mockReturnValue(true); - mockGetReportTransactions.mockReturnValue([{transactionID: 'txn-1'}, {transactionID: 'txn-2'}]); mockGetFocusedReportId.mockReturnValue('iou-1'); // When they press "View" - navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'}); + navigateToCreatedExpense({ + threadReportID: 'thread-1', + transactionID: 'txn-1', + iouReportID: 'iou-1', + reportTransactions: [buildTransaction('txn-1'), buildTransaction('txn-2')], + }); await waitForBatchedUpdates(); // Then the transaction thread still opens, since the list does not show the expense itself @@ -300,7 +299,7 @@ describe('navigateAfterExpenseCreate', () => { mockGetFocusedReportId.mockReturnValue('some-other-report'); // When they press "View" - navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'}); + navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1', reportTransactions: []}); await waitForBatchedUpdates(); // Then the transaction thread still opens @@ -313,7 +312,7 @@ describe('navigateAfterExpenseCreate', () => { mockIsSearchTopmostFullScreenRoute.mockReturnValue(true); // When they open a newly-created expense - navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'}); + navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1', reportTransactions: []}); await waitForBatchedUpdates(); // Then the transaction thread opens in the Spend RHP @@ -328,7 +327,7 @@ describe('navigateAfterExpenseCreate', () => { mockIsReportOpenInRHP.mockReturnValue(true); // When the user opens a newly-created expense - navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'}); + navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1', reportTransactions: []}); await waitForBatchedUpdates(); // Then the open report is replaced rather than stacked on @@ -342,7 +341,7 @@ describe('navigateAfterExpenseCreate', () => { mockGetIsNarrowLayout.mockReturnValue(true); // When they open a newly-created expense - navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'}); + navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1', reportTransactions: []}); // Then the transaction thread opens as a full report expect(Navigation.navigate).toHaveBeenCalledTimes(1); @@ -354,10 +353,13 @@ describe('navigateAfterExpenseCreate', () => { mockIsReportTopmostSplitNavigator.mockReturnValue(true); mockIsSearchTopmostFullScreenRoute.mockReturnValue(false); mockGetIsNarrowLayout.mockReturnValue(false); - mockGetReportTransactions.mockReturnValue([{transactionID: 'txn-1'}, {transactionID: 'txn-2'}]); - // When they open a newly-created expense - navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'}); + navigateToCreatedExpense({ + threadReportID: 'thread-1', + transactionID: 'txn-1', + iouReportID: 'iou-1', + reportTransactions: [buildTransaction('txn-1'), buildTransaction('txn-2')], + }); await waitForBatchedUpdates(); // Then the expense report opens with the thread RHP stacked on top of it @@ -370,12 +372,16 @@ describe('navigateAfterExpenseCreate', () => { mockIsReportTopmostSplitNavigator.mockReturnValue(true); mockIsSearchTopmostFullScreenRoute.mockReturnValue(false); mockGetIsNarrowLayout.mockReturnValue(false); - mockGetReportTransactions.mockReturnValue([{transactionID: 'txn-1'}, {transactionID: 'txn-2'}]); mockIsReportOpenInRHP.mockReturnValue(true); mockGetCurrentRoute.mockReturnValue({params: {backTo: '/home'}}); // When they open a newly-created expense - navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'}); + navigateToCreatedExpense({ + threadReportID: 'thread-1', + transactionID: 'txn-1', + iouReportID: 'iou-1', + reportTransactions: [buildTransaction('txn-1'), buildTransaction('txn-2')], + }); await waitForBatchedUpdates(); // Then the expense report opens with no backTo instead of inheriting the replaced RHP's origin @@ -387,10 +393,8 @@ describe('navigateAfterExpenseCreate', () => { mockIsReportTopmostSplitNavigator.mockReturnValue(true); mockIsSearchTopmostFullScreenRoute.mockReturnValue(false); mockGetIsNarrowLayout.mockReturnValue(false); - mockGetReportTransactions.mockReturnValue([{transactionID: 'txn-1'}]); - // When they open a newly-created expense - navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'}); + navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1', reportTransactions: [buildTransaction('txn-1')]}); // Then only the expense report opens, since it collapses to the thread itself expect(Navigation.navigate).toHaveBeenCalledTimes(1); @@ -404,7 +408,7 @@ describe('navigateAfterExpenseCreate', () => { mockGetIsNarrowLayout.mockReturnValue(false); // When they open a newly-created tracked expense, which has no expense report - navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: undefined}); + navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: undefined, reportTransactions: []}); // Then the thread opens as a full report, matching how tapping it in its self-DM chat does expect(Navigation.navigate).toHaveBeenCalledTimes(1); @@ -417,7 +421,7 @@ describe('navigateAfterExpenseCreate', () => { mockIsSearchTopmostFullScreenRoute.mockReturnValue(true); // When they open a newly-created tracked expense, which has no expense report - navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: undefined}); + navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: undefined, reportTransactions: []}); await waitForBatchedUpdates(); // Then the transaction thread still opens in the Spend RHP From 78022dc21ac7b47741e484477ef49a753c8d6aed Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Fri, 4 Sep 2026 10:24:55 +0200 Subject: [PATCH 12/28] Use getSearchRowBackgroundStyle in ChatListItem and TaskListItem --- .../Search/SearchList/ListItem/ChatListItem.tsx | 8 +++----- .../Search/SearchList/ListItem/TaskListItem.tsx | 5 ++--- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/components/Search/SearchList/ListItem/ChatListItem.tsx b/src/components/Search/SearchList/ListItem/ChatListItem.tsx index 482d68565819..0b2e9e2ce6eb 100644 --- a/src/components/Search/SearchList/ListItem/ChatListItem.tsx +++ b/src/components/Search/SearchList/ListItem/ChatListItem.tsx @@ -3,7 +3,7 @@ import BaseListItem from '@components/SelectionList/ListItem/BaseListItem'; import type {ListItem} from '@components/SelectionList/types'; import useOnyx from '@hooks/useOnyx'; -import useTheme from '@hooks/useTheme'; +import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import FS from '@libs/Fullstory'; @@ -11,8 +11,6 @@ import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import ReportActionItem from '@pages/inbox/report/ReportActionItem'; -import variables from '@styles/variables'; - import ONYXKEYS from '@src/ONYXKEYS'; import {getStableReportSelector} from '@src/selectors/Report'; @@ -40,7 +38,7 @@ function ChatListItem({ const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportActionItem?.childReportID}`); const [chatReportStable] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportStable?.chatReportID)}`, {selector: getStableReportSelector}); const styles = useThemeStyles(); - const theme = useTheme(); + const StyleUtils = useStyleUtils(); const {isSelected} = useRowSelection(item.keyForList); const pressableStyle = [ styles.selectionListPressableItemWrapper, @@ -73,7 +71,7 @@ function ChatListItem({ onDismissError={onDismissError} onFocus={onFocus} shouldSyncFocus={shouldSyncFocus} - pressableWrapperStyle={[styles.mh5, {backgroundColor: theme.highlightBG, borderRadius: variables.componentBorderRadius}]} + pressableWrapperStyle={[styles.mh5, StyleUtils.getSearchRowBackgroundStyle(isSelected), styles.br2]} hoverStyle={isSelected && styles.activeComponentBG} forwardedFSClass={fsClass} > diff --git a/src/components/Search/SearchList/ListItem/TaskListItem.tsx b/src/components/Search/SearchList/ListItem/TaskListItem.tsx index 79359c9595df..99f039eb0bc5 100644 --- a/src/components/Search/SearchList/ListItem/TaskListItem.tsx +++ b/src/components/Search/SearchList/ListItem/TaskListItem.tsx @@ -5,7 +5,6 @@ import type {ListItem} from '@components/SelectionList/types'; import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; -import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import FS from '@libs/Fullstory'; @@ -50,7 +49,6 @@ function TaskListItem({ liveParentReportAttributeName && liveParentReportAttributeName !== taskItem.parentReportName ? {...taskItem, parentReportName: liveParentReportAttributeName} : taskItem; const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); - const theme = useTheme(); const {isLargeScreenWidth} = useResponsiveLayout(); const {isSelected} = useRowSelection(item.keyForList); @@ -91,7 +89,8 @@ function TaskListItem({ hoverStyle={isSelected && styles.activeComponentBG} pressableWrapperStyle={[ styles.mh5, - {backgroundColor: theme.highlightBG, ...(!isLargeScreenWidth && {borderRadius: StyleUtils.getSearchTableHighlightBorderRadius(isLargeScreenWidth)})}, + StyleUtils.getSearchRowBackgroundStyle(isSelected), + !isLargeScreenWidth && styles.br2, isLargeScreenWidth && isLastItem && [styles.tableBottomRadius, styles.overflowHidden], ]} forwardedFSClass={fsClass} From 1519ca32791a778a108c494efee6a7190f555531 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Wed, 9 Sep 2026 20:07:35 +0200 Subject: [PATCH 13/28] Use getFocusedReportId for checking current report in ExpenseAddedGrowl --- src/components/ExpenseAddedGrowl.tsx | 2 +- tests/unit/ExpenseAddedGrowlTest.tsx | 20 ++++++++++++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/components/ExpenseAddedGrowl.tsx b/src/components/ExpenseAddedGrowl.tsx index d9ea18195785..76963be1cb9d 100644 --- a/src/components/ExpenseAddedGrowl.tsx +++ b/src/components/ExpenseAddedGrowl.tsx @@ -112,7 +112,7 @@ function ExpenseAddedGrowlContent({transactionID, signal, active, setActive}: Ex mergeExpenseAddedGrowlTransactionIDs(Object.fromEntries(pendingTransactionIDs.map((id) => [id, null]))); // Suppress the growl when the user is already viewing the expense's money-request report - if (Navigation.getTopmostReportId() === transaction.reportID) { + if (Navigation.getFocusedReportId() === transaction.reportID) { return; } nonceRef.current += 1; diff --git a/tests/unit/ExpenseAddedGrowlTest.tsx b/tests/unit/ExpenseAddedGrowlTest.tsx index e8032110f60c..f67e6bc47ebc 100644 --- a/tests/unit/ExpenseAddedGrowlTest.tsx +++ b/tests/unit/ExpenseAddedGrowlTest.tsx @@ -21,6 +21,7 @@ import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; type GrowlContentProps = ComponentProps; const mockGetTopmostReportId = jest.fn(); +const mockGetFocusedReportId = jest.fn(); const mockGrowlContent = jest.fn(); jest.mock('@components/GrowlNotification/GrowlNotificationContent', () => (props: GrowlContentProps) => { @@ -30,6 +31,7 @@ jest.mock('@components/GrowlNotification/GrowlNotificationContent', () => (props jest.mock('@libs/Navigation/Navigation', () => ({ getTopmostReportId: () => mockGetTopmostReportId(), + getFocusedReportId: () => mockGetFocusedReportId(), getActiveRoute: () => '', })); jest.mock('@libs/Navigation/helpers/navigateAfterExpenseCreate', () => ({ @@ -76,6 +78,7 @@ describe('ExpenseAddedGrowl', () => { jest.clearAllMocks(); mockCreateTransactionThreadReport.mockReset(); mockGetTopmostReportId.mockReturnValue(undefined); + mockGetFocusedReportId.mockReturnValue(undefined); await Onyx.clear(); await waitForBatchedUpdates(); }); @@ -107,7 +110,7 @@ describe('ExpenseAddedGrowl', () => { it("suppresses the growl when the user is already viewing the expense's report", async () => { // Given the user is already viewing report-1 - mockGetTopmostReportId.mockReturnValue('report-1'); + mockGetFocusedReportId.mockReturnValue('report-1'); render(); // When an expense is created in that same report @@ -117,9 +120,22 @@ describe('ExpenseAddedGrowl', () => { expect(mockGrowlContent).not.toHaveBeenCalled(); }); + it("still shows the growl when the expense's report is only left open on an inactive tab", async () => { + // Given report-1 sits in the Inbox stack the user isn't looking at, so only the central-pane read sees it + mockGetTopmostReportId.mockReturnValue('report-1'); + mockGetFocusedReportId.mockReturnValue(undefined); + render(); + + // When an expense is created in report-1 from another tab, e.g. splitting an expense in Spend + await seedExpense('1', 'report-1'); + + // Then the growl still shows, because nothing on screen highlights the new expense + expect(mockGrowlContent).toHaveBeenCalled(); + }); + it('still shows for a tracked/unreported (self-DM) expense even when a report is open, since its reportID is UNREPORTED', async () => { // Given the user is viewing some report - mockGetTopmostReportId.mockReturnValue('some-open-report'); + mockGetFocusedReportId.mockReturnValue('some-open-report'); render(); // When a tracked expense is created, which has no report to be viewed in From 1358757b7e98d944f38f439575037aab96fe5d17 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Wed, 9 Sep 2026 20:31:05 +0200 Subject: [PATCH 14/28] Move navigateToCreatedExpense to a separate file --- src/components/ExpenseAddedGrowl.tsx | 2 +- .../helpers/navigateAfterExpenseCreate.ts | 81 ------ .../helpers/navigateToCreatedExpense.ts | 90 +++++++ tests/unit/ExpenseAddedGrowlTest.tsx | 6 +- tests/unit/navigateAfterExpenseCreateTest.ts | 199 +------------- tests/unit/navigateToCreatedExpenseTest.ts | 246 ++++++++++++++++++ 6 files changed, 340 insertions(+), 284 deletions(-) create mode 100644 src/libs/Navigation/helpers/navigateToCreatedExpense.ts create mode 100644 tests/unit/navigateToCreatedExpenseTest.ts diff --git a/src/components/ExpenseAddedGrowl.tsx b/src/components/ExpenseAddedGrowl.tsx index 76963be1cb9d..33ceb2c391c1 100644 --- a/src/components/ExpenseAddedGrowl.tsx +++ b/src/components/ExpenseAddedGrowl.tsx @@ -6,7 +6,7 @@ import useReportTransactions from '@hooks/useReportTransactions'; import {createTransactionThreadReport, setOptimisticTransactionThread} from '@libs/actions/Report'; import {mergeExpenseAddedGrowlTransactionIDs} from '@libs/actions/Transaction'; import Log from '@libs/Log'; -import {navigateToCreatedExpense} from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; +import navigateToCreatedExpense from '@libs/Navigation/helpers/navigateToCreatedExpense'; import Navigation from '@libs/Navigation/Navigation'; import {getIOUActionForTransactionID} from '@libs/ReportActionsUtils'; import {findSelfDMReportID, isInvoiceReport, isMoneyRequestReport} from '@libs/ReportUtils'; diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts index 87abf82cdb1a..aff27a47bbda 100644 --- a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts @@ -1,5 +1,4 @@ import {addPendingNewTransactionIDs} from '@libs/actions/IOU/PendingNewTransactions'; -import {setActiveTransactionIDs} from '@libs/actions/TransactionThreadNavigation'; import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import Log from '@libs/Log'; import {getPreservedNavigatorState} from '@libs/Navigation/AppNavigator/createSplitNavigator/usePreserveNavigatorState'; @@ -11,13 +10,10 @@ import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; -import type {Transaction} from '@src/types/onyx'; import dismissModalAndOpenReportInInboxTab from './dismissModalAndOpenReportInInboxTab'; -import isReportOpenInRHP from './isReportOpenInRHP'; import isReportTopmostSplitNavigator from './isReportTopmostSplitNavigator'; import isSearchTopmostFullScreenRoute from './isSearchTopmostFullScreenRoute'; -import setNavigationActionToMicrotaskQueue from './setNavigationActionToMicrotaskQueue'; type NavigateAfterExpenseCreateParams = { /** Report the expense was created in */ @@ -65,82 +61,6 @@ function getNavigateAfterCreateSearchNavigatorState() { return searchNavigatorRoute?.state ?? (searchNavigatorRoute?.key ? getPreservedNavigatorState(searchNavigatorRoute.key) : undefined); } -function getCurrentRouteBackTo() { - const params = navigationRef.current?.getCurrentRoute()?.params; - if (typeof params !== 'object' || params === null || !('backTo' in params) || typeof params.backTo !== 'string') { - return undefined; - } - return params.backTo; -} - -type NavigateToCreatedExpenseParams = { - /** The transaction thread report to open. */ - threadReportID: string; - - /** The created transaction's ID. */ - transactionID: string; - - /** IOU report the transaction landed in, used to decide whether to stack the expense report underneath. */ - iouReportID?: string; - - /** Transactions currently belonging to the IOU report. */ - reportTransactions: Transaction[]; -}; - -/** - * Opens a just-created expense when "View" is pressed on the "Expense added" growl. The user may have - * switched tabs while the growl was up, so the destination follows wherever they are now. - */ -function navigateToCreatedExpense({threadReportID, transactionID, iouReportID, reportTransactions}: NavigateToCreatedExpenseParams) { - // Don't reopen an expense the user is already looking at - const hasMultipleReportTransactions = iouReportID - ? reportTransactions.filter((transaction) => transaction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE).length > 1 - : false; - const focusedReportID = Navigation.getFocusedReportId(); - if (focusedReportID === threadReportID || (!hasMultipleReportTransactions && !!iouReportID && focusedReportID === iouReportID)) { - return; - } - - const openOnInbox = isReportTopmostSplitNavigator() && !isSearchTopmostFullScreenRoute(); - - // When a report/expense is already open in the RHP the app's convention is to replace it rather than stack a second - // report RHP on top of it. - const forceReplace = isReportOpenInRHP(navigationRef.getRootState()); - const backTo = forceReplace ? getCurrentRouteBackTo() : Navigation.getActiveRoute(); - - if (!openOnInbox) { - setActiveTransactionIDs([transactionID]).then(() => { - Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: threadReportID, backTo}), {forceReplace}); - }); - return; - } - - if (getIsNarrowLayout()) { - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(threadReportID, undefined, undefined, backTo), {forceReplace}); - return; - } - if (iouReportID) { - Navigation.navigate(ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: iouReportID, backTo: forceReplace ? undefined : backTo}), {forceReplace}); - - // A multi-transaction report opens super wide RHP, so stack the thread RHP on top of it. A single-transaction - // report collapses to the thread itself, so the navigation above already landed on it. - if (hasMultipleReportTransactions) { - // Defer so the thread RHP stacks on top of the expense report navigation above. This is always a - // push (never a replace) - it stacks on the report we just opened, not on the previously-open one. - setNavigationActionToMicrotaskQueue(() => { - setActiveTransactionIDs([transactionID]).then(() => { - Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: threadReportID, backTo: Navigation.getActiveRoute()})); - }); - }); - } - return; - } - - // A tracked expense has no expense report, so open the thread as a full report - the same way tapping - // the expense in its self-DM chat does. - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(threadReportID, undefined, undefined, backTo), {forceReplace}); -} - /** * Helper to navigate after an expense is created in order to standardize the post‑creation experience * when creating an expense from the global create button. @@ -223,4 +143,3 @@ function navigateAfterExpenseCreate({ } export default navigateAfterExpenseCreate; -export {navigateToCreatedExpense}; diff --git a/src/libs/Navigation/helpers/navigateToCreatedExpense.ts b/src/libs/Navigation/helpers/navigateToCreatedExpense.ts new file mode 100644 index 000000000000..686dda07c218 --- /dev/null +++ b/src/libs/Navigation/helpers/navigateToCreatedExpense.ts @@ -0,0 +1,90 @@ +import {setActiveTransactionIDs} from '@libs/actions/TransactionThreadNavigation'; +import getIsNarrowLayout from '@libs/getIsNarrowLayout'; +import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; + +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import type {Transaction} from '@src/types/onyx'; + +import isReportOpenInRHP from './isReportOpenInRHP'; +import isReportTopmostSplitNavigator from './isReportTopmostSplitNavigator'; +import isSearchTopmostFullScreenRoute from './isSearchTopmostFullScreenRoute'; +import setNavigationActionToMicrotaskQueue from './setNavigationActionToMicrotaskQueue'; + +type NavigateToCreatedExpenseParams = { + /** The transaction thread report to open. */ + threadReportID: string; + + /** The created transaction's ID. */ + transactionID: string; + + /** IOU report the transaction landed in, used to decide whether to stack the expense report underneath. */ + iouReportID?: string; + + /** Transactions currently belonging to the IOU report. */ + reportTransactions: Transaction[]; +}; + +function getCurrentRouteBackTo() { + const params = navigationRef.current?.getCurrentRoute()?.params; + if (typeof params !== 'object' || params === null || !('backTo' in params) || typeof params.backTo !== 'string') { + return undefined; + } + return params.backTo; +} + +/** + * Opens a just-created expense when "View" is pressed on the "Expense added" growl. The user may have + * switched tabs while the growl was up, so the destination follows wherever they are now. + */ +function navigateToCreatedExpense({threadReportID, transactionID, iouReportID, reportTransactions}: NavigateToCreatedExpenseParams) { + // Don't reopen an expense the user is already looking at + const hasMultipleReportTransactions = iouReportID + ? reportTransactions.filter((transaction) => transaction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE).length > 1 + : false; + const focusedReportID = Navigation.getFocusedReportId(); + if (focusedReportID === threadReportID || (!hasMultipleReportTransactions && !!iouReportID && focusedReportID === iouReportID)) { + return; + } + + const openOnInbox = isReportTopmostSplitNavigator() && !isSearchTopmostFullScreenRoute(); + + // When a report/expense is already open in the RHP the app's convention is to replace it rather than stack a second + // report RHP on top of it. + const forceReplace = isReportOpenInRHP(navigationRef.getRootState()); + const backTo = forceReplace ? getCurrentRouteBackTo() : Navigation.getActiveRoute(); + + if (!openOnInbox) { + setActiveTransactionIDs([transactionID]).then(() => { + Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: threadReportID, backTo}), {forceReplace}); + }); + return; + } + + if (getIsNarrowLayout()) { + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(threadReportID, undefined, undefined, backTo), {forceReplace}); + return; + } + if (iouReportID) { + Navigation.navigate(ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: iouReportID, backTo: forceReplace ? undefined : backTo}), {forceReplace}); + + // A multi-transaction report opens super wide RHP, so stack the thread RHP on top of it. A single-transaction + // report collapses to the thread itself, so the navigation above already landed on it. + if (hasMultipleReportTransactions) { + // Defer so the thread RHP stacks on top of the expense report navigation above. This is always a + // push (never a replace) - it stacks on the report we just opened, not on the previously-open one. + setNavigationActionToMicrotaskQueue(() => { + setActiveTransactionIDs([transactionID]).then(() => { + Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: threadReportID, backTo: Navigation.getActiveRoute()})); + }); + }); + } + return; + } + + // A tracked expense has no expense report, so open the thread as a full report - the same way tapping + // the expense in its self-DM chat does. + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(threadReportID, undefined, undefined, backTo), {forceReplace}); +} + +export default navigateToCreatedExpense; diff --git a/tests/unit/ExpenseAddedGrowlTest.tsx b/tests/unit/ExpenseAddedGrowlTest.tsx index f67e6bc47ebc..13370dadf55d 100644 --- a/tests/unit/ExpenseAddedGrowlTest.tsx +++ b/tests/unit/ExpenseAddedGrowlTest.tsx @@ -4,7 +4,7 @@ import ExpenseAddedGrowl from '@components/ExpenseAddedGrowl'; import type GrowlNotificationContent from '@components/GrowlNotification/GrowlNotificationContent'; import {createTransactionThreadReport, setOptimisticTransactionThread} from '@libs/actions/Report'; -import {navigateToCreatedExpense} from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; +import navigateToCreatedExpense from '@libs/Navigation/helpers/navigateToCreatedExpense'; import {getOriginalMessage} from '@libs/ReportActionsUtils'; import CONST from '@src/CONST'; @@ -34,9 +34,7 @@ jest.mock('@libs/Navigation/Navigation', () => ({ getFocusedReportId: () => mockGetFocusedReportId(), getActiveRoute: () => '', })); -jest.mock('@libs/Navigation/helpers/navigateAfterExpenseCreate', () => ({ - navigateToCreatedExpense: jest.fn(), -})); +jest.mock('@libs/Navigation/helpers/navigateToCreatedExpense', () => jest.fn()); jest.mock('@libs/actions/Report', () => ({ createTransactionThreadReport: jest.fn(), setOptimisticTransactionThread: jest.fn(), diff --git a/tests/unit/navigateAfterExpenseCreateTest.ts b/tests/unit/navigateAfterExpenseCreateTest.ts index 249004d34552..a56cea2684da 100644 --- a/tests/unit/navigateAfterExpenseCreateTest.ts +++ b/tests/unit/navigateAfterExpenseCreateTest.ts @@ -1,15 +1,12 @@ import type isReportOpenInRHP from '@libs/Navigation/helpers/isReportOpenInRHP'; import type isReportOpenInSuperWideRHP from '@libs/Navigation/helpers/isReportOpenInSuperWideRHP'; -import navigateAfterExpenseCreate, {navigateToCreatedExpense} from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; +import navigateAfterExpenseCreate from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; import Navigation from '@libs/Navigation/Navigation'; import type {getCurrentSearchQueryJSON} from '@libs/SearchQueryUtils'; import type {setPendingSubmitFollowUpAction} from '@libs/telemetry/submitFollowUpAction'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; -import type {Transaction} from '@src/types/onyx'; - -import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; const mockIsReportTopmostSplitNavigator = jest.fn(); const mockIsSearchTopmostFullScreenRoute = jest.fn(); @@ -23,10 +20,6 @@ const mockGetCurrentSearchQueryJSON = jest.fn} | undefined, []>(); const mockGetFocusedReportId = jest.fn(); -function buildTransaction(transactionID: string): Transaction { - return {transactionID, reportID: 'iou-1', amount: 0, created: '', currency: CONST.CURRENCY.USD, merchant: '', comment: {}}; -} - jest.mock('@libs/Navigation/helpers/isReportTopmostSplitNavigator', () => () => mockIsReportTopmostSplitNavigator()); jest.mock('@libs/Navigation/helpers/isSearchTopmostFullScreenRoute', () => () => mockIsSearchTopmostFullScreenRoute()); jest.mock('@libs/Navigation/helpers/isReportOpenInRHP', () => (state: Parameters[0]) => mockIsReportOpenInRHP(state)); @@ -239,194 +232,4 @@ describe('navigateAfterExpenseCreate', () => { expect(Navigation.dismissModal).toHaveBeenCalled(); expect(Navigation.navigate).not.toHaveBeenCalled(); }); - - describe('navigateToCreatedExpense', () => { - it('should do nothing when the user already has the transaction thread open', async () => { - // Given the user opened the expense themselves before pressing "View" - mockIsReportTopmostSplitNavigator.mockReturnValue(true); - mockIsSearchTopmostFullScreenRoute.mockReturnValue(false); - mockGetIsNarrowLayout.mockReturnValue(true); - mockGetFocusedReportId.mockReturnValue('thread-1'); - - // When they press "View" - navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1', reportTransactions: []}); - await waitForBatchedUpdates(); - - // Then no navigation happens, so the report is not pushed a second time - expect(Navigation.navigate).not.toHaveBeenCalled(); - }); - - it('should do nothing when the user already has the collapsed expense report open', async () => { - // Given the user opened the single-transaction expense report, which renders the thread itself - mockIsReportTopmostSplitNavigator.mockReturnValue(true); - mockIsSearchTopmostFullScreenRoute.mockReturnValue(false); - mockGetIsNarrowLayout.mockReturnValue(true); - mockGetFocusedReportId.mockReturnValue('iou-1'); - - // When they press "View" - navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1', reportTransactions: [buildTransaction('txn-1')]}); - await waitForBatchedUpdates(); - - // Then no navigation happens, so the same expense is not opened a second time - expect(Navigation.navigate).not.toHaveBeenCalled(); - }); - - it('should still navigate when the focused expense report lists several transactions', async () => { - // Given the user is on an expense report holding more than one expense, so it shows a list rather than the thread - mockIsReportTopmostSplitNavigator.mockReturnValue(true); - mockIsSearchTopmostFullScreenRoute.mockReturnValue(false); - mockGetIsNarrowLayout.mockReturnValue(true); - mockGetFocusedReportId.mockReturnValue('iou-1'); - - // When they press "View" - navigateToCreatedExpense({ - threadReportID: 'thread-1', - transactionID: 'txn-1', - iouReportID: 'iou-1', - reportTransactions: [buildTransaction('txn-1'), buildTransaction('txn-2')], - }); - await waitForBatchedUpdates(); - - // Then the transaction thread still opens, since the list does not show the expense itself - expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute('thread-1', undefined, undefined, ''), {forceReplace: false}); - }); - - it('should still navigate when the focused report is a different one', async () => { - // Given the user is viewing some other report - mockIsReportTopmostSplitNavigator.mockReturnValue(true); - mockIsSearchTopmostFullScreenRoute.mockReturnValue(false); - mockGetIsNarrowLayout.mockReturnValue(true); - mockGetFocusedReportId.mockReturnValue('some-other-report'); - - // When they press "View" - navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1', reportTransactions: []}); - await waitForBatchedUpdates(); - - // Then the transaction thread still opens - expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute('thread-1', undefined, undefined, ''), {forceReplace: false}); - }); - - it('should open the transaction thread in the Spend RHP when the user is on the Spend tab', async () => { - // Given the user is on the Spend tab - mockIsReportTopmostSplitNavigator.mockReturnValue(false); - mockIsSearchTopmostFullScreenRoute.mockReturnValue(true); - - // When they open a newly-created expense - navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1', reportTransactions: []}); - await waitForBatchedUpdates(); - - // Then the transaction thread opens in the Spend RHP - expect(Navigation.navigate).toHaveBeenCalledTimes(1); - expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SEARCH_REPORT.getRoute({reportID: 'thread-1', backTo: ''}), {forceReplace: false}); - }); - - it('should replace the currently-open report instead of stacking when one is already open in the RHP', async () => { - // Given a report is already open in the RHP - mockIsReportTopmostSplitNavigator.mockReturnValue(false); - mockIsSearchTopmostFullScreenRoute.mockReturnValue(true); - mockIsReportOpenInRHP.mockReturnValue(true); - - // When the user opens a newly-created expense - navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1', reportTransactions: []}); - await waitForBatchedUpdates(); - - // Then the open report is replaced rather than stacked on - expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SEARCH_REPORT.getRoute({reportID: 'thread-1', backTo: ''}), {forceReplace: true}); - }); - - it('should open the transaction thread as a full report when the user is on the Inbox tab on a narrow layout', () => { - // Given the user is on the Inbox tab on a narrow layout - mockIsReportTopmostSplitNavigator.mockReturnValue(true); - mockIsSearchTopmostFullScreenRoute.mockReturnValue(false); - mockGetIsNarrowLayout.mockReturnValue(true); - - // When they open a newly-created expense - navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1', reportTransactions: []}); - - // Then the transaction thread opens as a full report - expect(Navigation.navigate).toHaveBeenCalledTimes(1); - expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute('thread-1', undefined, undefined, ''), {forceReplace: false}); - }); - - it('should open the expense report then stack the thread RHP when the user is on the Inbox tab on a wide layout and the report has multiple transactions', async () => { - // Given the user is on the Inbox tab on a wide layout and the expense report holds several transactions - mockIsReportTopmostSplitNavigator.mockReturnValue(true); - mockIsSearchTopmostFullScreenRoute.mockReturnValue(false); - mockGetIsNarrowLayout.mockReturnValue(false); - // When they open a newly-created expense - navigateToCreatedExpense({ - threadReportID: 'thread-1', - transactionID: 'txn-1', - iouReportID: 'iou-1', - reportTransactions: [buildTransaction('txn-1'), buildTransaction('txn-2')], - }); - await waitForBatchedUpdates(); - - // Then the expense report opens with the thread RHP stacked on top of it - expect(Navigation.navigate).toHaveBeenNthCalledWith(1, ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: 'iou-1', backTo: ''}), {forceReplace: false}); - expect(Navigation.navigate).toHaveBeenNthCalledWith(2, ROUTES.SEARCH_REPORT.getRoute({reportID: 'thread-1', backTo: ''})); - }); - - it('should open the expense report without the replaced RHP backTo, so deleting the report falls back to its chat', async () => { - // Given the user is on the Inbox tab on a wide layout with another report already open in the RHP - mockIsReportTopmostSplitNavigator.mockReturnValue(true); - mockIsSearchTopmostFullScreenRoute.mockReturnValue(false); - mockGetIsNarrowLayout.mockReturnValue(false); - mockIsReportOpenInRHP.mockReturnValue(true); - mockGetCurrentRoute.mockReturnValue({params: {backTo: '/home'}}); - - // When they open a newly-created expense - navigateToCreatedExpense({ - threadReportID: 'thread-1', - transactionID: 'txn-1', - iouReportID: 'iou-1', - reportTransactions: [buildTransaction('txn-1'), buildTransaction('txn-2')], - }); - await waitForBatchedUpdates(); - - // Then the expense report opens with no backTo instead of inheriting the replaced RHP's origin - expect(Navigation.navigate).toHaveBeenNthCalledWith(1, ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: 'iou-1'}), {forceReplace: true}); - }); - - it('should open the expense report when the user is on the Inbox tab on a wide layout and the report has a single transaction', () => { - // Given the user is on the Inbox tab on a wide layout and the expense report holds one transaction - mockIsReportTopmostSplitNavigator.mockReturnValue(true); - mockIsSearchTopmostFullScreenRoute.mockReturnValue(false); - mockGetIsNarrowLayout.mockReturnValue(false); - // When they open a newly-created expense - navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1', reportTransactions: [buildTransaction('txn-1')]}); - - // Then only the expense report opens, since it collapses to the thread itself - expect(Navigation.navigate).toHaveBeenCalledTimes(1); - expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: 'iou-1', backTo: ''}), {forceReplace: false}); - }); - - it('should open the transaction thread as a full report when there is no expense report (tracked/unreported self-DM expense) on the Inbox tab', () => { - // Given the user is on the Inbox tab on a wide layout - mockIsReportTopmostSplitNavigator.mockReturnValue(true); - mockIsSearchTopmostFullScreenRoute.mockReturnValue(false); - mockGetIsNarrowLayout.mockReturnValue(false); - - // When they open a newly-created tracked expense, which has no expense report - navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: undefined, reportTransactions: []}); - - // Then the thread opens as a full report, matching how tapping it in its self-DM chat does - expect(Navigation.navigate).toHaveBeenCalledTimes(1); - expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute('thread-1', undefined, undefined, ''), {forceReplace: false}); - }); - - it('should open the transaction thread in the Spend RHP for a tracked/unreported expense when the user is on the Spend tab', async () => { - // Given the user is on the Spend tab - mockIsReportTopmostSplitNavigator.mockReturnValue(false); - mockIsSearchTopmostFullScreenRoute.mockReturnValue(true); - - // When they open a newly-created tracked expense, which has no expense report - navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: undefined, reportTransactions: []}); - await waitForBatchedUpdates(); - - // Then the transaction thread still opens in the Spend RHP - expect(Navigation.navigate).toHaveBeenCalledTimes(1); - expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SEARCH_REPORT.getRoute({reportID: 'thread-1', backTo: ''}), {forceReplace: false}); - }); - }); }); diff --git a/tests/unit/navigateToCreatedExpenseTest.ts b/tests/unit/navigateToCreatedExpenseTest.ts new file mode 100644 index 000000000000..09736ecc3cf6 --- /dev/null +++ b/tests/unit/navigateToCreatedExpenseTest.ts @@ -0,0 +1,246 @@ +import type isReportOpenInRHP from '@libs/Navigation/helpers/isReportOpenInRHP'; +import navigateToCreatedExpense from '@libs/Navigation/helpers/navigateToCreatedExpense'; +import Navigation from '@libs/Navigation/Navigation'; + +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import type {Transaction} from '@src/types/onyx'; + +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +const mockIsReportTopmostSplitNavigator = jest.fn(); +const mockIsSearchTopmostFullScreenRoute = jest.fn(); +const mockIsReportOpenInRHP = jest.fn, Parameters>(); +const mockGetIsNarrowLayout = jest.fn(); +const mockGetCurrentRoute = jest.fn<{params?: Record} | undefined, []>(); +const mockGetFocusedReportId = jest.fn(); + +function buildTransaction(transactionID: string): Transaction { + return {transactionID, reportID: 'iou-1', amount: 0, created: '', currency: CONST.CURRENCY.USD, merchant: '', comment: {}}; +} + +jest.mock('@libs/Navigation/helpers/isReportTopmostSplitNavigator', () => () => mockIsReportTopmostSplitNavigator()); +jest.mock('@libs/Navigation/helpers/isSearchTopmostFullScreenRoute', () => () => mockIsSearchTopmostFullScreenRoute()); +jest.mock('@libs/Navigation/helpers/isReportOpenInRHP', () => (state: Parameters[0]) => mockIsReportOpenInRHP(state)); +jest.mock('@libs/Navigation/helpers/setNavigationActionToMicrotaskQueue', () => (callback: () => void) => { + callback(); +}); +jest.mock('@libs/getIsNarrowLayout', () => () => mockGetIsNarrowLayout()); +jest.mock('@libs/actions/TransactionThreadNavigation', () => ({ + setActiveTransactionIDs: jest.fn(() => Promise.resolve()), +})); + +jest.mock('@libs/Navigation/Navigation', () => ({ + navigate: jest.fn(), + getActiveRoute: jest.fn(() => ''), + getFocusedReportId: () => mockGetFocusedReportId(), + navigationRef: { + getRootState: jest.fn(() => ({ + routes: [], + })), + current: { + getCurrentRoute: () => mockGetCurrentRoute(), + }, + }, +})); + +jest.mock('@react-navigation/native'); + +describe('navigateToCreatedExpense', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockIsReportTopmostSplitNavigator.mockReturnValue(false); + mockIsSearchTopmostFullScreenRoute.mockReturnValue(false); + mockIsReportOpenInRHP.mockReturnValue(false); + mockGetCurrentRoute.mockReturnValue(undefined); + mockGetFocusedReportId.mockReturnValue(undefined); + }); + + it('should do nothing when the user already has the transaction thread open', async () => { + // Given the user opened the expense themselves before pressing "View" + mockIsReportTopmostSplitNavigator.mockReturnValue(true); + mockIsSearchTopmostFullScreenRoute.mockReturnValue(false); + mockGetIsNarrowLayout.mockReturnValue(true); + mockGetFocusedReportId.mockReturnValue('thread-1'); + + // When they press "View" + navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1', reportTransactions: []}); + await waitForBatchedUpdates(); + + // Then no navigation happens, so the report is not pushed a second time + expect(Navigation.navigate).not.toHaveBeenCalled(); + }); + + it('should do nothing when the user already has the collapsed expense report open', async () => { + // Given the user opened the single-transaction expense report, which renders the thread itself + mockIsReportTopmostSplitNavigator.mockReturnValue(true); + mockIsSearchTopmostFullScreenRoute.mockReturnValue(false); + mockGetIsNarrowLayout.mockReturnValue(true); + mockGetFocusedReportId.mockReturnValue('iou-1'); + + // When they press "View" + navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1', reportTransactions: [buildTransaction('txn-1')]}); + await waitForBatchedUpdates(); + + // Then no navigation happens, so the same expense is not opened a second time + expect(Navigation.navigate).not.toHaveBeenCalled(); + }); + + it('should still navigate when the focused expense report lists several transactions', async () => { + // Given the user is on an expense report holding more than one expense, so it shows a list rather than the thread + mockIsReportTopmostSplitNavigator.mockReturnValue(true); + mockIsSearchTopmostFullScreenRoute.mockReturnValue(false); + mockGetIsNarrowLayout.mockReturnValue(true); + mockGetFocusedReportId.mockReturnValue('iou-1'); + + // When they press "View" + navigateToCreatedExpense({ + threadReportID: 'thread-1', + transactionID: 'txn-1', + iouReportID: 'iou-1', + reportTransactions: [buildTransaction('txn-1'), buildTransaction('txn-2')], + }); + await waitForBatchedUpdates(); + + // Then the transaction thread still opens, since the list does not show the expense itself + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute('thread-1', undefined, undefined, ''), {forceReplace: false}); + }); + + it('should still navigate when the focused report is a different one', async () => { + // Given the user is viewing some other report + mockIsReportTopmostSplitNavigator.mockReturnValue(true); + mockIsSearchTopmostFullScreenRoute.mockReturnValue(false); + mockGetIsNarrowLayout.mockReturnValue(true); + mockGetFocusedReportId.mockReturnValue('some-other-report'); + + // When they press "View" + navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1', reportTransactions: []}); + await waitForBatchedUpdates(); + + // Then the transaction thread still opens + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute('thread-1', undefined, undefined, ''), {forceReplace: false}); + }); + + it('should open the transaction thread in the Spend RHP when the user is on the Spend tab', async () => { + // Given the user is on the Spend tab + mockIsReportTopmostSplitNavigator.mockReturnValue(false); + mockIsSearchTopmostFullScreenRoute.mockReturnValue(true); + + // When they open a newly-created expense + navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1', reportTransactions: []}); + await waitForBatchedUpdates(); + + // Then the transaction thread opens in the Spend RHP + expect(Navigation.navigate).toHaveBeenCalledTimes(1); + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SEARCH_REPORT.getRoute({reportID: 'thread-1', backTo: ''}), {forceReplace: false}); + }); + + it('should replace the currently-open report instead of stacking when one is already open in the RHP', async () => { + // Given a report is already open in the RHP + mockIsReportTopmostSplitNavigator.mockReturnValue(false); + mockIsSearchTopmostFullScreenRoute.mockReturnValue(true); + mockIsReportOpenInRHP.mockReturnValue(true); + + // When the user opens a newly-created expense + navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1', reportTransactions: []}); + await waitForBatchedUpdates(); + + // Then the open report is replaced rather than stacked on + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SEARCH_REPORT.getRoute({reportID: 'thread-1', backTo: ''}), {forceReplace: true}); + }); + + it('should open the transaction thread as a full report when the user is on the Inbox tab on a narrow layout', () => { + // Given the user is on the Inbox tab on a narrow layout + mockIsReportTopmostSplitNavigator.mockReturnValue(true); + mockIsSearchTopmostFullScreenRoute.mockReturnValue(false); + mockGetIsNarrowLayout.mockReturnValue(true); + + // When they open a newly-created expense + navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1', reportTransactions: []}); + + // Then the transaction thread opens as a full report + expect(Navigation.navigate).toHaveBeenCalledTimes(1); + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute('thread-1', undefined, undefined, ''), {forceReplace: false}); + }); + + it('should open the expense report then stack the thread RHP when the user is on the Inbox tab on a wide layout and the report has multiple transactions', async () => { + // Given the user is on the Inbox tab on a wide layout and the expense report holds several transactions + mockIsReportTopmostSplitNavigator.mockReturnValue(true); + mockIsSearchTopmostFullScreenRoute.mockReturnValue(false); + mockGetIsNarrowLayout.mockReturnValue(false); + // When they open a newly-created expense + navigateToCreatedExpense({ + threadReportID: 'thread-1', + transactionID: 'txn-1', + iouReportID: 'iou-1', + reportTransactions: [buildTransaction('txn-1'), buildTransaction('txn-2')], + }); + await waitForBatchedUpdates(); + + // Then the expense report opens with the thread RHP stacked on top of it + expect(Navigation.navigate).toHaveBeenNthCalledWith(1, ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: 'iou-1', backTo: ''}), {forceReplace: false}); + expect(Navigation.navigate).toHaveBeenNthCalledWith(2, ROUTES.SEARCH_REPORT.getRoute({reportID: 'thread-1', backTo: ''})); + }); + + it('should open the expense report without the replaced RHP backTo, so deleting the report falls back to its chat', async () => { + // Given the user is on the Inbox tab on a wide layout with another report already open in the RHP + mockIsReportTopmostSplitNavigator.mockReturnValue(true); + mockIsSearchTopmostFullScreenRoute.mockReturnValue(false); + mockGetIsNarrowLayout.mockReturnValue(false); + mockIsReportOpenInRHP.mockReturnValue(true); + mockGetCurrentRoute.mockReturnValue({params: {backTo: '/home'}}); + + // When they open a newly-created expense + navigateToCreatedExpense({ + threadReportID: 'thread-1', + transactionID: 'txn-1', + iouReportID: 'iou-1', + reportTransactions: [buildTransaction('txn-1'), buildTransaction('txn-2')], + }); + await waitForBatchedUpdates(); + + // Then the expense report opens with no backTo instead of inheriting the replaced RHP's origin + expect(Navigation.navigate).toHaveBeenNthCalledWith(1, ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: 'iou-1'}), {forceReplace: true}); + }); + + it('should open the expense report when the user is on the Inbox tab on a wide layout and the report has a single transaction', () => { + // Given the user is on the Inbox tab on a wide layout and the expense report holds one transaction + mockIsReportTopmostSplitNavigator.mockReturnValue(true); + mockIsSearchTopmostFullScreenRoute.mockReturnValue(false); + mockGetIsNarrowLayout.mockReturnValue(false); + // When they open a newly-created expense + navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1', reportTransactions: [buildTransaction('txn-1')]}); + + // Then only the expense report opens, since it collapses to the thread itself + expect(Navigation.navigate).toHaveBeenCalledTimes(1); + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: 'iou-1', backTo: ''}), {forceReplace: false}); + }); + + it('should open the transaction thread as a full report when there is no expense report (tracked/unreported self-DM expense) on the Inbox tab', () => { + // Given the user is on the Inbox tab on a wide layout + mockIsReportTopmostSplitNavigator.mockReturnValue(true); + mockIsSearchTopmostFullScreenRoute.mockReturnValue(false); + mockGetIsNarrowLayout.mockReturnValue(false); + + // When they open a newly-created tracked expense, which has no expense report + navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: undefined, reportTransactions: []}); + + // Then the thread opens as a full report, matching how tapping it in its self-DM chat does + expect(Navigation.navigate).toHaveBeenCalledTimes(1); + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute('thread-1', undefined, undefined, ''), {forceReplace: false}); + }); + + it('should open the transaction thread in the Spend RHP for a tracked/unreported expense when the user is on the Spend tab', async () => { + // Given the user is on the Spend tab + mockIsReportTopmostSplitNavigator.mockReturnValue(false); + mockIsSearchTopmostFullScreenRoute.mockReturnValue(true); + + // When they open a newly-created tracked expense, which has no expense report + navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: undefined, reportTransactions: []}); + await waitForBatchedUpdates(); + + // Then the transaction thread still opens in the Spend RHP + expect(Navigation.navigate).toHaveBeenCalledTimes(1); + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SEARCH_REPORT.getRoute({reportID: 'thread-1', backTo: ''}), {forceReplace: false}); + }); +}); \ No newline at end of file From 63cde1b12bdb423b646fd243f663977fd8487841 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Wed, 9 Sep 2026 20:47:50 +0200 Subject: [PATCH 15/28] Drop broder-radius:0 in TransactionListItemNarrow wrapper style --- .../ListItem/TransactionListItem/TransactionListItemNarrow.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemNarrow.tsx b/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemNarrow.tsx index 282c5bb87f89..03720419505b 100644 --- a/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemNarrow.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemNarrow.tsx @@ -101,7 +101,6 @@ function TransactionListItemNarrow({ styles.mh5, styles.flex1, StyleUtils.getSearchRowBackgroundStyle(isSelected), - styles.br0, styles.userSelectNone, isFirstItem && styles.tableTopRadius, isLastItem && styles.tableBottomRadius, From e290ed3b4e9fd94071562e01a44d32c30b262843 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Wed, 9 Sep 2026 20:55:55 +0200 Subject: [PATCH 16/28] Make EXPENSE_ADDED_GROWL_TRANSACTION_IDS onyx key ram only --- src/ONYXKEYS.ts | 4 ++-- src/components/ExpenseAddedGrowl.tsx | 2 +- src/libs/ExportOnyxState/common.ts | 2 +- src/libs/actions/Transaction.ts | 2 +- src/setup/index.ts | 4 +--- tests/actions/IOU/PerDiemTest.ts | 4 ++-- tests/actions/IOU/SplitReportTotalsTest.ts | 2 +- tests/actions/IOUTest/SplitTest.ts | 2 +- tests/unit/ExpenseAddedGrowlTest.tsx | 10 +++++----- 9 files changed, 15 insertions(+), 17 deletions(-) diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 6133c218d0c1..09f8517c93bc 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -865,7 +865,7 @@ const ONYXKEYS = { HAS_DENIED_CONTACT_IMPORT_PROMPT: 'hasDeniedContactImportPrompt', /** Maps each newly-added transaction ID to its search data type, flagging it for the "Expense added" growl */ - EXPENSE_ADDED_GROWL_TRANSACTION_IDS: 'expenseAddedGrowlTransactionIDs', + RAM_ONLY_EXPENSE_ADDED_GROWL_TRANSACTION_IDS: 'expenseAddedGrowlTransactionIDs', /** The report ID to be highlighted when returning to the workspace rooms page */ ROOM_ID_HIGHLIGHT_ON_ROOMS_PAGE: 'roomIDHighlightOnRoomsPage', @@ -1853,7 +1853,7 @@ type OnyxValuesMapping = { [ONYXKEYS.NVP_REPORT_DETAILS_COLUMNS]: string[]; [ONYXKEYS.HAS_DENIED_CONTACT_IMPORT_PROMPT]: boolean | undefined; [ONYXKEYS.PERSONAL_POLICY_ID]: string; - [ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS]: Record; + [ONYXKEYS.RAM_ONLY_EXPENSE_ADDED_GROWL_TRANSACTION_IDS]: Record; [ONYXKEYS.ROOM_ID_HIGHLIGHT_ON_ROOMS_PAGE]: string | null; [ONYXKEYS.DOMAIN_GROUP_CREATE_PREFERRED_POLICY_ID]: string | undefined; }; diff --git a/src/components/ExpenseAddedGrowl.tsx b/src/components/ExpenseAddedGrowl.tsx index 33ceb2c391c1..2d1920be372d 100644 --- a/src/components/ExpenseAddedGrowl.tsx +++ b/src/components/ExpenseAddedGrowl.tsx @@ -49,7 +49,7 @@ type ExpenseAddedGrowlContentProps = { /** Watches the "an expense was just added" Onyx signal and shows an "Expense added" growl with a "View" action. */ function ExpenseAddedGrowl() { const [active, setActive] = useState(null); - const [signal] = useOnyx(ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS); + const [signal] = useOnyx(ONYXKEYS.RAM_ONLY_EXPENSE_ADDED_GROWL_TRANSACTION_IDS); const transactionID = active?.transactionID ?? Object.keys(signal ?? {}).at(-1); if (!transactionID) { diff --git a/src/libs/ExportOnyxState/common.ts b/src/libs/ExportOnyxState/common.ts index d7509e1e8a02..a7fa85cc1ee1 100644 --- a/src/libs/ExportOnyxState/common.ts +++ b/src/libs/ExportOnyxState/common.ts @@ -212,7 +212,7 @@ const safeOnyxKeys = new Set([ ONYXKEYS.CURRENCY_LIST, ONYXKEYS.CURRENT_DATE, ONYXKEYS.DOMAIN_GROUP_CREATE_PREFERRED_POLICY_ID, - ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS, + ONYXKEYS.RAM_ONLY_EXPENSE_ADDED_GROWL_TRANSACTION_IDS, ONYXKEYS.EXPENSIFY_CARD_STATEMENT, ONYXKEYS.FULLSCREEN_VISIBILITY, ONYXKEYS.GOVERNMENT_MILEAGE_RATES, diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts index c2b1c7074a3e..a2152edd34c6 100644 --- a/src/libs/actions/Transaction.ts +++ b/src/libs/actions/Transaction.ts @@ -2038,7 +2038,7 @@ function getDefaultP2PMileageRate() { } function mergeExpenseAddedGrowlTransactionIDs(data: Record) { - return Onyx.merge(ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS, data); + return Onyx.merge(ONYXKEYS.RAM_ONLY_EXPENSE_ADDED_GROWL_TRANSACTION_IDS, data); } function getDuplicateTransactionDetails(transactionID?: string) { diff --git a/src/setup/index.ts b/src/setup/index.ts index 65bacf20a357..6ff0c30fdd50 100644 --- a/src/setup/index.ts +++ b/src/setup/index.ts @@ -61,13 +61,11 @@ export default function () { // Ensure the Supportal permission modal doesn't persist across reloads [ONYXKEYS.SUPPORTAL_PERMISSION_DENIED]: null, [ONYXKEYS.IS_OPEN_APP_FAILURE_MODAL_OPEN]: false, - // The "Expense added" growl confirms an expense created this session. Drop any signal left over - // from a prior session (e.g. force-quit before it was consumed) so it can't surface on next launch. - [ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS]: {}, }, skippableCollectionMemberIDs: CONST.SKIPPABLE_COLLECTION_MEMBER_IDS, snapshotMergeKeys: ['pendingAction', 'pendingFields'], ramOnlyKeys: [ + ONYXKEYS.RAM_ONLY_EXPENSE_ADDED_GROWL_TRANSACTION_IDS, ONYXKEYS.RAM_ONLY_ARE_TRANSLATIONS_LOADING, ONYXKEYS.RAM_ONLY_MOBILE_SELECTION_MODE, ONYXKEYS.RAM_ONLY_IS_SIDEBAR_LOADED, diff --git a/tests/actions/IOU/PerDiemTest.ts b/tests/actions/IOU/PerDiemTest.ts index bd1016060582..01f01d16ad2f 100644 --- a/tests/actions/IOU/PerDiemTest.ts +++ b/tests/actions/IOU/PerDiemTest.ts @@ -1205,7 +1205,7 @@ describe('PerDiem', () => { await waitForBatchedUpdates(); // Then the new transaction is queued for the "Expense added" growl - const signal = await getOnyxValue(ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS); + const signal = await getOnyxValue(ONYXKEYS.RAM_ONLY_EXPENSE_ADDED_GROWL_TRANSACTION_IDS); expect(Object.values(signal ?? {})).toEqual([CONST.SEARCH.DATA_TYPES.EXPENSE]); }); @@ -1216,7 +1216,7 @@ describe('PerDiem', () => { await waitForBatchedUpdates(); // Then nothing is queued for the growl - const signal = await getOnyxValue(ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS); + const signal = await getOnyxValue(ONYXKEYS.RAM_ONLY_EXPENSE_ADDED_GROWL_TRANSACTION_IDS); expect(signal).toBeUndefined(); }); }); diff --git a/tests/actions/IOU/SplitReportTotalsTest.ts b/tests/actions/IOU/SplitReportTotalsTest.ts index 0cd68e66ca7f..80e509c59a91 100644 --- a/tests/actions/IOU/SplitReportTotalsTest.ts +++ b/tests/actions/IOU/SplitReportTotalsTest.ts @@ -916,7 +916,7 @@ describe('actions/IOU', () => { updateSplitTransactionsFromSplitExpensesFlow(params); await waitForBatchedUpdates(); - const growlTransactionIDs = await getOnyxValue(ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS); + const growlTransactionIDs = await getOnyxValue(ONYXKEYS.RAM_ONLY_EXPENSE_ADDED_GROWL_TRANSACTION_IDS); expect(growlTransactionIDs?.['new-merged-tx']).toBeUndefined(); }); }); diff --git a/tests/actions/IOUTest/SplitTest.ts b/tests/actions/IOUTest/SplitTest.ts index 418fa6f2dbe8..51af91005955 100644 --- a/tests/actions/IOUTest/SplitTest.ts +++ b/tests/actions/IOUTest/SplitTest.ts @@ -9825,7 +9825,7 @@ describe('createDistanceRequest', () => { await waitForBatchedUpdates(); - const growlSignal = await getOnyxValue(ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS); + const growlSignal = await getOnyxValue(ONYXKEYS.RAM_ONLY_EXPENSE_ADDED_GROWL_TRANSACTION_IDS); expect(growlSignal?.[result.transactionID]).toBe(CONST.SEARCH.DATA_TYPES.EXPENSE); }); diff --git a/tests/unit/ExpenseAddedGrowlTest.tsx b/tests/unit/ExpenseAddedGrowlTest.tsx index 13370dadf55d..9ddfa9cee53a 100644 --- a/tests/unit/ExpenseAddedGrowlTest.tsx +++ b/tests/unit/ExpenseAddedGrowlTest.tsx @@ -59,7 +59,7 @@ function flush(mutate: () => unknown) { function seedExpense(transactionID: string, reportID: string, dataType: SearchDataTypes = EXPENSE) { return flush(async () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {transactionID, reportID}); - await Onyx.merge(ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS, {[transactionID]: dataType}); + await Onyx.merge(ONYXKEYS.RAM_ONLY_EXPENSE_ADDED_GROWL_TRANSACTION_IDS, {[transactionID]: dataType}); }); } @@ -154,7 +154,7 @@ describe('ExpenseAddedGrowl', () => { const signal: Record = {}; signal['1'] = EXPENSE; signal['2'] = EXPENSE; - await Onyx.merge(ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS, signal); + await Onyx.merge(ONYXKEYS.RAM_ONLY_EXPENSE_ADDED_GROWL_TRANSACTION_IDS, signal); }); // Then exactly one growl shows for the batch @@ -163,7 +163,7 @@ describe('ExpenseAddedGrowl', () => { // And the whole signal is consumed so it can't re-fire const remaining = await new Promise | undefined>((resolve) => { const connection = Onyx.connect({ - key: ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS, + key: ONYXKEYS.RAM_ONLY_EXPENSE_ADDED_GROWL_TRANSACTION_IDS, callback: (value) => { Onyx.disconnect(connection); resolve(value); @@ -211,7 +211,7 @@ describe('ExpenseAddedGrowl', () => { }); const signal: Record = {}; signal['1'] = EXPENSE; - await Onyx.merge(ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS, signal); + await Onyx.merge(ONYXKEYS.RAM_ONLY_EXPENSE_ADDED_GROWL_TRANSACTION_IDS, signal); }); // When View is pressed @@ -236,7 +236,7 @@ describe('ExpenseAddedGrowl', () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {[actionR14932.reportActionID]: actionR14932}); const signal: Record = {}; signal[transactionID] = EXPENSE; - await Onyx.merge(ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS, signal); + await Onyx.merge(ONYXKEYS.RAM_ONLY_EXPENSE_ADDED_GROWL_TRANSACTION_IDS, signal); }); // When View is pressed From 8089abf84bf277892993b48c1196942d7ad518fd Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Wed, 9 Sep 2026 21:07:55 +0200 Subject: [PATCH 17/28] Fix after merge --- tests/actions/IOU/SplitReportTotalsTest.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/actions/IOU/SplitReportTotalsTest.ts b/tests/actions/IOU/SplitReportTotalsTest.ts index 8d81ce86f10e..cdb7948c14ea 100644 --- a/tests/actions/IOU/SplitReportTotalsTest.ts +++ b/tests/actions/IOU/SplitReportTotalsTest.ts @@ -753,6 +753,11 @@ describe('actions/IOU', () => { }; } + /** The rail keys each flag by instance, so a test asks which transactions are flagged rather than indexing the record by ID. */ + function getFlaggedTransactionIDs(pendingNewTransactionIDs: Record | undefined) { + return Object.keys(pendingNewTransactionIDs ?? {}).map((flagKey) => parsePendingNewTransactionFlagKey(flagKey)?.transactionID); + } + function getPendingNewTransactionIDsFromOnyx(reportID: string) { return new Promise | undefined>((resolve) => { const connection = Onyx.connect({ From c01860cd0c20be318de02bd9184d19586f699f44 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Wed, 9 Sep 2026 21:09:57 +0200 Subject: [PATCH 18/28] Fix test --- src/libs/ExportOnyxState/common.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/ExportOnyxState/common.ts b/src/libs/ExportOnyxState/common.ts index 6697f0cbafe1..92f095289fd0 100644 --- a/src/libs/ExportOnyxState/common.ts +++ b/src/libs/ExportOnyxState/common.ts @@ -220,7 +220,6 @@ const safeOnyxKeys = new Set([ ONYXKEYS.CURRENCY_LIST, ONYXKEYS.CURRENT_DATE, ONYXKEYS.DOMAIN_GROUP_CREATE_PREFERRED_POLICY_ID, - ONYXKEYS.RAM_ONLY_EXPENSE_ADDED_GROWL_TRANSACTION_IDS, ONYXKEYS.EXPENSIFY_CARD_STATEMENT, ONYXKEYS.FULLSCREEN_VISIBILITY, ONYXKEYS.GOVERNMENT_MILEAGE_RATES, @@ -326,6 +325,7 @@ const safeOnyxKeys = new Set([ ONYXKEYS.PREFERRED_THEME, ONYXKEYS.RAM_ONLY_ARE_TRANSLATIONS_LOADING, ONYXKEYS.RAM_ONLY_CORPAY_PAY_MODAL, + ONYXKEYS.RAM_ONLY_EXPENSE_ADDED_GROWL_TRANSACTION_IDS, ONYXKEYS.RAM_ONLY_IS_AUTHENTICATING_WITH_SHORT_LIVED_TOKEN, ONYXKEYS.RAM_ONLY_IS_CHECKING_PUBLIC_ROOM, ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS, From b72298dbe78fb948815d401137078c3342544cbe Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Wed, 9 Sep 2026 21:11:48 +0200 Subject: [PATCH 19/28] Fix formatting --- tests/unit/navigateToCreatedExpenseTest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/navigateToCreatedExpenseTest.ts b/tests/unit/navigateToCreatedExpenseTest.ts index 09736ecc3cf6..edab7780a807 100644 --- a/tests/unit/navigateToCreatedExpenseTest.ts +++ b/tests/unit/navigateToCreatedExpenseTest.ts @@ -243,4 +243,4 @@ describe('navigateToCreatedExpense', () => { expect(Navigation.navigate).toHaveBeenCalledTimes(1); expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SEARCH_REPORT.getRoute({reportID: 'thread-1', backTo: ''}), {forceReplace: false}); }); -}); \ No newline at end of file +}); From 576165ce52aee265ad6833976c014b18371b8c43 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Thu, 10 Sep 2026 09:45:03 +0200 Subject: [PATCH 20/28] Clear pending growl in dismissModalAndOpenReportInInboxTab when navigating to multiexpense report view to not risk race conditions --- .../dismissModalAndOpenReportInInboxTab.ts | 8 ++++- .../helpers/navigateAfterExpenseCreate.ts | 2 +- ...dismissModalAndOpenReportInInboxTabTest.ts | 34 ++++++++++++++++++- 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/libs/Navigation/helpers/dismissModalAndOpenReportInInboxTab.ts b/src/libs/Navigation/helpers/dismissModalAndOpenReportInInboxTab.ts index 8edd6860c800..8074ab9c774d 100644 --- a/src/libs/Navigation/helpers/dismissModalAndOpenReportInInboxTab.ts +++ b/src/libs/Navigation/helpers/dismissModalAndOpenReportInInboxTab.ts @@ -1,3 +1,4 @@ +import {mergeExpenseAddedGrowlTransactionIDs} from '@libs/actions/Transaction'; import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; import {endSubmitFollowUpActionSpan, isTracking as isSubmitTracking, setPendingSubmitFollowUpAction} from '@libs/telemetry/submitFollowUpAction'; @@ -14,7 +15,7 @@ import setNavigationActionToMicrotaskQueue from './setNavigationActionToMicrotas * After finishing the action in RHP from the Inbox tab, besides dismissing the modal, we should open the report. * If the action is done from the report RHP, then we just want to dismiss the money request flow screens. */ -function dismissModalAndOpenReportInInboxTab(reportID: string | undefined, isInvoice: boolean | undefined, hasMultipleTransactions: boolean) { +function dismissModalAndOpenReportInInboxTab(reportID: string | undefined, isInvoice: boolean | undefined, hasMultipleTransactions: boolean, transactionID?: string) { const rootState = navigationRef.getRootState(); const hasActiveTracking = isSubmitTracking(); @@ -40,6 +41,11 @@ function dismissModalAndOpenReportInInboxTab(reportID: string | undefined, isInv } // When a report with one expense is opened in the wide RHP and the user adds another expense, RHP should be dismissed and ROUTES.SEARCH_MONEY_REQUEST_REPORT should be displayed. if (hasMultipleTransactions && reportID) { + // This navigation opens the expense report that contains the new transaction, so consume its pending growl + // before the deferred navigation. Inferring the destination from navigation state races the queued action below. + if (transactionID) { + mergeExpenseAddedGrowlTransactionIDs({[transactionID]: null}); + } // On small screens, dismiss all modals and then navigate to the right report. // On large screens, dismiss to the previous RHP first, then replace the current route with the new report. const isNarrowLayout = getIsNarrowLayout(); diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts index aff27a47bbda..18c8344c0086 100644 --- a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts @@ -88,7 +88,7 @@ function navigateAfterExpenseCreate({ // and open the report chat containing the IOU report if (!isFromGlobalCreate || isUserOnInbox || !transactionID) { if (shouldNavigate) { - dismissModalAndOpenReportInInboxTab(activeReportID, isInvoice, hasMultipleTransactions); + dismissModalAndOpenReportInInboxTab(activeReportID, isInvoice, hasMultipleTransactions, transactionID); } if (shouldAddPendingNewTransactionIDs) { addPendingNewTransactionIDs(activeReportID, transactionID); diff --git a/tests/unit/dismissModalAndOpenReportInInboxTabTest.ts b/tests/unit/dismissModalAndOpenReportInInboxTabTest.ts index 06800964c755..32befce4bec5 100644 --- a/tests/unit/dismissModalAndOpenReportInInboxTabTest.ts +++ b/tests/unit/dismissModalAndOpenReportInInboxTabTest.ts @@ -1,3 +1,4 @@ +import {mergeExpenseAddedGrowlTransactionIDs} from '@libs/actions/Transaction'; import dismissModalAndOpenReportInInboxTab from '@libs/Navigation/helpers/dismissModalAndOpenReportInInboxTab'; import type isReportOpenInRHP from '@libs/Navigation/helpers/isReportOpenInRHP'; import Navigation from '@libs/Navigation/Navigation'; @@ -17,6 +18,9 @@ jest.mock('@libs/Navigation/helpers/isReportOpenInSuperWideRHP', () => () => fal jest.mock('@libs/Navigation/helpers/setNavigationActionToMicrotaskQueue', () => (callback: () => void) => { callback(); }); +jest.mock('@libs/actions/Transaction', () => ({ + mergeExpenseAddedGrowlTransactionIDs: jest.fn(), +})); jest.mock('@libs/getIsNarrowLayout', () => () => false); jest.mock('@libs/telemetry/submitFollowUpAction', () => ({ isTracking: () => mockGetTrackingState(), @@ -32,7 +36,7 @@ jest.mock('@libs/Navigation/Navigation', () => ({ navigate: jest.fn(), navigationRef: { getRootState: jest.fn(() => ({ - routes: [], + routes: [{state: {key: 'rhp-key'}}], })), }, })); @@ -77,4 +81,32 @@ describe('dismissModalAndOpenReportInInboxTab', () => { expect(Navigation.dismissToPreviousRHP).not.toHaveBeenCalled(); expect(Navigation.dismissModalWithReport).toHaveBeenCalledWith({reportID}); }); + + it('should remove the pending growl before navigating to a multi-expense report', () => { + // Given a new transaction will be shown in a multi-expense report + const reportID = 'report-123'; + const transactionID = 'transaction-123'; + mockIsReportOpenInRHP.mockReturnValue(true); + + // When the modal is dismissed and the expense report navigation is queued + dismissModalAndOpenReportInInboxTab(reportID, undefined, true, transactionID); + + // Then the pending growl is consumed before navigating to the expense report + expect(mergeExpenseAddedGrowlTransactionIDs).toHaveBeenCalledWith({ + [transactionID]: null, + }); + expect(Navigation.navigate).toHaveBeenCalled(); + }); + + it('should keep the pending growl when navigating to an Inbox chat', () => { + // Given a new transaction will be shown in an Inbox chat rather than a multi-expense report + const reportID = 'report-123'; + const transactionID = 'transaction-123'; + + // When the modal is dismissed and the chat is opened + dismissModalAndOpenReportInInboxTab(reportID, undefined, false, transactionID); + + // Then the pending growl is kept + expect(mergeExpenseAddedGrowlTransactionIDs).not.toHaveBeenCalled(); + }); }); From df793074b5a48211b33caf83823a7d8be83169cc Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Thu, 10 Sep 2026 10:18:53 +0200 Subject: [PATCH 21/28] Ensure growl for split and duplicate shows up in Spend only --- src/libs/actions/IOU/Duplicate.ts | 5 +- .../actions/IOU/SplitTransactionUpdate.ts | 2 +- tests/actions/IOU/SplitReportTotalsTest.ts | 52 +++++++++++++++++++ tests/actions/IOUTest/DuplicateTest.ts | 19 ++++++- 4 files changed, 75 insertions(+), 3 deletions(-) diff --git a/src/libs/actions/IOU/Duplicate.ts b/src/libs/actions/IOU/Duplicate.ts index 3384e1f14c4e..1372bc944e8d 100644 --- a/src/libs/actions/IOU/Duplicate.ts +++ b/src/libs/actions/IOU/Duplicate.ts @@ -10,6 +10,7 @@ import {WRITE_COMMANDS} from '@libs/API/types'; import DateUtils from '@libs/DateUtils'; import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; import {getExistingTransactionID} from '@libs/IOUUtils'; +import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; import * as NumberUtils from '@libs/NumberUtils'; import Parser from '@libs/Parser'; import {isInstantSubmitEnabled, isPolicyAccessible, isSubmitAndClose} from '@libs/PolicyUtils'; @@ -1346,7 +1347,9 @@ function bulkDuplicateExpenses({ } playSound(SOUNDS.DONE); - signalExpenseAddedGrowl(lastDuplicateTransactionID, CONST.SEARCH.DATA_TYPES.EXPENSE); + if (isSearchTopmostFullScreenRoute()) { + signalExpenseAddedGrowl(lastDuplicateTransactionID, CONST.SEARCH.DATA_TYPES.EXPENSE); + } } type BulkDuplicateReportsParams = { diff --git a/src/libs/actions/IOU/SplitTransactionUpdate.ts b/src/libs/actions/IOU/SplitTransactionUpdate.ts index 751299fc9247..66adbf69d890 100644 --- a/src/libs/actions/IOU/SplitTransactionUpdate.ts +++ b/src/libs/actions/IOU/SplitTransactionUpdate.ts @@ -2109,7 +2109,7 @@ function updateSplitTransactionsFromSplitExpensesFlow(params: UpdateSplitTransac // Returns to Search, not the expense report, so rail flags would sit unconsumed and highlight stale rows the // next time that report is opened from the Inbox. updateSplitTransactions({...params, isFromSplitExpensesFlow: true, shouldSkipReportHighlightRail: true}); - if (!isReverseSplitOperation) { + if (isSearchPageTopmostFullScreenRoute && !isReverseSplitOperation) { signalExpenseAddedGrowl(getNewSplitTransactionIDs().at(-1), CONST.SEARCH.DATA_TYPES.EXPENSE); } diff --git a/tests/actions/IOU/SplitReportTotalsTest.ts b/tests/actions/IOU/SplitReportTotalsTest.ts index cdb7948c14ea..c82e9ce8e4bf 100644 --- a/tests/actions/IOU/SplitReportTotalsTest.ts +++ b/tests/actions/IOU/SplitReportTotalsTest.ts @@ -897,6 +897,7 @@ describe('actions/IOU', () => { }); it('does not signal the "Expense added" growl during a reverse split operation', async () => { + // Given an existing split is being reversed from the expense report const existingChildTx = { transactionID: 'child-tx-1', reportID: EXPENSE_REPORT_ID, @@ -912,11 +913,62 @@ describe('actions/IOU', () => { }, }); + // When saving the reverse split updateSplitTransactionsFromSplitExpensesFlow(params); await waitForBatchedUpdates(); + // Then no expense-added growl is queued const growlTransactionIDs = await getOnyxValue(ONYXKEYS.RAM_ONLY_EXPENSE_ADDED_GROWL_TRANSACTION_IDS); expect(growlTransactionIDs?.['new-merged-tx']).toBeUndefined(); }); + + it('signals the "Expense added" growl when splitting from Spend', async () => { + // Given a transaction is being split from the Search tab + jest.mocked(isSearchTopmostFullScreenRoute).mockReturnValue(true); + const params = buildBaseParams({ + transactionData: { + reportID: EXPENSE_REPORT_ID, + originalTransactionID: ORIGINAL_TX_ID, + splitExpenses: [ + {transactionID: 'new-search-tx-1', reportID: EXPENSE_REPORT_ID, statusNum: 0, amount: 500, created: '2024-01-01'}, + {transactionID: 'new-search-tx-2', reportID: EXPENSE_REPORT_ID, statusNum: 0, amount: 500, created: '2024-01-01'}, + ], + splitExpensesTotal: 1000, + }, + }); + + // When saving the split + updateSplitTransactionsFromSplitExpensesFlow(params); + await waitForBatchedUpdates(); + + // Then the latest new split is queued for the expense-added growl + const growlTransactionIDs = await getOnyxValue(ONYXKEYS.RAM_ONLY_EXPENSE_ADDED_GROWL_TRANSACTION_IDS); + expect(growlTransactionIDs?.['new-search-tx-2']).toBe(CONST.SEARCH.DATA_TYPES.EXPENSE); + }); + + it('does not signal the "Expense added" growl for a report without a parent outside Spend', async () => { + // Given a transaction in an Inbox report without a parent is being split + jest.mocked(isSearchTopmostFullScreenRoute).mockReturnValue(false); + const params = buildBaseParams({ + transactionReport: {reportID: 'report-without-parent'}, + transactionData: { + reportID: EXPENSE_REPORT_ID, + originalTransactionID: ORIGINAL_TX_ID, + splitExpenses: [ + {transactionID: 'new-inbox-tx-1', reportID: EXPENSE_REPORT_ID, statusNum: 0, amount: 500, created: '2024-01-01'}, + {transactionID: 'new-inbox-tx-2', reportID: EXPENSE_REPORT_ID, statusNum: 0, amount: 500, created: '2024-01-01'}, + ], + splitExpensesTotal: 1000, + }, + }); + + // When saving the split + updateSplitTransactionsFromSplitExpensesFlow(params); + await waitForBatchedUpdates(); + + // Then no expense-added growl is queued + const growlTransactionIDs = await getOnyxValue(ONYXKEYS.RAM_ONLY_EXPENSE_ADDED_GROWL_TRANSACTION_IDS); + expect(growlTransactionIDs?.['new-inbox-tx-2']).toBeUndefined(); + }); }); }); diff --git a/tests/actions/IOUTest/DuplicateTest.ts b/tests/actions/IOUTest/DuplicateTest.ts index edb97d92a5ae..430cef49bfb9 100644 --- a/tests/actions/IOUTest/DuplicateTest.ts +++ b/tests/actions/IOUTest/DuplicateTest.ts @@ -4,10 +4,12 @@ import type {RenderAPI} from '@testing-library/react-native'; import {bulkDuplicateExpenses, bulkDuplicateReports, duplicateExpenseTransaction, duplicateReport, mergeDuplicates, resolveDuplicates} from '@libs/actions/IOU/Duplicate'; import type {BulkDuplicateReportsParams, DuplicateReportParams} from '@libs/actions/IOU/Duplicate'; import {getReportPreviewReportAction} from '@libs/actions/IOU/MoneyRequestBuilder'; +import signalExpenseAddedGrowl from '@libs/actions/IOU/signalExpenseAddedGrowl'; import initOnyxDerivedValues from '@libs/actions/OnyxDerived'; import {addComment, openReport} from '@libs/actions/Report'; import type {MergeDuplicatesParams} from '@libs/API/parameters'; import {WRITE_COMMANDS} from '@libs/API/types'; +import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; import Navigation from '@libs/Navigation/Navigation'; import {getLoginsByAccountIDs} from '@libs/PersonalDetailsUtils'; import {getOriginalMessage, getReportAction} from '@libs/ReportActionsUtils'; @@ -41,6 +43,8 @@ import {formatPhoneNumber, getCurrencyDecimalsLocal, getGlobalFetchMock, getOnyx import {isObject} from '../../utils/typeGuards'; import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; +jest.mock('@libs/actions/IOU/signalExpenseAddedGrowl', () => jest.fn()); + const topMostReportID = '23423423'; jest.mock('@src/libs/Navigation/Navigation', () => ({ navigate: jest.fn(), @@ -3509,6 +3513,8 @@ describe('actions/Duplicate', () => { }); it('should create a single IOU report for multiple bulk-duplicated expenses', async () => { + // Given bulk duplication is initiated from Spend + jest.mocked(isSearchTopmostFullScreenRoute).mockReturnValue(true); const tx1: Transaction = { ...createRandomTransaction(1), transactionID: 'bulk_1', @@ -3533,6 +3539,7 @@ describe('actions/Duplicate', () => { [`${ONYXKEYS.COLLECTION.TRANSACTION}bulk_3`]: tx3, }; + // When the selected expenses are duplicated bulkDuplicateExpenses({ dateFnsLocale: undefined, conciergeChat: undefined, @@ -3569,9 +3576,15 @@ describe('actions/Duplicate', () => { const iouReportIDs = new Set(requestMoneyCalls.map((call) => call[1].iouReportID)); expect(iouReportIDs.size).toBe(1); + + // Then the expense-added growl is signaled + expect(signalExpenseAddedGrowl).toHaveBeenCalledTimes(1); + expect(jest.mocked(signalExpenseAddedGrowl).mock.calls.at(0)?.at(1)).toBe(CONST.SEARCH.DATA_TYPES.EXPENSE); }); - it('should not defer auto submit when the last selected expense is unreported', async () => { + it('should not defer auto submit or signal the growl outside Spend when the last selected expense is unreported', async () => { + // Given bulk duplication is initiated outside Spend + jest.mocked(isSearchTopmostFullScreenRoute).mockReturnValue(false); const reportedTransaction: Transaction = { ...createRandomTransaction(1), transactionID: 'bulk_reported', @@ -3590,6 +3603,7 @@ describe('actions/Duplicate', () => { [`${ONYXKEYS.COLLECTION.TRANSACTION}bulk_unreported`]: unreportedTransaction, }; + // When the selected expenses are duplicated bulkDuplicateExpenses({ dateFnsLocale: undefined, conciergeChat: undefined, @@ -3626,6 +3640,9 @@ describe('actions/Duplicate', () => { expect(requestMoneyCalls).toHaveLength(1); expect(trackExpenseCalls).toHaveLength(1); expect(requestMoneyCalls.at(0)?.[1].shouldDeferAutoSubmit).toBeFalsy(); + + // Then the expense-added growl is not signaled + expect(signalExpenseAddedGrowl).not.toHaveBeenCalled(); }); }); From 560dd21b8aa5e8ad3632d8018c1525bae72854f6 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Thu, 10 Sep 2026 14:42:16 +0200 Subject: [PATCH 22/28] Fix after merge --- tests/actions/IOU/SplitReportTotalsTest.ts | 41 +++++++++++++++++++--- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/tests/actions/IOU/SplitReportTotalsTest.ts b/tests/actions/IOU/SplitReportTotalsTest.ts index 64ee458a1d9b..12e6cc236a38 100644 --- a/tests/actions/IOU/SplitReportTotalsTest.ts +++ b/tests/actions/IOU/SplitReportTotalsTest.ts @@ -753,11 +753,6 @@ describe('actions/IOU', () => { }; } - /** The rail keys each flag by instance, so a test asks which transactions are flagged rather than indexing the record by ID. */ - function getFlaggedTransactionIDs(pendingNewTransactionIDs: Record | undefined) { - return Object.keys(pendingNewTransactionIDs ?? {}).map((flagKey) => parsePendingNewTransactionFlagKey(flagKey)?.transactionID); - } - function getPendingNewTransactionIDsFromOnyx(reportID: string) { return new Promise | undefined>((resolve) => { const connection = Onyx.connect({ @@ -976,5 +971,41 @@ describe('actions/IOU', () => { const growlTransactionIDs = await getOnyxValue(ONYXKEYS.RAM_ONLY_EXPENSE_ADDED_GROWL_TRANSACTION_IDS); expect(growlTransactionIDs?.['new-inbox-tx-2']).toBeUndefined(); }); + + it('leaves no highlight flags anywhere when splits move out to a different report', async () => { + // Given splits that leave the expense report entirely (each lands in its own other report). Nothing should + // be flagged on the source report, and the destination reports are never opened by this flow either, so + // no rail entry may be left behind for any of them. + jest.mocked(isSearchTopmostFullScreenRoute).mockReturnValue(false); + const expenseReport = {reportID: EXPENSE_REPORT_ID, type: CONST.REPORT.TYPE.EXPENSE, parentReportID: 'parent-report-1', chatReportID: 'chat-report-1'} as Report; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${EXPENSE_REPORT_ID}`, expenseReport); + const existingTx = {transactionID: 'existing-tx-3', reportID: EXPENSE_REPORT_ID, amount: 1000}; + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}existing-tx-3`, existingTx); + const params = buildBaseParams({ + expenseReport, + allReportsList: {[`${ONYXKEYS.COLLECTION.REPORT}${EXPENSE_REPORT_ID}`]: expenseReport}, + allTransactionsList: {[`${ONYXKEYS.COLLECTION.TRANSACTION}existing-tx-3`]: existingTx}, + transactionData: { + reportID: EXPENSE_REPORT_ID, + originalTransactionID: ORIGINAL_TX_ID, + splitExpenses: [ + {transactionID: 'moved-tx-1', reportID: 'other-report-1', statusNum: 0, amount: 500, created: '2024-01-01'}, + {transactionID: 'moved-tx-2', reportID: 'other-report-2', statusNum: 0, amount: 500, created: '2024-01-01'}, + ], + splitExpensesTotal: 1000, + }, + }); + + // When saving the split + updateSplitTransactionsFromSplitExpensesFlow(params); + await waitForBatchedUpdates(); + + // Then neither the source report nor the destination reports carry stranded highlight flags + const sourceRail = await getPendingNewTransactionIDsFromOnyx(EXPENSE_REPORT_ID); + expect(sourceRail?.['moved-tx-1']).toBeUndefined(); + expect(sourceRail?.['moved-tx-2']).toBeUndefined(); + expect((await getPendingNewTransactionIDsFromOnyx('other-report-1'))?.['moved-tx-1']).toBeUndefined(); + expect((await getPendingNewTransactionIDsFromOnyx('other-report-2'))?.['moved-tx-2']).toBeUndefined(); + }); }); }); From 7a33bee7b3d7fe13229638d13856b38a0ef3b1bf Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Wed, 16 Sep 2026 17:41:39 +0200 Subject: [PATCH 23/28] Replace ONYXKEYS.PERSONAL_DETAILS_LIST usage in ExpenseAddedGrowlContent --- src/components/ExpenseAddedGrowl.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/ExpenseAddedGrowl.tsx b/src/components/ExpenseAddedGrowl.tsx index 2d1920be372d..d57a06157e3e 100644 --- a/src/components/ExpenseAddedGrowl.tsx +++ b/src/components/ExpenseAddedGrowl.tsx @@ -1,6 +1,7 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import {usePersonalDetailsByIDs} from '@hooks/usePersonalDetails'; import useReportTransactions from '@hooks/useReportTransactions'; import {createTransactionThreadReport, setOptimisticTransactionThread} from '@libs/actions/Report'; @@ -71,7 +72,6 @@ function ExpenseAddedGrowlContent({transactionID, signal, active, setActive}: Ex const {translate} = useLocalize(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); const [betas] = useOnyx(ONYXKEYS.BETAS); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); @@ -86,6 +86,8 @@ function ExpenseAddedGrowlContent({transactionID, signal, active, setActive}: Ex const selfDMReportID = isUnreportedExpense ? findSelfDMReportID() : undefined; const hostReportID = isUnreportedExpense ? selfDMReportID : reportID; const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${hostReportID}`); + const iouAction = getIOUActionForTransactionID(Object.values(reportActions ?? {}), transactionID); + const [personalDetails] = usePersonalDetailsByIDs([currentUserPersonalDetails?.accountID, iouAction?.actorAccountID]); // A tracked expense lives in a chat, not an expense report. Leaving iouReportID undefined tells // navigateToCreatedExpense to open the transaction thread directly instead of an expense report RHP. @@ -128,7 +130,6 @@ function ExpenseAddedGrowlContent({transactionID, signal, active, setActive}: Ex // Build the thread on press rather than when the growl shows, so it is only created if the user taps // "View" and is built against the freshest Onyx data. const navigateToExpense = () => { - const iouAction = getIOUActionForTransactionID(Object.values(reportActions ?? {}), active.transactionID); let threadReportID = transaction?.transactionThreadReportID ?? iouAction?.childReportID; if (threadReportID) { setOptimisticTransactionThread(threadReportID, iouReport?.reportID, iouAction?.reportActionID, iouReport?.policyID); From b6f1eee7f36ccd5a87497c2a89d43b3d44a26094 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Mon, 21 Sep 2026 13:06:07 +0200 Subject: [PATCH 24/28] Remove unused getSearchTableHighlightBorderRadius --- src/styles/utils/index.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index c8cb5eac9425..8d3559a3f0aa 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -1978,8 +1978,6 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({ backgroundColor: isSelected ? theme.activeComponentBG : theme.highlightBG, }), - getSearchTableHighlightBorderRadius: (isLargeScreenWidth: boolean): number => (isLargeScreenWidth ? 0 : variables.componentBorderRadius), - getReportTableColumnStyles: (columnName: string, options: GetReportTableColumnStylesParams = {}): ViewStyle => { const { isSubmittedColumnWide, From d0a86ccf88099f19a704b7a1dc6ecfce8a76cc18 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Mon, 21 Sep 2026 13:10:42 +0200 Subject: [PATCH 25/28] Add comment explaining dropped backTo in navigateToCreatedExpense --- src/libs/Navigation/helpers/navigateToCreatedExpense.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/libs/Navigation/helpers/navigateToCreatedExpense.ts b/src/libs/Navigation/helpers/navigateToCreatedExpense.ts index 686dda07c218..80d57bd4ee92 100644 --- a/src/libs/Navigation/helpers/navigateToCreatedExpense.ts +++ b/src/libs/Navigation/helpers/navigateToCreatedExpense.ts @@ -66,6 +66,8 @@ function navigateToCreatedExpense({threadReportID, transactionID, iouReportID, r return; } if (iouReportID) { + // The replaced RHP's backTo can point at this same report, which turns into a not-here page once it's + // deleted. Drop it so deleting falls back to the report's chat instead. Navigation.navigate(ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: iouReportID, backTo: forceReplace ? undefined : backTo}), {forceReplace}); // A multi-transaction report opens super wide RHP, so stack the thread RHP on top of it. A single-transaction From 20b6ec76a9d3cb5f869159de2eab6ea03b042fd3 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Mon, 21 Sep 2026 13:15:00 +0200 Subject: [PATCH 26/28] Expand the comment for signalExpenseAddedGrowl --- src/libs/actions/IOU/signalExpenseAddedGrowl.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/libs/actions/IOU/signalExpenseAddedGrowl.ts b/src/libs/actions/IOU/signalExpenseAddedGrowl.ts index 9ca2858f8dc3..a34b6cbcfa35 100644 --- a/src/libs/actions/IOU/signalExpenseAddedGrowl.ts +++ b/src/libs/actions/IOU/signalExpenseAddedGrowl.ts @@ -2,7 +2,10 @@ import {mergeExpenseAddedGrowlTransactionIDs} from '@userActions/Transaction'; import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; -/** Queues the "Expense added" growl (toast) for a newly-created transaction. */ +/** + * Queues the "Expense added" growl (toast) for a newly-created transaction. Signal whenever the flow leaves the + * user somewhere other than the expense's report - the growl suppresses itself if they end up on that report. + */ function signalExpenseAddedGrowl(transactionID: string | undefined, dataType: SearchDataTypes) { if (!transactionID) { return; From 46220ca027aae63d22ffc8c19a2b8fb97dfde051 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Tue, 22 Sep 2026 09:38:03 +0200 Subject: [PATCH 27/28] Fix after merge --- .../SearchList/ListItem/TaskListItem.tsx | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/components/Search/SearchList/ListItem/TaskListItem.tsx b/src/components/Search/SearchList/ListItem/TaskListItem.tsx index d92fa9abf296..bda6e047162f 100644 --- a/src/components/Search/SearchList/ListItem/TaskListItem.tsx +++ b/src/components/Search/SearchList/ListItem/TaskListItem.tsx @@ -87,14 +87,23 @@ function TaskListItem({ onLongPressRow={onLongPressRow} shouldSyncFocus={shouldSyncFocus} hoverStyle={isSelected && styles.activeComponentBG} - pressableWrapperStyle={pressableWrapperStyle} - forwardedFSClass={fsClass} + pressableWrapperStyle={[ + styles.mh5, + StyleUtils.getSearchRowBackgroundStyle(isSelected), + !isLargeScreenWidth && styles.br2, + isLargeScreenWidth && isLastItem && [styles.tableBottomRadius, styles.overflowHidden], + ]} > - - + + + + ); } From 84e8c0f485032e16cb1ec81f84431dd3255ee8b0 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Fri, 25 Sep 2026 17:05:06 +0200 Subject: [PATCH 28/28] Fix after merge --- src/components/ExpenseAddedGrowl.tsx | 2 -- .../ListItem/ExpenseReportListItem.tsx | 1 - .../SearchList/ListItem/GroupHeader.tsx | 1 - .../ListItem/TransactionGroupListItem.tsx | 1 - .../Search/hooks/useSearchSnapshot.ts | 14 ++------ src/libs/actions/IOU/PerDiem.ts | 20 +++++------ src/libs/actions/IOU/TrackExpense.ts | 12 ++----- tests/unit/Search/useSearchSnapshotTest.ts | 36 ------------------- 8 files changed, 15 insertions(+), 72 deletions(-) diff --git a/src/components/ExpenseAddedGrowl.tsx b/src/components/ExpenseAddedGrowl.tsx index d57a06157e3e..39d67566f209 100644 --- a/src/components/ExpenseAddedGrowl.tsx +++ b/src/components/ExpenseAddedGrowl.tsx @@ -72,7 +72,6 @@ function ExpenseAddedGrowlContent({transactionID, signal, active, setActive}: Ex const {translate} = useLocalize(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const [betas] = useOnyx(ONYXKEYS.BETAS); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); @@ -139,7 +138,6 @@ function ExpenseAddedGrowlContent({transactionID, signal, active, setActive}: Ex conciergeChat, currentUserLogin: currentUserPersonalDetails?.login ?? '', currentUserAccountID: currentUserPersonalDetails?.accountID ?? CONST.DEFAULT_NUMBER_ID, - betas, iouReport, iouReportAction: iouAction, transaction, diff --git a/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx b/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx index d328ffbeebd0..aa4a32b3a809 100644 --- a/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx +++ b/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx @@ -22,7 +22,6 @@ import useOnyx from '@hooks/useOnyx'; import {useReportPaymentContext} from '@hooks/usePaymentContext'; import {useAllPersonalDetailsWithoutSnapshots} from '@hooks/usePersonalDetails'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useRowHighlightAnimation from '@hooks/useRowHighlightAnimation'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; diff --git a/src/components/Search/SearchList/ListItem/GroupHeader.tsx b/src/components/Search/SearchList/ListItem/GroupHeader.tsx index 73fc55d6314b..752b5036841b 100644 --- a/src/components/Search/SearchList/ListItem/GroupHeader.tsx +++ b/src/components/Search/SearchList/ListItem/GroupHeader.tsx @@ -13,7 +13,6 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useOnyx from '@hooks/useOnyx'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useRowHighlightAnimation from '@hooks/useRowHighlightAnimation'; import useStyleUtils from '@hooks/useStyleUtils'; import useSyncFocus from '@hooks/useSyncFocus'; import useTheme from '@hooks/useTheme'; diff --git a/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx b/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx index 6b84ea362002..59547572df0d 100644 --- a/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx @@ -10,7 +10,6 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useRowHighlightAnimation from '@hooks/useRowHighlightAnimation'; import useStyleUtils from '@hooks/useStyleUtils'; import useSyncFocus from '@hooks/useSyncFocus'; import useTheme from '@hooks/useTheme'; diff --git a/src/components/Search/hooks/useSearchSnapshot.ts b/src/components/Search/hooks/useSearchSnapshot.ts index 5478cb5393d4..f92dafa6af80 100644 --- a/src/components/Search/hooks/useSearchSnapshot.ts +++ b/src/components/Search/hooks/useSearchSnapshot.ts @@ -95,7 +95,7 @@ const EMPTY_COLUMNS: SearchColumnType[] = []; const EMPTY_HASHES: string[] = []; const EMPTY_GROUP_ITEMS: TransactionGroupListItemType[] = []; -/** How many leading rows the cap keeps: `rowLimit` rows on screen, stretched down to the last ticked or highlighted row. */ +/** How many leading rows the cap keeps: `rowLimit` rows on screen, stretched down to the last ticked row. */ function getRowLimitEnd(rows: SearchListItem[], rowLimit: number, selectedTransactions: SelectedTransactions | undefined, isOffline: boolean): number { const isKeySelected = (key: string | undefined) => !!key && !!selectedTransactions?.[key]?.isSelected; let shownRowCount = 0; @@ -107,7 +107,7 @@ function getRowLimitEnd(rows: SearchListItem[], rowLimit: number, selectedTransa } shownRowCount += 1; const isRowSelected = isKeySelected(row.keyForList) || (isTransactionGroupListItemType(row) && row.transactions.some((transaction) => isKeySelected(transaction.keyForList))); - if (shownRowCount <= rowLimit || !!row.shouldAnimateInHighlight || isRowSelected) { + if (shownRowCount <= rowLimit || isRowSelected) { end = index + 1; } } @@ -124,15 +124,7 @@ const hashToString = (queryHash?: number) => (queryHash || queryHash === 0 ? Str * the optimistic-row resilience. Returns the sorted rows plus the list-level meta and the * optimistic-tracking carriers that `` consumes. */ -function useSearchSnapshot({ - queryJSON, - searchResults, - - transactions, - reportActions, - visibleRowLimit, - selectedTransactions, -}: UseSearchSnapshotParams): SearchSnapshotResult { +function useSearchSnapshot({queryJSON, searchResults, transactions, reportActions, visibleRowLimit, selectedTransactions}: UseSearchSnapshotParams): SearchSnapshotResult { const {type, sortBy, sortOrder, hash, groupBy} = queryJSON; const {isOffline} = useNetwork(); diff --git a/src/libs/actions/IOU/PerDiem.ts b/src/libs/actions/IOU/PerDiem.ts index 423eb81fd090..100373b9eb71 100644 --- a/src/libs/actions/IOU/PerDiem.ts +++ b/src/libs/actions/IOU/PerDiem.ts @@ -1277,17 +1277,17 @@ function submitPerDiemExpenseForSelfDM(submitPerDiemExpenseInformation: PerDiemE playSound(SOUNDS.DONE); - const apiWrite = () => { - API.write(WRITE_COMMANDS.CREATE_PER_DIEM_REQUEST, parameters, onyxData); - }; - - deferOrExecuteWrite(apiWrite, { - shouldDeferForSearch: false, - optimisticWatchKey: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, - onDeferred: () => addOptimization(CONST.TELEMETRY.SUBMIT_OPTIMIZATION.DEFERRED_WRITE), - }); + API.writeWhenReady( + WRITE_COMMANDS.CREATE_PER_DIEM_REQUEST, + parameters, + onyxData, + resolveWriteBarrier({writeBarrier, optimisticWatchKey: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`}), + {onWriteStarted: () => notifyNewAction(chatReport.reportID, undefined, true)}, + ); - notifyNewAction(chatReport.reportID, undefined, true); + if (isFromGlobalCreate) { + signalExpenseAddedGrowl(transaction.transactionID, CONST.SEARCH.DATA_TYPES.EXPENSE); + } } export { diff --git a/src/libs/actions/IOU/TrackExpense.ts b/src/libs/actions/IOU/TrackExpense.ts index 1f9a7ebf7071..70861bbd873c 100644 --- a/src/libs/actions/IOU/TrackExpense.ts +++ b/src/libs/actions/IOU/TrackExpense.ts @@ -2000,14 +2000,6 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep signalExpenseAddedGrowl(transaction.transactionID, CONST.SEARCH.DATA_TYPES.EXPENSE); } - if (activeReportID && !isMoneyRequestReport) { - Navigation.setNavigationActionToMicrotaskQueue(() => - setTimeout(() => { - notifyNewAction(activeReportID, reportPreviewAction, payeeAccountID === currentUserAccountIDParam); - }, CONST.TIMING.NOTIFY_NEW_ACTION_DELAY), - ); - } - return {iouReport, transactionID: transaction.transactionID}; } @@ -2973,9 +2965,9 @@ function trackExpense(params: CreateTrackExpenseParams) { if (!isNotificationDeferredToWrite) { notifyTrackedAction(); - - return {iouReport, transactionID: transaction?.transactionID}; } + + return {iouReport, transactionID: transaction?.transactionID}; } /** diff --git a/tests/unit/Search/useSearchSnapshotTest.ts b/tests/unit/Search/useSearchSnapshotTest.ts index a6921622c19d..f3d2088da7d3 100644 --- a/tests/unit/Search/useSearchSnapshotTest.ts +++ b/tests/unit/Search/useSearchSnapshotTest.ts @@ -419,7 +419,6 @@ describe('useSearchSnapshot', () => { useSearchSnapshot({ queryJSON: makeQueryJSON(), searchResults, - newSearchResultKeys: undefined, transactions: undefined, reportActions: undefined, visibleRowLimit, @@ -453,7 +452,6 @@ describe('useSearchSnapshot', () => { useSearchSnapshot({ queryJSON: makeQueryJSON(), searchResults, - newSearchResultKeys: undefined, transactions: undefined, reportActions: undefined, visibleRowLimit: 2, @@ -477,7 +475,6 @@ describe('useSearchSnapshot', () => { useSearchSnapshot({ queryJSON: makeQueryJSON(), searchResults, - newSearchResultKeys: undefined, transactions: undefined, reportActions: undefined, visibleRowLimit: 3, @@ -500,7 +497,6 @@ describe('useSearchSnapshot', () => { useSearchSnapshot({ queryJSON: makeQueryJSON(), searchResults, - newSearchResultKeys: undefined, transactions: undefined, reportActions: undefined, visibleRowLimit: 2, @@ -530,7 +526,6 @@ describe('useSearchSnapshot', () => { useSearchSnapshot({ queryJSON: makeQueryJSON({groupBy: CONST.SEARCH.GROUP_BY.FROM}), searchResults, - newSearchResultKeys: undefined, transactions: undefined, reportActions: undefined, visibleRowLimit: 2, @@ -542,34 +537,6 @@ describe('useSearchSnapshot', () => { expect(result.current.filteredData).toEqual(groups.slice(0, 2).map((group) => expect.objectContaining({keyForList: group.keyForList, transactions: group.transactions}))); }); - it('keeps a highlighted row past visibleRowLimit on screen', () => { - // Given a live list capped at two groups, where a new expense just landed in the last group - const searchResults = makeSearchResults(); - mockUseOptimisticSearchTracking.mockReturnValue(trackingReturn(searchResults.data)); - const groups = Array.from({length: 5}, (_value, index) => ({ - groupID: `group${index}`, - keyForList: `group${index}`, - transactions: [{transactionID: `${index}`}], - })); - mockGetSections.mockReturnValue([groups, groups.length, false]); - mockGetSortedSections.mockReturnValue(groups); - - // When the snapshot is projected with that expense queued for highlight - const {result} = renderHook(() => - useSearchSnapshot({ - queryJSON: makeQueryJSON({groupBy: CONST.SEARCH.GROUP_BY.FROM}), - searchResults, - newSearchResultKeys: new Set([`${ONYXKEYS.COLLECTION.TRANSACTION}4`]), - transactions: undefined, - reportActions: undefined, - visibleRowLimit: 2, - }), - ); - - // Then the highlighted group still renders, or the scroll-to-new-expense has no row to land on - expect(result.current.data.map((item) => item.keyForList)).toContain('group4'); - }); - it('does not spend visibleRowLimit on rows being deleted online', () => { // Given a live list capped at two rows, whose first two rows are being deleted while online const searchResults = makeSearchResults(); @@ -587,7 +554,6 @@ describe('useSearchSnapshot', () => { useSearchSnapshot({ queryJSON: makeQueryJSON(), searchResults, - newSearchResultKeys: undefined, transactions: undefined, reportActions: undefined, visibleRowLimit: 2, @@ -616,7 +582,6 @@ describe('useSearchSnapshot', () => { useSearchSnapshot({ queryJSON: makeQueryJSON({groupBy: CONST.SEARCH.GROUP_BY.FROM}), searchResults, - newSearchResultKeys: undefined, transactions: undefined, reportActions: undefined, visibleRowLimit: 2, @@ -646,7 +611,6 @@ describe('useSearchSnapshot', () => { useSearchSnapshot({ queryJSON: makeQueryJSON(), searchResults, - newSearchResultKeys: undefined, transactions: undefined, reportActions: undefined, visibleRowLimit: 2,