diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv
index 1958e3371bbb..b3503e49395d 100644
--- a/config/eslint/eslint.seatbelt.tsv
+++ b/config/eslint/eslint.seatbelt.tsv
@@ -570,8 +570,8 @@
"../../src/hooks/useRestoreWorkspacesTabOnNavigate.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/hooks/useReviewDuplicatesNavigation.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/hooks/useReviewDuplicatesNavigation.tsx" "react-hooks/set-state-in-effect" 1
+"../../src/hooks/useSearchAutoRefetch.ts" "@typescript-eslint/no-unsafe-type-assertion" 4
"../../src/hooks/useSearchBulkActions.ts" "@typescript-eslint/no-unsafe-type-assertion" 10
-"../../src/hooks/useSearchHighlightAndScroll.ts" "@typescript-eslint/no-unsafe-type-assertion" 4
"../../src/hooks/useSidebarOrderedReports.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../src/hooks/useSidebarOrderedReports.tsx" "react-hooks/refs" 5
"../../src/hooks/useSidebarOrderedReports.tsx" "react-hooks/set-state-in-effect" 1
@@ -2093,7 +2093,7 @@
"../../tests/unit/useReceiptHoverZoomTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 3
"../../tests/unit/useReportActionAvatarsTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../tests/unit/useReportUnreadMessageScrollTrackingTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
-"../../tests/unit/useSearchHighlightAndScrollTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 2
+"../../tests/unit/useSearchAutoRefetchTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../tests/unit/useSearchSelectorTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 5
"../../tests/unit/useShareSavedSearchTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../tests/unit/useSidebarOrderedReportsTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3
diff --git a/src/GlobalModals.tsx b/src/GlobalModals.tsx
index a29dca9321cc..992b0fc55b70 100644
--- a/src/GlobalModals.tsx
+++ b/src/GlobalModals.tsx
@@ -2,6 +2,7 @@ import React, {startTransition, useEffect, useState} from 'react';
import DelegateNoAccessModalProvider from './components/DelegateNoAccessModalProvider';
import EmojiPicker from './components/EmojiPicker/EmojiPicker';
+import ExpenseAddedGrowl from './components/ExpenseAddedGrowl';
import GrowlNotification from './components/GrowlNotification';
import LazyModalSlot from './components/LazyModalSlot';
import * as EmojiPickerAction from './libs/actions/EmojiPickerAction';
@@ -50,6 +51,7 @@ function GlobalModals() {
return (
<>
+
{shouldRenderContextMenu && (
diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts
index d69c87a5617e..11d75fbd29b3 100755
--- a/src/ONYXKEYS.ts
+++ b/src/ONYXKEYS.ts
@@ -14,6 +14,7 @@ import type {Attendee, DistanceExpenseType, Participant} from './types/onyx/IOU'
import type Onboarding from './types/onyx/Onboarding';
import type {AnyOnyxUpdate} from './types/onyx/Request';
import type {SavedCSVColumnLayoutList} from './types/onyx/SavedCSVColumnLayout';
+import type {SearchDataTypes} from './types/onyx/SearchResults';
import type AssertTypesEqual from './types/utils/AssertTypesEqual';
import type DeepValueOf from './types/utils/DeepValueOf';
@@ -836,8 +837,8 @@ const ONYXKEYS = {
/** Whether the user has denied the contact import permission prompt */
HAS_DENIED_CONTACT_IMPORT_PROMPT: 'hasDeniedContactImportPrompt',
- /** The transaction IDs to be highlighted when opening the Expenses search route page */
- TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE: 'transactionIdsHighlightOnSearchRoute',
+ /** Maps each newly-added transaction ID to its search data type, flagging it for the "Expense added" growl */
+ EXPENSE_ADDED_GROWL_TRANSACTION_IDS: 'expenseAddedGrowlTransactionIDs',
/** The report ID to be highlighted when returning to the workspace rooms page */
ROOM_ID_HIGHLIGHT_ON_ROOMS_PAGE: 'roomIDHighlightOnRoomsPage',
@@ -1797,7 +1798,7 @@ type OnyxValuesMapping = {
[ONYXKEYS.NVP_REPORT_DETAILS_COLUMNS]: string[];
[ONYXKEYS.HAS_DENIED_CONTACT_IMPORT_PROMPT]: boolean | undefined;
[ONYXKEYS.PERSONAL_POLICY_ID]: string;
- [ONYXKEYS.TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE]: Record>;
+ [ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS]: Record;
[ONYXKEYS.ROOM_ID_HIGHLIGHT_ON_ROOMS_PAGE]: string | null;
[ONYXKEYS.DOMAIN_GROUP_CREATE_PREFERRED_POLICY_ID]: string | undefined;
};
diff --git a/src/components/ExpenseAddedGrowl.tsx b/src/components/ExpenseAddedGrowl.tsx
new file mode 100644
index 000000000000..882319fb7f6b
--- /dev/null
+++ b/src/components/ExpenseAddedGrowl.tsx
@@ -0,0 +1,178 @@
+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 (
+
+ );
+}
+
+/**
+ * Reads the candidate transaction and its report/actions and renders the growl. Split out from
+ * `ExpenseAddedGrowl` so these Onyx connections only exist while there is a pending signal or a visible growl -
+ * the outer component is mounted for the whole app lifetime and would otherwise hold them permanently while idle.
+ */
+function ExpenseAddedGrowlContent({transactionID, signal, active, setActive}: ExpenseAddedGrowlContentProps) {
+ const nonceRef = useRef(0);
+
+ const {translate} = useLocalize();
+ const currentUserPersonalDetails = useCurrentUserPersonalDetails();
+ const [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/unreported expense's transaction.reportID is UNREPORTED_REPORT_ID, and its IOU/track action lives
+ // on the self-DM report - not on transaction.reportID. Read actions from the report that actually hosts the
+ // action so "View" resolves the real transaction thread (the action's childReportID) instead of fabricating
+ // a mismatched optimistic one.
+ const isUnreportedExpense = !reportID || reportID === CONST.REPORT.UNREPORTED_REPORT_ID;
+ const selfDMReportID = isUnreportedExpense ? findSelfDMReportID() : undefined;
+ const hostReportID = isUnreportedExpense ? selfDMReportID : reportID;
+ const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${hostReportID}`);
+ // Only IOU/expense/invoice reports have the expense-report RHP that "View" opens. A tracked/unreported
+ // (self-DM) expense lives in a chat, so it has no such report - leaving iouReportID undefined makes
+ // navigateToCreatedExpense open the transaction thread directly instead of a super-wide report RHP it lacks.
+ 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) {
+ return;
+ }
+ // Wait for the created expense's optimistic data to land before acting. The create's API.write is
+ // usually deferred (the deferred-for-search pattern) until Search's onLayout flushes it, and the whole
+ // optimistic dataset (transaction + IOU report/action + thread) is applied atomically - so the
+ // transaction appearing in Onyx means "View" can resolve AND its report is known for the check below.
+ if (!transaction) {
+ return;
+ }
+
+ // Clear every pending ID but surface only the latest: rapid creations that pile up before this runs
+ // collapse into a single growl for the newest expense.
+ mergeExpenseAddedGrowlTransactionIDs(Object.fromEntries(pendingTransactionIDs.map((id) => [id, null])));
+
+ // Suppress the growl when the user is already viewing the expense's money-request report (its
+ // transaction list), where the new row is highlighted so a growl would be redundant. A tracked/self-DM
+ // expense has transaction.reportID === UNREPORTED_REPORT_ID (never a report you view), so this never
+ // matches for those and the growl always shows - matching "no transaction list to be in".
+ if (Navigation.getTopmostReportId() === transaction.reportID) {
+ return;
+ }
+ nonceRef.current += 1;
+ setActive({transactionID: latestTransactionID, dataType, nonce: nonceRef.current});
+ }, [signal, active, transaction, setActive]);
+
+ if (!active) {
+ return null;
+ }
+
+ const isInvoice = active.dataType === CONST.SEARCH.DATA_TYPES.INVOICE;
+
+ // Materialize the transaction thread and navigate to it at press time (not show time): the thread is
+ // only built if the user actually taps "View", against the freshest Onyx data, matching how every other
+ // thread navigation entry point builds the thread at navigation time.
+ const navigateToExpense = () => {
+ const iouAction = getIOUActionForTransactionID(Object.values(reportActions ?? {}), active.transactionID);
+ let threadReportID = transaction?.transactionThreadReportID ?? iouAction?.childReportID;
+ if (threadReportID) {
+ setOptimisticTransactionThread(threadReportID, iouReport?.reportID, iouAction?.reportActionID, iouReport?.policyID);
+ } else {
+ const optimisticThread = createTransactionThreadReport({
+ introSelected,
+ currentUserLogin: currentUserPersonalDetails?.login ?? '',
+ currentUserAccountID: currentUserPersonalDetails?.accountID ?? CONST.DEFAULT_NUMBER_ID,
+ betas,
+ iouReport,
+ iouReportAction: iouAction,
+ transaction,
+ });
+ threadReportID = optimisticThread?.reportID;
+ }
+ if (!threadReportID) {
+ Log.warn('[ExpenseAddedGrowl] Unable to resolve transaction thread reportID on View press.');
+ return;
+ }
+ navigateToCreatedExpense({threadReportID, transactionID: active.transactionID, iouReportID});
+ };
+
+ return (
+ setActive((prev) => (prev?.nonce === dismissedNonce ? null : prev))}
+ />
+ );
+}
+
+export default ExpenseAddedGrowl;
diff --git a/src/components/MoneyRequestHeaderSecondaryActions.tsx b/src/components/MoneyRequestHeaderSecondaryActions.tsx
index 4e633a922d14..02e39415787f 100644
--- a/src/components/MoneyRequestHeaderSecondaryActions.tsx
+++ b/src/components/MoneyRequestHeaderSecondaryActions.tsx
@@ -27,6 +27,7 @@ import useThrottledButtonState from '@hooks/useThrottledButtonState';
import useTransactionViolations from '@hooks/useTransactionViolations';
import {duplicateExpenseTransaction as duplicateTransactionAction} from '@libs/actions/IOU/Duplicate';
+import {signalExpenseAddedGrowl} from '@libs/actions/IOU/NavigationHelpers';
import {deleteTrackExpense} from '@libs/actions/IOU/TrackExpense';
import {setupMergeTransactionDataAndNavigate} from '@libs/actions/MergeTransaction';
import initSplitExpense from '@libs/actions/SplitExpenses';
@@ -243,11 +244,12 @@ function MoneyRequestHeaderSecondaryActions({reportID, onBackButtonPress}: Money
const optimisticIOUReportID = generateReportID();
const activePolicyCategoriesMap = defaultPolicyCategories ?? {};
+ let lastDuplicateTransactionID: string | undefined;
for (const item of transactions) {
const existingTransactionID = getExistingTransactionID(item.linkedTrackedExpenseReportAction);
const existingTransactionDraft = existingTransactionID ? transactionDrafts?.[existingTransactionID] : undefined;
- duplicateTransactionAction({
+ const result = duplicateTransactionAction({
transaction: item,
optimisticChatReportID,
optimisticIOUReportID,
@@ -272,7 +274,11 @@ function MoneyRequestHeaderSecondaryActions({reportID, onBackButtonPress}: Money
policyTagList,
formatPhoneNumber,
});
+ if (result?.transactionID) {
+ lastDuplicateTransactionID = result.transactionID;
+ }
}
+ signalExpenseAddedGrowl(lastDuplicateTransactionID, CONST.SEARCH.DATA_TYPES.EXPENSE);
};
const dismissModalAndUpdateUseHold = () => {
diff --git a/src/components/Search/ChatSearchView.tsx b/src/components/Search/ChatSearchView.tsx
index 21f390a3fa3e..361bb2aa734e 100644
--- a/src/components/Search/ChatSearchView.tsx
+++ b/src/components/Search/ChatSearchView.tsx
@@ -4,7 +4,7 @@ import CONST from '@src/CONST';
import type {NativeSyntheticEvent} from 'react-native';
-import React, {useImperativeHandle} from 'react';
+import React from 'react';
import type {SearchListItem} from './SearchList/ListItem/types';
import type {CommonSearchViewProps} from './searchViewProps';
@@ -49,7 +49,6 @@ function ChatSearchView({
onScroll,
contentContainerStyle,
containerStyle,
- ref,
}: ChatSearchViewProps) {
const {type} = queryJSON;
@@ -65,9 +64,6 @@ function ChatSearchView({
const selectedItemsLength = data.reduce((acc, item) => acc + (item.keyForList && selectedTransactions[item.keyForList]?.isSelected ? 1 : 0), 0);
const totalItems = data.filter((item) => !isRowDeleted(item)).length;
- // Flat data maps 1:1 to the rendered list, so highlight-scroll-to-index is the same as scroll-to-data-index.
- useImperativeHandle(ref, () => ({scrollToIndex: scrollToListIndex}), [scrollToListIndex]);
-
const renderItem = (item: SearchListItem, index: number, isItemFocused: boolean, onFocus?: (event: NativeSyntheticEvent) => void) => (
// Chat rows never animate their exit (only grouped expenses do), so the wrapper just preserves the overflow clip.
item.pendingAction === CONST.RED_
* `toggle`/`toggleAll`/`selectedTransactions`). The shared list state and interactions come from
* `useSearchListViewState`, and the surrounding chrome (horizontal scroll, header bar, long-press menu)
* from `SearchListViewLayout`; this view only owns the flat-expense specifics: the `TransactionListItem`
- * renderer, single-pass visibility/selection counts, and the highlight-scroll imperative handle.
- * `TransactionListItem` is the only row renderer here and rows always animate, so the
- * group/sticky/chat/task branches of `SearchList` do not apply. Keyboard navigation is inherited from
- * `BaseSearchList`; the post-create highlight stays in the router (the snapshot stamps
- * `shouldAnimateInHighlight`, and `newTransactions` flows into `extraData`).
+ * renderer and single-pass visibility/selection counts.
+ * `TransactionListItem` is the only row renderer here, so the group/sticky/chat/task branches of
+ * `SearchList` do not apply. Keyboard navigation is inherited from `BaseSearchList`; `newTransactions`
+ * flows into `extraData` so the list re-renders when a freshly-created expense lands.
*/
function ExpenseFlatSearchView({
queryJSON,
@@ -55,7 +54,6 @@ function ExpenseFlatSearchView({
onScroll,
contentContainerStyle,
containerStyle,
- ref,
}: ExpenseFlatSearchViewProps) {
const {type} = queryJSON;
@@ -89,9 +87,6 @@ function ExpenseFlatSearchView({
const selectedItemsLength = data.reduce((acc, item) => acc + (item.keyForList && selectedTransactions[item.keyForList]?.isSelected ? 1 : 0), 0);
const totalItems = data.filter((item) => !isRowDeleted(item)).length;
- // Flat data maps 1:1 to the rendered list, so highlight-scroll-to-index is the same as scroll-to-data-index.
- useImperativeHandle(ref, () => ({scrollToIndex: scrollToListIndex}), [scrollToListIndex]);
-
const renderItem = (item: SearchListItem, index: number, isItemFocused: boolean, onFocus?: (event: NativeSyntheticEvent) => void) => {
const isDisabled = isRowDeleted(item);
// Only expense row exits animate; invoice and trip transaction lists do not (matches the legacy per-type gate).
diff --git a/src/components/Search/ExpenseGroupedSearchView.tsx b/src/components/Search/ExpenseGroupedSearchView.tsx
index dc834187779d..f34d215ffe17 100644
--- a/src/components/Search/ExpenseGroupedSearchView.tsx
+++ b/src/components/Search/ExpenseGroupedSearchView.tsx
@@ -15,7 +15,7 @@ import type {Transaction} from '@src/types/onyx';
import type {NativeSyntheticEvent} from 'react-native';
-import React, {useImperativeHandle, useState} from 'react';
+import React, {useState} from 'react';
import type {SearchListItem} from './SearchList/ListItem/types';
import type {CommonSearchViewProps, TransactionViewExtras} from './searchViewProps';
@@ -110,7 +110,6 @@ function ExpenseGroupedSearchView({
onScroll,
contentContainerStyle,
containerStyle,
- ref,
}: ExpenseGroupedSearchViewProps) {
const {type, groupBy} = queryJSON;
const {isLargeScreenWidth} = useResponsiveLayout();
@@ -173,26 +172,6 @@ function ExpenseGroupedSearchView({
const firstVisibleIndex = listData.findIndex(isItemVisible);
const lastVisibleIndex = listData.findLastIndex(isItemVisible);
- // The router highlights by source-data index; remap it to the split-list index before scrolling.
- useImperativeHandle(
- ref,
- () => ({
- scrollToIndex: (index: number, animated = true) => {
- if (!shouldSplit) {
- scrollToListIndex(index, animated);
- return;
- }
- const item = data.at(index);
- if (!item) {
- return;
- }
- const splitIndex = item.keyForList ? listData.findIndex((listItem) => listItem.keyForList === `header_${item.keyForList}`) : -1;
- scrollToListIndex(splitIndex !== -1 ? splitIndex : index, animated);
- },
- }),
- [data, listData, shouldSplit, scrollToListIndex],
- );
-
const getItemType = (item: SearchListItem) => {
if (!shouldSplit) {
return undefined;
diff --git a/src/components/Search/ExpenseReportSearchView.tsx b/src/components/Search/ExpenseReportSearchView.tsx
index 1a8ba373e619..ed3b269d66b2 100644
--- a/src/components/Search/ExpenseReportSearchView.tsx
+++ b/src/components/Search/ExpenseReportSearchView.tsx
@@ -6,7 +6,7 @@ import CONST from '@src/CONST';
import type {NativeSyntheticEvent} from 'react-native';
-import React, {useImperativeHandle} from 'react';
+import React from 'react';
import type {SearchListItem} from './SearchList/ListItem/types';
import type {CommonSearchViewProps} from './searchViewProps';
@@ -55,7 +55,6 @@ function ExpenseReportSearchView({
onScroll,
contentContainerStyle,
containerStyle,
- ref,
}: ExpenseReportSearchViewProps) {
const {type} = queryJSON;
@@ -92,9 +91,6 @@ function ExpenseReportSearchView({
emptyReports.reduce((acc, item) => acc + (isRowSelected(item.keyForList, selectedTransactions) ? 1 : 0), 0);
const totalItems = flattenedTransactions.filter((item) => !isRowDeleted(item)).length + emptyReports.filter((item) => !isRowDeleted(item)).length;
- // Report data maps 1:1 to the rendered list, so highlight-scroll-to-index is the same as scroll-to-data-index.
- useImperativeHandle(ref, () => ({scrollToIndex: scrollToListIndex}), [scrollToListIndex]);
-
const renderItem = (item: SearchListItem, index: number, isItemFocused: boolean, onFocus?: (event: NativeSyntheticEvent) => void) => (
// Report rows never animate their exit (only grouped expenses do), so the wrapper just preserves the overflow clip.
({
const 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,
- // Removing background style because they are added to the parent OpacityView via animatedHighlightStyle
+ // Background is applied on the parent wrapper, so keep this transparent
styles.bgTransparent,
isSelected && styles.activeComponentBG,
styles.mh0,
@@ -82,7 +75,7 @@ function ChatListItem({
keyForList={item.keyForList}
onFocus={onFocus}
shouldSyncFocus={shouldSyncFocus}
- pressableWrapperStyle={[styles.mh5, animatedHighlightStyle]}
+ pressableWrapperStyle={[styles.mh5, {backgroundColor: theme.highlightBG, borderRadius: variables.componentBorderRadius}]}
hoverStyle={isSelected && styles.activeComponentBG}
forwardedFSClass={fsClass}
>
diff --git a/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx b/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx
index 92db0bf84ac7..7674f1e7f979 100644
--- a/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx
+++ b/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx
@@ -12,7 +12,6 @@ import BaseListItem from '@components/SelectionList/ListItem/BaseListItem';
import type {ListItem} from '@components/SelectionList/types';
import Text from '@components/Text';
-import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle';
import useConfirmModal from '@hooks/useConfirmModal';
import {useCurrencyListActions} from '@hooks/useCurrencyList';
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
@@ -350,7 +349,7 @@ function ExpenseReportListItemInner({
styles.selectionListPressableItemWrapper,
isLargeScreenWidth && styles.pv3,
isLargeScreenWidth && styles.ph3,
- // Removing background style because they are added to the parent OpacityView via animatedHighlightStyle
+ // Background is applied on the parent wrapper, so keep this transparent
styles.bgTransparent,
isSelected && styles.activeComponentBG,
styles.mh0,
@@ -371,14 +370,6 @@ function ExpenseReportListItemInner({
[styles, isLargeScreenWidth],
);
- const animatedHighlightStyle = useAnimatedHighlightStyle({
- borderRadius: 0,
- shouldHighlight: item?.shouldAnimateInHighlight ?? false,
- highlightColor: theme.messageHighlightBG,
- backgroundColor: isSelected ? theme.activeComponentBG : theme.highlightBG,
- shouldApplyOtherStyles: !isLargeScreenWidth,
- });
-
const shouldShowViolationDescription = isOpenExpenseReport(reportItem) || isProcessingReport(reportItem);
// Show violation description if either:
@@ -478,7 +469,7 @@ function ExpenseReportListItemInner({
hoverStyle={isSelected && styles.activeComponentBG}
pressableWrapperStyle={[
styles.mh5,
- animatedHighlightStyle,
+ {backgroundColor: isSelected ? theme.activeComponentBG : theme.highlightBG, ...(!isLargeScreenWidth && {borderRadius: 0})},
isPendingDelete && styles.cursorDisabled,
isLargeScreenWidth && isLastItem && [styles.tableBottomRadius, styles.overflowHidden],
!isLargeScreenWidth && isFirstItem && styles.tableTopRadius,
diff --git a/src/components/Search/SearchList/ListItem/GroupChildrenContainer.tsx b/src/components/Search/SearchList/ListItem/GroupChildrenContainer.tsx
index f894686a766d..dc7c7bd02d41 100644
--- a/src/components/Search/SearchList/ListItem/GroupChildrenContainer.tsx
+++ b/src/components/Search/SearchList/ListItem/GroupChildrenContainer.tsx
@@ -1,6 +1,5 @@
import {useSearchSelectionContext} from '@components/Search/SearchContext';
-import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle';
import useExpandCollapseAnimation from '@hooks/useExpandCollapseAnimation';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -43,27 +42,13 @@ function GroupChildrenContainer({
const isSelected = !!item.isSelected || (item.transactions.length > 0 && item.transactions.every((transaction) => selectedTransactions[transaction.transactionID]?.isSelected));
- const animatedHighlightStyle = useAnimatedHighlightStyle({
- shouldHighlight: 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 b2d4635acbae..3f1a9a5ea77d 100644
--- a/src/components/Search/SearchList/ListItem/GroupHeader.tsx
+++ b/src/components/Search/SearchList/ListItem/GroupHeader.tsx
@@ -7,7 +7,6 @@ import SearchTableHeader from '@components/Search/SearchTableHeader';
import type {SearchColumnType, SearchCustomColumnIds, SearchGroupBy} from '@components/Search/types';
import type {ExtendedTargetedEvent} from '@components/SelectionList/ListItem/types';
-import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle';
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import useExpandCollapseAnimation from '@hooks/useExpandCollapseAnimation';
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
@@ -220,13 +219,6 @@ function GroupHeader({
keyForList: originalKey,
});
- 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 : effectiveTransactions);
};
@@ -409,7 +401,7 @@ function GroupHeader({
]}
wrapperStyle={[
styles.mh5,
- animatedHighlightStyle,
+ {backgroundColor: isItemSelected ? theme.activeComponentBG : theme.highlightBG},
styles.userSelectNone,
isLargeScreenWidth
? [StyleUtils.getSearchTableGroupRowBorderStyle(isFirstItem, isLastItemCollapsed, isItemSelected), isLastItemCollapsed && styles.overflowHidden]
diff --git a/src/components/Search/SearchList/ListItem/TaskListItem.tsx b/src/components/Search/SearchList/ListItem/TaskListItem.tsx
index 03d4d09aa549..341c2480a1a1 100644
--- a/src/components/Search/SearchList/ListItem/TaskListItem.tsx
+++ b/src/components/Search/SearchList/ListItem/TaskListItem.tsx
@@ -2,7 +2,6 @@ import {useRowSelection} from '@components/Search/SearchSelectionProvider';
import BaseListItem from '@components/SelectionList/ListItem/BaseListItem';
import type {ListItem} from '@components/SelectionList/types';
-import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle';
import useOnyx from '@hooks/useOnyx';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useStyleUtils from '@hooks/useStyleUtils';
@@ -60,7 +59,7 @@ function TaskListItem({
styles.selectionListPressableItemWrapper,
styles.pv3,
styles.ph3,
- // Removing background style because they are added to the parent OpacityView via animatedHighlightStyle
+ // Background is applied on the parent wrapper, so keep this transparent
styles.bgTransparent,
isSelected && styles.activeComponentBG,
styles.mh0,
@@ -73,14 +72,6 @@ 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 (
@@ -100,7 +91,11 @@ function TaskListItem({
onLongPressRow={onLongPressRow}
shouldSyncFocus={shouldSyncFocus}
hoverStyle={isSelected && styles.activeComponentBG}
- pressableWrapperStyle={[styles.mh5, animatedHighlightStyle, isLargeScreenWidth && isLastItem && [styles.tableBottomRadius, styles.overflowHidden]]}
+ pressableWrapperStyle={[
+ styles.mh5,
+ {backgroundColor: theme.highlightBG, ...(!isLargeScreenWidth && {borderRadius: StyleUtils.getSearchTableHighlightBorderRadius(isLargeScreenWidth)})},
+ isLargeScreenWidth && isLastItem && [styles.tableBottomRadius, styles.overflowHidden],
+ ]}
forwardedFSClass={fsClass}
>
({
const pressableStyle = [styles.transactionListItemStyle, styles.p4, styles.noBorderRadius, isSelected && styles.activeComponentBG, {...styles.flexColumn, ...styles.alignItemsStretch}];
- const animatedHighlightStyle = useAnimatedHighlightStyle({
- borderRadius: 0,
- shouldHighlight: item?.shouldAnimateInHighlight ?? false,
- highlightColor: theme.messageHighlightBG,
- backgroundColor: isSelected ? theme.activeComponentBG : theme.highlightBG,
- shouldApplyOtherStyles: true,
- });
-
- // The highlight animation is applied to the row wrapper, which sits behind this pressable. A focused
- // row paints an opaque background on the pressable itself, which would cover the highlight - so after
- // splitting an expense the newly-created row that receives focus never appears highlighted. Suppress
- // the opaque focus background for the full highlight animation so the highlight shows. shouldAnimateInHighlight
- // only stays true for the brief queue window, so latch it for durationHighlightItem.
- const shouldAnimateInHighlight = !!item?.shouldAnimateInHighlight;
-
- // Initialize from the prop so a row that mounts already flagged (the split/search highlight case this
- // fixes) latches immediately - otherwise the render-time guard below never fires on first mount.
- const [isHighlighting, setIsHighlighting] = useState(shouldAnimateInHighlight);
- const [wasAnimatingHighlight, setWasAnimatingHighlight] = useState(shouldAnimateInHighlight);
-
- // Start the latch during render (React's "storing information from previous renders" pattern) to avoid
- // calling setState synchronously inside an effect. The effect below only clears it via an async timer.
- if (shouldAnimateInHighlight !== wasAnimatingHighlight) {
- setWasAnimatingHighlight(shouldAnimateInHighlight);
- if (shouldAnimateInHighlight) {
- setIsHighlighting(true);
- }
- }
- useEffect(() => {
- if (!isHighlighting) {
- return;
- }
- const timer = setTimeout(() => setIsHighlighting(false), durationHighlightItem);
- return () => clearTimeout(timer);
- }, [isHighlighting]);
- const shouldShowFocusBackground = !!isFocused && !isHighlighting;
-
return (
({
sentryLabel={CONST.SENTRY_LABEL.SEARCH.TRANSACTION_LIST_ITEM}
style={[
pressableStyle,
- shouldShowFocusBackground && StyleUtils.getItemBackgroundColorStyle(isSelected, !!isFocused, !!item.isDisabled, theme.activeComponentBG, theme.hoverComponentBG),
+ isFocused && StyleUtils.getItemBackgroundColorStyle(isSelected, !!isFocused, !!item.isDisabled, theme.activeComponentBG, theme.hoverComponentBG),
isDeletedTransaction && styles.cursorDefault,
]}
onFocus={onFocus}
wrapperStyle={[
styles.mh5,
styles.flex1,
- animatedHighlightStyle,
+ {backgroundColor: isSelected ? theme.activeComponentBG : theme.highlightBG, borderRadius: 0},
styles.userSelectNone,
isFirstItem && styles.tableTopRadius,
isLastItem && styles.tableBottomRadius,
diff --git a/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemWide.tsx b/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemWide.tsx
index 9eed61acb455..999e9a64e75c 100644
--- a/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemWide.tsx
+++ b/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemWide.tsx
@@ -7,7 +7,6 @@ import type {ListItem} from '@components/SelectionList/types';
import TransactionItemRow from '@components/TransactionItemRow';
import {useEditingCellState} from '@components/TransactionItemRow/EditableCell';
-import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle';
import useStyleUtils from '@hooks/useStyleUtils';
import useSyncFocus from '@hooks/useSyncFocus';
import useTheme from '@hooks/useTheme';
@@ -143,14 +142,6 @@ function TransactionListItemWide({
},
];
- const animatedHighlightStyle = useAnimatedHighlightStyle({
- borderRadius: 0,
- shouldHighlight: item?.shouldAnimateInHighlight ?? false,
- highlightColor: theme.messageHighlightBG,
- backgroundColor: isSelected ? theme.activeComponentBG : theme.highlightBG,
- shouldApplyOtherStyles: false,
- });
-
return (
({
isDeletedTransaction && styles.cursorDefault,
]}
onFocus={onFocus}
- wrapperStyle={[styles.mh5, styles.flex1, animatedHighlightStyle, styles.userSelectNone, isLastItem && [styles.tableBottomRadius, styles.overflowHidden]]}
+ wrapperStyle={[
+ styles.mh5,
+ styles.flex1,
+ {backgroundColor: isSelected ? theme.activeComponentBG : theme.highlightBG},
+ styles.userSelectNone,
+ isLastItem && [styles.tableBottomRadius, styles.overflowHidden],
+ ]}
>
{({hovered}) => (
acc + (item.keyForList && selectedTransactions[item.keyForList]?.isSelected ? 1 : 0), 0);
const totalItems = data.filter((item) => !isRowDeleted(item)).length;
- // Flat data maps 1:1 to the rendered list, so highlight-scroll-to-index is the same as scroll-to-data-index.
- useImperativeHandle(ref, () => ({scrollToIndex: scrollToListIndex}), [scrollToListIndex]);
-
const renderItem = (item: SearchListItem, index: number, isItemFocused: boolean, onFocus?: (event: NativeSyntheticEvent) => void) => (
// Task rows never animate their exit (only grouped expenses do), so the wrapper just preserves the overflow clip.
;
/** The current search snapshot, owned by the ancestor and passed in. */
searchResults: SearchResults | undefined;
- /** Keys flagged for the post-create highlight animation. */
- newSearchResultKeys: Set | null | undefined;
/** Full TRANSACTION + REPORT_ACTIONS collections used by the optimistic-row tracking. Threaded in from
- * the parent (which already subscribes to them for the highlight hook) so we don't open duplicate
+ * the parent (which already subscribes to them for the refetch hook) so we don't open duplicate
* full-collection reads. */
transactions: OptimisticTrackingParams['transactions'];
reportActions: OptimisticTrackingParams['reportActions'];
@@ -90,11 +88,11 @@ const hashToString = (queryHash?: number) => (queryHash || queryHash === 0 ? Str
* Single data layer for the Search screen.
*
* Owns the live inputs `getSections` needs for sort/group correctness, runs the deferral-gated
- * sort/group/paginate projection, stamps the post-create highlight, enriches grouped views with their
- * per-group sub-snapshots, and absorbs the optimistic-row resilience. Returns the sorted rows plus the
- * list-level meta and the optimistic-tracking carriers that `` consumes.
+ * sort/group/paginate projection, enriches grouped views with their per-group sub-snapshots, and absorbs
+ * the optimistic-row resilience. Returns the sorted rows plus the list-level meta and the
+ * optimistic-tracking carriers that `` consumes.
*/
-function useSearchSnapshot({queryJSON, searchResults, newSearchResultKeys, transactions, reportActions}: UseSearchSnapshotParams): SearchSnapshotResult {
+function useSearchSnapshot({queryJSON, searchResults, transactions, reportActions}: UseSearchSnapshotParams): SearchSnapshotResult {
const {type, sortBy, sortOrder, hash, groupBy} = queryJSON;
const {isOffline} = useNetwork();
@@ -304,8 +302,8 @@ function useSearchSnapshot({queryJSON, searchResults, newSearchResultKeys, trans
convertToDisplayString,
]);
- // Stage 3: sort the (enriched) data, then stamp the post-create highlight on each row. getSortedSections
- // accepts the full section union; our SearchListItem[] is a compatible subset of that input.
+ // Stage 3: sort the (enriched) data. getSortedSections accepts the full section union; our
+ // SearchListItem[] is a compatible subset of that input.
const chartData = useMemo(() => {
if (!shouldComputeSections) {
return EMPTY_DATA;
@@ -316,47 +314,13 @@ function useSearchSnapshot({queryJSON, searchResults, newSearchResultKeys, trans
policyTags,
fallbackPolicyID: policyForMovingExpensesID,
}).map((item) => {
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- chat variant rows are report actions
- const reportActionID = (item as ReportActionListItemType).reportActionID;
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- non-chat variant rows carry a transactionID
- const transactionID = (item as TransactionListItemType).transactionID;
- const baseKey = isChat ? `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportActionID}` : `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`;
-
- const isBaseKeyMatch = !!newSearchResultKeys?.has(baseKey);
-
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- group rows expose nested transactions
- const groupTransactionsForHighlight = (item as TransactionGroupListItemType)?.transactions;
- const isAnyTransactionMatch =
- !isChat &&
- groupTransactionsForHighlight?.some((transaction) => {
- const transactionKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`;
- return !!newSearchResultKeys?.has(transactionKey);
- });
-
- const shouldAnimateInHighlight = isBaseKeyMatch || isAnyTransactionMatch;
-
- if (item.shouldAnimateInHighlight === shouldAnimateInHighlight && item.hash === hash) {
+ if (item.hash === hash) {
return item;
}
- return {...item, shouldAnimateInHighlight, hash};
+ return {...item, hash};
});
- }, [
- shouldComputeSections,
- type,
- filteredData,
- localeCompare,
- translate,
- sortBy,
- sortOrder,
- validGroupBy,
- policyCategories,
- policyTags,
- policyForMovingExpensesID,
- isChat,
- newSearchResultKeys,
- hash,
- ]);
+ }, [shouldComputeSections, type, filteredData, localeCompare, translate, sortBy, sortOrder, validGroupBy, policyCategories, policyTags, policyForMovingExpensesID, hash]);
// Keep the optimistic row visible across a snapshot-replacement gap for up to
// OPTIMISTIC_ROLLBACK_GRACE_MS until the new snapshot picks it up or the grace expires.
diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx
index ab37b2acd7b1..bd419715eea8 100644
--- a/src/components/Search/index.tsx
+++ b/src/components/Search/index.tsx
@@ -1,6 +1,5 @@
import FullPageErrorView from '@components/BlockingViews/FullPageErrorView';
import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView';
-import type {SelectionListHandle} from '@components/SelectionList/types';
import SearchRowSkeleton from '@components/Skeletons/SearchRowSkeleton';
import {useWideRHPActions} from '@components/WideRHPContextProvider';
@@ -14,7 +13,7 @@ import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses';
import usePrevious from '@hooks/usePrevious';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useSaveSortedReportIDs from '@hooks/useSaveSortedReportIDs';
-import useSearchHighlightAndScroll from '@hooks/useSearchHighlightAndScroll';
+import useSearchAutoRefetch from '@hooks/useSearchAutoRefetch';
import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotals';
import useStableArrayReference from '@hooks/useStableArrayReference';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -188,7 +187,6 @@ function Search({
const previousReportActions = usePrevious(reportActions);
const {translate} = useLocalize();
- const searchListRef = useRef | null>(null);
const savedSearchSelector = useCallback((searches: OnyxEntry) => searches?.[hash], [hash]);
const [savedSearch] = useOnyx(ONYXKEYS.SAVED_SEARCHES, {
@@ -211,7 +209,7 @@ function Search({
clearSelectedTransactions();
}, [validGroupBy, prevValidGroupBy, clearSelectedTransactions]);
- const {newSearchResultKeys, handleSelectionListScroll, newTransactions, hasQueuedHighlights} = useSearchHighlightAndScroll({
+ const {newTransactions} = useSearchAutoRefetch({
searchResults,
transactions,
previousTransactions,
@@ -240,17 +238,7 @@ function Search({
hasPendingWriteOnMountRef,
skipDeferralOnFocusRef,
rearmTracking,
- } = useSearchSnapshot({queryJSON, searchResults, newSearchResultKeys, transactions, reportActions});
-
- // Mirror `hasQueuedHighlights` into a ref so the post-create-flow `useFocusEffect`
- // (which has empty deps) can read the latest value without re-creating its callback.
- // Used to skip the deferral that would otherwise hide the freshly-added row from
- // FlashList during the RHP dismiss transition, which would prevent the highlight
- // animation from ever firing on it.
- const hasQueuedHighlightsRef = useRef(hasQueuedHighlights);
- useEffect(() => {
- hasQueuedHighlightsRef.current = hasQueuedHighlights;
- }, [hasQueuedHighlights]);
+ } = useSearchSnapshot({queryJSON, searchResults, transactions, reportActions});
// There's a race condition in Onyx which makes it return data from the previous Search, so in addition to checking that the data is loaded
// we also need to check that the searchResults matches the type and status of the current search
@@ -311,14 +299,6 @@ function Search({
return;
}
- // If the highlight hook already queued rows for the post-create animation,
- // skip the skeleton-during-transition defer. Otherwise FlashList stays empty
- // for ~1s while the RHP dismiss transition runs, the row never mounts inside
- // the 300ms highlight window, and `useAnimatedHighlightStyle` never fires.
- if (hasQueuedHighlightsRef.current) {
- return;
- }
-
// Show skeleton while the RHP dismiss animation plays. The transition
// hasn't started yet when useFocusEffect fires (it begins after paint),
// so waitForUpcomingTransition defers until the animation actually ends.
@@ -753,9 +733,8 @@ function Search({
const onLayout = useCallback(() => {
onLayoutBase();
- handleSelectionListScroll(stableSortedData, searchListRef.current);
onContentReady?.();
- }, [onLayoutBase, handleSelectionListScroll, stableSortedData, onContentReady]);
+ }, [onLayoutBase, onContentReady]);
// Must be a ref, not state: cancelNavigationSpans is called during render
// (inside conditional returns), so using setState would trigger infinite re-renders.
@@ -1092,7 +1071,6 @@ function Search({
const isTransactionListView = type !== CONST.SEARCH.DATA_TYPES.CHAT && type !== CONST.SEARCH.DATA_TYPES.TASK && type !== CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT;
const commonViewProps: CommonSearchViewProps = {
- ref: searchListRef,
queryJSON,
data: stableSortedData,
columns: columnsToShow,
diff --git a/src/components/Search/searchViewProps.ts b/src/components/Search/searchViewProps.ts
index 40da64005c12..d11afac6b502 100644
--- a/src/components/Search/searchViewProps.ts
+++ b/src/components/Search/searchViewProps.ts
@@ -4,17 +4,11 @@ import type {ModifiedMouseEvent} from '@libs/Navigation/helpers/openInternalRout
import type {CardList, Transaction} from '@src/types/onyx';
import type React from 'react';
-import type {ForwardedRef} from 'react';
import type {NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle} from 'react-native';
import type {SearchListItem} from './SearchList/ListItem/types';
import type {SearchColumnType, SearchQueryJSON} from './types';
-/** Imperative handle the router uses for highlight-driven scrolling. */
-type SearchListHandle = {
- scrollToIndex: (index: number, animated?: boolean) => void;
-};
-
/**
* Props shared by every dedicated Search view. The router (``) builds these once and spreads
* them into whichever view renders; each view narrows to its own extras on top of this shape.
@@ -47,7 +41,7 @@ type CommonSearchViewProps = {
/** Whether everything has been loaded (gates the fully-checked select-all state). */
hasLoadedAllTransactions?: boolean;
- /** Rows flagged for the post-create highlight animation (feeds BaseSearchList extraData). */
+ /** Newly-added transactions (feeds BaseSearchList extraData and the grouped-view child-snapshot refetch). */
newTransactions: Transaction[];
/** The navigation handler for a row tap (owned by the router). */
@@ -70,9 +64,6 @@ type CommonSearchViewProps = {
/** Outer container style for the list wrapper. */
containerStyle: StyleProp;
-
- /** Imperative handle for highlight-driven scrolling, set by the router. */
- ref?: ForwardedRef;
};
/** Extra props specific to the transaction views (expense/invoice/trip): attendee tracking and card rendering. */
diff --git a/src/hooks/useExpenseActions.ts b/src/hooks/useExpenseActions.ts
index d2fe99385687..423ff74be8e7 100644
--- a/src/hooks/useExpenseActions.ts
+++ b/src/hooks/useExpenseActions.ts
@@ -5,6 +5,7 @@ import {useMoneyReportTransactionThread} from '@components/MoneyReportTransactio
import {useSearchQueryContext, useSearchSelectionActions} from '@components/Search/SearchContext';
import {duplicateReport as duplicateReportAction, duplicateExpenseTransaction as duplicateTransactionAction} from '@libs/actions/IOU/Duplicate';
+import {signalExpenseAddedGrowl} from '@libs/actions/IOU/NavigationHelpers';
import {setupMergeTransactionDataAndNavigate} from '@libs/actions/MergeTransaction';
import {deleteAppReport} from '@libs/actions/Report';
import initSplitExpense from '@libs/actions/SplitExpenses';
@@ -252,11 +253,12 @@ function useExpenseActions({reportID, isReportInSearch = false, backTo, onDuplic
const optimisticIOUReportID = generateReportID();
const activePolicyCategories = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${defaultExpensePolicy?.id}`] ?? {};
+ let lastDuplicateTransactionID: string | undefined;
for (const item of transactionList) {
const existingTransactionID = getExistingTransactionID(item.linkedTrackedExpenseReportAction);
const existingTransactionDraft = existingTransactionID ? transactionDrafts?.[existingTransactionID] : undefined;
- duplicateTransactionAction({
+ const result = duplicateTransactionAction({
transaction: item,
optimisticChatReportID,
optimisticIOUReportID,
@@ -281,7 +283,11 @@ function useExpenseActions({reportID, isReportInSearch = false, backTo, onDuplic
policyTagList,
formatPhoneNumber,
});
+ if (result?.transactionID) {
+ lastDuplicateTransactionID = result.transactionID;
+ }
}
+ signalExpenseAddedGrowl(lastDuplicateTransactionID, CONST.SEARCH.DATA_TYPES.EXPENSE);
};
const addExpenseDropdownOptions = getAddExpenseDropdownOptions({
diff --git a/src/hooks/useSearchAutoRefetch.ts b/src/hooks/useSearchAutoRefetch.ts
new file mode 100644
index 000000000000..ccd1f325686a
--- /dev/null
+++ b/src/hooks/useSearchAutoRefetch.ts
@@ -0,0 +1,218 @@
+import type {TransactionGroupListItemType, TransactionListItemType} from '@components/Search/SearchList/ListItem/types';
+import type {SearchQueryJSON} from '@components/Search/types';
+
+import {search} from '@libs/actions/Search';
+import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute';
+import TransitionTracker from '@libs/Navigation/TransitionTracker';
+import {isReportActionEntry} from '@libs/SearchUIUtils';
+import type {SearchKey} from '@libs/SearchUIUtils';
+
+import CONST from '@src/CONST';
+import 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 useNetwork from './useNetwork';
+
+type UseSearchAutoRefetch = {
+ /** Current Search snapshot */
+ searchResults: OnyxEntry;
+
+ /** Current transactions collection */
+ transactions: OnyxCollection;
+
+ /** Previous transactions collection, compared against `transactions` to detect newly-added ones */
+ previousTransactions: OnyxCollection;
+
+ /** Current report actions collection */
+ reportActions: OnyxCollection;
+
+ /** Previous report actions collection, compared against `reportActions` to detect new entries */
+ previousReportActions: OnyxCollection;
+
+ /** Parsed search query the refetch is issued for */
+ queryJSON: SearchQueryJSON;
+
+ /** Key identifying the current search */
+ searchKey: SearchKey | undefined;
+
+ /** Pagination offset for the refetch request */
+ offset: number;
+
+ /** Whether the refetch should recalculate result totals */
+ shouldCalculateTotals: boolean;
+
+ /** Whether the search is backed by live data (vs. a snapshot) */
+ shouldUseLiveData: boolean;
+};
+
+/**
+ * Hook used to trigger a search when a new transaction or report action is added, so the Search snapshot
+ * reflects freshly-created entries. Also returns the newly-added transactions, which the grouped views use to
+ * refetch each expanded group's child snapshot.
+ */
+function useSearchAutoRefetch({
+ searchResults,
+ transactions,
+ previousTransactions,
+ reportActions,
+ previousReportActions,
+ queryJSON,
+ searchKey,
+ offset,
+ shouldCalculateTotals,
+ shouldUseLiveData,
+}: UseSearchAutoRefetch) {
+ const isFocused = useIsFocused();
+ const {isOffline} = useNetwork();
+ const searchTriggeredRef = useRef(false);
+ const hasPendingSearchRef = useRef(false);
+ const isChat = queryJSON.type === CONST.SEARCH.DATA_TYPES.CHAT;
+
+ const searchResultsData = searchResults?.data;
+
+ const prevTransactionsIDs = Object.keys(previousTransactions ?? {});
+ const newTransactions: Transaction[] = [];
+ if (prevTransactionsIDs.length > 0) {
+ const previousIDs = new Set(prevTransactionsIDs);
+ for (const [id, transaction] of Object.entries(transactions ?? {})) {
+ if (!previousIDs.has(id) && transaction) {
+ newTransactions.push(transaction);
+ }
+ }
+ }
+
+ // Trigger search when a new report action is added while on chat or when a new transaction is added for the other search types.
+ useEffect(() => {
+ const previousTransactionIDsLocal = Object.keys(previousTransactions ?? {});
+ const transactionsIDs = Object.keys(transactions ?? {});
+
+ const reportActionsIDs = Object.values(reportActions ?? {})
+ .map((actions) => Object.keys(actions ?? {}))
+ .flat();
+ const previousReportActionsIDs = Object.values(previousReportActions ?? {})
+ .map((actions) => Object.keys(actions ?? {}))
+ .flat();
+
+ // Only proceed if we have previous data to compare against
+ // This prevents triggering on initial data load
+ if ((previousTransactionIDsLocal.length === 0 && previousReportActionsIDs.length === 0) || searchTriggeredRef.current) {
+ return;
+ }
+
+ const previousTransactionsIDsSet = new Set(previousTransactionIDsLocal);
+ const previousReportActionsIDsSet = new Set(previousReportActionsIDs);
+ const hasTransactionsIDsChange = transactionsIDs.length !== previousTransactionIDsLocal.length || transactionsIDs.some((id) => !previousTransactionsIDsSet.has(id));
+ const hasReportActionsIDsChange = reportActionsIDs.some((id) => !previousReportActionsIDsSet.has(id));
+
+ // Check if there is a change in the transactions or report actions list
+ if ((!isChat && hasTransactionsIDsChange) || hasReportActionsIDsChange || hasPendingSearchRef.current) {
+ // Skip if offline, or if the user has navigated to a different fullscreen page entirely.
+ // An RHP layered on top of Search makes `isFocused` false but keeps Search as the topmost
+ // fullscreen route, so we still want to refetch — otherwise the snapshot can't reflect
+ // entries the user creates from the RHP until they close it.
+ const isSearchStillActive = isFocused || isSearchTopmostFullScreenRoute();
+ if (!isSearchStillActive || isOffline) {
+ hasPendingSearchRef.current = true;
+ return;
+ }
+ hasPendingSearchRef.current = false;
+
+ const newIDs = isChat ? reportActionsIDs : transactionsIDs;
+ let currentSearchResultIDs: string[] = [];
+ if (searchResultsData) {
+ currentSearchResultIDs = isChat ? extractReportActionIDsFromSearchResults(searchResultsData) : extractTransactionIDsFromSearchResults(searchResultsData);
+ }
+ const existingSearchResultIDsSet = new Set(currentSearchResultIDs);
+ const hasAGenuinelyNewID = newIDs.some((id) => !existingSearchResultIDsSet.has(id));
+
+ // Only skip search if there are no new items AND search results aren't empty
+ // This ensures deletions that result in empty data still trigger search
+ if (!hasAGenuinelyNewID && currentSearchResultIDs.length > 0) {
+ const newIDsSet = new Set(newIDs);
+ const hasDeletedID = currentSearchResultIDs.some((id) => !newIDsSet.has(id));
+ if (!hasDeletedID) {
+ return;
+ }
+ }
+
+ // Trigger the search
+ TransitionTracker.runAfterTransitions({
+ callback: () => {
+ search({queryJSON, searchKey, offset, shouldCalculateTotals, isLoading: !!searchResults?.search?.isLoading});
+ },
+ });
+
+ // Set the ref to prevent further triggers until reset
+ searchTriggeredRef.current = true;
+ }
+ }, [
+ isFocused,
+ transactions,
+ previousTransactions,
+ queryJSON,
+ searchKey,
+ offset,
+ shouldCalculateTotals,
+ reportActions,
+ previousReportActions,
+ isChat,
+ searchResultsData,
+ isOffline,
+ searchResults?.search?.isLoading,
+ ]);
+
+ useEffect(() => {
+ // For live data, isLoading is always false, so we also need to reset when searchResultsData changes
+ // For snapshot data, we wait for isLoading to become false after the API call completes
+ if (searchResults?.search?.isLoading) {
+ return;
+ }
+
+ searchTriggeredRef.current = false;
+ }, [searchResults?.search?.isLoading, shouldUseLiveData, searchResultsData]);
+
+ return {newTransactions};
+}
+
+/**
+ * Helper function to extract transaction IDs from search results data.
+ */
+function extractTransactionIDsFromSearchResults(searchResultsData: Partial): string[] {
+ const transactionIDs: string[] = [];
+
+ for (const item of Object.values(searchResultsData)) {
+ // Check for transactionID directly on the item (TransactionListItemType)
+ if ((item as TransactionListItemType)?.transactionID) {
+ transactionIDs.push((item as TransactionListItemType).transactionID);
+ }
+
+ // Check for transactions array within the item (TransactionGroupListItemType)
+ if (Array.isArray((item as TransactionGroupListItemType)?.transactions)) {
+ for (const transaction of (item as TransactionGroupListItemType).transactions) {
+ if (!transaction?.transactionID) {
+ continue;
+ }
+ transactionIDs.push(transaction.transactionID);
+ }
+ }
+ }
+
+ return transactionIDs;
+}
+
+/**
+ * Helper function to extract report action IDs from search results data.
+ */
+function extractReportActionIDsFromSearchResults(searchResultsData: Partial): string[] {
+ return Object.keys(searchResultsData ?? {})
+ .filter(isReportActionEntry)
+ .map((key) => Object.keys(searchResultsData[key] ?? {}))
+ .flat();
+}
+
+export default useSearchAutoRefetch;
+export type {UseSearchAutoRefetch};
diff --git a/src/hooks/useSearchHighlightAndScroll.ts b/src/hooks/useSearchHighlightAndScroll.ts
deleted file mode 100644
index 0f1dd3cbe3b0..000000000000
--- a/src/hooks/useSearchHighlightAndScroll.ts
+++ /dev/null
@@ -1,366 +0,0 @@
-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';
-import type {SearchKey} from '@libs/SearchUIUtils';
-
-import CONST from '@src/CONST';
-import ONYXKEYS from '@src/ONYXKEYS';
-import type {ReportActions, SearchResults, Transaction} from '@src/types/onyx';
-
-import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
-
-import {useIsFocused} from '@react-navigation/native';
-import {useCallback, useEffect, useRef, useState} from 'react';
-
-import useNetwork from './useNetwork';
-import useOnyx from './useOnyx';
-import usePrevious from './usePrevious';
-
-type UseSearchHighlightAndScroll = {
- searchResults: OnyxEntry;
- transactions: OnyxCollection;
- previousTransactions: OnyxCollection;
- reportActions: OnyxCollection;
- previousReportActions: OnyxCollection;
- queryJSON: SearchQueryJSON;
- searchKey: SearchKey | undefined;
- offset: number;
- shouldCalculateTotals: boolean;
- shouldUseLiveData: boolean;
-};
-
-/**
- * Hook used to trigger a search when a new transaction or report action is added and handle highlighting and scrolling.
- */
-function useSearchHighlightAndScroll({
- searchResults,
- transactions,
- previousTransactions,
- reportActions,
- previousReportActions,
- queryJSON,
- searchKey,
- offset,
- shouldCalculateTotals,
- 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 ?? {});
- const newTransactions: Transaction[] = [];
- if (prevTransactionsIDs.length > 0) {
- const previousIDs = new Set(prevTransactionsIDs);
- for (const [id, transaction] of Object.entries(transactions ?? {})) {
- if (!previousIDs.has(id) && transaction) {
- newTransactions.push(transaction);
- }
- }
- }
-
- // Trigger search when a new report action is added while on chat or when a new transaction is added for the other search types.
- useEffect(() => {
- const previousTransactionIDsLocal = Object.keys(previousTransactions ?? {});
- const transactionsIDs = Object.keys(transactions ?? {});
-
- const reportActionsIDs = Object.values(reportActions ?? {})
- .map((actions) => Object.keys(actions ?? {}))
- .flat();
- const previousReportActionsIDs = Object.values(previousReportActions ?? {})
- .map((actions) => Object.keys(actions ?? {}))
- .flat();
-
- // Only proceed if we have previous data to compare against
- // This prevents triggering on initial data load
- if ((previousTransactionIDsLocal.length === 0 && previousReportActionsIDs.length === 0) || searchTriggeredRef.current) {
- return;
- }
-
- const previousTransactionsIDsSet = new Set(previousTransactionIDsLocal);
- const previousReportActionsIDsSet = new Set(previousReportActionsIDs);
- const hasTransactionsIDsChange = transactionsIDs.length !== previousTransactionIDsLocal.length || transactionsIDs.some((id) => !previousTransactionsIDsSet.has(id));
- const hasReportActionsIDsChange = reportActionsIDs.some((id) => !previousReportActionsIDsSet.has(id));
-
- // Check if there is a change in the transactions or report actions list
- if ((!isChat && hasTransactionsIDsChange) || hasReportActionsIDsChange || hasPendingSearchRef.current) {
- // Skip if offline, or if the user has navigated to a different fullscreen page entirely.
- // An RHP layered on top of Search makes `isFocused` false but keeps Search as the topmost
- // fullscreen route, so we still want to refetch — otherwise the snapshot can't reflect
- // entries the user creates from the RHP until they close it.
- const isSearchStillActive = isFocused || isSearchTopmostFullScreenRoute();
- if (!isSearchStillActive || isOffline) {
- hasPendingSearchRef.current = true;
- return;
- }
- hasPendingSearchRef.current = false;
-
- const newIDs = isChat ? reportActionsIDs : transactionsIDs;
- let currentSearchResultIDs: string[] = [];
- if (searchResultsData) {
- currentSearchResultIDs = isChat ? extractReportActionIDsFromSearchResults(searchResultsData) : extractTransactionIDsFromSearchResults(searchResultsData);
- }
- const existingSearchResultIDsSet = new Set(currentSearchResultIDs);
- const hasAGenuinelyNewID = newIDs.some((id) => !existingSearchResultIDsSet.has(id));
-
- // Only skip search if there are no new items AND search results aren't empty
- // This ensures deletions that result in empty data still trigger search
- if (!hasAGenuinelyNewID && currentSearchResultIDs.length > 0) {
- const newIDsSet = new Set(newIDs);
- const hasDeletedID = currentSearchResultIDs.some((id) => !newIDsSet.has(id));
- if (!hasDeletedID) {
- 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({
- callback: () => {
- search({queryJSON, searchKey, offset, shouldCalculateTotals, isLoading: !!searchResults?.search?.isLoading});
- },
- });
-
- // Set the ref to prevent further triggers until reset
- searchTriggeredRef.current = true;
- }
- }, [
- isFocused,
- transactions,
- previousTransactions,
- queryJSON,
- searchKey,
- offset,
- shouldCalculateTotals,
- reportActions,
- previousReportActions,
- isChat,
- searchResultsData,
- isOffline,
- searchResults?.search?.isLoading,
- ]);
-
- useEffect(() => {
- // For live data, isLoading is always false, so we also need to reset when searchResultsData changes
- // For snapshot data, we wait for isLoading to become false after the API call completes
- if (searchResults?.search?.isLoading) {
- return;
- }
-
- searchTriggeredRef.current = false;
- }, [searchResults?.search?.isLoading, shouldUseLiveData, searchResultsData]);
-
- // Initialize the set with existing IDs only once
- useEffect(() => {
- if (initializedRef.current || !searchResultsData) {
- return;
- }
-
- const initialIDs = isChat ? extractReportActionIDsFromSearchResults(searchResultsData) : extractTransactionIDsFromSearchResults(searchResultsData);
- highlightedIDs.current = new Set(initialIDs);
- initializedRef.current = true;
- }, [searchResultsData, isChat]);
-
- // Detect new items (transactions or report actions)
- useEffect(() => {
- if (!previousSearchResults || !searchResults?.data) {
- return;
- }
- if (isChat) {
- const previousReportActionIDs = extractReportActionIDsFromSearchResults(previousSearchResults);
- const currentReportActionIDs = extractReportActionIDsFromSearchResults(searchResults.data);
-
- // Find new report action IDs that are not in the previousReportActionIDs and not already highlighted
- const newReportActionIDs = currentReportActionIDs.filter((id) => !previousReportActionIDs.includes(id) && !highlightedIDs.current.has(id));
-
- if (!triggeredByHookRef.current || newReportActionIDs.length === 0 || !hasNewItemsRef.current) {
- return;
- }
-
- const newKeys = new Set();
- for (const id of newReportActionIDs) {
- const newReportActionKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${id}`;
- highlightedIDs.current.add(newReportActionKey);
- newKeys.add(newReportActionKey);
- }
- setNewSearchResultKeys(newKeys);
- } else {
- const previousTransactionIDs = extractTransactionIDsFromSearchResults(previousSearchResults);
- const currentTransactionIDs = extractTransactionIDsFromSearchResults(searchResults.data);
- const manualHighlightTransactionIDs = new Set(Object.keys(transactionIDsToHighlight ?? {}).filter((id) => !!transactionIDsToHighlight?.[id]));
-
- // Find new transaction IDs that are not in the previousTransactionIDs and not already highlighted
- const newTransactionIDs = currentTransactionIDs.filter((id) => {
- if (manualHighlightTransactionIDs.has(id)) {
- return true;
- }
- if (!triggeredByHookRef.current || !hasNewItemsRef.current) {
- return false;
- }
- return !previousTransactionIDs.includes(id) && !highlightedIDs.current.has(id);
- });
-
- if (newTransactionIDs.length === 0) {
- return;
- }
-
- const newKeys = new Set();
- const consumedManualIDs: string[] = [];
- for (const id of newTransactionIDs) {
- const newTransactionKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${id}`;
- highlightedIDs.current.add(newTransactionKey);
- newKeys.add(newTransactionKey);
- if (manualHighlightTransactionIDs.has(id)) {
- consumedManualIDs.push(id);
- }
- }
- setNewSearchResultKeys(newKeys);
-
- // Clear consumed manual highlight flags so subsequent detect runs don't re-highlight the same IDs.
- if (consumedManualIDs.length > 0) {
- mergeTransactionIdsHighlightOnSearchRoute(queryJSON.type, Object.fromEntries(consumedManualIDs.map((id) => [id, false])));
- }
- }
- }, [searchResults?.data, previousSearchResults, isChat, transactionIDsToHighlight, queryJSON.type]);
-
- // Remove transactionIDsToHighlight when the user leaves the current search type
- useEffect(
- () => () => {
- mergeTransactionIdsHighlightOnSearchRoute(queryJSON.type, null);
- },
- [queryJSON.type],
- );
-
- // Reset newSearchResultKey after it's been used
- useEffect(() => {
- if (newSearchResultKeys === null) {
- return;
- }
-
- const timer = setTimeout(() => {
- setNewSearchResultKeys(null);
- }, CONST.ANIMATED_HIGHLIGHT_START_DURATION);
-
- return () => clearTimeout(timer);
- }, [newSearchResultKeys]);
-
- /**
- * Callback to handle scrolling to the new search result.
- */
- const handleSelectionListScroll = (data: SearchListItem[], ref: SelectionListHandle | null) => {
- // Early return if there's no ref, new transaction wasn't brought in by this hook
- // or there's no new search result key
- const newSearchResultKey = newSearchResultKeys?.values().next().value;
- if (!ref || !triggeredByHookRef.current || !newSearchResultKey) {
- return;
- }
-
- // Extract the transaction/report action ID from the newSearchResultKey
- const newID = newSearchResultKey.replace(isChat ? ONYXKEYS.COLLECTION.REPORT_ACTIONS : ONYXKEYS.COLLECTION.TRANSACTION, '');
-
- // Find the index of the new transaction/report action in the data array
- const indexOfNewItem = data.findIndex((item) => {
- if (isChat) {
- if ('reportActionID' in item && item.reportActionID === newID) {
- return true;
- }
- } else {
- // Handle TransactionListItemType
- if ('transactionID' in item && item.transactionID === newID) {
- return true;
- }
-
- // Handle TransactionGroupListItemType with transactions array
- if ('transactions' in item && Array.isArray(item.transactions)) {
- return item.transactions.some((transaction) => transaction?.transactionID === newID);
- }
- }
-
- return false;
- });
-
- // Early return if the new item is not found in the data array
- if (indexOfNewItem < 0) {
- return;
- }
-
- // 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};
-}
-
-/**
- * Helper function to extract transaction IDs from search results data.
- */
-function extractTransactionIDsFromSearchResults(searchResultsData: Partial): string[] {
- const transactionIDs: string[] = [];
-
- for (const item of Object.values(searchResultsData)) {
- // Check for transactionID directly on the item (TransactionListItemType)
- if ((item as TransactionListItemType)?.transactionID) {
- transactionIDs.push((item as TransactionListItemType).transactionID);
- }
-
- // Check for transactions array within the item (TransactionGroupListItemType)
- if (Array.isArray((item as TransactionGroupListItemType)?.transactions)) {
- for (const transaction of (item as TransactionGroupListItemType).transactions) {
- if (!transaction?.transactionID) {
- continue;
- }
- transactionIDs.push(transaction.transactionID);
- }
- }
- }
-
- return transactionIDs;
-}
-
-/**
- * Helper function to extract report action IDs from search results data.
- */
-function extractReportActionIDsFromSearchResults(searchResultsData: Partial): string[] {
- return Object.keys(searchResultsData ?? {})
- .filter(isReportActionEntry)
- .map((key) => Object.keys(searchResultsData[key] ?? {}))
- .flat();
-}
-
-export default useSearchHighlightAndScroll;
-export type {UseSearchHighlightAndScroll};
diff --git a/src/languages/de.ts b/src/languages/de.ts
index 12b7c710355c..7169869ddc8e 100644
--- a/src/languages/de.ts
+++ b/src/languages/de.ts
@@ -1263,6 +1263,8 @@ 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/en.ts b/src/languages/en.ts
index 71077b5e4e3d..c478235b0603 100644
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -1335,6 +1335,8 @@ const translations = {
createTimeExpense: 'Create time expense',
},
iou: {
+ expenseAdded: 'Expense added',
+ invoiceSent: 'Invoice sent',
amount: 'Amount',
percent: 'Percent',
date: 'Date',
diff --git a/src/languages/es.ts b/src/languages/es.ts
index 77a759d6002e..6ff5521cd97f 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -1230,6 +1230,8 @@ const translations: TranslationDeepObject = {
createTimeExpense: 'Crear gasto de tiempo',
},
iou: {
+ expenseAdded: 'Gasto añadido',
+ invoiceSent: 'Factura enviada',
amount: 'Importe',
percent: 'Porcentaje',
date: 'Fecha',
diff --git a/src/languages/fr.ts b/src/languages/fr.ts
index 30dd15f3657d..cd8dab4377db 100644
--- a/src/languages/fr.ts
+++ b/src/languages/fr.ts
@@ -1267,6 +1267,8 @@ const translations: TranslationDeepObject = {
createTimeExpense: 'Créer une dépense de temps',
},
iou: {
+ expenseAdded: 'Dépense ajoutée',
+ invoiceSent: 'Facture envoyée',
amount: 'Montant',
percent: 'Pourcentage',
date: 'Date',
diff --git a/src/languages/it.ts b/src/languages/it.ts
index 026bca3524f9..b7d843c179fc 100644
--- a/src/languages/it.ts
+++ b/src/languages/it.ts
@@ -1262,6 +1262,8 @@ const translations: TranslationDeepObject = {
createTimeExpense: 'Crea nota spese tempo',
},
iou: {
+ expenseAdded: 'Spesa aggiunta',
+ invoiceSent: 'Fattura inviata',
amount: 'Importo',
percent: 'Percentuale',
date: 'Data',
diff --git a/src/languages/ja.ts b/src/languages/ja.ts
index c09a535f9ecf..63cd72b86870 100644
--- a/src/languages/ja.ts
+++ b/src/languages/ja.ts
@@ -1245,6 +1245,8 @@ const translations: TranslationDeepObject = {
createTimeExpense: '時間経費を作成',
},
iou: {
+ expenseAdded: '経費を追加しました',
+ invoiceSent: '請求書を送信しました',
amount: '金額',
percent: 'パーセント',
date: '日付',
diff --git a/src/languages/nl.ts b/src/languages/nl.ts
index f0660494a63c..4efe0dffb1ab 100644
--- a/src/languages/nl.ts
+++ b/src/languages/nl.ts
@@ -1261,6 +1261,8 @@ const translations: TranslationDeepObject = {
createTimeExpense: 'Tijdkosten aanmaken',
},
iou: {
+ expenseAdded: 'Uitgave toegevoegd',
+ invoiceSent: 'Factuur verzonden',
amount: 'Bedrag',
percent: 'Procent',
date: 'Datum',
diff --git a/src/languages/pl.ts b/src/languages/pl.ts
index f1845aaaa241..737e464fc0ef 100644
--- a/src/languages/pl.ts
+++ b/src/languages/pl.ts
@@ -1257,6 +1257,8 @@ const translations: TranslationDeepObject = {
createTimeExpense: 'Utwórz wydatek czasowy',
},
iou: {
+ expenseAdded: 'Dodano wydatek',
+ invoiceSent: 'Wysłano fakturę',
amount: 'Kwota',
percent: 'Procent',
date: 'Data',
diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts
index abbcf5852162..757dd65878ee 100644
--- a/src/languages/pt-BR.ts
+++ b/src/languages/pt-BR.ts
@@ -1261,6 +1261,8 @@ const translations: TranslationDeepObject = {
createTimeExpense: 'Criar despesa de tempo',
},
iou: {
+ expenseAdded: 'Despesa adicionada',
+ invoiceSent: 'Fatura enviada',
amount: 'Valor',
percent: 'Porcentagem',
date: 'Data',
diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts
index e4bf4e8a6547..1ad4dc796bb7 100644
--- a/src/languages/zh-hans.ts
+++ b/src/languages/zh-hans.ts
@@ -1213,6 +1213,8 @@ const translations: TranslationDeepObject = {
createTimeExpense: '创建工时报销',
},
iou: {
+ expenseAdded: '已添加支出',
+ invoiceSent: '已发送发票',
amount: '金额',
percent: '百分比',
date: '日期',
diff --git a/src/libs/ExportOnyxState/common.ts b/src/libs/ExportOnyxState/common.ts
index 9e2fa5e229a1..20f5d6e743b9 100644
--- a/src/libs/ExportOnyxState/common.ts
+++ b/src/libs/ExportOnyxState/common.ts
@@ -186,6 +186,7 @@ const safeOnyxKeys = new Set([
ONYXKEYS.CURRENCY_LIST,
ONYXKEYS.CURRENT_DATE,
ONYXKEYS.DOMAIN_GROUP_CREATE_PREFERRED_POLICY_ID,
+ ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS,
ONYXKEYS.EXPENSIFY_CARD_STATEMENT,
ONYXKEYS.FULLSCREEN_VISIBILITY,
ONYXKEYS.HAS_DENIED_CONTACT_IMPORT_PROMPT,
@@ -311,7 +312,6 @@ const safeOnyxKeys = new Set([
ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_FAILED,
ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_PENDING,
ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_SUCCESSFUL,
- ONYXKEYS.TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE,
ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS,
ONYXKEYS.TRAVEL_INVOICE_STATEMENT,
ONYXKEYS.VALIDATE_DOMAIN_TWO_FACTOR_CODE,
diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts
index 9cea646bc6c1..33a8d048138c 100644
--- a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts
+++ b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts
@@ -1,8 +1,10 @@
import {addPendingNewTransactionIDs} from '@libs/actions/IOU/PendingNewTransactions';
+import {setActiveTransactionIDs} from '@libs/actions/TransactionThreadNavigation';
import getIsNarrowLayout from '@libs/getIsNarrowLayout';
import Log from '@libs/Log';
import {getPreservedNavigatorState} from '@libs/Navigation/AppNavigator/createSplitNavigator/usePreserveNavigatorState';
import Navigation, {navigationRef} from '@libs/Navigation/Navigation';
+import {getReportTransactions} from '@libs/ReportUtils';
import {buildCannedSearchQuery, getCurrentSearchQueryJSON} from '@libs/SearchQueryUtils';
import {setPendingSubmitFollowUpAction} from '@libs/telemetry/submitFollowUpAction';
@@ -12,16 +14,31 @@ import ROUTES from '@src/ROUTES';
import SCREENS from '@src/SCREENS';
import dismissModalAndOpenReportInInboxTab from './dismissModalAndOpenReportInInboxTab';
+import isReportOpenInRHP from './isReportOpenInRHP';
import isReportTopmostSplitNavigator from './isReportTopmostSplitNavigator';
import isSearchTopmostFullScreenRoute from './isSearchTopmostFullScreenRoute';
+import setNavigationActionToMicrotaskQueue from './setNavigationActionToMicrotaskQueue';
type NavigateAfterExpenseCreateParams = {
+ /** Report the expense was created in */
activeReportID?: string;
+
+ /** The created transaction's ID */
transactionID?: string;
+
+ /** Whether the expense was started from the global create flow (FAB/no existing report) rather than from within a report */
isFromGlobalCreate?: boolean;
+
+ /** Whether the created item is an invoice rather than a regular expense */
isInvoice?: boolean;
+
+ /** Whether the destination report already contains transactions */
hasMultipleTransactions: boolean;
+
+ /** Whether to record the transaction ID in the report's pendingNewTransactionIDs metadata, used to highlight the newly-added row */
shouldAddPendingNewTransactionIDs?: boolean;
+
+ /** Whether to perform navigation, or only run the side effects */
shouldNavigate?: boolean;
};
@@ -33,12 +50,82 @@ function getNavigateAfterCreateSearchNavigatorState() {
return searchNavigatorRoute?.state ?? (searchNavigatorRoute?.key ? getPreservedNavigatorState(searchNavigatorRoute.key) : undefined);
}
+type NavigateToCreatedExpenseParams = {
+ /** The transaction thread report to open. */
+ threadReportID: string;
+
+ /** The created transaction's ID. */
+ transactionID: string;
+
+ /** IOU report the transaction landed in, used to decide whether to stack the expense report underneath. */
+ iouReportID?: string;
+};
+
+/**
+ * Navigates to a just-created expense, choosing the destination from the surface the user
+ * is currently looking at (they may have switched tabs while the growl was up)
+ * - Spend tab: the transaction thread RHP within Spend (report shown underneath via the wide RHP)
+ * - Inbox tab, narrow layout: the transaction thread as a full report view
+ * - Inbox tab, wide layout, with an expense report: the expense report - a multi-transaction report opens super
+ * wide with the specific thread RHP stacked on top; a single-transaction report collapses to the thread itself
+ * - Inbox tab, wide layout, tracked/unreported expense (no expense report): the transaction thread RHP directly
+ */
+function navigateToCreatedExpense({threadReportID, transactionID, iouReportID}: NavigateToCreatedExpenseParams) {
+ const openOnInbox = isReportTopmostSplitNavigator() && !isSearchTopmostFullScreenRoute();
+
+ // When a report/expense is already open in the RHP the app's convention is to replace it rather than stack a second
+ // report RHP on top of it.
+ const forceReplace = isReportOpenInRHP(navigationRef.getRootState());
+ const currentRouteParams = navigationRef.current?.getCurrentRoute()?.params;
+ const currentRouteBackTo =
+ typeof currentRouteParams === 'object' && currentRouteParams !== null && 'backTo' in currentRouteParams && typeof currentRouteParams.backTo === 'string'
+ ? currentRouteParams.backTo
+ : undefined;
+ const backTo = forceReplace ? currentRouteBackTo : Navigation.getActiveRoute();
+
+ if (!openOnInbox) {
+ setActiveTransactionIDs([transactionID]).then(() => {
+ Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: threadReportID, backTo}), {forceReplace});
+ });
+ return;
+ }
+
+ if (getIsNarrowLayout()) {
+ Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(threadReportID, undefined, undefined, backTo), {forceReplace});
+ return;
+ }
+ if (iouReportID) {
+ Navigation.navigate(ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: iouReportID, backTo}), {forceReplace});
+
+ // A multi-transaction report opens super wide (see SearchMoneyRequestReportPage's `shouldShowSuperWideRHP`),
+ // so stack the specific thread RHP on top of it. A single-transaction report collapses to the thread
+ // itself, so the expense report navigation above already lands on it.
+ const hasMultipleReportTransactions =
+ getReportTransactions(iouReportID).filter((transaction) => transaction?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE).length > 1;
+ if (hasMultipleReportTransactions) {
+ // Defer so the thread RHP stacks on top of the expense report navigation above. This is always a
+ // push (never a replace) - it stacks on the report we just opened, not on the previously-open one.
+ setNavigationActionToMicrotaskQueue(() => {
+ setActiveTransactionIDs([transactionID]).then(() => {
+ Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: threadReportID, backTo: Navigation.getActiveRoute()}));
+ });
+ });
+ }
+ return;
+ }
+
+ // Tracked/unreported expense (self-DM): there's no expense report, so open the transaction thread as a full
+ // report - the same way tapping the expense in its self-DM chat does (see ChatTransactionPreview), rather
+ // than the Search-tab RHP.
+ Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(threadReportID, undefined, undefined, backTo), {forceReplace});
+}
+
/**
* Helper to navigate after an expense is created in order to standardize the post‑creation experience
* when creating an expense from the global create button.
* If the expense is created from the global create button then:
* - If it is created on the inbox tab, it will open the chat report containing that expense.
- * - If it is created elsewhere, it will navigate to Reports > Expense and highlight the newly created expense.
+ * - If it is created elsewhere, it will navigate to Reports > Expense and show the "Expense added" growl.
*/
function navigateAfterExpenseCreate({
activeReportID,
@@ -109,3 +196,4 @@ function navigateAfterExpenseCreate({
}
export default navigateAfterExpenseCreate;
+export {navigateToCreatedExpense};
diff --git a/src/libs/actions/IOU/Duplicate.ts b/src/libs/actions/IOU/Duplicate.ts
index 8fc4f5b9e09b..31272bd150b7 100644
--- a/src/libs/actions/IOU/Duplicate.ts
+++ b/src/libs/actions/IOU/Duplicate.ts
@@ -62,6 +62,7 @@ import type {CreateTrackExpenseParams} from './TrackExpense';
import {buildParticipantsPolicyTags, getAllReports, getAllTransactions} from '.';
import {getCleanUpTransactionThreadReportOnyxData} from './DeleteMoneyRequest';
import {getMoneyRequestParticipantsFromReport} from './MoneyRequest';
+import {signalExpenseAddedGrowl} from './NavigationHelpers';
import {submitPerDiemExpense} from './PerDiem';
import {createDistanceRequest} from './Split';
import {requestMoney, trackExpense} from './TrackExpense';
@@ -1181,6 +1182,7 @@ function bulkDuplicateExpenses({
isSubmitAndClose(targetPolicy) &&
(allNonReimbursable || targetPolicy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO);
+ let lastDuplicateTransactionID: string | undefined;
for (let i = 0; i < transactionsToDuplicate.length; i++) {
const item = transactionsToDuplicate.at(i);
if (!item) {
@@ -1247,6 +1249,9 @@ function bulkDuplicateExpenses({
if (result?.iouReport) {
optimisticIOUReport = result.iouReport;
}
+ if (result?.transactionID) {
+ lastDuplicateTransactionID = result.transactionID;
+ }
if (currentTargetReport && !currentTargetReport.iouReportID) {
currentTargetReport = {...currentTargetReport, iouReportID: currentOptimisticIOUReportID};
@@ -1254,6 +1259,7 @@ function bulkDuplicateExpenses({
}
playSound(SOUNDS.DONE);
+ signalExpenseAddedGrowl(lastDuplicateTransactionID, CONST.SEARCH.DATA_TYPES.EXPENSE);
}
type BulkDuplicateReportsParams = {
diff --git a/src/libs/actions/IOU/NavigationHelpers.ts b/src/libs/actions/IOU/NavigationHelpers.ts
index dbbbd2f241cb..d102461956d8 100644
--- a/src/libs/actions/IOU/NavigationHelpers.ts
+++ b/src/libs/actions/IOU/NavigationHelpers.ts
@@ -1,8 +1,6 @@
import sharedDismissModalAndOpenReportInInboxTab from '@libs/Navigation/helpers/dismissModalAndOpenReportInInboxTab';
-import isReportTopmostSplitNavigator from '@libs/Navigation/helpers/isReportTopmostSplitNavigator';
-import navigateAfterExpenseCreate from '@libs/Navigation/helpers/navigateAfterExpenseCreate';
-import {mergeTransactionIdsHighlightOnSearchRoute} from '@userActions/Transaction';
+import {mergeExpenseAddedGrowlTransactionIDs} from '@userActions/Transaction';
import type {SearchDataTypes} from '@src/types/onyx/SearchResults';
@@ -20,40 +18,13 @@ function dismissModalAndOpenReportInInboxTab(reportID?: string, isInvoice?: bool
}
/**
- * Marks a transaction for highlight on the Search page when the expense was created
- * from the global create button and the user is not on the Inbox tab.
+ * Signals the "Expense added" growl for a newly-created transaction.
*/
-function highlightTransactionOnSearchRouteIfNeeded(isFromGlobalCreate: boolean | undefined, transactionID: string | undefined, dataType: SearchDataTypes) {
- if (!isFromGlobalCreate || isReportTopmostSplitNavigator() || !transactionID) {
+function signalExpenseAddedGrowl(transactionID: string | undefined, dataType: SearchDataTypes) {
+ if (!transactionID) {
return;
}
- mergeTransactionIdsHighlightOnSearchRoute(dataType, {[transactionID]: true});
+ mergeExpenseAddedGrowlTransactionIDs({[transactionID]: dataType});
}
-/**
- * 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 {dismissModalAndOpenReportInInboxTab, handleNavigateAfterExpenseCreate, highlightTransactionOnSearchRouteIfNeeded};
+export {dismissModalAndOpenReportInInboxTab, signalExpenseAddedGrowl};
diff --git a/src/libs/actions/IOU/PerDiem.ts b/src/libs/actions/IOU/PerDiem.ts
index 51732ccee083..237e578e3ba5 100644
--- a/src/libs/actions/IOU/PerDiem.ts
+++ b/src/libs/actions/IOU/PerDiem.ts
@@ -68,7 +68,7 @@ import {
mergePolicyRecentlyUsedCategories,
mergePolicyRecentlyUsedCurrencies,
} from './MoneyRequestBuilder';
-import {highlightTransactionOnSearchRouteIfNeeded} from './NavigationHelpers';
+import {signalExpenseAddedGrowl} from './NavigationHelpers';
function removeSubrate(transaction: OnyxEntry, currentIndex: string) {
// Index comes from the route params and is a string
@@ -1051,7 +1051,9 @@ function submitPerDiemExpense(submitPerDiemExpenseInformation: PerDiemExpenseInf
TransitionTracker.runAfterTransitions({callback: () => removeDraftTransaction(CONST.IOU.OPTIMISTIC_TRANSACTION_ID), waitForUpcomingTransition: true});
- highlightTransactionOnSearchRouteIfNeeded(isFromGlobalCreate, transaction.transactionID, CONST.SEARCH.DATA_TYPES.EXPENSE);
+ if (isFromGlobalCreate) {
+ signalExpenseAddedGrowl(transaction.transactionID, CONST.SEARCH.DATA_TYPES.EXPENSE);
+ }
if (activeReportID) {
notifyNewAction(activeReportID, undefined, participantParams.payeeAccountID === currentUserAccountIDParam);
diff --git a/src/libs/actions/IOU/SendInvoice.ts b/src/libs/actions/IOU/SendInvoice.ts
index bae0487c110a..4f75e114ceb8 100644
--- a/src/libs/actions/IOU/SendInvoice.ts
+++ b/src/libs/actions/IOU/SendInvoice.ts
@@ -41,7 +41,7 @@ import type BasePolicyParams from './types/BasePolicyParams';
import {getAllPersonalDetails} from '.';
import {getReceiptError, mergePolicyRecentlyUsedCategories, mergePolicyRecentlyUsedCurrencies} from './MoneyRequestBuilder';
-import {highlightTransactionOnSearchRouteIfNeeded} from './NavigationHelpers';
+import {signalExpenseAddedGrowl} from './NavigationHelpers';
import {getSearchOnyxUpdate} from './SearchUpdate';
type SendInvoiceInformation = {
@@ -840,7 +840,9 @@ function sendInvoice({
onDeferred: () => addOptimization(CONST.TELEMETRY.SUBMIT_OPTIMIZATION.DEFERRED_WRITE),
});
- highlightTransactionOnSearchRouteIfNeeded(isFromGlobalCreate, transactionID, CONST.SEARCH.DATA_TYPES.INVOICE);
+ if (isFromGlobalCreate) {
+ signalExpenseAddedGrowl(transactionID, CONST.SEARCH.DATA_TYPES.INVOICE);
+ }
notifyNewAction(invoiceRoom.reportID, undefined, true);
}
diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts
index 689ce2f0613d..a4b96aaca6d2 100644
--- a/src/libs/actions/IOU/Split.ts
+++ b/src/libs/actions/IOU/Split.ts
@@ -88,7 +88,7 @@ import {
mergePolicyRecentlyUsedCategories,
mergePolicyRecentlyUsedCurrencies,
} from './MoneyRequestBuilder';
-import {dismissModalAndOpenReportInInboxTab, highlightTransactionOnSearchRouteIfNeeded} from './NavigationHelpers';
+import {dismissModalAndOpenReportInInboxTab, signalExpenseAddedGrowl} from './NavigationHelpers';
import {addPendingNewTransactionIDs, isOneToTwoTransactionTransition} from './PendingNewTransactions';
type IOURequestType = ValueOf;
@@ -2270,7 +2270,9 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest
onDeferred: () => addOptimization(CONST.TELEMETRY.SUBMIT_OPTIMIZATION.DEFERRED_WRITE),
});
- highlightTransactionOnSearchRouteIfNeeded(isFromGlobalCreate, parameters.transactionID, CONST.SEARCH.DATA_TYPES.EXPENSE);
+ if (isFromGlobalCreate) {
+ signalExpenseAddedGrowl(parameters.transactionID, CONST.SEARCH.DATA_TYPES.EXPENSE);
+ }
if (!isMoneyRequestReport) {
notifyNewAction(activeReportID, undefined, true);
diff --git a/src/libs/actions/IOU/SplitTransactionUpdate.ts b/src/libs/actions/IOU/SplitTransactionUpdate.ts
index b63a1160807f..d33abdaac0f1 100644
--- a/src/libs/actions/IOU/SplitTransactionUpdate.ts
+++ b/src/libs/actions/IOU/SplitTransactionUpdate.ts
@@ -43,7 +43,6 @@ import {
navigateBackOnDeleteTransaction,
updateOptimisticParentReportAction,
} from '@libs/ReportUtils';
-import {getCurrentSearchQueryJSON} from '@libs/SearchQueryUtils';
import {isTracking, setPendingSubmitFollowUpAction} from '@libs/telemetry/submitFollowUpAction';
import {
getChildTransactions,
@@ -54,7 +53,6 @@ import {
} from '@libs/TransactionUtils';
import {setDeleteTransactionNavigateBackUrl} from '@userActions/Report';
-import {mergeTransactionIdsHighlightOnSearchRoute} from '@userActions/Transaction';
import {removeDraftSplitTransaction} from '@userActions/TransactionEdit';
import CONST from '@src/CONST';
@@ -81,6 +79,7 @@ import {getCleanUpTransactionThreadReportOnyxData} from './DeleteMoneyRequest';
import {getAllReports} from './index';
import {getMoneyRequestParticipantsFromReport} from './MoneyRequest';
import {getMoneyRequestInformation, getReportPreviewAction} from './MoneyRequestBuilder';
+import {signalExpenseAddedGrowl} from './NavigationHelpers';
import {addPendingNewTransactionIDs} from './PendingNewTransactions';
import {getDeleteTrackExpenseInformation} from './TrackExpense';
import {getUpdateMoneyRequestParams} from './UpdateMoneyRequest';
@@ -2066,31 +2065,9 @@ function updateSplitTransactionsFromSplitExpensesFlow(params: UpdateSplitTransac
const targetReportID = params.expenseReport?.reportID ?? String(CONST.DEFAULT_NUMBER_ID);
- // Register newly created split transaction IDs so they briefly highlight on the Search/Spend page.
- // The Search page reads TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE, which highlights matching rows
- // optimistically without waiting for a server re-search. Unlike the auto-detect path in
- // useSearchHighlightAndScroll (skipped while offline), this makes the highlight work offline too.
- // Reverse splits create no new transactions, and existing children are already in the list, so both are skipped.
- function registerSearchRouteHighlight() {
- if (!isSearchPageTopmostFullScreenRoute || isReverseSplitOperation) {
- return;
- }
- const currentSearchType = getCurrentSearchQueryJSON()?.type;
- if (!currentSearchType) {
- return;
- }
- const newTransactionIDsToHighlight: Record = {};
- for (const transactionID of getNewSplitTransactionIDs()) {
- newTransactionIDsToHighlight[transactionID] = true;
- }
- if (isEmptyObject(newTransactionIDsToHighlight)) {
- return;
- }
- mergeTransactionIdsHighlightOnSearchRoute(currentSearchType, newTransactionIDsToHighlight);
- }
+ signalExpenseAddedGrowl(getNewSplitTransactionIDs().at(-1), CONST.SEARCH.DATA_TYPES.EXPENSE);
if (isSearchPageTopmostFullScreenRoute || !params.transactionReport?.parentReportID) {
- registerSearchRouteHighlight();
updateSplitTransactions({...params, isFromSplitExpensesFlow: true});
if (!isSelfDMSplit) {
diff --git a/src/libs/actions/IOU/TrackExpense.ts b/src/libs/actions/IOU/TrackExpense.ts
index b0378ebf0c5f..79b2a02e480f 100644
--- a/src/libs/actions/IOU/TrackExpense.ts
+++ b/src/libs/actions/IOU/TrackExpense.ts
@@ -120,7 +120,7 @@ import type {
import {deleteMoneyRequest, getCleanUpTransactionThreadReportOnyxData, getNavigationUrlOnMoneyRequestDelete} from './DeleteMoneyRequest';
import {getAllReports, getAllTransactionDrafts, getAllTransactions, getAllTransactionViolations} from './index';
import {buildMinimalTransactionForFormula, getMoneyRequestInformation, getReceiptError, getReportPreviewAction, getTransactionWithPreservedLocalReceiptSource} from './MoneyRequestBuilder';
-import {highlightTransactionOnSearchRouteIfNeeded} from './NavigationHelpers';
+import {signalExpenseAddedGrowl} from './NavigationHelpers';
import {addPendingNewTransactionIDs, isOneToTwoTransactionTransition} from './PendingNewTransactions';
import {getSearchOnyxUpdate} from './SearchUpdate';
@@ -1624,7 +1624,7 @@ function convertTrackedExpenseToRequest(convertTrackedExpenseParams: ConvertTrac
/**
* Submit expense to another user
*/
-function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouReport?: OnyxTypes.Report} {
+function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouReport?: OnyxTypes.Report; transactionID?: string} {
const {
report,
existingIOUReport,
@@ -1920,8 +1920,8 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep
});
}
- if (!requestMoneyInformation.isRetry) {
- highlightTransactionOnSearchRouteIfNeeded(isFromGlobalCreate, transaction.transactionID, CONST.SEARCH.DATA_TYPES.EXPENSE);
+ if (!requestMoneyInformation.isRetry && isFromGlobalCreate) {
+ signalExpenseAddedGrowl(transaction.transactionID, CONST.SEARCH.DATA_TYPES.EXPENSE);
}
if (activeReportID && !isMoneyRequestReport) {
@@ -1932,7 +1932,7 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep
);
}
- return {iouReport};
+ return {iouReport, transactionID: transaction.transactionID};
}
/**
@@ -2864,11 +2864,13 @@ function trackExpense(params: CreateTrackExpenseParams) {
}
}
- if (!params.isRetry) {
- highlightTransactionOnSearchRouteIfNeeded(isFromGlobalCreate, transaction?.transactionID, CONST.SEARCH.DATA_TYPES.EXPENSE);
+ if (!params.isRetry && isFromGlobalCreate) {
+ signalExpenseAddedGrowl(transaction?.transactionID, CONST.SEARCH.DATA_TYPES.EXPENSE);
}
notifyNewAction(activeReportID, undefined, payeeAccountID === currentUserAccountIDParam);
+
+ return {iouReport, transactionID: transaction?.transactionID};
}
/**
diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts
index 6e075ccc6327..1bfce8c14493 100644
--- a/src/libs/actions/Transaction.ts
+++ b/src/libs/actions/Transaction.ts
@@ -2052,8 +2052,8 @@ function getDefaultP2PMileageRate() {
API.read(READ_COMMANDS.GET_DEFAULT_P2P_MILEAGE_RATE, null);
}
-function mergeTransactionIdsHighlightOnSearchRoute(type: SearchDataTypes, data: Record | null) {
- return Onyx.merge(ONYXKEYS.TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE, {[type]: data});
+function mergeExpenseAddedGrowlTransactionIDs(data: Record) {
+ return Onyx.merge(ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS, data);
}
function getDuplicateTransactionDetails(transactionID?: string) {
@@ -2090,6 +2090,6 @@ export {
getChangeTransactionsReportOnyxData,
setTransactionReport,
getDefaultP2PMileageRate,
- mergeTransactionIdsHighlightOnSearchRoute,
+ mergeExpenseAddedGrowlTransactionIDs,
getDuplicateTransactionDetails,
};
diff --git a/src/libs/telemetry/submitFollowUpAction.ts b/src/libs/telemetry/submitFollowUpAction.ts
index 8cbb576573cc..bb66919ebdae 100644
--- a/src/libs/telemetry/submitFollowUpAction.ts
+++ b/src/libs/telemetry/submitFollowUpAction.ts
@@ -87,7 +87,7 @@ function isSameFlowUpdate(pending: NonNullable, fol
return true;
}
// The fast path (pre-insert) sets NAVIGATE_TO_SEARCH before createTransaction runs.
- // handleNavigateAfterExpenseCreate may later call with DISMISS_MODAL_ONLY because it
+ // navigateAfterExpenseCreate may later call with DISMISS_MODAL_ONLY because it
// sees the Search page as already on top. Treat this as same-flow - keep the original.
return pending.followUpAction === CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.NAVIGATE_TO_SEARCH && followUpAction === CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.DISMISS_MODAL_ONLY;
}
@@ -105,7 +105,7 @@ function setPendingSubmitFollowUpAction(followUpAction: SubmitFollowUpAction, re
if (pending !== null && span && isSameFlowUpdate(pending, followUpAction, reportID)) {
// Same flow: only update when the new action is a genuine refinement (e.g.
// DISMISS_MODAL_ONLY -> DISMISS_MODAL_AND_OPEN_REPORT). When the fast path set
- // NAVIGATE_TO_SEARCH and handleNavigateAfterExpenseCreate later calls with
+ // NAVIGATE_TO_SEARCH and navigateAfterExpenseCreate later calls with
// DISMISS_MODAL_ONLY (because Search is already on top), preserve the original
// action so telemetry correctly reflects the pre-insert path.
const isRefinement =
diff --git a/src/pages/Share/SubmitDetailsPage.tsx b/src/pages/Share/SubmitDetailsPage.tsx
index c4f8704f5d46..6c0333a765a7 100644
--- a/src/pages/Share/SubmitDetailsPage.tsx
+++ b/src/pages/Share/SubmitDetailsPage.tsx
@@ -28,6 +28,7 @@ import {
setMoneyRequestReimbursable,
updateLastLocationPermissionPrompt,
} from '@libs/actions/IOU/MoneyRequest';
+import {signalExpenseAddedGrowl} from '@libs/actions/IOU/NavigationHelpers';
import {setMoneyRequestReceipt} from '@libs/actions/IOU/Receipt';
import {requestMoney, trackExpense} from '@libs/actions/IOU/TrackExpense';
import type {GPSPoint as GpsPoint} from '@libs/actions/IOU/types/TrackExpenseTransactionParams';
@@ -377,6 +378,10 @@ function SubmitDetailsPage({
delegateAccountID,
});
}
+ // requestMoney/trackExpense only signal the success growl for the global-create flow; the share extension
+ // isn't flagged as such, so signal it directly here with the created transaction's ID.
+ signalExpenseAddedGrowl(optimisticTransactionID, CONST.SEARCH.DATA_TYPES.EXPENSE);
+
cleanupAndNavigateAfterExpenseCreate({
report: isSelfDM(report) ? report : reportToSubmit,
action: CONST.IOU.ACTION.CREATE,
diff --git a/src/setup/index.ts b/src/setup/index.ts
index 7d93c8102157..0af8a29c60aa 100644
--- a/src/setup/index.ts
+++ b/src/setup/index.ts
@@ -57,6 +57,9 @@ export default function () {
// Ensure the Supportal permission modal doesn't persist across reloads
[ONYXKEYS.SUPPORTAL_PERMISSION_DENIED]: null,
[ONYXKEYS.IS_OPEN_APP_FAILURE_MODAL_OPEN]: false,
+ // The "Expense added" growl confirms an expense created this session. Drop any signal left over
+ // from a prior session (e.g. force-quit before it was consumed) so it can't surface on next launch.
+ [ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS]: {},
},
skippableCollectionMemberIDs: CONST.SKIPPABLE_COLLECTION_MEMBER_IDS,
snapshotMergeKeys: ['pendingAction', 'pendingFields'],
diff --git a/tests/actions/IOU/SplitReportTotalsTest.ts b/tests/actions/IOU/SplitReportTotalsTest.ts
index b6b37135863f..e89afe227d0d 100644
--- a/tests/actions/IOU/SplitReportTotalsTest.ts
+++ b/tests/actions/IOU/SplitReportTotalsTest.ts
@@ -1,11 +1,8 @@
-import {handleNavigateAfterExpenseCreate} from '@libs/actions/IOU/NavigationHelpers';
import {addPendingNewTransactionIDs} from '@libs/actions/IOU/PendingNewTransactions';
import '@libs/actions/IOU/MoneyRequest';
import {createSplitsAndOnyxData} from '@libs/actions/IOU/Split';
import {updateSplitTransactionsFromSplitExpensesFlow} from '@libs/actions/IOU/SplitTransactionUpdate';
import initOnyxDerivedValues from '@libs/actions/OnyxDerived';
-import isReportTopmostSplitNavigator from '@libs/Navigation/helpers/isReportTopmostSplitNavigator';
-import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute';
import {rand64} from '@libs/NumberUtils';
import type * as PolicyUtils from '@libs/PolicyUtils';
@@ -433,33 +430,6 @@ describe('actions/IOU', () => {
});
});
- it('handleNavigateAfterExpenseCreate', async () => {
- const mockedIsReportTopmostSplitNavigator = isReportTopmostSplitNavigator as jest.MockedFunction;
- 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'},
@@ -884,71 +854,5 @@ describe('actions/IOU', () => {
// Then nothing is registered — the list navigates away before any highlight could render
expect(addPendingNewTransactionIDs).not.toHaveBeenCalled();
});
-
- it('registers the search-route highlight (not report metadata) when splitting from the Search/Spend page', async () => {
- // Given the user is on the Search (Spend > Expenses) page, where the expense report is never opened
- jest.mocked(isSearchTopmostFullScreenRoute).mockReturnValue(true);
- const spyOnMergeTransactionIdsHighlightOnSearchRoute = jest.spyOn(require('@libs/actions/Transaction'), 'mergeTransactionIdsHighlightOnSearchRoute');
- const params = buildBaseParams({
- transactionData: {
- reportID: EXPENSE_REPORT_ID,
- originalTransactionID: ORIGINAL_TX_ID,
- splitExpenses: [
- {transactionID: 'new-tx-1', reportID: EXPENSE_REPORT_ID, statusNum: 0, amount: 500, created: '2024-01-01'},
- {transactionID: 'new-tx-2', reportID: EXPENSE_REPORT_ID, statusNum: 0, amount: 500, created: '2024-01-01'},
- ],
- splitExpensesTotal: 1000,
- },
- });
-
- // When saving the split from the Search page
- updateSplitTransactionsFromSplitExpensesFlow(params);
- await waitForBatchedUpdates();
-
- // Then the report-metadata highlight is skipped — the report is never mounted, so those flags would
- // never be cleared and would incorrectly highlight rows when the report is later opened from the Inbox.
- expect(addPendingNewTransactionIDs).not.toHaveBeenCalled();
-
- // And instead the new IDs are registered on the search-route highlight, keyed by the current search type.
- // This mechanism highlights optimistically without a server re-search, so it works offline too.
- expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledWith(
- 'expense',
- Object.fromEntries([
- ['new-tx-1', true],
- ['new-tx-2', true],
- ]),
- );
-
- spyOnMergeTransactionIdsHighlightOnSearchRoute.mockRestore();
- });
-
- it('skips the search-route highlight during a reverse split from the Search/Spend page', async () => {
- // Given the user is on the Search page and this is a reverse split (1 expense, existing child present)
- jest.mocked(isSearchTopmostFullScreenRoute).mockReturnValue(true);
- const spyOnMergeTransactionIdsHighlightOnSearchRoute = jest.spyOn(require('@libs/actions/Transaction'), 'mergeTransactionIdsHighlightOnSearchRoute');
- const existingChildTx = {
- transactionID: 'child-tx-1',
- reportID: EXPENSE_REPORT_ID,
- comment: {originalTransactionID: ORIGINAL_TX_ID, source: CONST.IOU.TYPE.SPLIT},
- };
- const params = buildBaseParams({
- allTransactionsList: {[`${ONYXKEYS.COLLECTION.TRANSACTION}child-tx-1`]: existingChildTx},
- transactionData: {
- reportID: EXPENSE_REPORT_ID,
- originalTransactionID: ORIGINAL_TX_ID,
- splitExpenses: [{transactionID: 'new-merged-tx', reportID: EXPENSE_REPORT_ID, statusNum: 0, amount: 1000, created: '2024-01-01'}],
- splitExpensesTotal: 1000,
- },
- });
-
- // When saving the reverse split
- updateSplitTransactionsFromSplitExpensesFlow(params);
- await waitForBatchedUpdates();
-
- // Then nothing is highlighted — reverse splits create no new transactions
- expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).not.toHaveBeenCalled();
-
- spyOnMergeTransactionIdsHighlightOnSearchRoute.mockRestore();
- });
});
});
diff --git a/tests/actions/IOUTest/SplitTest.ts b/tests/actions/IOUTest/SplitTest.ts
index 7e8e434393de..40bcb9b6c0ba 100644
--- a/tests/actions/IOUTest/SplitTest.ts
+++ b/tests/actions/IOUTest/SplitTest.ts
@@ -9032,7 +9032,7 @@ describe('createDistanceRequest', () => {
expect(result.transactionID).toBeTruthy();
});
- it('flags the created transaction for Search highlight on a global-create distance request', async () => {
+ it('flags the created transaction for the "Expense added" growl on a global-create distance request', async () => {
const recentWaypoints = (await getOnyxValue(ONYXKEYS.NVP_RECENT_WAYPOINTS)) ?? [];
const result = createDistanceRequest({
@@ -9042,8 +9042,8 @@ describe('createDistanceRequest', () => {
await waitForBatchedUpdates();
- const highlight = await getOnyxValue(ONYXKEYS.TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE);
- expect(Object.keys(highlight?.[CONST.SEARCH.DATA_TYPES.EXPENSE] ?? {})).toContain(result.transactionID);
+ const growlSignal = await getOnyxValue(ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS);
+ expect(growlSignal?.[result.transactionID]).toBe(CONST.SEARCH.DATA_TYPES.EXPENSE);
});
it('flags the new transaction for the 1→2 highlight fallback when added to an expense report that already has one transaction', async () => {
diff --git a/tests/unit/ExpenseAddedGrowlTest.tsx b/tests/unit/ExpenseAddedGrowlTest.tsx
new file mode 100644
index 000000000000..d67b3f9b4688
--- /dev/null
+++ b/tests/unit/ExpenseAddedGrowlTest.tsx
@@ -0,0 +1,136 @@
+import {act, render} from '@testing-library/react-native';
+
+import ExpenseAddedGrowl from '@components/ExpenseAddedGrowl';
+
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {SearchDataTypes} from '@src/types/onyx/SearchResults';
+
+import Onyx from 'react-native-onyx';
+
+import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
+
+type GrowlContentProps = {bodyText: string; type: string};
+
+const mockGetTopmostReportId = jest.fn();
+
+const mockGrowlContent = jest.fn();
+jest.mock('@components/GrowlNotification/GrowlNotificationContent', () => (props: GrowlContentProps) => {
+ mockGrowlContent(props);
+ return null;
+});
+
+jest.mock('@libs/Navigation/Navigation', () => ({
+ getTopmostReportId: () => mockGetTopmostReportId(),
+ getActiveRoute: () => '',
+}));
+jest.mock('@libs/Navigation/helpers/navigateAfterExpenseCreate', () => ({
+ navigateToCreatedExpense: jest.fn(),
+}));
+jest.mock('@libs/actions/Report', () => ({
+ createTransactionThreadReport: jest.fn(),
+ setOptimisticTransactionThread: jest.fn(),
+}));
+jest.mock('@hooks/useLocalize', () => () => ({translate: (key: string) => key}));
+jest.mock('@hooks/useCurrentUserPersonalDetails', () => () => ({accountID: 1, login: 'me@example.com'}));
+
+const EXPENSE = CONST.SEARCH.DATA_TYPES.EXPENSE;
+const INVOICE = CONST.SEARCH.DATA_TYPES.INVOICE;
+
+/** Run Onyx mutations and let the growl's effect settle, wrapped in act() so React state updates aren't flagged. */
+function flush(mutate: () => Promise) {
+ return act(async () => {
+ await mutate();
+ await waitForBatchedUpdates();
+ });
+}
+
+/** Seed the signal + the created transaction so the growl can capture and (given the deferred-write gate) show. */
+function seedExpense(transactionID: string, reportID: string, dataType: SearchDataTypes = EXPENSE) {
+ return flush(async () => {
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {transactionID, reportID});
+ await Onyx.merge(ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS, {[transactionID]: dataType});
+ });
+}
+
+/** The props of the most recently rendered growl, or undefined if it never rendered. */
+function lastGrowlProps() {
+ return mockGrowlContent.mock.calls.at(-1)?.[0];
+}
+
+describe('ExpenseAddedGrowl', () => {
+ beforeAll(() => {
+ Onyx.init({keys: ONYXKEYS});
+ });
+
+ beforeEach(async () => {
+ jest.clearAllMocks();
+ mockGetTopmostReportId.mockReturnValue(undefined);
+ await Onyx.clear();
+ await waitForBatchedUpdates();
+ });
+
+ it('shows the "Expense added" growl for a pending transaction when the user is not viewing its report', async () => {
+ render();
+ await seedExpense('1', 'report-1');
+
+ expect(mockGrowlContent).toHaveBeenCalled();
+ expect(lastGrowlProps()?.bodyText).toBe('iou.expenseAdded');
+ expect(lastGrowlProps()?.type).toBe(CONST.GROWL.SUCCESS);
+ });
+
+ it('uses the invoice copy for an invoice', async () => {
+ render();
+ await seedExpense('1', 'report-1', INVOICE);
+
+ expect(lastGrowlProps()?.bodyText).toBe('iou.invoiceSent');
+ });
+
+ it("suppresses the growl when the user is already viewing the expense's report", async () => {
+ mockGetTopmostReportId.mockReturnValue('report-1');
+ render();
+ await seedExpense('1', 'report-1');
+
+ expect(mockGrowlContent).not.toHaveBeenCalled();
+ });
+
+ it('still shows for a tracked/unreported (self-DM) expense even when a report is open, since its reportID is UNREPORTED', async () => {
+ mockGetTopmostReportId.mockReturnValue('some-open-report');
+ render();
+ await seedExpense('1', CONST.REPORT.UNREPORTED_REPORT_ID);
+
+ expect(mockGrowlContent).toHaveBeenCalled();
+ });
+
+ it('shows a single growl for a batch of new expenses and clears the whole signal', async () => {
+ render();
+ await flush(async () => {
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}1`, {transactionID: '1', reportID: 'report-1'});
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}2`, {transactionID: '2', reportID: 'report-1'});
+ const signal: Record = {};
+ signal['1'] = EXPENSE;
+ signal['2'] = EXPENSE;
+ await Onyx.merge(ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS, signal);
+ });
+
+ // Exactly one growl shown for the batch...
+ expect(mockGrowlContent).toHaveBeenCalledTimes(1);
+
+ // ...and the signal is consumed so it can't re-fire.
+ const remaining = await new Promise | undefined>((resolve) => {
+ const connection = Onyx.connect({
+ key: ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS,
+ callback: (value) => {
+ Onyx.disconnect(connection);
+ resolve(value);
+ },
+ });
+ });
+ expect(remaining ?? {}).toEqual({});
+ });
+
+ it('does not show a growl when there is no pending signal', () => {
+ render();
+ expect(mockGrowlContent).not.toHaveBeenCalled();
+ });
+});
diff --git a/tests/unit/Search/useSearchSnapshotTest.ts b/tests/unit/Search/useSearchSnapshotTest.ts
index a2c0b2531f1f..a482fced92e0 100644
--- a/tests/unit/Search/useSearchSnapshotTest.ts
+++ b/tests/unit/Search/useSearchSnapshotTest.ts
@@ -179,7 +179,6 @@ describe('useSearchSnapshot', () => {
useSearchSnapshot({
queryJSON: makeQueryJSON(),
searchResults,
- newSearchResultKeys: undefined,
transactions: undefined,
reportActions: undefined,
}),
@@ -205,7 +204,6 @@ describe('useSearchSnapshot', () => {
useSearchSnapshot({
queryJSON: makeQueryJSON(),
searchResults: undefined,
- newSearchResultKeys: undefined,
transactions: undefined,
reportActions: undefined,
}),
@@ -224,7 +222,6 @@ describe('useSearchSnapshot', () => {
useSearchSnapshot({
queryJSON: makeQueryJSON(),
searchResults,
- newSearchResultKeys: undefined,
transactions: undefined,
reportActions: undefined,
}),
@@ -244,7 +241,6 @@ describe('useSearchSnapshot', () => {
useSearchSnapshot({
queryJSON: makeQueryJSON(),
searchResults,
- newSearchResultKeys: undefined,
transactions: undefined,
reportActions: undefined,
}),
@@ -267,7 +263,6 @@ describe('useSearchSnapshot', () => {
groupBy: CONST.SEARCH.GROUP_BY.FROM,
}),
searchResults,
- newSearchResultKeys: undefined,
transactions: undefined,
reportActions: undefined,
}),
@@ -288,7 +283,6 @@ describe('useSearchSnapshot', () => {
useSearchSnapshot({
queryJSON: makeQueryJSON(),
searchResults,
- newSearchResultKeys: undefined,
transactions: undefined,
reportActions: undefined,
}),
@@ -313,7 +307,6 @@ describe('useSearchSnapshot', () => {
useSearchSnapshot({
queryJSON: makeQueryJSON(),
searchResults,
- newSearchResultKeys: undefined,
transactions: undefined,
reportActions: undefined,
}),
@@ -341,7 +334,6 @@ describe('useSearchSnapshot', () => {
useSearchSnapshot({
queryJSON: makeQueryJSON(),
searchResults,
- newSearchResultKeys: undefined,
transactions: undefined,
reportActions: undefined,
}),
@@ -351,24 +343,6 @@ describe('useSearchSnapshot', () => {
expect(result.current.hasCachedOptimisticItem).toBe(true);
});
- it('stamps the post-create highlight on matching rows (newSearchResultKeys)', () => {
- const searchResults = makeSearchResults();
- mockUseOptimisticSearchTracking.mockReturnValue(trackingReturn(searchResults.data));
- mockGetSortedSections.mockReturnValue([{transactionID: '7', keyForList: '7'}]);
-
- const {result} = renderHook(() =>
- useSearchSnapshot({
- queryJSON: makeQueryJSON(),
- searchResults,
- newSearchResultKeys: new Set([`${ONYXKEYS.COLLECTION.TRANSACTION}7`]),
- transactions: undefined,
- reportActions: undefined,
- }),
- );
-
- expect(result.current.chartData.at(0)).toEqual(expect.objectContaining({shouldAnimateInHighlight: true}));
- });
-
it('passes the query type through to getSortedSections for each variant shape', () => {
const variants = [
CONST.SEARCH.DATA_TYPES.CHAT,
@@ -386,7 +360,6 @@ describe('useSearchSnapshot', () => {
useSearchSnapshot({
queryJSON: makeQueryJSON({type}),
searchResults,
- newSearchResultKeys: undefined,
transactions: undefined,
reportActions: undefined,
}),
@@ -414,7 +387,6 @@ describe('useSearchSnapshot', () => {
const props = {
queryJSON: makeQueryJSON(),
searchResults,
- newSearchResultKeys: undefined,
transactions: undefined,
reportActions: undefined,
};
diff --git a/tests/unit/navigateAfterExpenseCreateTest.ts b/tests/unit/navigateAfterExpenseCreateTest.ts
index b7da2c18731e..da1dbd2deeb2 100644
--- a/tests/unit/navigateAfterExpenseCreateTest.ts
+++ b/tests/unit/navigateAfterExpenseCreateTest.ts
@@ -1,8 +1,11 @@
-import navigateAfterExpenseCreate from '@libs/Navigation/helpers/navigateAfterExpenseCreate';
+import navigateAfterExpenseCreate, {navigateToCreatedExpense} from '@libs/Navigation/helpers/navigateAfterExpenseCreate';
import Navigation from '@libs/Navigation/Navigation';
import CONST from '@src/CONST';
import ROUTES from '@src/ROUTES';
+import type {Transaction} from '@src/types/onyx';
+
+import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
const mockIsReportTopmostSplitNavigator = jest.fn();
const mockIsSearchTopmostFullScreenRoute = jest.fn();
@@ -12,6 +15,7 @@ const mockGetTrackingState = jest.fn();
// Declared but assigned after jest.mock hoisting - use require() to access the mock in tests
let mockSetPendingSubmitFollowUpAction: jest.Mock;
const mockGetCurrentSearchQueryJSON = jest.fn();
+const mockGetReportTransactions = jest.fn>, [string | undefined]>();
jest.mock('@libs/Navigation/helpers/isReportTopmostSplitNavigator', () => () => mockIsReportTopmostSplitNavigator() as boolean);
jest.mock('@libs/Navigation/helpers/isSearchTopmostFullScreenRoute', () => () => mockIsSearchTopmostFullScreenRoute() as boolean);
@@ -30,6 +34,15 @@ jest.mock('@libs/SearchQueryUtils', () => ({
buildCannedSearchQuery: jest.fn(({type}: {type: string}) => `type:${type}`),
getCurrentSearchQueryJSON: () => mockGetCurrentSearchQueryJSON() as undefined,
}));
+jest.mock('@libs/ReportUtils', () => {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- partial mock of the real module
+ const actual = jest.requireActual('@libs/ReportUtils');
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-return -- spread the real module and override one export
+ return {...actual, __esModule: true, getReportTransactions: (reportID: string | undefined) => mockGetReportTransactions(reportID)};
+});
+jest.mock('@libs/actions/TransactionThreadNavigation', () => ({
+ setActiveTransactionIDs: jest.fn(() => Promise.resolve()),
+}));
jest.mock('@libs/Navigation/Navigation', () => ({
dismissModal: jest.fn(),
@@ -37,6 +50,7 @@ jest.mock('@libs/Navigation/Navigation', () => ({
dismissModalWithReport: jest.fn(),
pop: jest.fn(),
navigate: jest.fn(),
+ getActiveRoute: jest.fn(() => ''),
revealRouteBeforeDismissingModal: jest.fn(),
isNavigationReady: jest.fn(() => Promise.resolve()),
getIsFullscreenPreInsertedUnderRHP: jest.fn(() => false),
@@ -64,6 +78,7 @@ describe('navigateAfterExpenseCreate', () => {
mockIsReportOpenInRHP.mockReturnValue(false);
mockGetTrackingState.mockReturnValue(null);
mockGetCurrentSearchQueryJSON.mockReturnValue(undefined);
+ mockGetReportTransactions.mockReturnValue([]);
});
it('should dismiss to report when not from global create', () => {
@@ -158,4 +173,87 @@ describe('navigateAfterExpenseCreate', () => {
expect(Navigation.dismissModal).toHaveBeenCalled();
expect(Navigation.navigate).not.toHaveBeenCalled();
});
+
+ describe('navigateToCreatedExpense', () => {
+ it('should open the transaction thread in the Spend RHP when the user is on the Spend tab', async () => {
+ mockIsReportTopmostSplitNavigator.mockReturnValue(false);
+ mockIsSearchTopmostFullScreenRoute.mockReturnValue(true);
+
+ navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'});
+ await waitForBatchedUpdates();
+
+ expect(Navigation.navigate).toHaveBeenCalledTimes(1);
+ expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SEARCH_REPORT.getRoute({reportID: 'thread-1', backTo: ''}), {forceReplace: false});
+ });
+
+ it('should replace the currently-open report instead of stacking when one is already open in the RHP', async () => {
+ mockIsReportTopmostSplitNavigator.mockReturnValue(false);
+ mockIsSearchTopmostFullScreenRoute.mockReturnValue(true);
+ mockIsReportOpenInRHP.mockReturnValue(true);
+
+ navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'});
+ await waitForBatchedUpdates();
+
+ expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SEARCH_REPORT.getRoute({reportID: 'thread-1', backTo: ''}), {forceReplace: true});
+ });
+
+ it('should open the transaction thread as a full report when the user is on the Inbox tab on a narrow layout', () => {
+ mockIsReportTopmostSplitNavigator.mockReturnValue(true);
+ mockIsSearchTopmostFullScreenRoute.mockReturnValue(false);
+ mockGetIsNarrowLayout.mockReturnValue(true);
+
+ navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'});
+
+ expect(Navigation.navigate).toHaveBeenCalledTimes(1);
+ expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute('thread-1', undefined, undefined, ''), {forceReplace: false});
+ });
+
+ it('should open the expense report then stack the thread RHP when the user is on the Inbox tab on a wide layout and the report has multiple transactions', async () => {
+ mockIsReportTopmostSplitNavigator.mockReturnValue(true);
+ mockIsSearchTopmostFullScreenRoute.mockReturnValue(false);
+ mockGetIsNarrowLayout.mockReturnValue(false);
+ mockGetReportTransactions.mockReturnValue([{transactionID: 'txn-1'}, {transactionID: 'txn-2'}]);
+
+ navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'});
+ await waitForBatchedUpdates();
+
+ expect(Navigation.navigate).toHaveBeenNthCalledWith(1, ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: 'iou-1', backTo: ''}), {forceReplace: false});
+ expect(Navigation.navigate).toHaveBeenNthCalledWith(2, ROUTES.SEARCH_REPORT.getRoute({reportID: 'thread-1', backTo: ''}));
+ });
+
+ it('should open the expense report when the user is on the Inbox tab on a wide layout and the report has a single transaction', () => {
+ mockIsReportTopmostSplitNavigator.mockReturnValue(true);
+ mockIsSearchTopmostFullScreenRoute.mockReturnValue(false);
+ mockGetIsNarrowLayout.mockReturnValue(false);
+ mockGetReportTransactions.mockReturnValue([{transactionID: 'txn-1'}]);
+
+ navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'});
+
+ expect(Navigation.navigate).toHaveBeenCalledTimes(1);
+ expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: 'iou-1', backTo: ''}), {forceReplace: false});
+ });
+
+ it('should open the transaction thread as a full report when there is no expense report (tracked/unreported self-DM expense) on the Inbox tab', () => {
+ mockIsReportTopmostSplitNavigator.mockReturnValue(true);
+ mockIsSearchTopmostFullScreenRoute.mockReturnValue(false);
+ mockGetIsNarrowLayout.mockReturnValue(false);
+
+ navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: undefined});
+
+ // Matches how tapping the expense in its self-DM chat opens it (full report), not the Spend RHP.
+ expect(Navigation.navigate).toHaveBeenCalledTimes(1);
+ expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute('thread-1', undefined, undefined, ''), {forceReplace: false});
+ });
+
+ it('should open the transaction thread in the Spend RHP for a tracked/unreported expense when the user is on the Spend tab', async () => {
+ mockIsReportTopmostSplitNavigator.mockReturnValue(false);
+ mockIsSearchTopmostFullScreenRoute.mockReturnValue(true);
+
+ navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: undefined});
+ await waitForBatchedUpdates();
+
+ expect(Navigation.navigate).toHaveBeenCalledTimes(1);
+ expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SEARCH_REPORT.getRoute({reportID: 'thread-1', backTo: ''}), {forceReplace: false});
+ });
+ });
});
diff --git a/tests/unit/useSearchAutoRefetchTest.ts b/tests/unit/useSearchAutoRefetchTest.ts
new file mode 100644
index 000000000000..00422ab97ac4
--- /dev/null
+++ b/tests/unit/useSearchAutoRefetchTest.ts
@@ -0,0 +1,239 @@
+/* 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 {search} from '@libs/actions/Search';
+
+import ONYXKEYS from '@src/ONYXKEYS';
+
+import Onyx from 'react-native-onyx';
+
+jest.mock('@libs/actions/Search');
+jest.mock('@react-navigation/native', () => ({
+ useIsFocused: jest.fn(() => true),
+ createNavigationContainerRef: () => ({}),
+}));
+jest.mock('@rnmapbox/maps', () => ({
+ __esModule: true,
+ default: {},
+ MarkerView: {},
+ setAccessToken: jest.fn(),
+}));
+
+const mockUseIsFocused = jest.fn().mockReturnValue(true);
+
+afterEach(() => {
+ jest.clearAllMocks();
+});
+
+describe('useSearchAutoRefetch', () => {
+ beforeAll(async () => {
+ Onyx.init({
+ keys: ONYXKEYS,
+ });
+ });
+
+ const baseProps: UseSearchAutoRefetch = {
+ shouldUseLiveData: false,
+ searchResults: {
+ data: {
+ personalDetailsList: {},
+ },
+ search: {
+ hasMoreResults: false,
+ hasResults: true,
+ offset: 0,
+ hash: 0,
+ sortBy: 'date',
+ sortOrder: 'desc',
+ type: 'expense',
+ isLoading: false,
+ },
+ },
+ transactions: {},
+ previousTransactions: {},
+ reportActions: {},
+ previousReportActions: {},
+ queryJSON: {
+ type: 'expense',
+ sortBy: 'date',
+ sortOrder: 'desc',
+ filters: {operator: 'and', left: 'tag', right: ''},
+ inputQuery: 'type:expense',
+ flatFilters: [],
+ hash: 123,
+ recentSearchHash: 456,
+ similarSearchHash: 789,
+ view: 'table',
+ },
+ searchKey: undefined,
+ shouldCalculateTotals: false,
+ offset: 0,
+ };
+
+ it('should not trigger search when collections are empty', () => {
+ renderHook(() => useSearchAutoRefetch(baseProps));
+ expect(search).not.toHaveBeenCalled();
+ });
+
+ it('should trigger search when new transaction added and focused', () => {
+ const initialProps = {
+ ...baseProps,
+ transactions: {'1': {transactionID: '1'}},
+ previousTransactions: {'1': {transactionID: '1'}},
+ };
+
+ const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), {
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-expect-error
+ initialProps,
+ });
+
+ const updatedProps = {
+ ...baseProps,
+ transactions: {
+ '1': {transactionID: '1'},
+ '2': {transactionID: '2'},
+ },
+ previousTransactions: {'1': {transactionID: '1'}},
+ };
+
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-expect-error
+ rerender(updatedProps);
+ expect(search).toHaveBeenCalledWith({queryJSON: baseProps.queryJSON, searchKey: undefined, offset: 0, shouldCalculateTotals: false, isLoading: false});
+ });
+
+ it('should not trigger search when not focused', () => {
+ mockUseIsFocused.mockReturnValue(false);
+
+ const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), {
+ initialProps: baseProps,
+ });
+
+ const updatedProps = {
+ ...baseProps,
+ transactions: {'1': {transactionID: '1'}},
+ };
+
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-expect-error
+ rerender(updatedProps);
+ expect(search).not.toHaveBeenCalled();
+ });
+
+ it('should trigger search for chat when report actions added and focused', () => {
+ mockUseIsFocused.mockReturnValue(true);
+
+ const chatProps = {
+ ...baseProps,
+ queryJSON: {...baseProps.queryJSON, type: 'chat' as const},
+ reportActions: {
+ reportActions_1: {
+ '1': {actionName: 'EXISTING', reportActionID: '1'},
+ },
+ },
+ previousReportActions: {
+ reportActions_1: {
+ '1': {actionName: 'EXISTING', reportActionID: '1'},
+ },
+ },
+ };
+
+ const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), {
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-expect-error
+ initialProps: chatProps,
+ });
+
+ const updatedProps = {
+ ...chatProps,
+ reportActions: {
+ reportActions_1: {
+ '1': {actionName: 'EXISTING', reportActionID: '1'},
+ '2': {actionName: 'ADDCOMMENT', reportActionID: '2'},
+ },
+ },
+ };
+
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-expect-error
+ rerender(updatedProps);
+ expect(search).toHaveBeenCalledWith({queryJSON: chatProps.queryJSON, searchKey: undefined, offset: 0, shouldCalculateTotals: false, isLoading: false});
+ });
+
+ it('should not trigger search when new transaction removed and focused', () => {
+ const initialProps = {
+ ...baseProps,
+ transactions: {
+ '1': {transactionID: '1'},
+ '2': {transactionID: '2'},
+ },
+ previousTransactions: {
+ '1': {transactionID: '1'},
+ '2': {transactionID: '2'},
+ },
+ };
+
+ const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), {
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-expect-error
+ initialProps,
+ });
+
+ const updatedProps = {
+ ...baseProps,
+ transactions: {
+ '1': {transactionID: '1'},
+ },
+ };
+
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-expect-error
+ rerender(updatedProps);
+ expect(search).not.toHaveBeenCalled();
+ });
+
+ it('should not trigger search for chat when report actions removed and focused', () => {
+ mockUseIsFocused.mockReturnValue(true);
+
+ const chatProps = {
+ ...baseProps,
+ queryJSON: {...baseProps.queryJSON, type: 'chat' as const},
+ reportActions: {
+ reportActions_1: {
+ '1': {actionName: 'EXISTING', reportActionID: '1'},
+ '2': {actionName: 'ADDCOMMENT', reportActionID: '2'},
+ },
+ },
+ previousReportActions: {
+ reportActions_1: {
+ '1': {actionName: 'EXISTING', reportActionID: '1'},
+ '2': {actionName: 'ADDCOMMENT', reportActionID: '2'},
+ },
+ },
+ };
+
+ const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), {
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-expect-error
+ initialProps: chatProps,
+ });
+
+ const updatedProps = {
+ ...chatProps,
+ reportActions: {
+ reportActions_1: {
+ '1': {actionName: 'EXISTING', reportActionID: '1'},
+ },
+ },
+ };
+
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-expect-error
+ rerender(updatedProps);
+ expect(search).not.toHaveBeenCalled();
+ });
+});
diff --git a/tests/unit/useSearchHighlightAndScrollTest.ts b/tests/unit/useSearchHighlightAndScrollTest.ts
deleted file mode 100644
index 79fee85c4747..000000000000
--- a/tests/unit/useSearchHighlightAndScrollTest.ts
+++ /dev/null
@@ -1,410 +0,0 @@
-/* eslint-disable @typescript-eslint/naming-convention */
-import {renderHook} from '@testing-library/react-native';
-
-import useSearchHighlightAndScroll from '@hooks/useSearchHighlightAndScroll';
-import type {UseSearchHighlightAndScroll} from '@hooks/useSearchHighlightAndScroll';
-
-import {search} from '@libs/actions/Search';
-
-import ONYXKEYS from '@src/ONYXKEYS';
-
-import Onyx from 'react-native-onyx';
-
-jest.mock('@libs/actions/Search');
-jest.mock('@react-navigation/native', () => ({
- useIsFocused: jest.fn(() => true),
- createNavigationContainerRef: () => ({}),
-}));
-jest.mock('@rnmapbox/maps', () => ({
- __esModule: true,
- default: {},
- MarkerView: {},
- setAccessToken: jest.fn(),
-}));
-
-const mockUseIsFocused = jest.fn().mockReturnValue(true);
-
-afterEach(() => {
- jest.clearAllMocks();
-});
-
-describe('useSearchHighlightAndScroll', () => {
- beforeAll(async () => {
- Onyx.init({
- keys: ONYXKEYS,
- });
- });
-
- const baseProps: UseSearchHighlightAndScroll = {
- shouldUseLiveData: false,
- searchResults: {
- data: {
- personalDetailsList: {},
- },
- search: {
- hasMoreResults: false,
- hasResults: true,
- offset: 0,
- hash: 0,
- sortBy: 'date',
- sortOrder: 'desc',
- type: 'expense',
- isLoading: false,
- },
- },
- transactions: {},
- previousTransactions: {},
- reportActions: {},
- previousReportActions: {},
- queryJSON: {
- type: 'expense',
- sortBy: 'date',
- sortOrder: 'desc',
- filters: {operator: 'and', left: 'tag', right: ''},
- inputQuery: 'type:expense',
- flatFilters: [],
- hash: 123,
- recentSearchHash: 456,
- similarSearchHash: 789,
- view: 'table',
- },
- searchKey: undefined,
- shouldCalculateTotals: false,
- offset: 0,
- };
-
- it('should not trigger search when collections are empty', () => {
- renderHook(() => useSearchHighlightAndScroll(baseProps));
- expect(search).not.toHaveBeenCalled();
- });
-
- it('should trigger search when new transaction added and focused', () => {
- const initialProps = {
- ...baseProps,
- transactions: {'1': {transactionID: '1'}},
- previousTransactions: {'1': {transactionID: '1'}},
- };
-
- const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), {
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-expect-error
- initialProps,
- });
-
- const updatedProps = {
- ...baseProps,
- transactions: {
- '1': {transactionID: '1'},
- '2': {transactionID: '2'},
- },
- previousTransactions: {'1': {transactionID: '1'}},
- };
-
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-expect-error
- rerender(updatedProps);
- expect(search).toHaveBeenCalledWith({queryJSON: baseProps.queryJSON, searchKey: undefined, offset: 0, shouldCalculateTotals: false, isLoading: false});
- });
-
- it('should not trigger search when not focused', () => {
- mockUseIsFocused.mockReturnValue(false);
-
- const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), {
- initialProps: baseProps,
- });
-
- const updatedProps = {
- ...baseProps,
- transactions: {'1': {transactionID: '1'}},
- };
-
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-expect-error
- rerender(updatedProps);
- expect(search).not.toHaveBeenCalled();
- });
-
- it('should trigger search for chat when report actions added and focused', () => {
- mockUseIsFocused.mockReturnValue(true);
-
- const chatProps = {
- ...baseProps,
- queryJSON: {...baseProps.queryJSON, type: 'chat' as const},
- reportActions: {
- reportActions_1: {
- '1': {actionName: 'EXISTING', reportActionID: '1'},
- },
- },
- previousReportActions: {
- reportActions_1: {
- '1': {actionName: 'EXISTING', reportActionID: '1'},
- },
- },
- };
-
- const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), {
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-expect-error
- initialProps: chatProps,
- });
-
- const updatedProps = {
- ...chatProps,
- reportActions: {
- reportActions_1: {
- '1': {actionName: 'EXISTING', reportActionID: '1'},
- '2': {actionName: 'ADDCOMMENT', reportActionID: '2'},
- },
- },
- };
-
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-expect-error
- rerender(updatedProps);
- expect(search).toHaveBeenCalledWith({queryJSON: chatProps.queryJSON, searchKey: undefined, offset: 0, shouldCalculateTotals: false, isLoading: false});
- });
-
- it('should not trigger search when new transaction removed and focused', () => {
- const initialProps = {
- ...baseProps,
- transactions: {
- '1': {transactionID: '1'},
- '2': {transactionID: '2'},
- },
- previousTransactions: {
- '1': {transactionID: '1'},
- '2': {transactionID: '2'},
- },
- };
-
- const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), {
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-expect-error
- initialProps,
- });
-
- const updatedProps = {
- ...baseProps,
- transactions: {
- '1': {transactionID: '1'},
- },
- };
-
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-expect-error
- rerender(updatedProps);
- expect(search).not.toHaveBeenCalled();
- });
-
- it('should not trigger search for chat when report actions removed and focused', () => {
- mockUseIsFocused.mockReturnValue(true);
-
- const chatProps = {
- ...baseProps,
- queryJSON: {...baseProps.queryJSON, type: 'chat' as const},
- reportActions: {
- reportActions_1: {
- '1': {actionName: 'EXISTING', reportActionID: '1'},
- '2': {actionName: 'ADDCOMMENT', reportActionID: '2'},
- },
- },
- previousReportActions: {
- reportActions_1: {
- '1': {actionName: 'EXISTING', reportActionID: '1'},
- '2': {actionName: 'ADDCOMMENT', reportActionID: '2'},
- },
- },
- };
-
- const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), {
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-expect-error
- initialProps: chatProps,
- });
-
- const updatedProps = {
- ...chatProps,
- reportActions: {
- reportActions_1: {
- '1': {actionName: 'EXISTING', reportActionID: '1'},
- },
- },
- };
-
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-expect-error
- rerender(updatedProps);
- expect(search).not.toHaveBeenCalled();
- });
-
- it('should clear the scroll trigger when the first new expense is already first', () => {
- const {rerender, result} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), {
- initialProps: baseProps,
- });
- const updatedProps = {
- ...baseProps,
- searchResults: {
- ...baseProps.searchResults,
- data: {
- transactions_1: {
- transactionID: '1',
- },
- transactions_2: {
- transactionID: '2',
- },
- },
- },
- transactions: {
- '1': {transactionID: '1'},
- '2': {transactionID: '2'},
- '3': {transactionID: '3'},
- },
- previousTransactions: {
- '1': {transactionID: '1'},
- },
- };
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-expect-error
- rerender(updatedProps);
- expect(result.current.newSearchResultKeys?.size).toBe(2);
-
- const scrollToIndex = jest.fn();
- const ref: NonNullable[1]> = {
- scrollAndHighlightItem: jest.fn(),
- scrollToIndex,
- updateFocusedIndex: jest.fn(),
- scrollToFocusedInput: jest.fn(),
- focusTextInput: jest.fn(),
- };
-
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-expect-error
- result.current.handleSelectionListScroll([{transactionID: '1'}, {transactionID: '2'}], ref);
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-expect-error
- result.current.handleSelectionListScroll([{transactionID: '2'}, {transactionID: '1'}], ref);
-
- expect(scrollToIndex).not.toHaveBeenCalled();
- });
-
- it('should return new search result keys for manually highlighted expenses', async () => {
- const spyOnMergeTransactionIdsHighlightOnSearchRoute = jest
- .spyOn(require('@libs/actions/Transaction'), 'mergeTransactionIdsHighlightOnSearchRoute')
- .mockImplementationOnce(jest.fn());
- // We need to mock requestAnimationFrame to mimic long Onyx merge overhead
- jest.spyOn(global, 'requestAnimationFrame').mockImplementation((callback: FrameRequestCallback) => {
- callback(performance.now());
- return 0;
- });
-
- await Onyx.merge(ONYXKEYS.TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE, {[baseProps.queryJSON.type]: {'3': true}});
-
- const {rerender, result} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), {
- initialProps: baseProps,
- });
- const updatedProps1 = {
- ...baseProps,
- searchResults: {
- ...baseProps.searchResults,
- data: {
- transactions_1: {
- transactionID: '1',
- },
- transactions_2: {
- transactionID: '2',
- },
- },
- },
- transactions: {
- '1': {transactionID: '1'},
- '2': {transactionID: '2'},
- '3': {transactionID: '3'},
- },
- previousTransactions: {
- '1': {transactionID: '1'},
- },
- } as unknown as UseSearchHighlightAndScroll;
-
- // When there is no data yet, even if the transactionID has been added to manual highlight transactionIDs,
- // it still will not be included in newSearchResultKeys.
- rerender(updatedProps1);
- expect(result.current.newSearchResultKeys?.size).toBe(2);
- expect([...(result.current.newSearchResultKeys ?? new Set())]).not.toContain('transactions_3');
-
- // When the data contains the highlight transactionID, it will be highlighted.
- const updatedProps2 = {
- ...updatedProps1,
- searchResults: {
- ...updatedProps1.searchResults,
- data: {
- transactions_1: {
- transactionID: '1',
- },
- transactions_2: {
- transactionID: '2',
- },
- transactions_3: {
- transactionID: '3',
- },
- },
- },
- } as unknown as UseSearchHighlightAndScroll;
-
- rerender(updatedProps2);
- expect(result.current.newSearchResultKeys?.size).toBe(1);
- expect([...(result.current.newSearchResultKeys ?? new Set())]).toContain('transactions_3');
-
- expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledWith(baseProps.queryJSON.type, {'3': false});
- });
-
- it('should return multiple new search result keys when there are multiple new chats', () => {
- const chatProps = {
- ...baseProps,
- queryJSON: {...baseProps.queryJSON, type: 'chat' as const},
- reportActions: {
- reportActions_1: {
- '1': {actionName: 'EXISTING', reportActionID: '1'},
- },
- },
- };
- const {rerender, result} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), {
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-expect-error
- initialProps: chatProps,
- });
- const updatedProps = {
- ...chatProps,
- searchResults: {
- ...baseProps.searchResults,
- data: {
- reportActions_1: {
- '1': {actionName: 'EXISTING', reportActionID: '1'},
- },
- reportActions_2: {
- '2': {actionName: 'EXISTING', reportActionID: '2'},
- },
- },
- },
- reportActions: {
- reportActions_1: {
- '1': {actionName: 'EXISTING', reportActionID: '1'},
- },
- reportActions_2: {
- '2': {actionName: 'EXISTING', reportActionID: '2'},
- },
- reportActions_3: {
- '3': {actionName: 'EXISTING', reportActionID: '3'},
- },
- },
- previousReportActions: {
- reportActions_1: {
- '1': {actionName: 'EXISTING', reportActionID: '1'},
- },
- },
- };
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-expect-error
- rerender(updatedProps);
- expect(result.current.newSearchResultKeys?.size).toBe(2);
- });
-});