From 47ece0230e816b21513fa336453a86f31cd8bf31 Mon Sep 17 00:00:00 2001
From: Vit Horacek <36083550+mountiny@users.noreply.github.com>
Date: Sun, 23 Aug 2026 22:46:50 +0200
Subject: [PATCH] =?UTF-8?q?Revert=20"Confirm=20new=20expenses=20with=20a?=
=?UTF-8?q?=20=E2=80=9CView=E2=80=9D=20toast=20instead=20of=20scrolling=20?=
=?UTF-8?q?to=20and=20highlighting=20the=20row"?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
config/eslint/eslint.seatbelt.tsv | 1 +
src/GlobalModals.tsx | 4 -
src/ONYXKEYS.ts | 7 +-
src/components/ExpenseAddedGrowl.tsx | 164 -----------
src/components/Search/ChatSearchView.tsx | 6 +-
.../Search/ExpenseFlatSearchView.tsx | 15 +-
.../Search/ExpenseGroupedSearchView.tsx | 26 +-
.../Search/ExpenseReportSearchView.tsx | 6 +-
.../SearchList/ListItem/ChatListItem.tsx | 11 +-
.../ListItem/ExpenseReportListItem.tsx | 14 +-
.../ListItem/GroupChildrenContainer.tsx | 23 +-
.../SearchList/ListItem/GroupHeader.tsx | 10 +-
.../SearchList/ListItem/TaskListItem.tsx | 17 +-
.../ListItem/TransactionGroupListItem.tsx | 10 +-
.../TransactionListItemNarrow.tsx | 47 +++-
.../TransactionListItemWide.tsx | 17 +-
src/components/Search/TaskSearchView.tsx | 6 +-
.../Search/hooks/useSearchSnapshot.ts | 58 +++-
src/components/Search/index.tsx | 30 +-
src/components/Search/searchViewProps.ts | 11 +-
...etch.ts => useSearchHighlightAndScroll.ts} | 237 ++++++++++++----
src/languages/de.ts | 2 -
src/languages/el.ts | 2 -
src/languages/en.ts | 2 -
src/languages/es.ts | 2 -
src/languages/fr.ts | 2 -
src/languages/it.ts | 2 -
src/languages/ja.ts | 2 -
src/languages/nl.ts | 2 -
src/languages/pl.ts | 2 -
src/languages/pt-BR.ts | 2 -
src/languages/zh-hans.ts | 2 -
src/libs/ExportOnyxState/common.ts | 2 +-
.../helpers/navigateAfterExpenseCreate.ts | 86 +-----
src/libs/actions/IOU/Duplicate.ts | 6 -
src/libs/actions/IOU/NavigationHelpers.ts | 47 ++++
src/libs/actions/IOU/PerDiem.ts | 6 +-
src/libs/actions/IOU/SendInvoice.ts | 6 +-
src/libs/actions/IOU/Split.ts | 6 +-
.../actions/IOU/SplitTransactionUpdate.ts | 29 +-
src/libs/actions/IOU/TrackExpense.ts | 16 +-
.../actions/IOU/signalExpenseAddedGrowl.ts | 13 -
src/libs/actions/Transaction.ts | 6 +-
src/libs/telemetry/submitFollowUpAction.ts | 4 +-
src/pages/Share/SubmitDetailsPage.tsx | 5 -
src/setup/index.ts | 3 -
src/styles/utils/index.ts | 4 -
tests/actions/IOU/SplitReportTotalsTest.ts | 180 ++++++++++--
tests/actions/IOUTest/SplitTest.ts | 6 +-
tests/unit/ExpenseAddedGrowlTest.tsx | 156 -----------
tests/unit/Search/useSearchSnapshotTest.ts | 28 ++
tests/unit/actionLayerNavigationGuardTest.ts | 2 +
tests/unit/navigateAfterExpenseCreateTest.ts | 120 +-------
....ts => useSearchHighlightAndScrollTest.ts} | 261 ++++++++++++++----
54 files changed, 948 insertions(+), 786 deletions(-)
delete mode 100644 src/components/ExpenseAddedGrowl.tsx
rename src/hooks/{useSearchAutoRefetch.ts => useSearchHighlightAndScroll.ts} (54%)
create mode 100644 src/libs/actions/IOU/NavigationHelpers.ts
delete mode 100644 src/libs/actions/IOU/signalExpenseAddedGrowl.ts
delete mode 100644 tests/unit/ExpenseAddedGrowlTest.tsx
rename tests/unit/{useSearchAutoRefetchTest.ts => useSearchHighlightAndScrollTest.ts} (59%)
diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv
index 8c07322f45a6..b100b32c6caa 100644
--- a/config/eslint/eslint.seatbelt.tsv
+++ b/config/eslint/eslint.seatbelt.tsv
@@ -474,6 +474,7 @@
"../../src/hooks/useReviewDuplicatesNavigation.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/hooks/useReviewDuplicatesNavigation.tsx" "react-hooks/set-state-in-effect" 1
"../../src/hooks/useSearchBulkActions.ts" "@typescript-eslint/no-unsafe-type-assertion" 10
+"../../src/hooks/useSearchHighlightAndScroll.ts" "@typescript-eslint/no-unsafe-type-assertion" 4
"../../src/hooks/useSidebarOrderedReports.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/hooks/useSidebarOrderedReports.tsx" "react-hooks/refs" 5
"../../src/hooks/useSidebarOrderedReports.tsx" "react-hooks/set-state-in-effect" 1
diff --git a/src/GlobalModals.tsx b/src/GlobalModals.tsx
index 435ce77e645b..a29dca9321cc 100644
--- a/src/GlobalModals.tsx
+++ b/src/GlobalModals.tsx
@@ -2,10 +2,8 @@ import React, {startTransition, useEffect, useState} from 'react';
import DelegateNoAccessModalProvider from './components/DelegateNoAccessModalProvider';
import EmojiPicker from './components/EmojiPicker/EmojiPicker';
-import ExpenseAddedGrowl from './components/ExpenseAddedGrowl';
import GrowlNotification from './components/GrowlNotification';
import LazyModalSlot from './components/LazyModalSlot';
-import useIsAuthenticated from './hooks/useIsAuthenticated';
import * as EmojiPickerAction from './libs/actions/EmojiPickerAction';
import {growlRef} from './libs/Growl';
import * as ReportActionContextMenu from './pages/inbox/report/ContextMenu/ReportActionContextMenu';
@@ -26,7 +24,6 @@ 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,
@@ -53,7 +50,6 @@ function GlobalModals() {
return (
<>
- {isAuthenticated && }
{shouldRenderContextMenu && (
diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts
index 98bf21b5a708..d054c019aecf 100755
--- a/src/ONYXKEYS.ts
+++ b/src/ONYXKEYS.ts
@@ -14,7 +14,6 @@ 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';
@@ -852,8 +851,8 @@ const ONYXKEYS = {
/** Whether the user has denied the contact import permission prompt */
HAS_DENIED_CONTACT_IMPORT_PROMPT: 'hasDeniedContactImportPrompt',
- /** Maps each newly-added transaction ID to its search data type, flagging it for the "Expense added" growl */
- EXPENSE_ADDED_GROWL_TRANSACTION_IDS: 'expenseAddedGrowlTransactionIDs',
+ /** The transaction IDs to be highlighted when opening the Expenses search route page */
+ TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE: 'transactionIdsHighlightOnSearchRoute',
/** The report ID to be highlighted when returning to the workspace rooms page */
ROOM_ID_HIGHLIGHT_ON_ROOMS_PAGE: 'roomIDHighlightOnRoomsPage',
@@ -1823,7 +1822,7 @@ type OnyxValuesMapping = {
[ONYXKEYS.NVP_REPORT_DETAILS_COLUMNS]: string[];
[ONYXKEYS.HAS_DENIED_CONTACT_IMPORT_PROMPT]: boolean | undefined;
[ONYXKEYS.PERSONAL_POLICY_ID]: string;
- [ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS]: Record;
+ [ONYXKEYS.TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE]: 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
deleted file mode 100644
index f880475951cf..000000000000
--- a/src/components/ExpenseAddedGrowl.tsx
+++ /dev/null
@@ -1,164 +0,0 @@
-import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
-import useLocalize from '@hooks/useLocalize';
-import useOnyx from '@hooks/useOnyx';
-
-import {createTransactionThreadReport, setOptimisticTransactionThread} from '@libs/actions/Report';
-import {mergeExpenseAddedGrowlTransactionIDs} from '@libs/actions/Transaction';
-import Log from '@libs/Log';
-import {navigateToCreatedExpense} from '@libs/Navigation/helpers/navigateAfterExpenseCreate';
-import Navigation from '@libs/Navigation/Navigation';
-import {getIOUActionForTransactionID} from '@libs/ReportActionsUtils';
-import {findSelfDMReportID, isInvoiceReport, isMoneyRequestReport} from '@libs/ReportUtils';
-
-import CONST from '@src/CONST';
-import ONYXKEYS from '@src/ONYXKEYS';
-import type {SearchDataTypes} from '@src/types/onyx/SearchResults';
-
-import type {Dispatch, SetStateAction} from 'react';
-
-import {useEffect, useRef, useState} from 'react';
-
-import GrowlNotificationContent from './GrowlNotification/GrowlNotificationContent';
-
-type ActiveGrowl = {
- /** ID of the transaction the growl was raised for */
- transactionID: string;
-
- /** Search data type the growl belongs to (expense, invoice, etc.) */
- dataType: SearchDataTypes;
-
- /** Identifies each growl instance so a stale slide-out dismissal can't clear a newer growl */
- nonce: number;
-};
-
-type ExpenseAddedGrowlContentProps = {
- /** Transaction to read for the growl: the active growl's transaction, or the latest pending signal ID */
- transactionID: string;
-
- /** Pending expense-added notifications, keyed by transaction ID with its search data type as the value */
- signal: Record | undefined;
-
- /** Currently displayed growl */
- active: ActiveGrowl | null;
-
- /** Setter for the active growl */
- setActive: Dispatch>;
-};
-
-/** Watches the "an expense was just added" Onyx signal and shows an "Expense added" growl with a "View" action. */
-function ExpenseAddedGrowl() {
- const [active, setActive] = useState(null);
- const [signal] = useOnyx(ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS);
- const transactionID = active?.transactionID ?? Object.keys(signal ?? {}).at(-1);
-
- if (!transactionID) {
- return null;
- }
-
- return (
-
- );
-}
-
-function ExpenseAddedGrowlContent({transactionID, signal, active, setActive}: ExpenseAddedGrowlContentProps) {
- const nonceRef = useRef(0);
-
- const {translate} = useLocalize();
- const currentUserPersonalDetails = useCurrentUserPersonalDetails();
- const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST);
- const [betas] = useOnyx(ONYXKEYS.BETAS);
- const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED);
- const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`);
- const reportID = transaction?.reportID;
- const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`);
-
- // A tracked expense's IOU action lives on the self-DM report, not on transaction.reportID, so read actions
- // from the report that actually hosts it so "View" can resolve the real transaction thread.
- const isUnreportedExpense = !reportID || reportID === CONST.REPORT.UNREPORTED_REPORT_ID;
- const selfDMReportID = isUnreportedExpense ? findSelfDMReportID() : undefined;
- const hostReportID = isUnreportedExpense ? selfDMReportID : reportID;
- const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${hostReportID}`);
-
- // A tracked expense lives in a chat, not an expense report. Leaving iouReportID undefined tells
- // navigateToCreatedExpense to open the transaction thread directly instead of an expense report RHP.
- const iouReport = isMoneyRequestReport(report) || isInvoiceReport(report) ? report : undefined;
- const iouReportID = iouReport?.reportID;
-
- useEffect(() => {
- if (active) {
- return;
- }
- const pendingTransactionIDs = Object.keys(signal ?? {});
-
- // The last key is the last-created transaction: IDs are large (rand64), so they exceed the array-index
- // range and JS preserves insertion order rather than sorting them numerically ascending.
- const latestTransactionID = pendingTransactionIDs.at(-1);
- const dataType = latestTransactionID ? signal?.[latestTransactionID] : undefined;
- if (!latestTransactionID || !dataType || !transaction) {
- return;
- }
-
- // Clear every pending ID but surface only the latest: rapid creations that pile up before this runs
- // collapse into a single growl for the newest expense.
- mergeExpenseAddedGrowlTransactionIDs(Object.fromEntries(pendingTransactionIDs.map((id) => [id, null])));
-
- // Suppress the growl when the user is already viewing the expense's money-request report
- if (Navigation.getTopmostReportId() === transaction.reportID) {
- return;
- }
- nonceRef.current += 1;
- setActive({transactionID: latestTransactionID, dataType, nonce: nonceRef.current});
- }, [signal, active, transaction, setActive]);
-
- if (!active) {
- return null;
- }
-
- const isInvoice = active.dataType === CONST.SEARCH.DATA_TYPES.INVOICE;
-
- // Build the thread on press rather than when the growl shows, so it is only created if the user taps
- // "View" and is built against the freshest Onyx data.
- const navigateToExpense = () => {
- const iouAction = getIOUActionForTransactionID(Object.values(reportActions ?? {}), active.transactionID);
- let threadReportID = transaction?.transactionThreadReportID ?? iouAction?.childReportID;
- if (threadReportID) {
- setOptimisticTransactionThread(threadReportID, iouReport?.reportID, iouAction?.reportActionID, iouReport?.policyID);
- } else {
- const optimisticThread = createTransactionThreadReport({
- introSelected,
- currentUserLogin: currentUserPersonalDetails?.login ?? '',
- currentUserAccountID: currentUserPersonalDetails?.accountID ?? CONST.DEFAULT_NUMBER_ID,
- betas,
- iouReport,
- iouReportAction: iouAction,
- transaction,
- personalDetails,
- });
- threadReportID = optimisticThread?.reportID;
- }
- if (!threadReportID) {
- Log.warn('[ExpenseAddedGrowl] Unable to resolve transaction thread reportID on View press.');
- return;
- }
- navigateToCreatedExpense({threadReportID, transactionID: active.transactionID, iouReportID});
- };
-
- return (
- setActive((prev) => (prev?.nonce === dismissedNonce ? null : prev))}
- />
- );
-}
-
-export default ExpenseAddedGrowl;
diff --git a/src/components/Search/ChatSearchView.tsx b/src/components/Search/ChatSearchView.tsx
index 361bb2aa734e..21f390a3fa3e 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 from 'react';
+import React, {useImperativeHandle} from 'react';
import type {SearchListItem} from './SearchList/ListItem/types';
import type {CommonSearchViewProps} from './searchViewProps';
@@ -49,6 +49,7 @@ function ChatSearchView({
onScroll,
contentContainerStyle,
containerStyle,
+ ref,
}: ChatSearchViewProps) {
const {type} = queryJSON;
@@ -64,6 +65,9 @@ function ChatSearchView({
const selectedItemsLength = data.reduce((acc, item) => acc + (item.keyForList && selectedTransactions[item.keyForList]?.isSelected ? 1 : 0), 0);
const totalItems = data.filter((item) => !isRowDeleted(item)).length;
+ // Flat data maps 1:1 to the rendered list, so highlight-scroll-to-index is the same as scroll-to-data-index.
+ useImperativeHandle(ref, () => ({scrollToIndex: scrollToListIndex}), [scrollToListIndex]);
+
const renderItem = (item: SearchListItem, index: number, isItemFocused: boolean, onFocus?: (event: NativeSyntheticEvent) => void) => (
// Chat rows never animate their exit (only grouped expenses do), so the wrapper just preserves the overflow clip.
item.pendingAction === CONST.RED_
* `toggle`/`toggleAll`/`selectedTransactions`). The shared list state and interactions come from
* `useSearchListViewState`, and the surrounding chrome (horizontal scroll, header bar, long-press menu)
* from `SearchListViewLayout`; this view only owns the flat-expense specifics: the `TransactionListItem`
- * renderer 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.
+ * 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`).
*/
function ExpenseFlatSearchView({
queryJSON,
@@ -54,6 +55,7 @@ function ExpenseFlatSearchView({
onScroll,
contentContainerStyle,
containerStyle,
+ ref,
}: ExpenseFlatSearchViewProps) {
const {type} = queryJSON;
@@ -87,6 +89,9 @@ function ExpenseFlatSearchView({
const selectedItemsLength = data.reduce((acc, item) => acc + (item.keyForList && selectedTransactions[item.keyForList]?.isSelected ? 1 : 0), 0);
const totalItems = data.filter((item) => !isRowDeleted(item)).length;
+ // Flat data maps 1:1 to the rendered list, so highlight-scroll-to-index is the same as scroll-to-data-index.
+ useImperativeHandle(ref, () => ({scrollToIndex: scrollToListIndex}), [scrollToListIndex]);
+
const renderItem = (item: SearchListItem, index: number, isItemFocused: boolean, onFocus?: (event: NativeSyntheticEvent) => void) => {
const isDisabled = isRowDeleted(item);
// Only expense row exits animate; invoice and trip transaction lists do not (matches the legacy per-type gate).
diff --git a/src/components/Search/ExpenseGroupedSearchView.tsx b/src/components/Search/ExpenseGroupedSearchView.tsx
index 9b59600cffea..57db036fc5bd 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, {useState} from 'react';
+import React, {useImperativeHandle, useState} from 'react';
import type {SearchListItem} from './SearchList/ListItem/types';
import type {CommonSearchViewProps, TransactionViewExtras} from './searchViewProps';
@@ -89,7 +89,8 @@ 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).
+ * 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.
*/
function ExpenseGroupedSearchView({
queryJSON,
@@ -111,6 +112,7 @@ function ExpenseGroupedSearchView({
onScroll,
contentContainerStyle,
containerStyle,
+ ref,
}: ExpenseGroupedSearchViewProps) {
const {type, groupBy} = queryJSON;
const {isLargeScreenWidth} = useResponsiveLayout();
@@ -170,6 +172,26 @@ 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 ed3b269d66b2..1a8ba373e619 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 from 'react';
+import React, {useImperativeHandle} from 'react';
import type {SearchListItem} from './SearchList/ListItem/types';
import type {CommonSearchViewProps} from './searchViewProps';
@@ -55,6 +55,7 @@ function ExpenseReportSearchView({
onScroll,
contentContainerStyle,
containerStyle,
+ ref,
}: ExpenseReportSearchViewProps) {
const {type} = queryJSON;
@@ -91,6 +92,9 @@ 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 styles = useThemeStyles();
const theme = useTheme();
const {isSelected} = useRowSelection(item.keyForList);
+ const animatedHighlightStyle = useAnimatedHighlightStyle({
+ borderRadius: variables.componentBorderRadius,
+ shouldHighlight: item?.shouldAnimateInHighlight ?? false,
+ highlightColor: theme.messageHighlightBG,
+ backgroundColor: theme.highlightBG,
+ });
const pressableStyle = [
styles.selectionListPressableItemWrapper,
styles.p0,
styles.textAlignLeft,
styles.overflowHidden,
- // Background is applied on the parent wrapper, so keep this transparent
+ // Removing background style because they are added to the parent OpacityView via animatedHighlightStyle
styles.bgTransparent,
isSelected && styles.activeComponentBG,
styles.mh0,
@@ -75,7 +82,7 @@ function ChatListItem({
keyForList={item.keyForList}
onFocus={onFocus}
shouldSyncFocus={shouldSyncFocus}
- pressableWrapperStyle={[styles.mh5, {backgroundColor: theme.highlightBG, borderRadius: variables.componentBorderRadius}]}
+ pressableWrapperStyle={[styles.mh5, animatedHighlightStyle]}
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 736d75b0a0e7..9b1954d33503 100644
--- a/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx
+++ b/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx
@@ -12,6 +12,7 @@ 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';
@@ -360,7 +361,7 @@ function ExpenseReportListItemInner({
styles.selectionListPressableItemWrapper,
isLargeScreenWidth && styles.pv3,
isLargeScreenWidth && styles.ph3,
- // Background is applied on the parent wrapper, so keep this transparent
+ // Removing background style because they are added to the parent OpacityView via animatedHighlightStyle
styles.bgTransparent,
isSelected && styles.activeComponentBG,
styles.mh0,
@@ -381,6 +382,14 @@ function ExpenseReportListItemInner({
[styles, isLargeScreenWidth],
);
+ const animatedHighlightStyle = useAnimatedHighlightStyle({
+ borderRadius: 0,
+ shouldHighlight: item?.shouldAnimateInHighlight ?? false,
+ highlightColor: theme.messageHighlightBG,
+ backgroundColor: isSelected ? theme.activeComponentBG : theme.highlightBG,
+ shouldApplyOtherStyles: !isLargeScreenWidth,
+ });
+
const shouldShowViolationDescription = isOpenExpenseReport(reportItem) || isProcessingReport(reportItem);
// Show violation description if either:
@@ -480,8 +489,7 @@ function ExpenseReportListItemInner({
hoverStyle={isSelected && styles.activeComponentBG}
pressableWrapperStyle={[
styles.mh5,
- StyleUtils.getSearchRowBackgroundStyle(isSelected),
- !isLargeScreenWidth && styles.br0,
+ animatedHighlightStyle,
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 816019fd1c25..a5e734772371 100644
--- a/src/components/Search/SearchList/ListItem/GroupChildrenContainer.tsx
+++ b/src/components/Search/SearchList/ListItem/GroupChildrenContainer.tsx
@@ -1,5 +1,6 @@
+import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle';
import useExpandCollapseAnimation from '@hooks/useExpandCollapseAnimation';
-import useStyleUtils from '@hooks/useStyleUtils';
+import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import React from 'react';
@@ -30,8 +31,8 @@ function GroupChildrenContainer({
isLastItem,
newTransactionID,
}: GroupChildrenContainerProps) {
+ const theme = useTheme();
const styles = useThemeStyles();
- const StyleUtils = useStyleUtils();
const {isRendered, animatedStyle, onLayout} = useExpandCollapseAnimation(isExpanded, false, item.keyForList);
const isContentVisible = isExpanded || isRendered;
const {isSelectAllChecked} = useGroupCheckboxState({groupKey: item.groupKeyForList, groupTransactions: item.transactions});
@@ -39,13 +40,27 @@ function GroupChildrenContainer({
// Only the rows this container holds decide its background, so a group still waiting for its first page is not painted as selected.
const isSelected = !!item.isSelected || (item.transactions.length > 0 && isSelectAllChecked);
+ const animatedHighlightStyle = useAnimatedHighlightStyle({
+ shouldHighlight: item?.shouldAnimateInHighlight ?? false,
+ highlightColor: theme.messageHighlightBG,
+ backgroundColor: isSelected ? theme.activeComponentBG : theme.highlightBG,
+ shouldApplyOtherStyles: false,
+ });
+
// Rendering null in FlashList can cause heavy first-render work; use an empty placeholder instead (LHN pattern).
if (!isExpanded && !isRendered) {
return ;
}
return (
-
+
{isContentVisible ? (
) : null}
-
+
);
}
diff --git a/src/components/Search/SearchList/ListItem/GroupHeader.tsx b/src/components/Search/SearchList/ListItem/GroupHeader.tsx
index ce99c5c95ba8..5e19f7353758 100644
--- a/src/components/Search/SearchList/ListItem/GroupHeader.tsx
+++ b/src/components/Search/SearchList/ListItem/GroupHeader.tsx
@@ -6,6 +6,7 @@ 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';
@@ -184,6 +185,13 @@ function GroupHeader({
keyForList: item.groupKeyForList,
});
+ const animatedHighlightStyle = useAnimatedHighlightStyle({
+ shouldHighlight: item?.shouldAnimateInHighlight ?? false,
+ highlightColor: theme.messageHighlightBG,
+ backgroundColor: isItemSelected ? theme.activeComponentBG : theme.highlightBG,
+ shouldApplyOtherStyles: false,
+ });
+
const handleSelectionButtonPress = () => {
onCheckboxPress(withOriginalKey(item), isExpenseReportType ? undefined : groupItem.transactions);
};
@@ -366,7 +374,7 @@ function GroupHeader({
]}
wrapperStyle={[
styles.mh5,
- StyleUtils.getSearchRowBackgroundStyle(!!isItemSelected),
+ animatedHighlightStyle,
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 341c2480a1a1..03d4d09aa549 100644
--- a/src/components/Search/SearchList/ListItem/TaskListItem.tsx
+++ b/src/components/Search/SearchList/ListItem/TaskListItem.tsx
@@ -2,6 +2,7 @@ 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 +60,7 @@ function TaskListItem({
styles.selectionListPressableItemWrapper,
styles.pv3,
styles.ph3,
- // Background is applied on the parent wrapper, so keep this transparent
+ // Removing background style because they are added to the parent OpacityView via animatedHighlightStyle
styles.bgTransparent,
isSelected && styles.activeComponentBG,
styles.mh0,
@@ -72,6 +73,14 @@ function TaskListItem({
isLargeScreenWidth ? {...styles.flexRow, ...styles.justifyContentBetween, ...styles.alignItemsCenter} : {...styles.flexColumn, ...styles.alignItemsStretch},
];
+ const animatedHighlightStyle = useAnimatedHighlightStyle({
+ borderRadius: StyleUtils.getSearchTableHighlightBorderRadius(isLargeScreenWidth),
+ shouldHighlight: item?.shouldAnimateInHighlight ?? false,
+ highlightColor: theme.messageHighlightBG,
+ backgroundColor: theme.highlightBG,
+ shouldApplyOtherStyles: !isLargeScreenWidth,
+ });
+
const fsClass = FS.getChatFSClass(parentReport);
return (
@@ -91,11 +100,7 @@ function TaskListItem({
onLongPressRow={onLongPressRow}
shouldSyncFocus={shouldSyncFocus}
hoverStyle={isSelected && styles.activeComponentBG}
- pressableWrapperStyle={[
- styles.mh5,
- {backgroundColor: theme.highlightBG, ...(!isLargeScreenWidth && {borderRadius: StyleUtils.getSearchTableHighlightBorderRadius(isLargeScreenWidth)})},
- isLargeScreenWidth && isLastItem && [styles.tableBottomRadius, styles.overflowHidden],
- ]}
+ pressableWrapperStyle={[styles.mh5, animatedHighlightStyle, 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: item?.shouldAnimateInHighlight ?? false,
+ highlightColor: theme.messageHighlightBG,
+ backgroundColor: isSelected ? theme.activeComponentBG : theme.highlightBG,
+ shouldApplyOtherStyles: true,
+ });
+
+ // The highlight animation is applied to the row wrapper, which sits behind this pressable. A focused
+ // row paints an opaque background on the pressable itself, which would cover the highlight - so after
+ // splitting an expense the newly-created row that receives focus never appears highlighted. Suppress
+ // the opaque focus background for the full highlight animation so the highlight shows. shouldAnimateInHighlight
+ // only stays true for the brief queue window, so latch it for durationHighlightItem.
+ const shouldAnimateInHighlight = !!item?.shouldAnimateInHighlight;
+
+ // Initialize from the prop so a row that mounts already flagged (the split/search highlight case this
+ // fixes) latches immediately - otherwise the render-time guard below never fires on first mount.
+ const [isHighlighting, setIsHighlighting] = useState(shouldAnimateInHighlight);
+ const [wasAnimatingHighlight, setWasAnimatingHighlight] = useState(shouldAnimateInHighlight);
+
+ // Start the latch during render (React's "storing information from previous renders" pattern) to avoid
+ // calling setState synchronously inside an effect. The effect below only clears it via an async timer.
+ if (shouldAnimateInHighlight !== wasAnimatingHighlight) {
+ setWasAnimatingHighlight(shouldAnimateInHighlight);
+ if (shouldAnimateInHighlight) {
+ setIsHighlighting(true);
+ }
+ }
+ useEffect(() => {
+ if (!isHighlighting) {
+ return;
+ }
+ const timer = setTimeout(() => setIsHighlighting(false), durationHighlightItem);
+ return () => clearTimeout(timer);
+ }, [isHighlighting]);
+ const shouldShowFocusBackground = !!isFocused && !isHighlighting;
+
return (
({
sentryLabel={CONST.SENTRY_LABEL.SEARCH.TRANSACTION_LIST_ITEM}
style={[
pressableStyle,
- isFocused && StyleUtils.getItemBackgroundColorStyle(isSelected, !!isFocused, !!item.isDisabled, theme.activeComponentBG, theme.hoverComponentBG),
+ shouldShowFocusBackground && StyleUtils.getItemBackgroundColorStyle(isSelected, !!isFocused, !!item.isDisabled, theme.activeComponentBG, theme.hoverComponentBG),
isDeletedTransaction && styles.cursorDefault,
]}
onFocus={onFocus}
wrapperStyle={[
styles.mh5,
styles.flex1,
- StyleUtils.getSearchRowBackgroundStyle(isSelected),
- styles.br0,
+ animatedHighlightStyle,
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 b31a8054aadb..188488030802 100644
--- a/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemWide.tsx
+++ b/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemWide.tsx
@@ -7,6 +7,7 @@ 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';
@@ -143,6 +144,14 @@ function TransactionListItemWide({
},
];
+ const animatedHighlightStyle = useAnimatedHighlightStyle({
+ borderRadius: 0,
+ shouldHighlight: item?.shouldAnimateInHighlight ?? false,
+ highlightColor: theme.messageHighlightBG,
+ backgroundColor: isSelected ? theme.activeComponentBG : theme.highlightBG,
+ shouldApplyOtherStyles: false,
+ });
+
return (
({
isDeletedTransaction && styles.cursorDefault,
]}
onFocus={onFocus}
- wrapperStyle={[
- styles.mh5,
- styles.flex1,
- StyleUtils.getSearchRowBackgroundStyle(isSelected),
- styles.userSelectNone,
- isLastItem && [styles.tableBottomRadius, styles.overflowHidden],
- ]}
+ wrapperStyle={[styles.mh5, styles.flex1, animatedHighlightStyle, styles.userSelectNone, isLastItem && [styles.tableBottomRadius, styles.overflowHidden]]}
>
{({hovered}) => (
acc + (item.keyForList && selectedTransactions[item.keyForList]?.isSelected ? 1 : 0), 0);
const totalItems = data.filter((item) => !isRowDeleted(item)).length;
+ // Flat data maps 1:1 to the rendered list, so highlight-scroll-to-index is the same as scroll-to-data-index.
+ useImperativeHandle(ref, () => ({scrollToIndex: scrollToListIndex}), [scrollToListIndex]);
+
const renderItem = (item: SearchListItem, index: number, isItemFocused: boolean, onFocus?: (event: NativeSyntheticEvent) => void) => (
// Task rows never animate their exit (only grouped expenses do), so the wrapper just preserves the overflow clip.
;
/** The current search snapshot, owned by the ancestor and passed in. */
searchResults: SearchResults | undefined;
+ /** Keys flagged for the post-create highlight animation. */
+ newSearchResultKeys: Set | null | undefined;
/** Full TRANSACTION + REPORT_ACTIONS collections used by the optimistic-row tracking. Threaded in from
- * the parent (which already subscribes to them for the refetch hook) so we don't open duplicate
+ * the parent (which already subscribes to them for the highlight hook) so we don't open duplicate
* full-collection reads. */
transactions: OptimisticTrackingParams['transactions'];
reportActions: OptimisticTrackingParams['reportActions'];
@@ -87,11 +89,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, 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, 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.
*/
-function useSearchSnapshot({queryJSON, searchResults, transactions, reportActions}: UseSearchSnapshotParams): SearchSnapshotResult {
+function useSearchSnapshot({queryJSON, searchResults, newSearchResultKeys, transactions, reportActions}: UseSearchSnapshotParams): SearchSnapshotResult {
const {type, sortBy, sortOrder, hash, groupBy} = queryJSON;
const {isOffline} = useNetwork();
@@ -305,8 +307,8 @@ function useSearchSnapshot({queryJSON, searchResults, transactions, reportAction
exportReportActions,
]);
- // Stage 3: sort the (enriched) data. getSortedSections accepts the full section union; our
- // SearchListItem[] is a compatible subset of that input.
+ // 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.
const chartData = useMemo(() => {
if (!shouldComputeSections) {
return EMPTY_DATA;
@@ -317,13 +319,47 @@ function useSearchSnapshot({queryJSON, searchResults, transactions, reportAction
policyTags,
fallbackPolicyID: policyForMovingExpensesID,
}).map((item) => {
- if (item.hash === hash) {
+ // 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) {
return item;
}
- return {...item, hash};
+ return {...item, shouldAnimateInHighlight, hash};
});
- }, [shouldComputeSections, type, filteredData, localeCompare, translate, sortBy, sortOrder, validGroupBy, policyCategories, policyTags, policyForMovingExpensesID, hash]);
+ }, [
+ shouldComputeSections,
+ type,
+ filteredData,
+ localeCompare,
+ translate,
+ sortBy,
+ sortOrder,
+ validGroupBy,
+ policyCategories,
+ policyTags,
+ policyForMovingExpensesID,
+ isChat,
+ newSearchResultKeys,
+ 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 b40179541b70..637add171c79 100644
--- a/src/components/Search/index.tsx
+++ b/src/components/Search/index.tsx
@@ -1,6 +1,7 @@
import FullPageErrorView from '@components/BlockingViews/FullPageErrorView';
import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView';
import {usePersonalDetails} from '@components/OnyxListItemProvider';
+import type {SelectionListHandle} from '@components/SelectionList/types';
import SearchRowSkeleton from '@components/Skeletons/SearchRowSkeleton';
import {useWideRHPActions} from '@components/WideRHPContextProvider';
@@ -15,7 +16,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/useSearchAutoRefetch';
+import useSearchHighlightAndScroll from '@hooks/useSearchHighlightAndScroll';
import useSearchShouldCalculateTotals, {getSearchRequestOffsetForMissingAllMatchingCount} from '@hooks/useSearchShouldCalculateTotals';
import useStableArrayReference from '@hooks/useStableArrayReference';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -207,6 +208,7 @@ function Search({
const previousReportActions = usePrevious(reportActions);
const {translate} = useLocalize();
const {getCurrencyDecimals} = useCurrencyListActions();
+ const searchListRef = useRef | null>(null);
const savedSearchSelector = useCallback((searches: OnyxEntry) => searches?.[hash], [hash]);
const [savedSearch] = useOnyx(ONYXKEYS.SAVED_SEARCHES, {
@@ -229,7 +231,7 @@ function Search({
clearSelectedTransactions();
}, [validGroupBy, prevValidGroupBy, clearSelectedTransactions]);
- const {newTransactions} = useSearchAutoRefetch({
+ const {newSearchResultKeys, handleSelectionListScroll, newTransactions, hasQueuedHighlights} = useSearchHighlightAndScroll({
searchResults,
transactions,
previousTransactions,
@@ -239,6 +241,7 @@ function Search({
shouldCalculateTotals,
reportActions,
previousReportActions,
+ shouldUseLiveData,
});
const {
@@ -260,10 +263,21 @@ function Search({
} = useSearchSnapshot({
queryJSON,
searchResults,
+ newSearchResultKeys,
transactions,
reportActions,
});
+ // Mirror `hasQueuedHighlights` into a ref so the post-create-flow `useFocusEffect`
+ // (which has empty deps) can read the latest value without re-creating its callback.
+ // Used to skip the deferral that would otherwise hide the freshly-added row from
+ // FlashList during the RHP dismiss transition, which would prevent the highlight
+ // animation from ever firing on it.
+ const hasQueuedHighlightsRef = useRef(hasQueuedHighlights);
+ useEffect(() => {
+ hasQueuedHighlightsRef.current = hasQueuedHighlights;
+ }, [hasQueuedHighlights]);
+
// There's a race condition in Onyx which makes it return data from the previous Search, so in addition to checking that the data is loaded
// we also need to check that the searchResults matches the type and status of the current search
const isDataLoaded = shouldUseLiveData || isSearchDataLoaded(searchResults, queryJSON);
@@ -323,6 +337,14 @@ 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.
@@ -801,8 +823,9 @@ function Search({
const onLayout = useCallback(() => {
onLayoutBase();
+ handleSelectionListScroll(stableSortedData, searchListRef.current);
onContentReady?.();
- }, [onLayoutBase, onContentReady]);
+ }, [onLayoutBase, handleSelectionListScroll, stableSortedData, onContentReady]);
// Must be a ref, not state: cancelNavigationSpans is called during render
// (inside conditional returns), so using setState would trigger infinite re-renders.
@@ -1145,6 +1168,7 @@ 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 d11afac6b502..40da64005c12 100644
--- a/src/components/Search/searchViewProps.ts
+++ b/src/components/Search/searchViewProps.ts
@@ -4,11 +4,17 @@ 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.
@@ -41,7 +47,7 @@ type CommonSearchViewProps = {
/** Whether everything has been loaded (gates the fully-checked select-all state). */
hasLoadedAllTransactions?: boolean;
- /** Newly-added transactions (feeds BaseSearchList extraData and the grouped-view child-snapshot refetch). */
+ /** Rows flagged for the post-create highlight animation (feeds BaseSearchList extraData). */
newTransactions: Transaction[];
/** The navigation handler for a row tap (owned by the router). */
@@ -64,6 +70,9 @@ 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/useSearchAutoRefetch.ts b/src/hooks/useSearchHighlightAndScroll.ts
similarity index 54%
rename from src/hooks/useSearchAutoRefetch.ts
rename to src/hooks/useSearchHighlightAndScroll.ts
index 9efc4d727343..77e057924e11 100644
--- a/src/hooks/useSearchAutoRefetch.ts
+++ b/src/hooks/useSearchHighlightAndScroll.ts
@@ -1,7 +1,9 @@
-import type {TransactionListItemType} from '@components/Search/SearchList/ListItem/types';
+import type {SearchListItem, 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';
@@ -14,44 +16,29 @@ import type {ReportActions, SearchResults, Transaction} from '@src/types/onyx';
import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
import {useIsFocused} from '@react-navigation/native';
-import {useEffect, useRef} from 'react';
+import {useCallback, useEffect, useRef, useState} from 'react';
import useNetwork from './useNetwork';
+import useOnyx from './useOnyx';
+import usePrevious from './usePrevious';
-type UseSearchAutoRefetch = {
- /** Current Search snapshot */
+type UseSearchHighlightAndScroll = {
searchResults: OnyxEntry;
-
- /** Current transactions collection */
transactions: OnyxCollection;
-
- /** Previous transactions collection, compared against `transactions` to detect newly-added ones */
previousTransactions: OnyxCollection;
-
- /** Current report actions collection */
reportActions: OnyxCollection;
-
- /** Previous report actions collection, compared against `reportActions` to detect new entries */
previousReportActions: OnyxCollection;
-
- /** Parsed search query the refetch is issued for */
queryJSON: SearchQueryJSON;
-
- /** Key identifying the current search */
searchKey: SearchKey | undefined;
-
- /** Pagination offset for the refetch request */
offset: number;
-
- /** Whether the refetch should recalculate result totals */
shouldCalculateTotals: boolean;
+ shouldUseLiveData: boolean;
};
/**
- * 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.
+ * Hook used to trigger a search when a new transaction or report action is added and handle highlighting and scrolling.
*/
-function useSearchAutoRefetch({
+function useSearchHighlightAndScroll({
searchResults,
transactions,
previousTransactions,
@@ -61,13 +48,25 @@ function useSearchAutoRefetch({
searchKey,
offset,
shouldCalculateTotals,
-}: UseSearchAutoRefetch) {
+ shouldUseLiveData,
+}: UseSearchHighlightAndScroll) {
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 ?? {});
@@ -154,6 +153,13 @@ function useSearchAutoRefetch({
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({
@@ -189,23 +195,155 @@ function useSearchAutoRefetch({
}
searchTriggeredRef.current = false;
- }, [searchResults?.search?.isLoading, searchResultsData]);
+ }, [searchResults?.search?.isLoading, shouldUseLiveData, searchResultsData]);
- return {newTransactions};
-}
+ // Initialize the set with existing IDs only once
+ useEffect(() => {
+ if (initializedRef.current || !searchResultsData) {
+ return;
+ }
-function getTransactionIDFromEntry(entry: unknown) {
- if (typeof entry !== 'object' || entry === null || !('transactionID' in entry) || typeof entry.transactionID !== 'string') {
- return undefined;
- }
- return entry.transactionID;
-}
+ const initialIDs = isChat ? extractReportActionIDsFromSearchResults(searchResultsData) : extractTransactionIDsFromSearchResults(searchResultsData);
+ highlightedIDs.current = new Set(initialIDs);
+ initializedRef.current = true;
+ }, [searchResultsData, isChat]);
-function hasNestedTransactions(entry: unknown): entry is {transactions: Array>} {
- if (typeof entry !== 'object' || entry === null || !('transactions' in entry)) {
- return false;
- }
- return Array.isArray(entry.transactions);
+ // 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;
+ }
+
+ // Reset the trigger even when the item is already first so a later render cannot scroll or highlight it again.
+ triggeredByHookRef.current = false;
+ if (indexOfNewItem === 0) {
+ return;
+ }
+
+ // Perform the scrolling action
+ ref.scrollToIndex(indexOfNewItem);
+ };
+
+ const hasQueuedHighlights = newSearchResultKeys !== null && newSearchResultKeys.size > 0;
+
+ return {newSearchResultKeys, handleSelectionListScroll, newTransactions, hasQueuedHighlights};
}
/**
@@ -216,21 +354,18 @@ function extractTransactionIDsFromSearchResults(searchResultsData: Partial searchResultIDs.has(transactionID));
}
-export default useSearchAutoRefetch;
-export type {UseSearchAutoRefetch};
+export default useSearchHighlightAndScroll;
+export type {UseSearchHighlightAndScroll};
diff --git a/src/languages/de.ts b/src/languages/de.ts
index cb7f64f28b61..bc6eb4fce2e5 100644
--- a/src/languages/de.ts
+++ b/src/languages/de.ts
@@ -1279,8 +1279,6 @@ const translations: TranslationDeepObject = {
createTimeExpense: 'Zeitaufwand erstellen',
},
iou: {
- expenseAdded: 'Ausgabe hinzugefügt',
- invoiceSent: 'Rechnung gesendet',
amount: 'Betrag',
percent: 'Prozent',
date: 'Datum',
diff --git a/src/languages/el.ts b/src/languages/el.ts
index a6fdc26d33f0..e2c13634045d 100644
--- a/src/languages/el.ts
+++ b/src/languages/el.ts
@@ -1328,8 +1328,6 @@ const translations: TranslationDeepObject = {
createTimeExpense: 'Δημιουργία χρονοχρέωσης',
},
iou: {
- expenseAdded: 'Δαπάνη προστέθηκε',
- invoiceSent: 'Τιμολόγιο στάλθηκε',
amount: 'Ποσό',
percent: 'Ποσοστό',
date: 'Ημερομηνία',
diff --git a/src/languages/en.ts b/src/languages/en.ts
index adc457476302..212b341e5fb2 100644
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -1350,8 +1350,6 @@ 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 c408d0807cb4..5e635d42524c 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -1275,8 +1275,6 @@ 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 a145c6100be3..5a36c572e52d 100644
--- a/src/languages/fr.ts
+++ b/src/languages/fr.ts
@@ -1283,8 +1283,6 @@ 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 e4ff503a7583..0c66764b7cbb 100644
--- a/src/languages/it.ts
+++ b/src/languages/it.ts
@@ -1278,8 +1278,6 @@ 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 557fd73bd21d..f85b3cb1d3e0 100644
--- a/src/languages/ja.ts
+++ b/src/languages/ja.ts
@@ -1260,8 +1260,6 @@ const translations: TranslationDeepObject = {
createTimeExpense: '時間経費を作成',
},
iou: {
- expenseAdded: '経費を追加しました',
- invoiceSent: '請求書を送信しました',
amount: '金額',
percent: 'パーセント',
date: '日付',
diff --git a/src/languages/nl.ts b/src/languages/nl.ts
index 995e91f8b976..bba67dadb0d8 100644
--- a/src/languages/nl.ts
+++ b/src/languages/nl.ts
@@ -1277,8 +1277,6 @@ 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 7980525f2ff9..fd435a3337fc 100644
--- a/src/languages/pl.ts
+++ b/src/languages/pl.ts
@@ -1306,8 +1306,6 @@ 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 fe2eff9d666b..3c3696575a3a 100644
--- a/src/languages/pt-BR.ts
+++ b/src/languages/pt-BR.ts
@@ -1277,8 +1277,6 @@ 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 d918c1b3b5f3..28a6abbe55c8 100644
--- a/src/languages/zh-hans.ts
+++ b/src/languages/zh-hans.ts
@@ -1229,8 +1229,6 @@ 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 0eef90965c64..df754b9647ea 100644
--- a/src/libs/ExportOnyxState/common.ts
+++ b/src/libs/ExportOnyxState/common.ts
@@ -207,7 +207,6 @@ const safeOnyxKeys = new Set([
ONYXKEYS.CURRENCY_LIST,
ONYXKEYS.CURRENT_DATE,
ONYXKEYS.DOMAIN_GROUP_CREATE_PREFERRED_POLICY_ID,
- ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS,
ONYXKEYS.EXPENSIFY_CARD_STATEMENT,
ONYXKEYS.FULLSCREEN_VISIBILITY,
ONYXKEYS.GOVERNMENT_MILEAGE_RATES,
@@ -335,6 +334,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.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS,
ONYXKEYS.TRAVEL_BILLING_STATEMENT,
ONYXKEYS.VALIDATE_DOMAIN_TWO_FACTOR_CODE,
diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts
index 7af6321f3942..5a9673ca36b1 100644
--- a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts
+++ b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts
@@ -1,10 +1,8 @@
import {addPendingNewTransactionIDs} from '@libs/actions/IOU/PendingNewTransactions';
-import {setActiveTransactionIDs} from '@libs/actions/TransactionThreadNavigation';
import getIsNarrowLayout from '@libs/getIsNarrowLayout';
import Log from '@libs/Log';
import {getPreservedNavigatorState} from '@libs/Navigation/AppNavigator/createSplitNavigator/usePreserveNavigatorState';
import Navigation, {navigationRef} from '@libs/Navigation/Navigation';
-import {getReportTransactions} from '@libs/ReportUtils';
import {buildCannedSearchQuery, getCurrentSearchQueryJSON} from '@libs/SearchQueryUtils';
import {setPendingSubmitFollowUpAction} from '@libs/telemetry/submitFollowUpAction';
@@ -14,31 +12,16 @@ import ROUTES from '@src/ROUTES';
import SCREENS from '@src/SCREENS';
import dismissModalAndOpenReportInInboxTab from './dismissModalAndOpenReportInInboxTab';
-import isReportOpenInRHP from './isReportOpenInRHP';
import isReportTopmostSplitNavigator from './isReportTopmostSplitNavigator';
import isSearchTopmostFullScreenRoute from './isSearchTopmostFullScreenRoute';
-import setNavigationActionToMicrotaskQueue from './setNavigationActionToMicrotaskQueue';
type NavigateAfterExpenseCreateParams = {
- /** Report the expense was created in */
activeReportID?: string;
-
- /** The created transaction's ID */
transactionID?: string;
-
- /** Whether the expense was started from the global create flow (FAB/no existing report) rather than from within a report */
isFromGlobalCreate?: boolean;
-
- /** Whether the created item is an invoice rather than a regular expense */
isInvoice?: boolean;
-
- /** Whether the destination report already contains transactions */
hasMultipleTransactions: boolean;
-
- /** Whether to record the transaction ID in the report's pendingNewTransactionIDs metadata, used to highlight the newly-added row */
shouldAddPendingNewTransactionIDs?: boolean;
-
- /** Whether to perform navigation, or only run the side effects */
shouldNavigate?: boolean;
/**
@@ -65,78 +48,12 @@ function getNavigateAfterCreateSearchNavigatorState() {
return searchNavigatorRoute?.state ?? (searchNavigatorRoute?.key ? getPreservedNavigatorState(searchNavigatorRoute.key) : undefined);
}
-function getCurrentRouteBackTo() {
- const params = navigationRef.current?.getCurrentRoute()?.params;
- if (typeof params !== 'object' || params === null || !('backTo' in params) || typeof params.backTo !== 'string') {
- return undefined;
- }
- return params.backTo;
-}
-
-type NavigateToCreatedExpenseParams = {
- /** The transaction thread report to open. */
- threadReportID: string;
-
- /** The created transaction's ID. */
- transactionID: string;
-
- /** IOU report the transaction landed in, used to decide whether to stack the expense report underneath. */
- iouReportID?: string;
-};
-
-/**
- * Opens a just-created expense when "View" is pressed on the "Expense added" growl. The user may have
- * switched tabs while the growl was up, so the destination follows wherever they are now.
- */
-function navigateToCreatedExpense({threadReportID, transactionID, iouReportID}: NavigateToCreatedExpenseParams) {
- const openOnInbox = isReportTopmostSplitNavigator() && !isSearchTopmostFullScreenRoute();
-
- // When a report/expense is already open in the RHP the app's convention is to replace it rather than stack a second
- // report RHP on top of it.
- const forceReplace = isReportOpenInRHP(navigationRef.getRootState());
- const backTo = forceReplace ? getCurrentRouteBackTo() : Navigation.getActiveRoute();
-
- if (!openOnInbox) {
- setActiveTransactionIDs([transactionID]).then(() => {
- Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: threadReportID, backTo}), {forceReplace});
- });
- return;
- }
-
- if (getIsNarrowLayout()) {
- Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(threadReportID, undefined, undefined, backTo), {forceReplace});
- return;
- }
- if (iouReportID) {
- Navigation.navigate(ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: iouReportID, backTo}), {forceReplace});
-
- // A multi-transaction report opens super wide RHP, so stack the thread RHP on top of it. A single-transaction
- // report collapses to the thread itself, so the navigation above already landed on it.
- const hasMultipleReportTransactions =
- getReportTransactions(iouReportID).filter((transaction) => transaction?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE).length > 1;
- if (hasMultipleReportTransactions) {
- // Defer so the thread RHP stacks on top of the expense report navigation above. This is always a
- // push (never a replace) - it stacks on the report we just opened, not on the previously-open one.
- setNavigationActionToMicrotaskQueue(() => {
- setActiveTransactionIDs([transactionID]).then(() => {
- Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: threadReportID, backTo: Navigation.getActiveRoute()}));
- });
- });
- }
- return;
- }
-
- // A tracked expense has no expense report, so open the thread as a full report - the same way tapping
- // the expense in its self-DM chat does.
- Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(threadReportID, undefined, undefined, backTo), {forceReplace});
-}
-
/**
* Helper to navigate after an expense is created in order to standardize the post‑creation experience
* when creating an expense from the global create button.
* If the expense is created from the global create button then:
* - If it is created on the inbox tab, it will open the chat report containing that expense.
- * - If it is created elsewhere, it will navigate to Reports > Expense and show the "Expense added" growl.
+ * - If it is created elsewhere, it will navigate to Reports > Expense and highlight the newly created expense.
*/
function navigateAfterExpenseCreate({
activeReportID,
@@ -213,4 +130,3 @@ function navigateAfterExpenseCreate({
}
export default navigateAfterExpenseCreate;
-export {navigateToCreatedExpense};
diff --git a/src/libs/actions/IOU/Duplicate.ts b/src/libs/actions/IOU/Duplicate.ts
index d8f49a894386..16b8390baa7d 100644
--- a/src/libs/actions/IOU/Duplicate.ts
+++ b/src/libs/actions/IOU/Duplicate.ts
@@ -67,7 +67,6 @@ import {getAllReports, getAllTransactions} from '.';
import {getCleanUpTransactionThreadReportOnyxData} from './DeleteMoneyRequest';
import {getMoneyRequestParticipantsFromReport} from './MoneyRequest';
import {submitPerDiemExpense} from './PerDiem';
-import signalExpenseAddedGrowl from './signalExpenseAddedGrowl';
import {createDistanceRequest} from './Split';
import {requestMoney, trackExpense} from './TrackExpense';
@@ -1245,7 +1244,6 @@ function bulkDuplicateExpenses({
// A copy of an unreported expense is tracked, so only an expense that lands on the report can submit it.
const lastReportBoundIndex = targetPolicy ? transactionsToDuplicate.findLastIndex((t) => !!t.reportID && t.reportID !== CONST.REPORT.UNREPORTED_REPORT_ID) : -1;
- let lastDuplicateTransactionID: string | undefined;
for (let i = 0; i < transactionsToDuplicate.length; i++) {
const item = transactionsToDuplicate.at(i);
@@ -1316,9 +1314,6 @@ function bulkDuplicateExpenses({
if (result?.iouReport) {
optimisticIOUReport = result.iouReport;
}
- if (result?.transactionID) {
- lastDuplicateTransactionID = result.transactionID;
- }
if (currentTargetReport && !currentTargetReport.iouReportID) {
currentTargetReport = {...currentTargetReport, iouReportID: currentOptimisticIOUReportID};
@@ -1326,7 +1321,6 @@ 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
new file mode 100644
index 000000000000..913a80c8a8eb
--- /dev/null
+++ b/src/libs/actions/IOU/NavigationHelpers.ts
@@ -0,0 +1,47 @@
+import isReportTopmostSplitNavigator from '@libs/Navigation/helpers/isReportTopmostSplitNavigator';
+import navigateAfterExpenseCreate from '@libs/Navigation/helpers/navigateAfterExpenseCreate';
+
+import {mergeTransactionIdsHighlightOnSearchRoute} from '@userActions/Transaction';
+
+import type {SearchDataTypes} from '@src/types/onyx/SearchResults';
+
+import {getAllTransactions} from './index';
+
+/**
+ * Marks a transaction for highlight on the Search page when the expense was created
+ * from the global create button and the user is not on the Inbox tab.
+ */
+function highlightTransactionOnSearchRouteIfNeeded(isFromGlobalCreate: boolean | undefined, transactionID: string | undefined, dataType: SearchDataTypes) {
+ if (!isFromGlobalCreate || isReportTopmostSplitNavigator() || !transactionID) {
+ return;
+ }
+ mergeTransactionIdsHighlightOnSearchRoute(dataType, {[transactionID]: true});
+}
+
+/**
+ * Helper to navigate after an expense is created in order to standardize the post‑creation experience
+ * when creating an expense from the global create button.
+ * If the expense is created from the global create button then:
+ * - If it is created on the inbox tab, it will open the chat report containing that expense.
+ * - If it is created elsewhere, it will navigate to Reports > Expense and highlight the newly created expense.
+ */
+function handleNavigateAfterExpenseCreate({
+ activeReportID,
+ transactionID,
+ isFromGlobalCreate,
+ isInvoice,
+ shouldAddPendingNewTransactionIDs = false,
+ shouldNavigate = true,
+}: {
+ activeReportID?: string;
+ transactionID?: string;
+ isFromGlobalCreate?: boolean;
+ isInvoice?: boolean;
+ shouldAddPendingNewTransactionIDs?: boolean;
+ shouldNavigate?: boolean;
+}) {
+ const hasMultipleTransactions = Object.values(getAllTransactions()).filter((transaction) => transaction?.reportID === activeReportID).length > 0;
+ navigateAfterExpenseCreate({activeReportID, transactionID, isFromGlobalCreate, isInvoice, hasMultipleTransactions, shouldAddPendingNewTransactionIDs, shouldNavigate});
+}
+
+export {handleNavigateAfterExpenseCreate, highlightTransactionOnSearchRouteIfNeeded};
diff --git a/src/libs/actions/IOU/PerDiem.ts b/src/libs/actions/IOU/PerDiem.ts
index bb118e835aef..9dd34328f413 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 './signalExpenseAddedGrowl';
+import {highlightTransactionOnSearchRouteIfNeeded} from './NavigationHelpers';
function removeSubrate(transaction: OnyxEntry, currentIndex: string) {
// Index comes from the route params and is a string
@@ -1124,9 +1124,7 @@ function submitPerDiemExpense(submitPerDiemExpenseInformation: PerDiemExpenseInf
TransitionTracker.runAfterTransitions({callback: () => removeDraftTransaction(CONST.IOU.OPTIMISTIC_TRANSACTION_ID), waitForUpcomingTransition: true});
- if (isFromGlobalCreate) {
- signalExpenseAddedGrowl(transaction.transactionID, CONST.SEARCH.DATA_TYPES.EXPENSE);
- }
+ highlightTransactionOnSearchRouteIfNeeded(isFromGlobalCreate, 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 1034b9e87083..68e8cd52b51b 100644
--- a/src/libs/actions/IOU/SendInvoice.ts
+++ b/src/libs/actions/IOU/SendInvoice.ts
@@ -44,8 +44,8 @@ import type BasePolicyParams from './types/BasePolicyParams';
import {getAllPersonalDetails} from '.';
import {getReceiptError, mergePolicyRecentlyUsedCategories, mergePolicyRecentlyUsedCurrencies} from './MoneyRequestBuilder';
+import {highlightTransactionOnSearchRouteIfNeeded} from './NavigationHelpers';
import {getSearchOnyxUpdate} from './SearchUpdate';
-import signalExpenseAddedGrowl from './signalExpenseAddedGrowl';
type SendInvoiceInformation = {
senderWorkspaceID: string | undefined;
@@ -892,9 +892,7 @@ function sendInvoice({
onDeferred: () => addOptimization(CONST.TELEMETRY.SUBMIT_OPTIMIZATION.DEFERRED_WRITE),
});
- if (isFromGlobalCreate) {
- signalExpenseAddedGrowl(transactionID, CONST.SEARCH.DATA_TYPES.INVOICE);
- }
+ highlightTransactionOnSearchRouteIfNeeded(isFromGlobalCreate, 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 be1eae5a84f5..10eb3e5da269 100644
--- a/src/libs/actions/IOU/Split.ts
+++ b/src/libs/actions/IOU/Split.ts
@@ -88,8 +88,8 @@ import {
mergePolicyRecentlyUsedCategories,
mergePolicyRecentlyUsedCurrencies,
} from './MoneyRequestBuilder';
+import {highlightTransactionOnSearchRouteIfNeeded} from './NavigationHelpers';
import {addPendingNewTransactionIDs, isOneToTwoTransactionTransition} from './PendingNewTransactions';
-import signalExpenseAddedGrowl from './signalExpenseAddedGrowl';
type IOURequestType = ValueOf;
@@ -2361,9 +2361,7 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest
onDeferred: () => addOptimization(CONST.TELEMETRY.SUBMIT_OPTIMIZATION.DEFERRED_WRITE),
});
- if (isFromGlobalCreate) {
- signalExpenseAddedGrowl(parameters.transactionID, CONST.SEARCH.DATA_TYPES.EXPENSE);
- }
+ highlightTransactionOnSearchRouteIfNeeded(isFromGlobalCreate, 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 9eaa636df960..ce08a7c7ea07 100644
--- a/src/libs/actions/IOU/SplitTransactionUpdate.ts
+++ b/src/libs/actions/IOU/SplitTransactionUpdate.ts
@@ -45,6 +45,7 @@ import {
navigateBackOnDeleteTransaction,
updateOptimisticParentReportAction,
} from '@libs/ReportUtils';
+import {getCurrentSearchQueryJSON} from '@libs/SearchQueryUtils';
import {isTracking, setPendingSubmitFollowUpAction} from '@libs/telemetry/submitFollowUpAction';
import {
getChildTransactions,
@@ -55,6 +56,7 @@ import {
} from '@libs/TransactionUtils';
import {setDeleteTransactionNavigateBackUrl} from '@userActions/Report';
+import {mergeTransactionIdsHighlightOnSearchRoute} from '@userActions/Transaction';
import {removeDraftSplitTransaction} from '@userActions/TransactionEdit';
import CONST from '@src/CONST';
@@ -81,7 +83,6 @@ import {getCleanUpTransactionThreadReportOnyxData} from './DeleteMoneyRequest';
import {getAllReports} from './index';
import {getMoneyRequestParticipantsFromReport} from './MoneyRequest';
import {getMoneyRequestInformation, getReportPreviewReportAction} from './MoneyRequestBuilder';
-import signalExpenseAddedGrowl from './signalExpenseAddedGrowl';
import {getDeleteTrackExpenseInformation} from './TrackExpense';
import {getUpdateMoneyRequestParams} from './UpdateMoneyRequest';
@@ -2105,13 +2106,33 @@ function updateSplitTransactionsFromSplitExpensesFlow(params: UpdateSplitTransac
const targetReportID = params.expenseReport?.reportID ?? String(CONST.DEFAULT_NUMBER_ID);
- if (!isReverseSplitOperation) {
- signalExpenseAddedGrowl(getNewSplitTransactionIDs().at(-1), CONST.SEARCH.DATA_TYPES.EXPENSE);
+ // Register newly created split transaction IDs so they briefly highlight on the Search/Spend page.
+ // The Search page reads TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE, which highlights matching rows
+ // optimistically without waiting for a server re-search. Unlike the auto-detect path in
+ // useSearchHighlightAndScroll (skipped while offline), this makes the highlight work offline too.
+ // Reverse splits create no new transactions, and existing children are already in the list, so both are skipped.
+ function registerSearchRouteHighlight() {
+ if (!isSearchPageTopmostFullScreenRoute || isReverseSplitOperation) {
+ return;
+ }
+ const currentSearchType = getCurrentSearchQueryJSON()?.type;
+ if (!currentSearchType) {
+ return;
+ }
+ const newTransactionIDsToHighlight: Record = {};
+ for (const transactionID of getNewSplitTransactionIDs()) {
+ newTransactionIDsToHighlight[transactionID] = true;
+ }
+ if (isEmptyObject(newTransactionIDsToHighlight)) {
+ return;
+ }
+ mergeTransactionIdsHighlightOnSearchRoute(currentSearchType, newTransactionIDsToHighlight);
}
if (isSearchPageTopmostFullScreenRoute || !params.transactionReport?.parentReportID) {
+ registerSearchRouteHighlight();
// Returns to Search, not the expense report, so rail flags would sit unconsumed and highlight stale rows the
- // next time that report is opened from the Inbox.
+ // next time that report is opened from the Inbox. registerSearchRouteHighlight above covers this page instead.
updateSplitTransactions({...params, isFromSplitExpensesFlow: true, shouldSkipReportHighlightRail: true});
if (!isSelfDMSplit) {
diff --git a/src/libs/actions/IOU/TrackExpense.ts b/src/libs/actions/IOU/TrackExpense.ts
index 764945ec38a4..32216b419209 100644
--- a/src/libs/actions/IOU/TrackExpense.ts
+++ b/src/libs/actions/IOU/TrackExpense.ts
@@ -131,9 +131,9 @@ import {
getReportPreviewReportAction,
getTransactionWithPreservedLocalReceiptSource,
} from './MoneyRequestBuilder';
+import {highlightTransactionOnSearchRouteIfNeeded} from './NavigationHelpers';
import {addPendingNewTransactionIDs, isOneToTwoTransactionTransition} from './PendingNewTransactions';
import {getSearchOnyxUpdate} from './SearchUpdate';
-import signalExpenseAddedGrowl from './signalExpenseAddedGrowl';
type TrackExpenseInformation = {
createdWorkspaceParams?: CreateWorkspaceParams;
@@ -1644,7 +1644,7 @@ function convertTrackedExpenseToRequest(convertTrackedExpenseParams: ConvertTrac
/**
* Submit expense to another user
*/
-function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouReport?: OnyxTypes.Report; transactionID?: string} {
+function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouReport?: OnyxTypes.Report} {
const {
report,
existingIOUReport,
@@ -1949,8 +1949,8 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep
});
}
- if (!requestMoneyInformation.isRetry && isFromGlobalCreate) {
- signalExpenseAddedGrowl(transaction.transactionID, CONST.SEARCH.DATA_TYPES.EXPENSE);
+ if (!requestMoneyInformation.isRetry) {
+ highlightTransactionOnSearchRouteIfNeeded(isFromGlobalCreate, transaction.transactionID, CONST.SEARCH.DATA_TYPES.EXPENSE);
}
if (activeReportID && !isMoneyRequestReport) {
@@ -1961,7 +1961,7 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep
);
}
- return {iouReport, transactionID: transaction.transactionID};
+ return {iouReport};
}
/**
@@ -2908,13 +2908,11 @@ function trackExpense(params: CreateTrackExpenseParams) {
}
}
- if (!params.isRetry && isFromGlobalCreate) {
- signalExpenseAddedGrowl(transaction?.transactionID, CONST.SEARCH.DATA_TYPES.EXPENSE);
+ if (!params.isRetry) {
+ highlightTransactionOnSearchRouteIfNeeded(isFromGlobalCreate, transaction?.transactionID, CONST.SEARCH.DATA_TYPES.EXPENSE);
}
notifyNewAction(activeReportID, undefined, payeeAccountID === currentUserAccountIDParam);
-
- return {iouReport, transactionID: transaction?.transactionID};
}
/**
diff --git a/src/libs/actions/IOU/signalExpenseAddedGrowl.ts b/src/libs/actions/IOU/signalExpenseAddedGrowl.ts
deleted file mode 100644
index 9ca2858f8dc3..000000000000
--- a/src/libs/actions/IOU/signalExpenseAddedGrowl.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import {mergeExpenseAddedGrowlTransactionIDs} from '@userActions/Transaction';
-
-import type {SearchDataTypes} from '@src/types/onyx/SearchResults';
-
-/** Queues the "Expense added" growl (toast) for a newly-created transaction. */
-function signalExpenseAddedGrowl(transactionID: string | undefined, dataType: SearchDataTypes) {
- if (!transactionID) {
- return;
- }
- mergeExpenseAddedGrowlTransactionIDs({[transactionID]: dataType});
-}
-
-export default signalExpenseAddedGrowl;
diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts
index b2e4f2a38fa3..085a12a4140d 100644
--- a/src/libs/actions/Transaction.ts
+++ b/src/libs/actions/Transaction.ts
@@ -2034,8 +2034,8 @@ function getDefaultP2PMileageRate() {
API.read(READ_COMMANDS.GET_DEFAULT_P2P_MILEAGE_RATE, null);
}
-function mergeExpenseAddedGrowlTransactionIDs(data: Record) {
- return Onyx.merge(ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS, data);
+function mergeTransactionIdsHighlightOnSearchRoute(type: SearchDataTypes, data: Record | null) {
+ return Onyx.merge(ONYXKEYS.TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE, {[type]: data});
}
function getDuplicateTransactionDetails(transactionID?: string) {
@@ -2072,7 +2072,7 @@ export {
getChangeTransactionsReportOnyxData,
setTransactionReport,
getDefaultP2PMileageRate,
- mergeExpenseAddedGrowlTransactionIDs,
+ mergeTransactionIdsHighlightOnSearchRoute,
getDuplicateTransactionDetails,
setSelectedRoute,
};
diff --git a/src/libs/telemetry/submitFollowUpAction.ts b/src/libs/telemetry/submitFollowUpAction.ts
index bb66919ebdae..8cbb576573cc 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.
- // navigateAfterExpenseCreate may later call with DISMISS_MODAL_ONLY because it
+ // handleNavigateAfterExpenseCreate 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 navigateAfterExpenseCreate later calls with
+ // NAVIGATE_TO_SEARCH and handleNavigateAfterExpenseCreate later calls with
// DISMISS_MODAL_ONLY (because Search is already on top), preserve the original
// action so telemetry correctly reflects the pre-insert path.
const isRefinement =
diff --git a/src/pages/Share/SubmitDetailsPage.tsx b/src/pages/Share/SubmitDetailsPage.tsx
index 5130a20f7e96..76fa14b8bb18 100644
--- a/src/pages/Share/SubmitDetailsPage.tsx
+++ b/src/pages/Share/SubmitDetailsPage.tsx
@@ -31,7 +31,6 @@ import {
updateLastLocationPermissionPrompt,
} from '@libs/actions/IOU/MoneyRequest';
import {setMoneyRequestReceipt} from '@libs/actions/IOU/Receipt';
-import signalExpenseAddedGrowl from '@libs/actions/IOU/signalExpenseAddedGrowl';
import {requestMoney, trackExpense} from '@libs/actions/IOU/TrackExpense';
import type {GPSPoint as GpsPoint} from '@libs/actions/IOU/types/TrackExpenseTransactionParams';
import {WRITE_COMMANDS} from '@libs/API/types';
@@ -467,10 +466,6 @@ function SubmitDetailsPage({
optimisticChatReportID: routeReportID,
});
}
-
- // requestMoney/trackExpense only signal the growl for the global-create flow and the share
- // extension isn't flagged as such, so signal it here instead.
- signalExpenseAddedGrowl(optimisticTransactionID, CONST.SEARCH.DATA_TYPES.EXPENSE);
};
const cleanupParams = {
diff --git a/src/setup/index.ts b/src/setup/index.ts
index 28acc8326031..2d0f25d0f3aa 100644
--- a/src/setup/index.ts
+++ b/src/setup/index.ts
@@ -58,9 +58,6 @@ export default function () {
// Ensure the Supportal permission modal doesn't persist across reloads
[ONYXKEYS.SUPPORTAL_PERMISSION_DENIED]: null,
[ONYXKEYS.IS_OPEN_APP_FAILURE_MODAL_OPEN]: false,
- // The "Expense added" growl confirms an expense created this session. Drop any signal left over
- // from a prior session (e.g. force-quit before it was consumed) so it can't surface on next launch.
- [ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS]: {},
},
skippableCollectionMemberIDs: CONST.SKIPPABLE_COLLECTION_MEMBER_IDS,
snapshotMergeKeys: ['pendingAction', 'pendingFields'],
diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts
index 7a6d8412db91..d26e7e8c4708 100644
--- a/src/styles/utils/index.ts
+++ b/src/styles/utils/index.ts
@@ -1917,10 +1917,6 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({
borderColor: isSelected ? theme.buttonHoveredBG : theme.border,
}),
- getSearchRowBackgroundStyle: (isSelected?: boolean): ViewStyle => ({
- backgroundColor: isSelected ? theme.activeComponentBG : theme.highlightBG,
- }),
-
getSearchTableHighlightBorderRadius: (isLargeScreenWidth: boolean): number => (isLargeScreenWidth ? 0 : variables.componentBorderRadius),
getReportTableColumnStyles: (columnName: string, options: GetReportTableColumnStylesParams = {}): ViewStyle => {
diff --git a/tests/actions/IOU/SplitReportTotalsTest.ts b/tests/actions/IOU/SplitReportTotalsTest.ts
index 0cd68e66ca7f..3bb94529effa 100644
--- a/tests/actions/IOU/SplitReportTotalsTest.ts
+++ b/tests/actions/IOU/SplitReportTotalsTest.ts
@@ -1,7 +1,9 @@
+import {handleNavigateAfterExpenseCreate} from '@libs/actions/IOU/NavigationHelpers';
import '@libs/actions/IOU/MoneyRequest';
import {createSplitsAndOnyxData} from '@libs/actions/IOU/Split';
import {updateSplitTransactionsFromSplitExpensesFlow} from '@libs/actions/IOU/SplitTransactionUpdate';
import initOnyxDerivedValues from '@libs/actions/OnyxDerived';
+import isReportTopmostSplitNavigator from '@libs/Navigation/helpers/isReportTopmostSplitNavigator';
import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute';
import {rand64} from '@libs/NumberUtils';
import type * as PolicyUtils from '@libs/PolicyUtils';
@@ -22,7 +24,6 @@ import Onyx from 'react-native-onyx';
import currencyList from '../../unit/currencyList.json';
import createMock from '../../utils/createMock';
-import getOnyxValue from '../../utils/getOnyxValue';
import {getGlobalFetchMock, formatPhoneNumber, getCurrencyDecimalsLocal, getCurrencySymbolLocal} from '../../utils/TestHelper';
import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates';
@@ -432,6 +433,33 @@ describe('actions/IOU', () => {
});
});
+ it('handleNavigateAfterExpenseCreate', async () => {
+ const mockedIsReportTopmostSplitNavigator = jest.mocked(isReportTopmostSplitNavigator);
+ const spyOnMergeTransactionIdsHighlightOnSearchRoute = jest.spyOn(require('@libs/actions/Transaction'), 'mergeTransactionIdsHighlightOnSearchRoute');
+ const activeReportID = '1';
+ const transactionID = '1';
+ mockedIsReportTopmostSplitNavigator.mockReturnValue(false);
+
+ handleNavigateAfterExpenseCreate({activeReportID, isFromGlobalCreate: false});
+ expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledTimes(0);
+
+ handleNavigateAfterExpenseCreate({activeReportID, isFromGlobalCreate: true});
+ expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledTimes(0);
+
+ mockedIsReportTopmostSplitNavigator.mockReturnValue(true);
+ handleNavigateAfterExpenseCreate({activeReportID, isFromGlobalCreate: true, transactionID});
+ expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledTimes(0);
+
+ mockedIsReportTopmostSplitNavigator.mockReturnValue(false);
+ handleNavigateAfterExpenseCreate({activeReportID, isFromGlobalCreate: true, transactionID});
+ expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledTimes(0);
+
+ handleNavigateAfterExpenseCreate({activeReportID, isFromGlobalCreate: true, transactionID, isInvoice: true});
+ expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledTimes(0);
+
+ spyOnMergeTransactionIdsHighlightOnSearchRoute.mockReset();
+ });
+
describe('createSplitsAndOnyxData', () => {
const mockPersonalDetails: PersonalDetailsList = {
[RORY_ACCOUNT_ID]: {accountID: RORY_ACCOUNT_ID, login: RORY_EMAIL, displayName: 'Rory'},
@@ -753,18 +781,6 @@ describe('actions/IOU', () => {
};
}
- function getPendingNewTransactionIDsFromOnyx(reportID: string) {
- return new Promise | undefined>((resolve) => {
- const connection = Onyx.connect({
- key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`,
- callback: (metadata) => {
- Onyx.disconnect(connection);
- resolve(metadata?.pendingNewTransactionIDs);
- },
- });
- });
- }
-
it('skips registration during a reverse split operation', async () => {
// Given one existing child transaction (triggers isReverseSplitOperation when splitExpenses.length === 1)
const existingChildTx = {
@@ -823,6 +839,56 @@ describe('actions/IOU', () => {
expect(pendingNewTransactionIDs?.['new-tx-2']).toBeUndefined();
});
+ it('registers the search-route highlight (not report metadata) when splitting from the Search/Spend page', async () => {
+ // Given the user is on the Search (Spend > Expenses) page, where the expense report is never opened
+ jest.mocked(isSearchTopmostFullScreenRoute).mockReturnValue(true);
+ const spyOnMergeTransactionIdsHighlightOnSearchRoute = jest.spyOn(require('@libs/actions/Transaction'), 'mergeTransactionIdsHighlightOnSearchRoute');
+ const params = buildBaseParams({
+ transactionData: {
+ reportID: EXPENSE_REPORT_ID,
+ originalTransactionID: ORIGINAL_TX_ID,
+ splitExpenses: [
+ {transactionID: 'new-tx-1', reportID: EXPENSE_REPORT_ID, statusNum: 0, amount: 500, created: '2024-01-01'},
+ {transactionID: 'new-tx-2', reportID: EXPENSE_REPORT_ID, statusNum: 0, amount: 500, created: '2024-01-01'},
+ ],
+ splitExpensesTotal: 1000,
+ },
+ });
+
+ // When saving the split from the Search page
+ updateSplitTransactionsFromSplitExpensesFlow(params);
+ await waitForBatchedUpdates();
+
+ // Then the new IDs are registered on the search-route highlight, keyed by the current search type.
+ // (The report-metadata rail is asserted separately, in the REPORT_METADATA test below.)
+ // This mechanism highlights optimistically without a server re-search, so it works offline too.
+ expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledWith(
+ 'expense',
+ Object.fromEntries([
+ ['new-tx-1', true],
+ ['new-tx-2', true],
+ ]),
+ );
+
+ spyOnMergeTransactionIdsHighlightOnSearchRoute.mockRestore();
+ });
+
+ /**
+ * Reads REPORT_METADATA directly: the flags are written as Onyx optimisticData, not through the mocked
+ * addPendingNewTransactionIDs, so mock-only assertions cannot observe them - which is how this regressed.
+ */
+ function getPendingNewTransactionIDsFromOnyx(reportID: string) {
+ return new Promise | undefined>((resolve) => {
+ const connection = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`,
+ callback: (metadata) => {
+ Onyx.disconnect(connection);
+ resolve(metadata?.pendingNewTransactionIDs);
+ },
+ });
+ });
+ }
+
it('does not write pendingNewTransactionIDs into report metadata when splitting from the Search/Spend page', async () => {
// Given the user is on the Search (Spend > Expenses) page and the expense report already holds a transaction,
// so the new splits would otherwise satisfy the "add makes the report multi-transaction" highlight condition
@@ -897,7 +963,10 @@ describe('actions/IOU', () => {
expect(pendingNewTransactionIDs?.['existing-tx-2']).toBeUndefined();
});
- it('does not signal the "Expense added" growl during a reverse split operation', async () => {
+ 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,
@@ -913,11 +982,90 @@ describe('actions/IOU', () => {
},
});
+ // When saving the reverse split
+ updateSplitTransactionsFromSplitExpensesFlow(params);
+ await waitForBatchedUpdates();
+
+ // Then nothing is highlighted — reverse splits create no new transactions
+ expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).not.toHaveBeenCalled();
+
+ spyOnMergeTransactionIdsHighlightOnSearchRoute.mockRestore();
+ });
+
+ it('registers the search-route highlight while offline, so the Spend page still highlights', async () => {
+ // Given the user is offline on the Search page. The auto-detect path in useSearchHighlightAndScroll is
+ // skipped while offline (it waits for a server re-search), so this rail is the only thing that can
+ // highlight the new rows - a reviewer caught the highlight silently disappearing offline.
+ jest.mocked(isSearchTopmostFullScreenRoute).mockReturnValue(true);
+ const spyOnMergeTransactionIdsHighlightOnSearchRoute = jest.spyOn(require('@libs/actions/Transaction'), 'mergeTransactionIdsHighlightOnSearchRoute');
+ const params = buildBaseParams({
+ isOffline: true,
+ transactionData: {
+ reportID: EXPENSE_REPORT_ID,
+ originalTransactionID: ORIGINAL_TX_ID,
+ splitExpenses: [
+ {transactionID: 'offline-tx-1', reportID: EXPENSE_REPORT_ID, statusNum: 0, amount: 500, created: '2024-01-01'},
+ {transactionID: 'offline-tx-2', reportID: EXPENSE_REPORT_ID, statusNum: 0, amount: 500, created: '2024-01-01'},
+ ],
+ splitExpensesTotal: 1000,
+ },
+ });
+
+ // When saving the split offline
+ updateSplitTransactionsFromSplitExpensesFlow(params);
+ await waitForBatchedUpdates();
+
+ // Then BOTH new IDs are registered - a reviewer also caught only one of the two rows highlighting
+ expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledWith(
+ 'expense',
+ Object.fromEntries([
+ ['offline-tx-1', true],
+ ['offline-tx-2', true],
+ ]),
+ );
+
+ // And the report-metadata rail stays clean offline too
+ const pendingNewTransactionIDs = await getPendingNewTransactionIDsFromOnyx(EXPENSE_REPORT_ID);
+ expect(pendingNewTransactionIDs?.['offline-tx-1']).toBeUndefined();
+ expect(pendingNewTransactionIDs?.['offline-tx-2']).toBeUndefined();
+
+ spyOnMergeTransactionIdsHighlightOnSearchRoute.mockRestore();
+ });
+
+ it('leaves no highlight flags anywhere when splits move out to a different report', async () => {
+ // Given splits that leave the expense report entirely (each lands in its own other report). Nothing should
+ // be flagged on the source report, and the destination reports are never opened by this flow either, so
+ // no rail entry may be left behind for any of them.
+ jest.mocked(isSearchTopmostFullScreenRoute).mockReturnValue(false);
+ const expenseReport = {reportID: EXPENSE_REPORT_ID, type: CONST.REPORT.TYPE.EXPENSE, parentReportID: 'parent-report-1', chatReportID: 'chat-report-1'} as Report;
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${EXPENSE_REPORT_ID}`, expenseReport);
+ const existingTx = {transactionID: 'existing-tx-3', reportID: EXPENSE_REPORT_ID, amount: 1000};
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}existing-tx-3`, existingTx);
+ const params = buildBaseParams({
+ expenseReport,
+ allReportsList: {[`${ONYXKEYS.COLLECTION.REPORT}${EXPENSE_REPORT_ID}`]: expenseReport},
+ allTransactionsList: {[`${ONYXKEYS.COLLECTION.TRANSACTION}existing-tx-3`]: existingTx},
+ transactionData: {
+ reportID: EXPENSE_REPORT_ID,
+ originalTransactionID: ORIGINAL_TX_ID,
+ splitExpenses: [
+ {transactionID: 'moved-tx-1', reportID: 'other-report-1', statusNum: 0, amount: 500, created: '2024-01-01'},
+ {transactionID: 'moved-tx-2', reportID: 'other-report-2', statusNum: 0, amount: 500, created: '2024-01-01'},
+ ],
+ splitExpensesTotal: 1000,
+ },
+ });
+
+ // When saving the split
updateSplitTransactionsFromSplitExpensesFlow(params);
await waitForBatchedUpdates();
- const growlTransactionIDs = await getOnyxValue(ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS);
- expect(growlTransactionIDs?.['new-merged-tx']).toBeUndefined();
+ // Then neither the source report nor the destination reports carry stranded highlight flags
+ const sourceRail = await getPendingNewTransactionIDsFromOnyx(EXPENSE_REPORT_ID);
+ expect(sourceRail?.['moved-tx-1']).toBeUndefined();
+ expect(sourceRail?.['moved-tx-2']).toBeUndefined();
+ expect((await getPendingNewTransactionIDsFromOnyx('other-report-1'))?.['moved-tx-1']).toBeUndefined();
+ expect((await getPendingNewTransactionIDsFromOnyx('other-report-2'))?.['moved-tx-2']).toBeUndefined();
});
});
});
diff --git a/tests/actions/IOUTest/SplitTest.ts b/tests/actions/IOUTest/SplitTest.ts
index 9e994ceb7df3..2fac119dd42b 100644
--- a/tests/actions/IOUTest/SplitTest.ts
+++ b/tests/actions/IOUTest/SplitTest.ts
@@ -9816,7 +9816,7 @@ describe('createDistanceRequest', () => {
expect(result.transactionID).toBeTruthy();
});
- it('flags the created transaction for the "Expense added" growl on a global-create distance request', async () => {
+ it('flags the created transaction for Search highlight on a global-create distance request', async () => {
const recentWaypoints = (await getOnyxValue(ONYXKEYS.NVP_RECENT_WAYPOINTS)) ?? [];
const result = createDistanceRequest({
@@ -9826,8 +9826,8 @@ describe('createDistanceRequest', () => {
await waitForBatchedUpdates();
- const growlSignal = await getOnyxValue(ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS);
- expect(growlSignal?.[result.transactionID]).toBe(CONST.SEARCH.DATA_TYPES.EXPENSE);
+ const highlight = await getOnyxValue(ONYXKEYS.TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE);
+ expect(Object.keys(highlight?.[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
deleted file mode 100644
index b2513c16c2e6..000000000000
--- a/tests/unit/ExpenseAddedGrowlTest.tsx
+++ /dev/null
@@ -1,156 +0,0 @@
-import {act, render} from '@testing-library/react-native';
-
-import ExpenseAddedGrowl from '@components/ExpenseAddedGrowl';
-
-import CONST from '@src/CONST';
-import ONYXKEYS from '@src/ONYXKEYS';
-import type {SearchDataTypes} from '@src/types/onyx/SearchResults';
-
-import Onyx from 'react-native-onyx';
-
-import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
-
-type GrowlContentProps = {bodyText: string; type: string};
-
-const mockGetTopmostReportId = jest.fn();
-
-const mockGrowlContent = jest.fn();
-jest.mock('@components/GrowlNotification/GrowlNotificationContent', () => (props: GrowlContentProps) => {
- mockGrowlContent(props);
- return null;
-});
-
-jest.mock('@libs/Navigation/Navigation', () => ({
- getTopmostReportId: () => mockGetTopmostReportId(),
- getActiveRoute: () => '',
-}));
-jest.mock('@libs/Navigation/helpers/navigateAfterExpenseCreate', () => ({
- navigateToCreatedExpense: jest.fn(),
-}));
-jest.mock('@libs/actions/Report', () => ({
- createTransactionThreadReport: jest.fn(),
- setOptimisticTransactionThread: jest.fn(),
-}));
-jest.mock('@hooks/useLocalize', () => () => ({translate: (key: string) => key}));
-jest.mock('@hooks/useCurrentUserPersonalDetails', () => () => ({accountID: 1, login: 'me@example.com'}));
-
-const EXPENSE = CONST.SEARCH.DATA_TYPES.EXPENSE;
-const INVOICE = CONST.SEARCH.DATA_TYPES.INVOICE;
-
-function flush(mutate: () => Promise) {
- return act(async () => {
- await mutate();
- await waitForBatchedUpdates();
- });
-}
-
-function seedExpense(transactionID: string, reportID: string, dataType: SearchDataTypes = EXPENSE) {
- return flush(async () => {
- await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {transactionID, reportID});
- await Onyx.merge(ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS, {[transactionID]: dataType});
- });
-}
-
-function lastGrowlProps() {
- return mockGrowlContent.mock.calls.at(-1)?.[0];
-}
-
-describe('ExpenseAddedGrowl', () => {
- beforeAll(() => {
- Onyx.init({keys: ONYXKEYS});
- });
-
- beforeEach(async () => {
- jest.clearAllMocks();
- mockGetTopmostReportId.mockReturnValue(undefined);
- await Onyx.clear();
- await waitForBatchedUpdates();
- });
-
- it('shows the "Expense added" growl for a pending transaction when the user is not viewing its report', async () => {
- // Given the growl is mounted and the user is not viewing any report
- render();
-
- // When an expense is created
- await seedExpense('1', 'report-1');
-
- // Then the "Expense added" success growl shows
- expect(mockGrowlContent).toHaveBeenCalled();
- expect(lastGrowlProps()?.bodyText).toBe('iou.expenseAdded');
- expect(lastGrowlProps()?.type).toBe(CONST.GROWL.SUCCESS);
- });
-
- it('uses the invoice copy for an invoice', async () => {
- // Given the growl is mounted and the user is not viewing any report
- render();
-
- // When an invoice is created
- await seedExpense('1', 'report-1', INVOICE);
-
- // Then the invoice copy is used instead of the expense one
- expect(lastGrowlProps()?.bodyText).toBe('iou.invoiceSent');
- });
-
- it("suppresses the growl when the user is already viewing the expense's report", async () => {
- // Given the user is already viewing report-1
- mockGetTopmostReportId.mockReturnValue('report-1');
- render();
-
- // When an expense is created in that same report
- await seedExpense('1', 'report-1');
-
- // Then no growl shows, because the report already highlights the new expense
- expect(mockGrowlContent).not.toHaveBeenCalled();
- });
-
- it('still shows for a tracked/unreported (self-DM) expense even when a report is open, since its reportID is UNREPORTED', async () => {
- // Given the user is viewing some report
- mockGetTopmostReportId.mockReturnValue('some-open-report');
- render();
-
- // When a tracked expense is created, which has no report to be viewed in
- await seedExpense('1', CONST.REPORT.UNREPORTED_REPORT_ID);
-
- // Then the growl still shows
- expect(mockGrowlContent).toHaveBeenCalled();
- });
-
- it('shows a single growl for a batch of new expenses and clears the whole signal', async () => {
- // Given the growl is mounted
- render();
-
- // When two expenses are created in the same batch
- await flush(async () => {
- await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}1`, {transactionID: '1', reportID: 'report-1'});
- await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}2`, {transactionID: '2', reportID: 'report-1'});
- const signal: Record = {};
- signal['1'] = EXPENSE;
- signal['2'] = EXPENSE;
- await Onyx.merge(ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS, signal);
- });
-
- // Then exactly one growl shows for the batch
- expect(mockGrowlContent).toHaveBeenCalledTimes(1);
-
- // And the whole signal is consumed so it can't re-fire
- const remaining = await new Promise | undefined>((resolve) => {
- const connection = Onyx.connect({
- key: ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS,
- callback: (value) => {
- Onyx.disconnect(connection);
- resolve(value);
- },
- });
- });
- expect(remaining ?? {}).toEqual({});
- });
-
- it('does not show a growl when there is no pending signal', () => {
- // Given no expense was created
- // When the growl mounts
- render();
-
- // Then the growl doesn't show
- expect(mockGrowlContent).not.toHaveBeenCalled();
- });
-});
diff --git a/tests/unit/Search/useSearchSnapshotTest.ts b/tests/unit/Search/useSearchSnapshotTest.ts
index a482fced92e0..a2c0b2531f1f 100644
--- a/tests/unit/Search/useSearchSnapshotTest.ts
+++ b/tests/unit/Search/useSearchSnapshotTest.ts
@@ -179,6 +179,7 @@ describe('useSearchSnapshot', () => {
useSearchSnapshot({
queryJSON: makeQueryJSON(),
searchResults,
+ newSearchResultKeys: undefined,
transactions: undefined,
reportActions: undefined,
}),
@@ -204,6 +205,7 @@ describe('useSearchSnapshot', () => {
useSearchSnapshot({
queryJSON: makeQueryJSON(),
searchResults: undefined,
+ newSearchResultKeys: undefined,
transactions: undefined,
reportActions: undefined,
}),
@@ -222,6 +224,7 @@ describe('useSearchSnapshot', () => {
useSearchSnapshot({
queryJSON: makeQueryJSON(),
searchResults,
+ newSearchResultKeys: undefined,
transactions: undefined,
reportActions: undefined,
}),
@@ -241,6 +244,7 @@ describe('useSearchSnapshot', () => {
useSearchSnapshot({
queryJSON: makeQueryJSON(),
searchResults,
+ newSearchResultKeys: undefined,
transactions: undefined,
reportActions: undefined,
}),
@@ -263,6 +267,7 @@ describe('useSearchSnapshot', () => {
groupBy: CONST.SEARCH.GROUP_BY.FROM,
}),
searchResults,
+ newSearchResultKeys: undefined,
transactions: undefined,
reportActions: undefined,
}),
@@ -283,6 +288,7 @@ describe('useSearchSnapshot', () => {
useSearchSnapshot({
queryJSON: makeQueryJSON(),
searchResults,
+ newSearchResultKeys: undefined,
transactions: undefined,
reportActions: undefined,
}),
@@ -307,6 +313,7 @@ describe('useSearchSnapshot', () => {
useSearchSnapshot({
queryJSON: makeQueryJSON(),
searchResults,
+ newSearchResultKeys: undefined,
transactions: undefined,
reportActions: undefined,
}),
@@ -334,6 +341,7 @@ describe('useSearchSnapshot', () => {
useSearchSnapshot({
queryJSON: makeQueryJSON(),
searchResults,
+ newSearchResultKeys: undefined,
transactions: undefined,
reportActions: undefined,
}),
@@ -343,6 +351,24 @@ 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,
@@ -360,6 +386,7 @@ describe('useSearchSnapshot', () => {
useSearchSnapshot({
queryJSON: makeQueryJSON({type}),
searchResults,
+ newSearchResultKeys: undefined,
transactions: undefined,
reportActions: undefined,
}),
@@ -387,6 +414,7 @@ describe('useSearchSnapshot', () => {
const props = {
queryJSON: makeQueryJSON(),
searchResults,
+ newSearchResultKeys: undefined,
transactions: undefined,
reportActions: undefined,
};
diff --git a/tests/unit/actionLayerNavigationGuardTest.ts b/tests/unit/actionLayerNavigationGuardTest.ts
index 55f34565dff7..f7b99adfbf93 100644
--- a/tests/unit/actionLayerNavigationGuardTest.ts
+++ b/tests/unit/actionLayerNavigationGuardTest.ts
@@ -4,6 +4,8 @@ import path from 'path';
/**
* Architecture guard for issue #84631: navigation belongs in the view layer, not in `src/libs/actions/`.
* `submitWithDismissFirst` is the view-layer dismiss-first orchestrator; no action file may import it.
+ * (PR 9 will additionally enforce this via an ESLint `no-restricted-imports` rule once the legacy
+ * `NavigationHelpers.ts` value-imports of the other view helpers are removed.)
*/
const ACTIONS_DIR = path.join(__dirname, '..', '..', 'src', 'libs', 'actions');
const FORBIDDEN_IMPORT = '@libs/Navigation/helpers/submitWithDismissFirst';
diff --git a/tests/unit/navigateAfterExpenseCreateTest.ts b/tests/unit/navigateAfterExpenseCreateTest.ts
index e08952eaf7a1..26ec395ea9af 100644
--- a/tests/unit/navigateAfterExpenseCreateTest.ts
+++ b/tests/unit/navigateAfterExpenseCreateTest.ts
@@ -1,15 +1,12 @@
import type isReportOpenInRHP from '@libs/Navigation/helpers/isReportOpenInRHP';
import type isReportOpenInSuperWideRHP from '@libs/Navigation/helpers/isReportOpenInSuperWideRHP';
-import navigateAfterExpenseCreate, {navigateToCreatedExpense} from '@libs/Navigation/helpers/navigateAfterExpenseCreate';
+import navigateAfterExpenseCreate from '@libs/Navigation/helpers/navigateAfterExpenseCreate';
import Navigation from '@libs/Navigation/Navigation';
import type {getCurrentSearchQueryJSON} from '@libs/SearchQueryUtils';
import type {setPendingSubmitFollowUpAction} from '@libs/telemetry/submitFollowUpAction';
import CONST from '@src/CONST';
import ROUTES from '@src/ROUTES';
-import type {Transaction} from '@src/types/onyx';
-
-import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
const mockIsReportTopmostSplitNavigator = jest.fn();
const mockIsSearchTopmostFullScreenRoute = jest.fn();
@@ -20,7 +17,6 @@ const mockGetTrackingState = jest.fn();
// Declared but assigned after jest.mock hoisting - use require() to access the mock in tests
let mockSetPendingSubmitFollowUpAction: jest.MockedFunction;
const mockGetCurrentSearchQueryJSON = jest.fn, Parameters>();
-const mockGetReportTransactions = jest.fn>, [string | undefined]>();
jest.mock('@libs/Navigation/helpers/isReportTopmostSplitNavigator', () => () => mockIsReportTopmostSplitNavigator());
jest.mock('@libs/Navigation/helpers/isSearchTopmostFullScreenRoute', () => () => mockIsSearchTopmostFullScreenRoute());
@@ -39,15 +35,6 @@ jest.mock('@libs/SearchQueryUtils', () => ({
buildCannedSearchQuery: jest.fn(({type}: {type: string}) => `type:${type}`),
getCurrentSearchQueryJSON: mockGetCurrentSearchQueryJSON,
}));
-jest.mock('@libs/ReportUtils', () => {
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- partial mock of the real module
- const actual = jest.requireActual('@libs/ReportUtils');
- // eslint-disable-next-line @typescript-eslint/no-unsafe-return -- spread the real module and override one export
- return {...actual, __esModule: true, getReportTransactions: (reportID: string | undefined) => mockGetReportTransactions(reportID)};
-});
-jest.mock('@libs/actions/TransactionThreadNavigation', () => ({
- setActiveTransactionIDs: jest.fn(() => Promise.resolve()),
-}));
jest.mock('@libs/Navigation/Navigation', () => ({
dismissModal: jest.fn(),
@@ -55,7 +42,6 @@ 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),
@@ -83,7 +69,6 @@ describe('navigateAfterExpenseCreate', () => {
mockIsReportOpenInRHP.mockReturnValue(false);
mockGetTrackingState.mockReturnValue(false);
mockGetCurrentSearchQueryJSON.mockReturnValue(undefined);
- mockGetReportTransactions.mockReturnValue([]);
});
it('should dismiss to report when not from global create', () => {
@@ -235,107 +220,4 @@ describe('navigateAfterExpenseCreate', () => {
expect(Navigation.dismissModal).toHaveBeenCalled();
expect(Navigation.navigate).not.toHaveBeenCalled();
});
-
- describe('navigateToCreatedExpense', () => {
- it('should open the transaction thread in the Spend RHP when the user is on the Spend tab', async () => {
- // Given the user is on the Spend tab
- mockIsReportTopmostSplitNavigator.mockReturnValue(false);
- mockIsSearchTopmostFullScreenRoute.mockReturnValue(true);
-
- // When they open a newly-created expense
- navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'});
- await waitForBatchedUpdates();
-
- // Then the transaction thread opens in the Spend RHP
- expect(Navigation.navigate).toHaveBeenCalledTimes(1);
- expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SEARCH_REPORT.getRoute({reportID: 'thread-1', backTo: ''}), {forceReplace: false});
- });
-
- it('should replace the currently-open report instead of stacking when one is already open in the RHP', async () => {
- // Given a report is already open in the RHP
- mockIsReportTopmostSplitNavigator.mockReturnValue(false);
- mockIsSearchTopmostFullScreenRoute.mockReturnValue(true);
- mockIsReportOpenInRHP.mockReturnValue(true);
-
- // When the user opens a newly-created expense
- navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'});
- await waitForBatchedUpdates();
-
- // Then the open report is replaced rather than stacked on
- expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SEARCH_REPORT.getRoute({reportID: 'thread-1', backTo: ''}), {forceReplace: true});
- });
-
- it('should open the transaction thread as a full report when the user is on the Inbox tab on a narrow layout', () => {
- // Given the user is on the Inbox tab on a narrow layout
- mockIsReportTopmostSplitNavigator.mockReturnValue(true);
- mockIsSearchTopmostFullScreenRoute.mockReturnValue(false);
- mockGetIsNarrowLayout.mockReturnValue(true);
-
- // When they open a newly-created expense
- navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'});
-
- // Then the transaction thread opens as a full report
- expect(Navigation.navigate).toHaveBeenCalledTimes(1);
- expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute('thread-1', undefined, undefined, ''), {forceReplace: false});
- });
-
- it('should open the expense report then stack the thread RHP when the user is on the Inbox tab on a wide layout and the report has multiple transactions', async () => {
- // Given the user is on the Inbox tab on a wide layout and the expense report holds several transactions
- mockIsReportTopmostSplitNavigator.mockReturnValue(true);
- mockIsSearchTopmostFullScreenRoute.mockReturnValue(false);
- mockGetIsNarrowLayout.mockReturnValue(false);
- mockGetReportTransactions.mockReturnValue([{transactionID: 'txn-1'}, {transactionID: 'txn-2'}]);
-
- // When they open a newly-created expense
- navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'});
- await waitForBatchedUpdates();
-
- // Then the expense report opens with the thread RHP stacked on top of it
- expect(Navigation.navigate).toHaveBeenNthCalledWith(1, ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: 'iou-1', backTo: ''}), {forceReplace: false});
- expect(Navigation.navigate).toHaveBeenNthCalledWith(2, ROUTES.SEARCH_REPORT.getRoute({reportID: 'thread-1', backTo: ''}));
- });
-
- it('should open the expense report when the user is on the Inbox tab on a wide layout and the report has a single transaction', () => {
- // Given the user is on the Inbox tab on a wide layout and the expense report holds one transaction
- mockIsReportTopmostSplitNavigator.mockReturnValue(true);
- mockIsSearchTopmostFullScreenRoute.mockReturnValue(false);
- mockGetIsNarrowLayout.mockReturnValue(false);
- mockGetReportTransactions.mockReturnValue([{transactionID: 'txn-1'}]);
-
- // When they open a newly-created expense
- navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'});
-
- // Then only the expense report opens, since it collapses to the thread itself
- expect(Navigation.navigate).toHaveBeenCalledTimes(1);
- expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: 'iou-1', backTo: ''}), {forceReplace: false});
- });
-
- it('should open the transaction thread as a full report when there is no expense report (tracked/unreported self-DM expense) on the Inbox tab', () => {
- // Given the user is on the Inbox tab on a wide layout
- mockIsReportTopmostSplitNavigator.mockReturnValue(true);
- mockIsSearchTopmostFullScreenRoute.mockReturnValue(false);
- mockGetIsNarrowLayout.mockReturnValue(false);
-
- // When they open a newly-created tracked expense, which has no expense report
- navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: undefined});
-
- // Then the thread opens as a full report, matching how tapping it in its self-DM chat does
- expect(Navigation.navigate).toHaveBeenCalledTimes(1);
- expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute('thread-1', undefined, undefined, ''), {forceReplace: false});
- });
-
- it('should open the transaction thread in the Spend RHP for a tracked/unreported expense when the user is on the Spend tab', async () => {
- // Given the user is on the Spend tab
- mockIsReportTopmostSplitNavigator.mockReturnValue(false);
- mockIsSearchTopmostFullScreenRoute.mockReturnValue(true);
-
- // When they open a newly-created tracked expense, which has no expense report
- navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: undefined});
- await waitForBatchedUpdates();
-
- // Then the transaction thread still opens in the Spend RHP
- expect(Navigation.navigate).toHaveBeenCalledTimes(1);
- expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SEARCH_REPORT.getRoute({reportID: 'thread-1', backTo: ''}), {forceReplace: false});
- });
- });
});
diff --git a/tests/unit/useSearchAutoRefetchTest.ts b/tests/unit/useSearchHighlightAndScrollTest.ts
similarity index 59%
rename from tests/unit/useSearchAutoRefetchTest.ts
rename to tests/unit/useSearchHighlightAndScrollTest.ts
index 676384d8be82..d34623d5f160 100644
--- a/tests/unit/useSearchAutoRefetchTest.ts
+++ b/tests/unit/useSearchHighlightAndScrollTest.ts
@@ -1,8 +1,10 @@
/* eslint-disable @typescript-eslint/naming-convention */
import {renderHook} from '@testing-library/react-native';
-import useSearchAutoRefetch from '@hooks/useSearchAutoRefetch';
-import type {UseSearchAutoRefetch} from '@hooks/useSearchAutoRefetch';
+import type {SearchListItem} from '@components/Search/SearchList/ListItem/types';
+
+import useSearchHighlightAndScroll from '@hooks/useSearchHighlightAndScroll';
+import type {UseSearchHighlightAndScroll} from '@hooks/useSearchHighlightAndScroll';
import {search} from '@libs/actions/Search';
@@ -35,14 +37,15 @@ afterEach(() => {
mockIsOffline = false;
});
-describe('useSearchAutoRefetch', () => {
+describe('useSearchHighlightAndScroll', () => {
beforeAll(async () => {
Onyx.init({
keys: ONYXKEYS,
});
});
- const baseProps: UseSearchAutoRefetch = {
+ const baseProps: UseSearchHighlightAndScroll = {
+ shouldUseLiveData: false,
searchResults: {
data: {
personalDetailsList: {},
@@ -80,22 +83,22 @@ describe('useSearchAutoRefetch', () => {
};
it('should not trigger search when collections are empty', () => {
- renderHook(() => useSearchAutoRefetch(baseProps));
+ renderHook(() => useSearchHighlightAndScroll(baseProps));
expect(search).not.toHaveBeenCalled();
});
it('should trigger search when new transaction added and focused', () => {
- const initialProps = createMock({
+ const initialProps = createMock({
...baseProps,
transactions: {transactions_1: {transactionID: '1'}},
previousTransactions: {transactions_1: {transactionID: '1'}},
});
- const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), {
+ const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), {
initialProps,
});
- const updatedProps = createMock({
+ const updatedProps = createMock({
...baseProps,
transactions: {
transactions_1: {transactionID: '1'},
@@ -111,11 +114,11 @@ describe('useSearchAutoRefetch', () => {
it('should not trigger search when not focused', () => {
mockUseIsFocused.mockReturnValue(false);
- const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), {
+ const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), {
initialProps: baseProps,
});
- const updatedProps = createMock({
+ const updatedProps = createMock({
...baseProps,
transactions: {transactions_1: {transactionID: '1'}},
});
@@ -127,7 +130,7 @@ describe('useSearchAutoRefetch', () => {
it('should trigger search for chat when report actions added and focused', () => {
mockUseIsFocused.mockReturnValue(true);
- const chatProps = createMock({
+ const chatProps = createMock({
...baseProps,
queryJSON: {...baseProps.queryJSON, type: 'chat' as const},
reportActions: {
@@ -142,11 +145,11 @@ describe('useSearchAutoRefetch', () => {
},
});
- const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), {
+ const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), {
initialProps: chatProps,
});
- const updatedProps = createMock({
+ const updatedProps = createMock({
...chatProps,
reportActions: {
reportActions_1: {
@@ -161,7 +164,7 @@ describe('useSearchAutoRefetch', () => {
});
it('should not trigger search when new transaction removed and focused', () => {
- const initialProps = createMock({
+ const initialProps = createMock({
...baseProps,
transactions: {
transactions_1: {transactionID: '1'},
@@ -173,11 +176,11 @@ describe('useSearchAutoRefetch', () => {
},
});
- const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), {
+ const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), {
initialProps,
});
- const updatedProps = createMock({
+ const updatedProps = createMock({
...baseProps,
transactions: {
transactions_1: {transactionID: '1'},
@@ -189,7 +192,7 @@ describe('useSearchAutoRefetch', () => {
});
it('should trigger search when a transaction that is absent from the results is added', () => {
- const initialProps = createMock({
+ const initialProps = createMock({
...baseProps,
searchResults: {
...baseProps.searchResults,
@@ -205,11 +208,11 @@ describe('useSearchAutoRefetch', () => {
},
});
- const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), {
+ const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), {
initialProps,
});
- const updatedProps = createMock({
+ const updatedProps = createMock({
...initialProps,
transactions: {
transactions_1: {transactionID: '1'},
@@ -225,7 +228,7 @@ describe('useSearchAutoRefetch', () => {
// Onyx keeps one value object per collection member, so an unedited transaction has to keep its
// reference across renders — the hook reads that identity to tell an edit from an untouched row.
const transaction1 = createMock({transactionID: '1'});
- const initialProps = createMock({
+ const initialProps = createMock({
...baseProps,
searchResults: {
...baseProps.searchResults,
@@ -242,11 +245,11 @@ describe('useSearchAutoRefetch', () => {
},
});
- const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), {
+ const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), {
initialProps,
});
- const updatedProps = createMock({
+ const updatedProps = createMock({
...initialProps,
transactions: {
transactions_1: transaction1,
@@ -260,7 +263,7 @@ describe('useSearchAutoRefetch', () => {
it('should trigger search when a transaction that is already in the results is edited', () => {
const transaction = createMock({transactionID: '1', amount: 100});
- const initialProps = createMock({
+ const initialProps = createMock({
...baseProps,
searchResults: {
...baseProps.searchResults,
@@ -272,11 +275,11 @@ describe('useSearchAutoRefetch', () => {
previousTransactions: {transactions_1: transaction},
});
- const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), {
+ const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), {
initialProps,
});
- const updatedProps = createMock({
+ const updatedProps = createMock({
...initialProps,
transactions: {transactions_1: {transactionID: '1', amount: 250}},
});
@@ -288,7 +291,7 @@ describe('useSearchAutoRefetch', () => {
it('should not trigger search when the edited transaction is absent from the results', () => {
const visibleTransaction = createMock({transactionID: '1', amount: 100});
const filteredOutTransaction = createMock({transactionID: '99', amount: 100});
- const initialProps = createMock({
+ const initialProps = createMock({
...baseProps,
searchResults: {
...baseProps.searchResults,
@@ -300,11 +303,11 @@ describe('useSearchAutoRefetch', () => {
previousTransactions: {transactions_1: visibleTransaction, transactions_99: filteredOutTransaction},
});
- const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), {
+ const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), {
initialProps,
});
- const updatedProps = createMock({
+ const updatedProps = createMock({
...initialProps,
transactions: {transactions_1: visibleTransaction, transactions_99: {transactionID: '99', amount: 250}},
});
@@ -315,7 +318,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 +330,11 @@ describe('useSearchAutoRefetch', () => {
previousTransactions: {transactions_99: movedTransaction},
});
- const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), {
+ const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), {
initialProps,
});
- const updatedProps = createMock({
+ const updatedProps = createMock({
...initialProps,
transactions: {transactions_99: {transactionID: '99', reportID: '2'}},
});
@@ -342,7 +345,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 +357,11 @@ describe('useSearchAutoRefetch', () => {
previousTransactions: {transactions_99: movedTransaction},
});
- const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), {
+ const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), {
initialProps,
});
- const updatedProps = createMock({
+ const updatedProps = createMock({
...initialProps,
transactions: {transactions_99: {transactionID: '99', reportID: '7'}},
});
@@ -370,7 +373,7 @@ describe('useSearchAutoRefetch', () => {
it('should trigger the deferred search once Search is active again, after previousTransactions caught up', () => {
const transaction = createMock({transactionID: '1', amount: 100});
const editedTransaction = createMock({transactionID: '1', amount: 250});
- const initialProps = createMock({
+ const initialProps = createMock({
...baseProps,
searchResults: {
...baseProps.searchResults,
@@ -383,17 +386,17 @@ describe('useSearchAutoRefetch', () => {
});
mockIsOffline = true;
- const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), {
+ const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), {
initialProps,
});
- const editedProps = createMock({...initialProps, transactions: {transactions_1: editedTransaction}});
+ const editedProps = createMock({...initialProps, transactions: {transactions_1: editedTransaction}});
rerender(editedProps);
expect(search).not.toHaveBeenCalled();
// `usePrevious` catches up while Search is inactive, so the edit is no longer visible to the comparisons.
- const settledProps = createMock({...editedProps, previousTransactions: {transactions_1: editedTransaction}});
+ const settledProps = createMock({...editedProps, previousTransactions: {transactions_1: editedTransaction}});
rerender(settledProps);
expect(search).not.toHaveBeenCalled();
@@ -407,7 +410,7 @@ describe('useSearchAutoRefetch', () => {
it('should not trigger search on a non-chat search when a report action was added and Onyx holds a transaction the query filters out', () => {
const transaction1 = createMock({transactionID: '1'});
const transaction99 = createMock({transactionID: '99'});
- const initialProps = createMock({
+ const initialProps = createMock({
...baseProps,
searchResults: {
...baseProps.searchResults,
@@ -436,11 +439,11 @@ describe('useSearchAutoRefetch', () => {
},
});
- const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), {
+ const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), {
initialProps,
});
- const updatedProps = createMock({
+ const updatedProps = createMock({
...initialProps,
reportActions: {
reportActions_1: {
@@ -457,7 +460,7 @@ describe('useSearchAutoRefetch', () => {
it('should not trigger search when the added transaction is already in the results and Onyx holds one the query filters out', () => {
const transaction1 = createMock({transactionID: '1'});
const transaction99 = createMock({transactionID: '99'});
- const initialProps = createMock({
+ const initialProps = createMock({
...baseProps,
searchResults: {
...baseProps.searchResults,
@@ -476,11 +479,11 @@ describe('useSearchAutoRefetch', () => {
},
});
- const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), {
+ const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), {
initialProps,
});
- const updatedProps = createMock({
+ const updatedProps = createMock({
...initialProps,
transactions: {
transactions_1: transaction1,
@@ -496,7 +499,7 @@ describe('useSearchAutoRefetch', () => {
it('should not trigger search for chat when report actions removed and focused', () => {
mockUseIsFocused.mockReturnValue(true);
- const chatProps = createMock({
+ const chatProps = createMock({
...baseProps,
queryJSON: {...baseProps.queryJSON, type: 'chat' as const},
reportActions: {
@@ -513,11 +516,11 @@ describe('useSearchAutoRefetch', () => {
},
});
- const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), {
+ const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), {
initialProps: chatProps,
});
- const updatedProps = createMock({
+ const updatedProps = createMock({
...chatProps,
reportActions: {
reportActions_1: {
@@ -529,4 +532,168 @@ 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 = createMock({
+ ...baseProps,
+ searchResults: {
+ ...baseProps.searchResults,
+ data: {
+ transactions_1: {
+ transactionID: '1',
+ },
+ transactions_2: {
+ transactionID: '2',
+ },
+ },
+ },
+ transactions: {
+ transactions_1: {transactionID: '1'},
+ transactions_2: {transactionID: '2'},
+ transactions_3: {transactionID: '3'},
+ },
+ previousTransactions: {
+ transactions_1: {transactionID: '1'},
+ },
+ });
+ rerender(updatedProps);
+ expect(result.current.newSearchResultKeys?.size).toBe(2);
+
+ const scrollToIndex = jest.fn();
+ const ref: NonNullable[1]> = {
+ scrollAndHighlightItem: jest.fn(),
+ scrollToIndex,
+ updateFocusedIndex: jest.fn(),
+ scrollToFocusedInput: jest.fn(),
+ focusTextInput: jest.fn(),
+ };
+
+ const listItem1 = createMock({transactionID: '1'});
+ const listItem2 = createMock({transactionID: '2'});
+
+ result.current.handleSelectionListScroll([listItem1, listItem2], ref);
+ result.current.handleSelectionListScroll([listItem2, listItem1], ref);
+
+ expect(scrollToIndex).not.toHaveBeenCalled();
+ });
+
+ it('should return new search result keys for manually highlighted expenses', async () => {
+ const spyOnMergeTransactionIdsHighlightOnSearchRoute = jest
+ .spyOn(require('@libs/actions/Transaction'), 'mergeTransactionIdsHighlightOnSearchRoute')
+ .mockImplementationOnce(jest.fn());
+ // We need to mock requestAnimationFrame to mimic long Onyx merge overhead
+ jest.spyOn(global, 'requestAnimationFrame').mockImplementation((callback: FrameRequestCallback) => {
+ callback(performance.now());
+ return 0;
+ });
+
+ await Onyx.merge(ONYXKEYS.TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE, {[baseProps.queryJSON.type]: {'3': true}});
+
+ const {rerender, result} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), {
+ initialProps: baseProps,
+ });
+ const updatedProps1 = createMock({
+ ...baseProps,
+ searchResults: {
+ ...baseProps.searchResults,
+ data: {
+ transactions_1: {
+ transactionID: '1',
+ },
+ transactions_2: {
+ transactionID: '2',
+ },
+ },
+ },
+ transactions: {
+ transactions_1: {transactionID: '1'},
+ transactions_2: {transactionID: '2'},
+ transactions_3: {transactionID: '3'},
+ },
+ previousTransactions: {
+ transactions_1: {transactionID: '1'},
+ },
+ });
+
+ // When there is no data yet, even if the transactionID has been added to manual highlight transactionIDs,
+ // it still will not be included in newSearchResultKeys.
+ rerender(updatedProps1);
+ expect(result.current.newSearchResultKeys?.size).toBe(2);
+ expect([...(result.current.newSearchResultKeys ?? new Set())]).not.toContain('transactions_3');
+
+ // When the data contains the highlight transactionID, it will be highlighted.
+ const updatedProps2 = createMock({
+ ...updatedProps1,
+ searchResults: {
+ ...updatedProps1.searchResults,
+ data: {
+ transactions_1: {
+ transactionID: '1',
+ },
+ transactions_2: {
+ transactionID: '2',
+ },
+ transactions_3: {
+ transactionID: '3',
+ },
+ },
+ },
+ });
+
+ rerender(updatedProps2);
+ expect(result.current.newSearchResultKeys?.size).toBe(1);
+ expect([...(result.current.newSearchResultKeys ?? new Set())]).toContain('transactions_3');
+
+ expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledWith(baseProps.queryJSON.type, {'3': false});
+ });
+
+ it('should return multiple new search result keys when there are multiple new chats', () => {
+ const chatProps = createMock({
+ ...baseProps,
+ queryJSON: {...baseProps.queryJSON, type: 'chat' as const},
+ reportActions: {
+ reportActions_1: {
+ '1': {actionName: 'CREATED', reportActionID: '1'},
+ },
+ },
+ });
+ const {rerender, result} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), {
+ initialProps: chatProps,
+ });
+ const updatedProps = createMock({
+ ...chatProps,
+ searchResults: {
+ ...baseProps.searchResults,
+ data: {
+ reportActions_1: {
+ '1': {actionName: 'CREATED', reportActionID: '1'},
+ },
+ reportActions_2: {
+ '2': {actionName: 'CREATED', reportActionID: '2'},
+ },
+ },
+ },
+ reportActions: {
+ reportActions_1: {
+ '1': {actionName: 'CREATED', reportActionID: '1'},
+ },
+ reportActions_2: {
+ '2': {actionName: 'CREATED', reportActionID: '2'},
+ },
+ reportActions_3: {
+ '3': {actionName: 'CREATED', reportActionID: '3'},
+ },
+ },
+ previousReportActions: {
+ reportActions_1: {
+ '1': {actionName: 'CREATED', reportActionID: '1'},
+ },
+ },
+ });
+ rerender(updatedProps);
+ expect(result.current.newSearchResultKeys?.size).toBe(2);
+ });
});