From 49cca883c8dea627d3ddc6bb3fa62b86adbe34de Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Mon, 20 Jul 2026 17:52:03 +0200 Subject: [PATCH 01/37] =?UTF-8?q?Confirm=20new=20expenses=20with=20a=20?= =?UTF-8?q?=E2=80=9CView=E2=80=9D=20toast=20instead=20of=20scrolling=20to?= =?UTF-8?q?=20and=20highlighting=20the=20row?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/GlobalModals.tsx | 2 + src/ONYXKEYS.ts | 7 +- src/components/ExpenseAddedGrowl.tsx | 134 +++++++++++++ .../MoneyRequestHeaderSecondaryActions.tsx | 8 +- src/components/Search/ChatSearchView.tsx | 6 +- .../Search/ExpenseFlatSearchView.tsx | 6 +- .../Search/ExpenseGroupedSearchView.tsx | 23 +-- .../Search/ExpenseReportSearchView.tsx | 6 +- .../SearchList/ListItem/ChatListItem.tsx | 2 +- .../ListItem/ExpenseReportListItem.tsx | 2 +- .../ListItem/GroupChildrenContainer.tsx | 2 +- .../SearchList/ListItem/GroupHeader.tsx | 2 +- .../SearchList/ListItem/TaskListItem.tsx | 2 +- .../ListItem/TransactionGroupListItem.tsx | 2 +- .../TransactionListItemNarrow.tsx | 2 +- .../TransactionListItemWide.tsx | 2 +- src/components/Search/TaskSearchView.tsx | 6 +- .../Search/hooks/useSearchSnapshot.ts | 58 ++---- src/components/Search/index.tsx | 30 +-- src/components/Search/searchViewProps.ts | 11 +- src/hooks/useExpenseActions.ts | 8 +- src/hooks/useSearchHighlightAndScroll.ts | 185 ++---------------- src/languages/de.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 | 76 ++++++- src/libs/actions/IOU/Duplicate.ts | 6 + src/libs/actions/IOU/NavigationHelpers.ts | 16 +- src/libs/actions/IOU/PerDiem.ts | 6 +- src/libs/actions/IOU/SendInvoice.ts | 6 +- src/libs/actions/IOU/Split.ts | 6 +- .../actions/IOU/SplitTransactionUpdate.ts | 23 ++- src/libs/actions/IOU/TrackExpense.ts | 16 +- src/libs/actions/Transaction.ts | 6 +- tests/actions/IOU/SplitReportTotalsTest.ts | 16 +- tests/actions/IOUTest/SplitTest.ts | 6 +- tests/unit/ExpenseAddedGrowlTest.tsx | 136 +++++++++++++ tests/unit/Search/useSearchSnapshotTest.ts | 28 --- tests/unit/navigateAfterExpenseCreateTest.ts | 100 +++++++++- tests/unit/useSearchHighlightAndScrollTest.ts | 173 +--------------- 48 files changed, 599 insertions(+), 549 deletions(-) create mode 100644 src/components/ExpenseAddedGrowl.tsx create mode 100644 tests/unit/ExpenseAddedGrowlTest.tsx diff --git a/src/GlobalModals.tsx b/src/GlobalModals.tsx index 09c6cb1ce963..e0ef098f96fb 100644 --- a/src/GlobalModals.tsx +++ b/src/GlobalModals.tsx @@ -2,6 +2,7 @@ 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 * as EmojiPickerAction from './libs/actions/EmojiPickerAction'; @@ -49,6 +50,7 @@ function GlobalModals() { return ( <> + {shouldRenderContextMenu && ( diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index d673456d2bfc..7f1f4b2f0225 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -13,6 +13,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'; @@ -783,8 +784,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', @@ -1723,7 +1724,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..7bd3acc65878 --- /dev/null +++ b/src/components/ExpenseAddedGrowl.tsx @@ -0,0 +1,134 @@ +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 {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; +}; + +/** 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 nonceRef = useRef(0); + + const {translate} = useLocalize(); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const [signal] = useOnyx(ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS); + const [betas] = useOnyx(ONYXKEYS.BETAS); + const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); + const transactionID = active?.transactionID ?? Object.keys(signal ?? {}).at(-1); + const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); + const reportID = transaction?.reportID; + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + // A tracked/unreported expense's transaction.reportID is UNREPORTED_REPORT_ID, and its IOU/track action lives + // on the self-DM report - not on transaction.reportID. Read actions from the report that actually hosts the + // action so "View" resolves the real transaction thread (the action's childReportID) instead of fabricating + // a mismatched optimistic one. + const hostReportID = !reportID || reportID === CONST.REPORT.UNREPORTED_REPORT_ID ? findSelfDMReportID() : reportID; + const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${hostReportID}`); + // Only IOU/expense/invoice reports have the expense-report RHP that "View" opens. A tracked/unreported + // (self-DM) expense lives in a chat, so it has no such report - leaving iouReportID undefined makes + // navigateToCreatedExpense open the transaction thread directly instead of a super-wide report RHP it lacks. + const iouReport = isMoneyRequestReport(report) || isInvoiceReport(report) ? report : undefined; + const iouReportID = iouReport?.reportID; + + useEffect(() => { + if (active) { + return; + } + const pendingTransactionIDs = Object.keys(signal ?? {}); + const latestTransactionID = pendingTransactionIDs.at(-1); + const dataType = latestTransactionID ? signal?.[latestTransactionID] : undefined; + if (!latestTransactionID || !dataType) { + return; + } + // Wait for the created expense's optimistic data to land before acting. The create's API.write is + // usually deferred (the deferred-for-search pattern) until Search's onLayout flushes it, and the whole + // optimistic dataset (transaction + IOU report/action + thread) is applied atomically - so the + // transaction appearing in Onyx means "View" can resolve AND its report is known for the check below. + if (!transaction) { + return; + } + + mergeExpenseAddedGrowlTransactionIDs(Object.fromEntries(pendingTransactionIDs.map((id) => [id, null]))); + + // Suppress the growl when the user is already viewing the expense's money-request report (its + // transaction list), where the new row is highlighted so a growl would be redundant. A tracked/self-DM + // expense has transaction.reportID === UNREPORTED_REPORT_ID (never a report you view), so this never + // matches for those and the growl always shows - matching "no transaction list to be in". + if (Navigation.getTopmostReportId() === transaction.reportID) { + return; + } + nonceRef.current += 1; + setActive({transactionID: latestTransactionID, dataType, nonce: nonceRef.current}); + }, [signal, active, transaction]); + + if (!active) { + return null; + } + + const isInvoice = active.dataType === CONST.SEARCH.DATA_TYPES.INVOICE; + + // Materialize the transaction thread and navigate to it at press time (not show time): the thread is + // only built if the user actually taps "View", against the freshest Onyx data, matching how every other + // thread navigation entry point builds the thread at navigation time. + 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, + }); + 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/MoneyRequestHeaderSecondaryActions.tsx b/src/components/MoneyRequestHeaderSecondaryActions.tsx index dc640cf73251..fd5dbf175d36 100644 --- a/src/components/MoneyRequestHeaderSecondaryActions.tsx +++ b/src/components/MoneyRequestHeaderSecondaryActions.tsx @@ -27,6 +27,7 @@ import useThrottledButtonState from '@hooks/useThrottledButtonState'; import useTransactionViolations from '@hooks/useTransactionViolations'; import {duplicateExpenseTransaction as duplicateTransactionAction} from '@libs/actions/IOU/Duplicate'; +import {signalExpenseAddedGrowl} from '@libs/actions/IOU/NavigationHelpers'; import {deleteTrackExpense} from '@libs/actions/IOU/TrackExpense'; import {setupMergeTransactionDataAndNavigate} from '@libs/actions/MergeTransaction'; import initSplitExpense from '@libs/actions/SplitExpenses'; @@ -243,11 +244,12 @@ function MoneyRequestHeaderSecondaryActions({reportID, onBackButtonPress}: Money const optimisticIOUReportID = generateReportID(); const activePolicyCategoriesMap = defaultPolicyCategories ?? {}; + let lastDuplicateTransactionID: string | undefined; for (const item of transactions) { const existingTransactionID = getExistingTransactionID(item.linkedTrackedExpenseReportAction); const existingTransactionDraft = existingTransactionID ? transactionDrafts?.[existingTransactionID] : undefined; - duplicateTransactionAction({ + const result = duplicateTransactionAction({ transaction: item, optimisticChatReportID, optimisticIOUReportID, @@ -271,7 +273,11 @@ function MoneyRequestHeaderSecondaryActions({reportID, onBackButtonPress}: Money delegateAccountID, policyTagList, }); + if (result?.transactionID) { + lastDuplicateTransactionID = result.transactionID; + } } + signalExpenseAddedGrowl(lastDuplicateTransactionID, CONST.SEARCH.DATA_TYPES.EXPENSE); }; const dismissModalAndUpdateUseHold = () => { 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. 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 dc834187779d..f34d215ffe17 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'; @@ -110,7 +110,6 @@ function ExpenseGroupedSearchView({ onScroll, contentContainerStyle, containerStyle, - ref, }: ExpenseGroupedSearchViewProps) { const {type, groupBy} = queryJSON; const {isLargeScreenWidth} = useResponsiveLayout(); @@ -173,26 +172,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. ({ const {isSelected} = useRowSelection(item.keyForList); const animatedHighlightStyle = useAnimatedHighlightStyle({ borderRadius: variables.componentBorderRadius, - shouldHighlight: item?.shouldAnimateInHighlight ?? false, + shouldHighlight: false, highlightColor: theme.messageHighlightBG, backgroundColor: theme.highlightBG, }); diff --git a/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx b/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx index d3267bb2aa88..3b8e8ff8e383 100644 --- a/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx +++ b/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx @@ -370,7 +370,7 @@ function ExpenseReportListItemInner({ const animatedHighlightStyle = useAnimatedHighlightStyle({ borderRadius: 0, - shouldHighlight: item?.shouldAnimateInHighlight ?? false, + shouldHighlight: false, highlightColor: theme.messageHighlightBG, backgroundColor: isSelected ? theme.activeComponentBG : theme.highlightBG, shouldApplyOtherStyles: !isLargeScreenWidth, diff --git a/src/components/Search/SearchList/ListItem/GroupChildrenContainer.tsx b/src/components/Search/SearchList/ListItem/GroupChildrenContainer.tsx index f894686a766d..f8d35d60b552 100644 --- a/src/components/Search/SearchList/ListItem/GroupChildrenContainer.tsx +++ b/src/components/Search/SearchList/ListItem/GroupChildrenContainer.tsx @@ -44,7 +44,7 @@ function GroupChildrenContainer({ const isSelected = !!item.isSelected || (item.transactions.length > 0 && item.transactions.every((transaction) => selectedTransactions[transaction.transactionID]?.isSelected)); const animatedHighlightStyle = useAnimatedHighlightStyle({ - shouldHighlight: item?.shouldAnimateInHighlight ?? false, + shouldHighlight: false, highlightColor: theme.messageHighlightBG, backgroundColor: isSelected ? theme.activeComponentBG : theme.highlightBG, shouldApplyOtherStyles: false, diff --git a/src/components/Search/SearchList/ListItem/GroupHeader.tsx b/src/components/Search/SearchList/ListItem/GroupHeader.tsx index 5e96d432467e..1a234bdfbd47 100644 --- a/src/components/Search/SearchList/ListItem/GroupHeader.tsx +++ b/src/components/Search/SearchList/ListItem/GroupHeader.tsx @@ -220,7 +220,7 @@ function GroupHeader({ }); const animatedHighlightStyle = useAnimatedHighlightStyle({ - shouldHighlight: item?.shouldAnimateInHighlight ?? false, + shouldHighlight: false, highlightColor: theme.messageHighlightBG, backgroundColor: isItemSelected ? theme.activeComponentBG : theme.highlightBG, shouldApplyOtherStyles: false, diff --git a/src/components/Search/SearchList/ListItem/TaskListItem.tsx b/src/components/Search/SearchList/ListItem/TaskListItem.tsx index 3edf00aa8268..44b2a7124c0f 100644 --- a/src/components/Search/SearchList/ListItem/TaskListItem.tsx +++ b/src/components/Search/SearchList/ListItem/TaskListItem.tsx @@ -74,7 +74,7 @@ function TaskListItem({ const animatedHighlightStyle = useAnimatedHighlightStyle({ borderRadius: StyleUtils.getSearchTableHighlightBorderRadius(isLargeScreenWidth), - shouldHighlight: item?.shouldAnimateInHighlight ?? false, + shouldHighlight: false, highlightColor: theme.messageHighlightBG, backgroundColor: theme.highlightBG, shouldApplyOtherStyles: !isLargeScreenWidth, diff --git a/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx b/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx index 2ae26d74a261..3b301b069f25 100644 --- a/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx @@ -228,7 +228,7 @@ function TransactionGroupListItem({ const isItemSelected = isSelectAllChecked || liveRowSelected; const animatedHighlightStyle = useAnimatedHighlightStyle({ - shouldHighlight: item?.shouldAnimateInHighlight ?? false, + shouldHighlight: false, highlightColor: theme.messageHighlightBG, backgroundColor: isItemSelected ? theme.activeComponentBG : theme.highlightBG, shouldApplyOtherStyles: false, diff --git a/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemNarrow.tsx b/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemNarrow.tsx index 9781c015bda0..81e83c021a29 100644 --- a/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemNarrow.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemNarrow.tsx @@ -70,7 +70,7 @@ function TransactionListItemNarrow({ const animatedHighlightStyle = useAnimatedHighlightStyle({ borderRadius: 0, - shouldHighlight: item?.shouldAnimateInHighlight ?? false, + shouldHighlight: false, highlightColor: theme.messageHighlightBG, backgroundColor: isSelected ? theme.activeComponentBG : theme.highlightBG, shouldApplyOtherStyles: true, diff --git a/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemWide.tsx b/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemWide.tsx index 9fc257f3df30..ddf71ca2cd2b 100644 --- a/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemWide.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemWide.tsx @@ -144,7 +144,7 @@ function TransactionListItemWide({ const animatedHighlightStyle = useAnimatedHighlightStyle({ borderRadius: 0, - shouldHighlight: item?.shouldAnimateInHighlight ?? false, + shouldHighlight: false, highlightColor: theme.messageHighlightBG, backgroundColor: isSelected ? theme.activeComponentBG : theme.highlightBG, shouldApplyOtherStyles: false, diff --git a/src/components/Search/TaskSearchView.tsx b/src/components/Search/TaskSearchView.tsx index 9779d5fb3be5..cb5e045b6bde 100644 --- a/src/components/Search/TaskSearchView.tsx +++ b/src/components/Search/TaskSearchView.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'; @@ -48,7 +48,6 @@ function TaskSearchView({ onScroll, contentContainerStyle, containerStyle, - ref, }: TaskSearchViewProps) { const {type} = queryJSON; @@ -62,9 +61,6 @@ function TaskSearchView({ 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) => ( // 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']; @@ -90,11 +88,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(); @@ -298,8 +296,8 @@ function useSearchSnapshot({queryJSON, searchResults, newSearchResultKeys, trans convertToDisplayString, ]); - // 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; @@ -310,47 +308,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 d827d2157fa2..093c2c52a6ff 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -1,6 +1,5 @@ import FullPageErrorView from '@components/BlockingViews/FullPageErrorView'; import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; -import type {SelectionListHandle} from '@components/SelectionList/types'; import SearchRowSkeleton from '@components/Skeletons/SearchRowSkeleton'; import {useWideRHPActions} from '@components/WideRHPContextProvider'; @@ -14,7 +13,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/useSearchHighlightAndScroll'; import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotals'; import useStableArrayReference from '@hooks/useStableArrayReference'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -184,7 +183,6 @@ function Search({ const previousReportActions = usePrevious(reportActions); const {translate} = useLocalize(); - const searchListRef = useRef | null>(null); const savedSearchSelector = useCallback((searches: OnyxEntry) => searches?.[hash], [hash]); const [savedSearch] = useOnyx(ONYXKEYS.SAVED_SEARCHES, { @@ -207,7 +205,7 @@ function Search({ clearSelectedTransactions(); }, [validGroupBy, prevValidGroupBy, clearSelectedTransactions]); - const {newSearchResultKeys, handleSelectionListScroll, newTransactions, hasQueuedHighlights} = useSearchHighlightAndScroll({ + const {newTransactions} = useSearchAutoRefetch({ searchResults, transactions, previousTransactions, @@ -236,17 +234,7 @@ function Search({ hasPendingWriteOnMountRef, skipDeferralOnFocusRef, rearmTracking, - } = 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]); + } = useSearchSnapshot({queryJSON, searchResults, transactions, reportActions}); // 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 @@ -307,14 +295,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. @@ -749,9 +729,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. @@ -1060,7 +1039,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/useExpenseActions.ts b/src/hooks/useExpenseActions.ts index d4b0b7f1906a..3816a8efcb94 100644 --- a/src/hooks/useExpenseActions.ts +++ b/src/hooks/useExpenseActions.ts @@ -5,6 +5,7 @@ import {useMoneyReportTransactionThread} from '@components/MoneyReportTransactio import {useSearchQueryContext, useSearchSelectionActions} from '@components/Search/SearchContext'; import {duplicateReport as duplicateReportAction, duplicateExpenseTransaction as duplicateTransactionAction} from '@libs/actions/IOU/Duplicate'; +import {signalExpenseAddedGrowl} from '@libs/actions/IOU/NavigationHelpers'; import {setupMergeTransactionDataAndNavigate} from '@libs/actions/MergeTransaction'; import {deleteAppReport} from '@libs/actions/Report'; import initSplitExpense from '@libs/actions/SplitExpenses'; @@ -252,11 +253,12 @@ function useExpenseActions({reportID, isReportInSearch = false, backTo, onDuplic const optimisticIOUReportID = generateReportID(); const activePolicyCategories = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${defaultExpensePolicy?.id}`] ?? {}; + let lastDuplicateTransactionID: string | undefined; for (const item of transactionList) { const existingTransactionID = getExistingTransactionID(item.linkedTrackedExpenseReportAction); const existingTransactionDraft = existingTransactionID ? transactionDrafts?.[existingTransactionID] : undefined; - duplicateTransactionAction({ + const result = duplicateTransactionAction({ transaction: item, optimisticChatReportID, optimisticIOUReportID, @@ -280,7 +282,11 @@ function useExpenseActions({reportID, isReportInSearch = false, backTo, onDuplic delegateAccountID, policyTagList, }); + if (result?.transactionID) { + lastDuplicateTransactionID = result.transactionID; + } } + signalExpenseAddedGrowl(lastDuplicateTransactionID, CONST.SEARCH.DATA_TYPES.EXPENSE); }; const addExpenseDropdownOptions = getAddExpenseDropdownOptions({ diff --git a/src/hooks/useSearchHighlightAndScroll.ts b/src/hooks/useSearchHighlightAndScroll.ts index b11c67ac96c8..f0c57cc82271 100644 --- a/src/hooks/useSearchHighlightAndScroll.ts +++ b/src/hooks/useSearchHighlightAndScroll.ts @@ -1,28 +1,23 @@ -import type {SearchListItem, TransactionGroupListItemType, TransactionListItemType} from '@components/Search/SearchList/ListItem/types'; +import type {TransactionGroupListItemType, 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'; import type {SearchKey} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; 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 = { searchResults: OnyxEntry; transactions: OnyxCollection; previousTransactions: OnyxCollection; @@ -36,9 +31,11 @@ type UseSearchHighlightAndScroll = { }; /** - * Hook used to trigger a search when a new transaction or report action is added and handle highlighting and scrolling. + * Hook used to trigger a search when a new transaction or report action is added, so the Search snapshot + * reflects freshly-created entries. Also 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, @@ -49,24 +46,13 @@ function useSearchHighlightAndScroll({ 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 ?? {}); @@ -133,13 +119,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({ @@ -177,149 +156,7 @@ 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; - } - - 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; - } - - // Perform the scrolling action - ref.scrollToIndex(indexOfNewItem); - // Reset the trigger flag to prevent unintended future scrolls and highlights - triggeredByHookRef.current = false; - }; - - const hasQueuedHighlights = newSearchResultKeys !== null && newSearchResultKeys.size > 0; - - return {newSearchResultKeys, handleSelectionListScroll, newTransactions, hasQueuedHighlights}; + return {newTransactions}; } /** @@ -358,5 +195,5 @@ function extractReportActionIDsFromSearchResults(searchResultsData: Partial = { createTimeExpense: 'Zeitaufwand erstellen', }, iou: { + expenseAdded: 'Ausgabe hinzugefügt', + invoiceSent: 'Rechnung gesendet', amount: 'Betrag', percent: 'Prozent', date: 'Datum', diff --git a/src/languages/en.ts b/src/languages/en.ts index b38453141ed1..2be3a22ea3e2 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1303,6 +1303,8 @@ const translations = { createTimeExpense: 'Create time expense', }, iou: { + 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 80f5e64f2d4c..2f22de4fbd7b 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1194,6 +1194,8 @@ const translations: TranslationDeepObject = { createTimeExpense: 'Crear gasto de tiempo', }, iou: { + 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 2c80ad56187f..6f5f94d7e21a 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1239,6 +1239,8 @@ const translations: TranslationDeepObject = { createTimeExpense: 'Créer une dépense de temps', }, iou: { + 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 c6cce232c27a..8d97fe6e94e9 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1235,6 +1235,8 @@ const translations: TranslationDeepObject = { createTimeExpense: 'Crea nota spese tempo', }, iou: { + 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 cd0d1dd46a70..fef447ddb5ad 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1217,6 +1217,8 @@ const translations: TranslationDeepObject = { createTimeExpense: '時間経費を作成', }, iou: { + expenseAdded: '経費を追加しました', + invoiceSent: '請求書を送信しました', amount: '金額', percent: 'パーセント', date: '日付', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 983cfb709e44..820279ecc0ef 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1233,6 +1233,8 @@ const translations: TranslationDeepObject = { createTimeExpense: 'Tijdkosten aanmaken', }, iou: { + 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 8ba7155c1df9..f8e3e6392cd3 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1229,6 +1229,8 @@ const translations: TranslationDeepObject = { createTimeExpense: 'Utwórz wydatek czasowy', }, iou: { + 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 6e1b8d8a3984..1c150226b6b1 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1233,6 +1233,8 @@ const translations: TranslationDeepObject = { createTimeExpense: 'Criar despesa de tempo', }, iou: { + 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 7ad0e99a0c82..315e728644e1 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1186,6 +1186,8 @@ const translations: TranslationDeepObject = { createTimeExpense: '创建工时报销', }, iou: { + expenseAdded: '已添加支出', + invoiceSent: '已发送发票', amount: '金额', percent: '百分比', date: '日期', diff --git a/src/libs/ExportOnyxState/common.ts b/src/libs/ExportOnyxState/common.ts index 36dd807d3927..92a3289f2d9b 100644 --- a/src/libs/ExportOnyxState/common.ts +++ b/src/libs/ExportOnyxState/common.ts @@ -285,7 +285,7 @@ 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.EXPENSE_ADDED_GROWL_TRANSACTION_IDS, ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS, ONYXKEYS.TRAVEL_INVOICE_STATEMENT, ONYXKEYS.VALIDATE_DOMAIN_TWO_FACTOR_CODE, diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts index 9cea646bc6c1..55428b0fa6ab 100644 --- a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts @@ -1,8 +1,11 @@ import {addPendingNewTransactionIDs} from '@libs/actions/IOU/PendingNewTransactions'; +import {mergeExpenseAddedGrowlTransactionIDs} from '@libs/actions/Transaction'; +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,8 +15,10 @@ 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 = { activeReportID?: string; @@ -33,12 +38,77 @@ function getNavigateAfterCreateSearchNavigatorState() { return searchNavigatorRoute?.state ?? (searchNavigatorRoute?.key ? getPreservedNavigatorState(searchNavigatorRoute.key) : undefined); } +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; +}; + +/** + * Navigates to a just-created expense, choosing the destination from the surface the user + * is currently looking at (they may have switched tabs while the growl was up) + * - Spend tab: the transaction thread RHP within Spend (report shown underneath via the wide RHP) + * - Inbox tab, narrow layout: the transaction thread as a full report view + * - Inbox tab, wide layout, with an expense report: the expense report - a multi-transaction report opens super + * wide with the specific thread RHP stacked on top; a single-transaction report collapses to the thread itself + * - Inbox tab, wide layout, tracked/unreported expense (no expense report): the transaction thread RHP directly + */ +function navigateToCreatedExpense({threadReportID, transactionID, iouReportID}: NavigateToCreatedExpenseParams) { + const backTo = Navigation.getActiveRoute(); + 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()); + + 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 (see SearchMoneyRequestReportPage's `shouldShowSuperWideRHP`), + // so stack the specific thread RHP on top of it. A single-transaction report collapses to the thread + // itself, so the expense report navigation above already lands 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; + } + + // Tracked/unreported expense (self-DM): there's no expense report, so open the transaction thread as a full + // report - the same way tapping the expense in its self-DM chat does (see ChatTransactionPreview), rather + // than the Search-tab RHP. + 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, @@ -70,6 +140,9 @@ function navigateAfterExpenseCreate({ const type = isInvoice ? CONST.SEARCH.DATA_TYPES.INVOICE : CONST.SEARCH.DATA_TYPES.EXPENSE; + // Signal that an expense was added so we can surface the "Expense added" growl. + mergeExpenseAddedGrowlTransactionIDs({[transactionID]: type}); + // When already on Search ROOT with the same type (expense vs invoice), we navigate to the same screen (no-op or refresh); record as dismiss_modal_only. // When on another Search sub-tab (e.g. Chats), or on Search with a different type (e.g. on Invoice, submitting expense), record as navigate_to_search. const searchNavigatorState = getNavigateAfterCreateSearchNavigatorState(); @@ -109,3 +182,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 16ec5d43afc5..ac9dcc91a72b 100644 --- a/src/libs/actions/IOU/Duplicate.ts +++ b/src/libs/actions/IOU/Duplicate.ts @@ -62,6 +62,7 @@ import type {CreateTrackExpenseParams} from './TrackExpense'; import {getAllReportActionsFromIOU, getAllReports, getAllTransactions} from '.'; import {getCleanUpTransactionThreadReportOnyxData} from './DeleteMoneyRequest'; import {getMoneyRequestParticipantsFromReport} from './MoneyRequest'; +import {signalExpenseAddedGrowl} from './NavigationHelpers'; import {submitPerDiemExpense} from './PerDiem'; import {createDistanceRequest} from './Split'; import {requestMoney, trackExpense} from './TrackExpense'; @@ -1157,6 +1158,7 @@ function bulkDuplicateExpenses({ isSubmitAndClose(targetPolicy) && (allNonReimbursable || targetPolicy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO); + let lastDuplicateTransactionID: string | undefined; for (let i = 0; i < transactionsToDuplicate.length; i++) { const item = transactionsToDuplicate.at(i); if (!item) { @@ -1222,6 +1224,9 @@ function bulkDuplicateExpenses({ if (result?.iouReport) { optimisticIOUReport = result.iouReport; } + if (result?.transactionID) { + lastDuplicateTransactionID = result.transactionID; + } if (currentTargetReport && !currentTargetReport.iouReportID) { currentTargetReport = {...currentTargetReport, iouReportID: currentOptimisticIOUReportID}; @@ -1229,6 +1234,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 index dbbbd2f241cb..ffe6e400f243 100644 --- a/src/libs/actions/IOU/NavigationHelpers.ts +++ b/src/libs/actions/IOU/NavigationHelpers.ts @@ -1,8 +1,7 @@ import sharedDismissModalAndOpenReportInInboxTab from '@libs/Navigation/helpers/dismissModalAndOpenReportInInboxTab'; -import isReportTopmostSplitNavigator from '@libs/Navigation/helpers/isReportTopmostSplitNavigator'; import navigateAfterExpenseCreate from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; -import {mergeTransactionIdsHighlightOnSearchRoute} from '@userActions/Transaction'; +import {mergeExpenseAddedGrowlTransactionIDs} from '@userActions/Transaction'; import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; @@ -20,14 +19,13 @@ function dismissModalAndOpenReportInInboxTab(reportID?: string, isInvoice?: bool } /** - * 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. + * Signals the "Expense added" growl for a newly-created transaction. */ -function highlightTransactionOnSearchRouteIfNeeded(isFromGlobalCreate: boolean | undefined, transactionID: string | undefined, dataType: SearchDataTypes) { - if (!isFromGlobalCreate || isReportTopmostSplitNavigator() || !transactionID) { +function signalExpenseAddedGrowl(transactionID: string | undefined, dataType: SearchDataTypes) { + if (!transactionID) { return; } - mergeTransactionIdsHighlightOnSearchRoute(dataType, {[transactionID]: true}); + mergeExpenseAddedGrowlTransactionIDs({[transactionID]: dataType}); } /** @@ -35,7 +33,7 @@ function highlightTransactionOnSearchRouteIfNeeded(isFromGlobalCreate: boolean | * 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 handleNavigateAfterExpenseCreate({ activeReportID, @@ -56,4 +54,4 @@ function handleNavigateAfterExpenseCreate({ navigateAfterExpenseCreate({activeReportID, transactionID, isFromGlobalCreate, isInvoice, hasMultipleTransactions, shouldAddPendingNewTransactionIDs, shouldNavigate}); } -export {dismissModalAndOpenReportInInboxTab, handleNavigateAfterExpenseCreate, highlightTransactionOnSearchRouteIfNeeded}; +export {dismissModalAndOpenReportInInboxTab, handleNavigateAfterExpenseCreate, signalExpenseAddedGrowl}; diff --git a/src/libs/actions/IOU/PerDiem.ts b/src/libs/actions/IOU/PerDiem.ts index 9292a1f7c02b..064a87b65233 100644 --- a/src/libs/actions/IOU/PerDiem.ts +++ b/src/libs/actions/IOU/PerDiem.ts @@ -67,7 +67,7 @@ import { mergePolicyRecentlyUsedCategories, mergePolicyRecentlyUsedCurrencies, } from './MoneyRequestBuilder'; -import {highlightTransactionOnSearchRouteIfNeeded} from './NavigationHelpers'; +import {signalExpenseAddedGrowl} from './NavigationHelpers'; function removeSubrate(transaction: OnyxEntry, currentIndex: string) { // Index comes from the route params and is a string @@ -1041,7 +1041,9 @@ function submitPerDiemExpense(submitPerDiemExpenseInformation: PerDiemExpenseInf TransitionTracker.runAfterTransitions({callback: () => removeDraftTransaction(CONST.IOU.OPTIMISTIC_TRANSACTION_ID), waitForUpcomingTransition: true}); - highlightTransactionOnSearchRouteIfNeeded(isFromGlobalCreate, transaction.transactionID, CONST.SEARCH.DATA_TYPES.EXPENSE); + if (isFromGlobalCreate) { + signalExpenseAddedGrowl(transaction.transactionID, CONST.SEARCH.DATA_TYPES.EXPENSE); + } if (activeReportID) { notifyNewAction(activeReportID, undefined, participantParams.payeeAccountID === currentUserAccountIDParam); diff --git a/src/libs/actions/IOU/SendInvoice.ts b/src/libs/actions/IOU/SendInvoice.ts index b78e32a89051..2b8e58a764d8 100644 --- a/src/libs/actions/IOU/SendInvoice.ts +++ b/src/libs/actions/IOU/SendInvoice.ts @@ -40,7 +40,7 @@ import type BasePolicyParams from './types/BasePolicyParams'; import {getAllPersonalDetails} from '.'; import {getReceiptError, mergePolicyRecentlyUsedCategories, mergePolicyRecentlyUsedCurrencies} from './MoneyRequestBuilder'; -import {highlightTransactionOnSearchRouteIfNeeded} from './NavigationHelpers'; +import {signalExpenseAddedGrowl} from './NavigationHelpers'; import {getSearchOnyxUpdate} from './SearchUpdate'; type SendInvoiceInformation = { @@ -813,7 +813,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 697fa680bbc9..b7dbe3f0ca44 100644 --- a/src/libs/actions/IOU/Split.ts +++ b/src/libs/actions/IOU/Split.ts @@ -87,7 +87,7 @@ import { mergePolicyRecentlyUsedCategories, mergePolicyRecentlyUsedCurrencies, } from './MoneyRequestBuilder'; -import {dismissModalAndOpenReportInInboxTab, highlightTransactionOnSearchRouteIfNeeded} from './NavigationHelpers'; +import {dismissModalAndOpenReportInInboxTab, signalExpenseAddedGrowl} from './NavigationHelpers'; import {addPendingNewTransactionIDs, isOneToTwoTransactionTransition} from './PendingNewTransactions'; type IOURequestType = ValueOf; @@ -2243,7 +2243,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 5719edf8aa64..adbc496298cf 100644 --- a/src/libs/actions/IOU/SplitTransactionUpdate.ts +++ b/src/libs/actions/IOU/SplitTransactionUpdate.ts @@ -72,6 +72,7 @@ import {getCleanUpTransactionThreadReportOnyxData} from './DeleteMoneyRequest'; import {getAllReports, getPolicyTagsData} from './index'; import {getMoneyRequestParticipantsFromReport} from './MoneyRequest'; import {getMoneyRequestInformation, getReportPreviewAction} from './MoneyRequestBuilder'; +import {signalExpenseAddedGrowl} from './NavigationHelpers'; import {addPendingNewTransactionIDs} from './PendingNewTransactions'; import {getDeleteTrackExpenseInformation} from './TrackExpense'; import {getUpdateMoneyRequestParams} from './UpdateMoneyRequest'; @@ -1928,19 +1929,25 @@ function updateSplitTransactionsFromSplitExpensesFlow(params: UpdateSplitTransac const targetReportID = params.expenseReport?.reportID ?? String(CONST.DEFAULT_NUMBER_ID); + // The new (not pre-existing) split transactions created by this save, in split order. Reverse splits + // create none. + const existingChildTransactionIDs = new Set(allChildTransactions.map((tx) => tx?.transactionID).filter(Boolean)); + const newSplitTransactionIDs = isReverseSplitOperation + ? [] + : splitExpenses + .map((splitExpense) => splitExpense.transactionID) + .filter((transactionID): transactionID is string => !!transactionID && !existingChildTransactionIDs.has(transactionID)); + // Register newly created split transaction IDs so they briefly highlight in the expense list. - // We skip existing transactions (already in allChildTransactions), reverse splits (no new transactions are created), - // and the last-transaction case (the report navigates away before the highlight renders). + // We skip the last-transaction case (the report navigates away before the highlight renders). if (params.expenseReport?.reportID && !isReverseSplitOperation && !isLastTransactionInReport) { - const existingChildTransactionIDs = new Set(allChildTransactions.map((tx) => tx?.transactionID).filter(Boolean)); - for (const splitExpense of splitExpenses) { - if (!splitExpense.transactionID || existingChildTransactionIDs.has(splitExpense.transactionID)) { - continue; - } - addPendingNewTransactionIDs(targetReportID, splitExpense.transactionID); + for (const transactionID of newSplitTransactionIDs) { + addPendingNewTransactionIDs(targetReportID, transactionID); } } + signalExpenseAddedGrowl(newSplitTransactionIDs.at(-1), CONST.SEARCH.DATA_TYPES.EXPENSE); + if (isSearchPageTopmostFullScreenRoute || !params.transactionReport?.parentReportID) { if (!isSelfDMSplit) { Navigation.navigateBackToLastSuperWideRHPScreen(); diff --git a/src/libs/actions/IOU/TrackExpense.ts b/src/libs/actions/IOU/TrackExpense.ts index 1e37ab32823f..6b1bad5016ee 100644 --- a/src/libs/actions/IOU/TrackExpense.ts +++ b/src/libs/actions/IOU/TrackExpense.ts @@ -120,7 +120,7 @@ import type { import {deleteMoneyRequest, getCleanUpTransactionThreadReportOnyxData, getNavigationUrlOnMoneyRequestDelete} from './DeleteMoneyRequest'; import {getAllReports, getAllTransactionDrafts, getAllTransactions, getAllTransactionViolations} from './index'; import {buildMinimalTransactionForFormula, getMoneyRequestInformation, getReceiptError, getReportPreviewAction, getTransactionWithPreservedLocalReceiptSource} from './MoneyRequestBuilder'; -import {highlightTransactionOnSearchRouteIfNeeded} from './NavigationHelpers'; +import {signalExpenseAddedGrowl} from './NavigationHelpers'; import {addPendingNewTransactionIDs, isOneToTwoTransactionTransition} from './PendingNewTransactions'; import {getSearchOnyxUpdate} from './SearchUpdate'; @@ -1621,7 +1621,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, @@ -1917,8 +1917,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) { @@ -1929,7 +1929,7 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep ); } - return {iouReport}; + return {iouReport, transactionID: transaction.transactionID}; } /** @@ -2856,11 +2856,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/Transaction.ts b/src/libs/actions/Transaction.ts index 39d9f173b32a..28065a42264b 100644 --- a/src/libs/actions/Transaction.ts +++ b/src/libs/actions/Transaction.ts @@ -1934,8 +1934,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) { @@ -1970,6 +1970,6 @@ export { changeTransactionsReport, setTransactionReport, getDefaultP2PMileageRate, - mergeTransactionIdsHighlightOnSearchRoute, + mergeExpenseAddedGrowlTransactionIDs, getDuplicateTransactionDetails, }; diff --git a/tests/actions/IOU/SplitReportTotalsTest.ts b/tests/actions/IOU/SplitReportTotalsTest.ts index f4a808d61990..b0a718d65653 100644 --- a/tests/actions/IOU/SplitReportTotalsTest.ts +++ b/tests/actions/IOU/SplitReportTotalsTest.ts @@ -434,29 +434,31 @@ describe('actions/IOU', () => { it('handleNavigateAfterExpenseCreate', async () => { const mockedIsReportTopmostSplitNavigator = isReportTopmostSplitNavigator as jest.MockedFunction; - const spyOnMergeTransactionIdsHighlightOnSearchRoute = jest.spyOn(require('@libs/actions/Transaction'), 'mergeTransactionIdsHighlightOnSearchRoute'); + const spyOnMergeExpenseAddedGrowlTransactionIDs = jest.spyOn(require('@libs/actions/Transaction'), 'mergeExpenseAddedGrowlTransactionIDs'); const activeReportID = '1'; const transactionID = '1'; mockedIsReportTopmostSplitNavigator.mockReturnValue(false); handleNavigateAfterExpenseCreate({activeReportID, isFromGlobalCreate: false}); - expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledTimes(0); + expect(spyOnMergeExpenseAddedGrowlTransactionIDs).toHaveBeenCalledTimes(0); handleNavigateAfterExpenseCreate({activeReportID, isFromGlobalCreate: true}); - expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledTimes(0); + expect(spyOnMergeExpenseAddedGrowlTransactionIDs).toHaveBeenCalledTimes(0); mockedIsReportTopmostSplitNavigator.mockReturnValue(true); handleNavigateAfterExpenseCreate({activeReportID, isFromGlobalCreate: true, transactionID}); - expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledTimes(0); + expect(spyOnMergeExpenseAddedGrowlTransactionIDs).toHaveBeenCalledTimes(0); mockedIsReportTopmostSplitNavigator.mockReturnValue(false); handleNavigateAfterExpenseCreate({activeReportID, isFromGlobalCreate: true, transactionID}); - expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledTimes(0); + expect(spyOnMergeExpenseAddedGrowlTransactionIDs).toHaveBeenCalledTimes(1); + expect(spyOnMergeExpenseAddedGrowlTransactionIDs).toHaveBeenLastCalledWith({[transactionID]: CONST.SEARCH.DATA_TYPES.EXPENSE}); handleNavigateAfterExpenseCreate({activeReportID, isFromGlobalCreate: true, transactionID, isInvoice: true}); - expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledTimes(0); + expect(spyOnMergeExpenseAddedGrowlTransactionIDs).toHaveBeenCalledTimes(2); + expect(spyOnMergeExpenseAddedGrowlTransactionIDs).toHaveBeenLastCalledWith({[transactionID]: CONST.SEARCH.DATA_TYPES.INVOICE}); - spyOnMergeTransactionIdsHighlightOnSearchRoute.mockReset(); + spyOnMergeExpenseAddedGrowlTransactionIDs.mockReset(); }); describe('createSplitsAndOnyxData', () => { diff --git a/tests/actions/IOUTest/SplitTest.ts b/tests/actions/IOUTest/SplitTest.ts index e2e8c5f9481b..8f15d276dd02 100644 --- a/tests/actions/IOUTest/SplitTest.ts +++ b/tests/actions/IOUTest/SplitTest.ts @@ -7631,7 +7631,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({ @@ -7641,8 +7641,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(Object.keys(growlSignal?.[CONST.SEARCH.DATA_TYPES.EXPENSE] ?? {})).toContain(result.transactionID); }); 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..d67b3f9b4688 --- /dev/null +++ b/tests/unit/ExpenseAddedGrowlTest.tsx @@ -0,0 +1,136 @@ +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; + +/** Run Onyx mutations and let the growl's effect settle, wrapped in act() so React state updates aren't flagged. */ +function flush(mutate: () => Promise) { + return act(async () => { + await mutate(); + await waitForBatchedUpdates(); + }); +} + +/** Seed the signal + the created transaction so the growl can capture and (given the deferred-write gate) show. */ +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}); + }); +} + +/** The props of the most recently rendered growl, or undefined if it never rendered. */ +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 () => { + render(); + await seedExpense('1', 'report-1'); + + 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 () => { + render(); + await seedExpense('1', 'report-1', INVOICE); + + expect(lastGrowlProps()?.bodyText).toBe('iou.invoiceSent'); + }); + + it("suppresses the growl when the user is already viewing the expense's report", async () => { + mockGetTopmostReportId.mockReturnValue('report-1'); + render(); + await seedExpense('1', 'report-1'); + + 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 () => { + mockGetTopmostReportId.mockReturnValue('some-open-report'); + render(); + await seedExpense('1', CONST.REPORT.UNREPORTED_REPORT_ID); + + expect(mockGrowlContent).toHaveBeenCalled(); + }); + + it('shows a single growl for a batch of new expenses and clears the whole signal', async () => { + render(); + 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); + }); + + // Exactly one growl shown for the batch... + expect(mockGrowlContent).toHaveBeenCalledTimes(1); + + // ...and the 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', () => { + render(); + 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/navigateAfterExpenseCreateTest.ts b/tests/unit/navigateAfterExpenseCreateTest.ts index b7da2c18731e..da1dbd2deeb2 100644 --- a/tests/unit/navigateAfterExpenseCreateTest.ts +++ b/tests/unit/navigateAfterExpenseCreateTest.ts @@ -1,8 +1,11 @@ -import navigateAfterExpenseCreate from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; +import navigateAfterExpenseCreate, {navigateToCreatedExpense} from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; 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(); @@ -12,6 +15,7 @@ const mockGetTrackingState = jest.fn(); // Declared but assigned after jest.mock hoisting - use require() to access the mock in tests let mockSetPendingSubmitFollowUpAction: jest.Mock; const mockGetCurrentSearchQueryJSON = jest.fn(); +const mockGetReportTransactions = jest.fn>, [string | undefined]>(); jest.mock('@libs/Navigation/helpers/isReportTopmostSplitNavigator', () => () => mockIsReportTopmostSplitNavigator() as boolean); jest.mock('@libs/Navigation/helpers/isSearchTopmostFullScreenRoute', () => () => mockIsSearchTopmostFullScreenRoute() as boolean); @@ -30,6 +34,15 @@ jest.mock('@libs/SearchQueryUtils', () => ({ buildCannedSearchQuery: jest.fn(({type}: {type: string}) => `type:${type}`), getCurrentSearchQueryJSON: () => mockGetCurrentSearchQueryJSON() as undefined, })); +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(), @@ -37,6 +50,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), @@ -64,6 +78,7 @@ describe('navigateAfterExpenseCreate', () => { mockIsReportOpenInRHP.mockReturnValue(false); mockGetTrackingState.mockReturnValue(null); mockGetCurrentSearchQueryJSON.mockReturnValue(undefined); + mockGetReportTransactions.mockReturnValue([]); }); it('should dismiss to report when not from global create', () => { @@ -158,4 +173,87 @@ 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 () => { + mockIsReportTopmostSplitNavigator.mockReturnValue(false); + mockIsSearchTopmostFullScreenRoute.mockReturnValue(true); + + navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'}); + await waitForBatchedUpdates(); + + 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 () => { + mockIsReportTopmostSplitNavigator.mockReturnValue(false); + mockIsSearchTopmostFullScreenRoute.mockReturnValue(true); + mockIsReportOpenInRHP.mockReturnValue(true); + + navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'}); + await waitForBatchedUpdates(); + + 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', () => { + mockIsReportTopmostSplitNavigator.mockReturnValue(true); + mockIsSearchTopmostFullScreenRoute.mockReturnValue(false); + mockGetIsNarrowLayout.mockReturnValue(true); + + navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'}); + + 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 () => { + mockIsReportTopmostSplitNavigator.mockReturnValue(true); + mockIsSearchTopmostFullScreenRoute.mockReturnValue(false); + mockGetIsNarrowLayout.mockReturnValue(false); + mockGetReportTransactions.mockReturnValue([{transactionID: 'txn-1'}, {transactionID: 'txn-2'}]); + + navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'}); + await waitForBatchedUpdates(); + + 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', () => { + mockIsReportTopmostSplitNavigator.mockReturnValue(true); + mockIsSearchTopmostFullScreenRoute.mockReturnValue(false); + mockGetIsNarrowLayout.mockReturnValue(false); + mockGetReportTransactions.mockReturnValue([{transactionID: 'txn-1'}]); + + navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'}); + + 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', () => { + mockIsReportTopmostSplitNavigator.mockReturnValue(true); + mockIsSearchTopmostFullScreenRoute.mockReturnValue(false); + mockGetIsNarrowLayout.mockReturnValue(false); + + navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: undefined}); + + // Matches how tapping the expense in its self-DM chat opens it (full report), not the Spend RHP. + 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 () => { + mockIsReportTopmostSplitNavigator.mockReturnValue(false); + mockIsSearchTopmostFullScreenRoute.mockReturnValue(true); + + navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: undefined}); + await waitForBatchedUpdates(); + + 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/useSearchHighlightAndScrollTest.ts index dc812f75fa56..69514cce9ba2 100644 --- a/tests/unit/useSearchHighlightAndScrollTest.ts +++ b/tests/unit/useSearchHighlightAndScrollTest.ts @@ -1,8 +1,8 @@ /* eslint-disable @typescript-eslint/naming-convention */ import {renderHook} from '@testing-library/react-native'; -import useSearchHighlightAndScroll from '@hooks/useSearchHighlightAndScroll'; -import type {UseSearchHighlightAndScroll} from '@hooks/useSearchHighlightAndScroll'; +import useSearchAutoRefetch from '@hooks/useSearchHighlightAndScroll'; +import type {UseSearchAutoRefetch} from '@hooks/useSearchHighlightAndScroll'; import {search} from '@libs/actions/Search'; @@ -28,14 +28,14 @@ afterEach(() => { jest.clearAllMocks(); }); -describe('useSearchHighlightAndScroll', () => { +describe('useSearchAutoRefetch', () => { beforeAll(async () => { Onyx.init({ keys: ONYXKEYS, }); }); - const baseProps: UseSearchHighlightAndScroll = { + const baseProps: UseSearchAutoRefetch = { shouldUseLiveData: false, searchResults: { data: { @@ -72,7 +72,7 @@ describe('useSearchHighlightAndScroll', () => { }; it('should not trigger search when collections are empty', () => { - renderHook(() => useSearchHighlightAndScroll(baseProps)); + renderHook(() => useSearchAutoRefetch(baseProps)); expect(search).not.toHaveBeenCalled(); }); @@ -83,7 +83,7 @@ describe('useSearchHighlightAndScroll', () => { previousTransactions: {'1': {transactionID: '1'}}, }; - const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), { + const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error initialProps, @@ -107,7 +107,7 @@ 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, }); @@ -140,7 +140,7 @@ describe('useSearchHighlightAndScroll', () => { }, }; - const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), { + const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error initialProps: chatProps, @@ -175,7 +175,7 @@ describe('useSearchHighlightAndScroll', () => { }, }; - const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), { + const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error initialProps, @@ -214,7 +214,7 @@ describe('useSearchHighlightAndScroll', () => { }, }; - const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), { + const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error initialProps: chatProps, @@ -234,157 +234,4 @@ describe('useSearchHighlightAndScroll', () => { rerender(updatedProps); expect(search).not.toHaveBeenCalled(); }); - - it('should return multiple new search result keys when there are multiple new expenses', () => { - const {rerender, result} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), { - initialProps: baseProps, - }); - const updatedProps = { - ...baseProps, - searchResults: { - ...baseProps.searchResults, - data: { - transactions_1: { - transactionID: '1', - }, - transactions_2: { - transactionID: '2', - }, - }, - }, - transactions: { - '1': {transactionID: '1'}, - '2': {transactionID: '2'}, - '3': {transactionID: '3'}, - }, - previousTransactions: { - '1': {transactionID: '1'}, - }, - }; - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error - rerender(updatedProps); - expect(result.current.newSearchResultKeys?.size).toBe(2); - }); - - 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 = { - ...baseProps, - searchResults: { - ...baseProps.searchResults, - data: { - transactions_1: { - transactionID: '1', - }, - transactions_2: { - transactionID: '2', - }, - }, - }, - transactions: { - '1': {transactionID: '1'}, - '2': {transactionID: '2'}, - '3': {transactionID: '3'}, - }, - previousTransactions: { - '1': {transactionID: '1'}, - }, - } as unknown as UseSearchHighlightAndScroll; - - // 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 = { - ...updatedProps1, - searchResults: { - ...updatedProps1.searchResults, - data: { - transactions_1: { - transactionID: '1', - }, - transactions_2: { - transactionID: '2', - }, - transactions_3: { - transactionID: '3', - }, - }, - }, - } as unknown as UseSearchHighlightAndScroll; - - 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 = { - ...baseProps, - queryJSON: {...baseProps.queryJSON, type: 'chat' as const}, - reportActions: { - reportActions_1: { - '1': {actionName: 'EXISTING', reportActionID: '1'}, - }, - }, - }; - const {rerender, result} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error - initialProps: chatProps, - }); - const updatedProps = { - ...chatProps, - searchResults: { - ...baseProps.searchResults, - data: { - reportActions_1: { - '1': {actionName: 'EXISTING', reportActionID: '1'}, - }, - reportActions_2: { - '2': {actionName: 'EXISTING', reportActionID: '2'}, - }, - }, - }, - reportActions: { - reportActions_1: { - '1': {actionName: 'EXISTING', reportActionID: '1'}, - }, - reportActions_2: { - '2': {actionName: 'EXISTING', reportActionID: '2'}, - }, - reportActions_3: { - '3': {actionName: 'EXISTING', reportActionID: '3'}, - }, - }, - previousReportActions: { - reportActions_1: { - '1': {actionName: 'EXISTING', reportActionID: '1'}, - }, - }, - }; - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error - rerender(updatedProps); - expect(result.current.newSearchResultKeys?.size).toBe(2); - }); }); From 956d2a2b319ac37e5ba0cb188497128f7de96329 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Mon, 20 Jul 2026 18:11:29 +0200 Subject: [PATCH 02/37] Fix tests --- src/libs/ExportOnyxState/common.ts | 2 +- tests/actions/IOUTest/SplitTest.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libs/ExportOnyxState/common.ts b/src/libs/ExportOnyxState/common.ts index cd4e771eb1ed..d24aa15dd914 100644 --- a/src/libs/ExportOnyxState/common.ts +++ b/src/libs/ExportOnyxState/common.ts @@ -172,6 +172,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.FULLSCREEN_VISIBILITY, ONYXKEYS.HAS_DENIED_CONTACT_IMPORT_PROMPT, ONYXKEYS.HAS_LOADED_APP, @@ -289,7 +290,6 @@ const safeOnyxKeys = new Set([ ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_PENDING, ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_SUCCESSFUL, ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_SNAPSHOT_HASH, - ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS, ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS, ONYXKEYS.TRAVEL_INVOICE_STATEMENT, ONYXKEYS.VALIDATE_DOMAIN_TWO_FACTOR_CODE, diff --git a/tests/actions/IOUTest/SplitTest.ts b/tests/actions/IOUTest/SplitTest.ts index 709f9f67394c..60a32af83150 100644 --- a/tests/actions/IOUTest/SplitTest.ts +++ b/tests/actions/IOUTest/SplitTest.ts @@ -7686,7 +7686,7 @@ describe('createDistanceRequest', () => { await waitForBatchedUpdates(); const growlSignal = await getOnyxValue(ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS); - expect(Object.keys(growlSignal?.[CONST.SEARCH.DATA_TYPES.EXPENSE] ?? {})).toContain(result.transactionID); + 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 () => { From b3e03b2c46939bd298d6f051f148885000b27a69 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Tue, 21 Jul 2026 14:53:59 +0200 Subject: [PATCH 03/37] Invoke findSelfDMReportID only when a transaction is active in ExpenseAddedGrowl --- src/components/ExpenseAddedGrowl.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/ExpenseAddedGrowl.tsx b/src/components/ExpenseAddedGrowl.tsx index 7bd3acc65878..35235a5f7cdd 100644 --- a/src/components/ExpenseAddedGrowl.tsx +++ b/src/components/ExpenseAddedGrowl.tsx @@ -47,7 +47,9 @@ function ExpenseAddedGrowl() { // on the self-DM report - not on transaction.reportID. Read actions from the report that actually hosts the // action so "View" resolves the real transaction thread (the action's childReportID) instead of fabricating // a mismatched optimistic one. - const hostReportID = !reportID || reportID === CONST.REPORT.UNREPORTED_REPORT_ID ? findSelfDMReportID() : reportID; + const isUnreportedExpense = !reportID || reportID === CONST.REPORT.UNREPORTED_REPORT_ID; + const selfDMReportID = transactionID && isUnreportedExpense ? findSelfDMReportID() : undefined; + const hostReportID = isUnreportedExpense ? selfDMReportID : reportID; const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${hostReportID}`); // Only IOU/expense/invoice reports have the expense-report RHP that "View" opens. A tracked/unreported // (self-DM) expense lives in a chat, so it has no such report - leaving iouReportID undefined makes From a9be08a2d5aee78e20389d9cef517878c27e8d4f Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Tue, 21 Jul 2026 15:02:35 +0200 Subject: [PATCH 04/37] Add comment for latestTransactionID last-created assumption --- src/components/ExpenseAddedGrowl.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/ExpenseAddedGrowl.tsx b/src/components/ExpenseAddedGrowl.tsx index 35235a5f7cdd..16b8ca31cb13 100644 --- a/src/components/ExpenseAddedGrowl.tsx +++ b/src/components/ExpenseAddedGrowl.tsx @@ -62,6 +62,8 @@ function ExpenseAddedGrowl() { 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) { From 62ee8e8f77bc99723a74985ed496333eb632e27e Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Tue, 21 Jul 2026 15:38:44 +0200 Subject: [PATCH 05/37] Clear EXPENSE_ADDED_GROWL_TRANSACTION_IDS on new session --- src/setup/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/setup/index.ts b/src/setup/index.ts index c65d8f014455..d1723825d273 100644 --- a/src/setup/index.ts +++ b/src/setup/index.ts @@ -57,6 +57,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'], From e53cdd386056141c46c20de56bc24ce62437a2fa Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Tue, 21 Jul 2026 15:46:45 +0200 Subject: [PATCH 06/37] Add key to ensure remounting GrowlNotificationContent in ExpenseAddedGrowl --- src/components/ExpenseAddedGrowl.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/ExpenseAddedGrowl.tsx b/src/components/ExpenseAddedGrowl.tsx index 16b8ca31cb13..b1078bb5d705 100644 --- a/src/components/ExpenseAddedGrowl.tsx +++ b/src/components/ExpenseAddedGrowl.tsx @@ -125,6 +125,7 @@ function ExpenseAddedGrowl() { return ( Date: Tue, 21 Jul 2026 16:08:45 +0200 Subject: [PATCH 07/37] Remove mergeExpenseAddedGrowlTransactionIDs call from navigateAfterExpenseCreate --- .../helpers/navigateAfterExpenseCreate.ts | 4 --- tests/actions/IOU/SplitReportTotalsTest.ts | 31 ------------------- 2 files changed, 35 deletions(-) diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts index 55428b0fa6ab..22682a679f23 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 {mergeExpenseAddedGrowlTransactionIDs} from '@libs/actions/Transaction'; import {setActiveTransactionIDs} from '@libs/actions/TransactionThreadNavigation'; import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import Log from '@libs/Log'; @@ -140,9 +139,6 @@ function navigateAfterExpenseCreate({ const type = isInvoice ? CONST.SEARCH.DATA_TYPES.INVOICE : CONST.SEARCH.DATA_TYPES.EXPENSE; - // Signal that an expense was added so we can surface the "Expense added" growl. - mergeExpenseAddedGrowlTransactionIDs({[transactionID]: type}); - // When already on Search ROOT with the same type (expense vs invoice), we navigate to the same screen (no-op or refresh); record as dismiss_modal_only. // When on another Search sub-tab (e.g. Chats), or on Search with a different type (e.g. on Invoice, submitting expense), record as navigate_to_search. const searchNavigatorState = getNavigateAfterCreateSearchNavigatorState(); diff --git a/tests/actions/IOU/SplitReportTotalsTest.ts b/tests/actions/IOU/SplitReportTotalsTest.ts index ed0216698ca4..c189c0c2e700 100644 --- a/tests/actions/IOU/SplitReportTotalsTest.ts +++ b/tests/actions/IOU/SplitReportTotalsTest.ts @@ -1,10 +1,8 @@ -import {handleNavigateAfterExpenseCreate} from '@libs/actions/IOU/NavigationHelpers'; import {addPendingNewTransactionIDs} from '@libs/actions/IOU/PendingNewTransactions'; 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 {rand64} from '@libs/NumberUtils'; import type * as PolicyUtils from '@libs/PolicyUtils'; @@ -432,35 +430,6 @@ describe('actions/IOU', () => { }); }); - it('handleNavigateAfterExpenseCreate', async () => { - const mockedIsReportTopmostSplitNavigator = isReportTopmostSplitNavigator as jest.MockedFunction; - const spyOnMergeExpenseAddedGrowlTransactionIDs = jest.spyOn(require('@libs/actions/Transaction'), 'mergeExpenseAddedGrowlTransactionIDs'); - const activeReportID = '1'; - const transactionID = '1'; - mockedIsReportTopmostSplitNavigator.mockReturnValue(false); - - handleNavigateAfterExpenseCreate({activeReportID, isFromGlobalCreate: false}); - expect(spyOnMergeExpenseAddedGrowlTransactionIDs).toHaveBeenCalledTimes(0); - - handleNavigateAfterExpenseCreate({activeReportID, isFromGlobalCreate: true}); - expect(spyOnMergeExpenseAddedGrowlTransactionIDs).toHaveBeenCalledTimes(0); - - mockedIsReportTopmostSplitNavigator.mockReturnValue(true); - handleNavigateAfterExpenseCreate({activeReportID, isFromGlobalCreate: true, transactionID}); - expect(spyOnMergeExpenseAddedGrowlTransactionIDs).toHaveBeenCalledTimes(0); - - mockedIsReportTopmostSplitNavigator.mockReturnValue(false); - handleNavigateAfterExpenseCreate({activeReportID, isFromGlobalCreate: true, transactionID}); - expect(spyOnMergeExpenseAddedGrowlTransactionIDs).toHaveBeenCalledTimes(1); - expect(spyOnMergeExpenseAddedGrowlTransactionIDs).toHaveBeenLastCalledWith({[transactionID]: CONST.SEARCH.DATA_TYPES.EXPENSE}); - - handleNavigateAfterExpenseCreate({activeReportID, isFromGlobalCreate: true, transactionID, isInvoice: true}); - expect(spyOnMergeExpenseAddedGrowlTransactionIDs).toHaveBeenCalledTimes(2); - expect(spyOnMergeExpenseAddedGrowlTransactionIDs).toHaveBeenLastCalledWith({[transactionID]: CONST.SEARCH.DATA_TYPES.INVOICE}); - - spyOnMergeExpenseAddedGrowlTransactionIDs.mockReset(); - }); - describe('createSplitsAndOnyxData', () => { const mockPersonalDetails: PersonalDetailsList = { [RORY_ACCOUNT_ID]: {accountID: RORY_ACCOUNT_ID, login: RORY_EMAIL, displayName: 'Rory'}, From 698268cb728395ac6cadd905ece4777ad22597fe Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Tue, 21 Jul 2026 16:31:59 +0200 Subject: [PATCH 08/37] Remove useAnimatedHighlightStyle usage from Spend table --- .../SearchList/ListItem/ChatListItem.tsx | 11 ++--------- .../ListItem/ExpenseReportListItem.tsx | 13 ++----------- .../ListItem/GroupChildrenContainer.tsx | 19 ++----------------- .../SearchList/ListItem/GroupHeader.tsx | 10 +--------- .../SearchList/ListItem/TaskListItem.tsx | 17 ++++++----------- .../ListItem/TransactionGroupListItem.tsx | 10 +--------- .../TransactionListItemNarrow.tsx | 11 +---------- .../TransactionListItemWide.tsx | 17 +++++++---------- 8 files changed, 22 insertions(+), 86 deletions(-) diff --git a/src/components/Search/SearchList/ListItem/ChatListItem.tsx b/src/components/Search/SearchList/ListItem/ChatListItem.tsx index 3cb49da26074..9fea29a1b49e 100644 --- a/src/components/Search/SearchList/ListItem/ChatListItem.tsx +++ b/src/components/Search/SearchList/ListItem/ChatListItem.tsx @@ -2,7 +2,6 @@ import {useRowSelection} from '@components/Search/SearchSelectionProvider'; import BaseListItem from '@components/SelectionList/ListItem/BaseListItem'; import type {ListItem} from '@components/SelectionList/types'; -import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle'; import useOnyx from '@hooks/useOnyx'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -43,18 +42,12 @@ function ChatListItem({ const styles = useThemeStyles(); const theme = useTheme(); const {isSelected} = useRowSelection(item.keyForList); - const animatedHighlightStyle = useAnimatedHighlightStyle({ - borderRadius: variables.componentBorderRadius, - shouldHighlight: false, - highlightColor: theme.messageHighlightBG, - backgroundColor: theme.highlightBG, - }); const pressableStyle = [ styles.selectionListPressableItemWrapper, styles.p0, styles.textAlignLeft, styles.overflowHidden, - // 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, @@ -82,7 +75,7 @@ function ChatListItem({ keyForList={item.keyForList} onFocus={onFocus} shouldSyncFocus={shouldSyncFocus} - pressableWrapperStyle={[styles.mh5, animatedHighlightStyle]} + pressableWrapperStyle={[styles.mh5, {backgroundColor: theme.highlightBG, borderRadius: variables.componentBorderRadius}]} hoverStyle={isSelected && styles.activeComponentBG} forwardedFSClass={fsClass} > diff --git a/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx b/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx index a6391887bdba..945ac2ae9c29 100644 --- a/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx +++ b/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx @@ -12,7 +12,6 @@ import BaseListItem from '@components/SelectionList/ListItem/BaseListItem'; import type {ListItem} from '@components/SelectionList/types'; import Text from '@components/Text'; -import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle'; import useConfirmModal from '@hooks/useConfirmModal'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; @@ -351,7 +350,7 @@ function ExpenseReportListItemInner({ 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, @@ -372,14 +371,6 @@ function ExpenseReportListItemInner({ [styles, isLargeScreenWidth], ); - const animatedHighlightStyle = useAnimatedHighlightStyle({ - borderRadius: 0, - shouldHighlight: false, - highlightColor: theme.messageHighlightBG, - backgroundColor: isSelected ? theme.activeComponentBG : theme.highlightBG, - shouldApplyOtherStyles: !isLargeScreenWidth, - }); - const shouldShowViolationDescription = isOpenExpenseReport(reportItem) || isProcessingReport(reportItem); // Show violation description if either: @@ -479,7 +470,7 @@ function ExpenseReportListItemInner({ hoverStyle={isSelected && styles.activeComponentBG} pressableWrapperStyle={[ styles.mh5, - animatedHighlightStyle, + {backgroundColor: isSelected ? theme.activeComponentBG : theme.highlightBG, ...(!isLargeScreenWidth && {borderRadius: 0})}, 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 f8d35d60b552..dc7c7bd02d41 100644 --- a/src/components/Search/SearchList/ListItem/GroupChildrenContainer.tsx +++ b/src/components/Search/SearchList/ListItem/GroupChildrenContainer.tsx @@ -1,6 +1,5 @@ import {useSearchSelectionContext} from '@components/Search/SearchContext'; -import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle'; import useExpandCollapseAnimation from '@hooks/useExpandCollapseAnimation'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -43,27 +42,13 @@ function GroupChildrenContainer({ const isSelected = !!item.isSelected || (item.transactions.length > 0 && item.transactions.every((transaction) => selectedTransactions[transaction.transactionID]?.isSelected)); - const animatedHighlightStyle = useAnimatedHighlightStyle({ - shouldHighlight: 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 1a234bdfbd47..2def41814182 100644 --- a/src/components/Search/SearchList/ListItem/GroupHeader.tsx +++ b/src/components/Search/SearchList/ListItem/GroupHeader.tsx @@ -7,7 +7,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'; @@ -219,13 +218,6 @@ function GroupHeader({ keyForList: originalKey, }); - const animatedHighlightStyle = useAnimatedHighlightStyle({ - shouldHighlight: false, - highlightColor: theme.messageHighlightBG, - backgroundColor: isItemSelected ? theme.activeComponentBG : theme.highlightBG, - shouldApplyOtherStyles: false, - }); - const handleSelectionButtonPress = () => { onCheckboxPress(withOriginalKey(item), isExpenseReportType ? undefined : effectiveTransactions); }; @@ -408,7 +400,7 @@ function GroupHeader({ ]} wrapperStyle={[ styles.mh5, - animatedHighlightStyle, + {backgroundColor: isItemSelected ? theme.activeComponentBG : theme.highlightBG}, styles.userSelectNone, isLargeScreenWidth ? [StyleUtils.getSearchTableGroupRowBorderStyle(isFirstItem, isLastItemCollapsed, isItemSelected), isLastItemCollapsed && styles.overflowHidden] diff --git a/src/components/Search/SearchList/ListItem/TaskListItem.tsx b/src/components/Search/SearchList/ListItem/TaskListItem.tsx index 44b2a7124c0f..12c65d502e62 100644 --- a/src/components/Search/SearchList/ListItem/TaskListItem.tsx +++ b/src/components/Search/SearchList/ListItem/TaskListItem.tsx @@ -2,7 +2,6 @@ import {useRowSelection} from '@components/Search/SearchSelectionProvider'; import BaseListItem from '@components/SelectionList/ListItem/BaseListItem'; import type {ListItem} from '@components/SelectionList/types'; -import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle'; import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -59,7 +58,7 @@ function TaskListItem({ styles.selectionListPressableItemWrapper, styles.pv3, 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, @@ -72,14 +71,6 @@ function TaskListItem({ isLargeScreenWidth ? {...styles.flexRow, ...styles.justifyContentBetween, ...styles.alignItemsCenter} : {...styles.flexColumn, ...styles.alignItemsStretch}, ]; - const animatedHighlightStyle = useAnimatedHighlightStyle({ - borderRadius: StyleUtils.getSearchTableHighlightBorderRadius(isLargeScreenWidth), - shouldHighlight: false, - highlightColor: theme.messageHighlightBG, - backgroundColor: theme.highlightBG, - shouldApplyOtherStyles: !isLargeScreenWidth, - }); - const fsClass = FS.getChatFSClass(parentReport); return ( @@ -99,7 +90,11 @@ function TaskListItem({ onLongPressRow={onLongPressRow} shouldSyncFocus={shouldSyncFocus} hoverStyle={isSelected && styles.activeComponentBG} - pressableWrapperStyle={[styles.mh5, animatedHighlightStyle, isLargeScreenWidth && isLastItem && [styles.tableBottomRadius, styles.overflowHidden]]} + pressableWrapperStyle={[ + styles.mh5, + {backgroundColor: theme.highlightBG, ...(!isLargeScreenWidth && {borderRadius: StyleUtils.getSearchTableHighlightBorderRadius(isLargeScreenWidth)})}, + isLargeScreenWidth && isLastItem && [styles.tableBottomRadius, styles.overflowHidden], + ]} forwardedFSClass={fsClass} > ({ const pressableStyle = [styles.transactionListItemStyle, styles.p4, styles.noBorderRadius, isSelected && styles.activeComponentBG, {...styles.flexColumn, ...styles.alignItemsStretch}]; - const animatedHighlightStyle = useAnimatedHighlightStyle({ - borderRadius: 0, - shouldHighlight: false, - highlightColor: theme.messageHighlightBG, - backgroundColor: isSelected ? theme.activeComponentBG : theme.highlightBG, - shouldApplyOtherStyles: true, - }); - return ( ({ wrapperStyle={[ styles.mh5, styles.flex1, - animatedHighlightStyle, + {backgroundColor: isSelected ? theme.activeComponentBG : theme.highlightBG, borderRadius: 0}, 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 ddf71ca2cd2b..a7e5a97d2303 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'; @@ -142,14 +141,6 @@ function TransactionListItemWide({ }, ]; - const animatedHighlightStyle = useAnimatedHighlightStyle({ - borderRadius: 0, - shouldHighlight: 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, + {backgroundColor: isSelected ? theme.activeComponentBG : theme.highlightBG}, + styles.userSelectNone, + isLastItem && [styles.tableBottomRadius, styles.overflowHidden], + ]} > {({hovered}) => ( Date: Tue, 21 Jul 2026 16:59:35 +0200 Subject: [PATCH 09/37] Remove unused handleNavigateAfterExpenseCreate --- src/libs/actions/IOU/NavigationHelpers.ts | 29 +--------------------- src/libs/telemetry/submitFollowUpAction.ts | 4 +-- 2 files changed, 3 insertions(+), 30 deletions(-) diff --git a/src/libs/actions/IOU/NavigationHelpers.ts b/src/libs/actions/IOU/NavigationHelpers.ts index ffe6e400f243..d102461956d8 100644 --- a/src/libs/actions/IOU/NavigationHelpers.ts +++ b/src/libs/actions/IOU/NavigationHelpers.ts @@ -1,5 +1,4 @@ import sharedDismissModalAndOpenReportInInboxTab from '@libs/Navigation/helpers/dismissModalAndOpenReportInInboxTab'; -import navigateAfterExpenseCreate from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; import {mergeExpenseAddedGrowlTransactionIDs} from '@userActions/Transaction'; @@ -28,30 +27,4 @@ function signalExpenseAddedGrowl(transactionID: string | undefined, dataType: Se mergeExpenseAddedGrowlTransactionIDs({[transactionID]: dataType}); } -/** - * 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 show the "Expense added" growl. - */ -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 {dismissModalAndOpenReportInInboxTab, handleNavigateAfterExpenseCreate, signalExpenseAddedGrowl}; +export {dismissModalAndOpenReportInInboxTab, signalExpenseAddedGrowl}; 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 = From 569cb425b616a6be5baaa897a5b59fdb121c3c07 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Thu, 23 Jul 2026 18:30:14 +0200 Subject: [PATCH 10/37] Fix not showing growl when submitting photo from gallery --- src/pages/Share/SubmitDetailsPage.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/pages/Share/SubmitDetailsPage.tsx b/src/pages/Share/SubmitDetailsPage.tsx index 095da0bdf8f5..7034f5fa6421 100644 --- a/src/pages/Share/SubmitDetailsPage.tsx +++ b/src/pages/Share/SubmitDetailsPage.tsx @@ -28,6 +28,7 @@ import { setMoneyRequestReimbursable, updateLastLocationPermissionPrompt, } from '@libs/actions/IOU/MoneyRequest'; +import {signalExpenseAddedGrowl} from '@libs/actions/IOU/NavigationHelpers'; import {setMoneyRequestReceipt} from '@libs/actions/IOU/Receipt'; import {requestMoney, trackExpense} from '@libs/actions/IOU/TrackExpense'; import type {GPSPoint as GpsPoint} from '@libs/actions/IOU/types/TrackExpenseTransactionParams'; @@ -367,6 +368,10 @@ function SubmitDetailsPage({ delegateAccountID, }); } + // requestMoney/trackExpense only signal the success growl for the global-create flow; the share extension + // isn't flagged as such, so signal it directly here with the created transaction's ID. + signalExpenseAddedGrowl(optimisticTransactionID, CONST.SEARCH.DATA_TYPES.EXPENSE); + cleanupAndNavigateAfterExpenseCreate({ report: isSelfDM(report) ? report : reportToSubmit, action: CONST.IOU.ACTION.CREATE, From c0c93d4173e2f663a184ed28c82b27d10a7baadc Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Fri, 24 Jul 2026 15:14:04 +0200 Subject: [PATCH 11/37] Fix after merge --- .../actions/IOU/SplitTransactionUpdate.ts | 23 +------------------ 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/src/libs/actions/IOU/SplitTransactionUpdate.ts b/src/libs/actions/IOU/SplitTransactionUpdate.ts index e35fa6a51787..fcba1a8fbbfd 100644 --- a/src/libs/actions/IOU/SplitTransactionUpdate.ts +++ b/src/libs/actions/IOU/SplitTransactionUpdate.ts @@ -43,12 +43,10 @@ import { navigateBackOnDeleteTransaction, updateOptimisticParentReportAction, } from '@libs/ReportUtils'; -import {getCurrentSearchQueryJSON} from '@libs/SearchQueryUtils'; import {isTracking, setPendingSubmitFollowUpAction} from '@libs/telemetry/submitFollowUpAction'; import {getChildTransactions, isDistanceRequest as isDistanceRequestTransactionUtils, isOnHold, isPerDiemRequest as isPerDiemRequestTransactionUtils} from '@libs/TransactionUtils'; import {setDeleteTransactionNavigateBackUrl} from '@userActions/Report'; -import {mergeTransactionIdsHighlightOnSearchRoute} from '@userActions/Transaction'; import {removeDraftSplitTransaction} from '@userActions/TransactionEdit'; import CONST from '@src/CONST'; @@ -1938,28 +1936,9 @@ function updateSplitTransactionsFromSplitExpensesFlow(params: UpdateSplitTransac const targetReportID = params.expenseReport?.reportID ?? String(CONST.DEFAULT_NUMBER_ID); - // The new (not pre-existing) split transactions created by this save, in split order. Reverse splits - // create none. - const existingChildTransactionIDs = new Set(allChildTransactions.map((tx) => tx?.transactionID).filter(Boolean)); - const newSplitTransactionIDs = isReverseSplitOperation - ? [] - : splitExpenses - .map((splitExpense) => splitExpense.transactionID) - .filter((transactionID): transactionID is string => !!transactionID && !existingChildTransactionIDs.has(transactionID)); - - // Register newly created split transaction IDs so they briefly highlight in the expense list. - // We skip the last-transaction case (the report navigates away before the highlight renders). - if (params.expenseReport?.reportID && !isReverseSplitOperation && !isLastTransactionInReport) { - for (const transactionID of newSplitTransactionIDs) { - addPendingNewTransactionIDs(targetReportID, transactionID); - } - } - - signalExpenseAddedGrowl(newSplitTransactionIDs.at(-1), CONST.SEARCH.DATA_TYPES.EXPENSE); + signalExpenseAddedGrowl(getNewSplitTransactionIDs().at(-1), CONST.SEARCH.DATA_TYPES.EXPENSE); if (isSearchPageTopmostFullScreenRoute || !params.transactionReport?.parentReportID) { - registerSearchRouteHighlight(); - if (!isSelfDMSplit) { Navigation.navigateBackToLastSuperWideRHPScreen(); } From 62419b62d7bb23eeff319a61c85e01e27f7cb6bc Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Fri, 24 Jul 2026 16:11:00 +0200 Subject: [PATCH 12/37] Fix after merge 2 --- .../TransactionListItemNarrow.tsx | 6 +- tests/actions/IOU/SplitReportTotalsTest.ts | 67 ------------------- 2 files changed, 2 insertions(+), 71 deletions(-) diff --git a/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemNarrow.tsx b/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemNarrow.tsx index 5a33f8fbe49f..14249c9936fe 100644 --- a/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemNarrow.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemNarrow.tsx @@ -12,13 +12,11 @@ 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'; @@ -85,7 +83,7 @@ function TransactionListItemNarrow({ 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} diff --git a/tests/actions/IOU/SplitReportTotalsTest.ts b/tests/actions/IOU/SplitReportTotalsTest.ts index 624ee0f7d3ef..e89afe227d0d 100644 --- a/tests/actions/IOU/SplitReportTotalsTest.ts +++ b/tests/actions/IOU/SplitReportTotalsTest.ts @@ -3,7 +3,6 @@ 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 isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; import {rand64} from '@libs/NumberUtils'; import type * as PolicyUtils from '@libs/PolicyUtils'; @@ -855,71 +854,5 @@ describe('actions/IOU', () => { // Then nothing is registered — the list navigates away before any highlight could render expect(addPendingNewTransactionIDs).not.toHaveBeenCalled(); }); - - 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 report-metadata highlight is skipped — the report is never mounted, so those flags would - // never be cleared and would incorrectly highlight rows when the report is later opened from the Inbox. - expect(addPendingNewTransactionIDs).not.toHaveBeenCalled(); - - // And instead the new IDs are registered on the search-route highlight, keyed by the current search type. - // 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(); - }); - - 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'); - const existingChildTx = { - transactionID: 'child-tx-1', - reportID: EXPENSE_REPORT_ID, - comment: {originalTransactionID: ORIGINAL_TX_ID, source: CONST.IOU.TYPE.SPLIT}, - }; - const params = buildBaseParams({ - allTransactionsList: {[`${ONYXKEYS.COLLECTION.TRANSACTION}child-tx-1`]: existingChildTx}, - transactionData: { - reportID: EXPENSE_REPORT_ID, - originalTransactionID: ORIGINAL_TX_ID, - splitExpenses: [{transactionID: 'new-merged-tx', reportID: EXPENSE_REPORT_ID, statusNum: 0, amount: 1000, created: '2024-01-01'}], - splitExpensesTotal: 1000, - }, - }); - - // 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(); - }); }); }); From cb7450594823ac27e97c26fad12395afd3cb8e8c Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Tue, 28 Jul 2026 10:29:48 +0200 Subject: [PATCH 13/37] Rename files from useSearchHighlightAndScroll to useSearchAutoRefetch --- config/eslint/eslint.seatbelt.tsv | 4 ++-- src/components/Search/index.tsx | 2 +- ...useSearchHighlightAndScroll.ts => useSearchAutoRefetch.ts} | 0 ...hHighlightAndScrollTest.ts => useSearchAutoRefetchTest.ts} | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) rename src/hooks/{useSearchHighlightAndScroll.ts => useSearchAutoRefetch.ts} (100%) rename tests/unit/{useSearchHighlightAndScrollTest.ts => useSearchAutoRefetchTest.ts} (98%) diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index 4c5db7d29d4a..22fb4a65bd45 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -581,8 +581,8 @@ "../../src/hooks/useRestoreWorkspacesTabOnNavigate.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/hooks/useReviewDuplicatesNavigation.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/hooks/useReviewDuplicatesNavigation.tsx" "react-hooks/set-state-in-effect" 1 +"../../src/hooks/useSearchAutoRefetch.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../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" 2 "../../src/hooks/useSidebarOrderedReports.tsx" "react-hooks/refs" 5 "../../src/hooks/useSidebarOrderedReports.tsx" "react-hooks/set-state-in-effect" 1 @@ -2163,7 +2163,7 @@ "../../tests/unit/useReceiptHoverZoomTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../tests/unit/useReportActionAvatarsTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../tests/unit/useReportUnreadMessageScrollTrackingTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../tests/unit/useSearchHighlightAndScrollTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../tests/unit/useSearchAutoRefetchTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../tests/unit/useSearchSelectorTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 5 "../../tests/unit/useShareSavedSearchTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../tests/unit/useSidebarOrderedReportsTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 99c1323ea931..c3ca40d4b38c 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -13,7 +13,7 @@ import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSaveSortedReportIDs from '@hooks/useSaveSortedReportIDs'; -import useSearchAutoRefetch from '@hooks/useSearchHighlightAndScroll'; +import useSearchAutoRefetch from '@hooks/useSearchAutoRefetch'; import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotals'; import useStableArrayReference from '@hooks/useStableArrayReference'; import useThemeStyles from '@hooks/useThemeStyles'; diff --git a/src/hooks/useSearchHighlightAndScroll.ts b/src/hooks/useSearchAutoRefetch.ts similarity index 100% rename from src/hooks/useSearchHighlightAndScroll.ts rename to src/hooks/useSearchAutoRefetch.ts diff --git a/tests/unit/useSearchHighlightAndScrollTest.ts b/tests/unit/useSearchAutoRefetchTest.ts similarity index 98% rename from tests/unit/useSearchHighlightAndScrollTest.ts rename to tests/unit/useSearchAutoRefetchTest.ts index 7b6bc003b5d6..00422ab97ac4 100644 --- a/tests/unit/useSearchHighlightAndScrollTest.ts +++ b/tests/unit/useSearchAutoRefetchTest.ts @@ -1,8 +1,8 @@ /* eslint-disable @typescript-eslint/naming-convention */ import {renderHook} from '@testing-library/react-native'; -import useSearchAutoRefetch from '@hooks/useSearchHighlightAndScroll'; -import type {UseSearchAutoRefetch} from '@hooks/useSearchHighlightAndScroll'; +import useSearchAutoRefetch from '@hooks/useSearchAutoRefetch'; +import type {UseSearchAutoRefetch} from '@hooks/useSearchAutoRefetch'; import {search} from '@libs/actions/Search'; From 3dc7dd2de4e4b4d71e3c4c9d254ea439b41fa12e Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Tue, 28 Jul 2026 10:32:13 +0200 Subject: [PATCH 14/37] Add jsdoc to UseSearchAutoRefetch --- src/hooks/useSearchAutoRefetch.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/hooks/useSearchAutoRefetch.ts b/src/hooks/useSearchAutoRefetch.ts index f0c57cc82271..ccd1f325686a 100644 --- a/src/hooks/useSearchAutoRefetch.ts +++ b/src/hooks/useSearchAutoRefetch.ts @@ -18,15 +18,34 @@ import {useEffect, useRef} from 'react'; import useNetwork from './useNetwork'; 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; + + /** Whether the search is backed by live data (vs. a snapshot) */ shouldUseLiveData: boolean; }; From 13d868fc5fcac47f97757d639c1050bd9c33f921 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Tue, 28 Jul 2026 11:12:11 +0200 Subject: [PATCH 15/37] Separate ExpenseAddedGrowlContent to remove onyx connections when no transaction is scheduled --- src/components/ExpenseAddedGrowl.tsx | 45 +++++++++++++++++++++++++--- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/src/components/ExpenseAddedGrowl.tsx b/src/components/ExpenseAddedGrowl.tsx index b1078bb5d705..ac1cfe274456 100644 --- a/src/components/ExpenseAddedGrowl.tsx +++ b/src/components/ExpenseAddedGrowl.tsx @@ -14,6 +14,8 @@ 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'; @@ -29,17 +31,52 @@ type ActiveGrowl = { 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 ( + + ); +} + +/** + * Reads the candidate transaction and its report/actions and renders the growl. Split out from + * `ExpenseAddedGrowl` so these Onyx connections only exist while there is a pending signal or a visible growl - + * the outer component is mounted for the whole app lifetime and would otherwise hold them permanently while idle. + */ +function ExpenseAddedGrowlContent({transactionID, signal, active, setActive}: ExpenseAddedGrowlContentProps) { const nonceRef = useRef(0); const {translate} = useLocalize(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const [signal] = useOnyx(ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS); const [betas] = useOnyx(ONYXKEYS.BETAS); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); - const transactionID = active?.transactionID ?? Object.keys(signal ?? {}).at(-1); const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); const reportID = transaction?.reportID; const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); @@ -48,7 +85,7 @@ function ExpenseAddedGrowl() { // action so "View" resolves the real transaction thread (the action's childReportID) instead of fabricating // a mismatched optimistic one. const isUnreportedExpense = !reportID || reportID === CONST.REPORT.UNREPORTED_REPORT_ID; - const selfDMReportID = transactionID && isUnreportedExpense ? findSelfDMReportID() : undefined; + const selfDMReportID = isUnreportedExpense ? findSelfDMReportID() : undefined; const hostReportID = isUnreportedExpense ? selfDMReportID : reportID; const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${hostReportID}`); // Only IOU/expense/invoice reports have the expense-report RHP that "View" opens. A tracked/unreported @@ -88,7 +125,7 @@ function ExpenseAddedGrowl() { } nonceRef.current += 1; setActive({transactionID: latestTransactionID, dataType, nonce: nonceRef.current}); - }, [signal, active, transaction]); + }, [signal, active, transaction, setActive]); if (!active) { return null; From 8d6453972e12227ae40b2475e07fd1ecaa47ca5b Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Tue, 28 Jul 2026 11:46:18 +0200 Subject: [PATCH 16/37] Derive backTo from the existing RHP's own fallback route when forceReplace is true --- src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts index 22682a679f23..2410441bd252 100644 --- a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts @@ -58,12 +58,17 @@ type NavigateToCreatedExpenseParams = { * - Inbox tab, wide layout, tracked/unreported expense (no expense report): the transaction thread RHP directly */ function navigateToCreatedExpense({threadReportID, transactionID, iouReportID}: NavigateToCreatedExpenseParams) { - const backTo = Navigation.getActiveRoute(); 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 currentRouteParams = navigationRef.current?.getCurrentRoute()?.params; + const currentRouteBackTo = + typeof currentRouteParams === 'object' && currentRouteParams !== null && 'backTo' in currentRouteParams && typeof currentRouteParams.backTo === 'string' + ? currentRouteParams.backTo + : undefined; + const backTo = forceReplace ? currentRouteBackTo : Navigation.getActiveRoute(); if (!openOnInbox) { setActiveTransactionIDs([transactionID]).then(() => { From 42fb7e7678d351c92aab1019715a458663194266 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Tue, 28 Jul 2026 12:03:26 +0200 Subject: [PATCH 17/37] Add jsdoc to NavigateAfterExpenseCreateParams --- .../helpers/navigateAfterExpenseCreate.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts index 2410441bd252..33a8d048138c 100644 --- a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts @@ -20,12 +20,25 @@ 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; }; From 5e8f6bc7b192cef8322bc3f127e1c99b25776b01 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Tue, 28 Jul 2026 12:08:16 +0200 Subject: [PATCH 18/37] Add a comment about surfacing only the latest transaction id from a batch --- src/components/ExpenseAddedGrowl.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/ExpenseAddedGrowl.tsx b/src/components/ExpenseAddedGrowl.tsx index ac1cfe274456..882319fb7f6b 100644 --- a/src/components/ExpenseAddedGrowl.tsx +++ b/src/components/ExpenseAddedGrowl.tsx @@ -114,6 +114,8 @@ function ExpenseAddedGrowlContent({transactionID, signal, active, setActive}: Ex 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 (its From 8a9d087da0f90c3d8a49d1d4f58b0787c6160a5e Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Tue, 28 Jul 2026 12:19:53 +0200 Subject: [PATCH 19/37] Update outdated ExpenseFlatSearchView comment --- src/components/Search/ExpenseFlatSearchView.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/components/Search/ExpenseFlatSearchView.tsx b/src/components/Search/ExpenseFlatSearchView.tsx index ddfb6a1f0181..47d21a94b5d4 100644 --- a/src/components/Search/ExpenseFlatSearchView.tsx +++ b/src/components/Search/ExpenseFlatSearchView.tsx @@ -29,11 +29,10 @@ const isRowDeleted = (item: SearchListItem) => 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, From 67a461292bfe54027fa94d9108d49a47c5963e5a Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Tue, 28 Jul 2026 15:13:24 +0200 Subject: [PATCH 20/37] Fix after merge --- tests/unit/useSearchAutoRefetchTest.ts | 171 ------------------------- 1 file changed, 171 deletions(-) diff --git a/tests/unit/useSearchAutoRefetchTest.ts b/tests/unit/useSearchAutoRefetchTest.ts index d2b1f5f1e8ff..00422ab97ac4 100644 --- a/tests/unit/useSearchAutoRefetchTest.ts +++ b/tests/unit/useSearchAutoRefetchTest.ts @@ -236,175 +236,4 @@ describe('useSearchAutoRefetch', () => { 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 = { - ...baseProps, - searchResults: { - ...baseProps.searchResults, - data: { - transactions_1: { - transactionID: '1', - }, - transactions_2: { - transactionID: '2', - }, - }, - }, - transactions: { - '1': {transactionID: '1'}, - '2': {transactionID: '2'}, - '3': {transactionID: '3'}, - }, - previousTransactions: { - '1': {transactionID: '1'}, - }, - }; - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error - 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(), - }; - - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error - result.current.handleSelectionListScroll([{transactionID: '1'}, {transactionID: '2'}], ref); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error - result.current.handleSelectionListScroll([{transactionID: '2'}, {transactionID: '1'}], 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 = { - ...baseProps, - searchResults: { - ...baseProps.searchResults, - data: { - transactions_1: { - transactionID: '1', - }, - transactions_2: { - transactionID: '2', - }, - }, - }, - transactions: { - '1': {transactionID: '1'}, - '2': {transactionID: '2'}, - '3': {transactionID: '3'}, - }, - previousTransactions: { - '1': {transactionID: '1'}, - }, - } as unknown as UseSearchHighlightAndScroll; - - // 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 = { - ...updatedProps1, - searchResults: { - ...updatedProps1.searchResults, - data: { - transactions_1: { - transactionID: '1', - }, - transactions_2: { - transactionID: '2', - }, - transactions_3: { - transactionID: '3', - }, - }, - }, - } as unknown as UseSearchHighlightAndScroll; - - 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 = { - ...baseProps, - queryJSON: {...baseProps.queryJSON, type: 'chat' as const}, - reportActions: { - reportActions_1: { - '1': {actionName: 'EXISTING', reportActionID: '1'}, - }, - }, - }; - const {rerender, result} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error - initialProps: chatProps, - }); - const updatedProps = { - ...chatProps, - searchResults: { - ...baseProps.searchResults, - data: { - reportActions_1: { - '1': {actionName: 'EXISTING', reportActionID: '1'}, - }, - reportActions_2: { - '2': {actionName: 'EXISTING', reportActionID: '2'}, - }, - }, - }, - reportActions: { - reportActions_1: { - '1': {actionName: 'EXISTING', reportActionID: '1'}, - }, - reportActions_2: { - '2': {actionName: 'EXISTING', reportActionID: '2'}, - }, - reportActions_3: { - '3': {actionName: 'EXISTING', reportActionID: '3'}, - }, - }, - previousReportActions: { - reportActions_1: { - '1': {actionName: 'EXISTING', reportActionID: '1'}, - }, - }, - }; - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error - rerender(updatedProps); - expect(result.current.newSearchResultKeys?.size).toBe(2); - }); }); From a6214e49957095f78d3cbac0d795e53dbf603c1e Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Tue, 4 Aug 2026 11:32:13 +0200 Subject: [PATCH 21/37] Fix after merge --- tests/unit/navigateAfterExpenseCreateTest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/navigateAfterExpenseCreateTest.ts b/tests/unit/navigateAfterExpenseCreateTest.ts index ccc7a5b72565..b873b879624d 100644 --- a/tests/unit/navigateAfterExpenseCreateTest.ts +++ b/tests/unit/navigateAfterExpenseCreateTest.ts @@ -37,7 +37,7 @@ jest.mock('@libs/telemetry/submitFollowUpAction', () => ({ })); jest.mock('@libs/SearchQueryUtils', () => ({ buildCannedSearchQuery: jest.fn(({type}: {type: string}) => `type:${type}`), - getCurrentSearchQueryJSON: () => mockGetCurrentSearchQueryJSON() as undefined, + getCurrentSearchQueryJSON: mockGetCurrentSearchQueryJSON, })); jest.mock('@libs/ReportUtils', () => { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- partial mock of the real module From 0df81a2959e9fd0d104536568e0d89cc306277a9 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Mon, 10 Aug 2026 10:58:37 +0200 Subject: [PATCH 22/37] Fix after merge --- .../actions/IOU/SplitTransactionUpdate.ts | 2 +- tests/actions/IOU/SplitReportTotalsTest.ts | 94 ++++++++++++------- 2 files changed, 61 insertions(+), 35 deletions(-) diff --git a/src/libs/actions/IOU/SplitTransactionUpdate.ts b/src/libs/actions/IOU/SplitTransactionUpdate.ts index 00a745598a08..07340d331030 100644 --- a/src/libs/actions/IOU/SplitTransactionUpdate.ts +++ b/src/libs/actions/IOU/SplitTransactionUpdate.ts @@ -2069,7 +2069,7 @@ function updateSplitTransactionsFromSplitExpensesFlow(params: UpdateSplitTransac 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. 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/tests/actions/IOU/SplitReportTotalsTest.ts b/tests/actions/IOU/SplitReportTotalsTest.ts index c294ca309b79..1433c2898c6a 100644 --- a/tests/actions/IOU/SplitReportTotalsTest.ts +++ b/tests/actions/IOU/SplitReportTotalsTest.ts @@ -2,6 +2,7 @@ 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 isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; import {rand64} from '@libs/NumberUtils'; import type * as PolicyUtils from '@libs/PolicyUtils'; @@ -747,6 +748,23 @@ describe('actions/IOU', () => { }; } + /** + * Reads the real REPORT_METADATA value rather than the mocked addPendingNewTransactionIDs. The flags are written + * by buildOnyxDataForMoneyRequest as Onyx optimisticData, not through that function, so assertions on the mock + * alone cannot observe them - which is how the Search/Spend pollution regressed after it was first fixed. + */ + 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 = { @@ -805,11 +823,19 @@ 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 + 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 jest.mocked(isSearchTopmostFullScreenRoute).mockReturnValue(true); - const spyOnMergeTransactionIdsHighlightOnSearchRoute = jest.spyOn(require('@libs/actions/Transaction'), 'mergeTransactionIdsHighlightOnSearchRoute'); + // Same fixture as the expense-report case below, so the only difference is where the split was saved from + 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-1', reportID: EXPENSE_REPORT_ID, amount: 1000}; + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}existing-tx-1`, existingTx); const params = buildBaseParams({ + expenseReport, + allReportsList: {[`${ONYXKEYS.COLLECTION.REPORT}${EXPENSE_REPORT_ID}`]: expenseReport}, + allTransactionsList: {[`${ONYXKEYS.COLLECTION.TRANSACTION}existing-tx-1`]: existingTx}, transactionData: { reportID: EXPENSE_REPORT_ID, originalTransactionID: ORIGINAL_TX_ID, @@ -825,50 +851,50 @@ describe('actions/IOU', () => { updateSplitTransactionsFromSplitExpensesFlow(params); await waitForBatchedUpdates(); - // Then the report-metadata highlight is skipped — the report is never mounted, so those flags would - // never be cleared and would incorrectly highlight rows when the report is later opened from the Inbox. - expect(addPendingNewTransactionIDs).not.toHaveBeenCalled(); - - // And instead the new IDs are registered on the search-route highlight, keyed by the current search type. - // 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(); + // Then no highlight flags land in REPORT_METADATA. Search navigates back to the Spend page and never mounts + // the expense report's list, so nothing would consume or clear them - they would instead highlight stale rows + // the next time the user opened that report from the Inbox. + const pendingNewTransactionIDs = await getPendingNewTransactionIDsFromOnyx(EXPENSE_REPORT_ID); + expect(pendingNewTransactionIDs?.['new-tx-1']).toBeUndefined(); + expect(pendingNewTransactionIDs?.['new-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'); - const existingChildTx = { - transactionID: 'child-tx-1', - reportID: EXPENSE_REPORT_ID, - comment: {originalTransactionID: ORIGINAL_TX_ID, source: CONST.IOU.TYPE.SPLIT}, - }; + it('writes pendingNewTransactionIDs into report metadata when splitting from the expense report', async () => { + // Given the user is NOT on the Search page, so saving ends by opening the expense report + jest.mocked(isSearchTopmostFullScreenRoute).mockReturnValue(false); + // The highlight guard only fires for a real money request report that already holds a transaction + 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-2', reportID: EXPENSE_REPORT_ID, amount: 1000}; + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}existing-tx-2`, existingTx); const params = buildBaseParams({ - allTransactionsList: {[`${ONYXKEYS.COLLECTION.TRANSACTION}child-tx-1`]: existingChildTx}, + expenseReport, + allReportsList: {[`${ONYXKEYS.COLLECTION.REPORT}${EXPENSE_REPORT_ID}`]: expenseReport}, + allTransactionsList: {[`${ONYXKEYS.COLLECTION.TRANSACTION}existing-tx-2`]: existingTx}, transactionData: { reportID: EXPENSE_REPORT_ID, originalTransactionID: ORIGINAL_TX_ID, - splitExpenses: [{transactionID: 'new-merged-tx', reportID: EXPENSE_REPORT_ID, statusNum: 0, amount: 1000, created: '2024-01-01'}], - splitExpensesTotal: 1000, + splitExpenses: [ + {transactionID: 'existing-tx-2', reportID: EXPENSE_REPORT_ID, statusNum: 0, amount: 500, created: '2024-01-01'}, + {transactionID: 'new-tx-3', reportID: EXPENSE_REPORT_ID, statusNum: 0, amount: 500, created: '2024-01-01'}, + {transactionID: 'new-tx-4', reportID: EXPENSE_REPORT_ID, statusNum: 0, amount: 500, created: '2024-01-01'}, + ], + splitExpensesTotal: 1500, }, }); - // When saving the reverse split + // When saving the split updateSplitTransactionsFromSplitExpensesFlow(params); await waitForBatchedUpdates(); - // Then nothing is highlighted — reverse splits create no new transactions - expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).not.toHaveBeenCalled(); + // Then the flags are written, because this path opens the report and its list consumes and clears them on mount + const pendingNewTransactionIDs = await getPendingNewTransactionIDsFromOnyx(EXPENSE_REPORT_ID); + expect(pendingNewTransactionIDs?.['new-tx-3']).toBe(true); + expect(pendingNewTransactionIDs?.['new-tx-4']).toBe(true); - spyOnMergeTransactionIdsHighlightOnSearchRoute.mockRestore(); + // And the transaction that already existed in the report is not flagged - it is not new, so highlighting it + // would draw attention to a row the user has already seen + expect(pendingNewTransactionIDs?.['existing-tx-2']).toBeUndefined(); }); }); }); From 2d9ff2dc043cd7082ffea715e3fe8fcd04e47c55 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Mon, 10 Aug 2026 11:14:10 +0200 Subject: [PATCH 23/37] Add greek translations --- src/languages/el.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/languages/el.ts b/src/languages/el.ts index 5beb2a28fe1c..5f7b0bd4b1ca 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -1278,6 +1278,8 @@ const translations: TranslationDeepObject = { createTimeExpense: 'Δημιουργία χρονοχρέωσης', }, iou: { + expenseAdded: 'Δαπάνη προστέθηκε', + invoiceSent: 'Τιμολόγιο στάλθηκε', amount: 'Ποσό', percent: 'Ποσοστό', date: 'Ημερομηνία', From ccf20444c47c7656b51ddd5701ff942593bee7b6 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Tue, 11 Aug 2026 17:20:38 +0200 Subject: [PATCH 24/37] Yet another fix after merge --- src/components/Search/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index d142805b52a9..9fb365d59c3e 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -201,7 +201,7 @@ function Search({ const previousReportActions = usePrevious(reportActions); const {translate} = useLocalize(); - const searchListRef = useRef | null>(null); + const {getCurrencyDecimals} = useCurrencyListActions(); const savedSearchSelector = useCallback((searches: OnyxEntry) => searches?.[hash], [hash]); const [savedSearch] = useOnyx(ONYXKEYS.SAVED_SEARCHES, { From dbe59bfe2b07cdf403c1c11b91924c2055091480 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Wed, 12 Aug 2026 12:13:11 +0200 Subject: [PATCH 25/37] Fix after merge --- src/components/ExpenseAddedGrowl.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/ExpenseAddedGrowl.tsx b/src/components/ExpenseAddedGrowl.tsx index 882319fb7f6b..47107ea83db2 100644 --- a/src/components/ExpenseAddedGrowl.tsx +++ b/src/components/ExpenseAddedGrowl.tsx @@ -19,6 +19,7 @@ import type {Dispatch, SetStateAction} from 'react'; import {useEffect, useRef, useState} from 'react'; import GrowlNotificationContent from './GrowlNotification/GrowlNotificationContent'; +import {usePersonalDetails} from './OnyxListItemProvider'; type ActiveGrowl = { /** ID of the transaction the growl was raised for */ @@ -75,6 +76,7 @@ function ExpenseAddedGrowlContent({transactionID, signal, active, setActive}: Ex const {translate} = useLocalize(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const personalDetails = usePersonalDetails(); const [betas] = useOnyx(ONYXKEYS.BETAS); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); @@ -152,6 +154,7 @@ function ExpenseAddedGrowlContent({transactionID, signal, active, setActive}: Ex iouReport, iouReportAction: iouAction, transaction, + personalDetails, }); threadReportID = optimisticThread?.reportID; } From fed957e6cbbd813457ed24abe57c6696c7e58001 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Wed, 12 Aug 2026 12:28:42 +0200 Subject: [PATCH 26/37] Fix tests --- src/components/ExpenseAddedGrowl.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/components/ExpenseAddedGrowl.tsx b/src/components/ExpenseAddedGrowl.tsx index 47107ea83db2..5bcbf866cc6d 100644 --- a/src/components/ExpenseAddedGrowl.tsx +++ b/src/components/ExpenseAddedGrowl.tsx @@ -19,7 +19,6 @@ import type {Dispatch, SetStateAction} from 'react'; import {useEffect, useRef, useState} from 'react'; import GrowlNotificationContent from './GrowlNotification/GrowlNotificationContent'; -import {usePersonalDetails} from './OnyxListItemProvider'; type ActiveGrowl = { /** ID of the transaction the growl was raised for */ @@ -76,7 +75,7 @@ function ExpenseAddedGrowlContent({transactionID, signal, active, setActive}: Ex const {translate} = useLocalize(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const personalDetails = usePersonalDetails(); + 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}`); From 7dd55bb6c3ac51b6fcb6bd719c5fe46d98b19fed Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Fri, 14 Aug 2026 12:36:29 +0200 Subject: [PATCH 27/37] Implement Melvin's nit suggestions --- src/components/Search/ExpenseGroupedSearchView.tsx | 3 +-- src/components/Search/index.tsx | 2 -- src/hooks/useSearchAutoRefetch.ts | 6 +----- tests/unit/useSearchAutoRefetchTest.ts | 1 - 4 files changed, 2 insertions(+), 10 deletions(-) diff --git a/src/components/Search/ExpenseGroupedSearchView.tsx b/src/components/Search/ExpenseGroupedSearchView.tsx index 70c73a6614b9..61314afc2ba1 100644 --- a/src/components/Search/ExpenseGroupedSearchView.tsx +++ b/src/components/Search/ExpenseGroupedSearchView.tsx @@ -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, diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 4f49e6cbaae5..b40179541b70 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -239,7 +239,6 @@ function Search({ shouldCalculateTotals, reportActions, previousReportActions, - shouldUseLiveData, }); const { @@ -261,7 +260,6 @@ function Search({ } = useSearchSnapshot({ queryJSON, searchResults, - transactions, reportActions, }); diff --git a/src/hooks/useSearchAutoRefetch.ts b/src/hooks/useSearchAutoRefetch.ts index ccd1f325686a..7dda3eb19591 100644 --- a/src/hooks/useSearchAutoRefetch.ts +++ b/src/hooks/useSearchAutoRefetch.ts @@ -44,9 +44,6 @@ type UseSearchAutoRefetch = { /** Whether the refetch should recalculate result totals */ shouldCalculateTotals: boolean; - - /** Whether the search is backed by live data (vs. a snapshot) */ - shouldUseLiveData: boolean; }; /** @@ -64,7 +61,6 @@ function useSearchAutoRefetch({ searchKey, offset, shouldCalculateTotals, - shouldUseLiveData, }: UseSearchAutoRefetch) { const isFocused = useIsFocused(); const {isOffline} = useNetwork(); @@ -173,7 +169,7 @@ function useSearchAutoRefetch({ } searchTriggeredRef.current = false; - }, [searchResults?.search?.isLoading, shouldUseLiveData, searchResultsData]); + }, [searchResults?.search?.isLoading, searchResultsData]); return {newTransactions}; } diff --git a/tests/unit/useSearchAutoRefetchTest.ts b/tests/unit/useSearchAutoRefetchTest.ts index 00422ab97ac4..55cd6876864b 100644 --- a/tests/unit/useSearchAutoRefetchTest.ts +++ b/tests/unit/useSearchAutoRefetchTest.ts @@ -36,7 +36,6 @@ describe('useSearchAutoRefetch', () => { }); const baseProps: UseSearchAutoRefetch = { - shouldUseLiveData: false, searchResults: { data: { personalDetailsList: {}, From a5892ffc6ca741ec6c8f7d3d873e08579b482354 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Fri, 14 Aug 2026 12:50:08 +0200 Subject: [PATCH 28/37] Add guard for skipping growl after reverse split operation --- .../actions/IOU/SplitTransactionUpdate.ts | 4 +++- tests/actions/IOU/SplitReportTotalsTest.ts | 24 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/libs/actions/IOU/SplitTransactionUpdate.ts b/src/libs/actions/IOU/SplitTransactionUpdate.ts index da422b2ac680..664e78f7a5cd 100644 --- a/src/libs/actions/IOU/SplitTransactionUpdate.ts +++ b/src/libs/actions/IOU/SplitTransactionUpdate.ts @@ -2076,7 +2076,9 @@ function updateSplitTransactionsFromSplitExpensesFlow(params: UpdateSplitTransac const targetReportID = params.expenseReport?.reportID ?? String(CONST.DEFAULT_NUMBER_ID); - signalExpenseAddedGrowl(getNewSplitTransactionIDs().at(-1), CONST.SEARCH.DATA_TYPES.EXPENSE); + 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 diff --git a/tests/actions/IOU/SplitReportTotalsTest.ts b/tests/actions/IOU/SplitReportTotalsTest.ts index 2ecf7de8eb1e..274558cceee2 100644 --- a/tests/actions/IOU/SplitReportTotalsTest.ts +++ b/tests/actions/IOU/SplitReportTotalsTest.ts @@ -22,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} from '../../utils/TestHelper'; import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; @@ -899,5 +900,28 @@ describe('actions/IOU', () => { // would draw attention to a row the user has already seen expect(pendingNewTransactionIDs?.['existing-tx-2']).toBeUndefined(); }); + + it('does not signal the "Expense added" growl during a reverse split operation', async () => { + const existingChildTx = { + transactionID: 'child-tx-1', + reportID: EXPENSE_REPORT_ID, + comment: {originalTransactionID: ORIGINAL_TX_ID, source: CONST.IOU.TYPE.SPLIT}, + }; + const params = buildBaseParams({ + allTransactionsList: {[`${ONYXKEYS.COLLECTION.TRANSACTION}child-tx-1`]: existingChildTx}, + transactionData: { + reportID: EXPENSE_REPORT_ID, + originalTransactionID: ORIGINAL_TX_ID, + splitExpenses: [{transactionID: 'new-merged-tx', reportID: EXPENSE_REPORT_ID, statusNum: 0, amount: 1000, created: '2024-01-01'}], + splitExpensesTotal: 1000, + }, + }); + + updateSplitTransactionsFromSplitExpensesFlow(params); + await waitForBatchedUpdates(); + + const growlTransactionIDs = await getOnyxValue(ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS); + expect(growlTransactionIDs?.['new-merged-tx']).toBeUndefined(); + }); }); }); From 19a654841909a6ec006d8b61b95cb0c46bc9dba5 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Fri, 14 Aug 2026 13:50:18 +0200 Subject: [PATCH 29/37] Show growl for duplicate only via bulk action --- src/components/MoneyRequestHeaderSecondaryActions.tsx | 8 +------- src/hooks/useExpenseActions.ts | 8 +------- 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/src/components/MoneyRequestHeaderSecondaryActions.tsx b/src/components/MoneyRequestHeaderSecondaryActions.tsx index 1f96a6ca2fcb..37d6b8009728 100644 --- a/src/components/MoneyRequestHeaderSecondaryActions.tsx +++ b/src/components/MoneyRequestHeaderSecondaryActions.tsx @@ -29,7 +29,6 @@ import useThrottledButtonState from '@hooks/useThrottledButtonState'; import useTransactionViolations from '@hooks/useTransactionViolations'; import {duplicateExpenseTransaction as duplicateTransactionAction} from '@libs/actions/IOU/Duplicate'; -import {signalExpenseAddedGrowl} from '@libs/actions/IOU/NavigationHelpers'; import {deleteTrackExpense} from '@libs/actions/IOU/TrackExpense'; import {setupMergeTransactionDataAndNavigate} from '@libs/actions/MergeTransaction'; import initSplitExpense from '@libs/actions/SplitExpenses'; @@ -253,12 +252,11 @@ function MoneyRequestHeaderSecondaryActions({reportID, onBackButtonPress}: Money const optimisticIOUReportID = generateReportID(); const activePolicyCategoriesMap = defaultPolicyCategories ?? {}; - let lastDuplicateTransactionID: string | undefined; for (const item of transactions) { const existingTransactionID = getExistingTransactionID(item.linkedTrackedExpenseReportAction); const existingTransactionDraft = existingTransactionID ? transactionDrafts?.[existingTransactionID] : undefined; - const result = duplicateTransactionAction({ + duplicateTransactionAction({ dateFnsLocale, getCurrencyDecimals, transaction: item, @@ -287,11 +285,7 @@ function MoneyRequestHeaderSecondaryActions({reportID, onBackButtonPress}: Money participantsPolicyTags, conciergeChat, }); - if (result?.transactionID) { - lastDuplicateTransactionID = result.transactionID; - } } - signalExpenseAddedGrowl(lastDuplicateTransactionID, CONST.SEARCH.DATA_TYPES.EXPENSE); }; const dismissModalAndUpdateUseHold = () => { diff --git a/src/hooks/useExpenseActions.ts b/src/hooks/useExpenseActions.ts index 512ea11de243..97b805626bc7 100644 --- a/src/hooks/useExpenseActions.ts +++ b/src/hooks/useExpenseActions.ts @@ -5,7 +5,6 @@ import {useMoneyReportTransactionThread} from '@components/MoneyReportTransactio import {useSearchQueryContext, useSearchSelectionActions} from '@components/Search/SearchContext'; import {duplicateReport as duplicateReportAction, duplicateExpenseTransaction as duplicateTransactionAction} from '@libs/actions/IOU/Duplicate'; -import {signalExpenseAddedGrowl} from '@libs/actions/IOU/NavigationHelpers'; import {setupMergeTransactionDataAndNavigate} from '@libs/actions/MergeTransaction'; import {deleteAppReport} from '@libs/actions/Report'; import initSplitExpense from '@libs/actions/SplitExpenses'; @@ -260,12 +259,11 @@ function useExpenseActions({reportID, isReportInSearch = false, backTo, onDuplic const optimisticIOUReportID = generateReportID(); const activePolicyCategories = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${defaultExpensePolicy?.id}`] ?? {}; - let lastDuplicateTransactionID: string | undefined; for (const item of transactionList) { const existingTransactionID = getExistingTransactionID(item.linkedTrackedExpenseReportAction); const existingTransactionDraft = existingTransactionID ? transactionDrafts?.[existingTransactionID] : undefined; - const result = duplicateTransactionAction({ + duplicateTransactionAction({ dateFnsLocale, getCurrencyDecimals, transaction: item, @@ -294,11 +292,7 @@ function useExpenseActions({reportID, isReportInSearch = false, backTo, onDuplic participantsPolicyTags, conciergeChat, }); - if (result?.transactionID) { - lastDuplicateTransactionID = result.transactionID; - } } - signalExpenseAddedGrowl(lastDuplicateTransactionID, CONST.SEARCH.DATA_TYPES.EXPENSE); }; const addExpenseDropdownOptions = getAddExpenseDropdownOptions({ From 241c28fad168f36d8757e804e3339c5a88227f1b Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Mon, 17 Aug 2026 10:30:30 +0200 Subject: [PATCH 30/37] Fix after merge --- config/eslint/eslint.seatbelt.tsv | 1 - src/hooks/useSearchAutoRefetch.ts | 82 +++++- tests/unit/useSearchAutoRefetchTest.ts | 346 +++++++++++++++++++++---- 3 files changed, 361 insertions(+), 68 deletions(-) diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index b6c8a456dc94..d886388dfcad 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -1461,7 +1461,6 @@ "../../tests/unit/useAccountTabIndicatorStatusTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 9 "../../tests/unit/useCreateReportTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 9 "../../tests/unit/useMarkAsReadTest.ts" "no-restricted-imports" 1 -"../../tests/unit/useSearchAutoRefetchTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../tests/unit/useSubPageTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../tests/utils/createMock.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../tests/utils/getIsUsingFakeTimers.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 diff --git a/src/hooks/useSearchAutoRefetch.ts b/src/hooks/useSearchAutoRefetch.ts index 7dda3eb19591..c5f4a9d6fb33 100644 --- a/src/hooks/useSearchAutoRefetch.ts +++ b/src/hooks/useSearchAutoRefetch.ts @@ -86,26 +86,30 @@ function useSearchAutoRefetch({ const previousTransactionIDsLocal = Object.keys(previousTransactions ?? {}); const transactionsIDs = Object.keys(transactions ?? {}); - const reportActionsIDs = Object.values(reportActions ?? {}) - .map((actions) => Object.keys(actions ?? {})) - .flat(); - const previousReportActionsIDs = Object.values(previousReportActions ?? {}) - .map((actions) => Object.keys(actions ?? {})) - .flat(); - // Only proceed if we have previous data to compare against // This prevents triggering on initial data load - if ((previousTransactionIDsLocal.length === 0 && previousReportActionsIDs.length === 0) || searchTriggeredRef.current) { + const hasPreviousReportActions = Object.values(previousReportActions ?? {}).some((actions) => Object.keys(actions ?? {}).length > 0); + if ((previousTransactionIDsLocal.length === 0 && !hasPreviousReportActions) || searchTriggeredRef.current) { return; } + // Only chat searches are driven by report actions, so the rest skip walking that collection entirely. + const reportActionsIDs = isChat ? Object.values(reportActions ?? {}).flatMap((actions) => Object.keys(actions ?? {})) : []; + const previousReportActionsIDs = isChat ? Object.values(previousReportActions ?? {}).flatMap((actions) => Object.keys(actions ?? {})) : []; + const previousTransactionsIDsSet = new Set(previousTransactionIDsLocal); const previousReportActionsIDsSet = new Set(previousReportActionsIDs); const hasTransactionsIDsChange = transactionsIDs.length !== previousTransactionIDsLocal.length || transactionsIDs.some((id) => !previousTransactionsIDsSet.has(id)); const hasReportActionsIDsChange = reportActionsIDs.some((id) => !previousReportActionsIDsSet.has(id)); + // Editing an expense that the results already show changes no transaction ID, so the ID checks above miss it. + // The rows and the footer total are served from the Search snapshot, and the footer total is computed by the + // server, so those edits only become visible after a refetch. + const hasChangedResultTransaction = + !isChat && !hasTransactionsIDsChange && hasChangedTransactionInSearchResults(transactions, previousTransactions, previousTransactionsIDsSet, searchResultsData); + // Check if there is a change in the transactions or report actions list - if ((!isChat && hasTransactionsIDsChange) || hasReportActionsIDsChange || hasPendingSearchRef.current) { + if ((isChat ? hasReportActionsIDsChange : hasTransactionsIDsChange || hasChangedResultTransaction) || hasPendingSearchRef.current) { // Skip if offline, or if the user has navigated to a different fullscreen page entirely. // An RHP layered on top of Search makes `isFocused` false but keeps Search as the topmost // fullscreen route, so we still want to refetch — otherwise the snapshot can't reflect @@ -115,21 +119,37 @@ function useSearchAutoRefetch({ hasPendingSearchRef.current = true; return; } + // A deferred refetch is its own reason to search. `usePrevious` advances while Search is inactive, so by + // the time it becomes active again the change that set the flag has washed out of the comparisons below. + const hadPendingSearch = hasPendingSearchRef.current; hasPendingSearchRef.current = false; - const newIDs = isChat ? reportActionsIDs : transactionsIDs; + // Transaction Onyx keys are `transactions_` but search results yield bare IDs, so read the ID off the value. + const addedTransactionIDs: string[] = []; + const currentTransactionIDs: string[] = []; + for (const [key, transaction] of Object.entries(transactions ?? {})) { + const transactionID = transaction?.transactionID; + if (!transactionID) { + continue; + } + currentTransactionIDs.push(transactionID); + if (!previousTransactionsIDsSet.has(key)) { + addedTransactionIDs.push(transactionID); + } + } + let currentSearchResultIDs: string[] = []; if (searchResultsData) { currentSearchResultIDs = isChat ? extractReportActionIDsFromSearchResults(searchResultsData) : extractTransactionIDsFromSearchResults(searchResultsData); } const existingSearchResultIDsSet = new Set(currentSearchResultIDs); - const hasAGenuinelyNewID = newIDs.some((id) => !existingSearchResultIDsSet.has(id)); + const hasAGenuinelyNewID = (isChat ? reportActionsIDs : addedTransactionIDs).some((id) => !existingSearchResultIDsSet.has(id)); // Only skip search if there are no new items AND search results aren't empty // This ensures deletions that result in empty data still trigger search - if (!hasAGenuinelyNewID && currentSearchResultIDs.length > 0) { - const newIDsSet = new Set(newIDs); - const hasDeletedID = currentSearchResultIDs.some((id) => !newIDsSet.has(id)); + if (!hasAGenuinelyNewID && !hasChangedResultTransaction && !hadPendingSearch && currentSearchResultIDs.length > 0) { + const currentIDsSet = new Set(isChat ? reportActionsIDs : currentTransactionIDs); + const hasDeletedID = currentSearchResultIDs.some((id) => !currentIDsSet.has(id)); if (!hasDeletedID) { return; } @@ -210,5 +230,39 @@ function extractReportActionIDsFromSearchResults(searchResultsData: Partial, + previousTransactions: OnyxCollection, + previousTransactionKeys: Set, + searchResultsData: Partial | undefined, +): boolean { + if (!searchResultsData) { + return false; + } + + const changedTransactionIDs: string[] = []; + for (const [key, transaction] of Object.entries(transactions ?? {})) { + if (!transaction?.transactionID || !previousTransactionKeys.has(key) || previousTransactions?.[key] === transaction) { + continue; + } + changedTransactionIDs.push(transaction.transactionID); + } + + // Walking the results is the expensive half, so only do it once something actually changed. + if (changedTransactionIDs.length === 0) { + return false; + } + + const searchResultIDs = new Set(extractTransactionIDsFromSearchResults(searchResultsData)); + return changedTransactionIDs.some((transactionID) => searchResultIDs.has(transactionID)); +} + export default useSearchAutoRefetch; export type {UseSearchAutoRefetch}; diff --git a/tests/unit/useSearchAutoRefetchTest.ts b/tests/unit/useSearchAutoRefetchTest.ts index 55cd6876864b..095d8585a96c 100644 --- a/tests/unit/useSearchAutoRefetchTest.ts +++ b/tests/unit/useSearchAutoRefetchTest.ts @@ -7,9 +7,12 @@ import type {UseSearchAutoRefetch} from '@hooks/useSearchAutoRefetch'; import {search} from '@libs/actions/Search'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {Transaction} from '@src/types/onyx'; import Onyx from 'react-native-onyx'; +import createMock from '../utils/createMock'; + jest.mock('@libs/actions/Search'); jest.mock('@react-navigation/native', () => ({ useIsFocused: jest.fn(() => true), @@ -22,10 +25,14 @@ jest.mock('@rnmapbox/maps', () => ({ setAccessToken: jest.fn(), })); +let mockIsOffline = false; +jest.mock('@hooks/useNetwork', () => jest.fn(() => ({isOffline: mockIsOffline}))); + const mockUseIsFocused = jest.fn().mockReturnValue(true); afterEach(() => { jest.clearAllMocks(); + mockIsOffline = false; }); describe('useSearchAutoRefetch', () => { @@ -78,29 +85,25 @@ describe('useSearchAutoRefetch', () => { }); it('should trigger search when new transaction added and focused', () => { - const initialProps = { + const initialProps = createMock({ ...baseProps, - transactions: {'1': {transactionID: '1'}}, - previousTransactions: {'1': {transactionID: '1'}}, - }; + transactions: {transactions_1: {transactionID: '1'}}, + previousTransactions: {transactions_1: {transactionID: '1'}}, + }); const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error initialProps, }); - const updatedProps = { + const updatedProps = createMock({ ...baseProps, transactions: { - '1': {transactionID: '1'}, - '2': {transactionID: '2'}, + transactions_1: {transactionID: '1'}, + transactions_2: {transactionID: '2'}, }, - previousTransactions: {'1': {transactionID: '1'}}, - }; + previousTransactions: {transactions_1: {transactionID: '1'}}, + }); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error rerender(updatedProps); expect(search).toHaveBeenCalledWith({queryJSON: baseProps.queryJSON, searchKey: undefined, offset: 0, shouldCalculateTotals: false, isLoading: false}); }); @@ -112,13 +115,11 @@ describe('useSearchAutoRefetch', () => { initialProps: baseProps, }); - const updatedProps = { + const updatedProps = createMock({ ...baseProps, - transactions: {'1': {transactionID: '1'}}, - }; + transactions: {transactions_1: {transactionID: '1'}}, + }); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error rerender(updatedProps); expect(search).not.toHaveBeenCalled(); }); @@ -126,71 +127,314 @@ describe('useSearchAutoRefetch', () => { it('should trigger search for chat when report actions added and focused', () => { mockUseIsFocused.mockReturnValue(true); - const chatProps = { + const chatProps = createMock({ ...baseProps, queryJSON: {...baseProps.queryJSON, type: 'chat' as const}, reportActions: { reportActions_1: { - '1': {actionName: 'EXISTING', reportActionID: '1'}, + '1': {actionName: 'CREATED', reportActionID: '1'}, }, }, previousReportActions: { reportActions_1: { - '1': {actionName: 'EXISTING', reportActionID: '1'}, + '1': {actionName: 'CREATED', reportActionID: '1'}, }, }, - }; + }); const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error initialProps: chatProps, }); - const updatedProps = { + const updatedProps = createMock({ ...chatProps, reportActions: { reportActions_1: { - '1': {actionName: 'EXISTING', reportActionID: '1'}, + '1': {actionName: 'CREATED', reportActionID: '1'}, '2': {actionName: 'ADDCOMMENT', reportActionID: '2'}, }, }, - }; + }); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error rerender(updatedProps); expect(search).toHaveBeenCalledWith({queryJSON: chatProps.queryJSON, searchKey: undefined, offset: 0, shouldCalculateTotals: false, isLoading: false}); }); it('should not trigger search when new transaction removed and focused', () => { - const initialProps = { + const initialProps = createMock({ + ...baseProps, + transactions: { + transactions_1: {transactionID: '1'}, + transactions_2: {transactionID: '2'}, + }, + previousTransactions: { + transactions_1: {transactionID: '1'}, + transactions_2: {transactionID: '2'}, + }, + }); + + const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), { + initialProps, + }); + + const updatedProps = createMock({ + ...baseProps, + transactions: { + transactions_1: {transactionID: '1'}, + }, + }); + + rerender(updatedProps); + expect(search).not.toHaveBeenCalled(); + }); + + it('should trigger search when a transaction that is absent from the results is added', () => { + const initialProps = createMock({ + ...baseProps, + searchResults: { + ...baseProps.searchResults, + data: { + transactions_1: {transactionID: '1'}, + }, + }, + transactions: { + transactions_1: {transactionID: '1'}, + }, + previousTransactions: { + transactions_1: {transactionID: '1'}, + }, + }); + + const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), { + initialProps, + }); + + const updatedProps = createMock({ + ...initialProps, + transactions: { + transactions_1: {transactionID: '1'}, + transactions_2: {transactionID: '2'}, + }, + }); + + rerender(updatedProps); + expect(search).toHaveBeenCalledWith({queryJSON: baseProps.queryJSON, searchKey: undefined, offset: 0, shouldCalculateTotals: false, isLoading: false}); + }); + + it('should not trigger search when the added transaction is already in the search results', () => { + // 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({ + ...baseProps, + searchResults: { + ...baseProps.searchResults, + data: { + transactions_1: {transactionID: '1'}, + transactions_2: {transactionID: '2'}, + }, + }, + transactions: { + transactions_1: transaction1, + }, + previousTransactions: { + transactions_1: transaction1, + }, + }); + + const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), { + initialProps, + }); + + const updatedProps = createMock({ + ...initialProps, + transactions: { + transactions_1: transaction1, + transactions_2: {transactionID: '2'}, + }, + }); + + rerender(updatedProps); + expect(search).not.toHaveBeenCalled(); + }); + + 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({ + ...baseProps, + searchResults: { + ...baseProps.searchResults, + data: { + transactions_1: {transactionID: '1'}, + }, + }, + transactions: {transactions_1: transaction}, + previousTransactions: {transactions_1: transaction}, + }); + + const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), { + initialProps, + }); + + const updatedProps = createMock({ + ...initialProps, + transactions: {transactions_1: {transactionID: '1', amount: 250}}, + }); + + rerender(updatedProps); + expect(search).toHaveBeenCalledWith({queryJSON: baseProps.queryJSON, searchKey: undefined, offset: 0, shouldCalculateTotals: false, isLoading: false}); + }); + + 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({ ...baseProps, + searchResults: { + ...baseProps.searchResults, + data: { + transactions_1: {transactionID: '1'}, + }, + }, + transactions: {transactions_1: visibleTransaction, transactions_99: filteredOutTransaction}, + previousTransactions: {transactions_1: visibleTransaction, transactions_99: filteredOutTransaction}, + }); + + const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), { + initialProps, + }); + + const updatedProps = createMock({ + ...initialProps, + transactions: {transactions_1: visibleTransaction, transactions_99: {transactionID: '99', amount: 250}}, + }); + + rerender(updatedProps); + expect(search).not.toHaveBeenCalled(); + }); + + 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({ + ...baseProps, + searchResults: { + ...baseProps.searchResults, + data: { + transactions_1: {transactionID: '1'}, + }, + }, + transactions: {transactions_1: transaction}, + previousTransactions: {transactions_1: transaction}, + }); + + mockIsOffline = true; + const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), { + initialProps, + }); + + 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}}); + + rerender(settledProps); + expect(search).not.toHaveBeenCalled(); + + mockIsOffline = false; + + rerender({...settledProps}); + expect(search).toHaveBeenCalledTimes(1); + }); + + 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({ + ...baseProps, + searchResults: { + ...baseProps.searchResults, + data: { + transactions_1: transaction1, + }, + }, + // `transactions_99` is held by the client but filtered out by the query, the normal paginated/filtered state. transactions: { - '1': {transactionID: '1'}, - '2': {transactionID: '2'}, + transactions_1: transaction1, + transactions_99: transaction99, }, previousTransactions: { - '1': {transactionID: '1'}, - '2': {transactionID: '2'}, + transactions_1: transaction1, + transactions_99: transaction99, + }, + reportActions: { + reportActions_1: { + '1': {actionName: 'CREATED', reportActionID: '1'}, + }, }, - }; + previousReportActions: { + reportActions_1: { + '1': {actionName: 'CREATED', reportActionID: '1'}, + }, + }, + }); const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error initialProps, }); - const updatedProps = { + const updatedProps = createMock({ + ...initialProps, + reportActions: { + reportActions_1: { + '1': {actionName: 'CREATED', reportActionID: '1'}, + '2': {actionName: 'ADDCOMMENT', reportActionID: '2'}, + }, + }, + }); + + rerender(updatedProps); + expect(search).not.toHaveBeenCalled(); + }); + + 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({ ...baseProps, + searchResults: { + ...baseProps.searchResults, + data: { + transactions_1: {transactionID: '1'}, + transactions_2: {transactionID: '2'}, + }, + }, transactions: { - '1': {transactionID: '1'}, + transactions_1: transaction1, + transactions_99: transaction99, + }, + previousTransactions: { + transactions_1: transaction1, + transactions_99: transaction99, }, - }; + }); + + const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), { + initialProps, + }); + + const updatedProps = createMock({ + ...initialProps, + transactions: { + transactions_1: transaction1, + transactions_99: transaction99, + transactions_2: {transactionID: '2'}, + }, + }); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error rerender(updatedProps); expect(search).not.toHaveBeenCalled(); }); @@ -198,40 +442,36 @@ describe('useSearchAutoRefetch', () => { it('should not trigger search for chat when report actions removed and focused', () => { mockUseIsFocused.mockReturnValue(true); - const chatProps = { + const chatProps = createMock({ ...baseProps, queryJSON: {...baseProps.queryJSON, type: 'chat' as const}, reportActions: { reportActions_1: { - '1': {actionName: 'EXISTING', reportActionID: '1'}, + '1': {actionName: 'CREATED', reportActionID: '1'}, '2': {actionName: 'ADDCOMMENT', reportActionID: '2'}, }, }, previousReportActions: { reportActions_1: { - '1': {actionName: 'EXISTING', reportActionID: '1'}, + '1': {actionName: 'CREATED', reportActionID: '1'}, '2': {actionName: 'ADDCOMMENT', reportActionID: '2'}, }, }, - }; + }); const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error initialProps: chatProps, }); - const updatedProps = { + const updatedProps = createMock({ ...chatProps, reportActions: { reportActions_1: { - '1': {actionName: 'EXISTING', reportActionID: '1'}, + '1': {actionName: 'CREATED', reportActionID: '1'}, }, }, - }; + }); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error rerender(updatedProps); expect(search).not.toHaveBeenCalled(); }); From dfa97df840110c8b544de4e1f1f6edfbac6da2ec Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Mon, 17 Aug 2026 11:46:23 +0200 Subject: [PATCH 31/37] Fix after merge --- src/hooks/useSearchAutoRefetch.ts | 1 + tests/unit/useSearchAutoRefetchTest.ts | 12 ++++++------ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/hooks/useSearchAutoRefetch.ts b/src/hooks/useSearchAutoRefetch.ts index df91fd8efc50..5394a1d28672 100644 --- a/src/hooks/useSearchAutoRefetch.ts +++ b/src/hooks/useSearchAutoRefetch.ts @@ -8,6 +8,7 @@ import {isReportActionEntry} from '@libs/SearchUIUtils'; import type {SearchKey} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import type {ReportActions, SearchResults, Transaction} from '@src/types/onyx'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; diff --git a/tests/unit/useSearchAutoRefetchTest.ts b/tests/unit/useSearchAutoRefetchTest.ts index 212523ade45d..676384d8be82 100644 --- a/tests/unit/useSearchAutoRefetchTest.ts +++ b/tests/unit/useSearchAutoRefetchTest.ts @@ -315,7 +315,7 @@ describe('useSearchAutoRefetch', () => { 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, @@ -327,11 +327,11 @@ describe('useSearchAutoRefetch', () => { 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'}}, }); @@ -342,7 +342,7 @@ describe('useSearchAutoRefetch', () => { 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, @@ -354,11 +354,11 @@ describe('useSearchAutoRefetch', () => { 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'}}, }); From 3e330dc2622336ebb9e90b02a7e065f824a3b824 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Tue, 18 Aug 2026 14:18:47 +0200 Subject: [PATCH 32/37] Extract getCurrentRouteBackTo helper --- .../helpers/navigateAfterExpenseCreate.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts index 33a8d048138c..6cd0c69f01d6 100644 --- a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts @@ -50,6 +50,14 @@ 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; @@ -76,12 +84,7 @@ function navigateToCreatedExpense({threadReportID, transactionID, iouReportID}: // 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 currentRouteParams = navigationRef.current?.getCurrentRoute()?.params; - const currentRouteBackTo = - typeof currentRouteParams === 'object' && currentRouteParams !== null && 'backTo' in currentRouteParams && typeof currentRouteParams.backTo === 'string' - ? currentRouteParams.backTo - : undefined; - const backTo = forceReplace ? currentRouteBackTo : Navigation.getActiveRoute(); + const backTo = forceReplace ? getCurrentRouteBackTo() : Navigation.getActiveRoute(); if (!openOnInbox) { setActiveTransactionIDs([transactionID]).then(() => { From 2ba8bc5c8b8d79a8957f00d654b86f963aa0f03c Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Tue, 18 Aug 2026 14:38:12 +0200 Subject: [PATCH 33/37] Add StyleUtils.getSearchRowBackgroundStyle --- .../Search/SearchList/ListItem/ExpenseReportListItem.tsx | 3 ++- .../Search/SearchList/ListItem/GroupChildrenContainer.tsx | 6 +++--- src/components/Search/SearchList/ListItem/GroupHeader.tsx | 2 +- .../Search/SearchList/ListItem/TransactionGroupListItem.tsx | 2 +- .../TransactionListItem/TransactionListItemNarrow.tsx | 3 ++- .../TransactionListItem/TransactionListItemWide.tsx | 2 +- src/styles/utils/index.ts | 4 ++++ 7 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx b/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx index 4d9efae42273..ef0c2168dce0 100644 --- a/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx +++ b/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx @@ -482,7 +482,8 @@ function ExpenseReportListItemInner({ hoverStyle={isSelected && styles.activeComponentBG} pressableWrapperStyle={[ styles.mh5, - {backgroundColor: isSelected ? theme.activeComponentBG : theme.highlightBG, ...(!isLargeScreenWidth && {borderRadius: 0})}, + 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 dc7c7bd02d41..c3c4a032624e 100644 --- a/src/components/Search/SearchList/ListItem/GroupChildrenContainer.tsx +++ b/src/components/Search/SearchList/ListItem/GroupChildrenContainer.tsx @@ -1,7 +1,7 @@ import {useSearchSelectionContext} from '@components/Search/SearchContext'; import useExpandCollapseAnimation from '@hooks/useExpandCollapseAnimation'; -import useTheme from '@hooks/useTheme'; +import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import React from 'react'; @@ -34,8 +34,8 @@ function GroupChildrenContainer({ cardFeeds, conciergeReportID, }: GroupChildrenContainerProps) { - const theme = useTheme(); const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); const {selectedTransactions} = useSearchSelectionContext(); const {isRendered, animatedStyle, onLayout} = useExpandCollapseAnimation(isExpanded, false, item.keyForList); const isContentVisible = isExpanded || isRendered; @@ -48,7 +48,7 @@ function GroupChildrenContainer({ } return ( - + {isContentVisible ? ( ({ wrapperStyle={[ styles.mh5, styles.flex1, - {backgroundColor: isSelected ? theme.activeComponentBG : theme.highlightBG, borderRadius: 0}, + 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 9339c38874f0..b31a8054aadb 100644 --- a/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemWide.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemWide.tsx @@ -168,7 +168,7 @@ function TransactionListItemWide({ wrapperStyle={[ styles.mh5, styles.flex1, - {backgroundColor: isSelected ? theme.activeComponentBG : theme.highlightBG}, + StyleUtils.getSearchRowBackgroundStyle(isSelected), styles.userSelectNone, isLastItem && [styles.tableBottomRadius, styles.overflowHidden], ]} diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index d26e7e8c4708..7a6d8412db91 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -1917,6 +1917,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 => { From 083807d9c82e8adcf7ed54178fa6bab4b4c0a1cf Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Tue, 18 Aug 2026 14:51:32 +0200 Subject: [PATCH 34/37] Fix eslint warnings in useSearchAutoRefetch.ts --- config/eslint/eslint.seatbelt.tsv | 1 - src/hooks/useSearchAutoRefetch.ts | 35 +++++++++++++++++++++++-------- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index 950d32a8d9f5..819ba48f7f84 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -485,7 +485,6 @@ "../../src/hooks/useRestoreWorkspacesTabOnNavigate.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/hooks/useReviewDuplicatesNavigation.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/hooks/useReviewDuplicatesNavigation.tsx" "react-hooks/set-state-in-effect" 1 -"../../src/hooks/useSearchAutoRefetch.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../src/hooks/useSearchBulkActions.ts" "@typescript-eslint/no-unsafe-type-assertion" 10 "../../src/hooks/useSidebarOrderedReports.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/hooks/useSidebarOrderedReports.tsx" "react-hooks/refs" 5 diff --git a/src/hooks/useSearchAutoRefetch.ts b/src/hooks/useSearchAutoRefetch.ts index 5394a1d28672..b25a0d3fbc75 100644 --- a/src/hooks/useSearchAutoRefetch.ts +++ b/src/hooks/useSearchAutoRefetch.ts @@ -1,4 +1,4 @@ -import type {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 {search} from '@libs/actions/Search'; @@ -195,6 +195,20 @@ function useSearchAutoRefetch({ return {newTransactions}; } +function getTransactionIDFromEntry(entry: unknown) { + if (typeof entry !== 'object' || entry === null || !('transactionID' in entry) || typeof entry.transactionID !== 'string') { + return undefined; + } + return entry.transactionID; +} + +function hasNestedTransactions(entry: unknown): entry is {transactions: Array>} { + if (typeof entry !== 'object' || entry === null || !('transactions' in entry)) { + return false; + } + return Array.isArray(entry.transactions); +} + /** * Helper function to extract transaction IDs from search results data. */ @@ -203,18 +217,21 @@ function extractTransactionIDsFromSearchResults(searchResultsData: Partial Date: Tue, 18 Aug 2026 15:03:31 +0200 Subject: [PATCH 35/37] Don't show the growl after log out --- src/GlobalModals.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/GlobalModals.tsx b/src/GlobalModals.tsx index 992b0fc55b70..435ce77e645b 100644 --- a/src/GlobalModals.tsx +++ b/src/GlobalModals.tsx @@ -5,6 +5,7 @@ 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'; @@ -25,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, @@ -51,7 +53,7 @@ function GlobalModals() { return ( <> - + {isAuthenticated && } {shouldRenderContextMenu && ( From 1f6e651cb7646a8a0471366c2e286b0fb0d008b6 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Thu, 20 Aug 2026 10:40:11 +0200 Subject: [PATCH 36/37] The last fix after merge --- .../Search/SearchList/ListItem/GroupChildrenContainer.tsx | 2 -- src/libs/actions/IOU/Duplicate.ts | 2 +- src/libs/actions/IOU/PerDiem.ts | 2 +- src/libs/actions/IOU/SendInvoice.ts | 2 +- src/libs/actions/IOU/Split.ts | 2 +- src/libs/actions/IOU/SplitTransactionUpdate.ts | 2 +- src/libs/actions/IOU/TrackExpense.ts | 2 +- .../IOU/{NavigationHelpers.ts => signalExpenseAddedGrowl.ts} | 4 +--- src/pages/Share/SubmitDetailsPage.tsx | 2 +- tests/unit/actionLayerNavigationGuardTest.ts | 2 -- 10 files changed, 8 insertions(+), 14 deletions(-) rename src/libs/actions/IOU/{NavigationHelpers.ts => signalExpenseAddedGrowl.ts} (84%) diff --git a/src/components/Search/SearchList/ListItem/GroupChildrenContainer.tsx b/src/components/Search/SearchList/ListItem/GroupChildrenContainer.tsx index bf17817804c9..816019fd1c25 100644 --- a/src/components/Search/SearchList/ListItem/GroupChildrenContainer.tsx +++ b/src/components/Search/SearchList/ListItem/GroupChildrenContainer.tsx @@ -1,5 +1,3 @@ -import {useSearchSelectionContext} from '@components/Search/SearchContext'; - import useExpandCollapseAnimation from '@hooks/useExpandCollapseAnimation'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; diff --git a/src/libs/actions/IOU/Duplicate.ts b/src/libs/actions/IOU/Duplicate.ts index 74c71d4b86b2..a22dde701d1a 100644 --- a/src/libs/actions/IOU/Duplicate.ts +++ b/src/libs/actions/IOU/Duplicate.ts @@ -66,8 +66,8 @@ import type {CreateTrackExpenseParams} from './TrackExpense'; import {getAllReports, getAllTransactions} from '.'; import {getCleanUpTransactionThreadReportOnyxData} from './DeleteMoneyRequest'; import {getMoneyRequestParticipantsFromReport} from './MoneyRequest'; -import {signalExpenseAddedGrowl} from './NavigationHelpers'; import {submitPerDiemExpense} from './PerDiem'; +import signalExpenseAddedGrowl from './signalExpenseAddedGrowl'; import {createDistanceRequest} from './Split'; import {requestMoney, trackExpense} from './TrackExpense'; diff --git a/src/libs/actions/IOU/PerDiem.ts b/src/libs/actions/IOU/PerDiem.ts index 86a7ae8d0525..bb118e835aef 100644 --- a/src/libs/actions/IOU/PerDiem.ts +++ b/src/libs/actions/IOU/PerDiem.ts @@ -71,7 +71,7 @@ import { mergePolicyRecentlyUsedCategories, mergePolicyRecentlyUsedCurrencies, } from './MoneyRequestBuilder'; -import {signalExpenseAddedGrowl} from './NavigationHelpers'; +import signalExpenseAddedGrowl from './signalExpenseAddedGrowl'; function removeSubrate(transaction: OnyxEntry, currentIndex: string) { // Index comes from the route params and is a string diff --git a/src/libs/actions/IOU/SendInvoice.ts b/src/libs/actions/IOU/SendInvoice.ts index 96fe4b2e3e09..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 {signalExpenseAddedGrowl} from './NavigationHelpers'; import {getSearchOnyxUpdate} from './SearchUpdate'; +import signalExpenseAddedGrowl from './signalExpenseAddedGrowl'; type SendInvoiceInformation = { senderWorkspaceID: string | undefined; diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts index 33ad2839d95e..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 {signalExpenseAddedGrowl} from './NavigationHelpers'; import {addPendingNewTransactionIDs, isOneToTwoTransactionTransition} from './PendingNewTransactions'; +import signalExpenseAddedGrowl from './signalExpenseAddedGrowl'; type IOURequestType = ValueOf; diff --git a/src/libs/actions/IOU/SplitTransactionUpdate.ts b/src/libs/actions/IOU/SplitTransactionUpdate.ts index b2124e5fffcd..cb64c6f1fd9b 100644 --- a/src/libs/actions/IOU/SplitTransactionUpdate.ts +++ b/src/libs/actions/IOU/SplitTransactionUpdate.ts @@ -81,7 +81,7 @@ import {getCleanUpTransactionThreadReportOnyxData} from './DeleteMoneyRequest'; import {getAllReports} from './index'; import {getMoneyRequestParticipantsFromReport} from './MoneyRequest'; import {getMoneyRequestInformation, getReportPreviewReportAction} from './MoneyRequestBuilder'; -import {signalExpenseAddedGrowl} from './NavigationHelpers'; +import signalExpenseAddedGrowl from './signalExpenseAddedGrowl'; import {getDeleteTrackExpenseInformation} from './TrackExpense'; import {getUpdateMoneyRequestParams} from './UpdateMoneyRequest'; diff --git a/src/libs/actions/IOU/TrackExpense.ts b/src/libs/actions/IOU/TrackExpense.ts index f53c8200ab25..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 {signalExpenseAddedGrowl} from './NavigationHelpers'; import {addPendingNewTransactionIDs, isOneToTwoTransactionTransition} from './PendingNewTransactions'; import {getSearchOnyxUpdate} from './SearchUpdate'; +import signalExpenseAddedGrowl from './signalExpenseAddedGrowl'; type TrackExpenseInformation = { createdWorkspaceParams?: CreateWorkspaceParams; diff --git a/src/libs/actions/IOU/NavigationHelpers.ts b/src/libs/actions/IOU/signalExpenseAddedGrowl.ts similarity index 84% rename from src/libs/actions/IOU/NavigationHelpers.ts rename to src/libs/actions/IOU/signalExpenseAddedGrowl.ts index fbe1c0ba544c..ed3593d09d72 100644 --- a/src/libs/actions/IOU/NavigationHelpers.ts +++ b/src/libs/actions/IOU/signalExpenseAddedGrowl.ts @@ -2,8 +2,6 @@ import {mergeExpenseAddedGrowlTransactionIDs} from '@userActions/Transaction'; import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; -import {getAllTransactions} from './index'; - /** * Signals the "Expense added" growl for a newly-created transaction. */ @@ -14,4 +12,4 @@ function signalExpenseAddedGrowl(transactionID: string | undefined, dataType: Se mergeExpenseAddedGrowlTransactionIDs({[transactionID]: dataType}); } -export {signalExpenseAddedGrowl}; +export default signalExpenseAddedGrowl; diff --git a/src/pages/Share/SubmitDetailsPage.tsx b/src/pages/Share/SubmitDetailsPage.tsx index 27f03efbc4ad..d88b89426f17 100644 --- a/src/pages/Share/SubmitDetailsPage.tsx +++ b/src/pages/Share/SubmitDetailsPage.tsx @@ -30,8 +30,8 @@ import { setMoneyRequestReimbursable, updateLastLocationPermissionPrompt, } from '@libs/actions/IOU/MoneyRequest'; -import {signalExpenseAddedGrowl} from '@libs/actions/IOU/NavigationHelpers'; 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'; 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'; From b680e1087c089340755cdd17001d9730c733d486 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Fri, 21 Aug 2026 10:24:14 +0200 Subject: [PATCH 37/37] Comments overhaul --- src/components/ExpenseAddedGrowl.tsx | 38 ++++++------------- src/hooks/useSearchAutoRefetch.ts | 5 +-- .../helpers/navigateAfterExpenseCreate.ts | 19 +++------- .../actions/IOU/signalExpenseAddedGrowl.ts | 4 +- src/pages/Share/SubmitDetailsPage.tsx | 4 +- tests/actions/IOU/SplitReportTotalsTest.ts | 5 --- tests/unit/ExpenseAddedGrowlTest.tsx | 30 ++++++++++++--- tests/unit/navigateAfterExpenseCreateTest.ts | 22 ++++++++++- 8 files changed, 68 insertions(+), 59 deletions(-) diff --git a/src/components/ExpenseAddedGrowl.tsx b/src/components/ExpenseAddedGrowl.tsx index 5bcbf866cc6d..f880475951cf 100644 --- a/src/components/ExpenseAddedGrowl.tsx +++ b/src/components/ExpenseAddedGrowl.tsx @@ -65,11 +65,6 @@ function ExpenseAddedGrowl() { ); } -/** - * Reads the candidate transaction and its report/actions and renders the growl. Split out from - * `ExpenseAddedGrowl` so these Onyx connections only exist while there is a pending signal or a visible growl - - * the outer component is mounted for the whole app lifetime and would otherwise hold them permanently while idle. - */ function ExpenseAddedGrowlContent({transactionID, signal, active, setActive}: ExpenseAddedGrowlContentProps) { const nonceRef = useRef(0); @@ -81,17 +76,16 @@ function ExpenseAddedGrowlContent({transactionID, signal, active, setActive}: Ex const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); const reportID = transaction?.reportID; const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); - // A tracked/unreported expense's transaction.reportID is UNREPORTED_REPORT_ID, and its IOU/track action lives - // on the self-DM report - not on transaction.reportID. Read actions from the report that actually hosts the - // action so "View" resolves the real transaction thread (the action's childReportID) instead of fabricating - // a mismatched optimistic one. + + // 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}`); - // Only IOU/expense/invoice reports have the expense-report RHP that "View" opens. A tracked/unreported - // (self-DM) expense lives in a chat, so it has no such report - leaving iouReportID undefined makes - // navigateToCreatedExpense open the transaction thread directly instead of a super-wide report RHP it lacks. + + // 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; @@ -100,18 +94,12 @@ function ExpenseAddedGrowlContent({transactionID, signal, active, setActive}: Ex 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) { - return; - } - // Wait for the created expense's optimistic data to land before acting. The create's API.write is - // usually deferred (the deferred-for-search pattern) until Search's onLayout flushes it, and the whole - // optimistic dataset (transaction + IOU report/action + thread) is applied atomically - so the - // transaction appearing in Onyx means "View" can resolve AND its report is known for the check below. - if (!transaction) { + if (!latestTransactionID || !dataType || !transaction) { return; } @@ -119,10 +107,7 @@ function ExpenseAddedGrowlContent({transactionID, signal, active, setActive}: Ex // 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 (its - // transaction list), where the new row is highlighted so a growl would be redundant. A tracked/self-DM - // expense has transaction.reportID === UNREPORTED_REPORT_ID (never a report you view), so this never - // matches for those and the growl always shows - matching "no transaction list to be in". + // Suppress the growl when the user is already viewing the expense's money-request report if (Navigation.getTopmostReportId() === transaction.reportID) { return; } @@ -136,9 +121,8 @@ function ExpenseAddedGrowlContent({transactionID, signal, active, setActive}: Ex const isInvoice = active.dataType === CONST.SEARCH.DATA_TYPES.INVOICE; - // Materialize the transaction thread and navigate to it at press time (not show time): the thread is - // only built if the user actually taps "View", against the freshest Onyx data, matching how every other - // thread navigation entry point builds the thread at navigation time. + // 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; diff --git a/src/hooks/useSearchAutoRefetch.ts b/src/hooks/useSearchAutoRefetch.ts index b25a0d3fbc75..9efc4d727343 100644 --- a/src/hooks/useSearchAutoRefetch.ts +++ b/src/hooks/useSearchAutoRefetch.ts @@ -48,9 +48,8 @@ type UseSearchAutoRefetch = { }; /** - * Hook used to trigger a search when a new transaction or report action is added, so the Search snapshot - * reflects freshly-created entries. Also returns the newly-added transactions, which the grouped views use to - * refetch each expanded group's child snapshot. + * 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 useSearchAutoRefetch({ searchResults, diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts index 6cd0c69f01d6..2e911a994998 100644 --- a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts @@ -70,13 +70,8 @@ type NavigateToCreatedExpenseParams = { }; /** - * Navigates to a just-created expense, choosing the destination from the surface the user - * is currently looking at (they may have switched tabs while the growl was up) - * - Spend tab: the transaction thread RHP within Spend (report shown underneath via the wide RHP) - * - Inbox tab, narrow layout: the transaction thread as a full report view - * - Inbox tab, wide layout, with an expense report: the expense report - a multi-transaction report opens super - * wide with the specific thread RHP stacked on top; a single-transaction report collapses to the thread itself - * - Inbox tab, wide layout, tracked/unreported expense (no expense report): the transaction thread RHP directly + * 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(); @@ -100,9 +95,8 @@ function navigateToCreatedExpense({threadReportID, transactionID, iouReportID}: if (iouReportID) { Navigation.navigate(ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: iouReportID, backTo}), {forceReplace}); - // A multi-transaction report opens super wide (see SearchMoneyRequestReportPage's `shouldShowSuperWideRHP`), - // so stack the specific thread RHP on top of it. A single-transaction report collapses to the thread - // itself, so the expense report navigation above already lands on it. + // 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) { @@ -117,9 +111,8 @@ function navigateToCreatedExpense({threadReportID, transactionID, iouReportID}: return; } - // Tracked/unreported expense (self-DM): there's no expense report, so open the transaction thread as a full - // report - the same way tapping the expense in its self-DM chat does (see ChatTransactionPreview), rather - // than the Search-tab RHP. + // 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}); } diff --git a/src/libs/actions/IOU/signalExpenseAddedGrowl.ts b/src/libs/actions/IOU/signalExpenseAddedGrowl.ts index ed3593d09d72..9ca2858f8dc3 100644 --- a/src/libs/actions/IOU/signalExpenseAddedGrowl.ts +++ b/src/libs/actions/IOU/signalExpenseAddedGrowl.ts @@ -2,9 +2,7 @@ import {mergeExpenseAddedGrowlTransactionIDs} from '@userActions/Transaction'; import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; -/** - * Signals the "Expense added" growl for a newly-created transaction. - */ +/** Queues the "Expense added" growl (toast) for a newly-created transaction. */ function signalExpenseAddedGrowl(transactionID: string | undefined, dataType: SearchDataTypes) { if (!transactionID) { return; diff --git a/src/pages/Share/SubmitDetailsPage.tsx b/src/pages/Share/SubmitDetailsPage.tsx index d88b89426f17..731621a01b85 100644 --- a/src/pages/Share/SubmitDetailsPage.tsx +++ b/src/pages/Share/SubmitDetailsPage.tsx @@ -466,8 +466,8 @@ function SubmitDetailsPage({ }); } - // requestMoney/trackExpense only signal the success growl for the global-create flow; the share extension - // isn't flagged as such, so signal it directly here with the created transaction's ID. + // 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); }; diff --git a/tests/actions/IOU/SplitReportTotalsTest.ts b/tests/actions/IOU/SplitReportTotalsTest.ts index 6e94cedced53..0cd68e66ca7f 100644 --- a/tests/actions/IOU/SplitReportTotalsTest.ts +++ b/tests/actions/IOU/SplitReportTotalsTest.ts @@ -753,11 +753,6 @@ describe('actions/IOU', () => { }; } - /** - * Reads the real REPORT_METADATA value rather than the mocked addPendingNewTransactionIDs. The flags are written - * by buildOnyxDataForMoneyRequest as Onyx optimisticData, not through that function, so assertions on the mock - * alone cannot observe them - which is how the Search/Spend pollution regressed after it was first fixed. - */ function getPendingNewTransactionIDsFromOnyx(reportID: string) { return new Promise | undefined>((resolve) => { const connection = Onyx.connect({ diff --git a/tests/unit/ExpenseAddedGrowlTest.tsx b/tests/unit/ExpenseAddedGrowlTest.tsx index d67b3f9b4688..b2513c16c2e6 100644 --- a/tests/unit/ExpenseAddedGrowlTest.tsx +++ b/tests/unit/ExpenseAddedGrowlTest.tsx @@ -37,7 +37,6 @@ jest.mock('@hooks/useCurrentUserPersonalDetails', () => () => ({accountID: 1, lo const EXPENSE = CONST.SEARCH.DATA_TYPES.EXPENSE; const INVOICE = CONST.SEARCH.DATA_TYPES.INVOICE; -/** Run Onyx mutations and let the growl's effect settle, wrapped in act() so React state updates aren't flagged. */ function flush(mutate: () => Promise) { return act(async () => { await mutate(); @@ -45,7 +44,6 @@ function flush(mutate: () => Promise) { }); } -/** Seed the signal + the created transaction so the growl can capture and (given the deferred-write gate) show. */ function seedExpense(transactionID: string, reportID: string, dataType: SearchDataTypes = EXPENSE) { return flush(async () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {transactionID, reportID}); @@ -53,7 +51,6 @@ function seedExpense(transactionID: string, reportID: string, dataType: SearchDa }); } -/** The props of the most recently rendered growl, or undefined if it never rendered. */ function lastGrowlProps() { return mockGrowlContent.mock.calls.at(-1)?.[0]; } @@ -71,39 +68,58 @@ describe('ExpenseAddedGrowl', () => { }); 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'}); @@ -113,10 +129,10 @@ describe('ExpenseAddedGrowl', () => { await Onyx.merge(ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS, signal); }); - // Exactly one growl shown for the batch... + // Then exactly one growl shows for the batch expect(mockGrowlContent).toHaveBeenCalledTimes(1); - // ...and the signal is consumed so it can't re-fire. + // 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, @@ -130,7 +146,11 @@ describe('ExpenseAddedGrowl', () => { }); 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/navigateAfterExpenseCreateTest.ts b/tests/unit/navigateAfterExpenseCreateTest.ts index b873b879624d..82511a93b767 100644 --- a/tests/unit/navigateAfterExpenseCreateTest.ts +++ b/tests/unit/navigateAfterExpenseCreateTest.ts @@ -181,82 +181,102 @@ describe('navigateAfterExpenseCreate', () => { 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}); - // Matches how tapping the expense in its self-DM chat opens it (full report), not the Spend RHP. + // 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}); });