diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv
index 0f408e73b2ee..87bd6c0f6ca9 100644
--- a/config/eslint/eslint.seatbelt.tsv
+++ b/config/eslint/eslint.seatbelt.tsv
@@ -475,7 +475,6 @@
"../../src/hooks/useReviewDuplicatesNavigation.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/hooks/useReviewDuplicatesNavigation.tsx" "react-hooks/set-state-in-effect" 1
"../../src/hooks/useSearchBulkActions.ts" "@typescript-eslint/no-unsafe-type-assertion" 10
-"../../src/hooks/useSearchHighlightAndScroll.ts" "@typescript-eslint/no-unsafe-type-assertion" 4
"../../src/hooks/useSidebarOrderedReports.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/hooks/useSidebarOrderedReports.tsx" "react-hooks/refs" 5
"../../src/hooks/useSidebarOrderedReports.tsx" "react-hooks/set-state-in-effect" 1
diff --git a/src/GlobalModals.tsx b/src/GlobalModals.tsx
index a29dca9321cc..435ce77e645b 100644
--- a/src/GlobalModals.tsx
+++ b/src/GlobalModals.tsx
@@ -2,8 +2,10 @@ import React, {startTransition, useEffect, useState} from 'react';
import DelegateNoAccessModalProvider from './components/DelegateNoAccessModalProvider';
import EmojiPicker from './components/EmojiPicker/EmojiPicker';
+import ExpenseAddedGrowl from './components/ExpenseAddedGrowl';
import GrowlNotification from './components/GrowlNotification';
import LazyModalSlot from './components/LazyModalSlot';
+import useIsAuthenticated from './hooks/useIsAuthenticated';
import * as EmojiPickerAction from './libs/actions/EmojiPickerAction';
import {growlRef} from './libs/Growl';
import * as ReportActionContextMenu from './pages/inbox/report/ContextMenu/ReportActionContextMenu';
@@ -24,6 +26,7 @@ const IDLE_CALLBACK_TIMEOUT_MS = 2000;
function GlobalModals() {
const [shouldRenderContextMenu, setShouldRenderContextMenu] = useState(false);
const [shouldRenderDeferredModals, setShouldRenderDeferredModals] = useState(false);
+ const isAuthenticated = useIsAuthenticated();
// Defer loading the context menu and rare-condition modals until after startup to avoid
// pulling in their dependencies (ContextMenuActions, ReportUtils, ModifiedExpenseMessage,
@@ -50,6 +53,7 @@ function GlobalModals() {
return (
<>
+ {isAuthenticated && }
{shouldRenderContextMenu && (
diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts
index 08c248fd7e6a..a524ccea3964 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';
@@ -848,8 +849,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',
@@ -1818,7 +1819,7 @@ type OnyxValuesMapping = {
[ONYXKEYS.NVP_REPORT_DETAILS_COLUMNS]: string[];
[ONYXKEYS.HAS_DENIED_CONTACT_IMPORT_PROMPT]: boolean | undefined;
[ONYXKEYS.PERSONAL_POLICY_ID]: string;
- [ONYXKEYS.TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE]: Record>;
+ [ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS]: Record;
[ONYXKEYS.ROOM_ID_HIGHLIGHT_ON_ROOMS_PAGE]: string | null;
[ONYXKEYS.DOMAIN_GROUP_CREATE_PREFERRED_POLICY_ID]: string | undefined;
};
diff --git a/src/components/ExpenseAddedGrowl.tsx b/src/components/ExpenseAddedGrowl.tsx
new file mode 100644
index 000000000000..f880475951cf
--- /dev/null
+++ b/src/components/ExpenseAddedGrowl.tsx
@@ -0,0 +1,164 @@
+import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
+import useLocalize from '@hooks/useLocalize';
+import useOnyx from '@hooks/useOnyx';
+
+import {createTransactionThreadReport, setOptimisticTransactionThread} from '@libs/actions/Report';
+import {mergeExpenseAddedGrowlTransactionIDs} from '@libs/actions/Transaction';
+import Log from '@libs/Log';
+import {navigateToCreatedExpense} from '@libs/Navigation/helpers/navigateAfterExpenseCreate';
+import Navigation from '@libs/Navigation/Navigation';
+import {getIOUActionForTransactionID} from '@libs/ReportActionsUtils';
+import {findSelfDMReportID, isInvoiceReport, isMoneyRequestReport} from '@libs/ReportUtils';
+
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {SearchDataTypes} from '@src/types/onyx/SearchResults';
+
+import type {Dispatch, SetStateAction} from 'react';
+
+import {useEffect, useRef, useState} from 'react';
+
+import GrowlNotificationContent from './GrowlNotification/GrowlNotificationContent';
+
+type ActiveGrowl = {
+ /** ID of the transaction the growl was raised for */
+ transactionID: string;
+
+ /** Search data type the growl belongs to (expense, invoice, etc.) */
+ dataType: SearchDataTypes;
+
+ /** Identifies each growl instance so a stale slide-out dismissal can't clear a newer growl */
+ nonce: number;
+};
+
+type ExpenseAddedGrowlContentProps = {
+ /** Transaction to read for the growl: the active growl's transaction, or the latest pending signal ID */
+ transactionID: string;
+
+ /** Pending expense-added notifications, keyed by transaction ID with its search data type as the value */
+ signal: Record | undefined;
+
+ /** Currently displayed growl */
+ active: ActiveGrowl | null;
+
+ /** Setter for the active growl */
+ setActive: Dispatch>;
+};
+
+/** Watches the "an expense was just added" Onyx signal and shows an "Expense added" growl with a "View" action. */
+function ExpenseAddedGrowl() {
+ const [active, setActive] = useState(null);
+ const [signal] = useOnyx(ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS);
+ const transactionID = active?.transactionID ?? Object.keys(signal ?? {}).at(-1);
+
+ if (!transactionID) {
+ return null;
+ }
+
+ return (
+
+ );
+}
+
+function ExpenseAddedGrowlContent({transactionID, signal, active, setActive}: ExpenseAddedGrowlContentProps) {
+ const nonceRef = useRef(0);
+
+ const {translate} = useLocalize();
+ const currentUserPersonalDetails = useCurrentUserPersonalDetails();
+ const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST);
+ const [betas] = useOnyx(ONYXKEYS.BETAS);
+ const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED);
+ const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`);
+ const reportID = transaction?.reportID;
+ const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`);
+
+ // A tracked expense's IOU action lives on the self-DM report, not on transaction.reportID, so read actions
+ // from the report that actually hosts it so "View" can resolve the real transaction thread.
+ const isUnreportedExpense = !reportID || reportID === CONST.REPORT.UNREPORTED_REPORT_ID;
+ const selfDMReportID = isUnreportedExpense ? findSelfDMReportID() : undefined;
+ const hostReportID = isUnreportedExpense ? selfDMReportID : reportID;
+ const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${hostReportID}`);
+
+ // A tracked expense lives in a chat, not an expense report. Leaving iouReportID undefined tells
+ // navigateToCreatedExpense to open the transaction thread directly instead of an expense report RHP.
+ const iouReport = isMoneyRequestReport(report) || isInvoiceReport(report) ? report : undefined;
+ const iouReportID = iouReport?.reportID;
+
+ useEffect(() => {
+ if (active) {
+ return;
+ }
+ const pendingTransactionIDs = Object.keys(signal ?? {});
+
+ // The last key is the last-created transaction: IDs are large (rand64), so they exceed the array-index
+ // range and JS preserves insertion order rather than sorting them numerically ascending.
+ const latestTransactionID = pendingTransactionIDs.at(-1);
+ const dataType = latestTransactionID ? signal?.[latestTransactionID] : undefined;
+ if (!latestTransactionID || !dataType || !transaction) {
+ return;
+ }
+
+ // Clear every pending ID but surface only the latest: rapid creations that pile up before this runs
+ // collapse into a single growl for the newest expense.
+ mergeExpenseAddedGrowlTransactionIDs(Object.fromEntries(pendingTransactionIDs.map((id) => [id, null])));
+
+ // Suppress the growl when the user is already viewing the expense's money-request report
+ if (Navigation.getTopmostReportId() === transaction.reportID) {
+ return;
+ }
+ nonceRef.current += 1;
+ setActive({transactionID: latestTransactionID, dataType, nonce: nonceRef.current});
+ }, [signal, active, transaction, setActive]);
+
+ if (!active) {
+ return null;
+ }
+
+ const isInvoice = active.dataType === CONST.SEARCH.DATA_TYPES.INVOICE;
+
+ // Build the thread on press rather than when the growl shows, so it is only created if the user taps
+ // "View" and is built against the freshest Onyx data.
+ const navigateToExpense = () => {
+ const iouAction = getIOUActionForTransactionID(Object.values(reportActions ?? {}), active.transactionID);
+ let threadReportID = transaction?.transactionThreadReportID ?? iouAction?.childReportID;
+ if (threadReportID) {
+ setOptimisticTransactionThread(threadReportID, iouReport?.reportID, iouAction?.reportActionID, iouReport?.policyID);
+ } else {
+ const optimisticThread = createTransactionThreadReport({
+ introSelected,
+ currentUserLogin: currentUserPersonalDetails?.login ?? '',
+ currentUserAccountID: currentUserPersonalDetails?.accountID ?? CONST.DEFAULT_NUMBER_ID,
+ betas,
+ iouReport,
+ iouReportAction: iouAction,
+ transaction,
+ personalDetails,
+ });
+ threadReportID = optimisticThread?.reportID;
+ }
+ if (!threadReportID) {
+ Log.warn('[ExpenseAddedGrowl] Unable to resolve transaction thread reportID on View press.');
+ return;
+ }
+ navigateToCreatedExpense({threadReportID, transactionID: active.transactionID, iouReportID});
+ };
+
+ return (
+ setActive((prev) => (prev?.nonce === dismissedNonce ? null : prev))}
+ />
+ );
+}
+
+export default ExpenseAddedGrowl;
diff --git a/src/components/Search/ChatSearchView.tsx b/src/components/Search/ChatSearchView.tsx
index 21f390a3fa3e..361bb2aa734e 100644
--- a/src/components/Search/ChatSearchView.tsx
+++ b/src/components/Search/ChatSearchView.tsx
@@ -4,7 +4,7 @@ import CONST from '@src/CONST';
import type {NativeSyntheticEvent} from 'react-native';
-import React, {useImperativeHandle} from 'react';
+import React from 'react';
import type {SearchListItem} from './SearchList/ListItem/types';
import type {CommonSearchViewProps} from './searchViewProps';
@@ -49,7 +49,6 @@ function ChatSearchView({
onScroll,
contentContainerStyle,
containerStyle,
- ref,
}: ChatSearchViewProps) {
const {type} = queryJSON;
@@ -65,9 +64,6 @@ function ChatSearchView({
const selectedItemsLength = data.reduce((acc, item) => acc + (item.keyForList && selectedTransactions[item.keyForList]?.isSelected ? 1 : 0), 0);
const totalItems = data.filter((item) => !isRowDeleted(item)).length;
- // Flat data maps 1:1 to the rendered list, so highlight-scroll-to-index is the same as scroll-to-data-index.
- useImperativeHandle(ref, () => ({scrollToIndex: scrollToListIndex}), [scrollToListIndex]);
-
const renderItem = (item: SearchListItem, index: number, isItemFocused: boolean, onFocus?: (event: NativeSyntheticEvent) => void) => (
// Chat rows never animate their exit (only grouped expenses do), so the wrapper just preserves the overflow clip.
item.pendingAction === CONST.RED_
* `toggle`/`toggleAll`/`selectedTransactions`). The shared list state and interactions come from
* `useSearchListViewState`, and the surrounding chrome (horizontal scroll, header bar, long-press menu)
* from `SearchListViewLayout`; this view only owns the flat-expense specifics: the `TransactionListItem`
- * renderer, single-pass visibility/selection counts, and the highlight-scroll imperative handle.
- * `TransactionListItem` is the only row renderer here and rows always animate, so the
- * group/sticky/chat/task branches of `SearchList` do not apply. Keyboard navigation is inherited from
- * `BaseSearchList`; the post-create highlight stays in the router (the snapshot stamps
- * `shouldAnimateInHighlight`, and `newTransactions` flows into `extraData`).
+ * renderer and single-pass visibility/selection counts.
+ * `TransactionListItem` is the only row renderer here, so the group/sticky/chat/task branches of
+ * `SearchList` do not apply. Keyboard navigation is inherited from `BaseSearchList`; `newTransactions`
+ * flows into `extraData` so the list re-renders when a freshly-created expense lands.
*/
function ExpenseFlatSearchView({
queryJSON,
@@ -55,7 +54,6 @@ function ExpenseFlatSearchView({
onScroll,
contentContainerStyle,
containerStyle,
- ref,
}: ExpenseFlatSearchViewProps) {
const {type} = queryJSON;
@@ -89,9 +87,6 @@ function ExpenseFlatSearchView({
const selectedItemsLength = data.reduce((acc, item) => acc + (item.keyForList && selectedTransactions[item.keyForList]?.isSelected ? 1 : 0), 0);
const totalItems = data.filter((item) => !isRowDeleted(item)).length;
- // Flat data maps 1:1 to the rendered list, so highlight-scroll-to-index is the same as scroll-to-data-index.
- useImperativeHandle(ref, () => ({scrollToIndex: scrollToListIndex}), [scrollToListIndex]);
-
const renderItem = (item: SearchListItem, index: number, isItemFocused: boolean, onFocus?: (event: NativeSyntheticEvent) => void) => {
const isDisabled = isRowDeleted(item);
// Only expense row exits animate; invoice and trip transaction lists do not (matches the legacy per-type gate).
diff --git a/src/components/Search/ExpenseGroupedSearchView.tsx b/src/components/Search/ExpenseGroupedSearchView.tsx
index 57db036fc5bd..9b59600cffea 100644
--- a/src/components/Search/ExpenseGroupedSearchView.tsx
+++ b/src/components/Search/ExpenseGroupedSearchView.tsx
@@ -15,7 +15,7 @@ import type {Transaction} from '@src/types/onyx';
import type {NativeSyntheticEvent} from 'react-native';
-import React, {useImperativeHandle, useState} from 'react';
+import React, {useState} from 'react';
import type {SearchListItem} from './SearchList/ListItem/types';
import type {CommonSearchViewProps, TransactionViewExtras} from './searchViewProps';
@@ -89,8 +89,7 @@ function buildNewTransactionIDMap(data: SearchListItem[], newTransactions: Trans
* `useSearchListViewState`, and the surrounding chrome from `SearchListViewLayout`. This view owns the group
* machinery: on wide web it splits each group into a sticky `GroupHeader` plus an expandable
* `GroupChildrenContainer` (`shouldSplitGroups`); otherwise each group renders through `TransactionGroupListItem`.
- * Selection counts are report-aware (flattened over child transactions plus empty groups), and the scroll
- * handle remaps the router's data index to the split-list index.
+ * Selection counts are report-aware (flattened over child transactions plus empty groups).
*/
function ExpenseGroupedSearchView({
queryJSON,
@@ -112,7 +111,6 @@ function ExpenseGroupedSearchView({
onScroll,
contentContainerStyle,
containerStyle,
- ref,
}: ExpenseGroupedSearchViewProps) {
const {type, groupBy} = queryJSON;
const {isLargeScreenWidth} = useResponsiveLayout();
@@ -172,26 +170,6 @@ function ExpenseGroupedSearchView({
const firstVisibleIndex = listData.findIndex(isItemVisible);
const lastVisibleIndex = listData.findLastIndex(isItemVisible);
- // The router highlights by source-data index; remap it to the split-list index before scrolling.
- useImperativeHandle(
- ref,
- () => ({
- scrollToIndex: (index: number, animated = true) => {
- if (!shouldSplit) {
- scrollToListIndex(index, animated);
- return;
- }
- const item = data.at(index);
- if (!item) {
- return;
- }
- const splitIndex = item.keyForList ? listData.findIndex((listItem) => listItem.keyForList === `header_${item.keyForList}`) : -1;
- scrollToListIndex(splitIndex !== -1 ? splitIndex : index, animated);
- },
- }),
- [data, listData, shouldSplit, scrollToListIndex],
- );
-
const getItemType = (item: SearchListItem) => {
if (!shouldSplit) {
return undefined;
diff --git a/src/components/Search/ExpenseReportSearchView.tsx b/src/components/Search/ExpenseReportSearchView.tsx
index 1a8ba373e619..ed3b269d66b2 100644
--- a/src/components/Search/ExpenseReportSearchView.tsx
+++ b/src/components/Search/ExpenseReportSearchView.tsx
@@ -6,7 +6,7 @@ import CONST from '@src/CONST';
import type {NativeSyntheticEvent} from 'react-native';
-import React, {useImperativeHandle} from 'react';
+import React from 'react';
import type {SearchListItem} from './SearchList/ListItem/types';
import type {CommonSearchViewProps} from './searchViewProps';
@@ -55,7 +55,6 @@ function ExpenseReportSearchView({
onScroll,
contentContainerStyle,
containerStyle,
- ref,
}: ExpenseReportSearchViewProps) {
const {type} = queryJSON;
@@ -92,9 +91,6 @@ function ExpenseReportSearchView({
emptyReports.reduce((acc, item) => acc + (isRowSelected(item.keyForList, selectedTransactions) ? 1 : 0), 0);
const totalItems = flattenedTransactions.filter((item) => !isRowDeleted(item)).length + emptyReports.filter((item) => !isRowDeleted(item)).length;
- // Report data maps 1:1 to the rendered list, so highlight-scroll-to-index is the same as scroll-to-data-index.
- useImperativeHandle(ref, () => ({scrollToIndex: scrollToListIndex}), [scrollToListIndex]);
-
const renderItem = (item: SearchListItem, index: number, isItemFocused: boolean, onFocus?: (event: NativeSyntheticEvent) => void) => (
// Report rows never animate their exit (only grouped expenses do), so the wrapper just preserves the overflow clip.
({
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 9b1954d33503..736d75b0a0e7 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';
@@ -361,7 +360,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,
@@ -382,14 +381,6 @@ function ExpenseReportListItemInner({
[styles, isLargeScreenWidth],
);
- const animatedHighlightStyle = useAnimatedHighlightStyle({
- borderRadius: 0,
- shouldHighlight: item?.shouldAnimateInHighlight ?? false,
- highlightColor: theme.messageHighlightBG,
- backgroundColor: isSelected ? theme.activeComponentBG : theme.highlightBG,
- shouldApplyOtherStyles: !isLargeScreenWidth,
- });
-
const shouldShowViolationDescription = isOpenExpenseReport(reportItem) || isProcessingReport(reportItem);
// Show violation description if either:
@@ -489,7 +480,8 @@ function ExpenseReportListItemInner({
hoverStyle={isSelected && styles.activeComponentBG}
pressableWrapperStyle={[
styles.mh5,
- animatedHighlightStyle,
+ StyleUtils.getSearchRowBackgroundStyle(isSelected),
+ !isLargeScreenWidth && styles.br0,
isPendingDelete && styles.cursorDisabled,
isLargeScreenWidth && isLastItem && [styles.tableBottomRadius, styles.overflowHidden],
!isLargeScreenWidth && isFirstItem && styles.tableTopRadius,
diff --git a/src/components/Search/SearchList/ListItem/GroupChildrenContainer.tsx b/src/components/Search/SearchList/ListItem/GroupChildrenContainer.tsx
index a5e734772371..816019fd1c25 100644
--- a/src/components/Search/SearchList/ListItem/GroupChildrenContainer.tsx
+++ b/src/components/Search/SearchList/ListItem/GroupChildrenContainer.tsx
@@ -1,6 +1,5 @@
-import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle';
import useExpandCollapseAnimation from '@hooks/useExpandCollapseAnimation';
-import useTheme from '@hooks/useTheme';
+import useStyleUtils from '@hooks/useStyleUtils';
import useThemeStyles from '@hooks/useThemeStyles';
import React from 'react';
@@ -31,8 +30,8 @@ function GroupChildrenContainer({
isLastItem,
newTransactionID,
}: GroupChildrenContainerProps) {
- const theme = useTheme();
const styles = useThemeStyles();
+ const StyleUtils = useStyleUtils();
const {isRendered, animatedStyle, onLayout} = useExpandCollapseAnimation(isExpanded, false, item.keyForList);
const isContentVisible = isExpanded || isRendered;
const {isSelectAllChecked} = useGroupCheckboxState({groupKey: item.groupKeyForList, groupTransactions: item.transactions});
@@ -40,27 +39,13 @@ function GroupChildrenContainer({
// Only the rows this container holds decide its background, so a group still waiting for its first page is not painted as selected.
const isSelected = !!item.isSelected || (item.transactions.length > 0 && isSelectAllChecked);
- const animatedHighlightStyle = useAnimatedHighlightStyle({
- shouldHighlight: item?.shouldAnimateInHighlight ?? false,
- highlightColor: theme.messageHighlightBG,
- backgroundColor: isSelected ? theme.activeComponentBG : theme.highlightBG,
- shouldApplyOtherStyles: false,
- });
-
// Rendering null in FlashList can cause heavy first-render work; use an empty placeholder instead (LHN pattern).
if (!isExpanded && !isRendered) {
return ;
}
return (
-
+
{isContentVisible ? (
) : null}
-
+
);
}
diff --git a/src/components/Search/SearchList/ListItem/GroupHeader.tsx b/src/components/Search/SearchList/ListItem/GroupHeader.tsx
index 5e19f7353758..ce99c5c95ba8 100644
--- a/src/components/Search/SearchList/ListItem/GroupHeader.tsx
+++ b/src/components/Search/SearchList/ListItem/GroupHeader.tsx
@@ -6,7 +6,6 @@ import SearchTableHeader from '@components/Search/SearchTableHeader';
import type {SearchColumnType, SearchCustomColumnIds, SearchGroupBy} from '@components/Search/types';
import type {ExtendedTargetedEvent} from '@components/SelectionList/ListItem/types';
-import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle';
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import useExpandCollapseAnimation from '@hooks/useExpandCollapseAnimation';
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
@@ -185,13 +184,6 @@ function GroupHeader({
keyForList: item.groupKeyForList,
});
- const animatedHighlightStyle = useAnimatedHighlightStyle({
- shouldHighlight: item?.shouldAnimateInHighlight ?? false,
- highlightColor: theme.messageHighlightBG,
- backgroundColor: isItemSelected ? theme.activeComponentBG : theme.highlightBG,
- shouldApplyOtherStyles: false,
- });
-
const handleSelectionButtonPress = () => {
onCheckboxPress(withOriginalKey(item), isExpenseReportType ? undefined : groupItem.transactions);
};
@@ -374,7 +366,7 @@ function GroupHeader({
]}
wrapperStyle={[
styles.mh5,
- animatedHighlightStyle,
+ StyleUtils.getSearchRowBackgroundStyle(!!isItemSelected),
styles.userSelectNone,
isLargeScreenWidth
? [StyleUtils.getSearchTableGroupRowBorderStyle(isFirstItem, isLastItemCollapsed, isItemSelected), isLastItemCollapsed && styles.overflowHidden]
diff --git a/src/components/Search/SearchList/ListItem/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,
+ StyleUtils.getSearchRowBackgroundStyle(isSelected),
+ styles.br0,
styles.userSelectNone,
isFirstItem && styles.tableTopRadius,
isLastItem && styles.tableBottomRadius,
diff --git a/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemWide.tsx b/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemWide.tsx
index 188488030802..b31a8054aadb 100644
--- a/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemWide.tsx
+++ b/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemWide.tsx
@@ -7,7 +7,6 @@ import type {ListItem} from '@components/SelectionList/types';
import TransactionItemRow from '@components/TransactionItemRow';
import {useEditingCellState} from '@components/TransactionItemRow/EditableCell';
-import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle';
import useStyleUtils from '@hooks/useStyleUtils';
import useSyncFocus from '@hooks/useSyncFocus';
import useTheme from '@hooks/useTheme';
@@ -144,14 +143,6 @@ function TransactionListItemWide({
},
];
- const animatedHighlightStyle = useAnimatedHighlightStyle({
- borderRadius: 0,
- shouldHighlight: item?.shouldAnimateInHighlight ?? false,
- highlightColor: theme.messageHighlightBG,
- backgroundColor: isSelected ? theme.activeComponentBG : theme.highlightBG,
- shouldApplyOtherStyles: false,
- });
-
return (
({
isDeletedTransaction && styles.cursorDefault,
]}
onFocus={onFocus}
- wrapperStyle={[styles.mh5, styles.flex1, animatedHighlightStyle, styles.userSelectNone, isLastItem && [styles.tableBottomRadius, styles.overflowHidden]]}
+ wrapperStyle={[
+ styles.mh5,
+ styles.flex1,
+ StyleUtils.getSearchRowBackgroundStyle(isSelected),
+ styles.userSelectNone,
+ isLastItem && [styles.tableBottomRadius, styles.overflowHidden],
+ ]}
>
{({hovered}) => (
acc + (item.keyForList && selectedTransactions[item.keyForList]?.isSelected ? 1 : 0), 0);
const totalItems = data.filter((item) => !isRowDeleted(item)).length;
- // Flat data maps 1:1 to the rendered list, so highlight-scroll-to-index is the same as scroll-to-data-index.
- useImperativeHandle(ref, () => ({scrollToIndex: scrollToListIndex}), [scrollToListIndex]);
-
const renderItem = (item: SearchListItem, index: number, isItemFocused: boolean, onFocus?: (event: NativeSyntheticEvent) => void) => (
// Task rows never animate their exit (only grouped expenses do), so the wrapper just preserves the overflow clip.
;
/** The current search snapshot, owned by the ancestor and passed in. */
searchResults: SearchResults | undefined;
- /** Keys flagged for the post-create highlight animation. */
- newSearchResultKeys: Set | null | undefined;
/** Full TRANSACTION + REPORT_ACTIONS collections used by the optimistic-row tracking. Threaded in from
- * the parent (which already subscribes to them for the highlight hook) so we don't open duplicate
+ * the parent (which already subscribes to them for the refetch hook) so we don't open duplicate
* full-collection reads. */
transactions: OptimisticTrackingParams['transactions'];
reportActions: OptimisticTrackingParams['reportActions'];
@@ -89,11 +87,11 @@ const hashToString = (queryHash?: number) => (queryHash || queryHash === 0 ? Str
* Single data layer for the Search screen.
*
* Owns the live inputs `getSections` needs for sort/group correctness, runs the deferral-gated
- * sort/group/paginate projection, stamps the post-create highlight, enriches grouped views with their
- * per-group sub-snapshots, and absorbs the optimistic-row resilience. Returns the sorted rows plus the
- * list-level meta and the optimistic-tracking carriers that `` consumes.
+ * sort/group/paginate projection, enriches grouped views with their per-group sub-snapshots, and absorbs
+ * the optimistic-row resilience. Returns the sorted rows plus the list-level meta and the
+ * optimistic-tracking carriers that `` consumes.
*/
-function useSearchSnapshot({queryJSON, searchResults, newSearchResultKeys, transactions, reportActions}: UseSearchSnapshotParams): SearchSnapshotResult {
+function useSearchSnapshot({queryJSON, searchResults, transactions, reportActions}: UseSearchSnapshotParams): SearchSnapshotResult {
const {type, sortBy, sortOrder, hash, groupBy} = queryJSON;
const {isOffline} = useNetwork();
@@ -307,8 +305,8 @@ function useSearchSnapshot({queryJSON, searchResults, newSearchResultKeys, trans
exportReportActions,
]);
- // Stage 3: sort the (enriched) data, then stamp the post-create highlight on each row. getSortedSections
- // accepts the full section union; our SearchListItem[] is a compatible subset of that input.
+ // Stage 3: sort the (enriched) data. getSortedSections accepts the full section union; our
+ // SearchListItem[] is a compatible subset of that input.
const chartData = useMemo(() => {
if (!shouldComputeSections) {
return EMPTY_DATA;
@@ -319,47 +317,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 637add171c79..b40179541b70 100644
--- a/src/components/Search/index.tsx
+++ b/src/components/Search/index.tsx
@@ -1,7 +1,6 @@
import FullPageErrorView from '@components/BlockingViews/FullPageErrorView';
import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView';
import {usePersonalDetails} from '@components/OnyxListItemProvider';
-import type {SelectionListHandle} from '@components/SelectionList/types';
import SearchRowSkeleton from '@components/Skeletons/SearchRowSkeleton';
import {useWideRHPActions} from '@components/WideRHPContextProvider';
@@ -16,7 +15,7 @@ import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses';
import usePrevious from '@hooks/usePrevious';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useSaveSortedReportIDs from '@hooks/useSaveSortedReportIDs';
-import useSearchHighlightAndScroll from '@hooks/useSearchHighlightAndScroll';
+import useSearchAutoRefetch from '@hooks/useSearchAutoRefetch';
import useSearchShouldCalculateTotals, {getSearchRequestOffsetForMissingAllMatchingCount} from '@hooks/useSearchShouldCalculateTotals';
import useStableArrayReference from '@hooks/useStableArrayReference';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -208,7 +207,6 @@ function Search({
const previousReportActions = usePrevious(reportActions);
const {translate} = useLocalize();
const {getCurrencyDecimals} = useCurrencyListActions();
- const searchListRef = useRef | null>(null);
const savedSearchSelector = useCallback((searches: OnyxEntry) => searches?.[hash], [hash]);
const [savedSearch] = useOnyx(ONYXKEYS.SAVED_SEARCHES, {
@@ -231,7 +229,7 @@ function Search({
clearSelectedTransactions();
}, [validGroupBy, prevValidGroupBy, clearSelectedTransactions]);
- const {newSearchResultKeys, handleSelectionListScroll, newTransactions, hasQueuedHighlights} = useSearchHighlightAndScroll({
+ const {newTransactions} = useSearchAutoRefetch({
searchResults,
transactions,
previousTransactions,
@@ -241,7 +239,6 @@ function Search({
shouldCalculateTotals,
reportActions,
previousReportActions,
- shouldUseLiveData,
});
const {
@@ -263,21 +260,10 @@ function Search({
} = useSearchSnapshot({
queryJSON,
searchResults,
- newSearchResultKeys,
transactions,
reportActions,
});
- // Mirror `hasQueuedHighlights` into a ref so the post-create-flow `useFocusEffect`
- // (which has empty deps) can read the latest value without re-creating its callback.
- // Used to skip the deferral that would otherwise hide the freshly-added row from
- // FlashList during the RHP dismiss transition, which would prevent the highlight
- // animation from ever firing on it.
- const hasQueuedHighlightsRef = useRef(hasQueuedHighlights);
- useEffect(() => {
- hasQueuedHighlightsRef.current = hasQueuedHighlights;
- }, [hasQueuedHighlights]);
-
// There's a race condition in Onyx which makes it return data from the previous Search, so in addition to checking that the data is loaded
// we also need to check that the searchResults matches the type and status of the current search
const isDataLoaded = shouldUseLiveData || isSearchDataLoaded(searchResults, queryJSON);
@@ -337,14 +323,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.
@@ -823,9 +801,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.
@@ -1168,7 +1145,6 @@ function Search({
const isTransactionListView = type !== CONST.SEARCH.DATA_TYPES.CHAT && type !== CONST.SEARCH.DATA_TYPES.TASK && type !== CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT;
const commonViewProps: CommonSearchViewProps = {
- ref: searchListRef,
queryJSON,
data: stableSortedData,
columns: columnsToShow,
diff --git a/src/components/Search/searchViewProps.ts b/src/components/Search/searchViewProps.ts
index 40da64005c12..d11afac6b502 100644
--- a/src/components/Search/searchViewProps.ts
+++ b/src/components/Search/searchViewProps.ts
@@ -4,17 +4,11 @@ import type {ModifiedMouseEvent} from '@libs/Navigation/helpers/openInternalRout
import type {CardList, Transaction} from '@src/types/onyx';
import type React from 'react';
-import type {ForwardedRef} from 'react';
import type {NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle} from 'react-native';
import type {SearchListItem} from './SearchList/ListItem/types';
import type {SearchColumnType, SearchQueryJSON} from './types';
-/** Imperative handle the router uses for highlight-driven scrolling. */
-type SearchListHandle = {
- scrollToIndex: (index: number, animated?: boolean) => void;
-};
-
/**
* Props shared by every dedicated Search view. The router (``) builds these once and spreads
* them into whichever view renders; each view narrows to its own extras on top of this shape.
@@ -47,7 +41,7 @@ type CommonSearchViewProps = {
/** Whether everything has been loaded (gates the fully-checked select-all state). */
hasLoadedAllTransactions?: boolean;
- /** Rows flagged for the post-create highlight animation (feeds BaseSearchList extraData). */
+ /** Newly-added transactions (feeds BaseSearchList extraData and the grouped-view child-snapshot refetch). */
newTransactions: Transaction[];
/** The navigation handler for a row tap (owned by the router). */
@@ -70,9 +64,6 @@ type CommonSearchViewProps = {
/** Outer container style for the list wrapper. */
containerStyle: StyleProp;
-
- /** Imperative handle for highlight-driven scrolling, set by the router. */
- ref?: ForwardedRef;
};
/** Extra props specific to the transaction views (expense/invoice/trip): attendee tracking and card rendering. */
diff --git a/src/hooks/useSearchHighlightAndScroll.ts b/src/hooks/useSearchAutoRefetch.ts
similarity index 54%
rename from src/hooks/useSearchHighlightAndScroll.ts
rename to src/hooks/useSearchAutoRefetch.ts
index 77e057924e11..9efc4d727343 100644
--- a/src/hooks/useSearchHighlightAndScroll.ts
+++ b/src/hooks/useSearchAutoRefetch.ts
@@ -1,9 +1,7 @@
-import type {SearchListItem, TransactionGroupListItemType, TransactionListItemType} from '@components/Search/SearchList/ListItem/types';
+import type {TransactionListItemType} from '@components/Search/SearchList/ListItem/types';
import type {SearchQueryJSON} from '@components/Search/types';
-import type {SelectionListHandle} from '@components/SelectionList/types';
import {search} from '@libs/actions/Search';
-import {mergeTransactionIdsHighlightOnSearchRoute} from '@libs/actions/Transaction';
import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute';
import TransitionTracker from '@libs/Navigation/TransitionTracker';
import {isReportActionEntry} from '@libs/SearchUIUtils';
@@ -16,29 +14,44 @@ import type {ReportActions, SearchResults, Transaction} from '@src/types/onyx';
import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
import {useIsFocused} from '@react-navigation/native';
-import {useCallback, useEffect, useRef, useState} from 'react';
+import {useEffect, useRef} from 'react';
import useNetwork from './useNetwork';
-import useOnyx from './useOnyx';
-import usePrevious from './usePrevious';
-type UseSearchHighlightAndScroll = {
+type UseSearchAutoRefetch = {
+ /** Current Search snapshot */
searchResults: OnyxEntry;
+
+ /** Current transactions collection */
transactions: OnyxCollection;
+
+ /** Previous transactions collection, compared against `transactions` to detect newly-added ones */
previousTransactions: OnyxCollection;
+
+ /** Current report actions collection */
reportActions: OnyxCollection;
+
+ /** Previous report actions collection, compared against `reportActions` to detect new entries */
previousReportActions: OnyxCollection;
+
+ /** Parsed search query the refetch is issued for */
queryJSON: SearchQueryJSON;
+
+ /** Key identifying the current search */
searchKey: SearchKey | undefined;
+
+ /** Pagination offset for the refetch request */
offset: number;
+
+ /** Whether the refetch should recalculate result totals */
shouldCalculateTotals: boolean;
- shouldUseLiveData: boolean;
};
/**
- * Hook used to trigger a search when a new transaction or report action is added and handle highlighting and scrolling.
+ * Triggers a search when a transaction or report action is added so the snapshot reflects it.
+ * Returns the newly-added transactions, which the grouped views use to refetch each expanded group's child snapshot.
*/
-function useSearchHighlightAndScroll({
+function useSearchAutoRefetch({
searchResults,
transactions,
previousTransactions,
@@ -48,25 +61,13 @@ function useSearchHighlightAndScroll({
searchKey,
offset,
shouldCalculateTotals,
- shouldUseLiveData,
-}: UseSearchHighlightAndScroll) {
+}: UseSearchAutoRefetch) {
const isFocused = useIsFocused();
const {isOffline} = useNetwork();
- // Ref to track if the search was triggered by this hook
- const triggeredByHookRef = useRef(false);
const searchTriggeredRef = useRef(false);
- const hasNewItemsRef = useRef(false);
- const previousSearchResults = usePrevious(searchResults?.data);
- const [newSearchResultKeys, setNewSearchResultKeys] = useState | null>(null);
- const highlightedIDs = useRef>(new Set());
- const initializedRef = useRef(false);
const hasPendingSearchRef = useRef(false);
const isChat = queryJSON.type === CONST.SEARCH.DATA_TYPES.CHAT;
- const transactionIDsToHighlightSelector = useCallback((allTransactionIDs: OnyxEntry>>) => allTransactionIDs?.[queryJSON.type], [queryJSON.type]);
- const [transactionIDsToHighlight] = useOnyx(ONYXKEYS.TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE, {
- selector: transactionIDsToHighlightSelector,
- });
const searchResultsData = searchResults?.data;
const prevTransactionsIDs = Object.keys(previousTransactions ?? {});
@@ -153,13 +154,6 @@ function useSearchHighlightAndScroll({
return;
}
}
- // We only want to highlight new items if the addition of transactions or report actions triggered the search.
- // This is because, on deletion of items, the backend sometimes returns old items in place of the deleted ones.
- // We don't want to highlight these old items, even if they appear new in the current search results.
- hasNewItemsRef.current = isChat ? reportActionsIDs.length > previousReportActionsIDs.length : transactionsIDs.length > previousTransactionIDsLocal.length;
-
- // Set the flag indicating the search is triggered by the hook
- triggeredByHookRef.current = true;
// Trigger the search
TransitionTracker.runAfterTransitions({
@@ -195,155 +189,23 @@ function useSearchHighlightAndScroll({
}
searchTriggeredRef.current = false;
- }, [searchResults?.search?.isLoading, shouldUseLiveData, searchResultsData]);
-
- // Initialize the set with existing IDs only once
- useEffect(() => {
- if (initializedRef.current || !searchResultsData) {
- return;
- }
-
- const initialIDs = isChat ? extractReportActionIDsFromSearchResults(searchResultsData) : extractTransactionIDsFromSearchResults(searchResultsData);
- highlightedIDs.current = new Set(initialIDs);
- initializedRef.current = true;
- }, [searchResultsData, isChat]);
-
- // Detect new items (transactions or report actions)
- useEffect(() => {
- if (!previousSearchResults || !searchResults?.data) {
- return;
- }
- if (isChat) {
- const previousReportActionIDs = extractReportActionIDsFromSearchResults(previousSearchResults);
- const currentReportActionIDs = extractReportActionIDsFromSearchResults(searchResults.data);
-
- // Find new report action IDs that are not in the previousReportActionIDs and not already highlighted
- const newReportActionIDs = currentReportActionIDs.filter((id) => !previousReportActionIDs.includes(id) && !highlightedIDs.current.has(id));
-
- if (!triggeredByHookRef.current || newReportActionIDs.length === 0 || !hasNewItemsRef.current) {
- return;
- }
+ }, [searchResults?.search?.isLoading, searchResultsData]);
- const newKeys = new Set();
- for (const id of newReportActionIDs) {
- const newReportActionKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${id}`;
- highlightedIDs.current.add(newReportActionKey);
- newKeys.add(newReportActionKey);
- }
- setNewSearchResultKeys(newKeys);
- } else {
- const previousTransactionIDs = extractTransactionIDsFromSearchResults(previousSearchResults);
- const currentTransactionIDs = extractTransactionIDsFromSearchResults(searchResults.data);
- const manualHighlightTransactionIDs = new Set(Object.keys(transactionIDsToHighlight ?? {}).filter((id) => !!transactionIDsToHighlight?.[id]));
-
- // Find new transaction IDs that are not in the previousTransactionIDs and not already highlighted
- const newTransactionIDs = currentTransactionIDs.filter((id) => {
- if (manualHighlightTransactionIDs.has(id)) {
- return true;
- }
- if (!triggeredByHookRef.current || !hasNewItemsRef.current) {
- return false;
- }
- return !previousTransactionIDs.includes(id) && !highlightedIDs.current.has(id);
- });
-
- if (newTransactionIDs.length === 0) {
- return;
- }
-
- const newKeys = new Set();
- const consumedManualIDs: string[] = [];
- for (const id of newTransactionIDs) {
- const newTransactionKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${id}`;
- highlightedIDs.current.add(newTransactionKey);
- newKeys.add(newTransactionKey);
- if (manualHighlightTransactionIDs.has(id)) {
- consumedManualIDs.push(id);
- }
- }
- setNewSearchResultKeys(newKeys);
-
- // Clear consumed manual highlight flags so subsequent detect runs don't re-highlight the same IDs.
- if (consumedManualIDs.length > 0) {
- mergeTransactionIdsHighlightOnSearchRoute(queryJSON.type, Object.fromEntries(consumedManualIDs.map((id) => [id, false])));
- }
- }
- }, [searchResults?.data, previousSearchResults, isChat, transactionIDsToHighlight, queryJSON.type]);
-
- // Remove transactionIDsToHighlight when the user leaves the current search type
- useEffect(
- () => () => {
- mergeTransactionIdsHighlightOnSearchRoute(queryJSON.type, null);
- },
- [queryJSON.type],
- );
-
- // Reset newSearchResultKey after it's been used
- useEffect(() => {
- if (newSearchResultKeys === null) {
- return;
- }
-
- const timer = setTimeout(() => {
- setNewSearchResultKeys(null);
- }, CONST.ANIMATED_HIGHLIGHT_START_DURATION);
-
- return () => clearTimeout(timer);
- }, [newSearchResultKeys]);
-
- /**
- * Callback to handle scrolling to the new search result.
- */
- const handleSelectionListScroll = (data: SearchListItem[], ref: SelectionListHandle | null) => {
- // Early return if there's no ref, new transaction wasn't brought in by this hook
- // or there's no new search result key
- const newSearchResultKey = newSearchResultKeys?.values().next().value;
- if (!ref || !triggeredByHookRef.current || !newSearchResultKey) {
- return;
- }
-
- // Extract the transaction/report action ID from the newSearchResultKey
- const newID = newSearchResultKey.replace(isChat ? ONYXKEYS.COLLECTION.REPORT_ACTIONS : ONYXKEYS.COLLECTION.TRANSACTION, '');
-
- // Find the index of the new transaction/report action in the data array
- const indexOfNewItem = data.findIndex((item) => {
- if (isChat) {
- if ('reportActionID' in item && item.reportActionID === newID) {
- return true;
- }
- } else {
- // Handle TransactionListItemType
- if ('transactionID' in item && item.transactionID === newID) {
- return true;
- }
-
- // Handle TransactionGroupListItemType with transactions array
- if ('transactions' in item && Array.isArray(item.transactions)) {
- return item.transactions.some((transaction) => transaction?.transactionID === newID);
- }
- }
-
- return false;
- });
-
- // Early return if the new item is not found in the data array
- if (indexOfNewItem < 0) {
- return;
- }
-
- // Reset the trigger even when the item is already first so a later render cannot scroll or highlight it again.
- triggeredByHookRef.current = false;
- if (indexOfNewItem === 0) {
- return;
- }
-
- // Perform the scrolling action
- ref.scrollToIndex(indexOfNewItem);
- };
+ return {newTransactions};
+}
- const hasQueuedHighlights = newSearchResultKeys !== null && newSearchResultKeys.size > 0;
+function getTransactionIDFromEntry(entry: unknown) {
+ if (typeof entry !== 'object' || entry === null || !('transactionID' in entry) || typeof entry.transactionID !== 'string') {
+ return undefined;
+ }
+ return entry.transactionID;
+}
- return {newSearchResultKeys, handleSelectionListScroll, newTransactions, hasQueuedHighlights};
+function hasNestedTransactions(entry: unknown): entry is {transactions: Array>} {
+ if (typeof entry !== 'object' || entry === null || !('transactions' in entry)) {
+ return false;
+ }
+ return Array.isArray(entry.transactions);
}
/**
@@ -354,18 +216,21 @@ function extractTransactionIDsFromSearchResults(searchResultsData: Partial searchResultIDs.has(transactionID));
}
-export default useSearchHighlightAndScroll;
-export type {UseSearchHighlightAndScroll};
+export default useSearchAutoRefetch;
+export type {UseSearchAutoRefetch};
diff --git a/src/languages/de.ts b/src/languages/de.ts
index d6925b917343..f97cb618d7cf 100644
--- a/src/languages/de.ts
+++ b/src/languages/de.ts
@@ -1231,6 +1231,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/el.ts b/src/languages/el.ts
index d90780d460fb..897bc9642541 100644
--- a/src/languages/el.ts
+++ b/src/languages/el.ts
@@ -1282,6 +1282,8 @@ const translations: TranslationDeepObject = {
createTimeExpense: 'Δημιουργία χρονοχρέωσης',
},
iou: {
+ expenseAdded: 'Δαπάνη προστέθηκε',
+ invoiceSent: 'Τιμολόγιο στάλθηκε',
amount: 'Ποσό',
percent: 'Ποσοστό',
date: 'Ημερομηνία',
diff --git a/src/languages/en.ts b/src/languages/en.ts
index 261d9a2d40bc..658c92aa43d8 100644
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -1310,6 +1310,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 7324ec80db0e..86346e69b6ac 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -1229,6 +1229,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 ee49a6dd16b8..6851d2d006f5 100644
--- a/src/languages/fr.ts
+++ b/src/languages/fr.ts
@@ -1235,6 +1235,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 c33b91bad7fd..6cc984d7cae4 100644
--- a/src/languages/it.ts
+++ b/src/languages/it.ts
@@ -1230,6 +1230,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 9b9d5144f450..57733e7674d2 100644
--- a/src/languages/ja.ts
+++ b/src/languages/ja.ts
@@ -1214,6 +1214,8 @@ const translations: TranslationDeepObject = {
createTimeExpense: '時間経費を作成',
},
iou: {
+ expenseAdded: '経費を追加しました',
+ invoiceSent: '請求書を送信しました',
amount: '金額',
percent: 'パーセント',
date: '日付',
diff --git a/src/languages/nl.ts b/src/languages/nl.ts
index 5fc037f0b041..1088c797e426 100644
--- a/src/languages/nl.ts
+++ b/src/languages/nl.ts
@@ -1229,6 +1229,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 81eca1b97b0d..3504d337148f 100644
--- a/src/languages/pl.ts
+++ b/src/languages/pl.ts
@@ -1225,6 +1225,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 e59b9f162252..cd2f9fb0e7e7 100644
--- a/src/languages/pt-BR.ts
+++ b/src/languages/pt-BR.ts
@@ -1229,6 +1229,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 7cc8a1c5ba26..b5a8486cf586 100644
--- a/src/languages/zh-hans.ts
+++ b/src/languages/zh-hans.ts
@@ -1180,6 +1180,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 288351f180b0..443b27f5d572 100644
--- a/src/libs/ExportOnyxState/common.ts
+++ b/src/libs/ExportOnyxState/common.ts
@@ -207,6 +207,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,
@@ -333,7 +334,6 @@ const safeOnyxKeys = new Set([
ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_FAILED,
ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_PENDING,
ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_SUCCESSFUL,
- ONYXKEYS.TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE,
ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS,
ONYXKEYS.TRAVEL_BILLING_STATEMENT,
ONYXKEYS.VALIDATE_DOMAIN_TWO_FACTOR_CODE,
diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts
index 5a9673ca36b1..7af6321f3942 100644
--- a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts
+++ b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts
@@ -1,8 +1,10 @@
import {addPendingNewTransactionIDs} from '@libs/actions/IOU/PendingNewTransactions';
+import {setActiveTransactionIDs} from '@libs/actions/TransactionThreadNavigation';
import getIsNarrowLayout from '@libs/getIsNarrowLayout';
import Log from '@libs/Log';
import {getPreservedNavigatorState} from '@libs/Navigation/AppNavigator/createSplitNavigator/usePreserveNavigatorState';
import Navigation, {navigationRef} from '@libs/Navigation/Navigation';
+import {getReportTransactions} from '@libs/ReportUtils';
import {buildCannedSearchQuery, getCurrentSearchQueryJSON} from '@libs/SearchQueryUtils';
import {setPendingSubmitFollowUpAction} from '@libs/telemetry/submitFollowUpAction';
@@ -12,16 +14,31 @@ import ROUTES from '@src/ROUTES';
import SCREENS from '@src/SCREENS';
import dismissModalAndOpenReportInInboxTab from './dismissModalAndOpenReportInInboxTab';
+import isReportOpenInRHP from './isReportOpenInRHP';
import isReportTopmostSplitNavigator from './isReportTopmostSplitNavigator';
import isSearchTopmostFullScreenRoute from './isSearchTopmostFullScreenRoute';
+import setNavigationActionToMicrotaskQueue from './setNavigationActionToMicrotaskQueue';
type NavigateAfterExpenseCreateParams = {
+ /** Report the expense was created in */
activeReportID?: string;
+
+ /** The created transaction's ID */
transactionID?: string;
+
+ /** Whether the expense was started from the global create flow (FAB/no existing report) rather than from within a report */
isFromGlobalCreate?: boolean;
+
+ /** Whether the created item is an invoice rather than a regular expense */
isInvoice?: boolean;
+
+ /** Whether the destination report already contains transactions */
hasMultipleTransactions: boolean;
+
+ /** Whether to record the transaction ID in the report's pendingNewTransactionIDs metadata, used to highlight the newly-added row */
shouldAddPendingNewTransactionIDs?: boolean;
+
+ /** Whether to perform navigation, or only run the side effects */
shouldNavigate?: boolean;
/**
@@ -48,12 +65,78 @@ function getNavigateAfterCreateSearchNavigatorState() {
return searchNavigatorRoute?.state ?? (searchNavigatorRoute?.key ? getPreservedNavigatorState(searchNavigatorRoute.key) : undefined);
}
+function getCurrentRouteBackTo() {
+ const params = navigationRef.current?.getCurrentRoute()?.params;
+ if (typeof params !== 'object' || params === null || !('backTo' in params) || typeof params.backTo !== 'string') {
+ return undefined;
+ }
+ return params.backTo;
+}
+
+type NavigateToCreatedExpenseParams = {
+ /** The transaction thread report to open. */
+ threadReportID: string;
+
+ /** The created transaction's ID. */
+ transactionID: string;
+
+ /** IOU report the transaction landed in, used to decide whether to stack the expense report underneath. */
+ iouReportID?: string;
+};
+
+/**
+ * Opens a just-created expense when "View" is pressed on the "Expense added" growl. The user may have
+ * switched tabs while the growl was up, so the destination follows wherever they are now.
+ */
+function navigateToCreatedExpense({threadReportID, transactionID, iouReportID}: NavigateToCreatedExpenseParams) {
+ const openOnInbox = isReportTopmostSplitNavigator() && !isSearchTopmostFullScreenRoute();
+
+ // When a report/expense is already open in the RHP the app's convention is to replace it rather than stack a second
+ // report RHP on top of it.
+ const forceReplace = isReportOpenInRHP(navigationRef.getRootState());
+ const backTo = forceReplace ? getCurrentRouteBackTo() : Navigation.getActiveRoute();
+
+ if (!openOnInbox) {
+ setActiveTransactionIDs([transactionID]).then(() => {
+ Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: threadReportID, backTo}), {forceReplace});
+ });
+ return;
+ }
+
+ if (getIsNarrowLayout()) {
+ Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(threadReportID, undefined, undefined, backTo), {forceReplace});
+ return;
+ }
+ if (iouReportID) {
+ Navigation.navigate(ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: iouReportID, backTo}), {forceReplace});
+
+ // A multi-transaction report opens super wide RHP, so stack the thread RHP on top of it. A single-transaction
+ // report collapses to the thread itself, so the navigation above already landed on it.
+ const hasMultipleReportTransactions =
+ getReportTransactions(iouReportID).filter((transaction) => transaction?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE).length > 1;
+ if (hasMultipleReportTransactions) {
+ // Defer so the thread RHP stacks on top of the expense report navigation above. This is always a
+ // push (never a replace) - it stacks on the report we just opened, not on the previously-open one.
+ setNavigationActionToMicrotaskQueue(() => {
+ setActiveTransactionIDs([transactionID]).then(() => {
+ Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: threadReportID, backTo: Navigation.getActiveRoute()}));
+ });
+ });
+ }
+ return;
+ }
+
+ // A tracked expense has no expense report, so open the thread as a full report - the same way tapping
+ // the expense in its self-DM chat does.
+ Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(threadReportID, undefined, undefined, backTo), {forceReplace});
+}
+
/**
* Helper to navigate after an expense is created in order to standardize the post‑creation experience
* when creating an expense from the global create button.
* If the expense is created from the global create button then:
* - If it is created on the inbox tab, it will open the chat report containing that expense.
- * - If it is created elsewhere, it will navigate to Reports > Expense and highlight the newly created expense.
+ * - If it is created elsewhere, it will navigate to Reports > Expense and show the "Expense added" growl.
*/
function navigateAfterExpenseCreate({
activeReportID,
@@ -130,3 +213,4 @@ function navigateAfterExpenseCreate({
}
export default navigateAfterExpenseCreate;
+export {navigateToCreatedExpense};
diff --git a/src/libs/actions/IOU/Duplicate.ts b/src/libs/actions/IOU/Duplicate.ts
index 16b8390baa7d..d8f49a894386 100644
--- a/src/libs/actions/IOU/Duplicate.ts
+++ b/src/libs/actions/IOU/Duplicate.ts
@@ -67,6 +67,7 @@ import {getAllReports, getAllTransactions} from '.';
import {getCleanUpTransactionThreadReportOnyxData} from './DeleteMoneyRequest';
import {getMoneyRequestParticipantsFromReport} from './MoneyRequest';
import {submitPerDiemExpense} from './PerDiem';
+import signalExpenseAddedGrowl from './signalExpenseAddedGrowl';
import {createDistanceRequest} from './Split';
import {requestMoney, trackExpense} from './TrackExpense';
@@ -1244,6 +1245,7 @@ function bulkDuplicateExpenses({
// A copy of an unreported expense is tracked, so only an expense that lands on the report can submit it.
const lastReportBoundIndex = targetPolicy ? transactionsToDuplicate.findLastIndex((t) => !!t.reportID && t.reportID !== CONST.REPORT.UNREPORTED_REPORT_ID) : -1;
+ let lastDuplicateTransactionID: string | undefined;
for (let i = 0; i < transactionsToDuplicate.length; i++) {
const item = transactionsToDuplicate.at(i);
@@ -1314,6 +1316,9 @@ function bulkDuplicateExpenses({
if (result?.iouReport) {
optimisticIOUReport = result.iouReport;
}
+ if (result?.transactionID) {
+ lastDuplicateTransactionID = result.transactionID;
+ }
if (currentTargetReport && !currentTargetReport.iouReportID) {
currentTargetReport = {...currentTargetReport, iouReportID: currentOptimisticIOUReportID};
@@ -1321,6 +1326,7 @@ function bulkDuplicateExpenses({
}
playSound(SOUNDS.DONE);
+ signalExpenseAddedGrowl(lastDuplicateTransactionID, CONST.SEARCH.DATA_TYPES.EXPENSE);
}
type BulkDuplicateReportsParams = {
diff --git a/src/libs/actions/IOU/NavigationHelpers.ts b/src/libs/actions/IOU/NavigationHelpers.ts
deleted file mode 100644
index 913a80c8a8eb..000000000000
--- a/src/libs/actions/IOU/NavigationHelpers.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import isReportTopmostSplitNavigator from '@libs/Navigation/helpers/isReportTopmostSplitNavigator';
-import navigateAfterExpenseCreate from '@libs/Navigation/helpers/navigateAfterExpenseCreate';
-
-import {mergeTransactionIdsHighlightOnSearchRoute} from '@userActions/Transaction';
-
-import type {SearchDataTypes} from '@src/types/onyx/SearchResults';
-
-import {getAllTransactions} from './index';
-
-/**
- * Marks a transaction for highlight on the Search page when the expense was created
- * from the global create button and the user is not on the Inbox tab.
- */
-function highlightTransactionOnSearchRouteIfNeeded(isFromGlobalCreate: boolean | undefined, transactionID: string | undefined, dataType: SearchDataTypes) {
- if (!isFromGlobalCreate || isReportTopmostSplitNavigator() || !transactionID) {
- return;
- }
- mergeTransactionIdsHighlightOnSearchRoute(dataType, {[transactionID]: true});
-}
-
-/**
- * Helper to navigate after an expense is created in order to standardize the post‑creation experience
- * when creating an expense from the global create button.
- * If the expense is created from the global create button then:
- * - If it is created on the inbox tab, it will open the chat report containing that expense.
- * - If it is created elsewhere, it will navigate to Reports > Expense and highlight the newly created expense.
- */
-function handleNavigateAfterExpenseCreate({
- activeReportID,
- transactionID,
- isFromGlobalCreate,
- isInvoice,
- shouldAddPendingNewTransactionIDs = false,
- shouldNavigate = true,
-}: {
- activeReportID?: string;
- transactionID?: string;
- isFromGlobalCreate?: boolean;
- isInvoice?: boolean;
- shouldAddPendingNewTransactionIDs?: boolean;
- shouldNavigate?: boolean;
-}) {
- const hasMultipleTransactions = Object.values(getAllTransactions()).filter((transaction) => transaction?.reportID === activeReportID).length > 0;
- navigateAfterExpenseCreate({activeReportID, transactionID, isFromGlobalCreate, isInvoice, hasMultipleTransactions, shouldAddPendingNewTransactionIDs, shouldNavigate});
-}
-
-export {handleNavigateAfterExpenseCreate, highlightTransactionOnSearchRouteIfNeeded};
diff --git a/src/libs/actions/IOU/PerDiem.ts b/src/libs/actions/IOU/PerDiem.ts
index 9dd34328f413..bb118e835aef 100644
--- a/src/libs/actions/IOU/PerDiem.ts
+++ b/src/libs/actions/IOU/PerDiem.ts
@@ -71,7 +71,7 @@ import {
mergePolicyRecentlyUsedCategories,
mergePolicyRecentlyUsedCurrencies,
} from './MoneyRequestBuilder';
-import {highlightTransactionOnSearchRouteIfNeeded} from './NavigationHelpers';
+import signalExpenseAddedGrowl from './signalExpenseAddedGrowl';
function removeSubrate(transaction: OnyxEntry, currentIndex: string) {
// Index comes from the route params and is a string
@@ -1124,7 +1124,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 68e8cd52b51b..1034b9e87083 100644
--- a/src/libs/actions/IOU/SendInvoice.ts
+++ b/src/libs/actions/IOU/SendInvoice.ts
@@ -44,8 +44,8 @@ import type BasePolicyParams from './types/BasePolicyParams';
import {getAllPersonalDetails} from '.';
import {getReceiptError, mergePolicyRecentlyUsedCategories, mergePolicyRecentlyUsedCurrencies} from './MoneyRequestBuilder';
-import {highlightTransactionOnSearchRouteIfNeeded} from './NavigationHelpers';
import {getSearchOnyxUpdate} from './SearchUpdate';
+import signalExpenseAddedGrowl from './signalExpenseAddedGrowl';
type SendInvoiceInformation = {
senderWorkspaceID: string | undefined;
@@ -892,7 +892,9 @@ function sendInvoice({
onDeferred: () => addOptimization(CONST.TELEMETRY.SUBMIT_OPTIMIZATION.DEFERRED_WRITE),
});
- highlightTransactionOnSearchRouteIfNeeded(isFromGlobalCreate, transactionID, CONST.SEARCH.DATA_TYPES.INVOICE);
+ if (isFromGlobalCreate) {
+ signalExpenseAddedGrowl(transactionID, CONST.SEARCH.DATA_TYPES.INVOICE);
+ }
notifyNewAction(invoiceRoom.reportID, undefined, true);
}
diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts
index 10eb3e5da269..be1eae5a84f5 100644
--- a/src/libs/actions/IOU/Split.ts
+++ b/src/libs/actions/IOU/Split.ts
@@ -88,8 +88,8 @@ import {
mergePolicyRecentlyUsedCategories,
mergePolicyRecentlyUsedCurrencies,
} from './MoneyRequestBuilder';
-import {highlightTransactionOnSearchRouteIfNeeded} from './NavigationHelpers';
import {addPendingNewTransactionIDs, isOneToTwoTransactionTransition} from './PendingNewTransactions';
+import signalExpenseAddedGrowl from './signalExpenseAddedGrowl';
type IOURequestType = ValueOf;
@@ -2361,7 +2361,9 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest
onDeferred: () => addOptimization(CONST.TELEMETRY.SUBMIT_OPTIMIZATION.DEFERRED_WRITE),
});
- highlightTransactionOnSearchRouteIfNeeded(isFromGlobalCreate, parameters.transactionID, CONST.SEARCH.DATA_TYPES.EXPENSE);
+ if (isFromGlobalCreate) {
+ signalExpenseAddedGrowl(parameters.transactionID, CONST.SEARCH.DATA_TYPES.EXPENSE);
+ }
if (!isMoneyRequestReport) {
notifyNewAction(activeReportID, undefined, true);
diff --git a/src/libs/actions/IOU/SplitTransactionUpdate.ts b/src/libs/actions/IOU/SplitTransactionUpdate.ts
index ce08a7c7ea07..9eaa636df960 100644
--- a/src/libs/actions/IOU/SplitTransactionUpdate.ts
+++ b/src/libs/actions/IOU/SplitTransactionUpdate.ts
@@ -45,7 +45,6 @@ import {
navigateBackOnDeleteTransaction,
updateOptimisticParentReportAction,
} from '@libs/ReportUtils';
-import {getCurrentSearchQueryJSON} from '@libs/SearchQueryUtils';
import {isTracking, setPendingSubmitFollowUpAction} from '@libs/telemetry/submitFollowUpAction';
import {
getChildTransactions,
@@ -56,7 +55,6 @@ import {
} from '@libs/TransactionUtils';
import {setDeleteTransactionNavigateBackUrl} from '@userActions/Report';
-import {mergeTransactionIdsHighlightOnSearchRoute} from '@userActions/Transaction';
import {removeDraftSplitTransaction} from '@userActions/TransactionEdit';
import CONST from '@src/CONST';
@@ -83,6 +81,7 @@ import {getCleanUpTransactionThreadReportOnyxData} from './DeleteMoneyRequest';
import {getAllReports} from './index';
import {getMoneyRequestParticipantsFromReport} from './MoneyRequest';
import {getMoneyRequestInformation, getReportPreviewReportAction} from './MoneyRequestBuilder';
+import signalExpenseAddedGrowl from './signalExpenseAddedGrowl';
import {getDeleteTrackExpenseInformation} from './TrackExpense';
import {getUpdateMoneyRequestParams} from './UpdateMoneyRequest';
@@ -2106,33 +2105,13 @@ function updateSplitTransactionsFromSplitExpensesFlow(params: UpdateSplitTransac
const targetReportID = params.expenseReport?.reportID ?? String(CONST.DEFAULT_NUMBER_ID);
- // Register newly created split transaction IDs so they briefly highlight on the Search/Spend page.
- // The Search page reads TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE, which highlights matching rows
- // optimistically without waiting for a server re-search. Unlike the auto-detect path in
- // useSearchHighlightAndScroll (skipped while offline), this makes the highlight work offline too.
- // Reverse splits create no new transactions, and existing children are already in the list, so both are skipped.
- function registerSearchRouteHighlight() {
- if (!isSearchPageTopmostFullScreenRoute || isReverseSplitOperation) {
- return;
- }
- const currentSearchType = getCurrentSearchQueryJSON()?.type;
- if (!currentSearchType) {
- return;
- }
- const newTransactionIDsToHighlight: Record = {};
- for (const transactionID of getNewSplitTransactionIDs()) {
- newTransactionIDsToHighlight[transactionID] = true;
- }
- if (isEmptyObject(newTransactionIDsToHighlight)) {
- return;
- }
- mergeTransactionIdsHighlightOnSearchRoute(currentSearchType, newTransactionIDsToHighlight);
+ if (!isReverseSplitOperation) {
+ signalExpenseAddedGrowl(getNewSplitTransactionIDs().at(-1), CONST.SEARCH.DATA_TYPES.EXPENSE);
}
if (isSearchPageTopmostFullScreenRoute || !params.transactionReport?.parentReportID) {
- registerSearchRouteHighlight();
// Returns to Search, not the expense report, so rail flags would sit unconsumed and highlight stale rows the
- // next time that report is opened from the Inbox. registerSearchRouteHighlight above covers this page instead.
+ // next time that report is opened from the Inbox.
updateSplitTransactions({...params, isFromSplitExpensesFlow: true, shouldSkipReportHighlightRail: true});
if (!isSelfDMSplit) {
diff --git a/src/libs/actions/IOU/TrackExpense.ts b/src/libs/actions/IOU/TrackExpense.ts
index 32216b419209..764945ec38a4 100644
--- a/src/libs/actions/IOU/TrackExpense.ts
+++ b/src/libs/actions/IOU/TrackExpense.ts
@@ -131,9 +131,9 @@ import {
getReportPreviewReportAction,
getTransactionWithPreservedLocalReceiptSource,
} from './MoneyRequestBuilder';
-import {highlightTransactionOnSearchRouteIfNeeded} from './NavigationHelpers';
import {addPendingNewTransactionIDs, isOneToTwoTransactionTransition} from './PendingNewTransactions';
import {getSearchOnyxUpdate} from './SearchUpdate';
+import signalExpenseAddedGrowl from './signalExpenseAddedGrowl';
type TrackExpenseInformation = {
createdWorkspaceParams?: CreateWorkspaceParams;
@@ -1644,7 +1644,7 @@ function convertTrackedExpenseToRequest(convertTrackedExpenseParams: ConvertTrac
/**
* Submit expense to another user
*/
-function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouReport?: OnyxTypes.Report} {
+function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouReport?: OnyxTypes.Report; transactionID?: string} {
const {
report,
existingIOUReport,
@@ -1949,8 +1949,8 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep
});
}
- if (!requestMoneyInformation.isRetry) {
- highlightTransactionOnSearchRouteIfNeeded(isFromGlobalCreate, transaction.transactionID, CONST.SEARCH.DATA_TYPES.EXPENSE);
+ if (!requestMoneyInformation.isRetry && isFromGlobalCreate) {
+ signalExpenseAddedGrowl(transaction.transactionID, CONST.SEARCH.DATA_TYPES.EXPENSE);
}
if (activeReportID && !isMoneyRequestReport) {
@@ -1961,7 +1961,7 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep
);
}
- return {iouReport};
+ return {iouReport, transactionID: transaction.transactionID};
}
/**
@@ -2908,11 +2908,13 @@ function trackExpense(params: CreateTrackExpenseParams) {
}
}
- if (!params.isRetry) {
- highlightTransactionOnSearchRouteIfNeeded(isFromGlobalCreate, transaction?.transactionID, CONST.SEARCH.DATA_TYPES.EXPENSE);
+ if (!params.isRetry && isFromGlobalCreate) {
+ signalExpenseAddedGrowl(transaction?.transactionID, CONST.SEARCH.DATA_TYPES.EXPENSE);
}
notifyNewAction(activeReportID, undefined, payeeAccountID === currentUserAccountIDParam);
+
+ return {iouReport, transactionID: transaction?.transactionID};
}
/**
diff --git a/src/libs/actions/IOU/signalExpenseAddedGrowl.ts b/src/libs/actions/IOU/signalExpenseAddedGrowl.ts
new file mode 100644
index 000000000000..9ca2858f8dc3
--- /dev/null
+++ b/src/libs/actions/IOU/signalExpenseAddedGrowl.ts
@@ -0,0 +1,13 @@
+import {mergeExpenseAddedGrowlTransactionIDs} from '@userActions/Transaction';
+
+import type {SearchDataTypes} from '@src/types/onyx/SearchResults';
+
+/** Queues the "Expense added" growl (toast) for a newly-created transaction. */
+function signalExpenseAddedGrowl(transactionID: string | undefined, dataType: SearchDataTypes) {
+ if (!transactionID) {
+ return;
+ }
+ mergeExpenseAddedGrowlTransactionIDs({[transactionID]: dataType});
+}
+
+export default signalExpenseAddedGrowl;
diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts
index 085a12a4140d..b2e4f2a38fa3 100644
--- a/src/libs/actions/Transaction.ts
+++ b/src/libs/actions/Transaction.ts
@@ -2034,8 +2034,8 @@ function getDefaultP2PMileageRate() {
API.read(READ_COMMANDS.GET_DEFAULT_P2P_MILEAGE_RATE, null);
}
-function 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) {
@@ -2072,7 +2072,7 @@ export {
getChangeTransactionsReportOnyxData,
setTransactionReport,
getDefaultP2PMileageRate,
- mergeTransactionIdsHighlightOnSearchRoute,
+ mergeExpenseAddedGrowlTransactionIDs,
getDuplicateTransactionDetails,
setSelectedRoute,
};
diff --git a/src/libs/telemetry/submitFollowUpAction.ts b/src/libs/telemetry/submitFollowUpAction.ts
index 8cbb576573cc..bb66919ebdae 100644
--- a/src/libs/telemetry/submitFollowUpAction.ts
+++ b/src/libs/telemetry/submitFollowUpAction.ts
@@ -87,7 +87,7 @@ function isSameFlowUpdate(pending: NonNullable, fol
return true;
}
// The fast path (pre-insert) sets NAVIGATE_TO_SEARCH before createTransaction runs.
- // handleNavigateAfterExpenseCreate may later call with DISMISS_MODAL_ONLY because it
+ // navigateAfterExpenseCreate may later call with DISMISS_MODAL_ONLY because it
// sees the Search page as already on top. Treat this as same-flow - keep the original.
return pending.followUpAction === CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.NAVIGATE_TO_SEARCH && followUpAction === CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.DISMISS_MODAL_ONLY;
}
@@ -105,7 +105,7 @@ function setPendingSubmitFollowUpAction(followUpAction: SubmitFollowUpAction, re
if (pending !== null && span && isSameFlowUpdate(pending, followUpAction, reportID)) {
// Same flow: only update when the new action is a genuine refinement (e.g.
// DISMISS_MODAL_ONLY -> DISMISS_MODAL_AND_OPEN_REPORT). When the fast path set
- // NAVIGATE_TO_SEARCH and handleNavigateAfterExpenseCreate later calls with
+ // NAVIGATE_TO_SEARCH and navigateAfterExpenseCreate later calls with
// DISMISS_MODAL_ONLY (because Search is already on top), preserve the original
// action so telemetry correctly reflects the pre-insert path.
const isRefinement =
diff --git a/src/pages/Share/SubmitDetailsPage.tsx b/src/pages/Share/SubmitDetailsPage.tsx
index 76fa14b8bb18..5130a20f7e96 100644
--- a/src/pages/Share/SubmitDetailsPage.tsx
+++ b/src/pages/Share/SubmitDetailsPage.tsx
@@ -31,6 +31,7 @@ import {
updateLastLocationPermissionPrompt,
} from '@libs/actions/IOU/MoneyRequest';
import {setMoneyRequestReceipt} from '@libs/actions/IOU/Receipt';
+import signalExpenseAddedGrowl from '@libs/actions/IOU/signalExpenseAddedGrowl';
import {requestMoney, trackExpense} from '@libs/actions/IOU/TrackExpense';
import type {GPSPoint as GpsPoint} from '@libs/actions/IOU/types/TrackExpenseTransactionParams';
import {WRITE_COMMANDS} from '@libs/API/types';
@@ -466,6 +467,10 @@ function SubmitDetailsPage({
optimisticChatReportID: routeReportID,
});
}
+
+ // requestMoney/trackExpense only signal the growl for the global-create flow and the share
+ // extension isn't flagged as such, so signal it here instead.
+ signalExpenseAddedGrowl(optimisticTransactionID, CONST.SEARCH.DATA_TYPES.EXPENSE);
};
const cleanupParams = {
diff --git a/src/setup/index.ts b/src/setup/index.ts
index 2d0f25d0f3aa..28acc8326031 100644
--- a/src/setup/index.ts
+++ b/src/setup/index.ts
@@ -58,6 +58,9 @@ export default function () {
// Ensure the Supportal permission modal doesn't persist across reloads
[ONYXKEYS.SUPPORTAL_PERMISSION_DENIED]: null,
[ONYXKEYS.IS_OPEN_APP_FAILURE_MODAL_OPEN]: false,
+ // The "Expense added" growl confirms an expense created this session. Drop any signal left over
+ // from a prior session (e.g. force-quit before it was consumed) so it can't surface on next launch.
+ [ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS]: {},
},
skippableCollectionMemberIDs: CONST.SKIPPABLE_COLLECTION_MEMBER_IDS,
snapshotMergeKeys: ['pendingAction', 'pendingFields'],
diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts
index 16f749d76756..8d2d6ee0bf32 100644
--- a/src/styles/utils/index.ts
+++ b/src/styles/utils/index.ts
@@ -1923,6 +1923,10 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({
borderColor: isSelected ? theme.buttonHoveredBG : theme.border,
}),
+ getSearchRowBackgroundStyle: (isSelected?: boolean): ViewStyle => ({
+ backgroundColor: isSelected ? theme.activeComponentBG : theme.highlightBG,
+ }),
+
getSearchTableHighlightBorderRadius: (isLargeScreenWidth: boolean): number => (isLargeScreenWidth ? 0 : variables.componentBorderRadius),
getReportTableColumnStyles: (columnName: string, options: GetReportTableColumnStylesParams = {}): ViewStyle => {
diff --git a/tests/actions/IOU/SplitReportTotalsTest.ts b/tests/actions/IOU/SplitReportTotalsTest.ts
index 3bb94529effa..0cd68e66ca7f 100644
--- a/tests/actions/IOU/SplitReportTotalsTest.ts
+++ b/tests/actions/IOU/SplitReportTotalsTest.ts
@@ -1,9 +1,7 @@
-import {handleNavigateAfterExpenseCreate} from '@libs/actions/IOU/NavigationHelpers';
import '@libs/actions/IOU/MoneyRequest';
import {createSplitsAndOnyxData} from '@libs/actions/IOU/Split';
import {updateSplitTransactionsFromSplitExpensesFlow} from '@libs/actions/IOU/SplitTransactionUpdate';
import initOnyxDerivedValues from '@libs/actions/OnyxDerived';
-import isReportTopmostSplitNavigator from '@libs/Navigation/helpers/isReportTopmostSplitNavigator';
import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute';
import {rand64} from '@libs/NumberUtils';
import type * as PolicyUtils from '@libs/PolicyUtils';
@@ -24,6 +22,7 @@ import Onyx from 'react-native-onyx';
import currencyList from '../../unit/currencyList.json';
import createMock from '../../utils/createMock';
+import getOnyxValue from '../../utils/getOnyxValue';
import {getGlobalFetchMock, formatPhoneNumber, getCurrencyDecimalsLocal, getCurrencySymbolLocal} from '../../utils/TestHelper';
import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates';
@@ -433,33 +432,6 @@ describe('actions/IOU', () => {
});
});
- it('handleNavigateAfterExpenseCreate', async () => {
- const mockedIsReportTopmostSplitNavigator = jest.mocked(isReportTopmostSplitNavigator);
- const spyOnMergeTransactionIdsHighlightOnSearchRoute = jest.spyOn(require('@libs/actions/Transaction'), 'mergeTransactionIdsHighlightOnSearchRoute');
- const activeReportID = '1';
- const transactionID = '1';
- mockedIsReportTopmostSplitNavigator.mockReturnValue(false);
-
- handleNavigateAfterExpenseCreate({activeReportID, isFromGlobalCreate: false});
- expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledTimes(0);
-
- handleNavigateAfterExpenseCreate({activeReportID, isFromGlobalCreate: true});
- expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledTimes(0);
-
- mockedIsReportTopmostSplitNavigator.mockReturnValue(true);
- handleNavigateAfterExpenseCreate({activeReportID, isFromGlobalCreate: true, transactionID});
- expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledTimes(0);
-
- mockedIsReportTopmostSplitNavigator.mockReturnValue(false);
- handleNavigateAfterExpenseCreate({activeReportID, isFromGlobalCreate: true, transactionID});
- expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledTimes(0);
-
- handleNavigateAfterExpenseCreate({activeReportID, isFromGlobalCreate: true, transactionID, isInvoice: true});
- expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledTimes(0);
-
- spyOnMergeTransactionIdsHighlightOnSearchRoute.mockReset();
- });
-
describe('createSplitsAndOnyxData', () => {
const mockPersonalDetails: PersonalDetailsList = {
[RORY_ACCOUNT_ID]: {accountID: RORY_ACCOUNT_ID, login: RORY_EMAIL, displayName: 'Rory'},
@@ -781,6 +753,18 @@ describe('actions/IOU', () => {
};
}
+ function getPendingNewTransactionIDsFromOnyx(reportID: string) {
+ return new Promise | undefined>((resolve) => {
+ const connection = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`,
+ callback: (metadata) => {
+ Onyx.disconnect(connection);
+ resolve(metadata?.pendingNewTransactionIDs);
+ },
+ });
+ });
+ }
+
it('skips registration during a reverse split operation', async () => {
// Given one existing child transaction (triggers isReverseSplitOperation when splitExpenses.length === 1)
const existingChildTx = {
@@ -839,56 +823,6 @@ describe('actions/IOU', () => {
expect(pendingNewTransactionIDs?.['new-tx-2']).toBeUndefined();
});
- it('registers the search-route highlight (not report metadata) when splitting from the Search/Spend page', async () => {
- // Given the user is on the Search (Spend > Expenses) page, where the expense report is never opened
- jest.mocked(isSearchTopmostFullScreenRoute).mockReturnValue(true);
- const spyOnMergeTransactionIdsHighlightOnSearchRoute = jest.spyOn(require('@libs/actions/Transaction'), 'mergeTransactionIdsHighlightOnSearchRoute');
- const params = buildBaseParams({
- transactionData: {
- reportID: EXPENSE_REPORT_ID,
- originalTransactionID: ORIGINAL_TX_ID,
- splitExpenses: [
- {transactionID: 'new-tx-1', reportID: EXPENSE_REPORT_ID, statusNum: 0, amount: 500, created: '2024-01-01'},
- {transactionID: 'new-tx-2', reportID: EXPENSE_REPORT_ID, statusNum: 0, amount: 500, created: '2024-01-01'},
- ],
- splitExpensesTotal: 1000,
- },
- });
-
- // When saving the split from the Search page
- updateSplitTransactionsFromSplitExpensesFlow(params);
- await waitForBatchedUpdates();
-
- // Then the new IDs are registered on the search-route highlight, keyed by the current search type.
- // (The report-metadata rail is asserted separately, in the REPORT_METADATA test below.)
- // This mechanism highlights optimistically without a server re-search, so it works offline too.
- expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledWith(
- 'expense',
- Object.fromEntries([
- ['new-tx-1', true],
- ['new-tx-2', true],
- ]),
- );
-
- spyOnMergeTransactionIdsHighlightOnSearchRoute.mockRestore();
- });
-
- /**
- * Reads REPORT_METADATA directly: the flags are written as Onyx optimisticData, not through the mocked
- * addPendingNewTransactionIDs, so mock-only assertions cannot observe them - which is how this regressed.
- */
- function getPendingNewTransactionIDsFromOnyx(reportID: string) {
- return new Promise | undefined>((resolve) => {
- const connection = Onyx.connect({
- key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`,
- callback: (metadata) => {
- Onyx.disconnect(connection);
- resolve(metadata?.pendingNewTransactionIDs);
- },
- });
- });
- }
-
it('does not write pendingNewTransactionIDs into report metadata when splitting from the Search/Spend page', async () => {
// Given the user is on the Search (Spend > Expenses) page and the expense report already holds a transaction,
// so the new splits would otherwise satisfy the "add makes the report multi-transaction" highlight condition
@@ -963,10 +897,7 @@ describe('actions/IOU', () => {
expect(pendingNewTransactionIDs?.['existing-tx-2']).toBeUndefined();
});
- it('skips the search-route highlight during a reverse split from the Search/Spend page', async () => {
- // Given the user is on the Search page and this is a reverse split (1 expense, existing child present)
- jest.mocked(isSearchTopmostFullScreenRoute).mockReturnValue(true);
- const spyOnMergeTransactionIdsHighlightOnSearchRoute = jest.spyOn(require('@libs/actions/Transaction'), 'mergeTransactionIdsHighlightOnSearchRoute');
+ it('does not signal the "Expense added" growl during a reverse split operation', async () => {
const existingChildTx = {
transactionID: 'child-tx-1',
reportID: EXPENSE_REPORT_ID,
@@ -982,90 +913,11 @@ describe('actions/IOU', () => {
},
});
- // When saving the reverse split
- updateSplitTransactionsFromSplitExpensesFlow(params);
- await waitForBatchedUpdates();
-
- // Then nothing is highlighted — reverse splits create no new transactions
- expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).not.toHaveBeenCalled();
-
- spyOnMergeTransactionIdsHighlightOnSearchRoute.mockRestore();
- });
-
- it('registers the search-route highlight while offline, so the Spend page still highlights', async () => {
- // Given the user is offline on the Search page. The auto-detect path in useSearchHighlightAndScroll is
- // skipped while offline (it waits for a server re-search), so this rail is the only thing that can
- // highlight the new rows - a reviewer caught the highlight silently disappearing offline.
- jest.mocked(isSearchTopmostFullScreenRoute).mockReturnValue(true);
- const spyOnMergeTransactionIdsHighlightOnSearchRoute = jest.spyOn(require('@libs/actions/Transaction'), 'mergeTransactionIdsHighlightOnSearchRoute');
- const params = buildBaseParams({
- isOffline: true,
- transactionData: {
- reportID: EXPENSE_REPORT_ID,
- originalTransactionID: ORIGINAL_TX_ID,
- splitExpenses: [
- {transactionID: 'offline-tx-1', reportID: EXPENSE_REPORT_ID, statusNum: 0, amount: 500, created: '2024-01-01'},
- {transactionID: 'offline-tx-2', reportID: EXPENSE_REPORT_ID, statusNum: 0, amount: 500, created: '2024-01-01'},
- ],
- splitExpensesTotal: 1000,
- },
- });
-
- // When saving the split offline
- updateSplitTransactionsFromSplitExpensesFlow(params);
- await waitForBatchedUpdates();
-
- // Then BOTH new IDs are registered - a reviewer also caught only one of the two rows highlighting
- expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledWith(
- 'expense',
- Object.fromEntries([
- ['offline-tx-1', true],
- ['offline-tx-2', true],
- ]),
- );
-
- // And the report-metadata rail stays clean offline too
- const pendingNewTransactionIDs = await getPendingNewTransactionIDsFromOnyx(EXPENSE_REPORT_ID);
- expect(pendingNewTransactionIDs?.['offline-tx-1']).toBeUndefined();
- expect(pendingNewTransactionIDs?.['offline-tx-2']).toBeUndefined();
-
- spyOnMergeTransactionIdsHighlightOnSearchRoute.mockRestore();
- });
-
- it('leaves no highlight flags anywhere when splits move out to a different report', async () => {
- // Given splits that leave the expense report entirely (each lands in its own other report). Nothing should
- // be flagged on the source report, and the destination reports are never opened by this flow either, so
- // no rail entry may be left behind for any of them.
- jest.mocked(isSearchTopmostFullScreenRoute).mockReturnValue(false);
- const expenseReport = {reportID: EXPENSE_REPORT_ID, type: CONST.REPORT.TYPE.EXPENSE, parentReportID: 'parent-report-1', chatReportID: 'chat-report-1'} as Report;
- await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${EXPENSE_REPORT_ID}`, expenseReport);
- const existingTx = {transactionID: 'existing-tx-3', reportID: EXPENSE_REPORT_ID, amount: 1000};
- await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}existing-tx-3`, existingTx);
- const params = buildBaseParams({
- expenseReport,
- allReportsList: {[`${ONYXKEYS.COLLECTION.REPORT}${EXPENSE_REPORT_ID}`]: expenseReport},
- allTransactionsList: {[`${ONYXKEYS.COLLECTION.TRANSACTION}existing-tx-3`]: existingTx},
- transactionData: {
- reportID: EXPENSE_REPORT_ID,
- originalTransactionID: ORIGINAL_TX_ID,
- splitExpenses: [
- {transactionID: 'moved-tx-1', reportID: 'other-report-1', statusNum: 0, amount: 500, created: '2024-01-01'},
- {transactionID: 'moved-tx-2', reportID: 'other-report-2', statusNum: 0, amount: 500, created: '2024-01-01'},
- ],
- splitExpensesTotal: 1000,
- },
- });
-
- // When saving the split
updateSplitTransactionsFromSplitExpensesFlow(params);
await waitForBatchedUpdates();
- // Then neither the source report nor the destination reports carry stranded highlight flags
- const sourceRail = await getPendingNewTransactionIDsFromOnyx(EXPENSE_REPORT_ID);
- expect(sourceRail?.['moved-tx-1']).toBeUndefined();
- expect(sourceRail?.['moved-tx-2']).toBeUndefined();
- expect((await getPendingNewTransactionIDsFromOnyx('other-report-1'))?.['moved-tx-1']).toBeUndefined();
- expect((await getPendingNewTransactionIDsFromOnyx('other-report-2'))?.['moved-tx-2']).toBeUndefined();
+ const growlTransactionIDs = await getOnyxValue(ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS);
+ expect(growlTransactionIDs?.['new-merged-tx']).toBeUndefined();
});
});
});
diff --git a/tests/actions/IOUTest/SplitTest.ts b/tests/actions/IOUTest/SplitTest.ts
index c99eab2f8795..1a9e976d8830 100644
--- a/tests/actions/IOUTest/SplitTest.ts
+++ b/tests/actions/IOUTest/SplitTest.ts
@@ -9815,7 +9815,7 @@ describe('createDistanceRequest', () => {
expect(result.transactionID).toBeTruthy();
});
- it('flags the created transaction for Search highlight on a global-create distance request', async () => {
+ it('flags the created transaction for the "Expense added" growl on a global-create distance request', async () => {
const recentWaypoints = (await getOnyxValue(ONYXKEYS.NVP_RECENT_WAYPOINTS)) ?? [];
const result = createDistanceRequest({
@@ -9825,8 +9825,8 @@ describe('createDistanceRequest', () => {
await waitForBatchedUpdates();
- const highlight = await getOnyxValue(ONYXKEYS.TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE);
- expect(Object.keys(highlight?.[CONST.SEARCH.DATA_TYPES.EXPENSE] ?? {})).toContain(result.transactionID);
+ const growlSignal = await getOnyxValue(ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS);
+ expect(growlSignal?.[result.transactionID]).toBe(CONST.SEARCH.DATA_TYPES.EXPENSE);
});
it('flags the new transaction for the 1→2 highlight fallback when added to an expense report that already has one transaction', async () => {
diff --git a/tests/unit/ExpenseAddedGrowlTest.tsx b/tests/unit/ExpenseAddedGrowlTest.tsx
new file mode 100644
index 000000000000..b2513c16c2e6
--- /dev/null
+++ b/tests/unit/ExpenseAddedGrowlTest.tsx
@@ -0,0 +1,156 @@
+import {act, render} from '@testing-library/react-native';
+
+import ExpenseAddedGrowl from '@components/ExpenseAddedGrowl';
+
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {SearchDataTypes} from '@src/types/onyx/SearchResults';
+
+import Onyx from 'react-native-onyx';
+
+import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
+
+type GrowlContentProps = {bodyText: string; type: string};
+
+const mockGetTopmostReportId = jest.fn();
+
+const mockGrowlContent = jest.fn();
+jest.mock('@components/GrowlNotification/GrowlNotificationContent', () => (props: GrowlContentProps) => {
+ mockGrowlContent(props);
+ return null;
+});
+
+jest.mock('@libs/Navigation/Navigation', () => ({
+ getTopmostReportId: () => mockGetTopmostReportId(),
+ getActiveRoute: () => '',
+}));
+jest.mock('@libs/Navigation/helpers/navigateAfterExpenseCreate', () => ({
+ navigateToCreatedExpense: jest.fn(),
+}));
+jest.mock('@libs/actions/Report', () => ({
+ createTransactionThreadReport: jest.fn(),
+ setOptimisticTransactionThread: jest.fn(),
+}));
+jest.mock('@hooks/useLocalize', () => () => ({translate: (key: string) => key}));
+jest.mock('@hooks/useCurrentUserPersonalDetails', () => () => ({accountID: 1, login: 'me@example.com'}));
+
+const EXPENSE = CONST.SEARCH.DATA_TYPES.EXPENSE;
+const INVOICE = CONST.SEARCH.DATA_TYPES.INVOICE;
+
+function flush(mutate: () => Promise) {
+ return act(async () => {
+ await mutate();
+ await waitForBatchedUpdates();
+ });
+}
+
+function seedExpense(transactionID: string, reportID: string, dataType: SearchDataTypes = EXPENSE) {
+ return flush(async () => {
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {transactionID, reportID});
+ await Onyx.merge(ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS, {[transactionID]: dataType});
+ });
+}
+
+function lastGrowlProps() {
+ return mockGrowlContent.mock.calls.at(-1)?.[0];
+}
+
+describe('ExpenseAddedGrowl', () => {
+ beforeAll(() => {
+ Onyx.init({keys: ONYXKEYS});
+ });
+
+ beforeEach(async () => {
+ jest.clearAllMocks();
+ mockGetTopmostReportId.mockReturnValue(undefined);
+ await Onyx.clear();
+ await waitForBatchedUpdates();
+ });
+
+ it('shows the "Expense added" growl for a pending transaction when the user is not viewing its report', async () => {
+ // Given the growl is mounted and the user is not viewing any report
+ render();
+
+ // When an expense is created
+ await seedExpense('1', 'report-1');
+
+ // Then the "Expense added" success growl shows
+ expect(mockGrowlContent).toHaveBeenCalled();
+ expect(lastGrowlProps()?.bodyText).toBe('iou.expenseAdded');
+ expect(lastGrowlProps()?.type).toBe(CONST.GROWL.SUCCESS);
+ });
+
+ it('uses the invoice copy for an invoice', async () => {
+ // Given the growl is mounted and the user is not viewing any report
+ render();
+
+ // When an invoice is created
+ await seedExpense('1', 'report-1', INVOICE);
+
+ // Then the invoice copy is used instead of the expense one
+ expect(lastGrowlProps()?.bodyText).toBe('iou.invoiceSent');
+ });
+
+ it("suppresses the growl when the user is already viewing the expense's report", async () => {
+ // Given the user is already viewing report-1
+ mockGetTopmostReportId.mockReturnValue('report-1');
+ render();
+
+ // When an expense is created in that same report
+ await seedExpense('1', 'report-1');
+
+ // Then no growl shows, because the report already highlights the new expense
+ expect(mockGrowlContent).not.toHaveBeenCalled();
+ });
+
+ it('still shows for a tracked/unreported (self-DM) expense even when a report is open, since its reportID is UNREPORTED', async () => {
+ // Given the user is viewing some report
+ mockGetTopmostReportId.mockReturnValue('some-open-report');
+ render();
+
+ // When a tracked expense is created, which has no report to be viewed in
+ await seedExpense('1', CONST.REPORT.UNREPORTED_REPORT_ID);
+
+ // Then the growl still shows
+ expect(mockGrowlContent).toHaveBeenCalled();
+ });
+
+ it('shows a single growl for a batch of new expenses and clears the whole signal', async () => {
+ // Given the growl is mounted
+ render();
+
+ // When two expenses are created in the same batch
+ await flush(async () => {
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}1`, {transactionID: '1', reportID: 'report-1'});
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}2`, {transactionID: '2', reportID: 'report-1'});
+ const signal: Record = {};
+ signal['1'] = EXPENSE;
+ signal['2'] = EXPENSE;
+ await Onyx.merge(ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS, signal);
+ });
+
+ // Then exactly one growl shows for the batch
+ expect(mockGrowlContent).toHaveBeenCalledTimes(1);
+
+ // And the whole signal is consumed so it can't re-fire
+ const remaining = await new Promise | undefined>((resolve) => {
+ const connection = Onyx.connect({
+ key: ONYXKEYS.EXPENSE_ADDED_GROWL_TRANSACTION_IDS,
+ callback: (value) => {
+ Onyx.disconnect(connection);
+ resolve(value);
+ },
+ });
+ });
+ expect(remaining ?? {}).toEqual({});
+ });
+
+ it('does not show a growl when there is no pending signal', () => {
+ // Given no expense was created
+ // When the growl mounts
+ render();
+
+ // Then the growl doesn't show
+ expect(mockGrowlContent).not.toHaveBeenCalled();
+ });
+});
diff --git a/tests/unit/Search/useSearchSnapshotTest.ts b/tests/unit/Search/useSearchSnapshotTest.ts
index a2c0b2531f1f..a482fced92e0 100644
--- a/tests/unit/Search/useSearchSnapshotTest.ts
+++ b/tests/unit/Search/useSearchSnapshotTest.ts
@@ -179,7 +179,6 @@ describe('useSearchSnapshot', () => {
useSearchSnapshot({
queryJSON: makeQueryJSON(),
searchResults,
- newSearchResultKeys: undefined,
transactions: undefined,
reportActions: undefined,
}),
@@ -205,7 +204,6 @@ describe('useSearchSnapshot', () => {
useSearchSnapshot({
queryJSON: makeQueryJSON(),
searchResults: undefined,
- newSearchResultKeys: undefined,
transactions: undefined,
reportActions: undefined,
}),
@@ -224,7 +222,6 @@ describe('useSearchSnapshot', () => {
useSearchSnapshot({
queryJSON: makeQueryJSON(),
searchResults,
- newSearchResultKeys: undefined,
transactions: undefined,
reportActions: undefined,
}),
@@ -244,7 +241,6 @@ describe('useSearchSnapshot', () => {
useSearchSnapshot({
queryJSON: makeQueryJSON(),
searchResults,
- newSearchResultKeys: undefined,
transactions: undefined,
reportActions: undefined,
}),
@@ -267,7 +263,6 @@ describe('useSearchSnapshot', () => {
groupBy: CONST.SEARCH.GROUP_BY.FROM,
}),
searchResults,
- newSearchResultKeys: undefined,
transactions: undefined,
reportActions: undefined,
}),
@@ -288,7 +283,6 @@ describe('useSearchSnapshot', () => {
useSearchSnapshot({
queryJSON: makeQueryJSON(),
searchResults,
- newSearchResultKeys: undefined,
transactions: undefined,
reportActions: undefined,
}),
@@ -313,7 +307,6 @@ describe('useSearchSnapshot', () => {
useSearchSnapshot({
queryJSON: makeQueryJSON(),
searchResults,
- newSearchResultKeys: undefined,
transactions: undefined,
reportActions: undefined,
}),
@@ -341,7 +334,6 @@ describe('useSearchSnapshot', () => {
useSearchSnapshot({
queryJSON: makeQueryJSON(),
searchResults,
- newSearchResultKeys: undefined,
transactions: undefined,
reportActions: undefined,
}),
@@ -351,24 +343,6 @@ describe('useSearchSnapshot', () => {
expect(result.current.hasCachedOptimisticItem).toBe(true);
});
- it('stamps the post-create highlight on matching rows (newSearchResultKeys)', () => {
- const searchResults = makeSearchResults();
- mockUseOptimisticSearchTracking.mockReturnValue(trackingReturn(searchResults.data));
- mockGetSortedSections.mockReturnValue([{transactionID: '7', keyForList: '7'}]);
-
- const {result} = renderHook(() =>
- useSearchSnapshot({
- queryJSON: makeQueryJSON(),
- searchResults,
- newSearchResultKeys: new Set([`${ONYXKEYS.COLLECTION.TRANSACTION}7`]),
- transactions: undefined,
- reportActions: undefined,
- }),
- );
-
- expect(result.current.chartData.at(0)).toEqual(expect.objectContaining({shouldAnimateInHighlight: true}));
- });
-
it('passes the query type through to getSortedSections for each variant shape', () => {
const variants = [
CONST.SEARCH.DATA_TYPES.CHAT,
@@ -386,7 +360,6 @@ describe('useSearchSnapshot', () => {
useSearchSnapshot({
queryJSON: makeQueryJSON({type}),
searchResults,
- newSearchResultKeys: undefined,
transactions: undefined,
reportActions: undefined,
}),
@@ -414,7 +387,6 @@ describe('useSearchSnapshot', () => {
const props = {
queryJSON: makeQueryJSON(),
searchResults,
- newSearchResultKeys: undefined,
transactions: undefined,
reportActions: undefined,
};
diff --git a/tests/unit/actionLayerNavigationGuardTest.ts b/tests/unit/actionLayerNavigationGuardTest.ts
index f7b99adfbf93..55f34565dff7 100644
--- a/tests/unit/actionLayerNavigationGuardTest.ts
+++ b/tests/unit/actionLayerNavigationGuardTest.ts
@@ -4,8 +4,6 @@ import path from 'path';
/**
* Architecture guard for issue #84631: navigation belongs in the view layer, not in `src/libs/actions/`.
* `submitWithDismissFirst` is the view-layer dismiss-first orchestrator; no action file may import it.
- * (PR 9 will additionally enforce this via an ESLint `no-restricted-imports` rule once the legacy
- * `NavigationHelpers.ts` value-imports of the other view helpers are removed.)
*/
const ACTIONS_DIR = path.join(__dirname, '..', '..', 'src', 'libs', 'actions');
const FORBIDDEN_IMPORT = '@libs/Navigation/helpers/submitWithDismissFirst';
diff --git a/tests/unit/navigateAfterExpenseCreateTest.ts b/tests/unit/navigateAfterExpenseCreateTest.ts
index 26ec395ea9af..e08952eaf7a1 100644
--- a/tests/unit/navigateAfterExpenseCreateTest.ts
+++ b/tests/unit/navigateAfterExpenseCreateTest.ts
@@ -1,12 +1,15 @@
import type isReportOpenInRHP from '@libs/Navigation/helpers/isReportOpenInRHP';
import type isReportOpenInSuperWideRHP from '@libs/Navigation/helpers/isReportOpenInSuperWideRHP';
-import navigateAfterExpenseCreate from '@libs/Navigation/helpers/navigateAfterExpenseCreate';
+import navigateAfterExpenseCreate, {navigateToCreatedExpense} from '@libs/Navigation/helpers/navigateAfterExpenseCreate';
import Navigation from '@libs/Navigation/Navigation';
import type {getCurrentSearchQueryJSON} from '@libs/SearchQueryUtils';
import type {setPendingSubmitFollowUpAction} from '@libs/telemetry/submitFollowUpAction';
import CONST from '@src/CONST';
import ROUTES from '@src/ROUTES';
+import type {Transaction} from '@src/types/onyx';
+
+import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
const mockIsReportTopmostSplitNavigator = jest.fn();
const mockIsSearchTopmostFullScreenRoute = jest.fn();
@@ -17,6 +20,7 @@ const mockGetTrackingState = jest.fn();
// Declared but assigned after jest.mock hoisting - use require() to access the mock in tests
let mockSetPendingSubmitFollowUpAction: jest.MockedFunction;
const mockGetCurrentSearchQueryJSON = jest.fn, Parameters>();
+const mockGetReportTransactions = jest.fn>, [string | undefined]>();
jest.mock('@libs/Navigation/helpers/isReportTopmostSplitNavigator', () => () => mockIsReportTopmostSplitNavigator());
jest.mock('@libs/Navigation/helpers/isSearchTopmostFullScreenRoute', () => () => mockIsSearchTopmostFullScreenRoute());
@@ -35,6 +39,15 @@ jest.mock('@libs/SearchQueryUtils', () => ({
buildCannedSearchQuery: jest.fn(({type}: {type: string}) => `type:${type}`),
getCurrentSearchQueryJSON: mockGetCurrentSearchQueryJSON,
}));
+jest.mock('@libs/ReportUtils', () => {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- partial mock of the real module
+ const actual = jest.requireActual('@libs/ReportUtils');
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-return -- spread the real module and override one export
+ return {...actual, __esModule: true, getReportTransactions: (reportID: string | undefined) => mockGetReportTransactions(reportID)};
+});
+jest.mock('@libs/actions/TransactionThreadNavigation', () => ({
+ setActiveTransactionIDs: jest.fn(() => Promise.resolve()),
+}));
jest.mock('@libs/Navigation/Navigation', () => ({
dismissModal: jest.fn(),
@@ -42,6 +55,7 @@ jest.mock('@libs/Navigation/Navigation', () => ({
dismissModalWithReport: jest.fn(),
pop: jest.fn(),
navigate: jest.fn(),
+ getActiveRoute: jest.fn(() => ''),
revealRouteBeforeDismissingModal: jest.fn(),
isNavigationReady: jest.fn(() => Promise.resolve()),
getIsFullscreenPreInsertedUnderRHP: jest.fn(() => false),
@@ -69,6 +83,7 @@ describe('navigateAfterExpenseCreate', () => {
mockIsReportOpenInRHP.mockReturnValue(false);
mockGetTrackingState.mockReturnValue(false);
mockGetCurrentSearchQueryJSON.mockReturnValue(undefined);
+ mockGetReportTransactions.mockReturnValue([]);
});
it('should dismiss to report when not from global create', () => {
@@ -220,4 +235,107 @@ describe('navigateAfterExpenseCreate', () => {
expect(Navigation.dismissModal).toHaveBeenCalled();
expect(Navigation.navigate).not.toHaveBeenCalled();
});
+
+ describe('navigateToCreatedExpense', () => {
+ it('should open the transaction thread in the Spend RHP when the user is on the Spend tab', async () => {
+ // Given the user is on the Spend tab
+ mockIsReportTopmostSplitNavigator.mockReturnValue(false);
+ mockIsSearchTopmostFullScreenRoute.mockReturnValue(true);
+
+ // When they open a newly-created expense
+ navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'});
+ await waitForBatchedUpdates();
+
+ // Then the transaction thread opens in the Spend RHP
+ expect(Navigation.navigate).toHaveBeenCalledTimes(1);
+ expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SEARCH_REPORT.getRoute({reportID: 'thread-1', backTo: ''}), {forceReplace: false});
+ });
+
+ it('should replace the currently-open report instead of stacking when one is already open in the RHP', async () => {
+ // Given a report is already open in the RHP
+ mockIsReportTopmostSplitNavigator.mockReturnValue(false);
+ mockIsSearchTopmostFullScreenRoute.mockReturnValue(true);
+ mockIsReportOpenInRHP.mockReturnValue(true);
+
+ // When the user opens a newly-created expense
+ navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'});
+ await waitForBatchedUpdates();
+
+ // Then the open report is replaced rather than stacked on
+ expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SEARCH_REPORT.getRoute({reportID: 'thread-1', backTo: ''}), {forceReplace: true});
+ });
+
+ it('should open the transaction thread as a full report when the user is on the Inbox tab on a narrow layout', () => {
+ // Given the user is on the Inbox tab on a narrow layout
+ mockIsReportTopmostSplitNavigator.mockReturnValue(true);
+ mockIsSearchTopmostFullScreenRoute.mockReturnValue(false);
+ mockGetIsNarrowLayout.mockReturnValue(true);
+
+ // When they open a newly-created expense
+ navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'});
+
+ // Then the transaction thread opens as a full report
+ expect(Navigation.navigate).toHaveBeenCalledTimes(1);
+ expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute('thread-1', undefined, undefined, ''), {forceReplace: false});
+ });
+
+ it('should open the expense report then stack the thread RHP when the user is on the Inbox tab on a wide layout and the report has multiple transactions', async () => {
+ // Given the user is on the Inbox tab on a wide layout and the expense report holds several transactions
+ mockIsReportTopmostSplitNavigator.mockReturnValue(true);
+ mockIsSearchTopmostFullScreenRoute.mockReturnValue(false);
+ mockGetIsNarrowLayout.mockReturnValue(false);
+ mockGetReportTransactions.mockReturnValue([{transactionID: 'txn-1'}, {transactionID: 'txn-2'}]);
+
+ // When they open a newly-created expense
+ navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'});
+ await waitForBatchedUpdates();
+
+ // Then the expense report opens with the thread RHP stacked on top of it
+ expect(Navigation.navigate).toHaveBeenNthCalledWith(1, ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: 'iou-1', backTo: ''}), {forceReplace: false});
+ expect(Navigation.navigate).toHaveBeenNthCalledWith(2, ROUTES.SEARCH_REPORT.getRoute({reportID: 'thread-1', backTo: ''}));
+ });
+
+ it('should open the expense report when the user is on the Inbox tab on a wide layout and the report has a single transaction', () => {
+ // Given the user is on the Inbox tab on a wide layout and the expense report holds one transaction
+ mockIsReportTopmostSplitNavigator.mockReturnValue(true);
+ mockIsSearchTopmostFullScreenRoute.mockReturnValue(false);
+ mockGetIsNarrowLayout.mockReturnValue(false);
+ mockGetReportTransactions.mockReturnValue([{transactionID: 'txn-1'}]);
+
+ // When they open a newly-created expense
+ navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'});
+
+ // Then only the expense report opens, since it collapses to the thread itself
+ expect(Navigation.navigate).toHaveBeenCalledTimes(1);
+ expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: 'iou-1', backTo: ''}), {forceReplace: false});
+ });
+
+ it('should open the transaction thread as a full report when there is no expense report (tracked/unreported self-DM expense) on the Inbox tab', () => {
+ // Given the user is on the Inbox tab on a wide layout
+ mockIsReportTopmostSplitNavigator.mockReturnValue(true);
+ mockIsSearchTopmostFullScreenRoute.mockReturnValue(false);
+ mockGetIsNarrowLayout.mockReturnValue(false);
+
+ // When they open a newly-created tracked expense, which has no expense report
+ navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: undefined});
+
+ // Then the thread opens as a full report, matching how tapping it in its self-DM chat does
+ expect(Navigation.navigate).toHaveBeenCalledTimes(1);
+ expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute('thread-1', undefined, undefined, ''), {forceReplace: false});
+ });
+
+ it('should open the transaction thread in the Spend RHP for a tracked/unreported expense when the user is on the Spend tab', async () => {
+ // Given the user is on the Spend tab
+ mockIsReportTopmostSplitNavigator.mockReturnValue(false);
+ mockIsSearchTopmostFullScreenRoute.mockReturnValue(true);
+
+ // When they open a newly-created tracked expense, which has no expense report
+ navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: undefined});
+ await waitForBatchedUpdates();
+
+ // Then the transaction thread still opens in the Spend RHP
+ expect(Navigation.navigate).toHaveBeenCalledTimes(1);
+ expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SEARCH_REPORT.getRoute({reportID: 'thread-1', backTo: ''}), {forceReplace: false});
+ });
+ });
});
diff --git a/tests/unit/useSearchHighlightAndScrollTest.ts b/tests/unit/useSearchAutoRefetchTest.ts
similarity index 59%
rename from tests/unit/useSearchHighlightAndScrollTest.ts
rename to tests/unit/useSearchAutoRefetchTest.ts
index d34623d5f160..676384d8be82 100644
--- a/tests/unit/useSearchHighlightAndScrollTest.ts
+++ b/tests/unit/useSearchAutoRefetchTest.ts
@@ -1,10 +1,8 @@
/* eslint-disable @typescript-eslint/naming-convention */
import {renderHook} from '@testing-library/react-native';
-import type {SearchListItem} from '@components/Search/SearchList/ListItem/types';
-
-import useSearchHighlightAndScroll from '@hooks/useSearchHighlightAndScroll';
-import type {UseSearchHighlightAndScroll} from '@hooks/useSearchHighlightAndScroll';
+import useSearchAutoRefetch from '@hooks/useSearchAutoRefetch';
+import type {UseSearchAutoRefetch} from '@hooks/useSearchAutoRefetch';
import {search} from '@libs/actions/Search';
@@ -37,15 +35,14 @@ afterEach(() => {
mockIsOffline = false;
});
-describe('useSearchHighlightAndScroll', () => {
+describe('useSearchAutoRefetch', () => {
beforeAll(async () => {
Onyx.init({
keys: ONYXKEYS,
});
});
- const baseProps: UseSearchHighlightAndScroll = {
- shouldUseLiveData: false,
+ const baseProps: UseSearchAutoRefetch = {
searchResults: {
data: {
personalDetailsList: {},
@@ -83,22 +80,22 @@ describe('useSearchHighlightAndScroll', () => {
};
it('should not trigger search when collections are empty', () => {
- renderHook(() => useSearchHighlightAndScroll(baseProps));
+ renderHook(() => useSearchAutoRefetch(baseProps));
expect(search).not.toHaveBeenCalled();
});
it('should trigger search when new transaction added and focused', () => {
- const initialProps = createMock({
+ const initialProps = createMock({
...baseProps,
transactions: {transactions_1: {transactionID: '1'}},
previousTransactions: {transactions_1: {transactionID: '1'}},
});
- const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), {
+ const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), {
initialProps,
});
- const updatedProps = createMock({
+ const updatedProps = createMock({
...baseProps,
transactions: {
transactions_1: {transactionID: '1'},
@@ -114,11 +111,11 @@ describe('useSearchHighlightAndScroll', () => {
it('should not trigger search when not focused', () => {
mockUseIsFocused.mockReturnValue(false);
- const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), {
+ const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), {
initialProps: baseProps,
});
- const updatedProps = createMock({
+ const updatedProps = createMock({
...baseProps,
transactions: {transactions_1: {transactionID: '1'}},
});
@@ -130,7 +127,7 @@ describe('useSearchHighlightAndScroll', () => {
it('should trigger search for chat when report actions added and focused', () => {
mockUseIsFocused.mockReturnValue(true);
- const chatProps = createMock({
+ const chatProps = createMock({
...baseProps,
queryJSON: {...baseProps.queryJSON, type: 'chat' as const},
reportActions: {
@@ -145,11 +142,11 @@ describe('useSearchHighlightAndScroll', () => {
},
});
- const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), {
+ const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), {
initialProps: chatProps,
});
- const updatedProps = createMock({
+ const updatedProps = createMock({
...chatProps,
reportActions: {
reportActions_1: {
@@ -164,7 +161,7 @@ describe('useSearchHighlightAndScroll', () => {
});
it('should not trigger search when new transaction removed and focused', () => {
- const initialProps = createMock({
+ const initialProps = createMock({
...baseProps,
transactions: {
transactions_1: {transactionID: '1'},
@@ -176,11 +173,11 @@ describe('useSearchHighlightAndScroll', () => {
},
});
- const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), {
+ const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), {
initialProps,
});
- const updatedProps = createMock({
+ const updatedProps = createMock({
...baseProps,
transactions: {
transactions_1: {transactionID: '1'},
@@ -192,7 +189,7 @@ describe('useSearchHighlightAndScroll', () => {
});
it('should trigger search when a transaction that is absent from the results is added', () => {
- const initialProps = createMock({
+ const initialProps = createMock({
...baseProps,
searchResults: {
...baseProps.searchResults,
@@ -208,11 +205,11 @@ describe('useSearchHighlightAndScroll', () => {
},
});
- const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), {
+ const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), {
initialProps,
});
- const updatedProps = createMock({
+ const updatedProps = createMock({
...initialProps,
transactions: {
transactions_1: {transactionID: '1'},
@@ -228,7 +225,7 @@ describe('useSearchHighlightAndScroll', () => {
// Onyx keeps one value object per collection member, so an unedited transaction has to keep its
// reference across renders — the hook reads that identity to tell an edit from an untouched row.
const transaction1 = createMock({transactionID: '1'});
- const initialProps = createMock({
+ const initialProps = createMock({
...baseProps,
searchResults: {
...baseProps.searchResults,
@@ -245,11 +242,11 @@ describe('useSearchHighlightAndScroll', () => {
},
});
- const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), {
+ const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), {
initialProps,
});
- const updatedProps = createMock({
+ const updatedProps = createMock({
...initialProps,
transactions: {
transactions_1: transaction1,
@@ -263,7 +260,7 @@ describe('useSearchHighlightAndScroll', () => {
it('should trigger search when a transaction that is already in the results is edited', () => {
const transaction = createMock({transactionID: '1', amount: 100});
- const initialProps = createMock({
+ const initialProps = createMock({
...baseProps,
searchResults: {
...baseProps.searchResults,
@@ -275,11 +272,11 @@ describe('useSearchHighlightAndScroll', () => {
previousTransactions: {transactions_1: transaction},
});
- const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), {
+ const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), {
initialProps,
});
- const updatedProps = createMock({
+ const updatedProps = createMock({
...initialProps,
transactions: {transactions_1: {transactionID: '1', amount: 250}},
});
@@ -291,7 +288,7 @@ describe('useSearchHighlightAndScroll', () => {
it('should not trigger search when the edited transaction is absent from the results', () => {
const visibleTransaction = createMock({transactionID: '1', amount: 100});
const filteredOutTransaction = createMock({transactionID: '99', amount: 100});
- const initialProps = createMock({
+ const initialProps = createMock({
...baseProps,
searchResults: {
...baseProps.searchResults,
@@ -303,11 +300,11 @@ describe('useSearchHighlightAndScroll', () => {
previousTransactions: {transactions_1: visibleTransaction, transactions_99: filteredOutTransaction},
});
- const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), {
+ const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), {
initialProps,
});
- const updatedProps = createMock({
+ const updatedProps = createMock({
...initialProps,
transactions: {transactions_1: visibleTransaction, transactions_99: {transactionID: '99', amount: 250}},
});
@@ -318,7 +315,7 @@ describe('useSearchHighlightAndScroll', () => {
it('should trigger search when a transaction moves into a report the results display', () => {
const movedTransaction = createMock({transactionID: '99', reportID: '5'});
- const initialProps = createMock({
+ const initialProps = createMock({
...baseProps,
searchResults: {
...baseProps.searchResults,
@@ -330,11 +327,11 @@ describe('useSearchHighlightAndScroll', () => {
previousTransactions: {transactions_99: movedTransaction},
});
- const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), {
+ const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), {
initialProps,
});
- const updatedProps = createMock({
+ const updatedProps = createMock({
...initialProps,
transactions: {transactions_99: {transactionID: '99', reportID: '2'}},
});
@@ -345,7 +342,7 @@ describe('useSearchHighlightAndScroll', () => {
it('should not trigger search when a transaction moves between reports the results do not display', () => {
const movedTransaction = createMock({transactionID: '99', reportID: '5'});
- const initialProps = createMock({
+ const initialProps = createMock({
...baseProps,
searchResults: {
...baseProps.searchResults,
@@ -357,11 +354,11 @@ describe('useSearchHighlightAndScroll', () => {
previousTransactions: {transactions_99: movedTransaction},
});
- const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), {
+ const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), {
initialProps,
});
- const updatedProps = createMock({
+ const updatedProps = createMock({
...initialProps,
transactions: {transactions_99: {transactionID: '99', reportID: '7'}},
});
@@ -373,7 +370,7 @@ describe('useSearchHighlightAndScroll', () => {
it('should trigger the deferred search once Search is active again, after previousTransactions caught up', () => {
const transaction = createMock({transactionID: '1', amount: 100});
const editedTransaction = createMock({transactionID: '1', amount: 250});
- const initialProps = createMock({
+ const initialProps = createMock({
...baseProps,
searchResults: {
...baseProps.searchResults,
@@ -386,17 +383,17 @@ describe('useSearchHighlightAndScroll', () => {
});
mockIsOffline = true;
- const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), {
+ const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), {
initialProps,
});
- const editedProps = createMock({...initialProps, transactions: {transactions_1: editedTransaction}});
+ const editedProps = createMock({...initialProps, transactions: {transactions_1: editedTransaction}});
rerender(editedProps);
expect(search).not.toHaveBeenCalled();
// `usePrevious` catches up while Search is inactive, so the edit is no longer visible to the comparisons.
- const settledProps = createMock({...editedProps, previousTransactions: {transactions_1: editedTransaction}});
+ const settledProps = createMock({...editedProps, previousTransactions: {transactions_1: editedTransaction}});
rerender(settledProps);
expect(search).not.toHaveBeenCalled();
@@ -410,7 +407,7 @@ describe('useSearchHighlightAndScroll', () => {
it('should not trigger search on a non-chat search when a report action was added and Onyx holds a transaction the query filters out', () => {
const transaction1 = createMock({transactionID: '1'});
const transaction99 = createMock({transactionID: '99'});
- const initialProps = createMock({
+ const initialProps = createMock({
...baseProps,
searchResults: {
...baseProps.searchResults,
@@ -439,11 +436,11 @@ describe('useSearchHighlightAndScroll', () => {
},
});
- const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), {
+ const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), {
initialProps,
});
- const updatedProps = createMock({
+ const updatedProps = createMock({
...initialProps,
reportActions: {
reportActions_1: {
@@ -460,7 +457,7 @@ describe('useSearchHighlightAndScroll', () => {
it('should not trigger search when the added transaction is already in the results and Onyx holds one the query filters out', () => {
const transaction1 = createMock({transactionID: '1'});
const transaction99 = createMock({transactionID: '99'});
- const initialProps = createMock({
+ const initialProps = createMock({
...baseProps,
searchResults: {
...baseProps.searchResults,
@@ -479,11 +476,11 @@ describe('useSearchHighlightAndScroll', () => {
},
});
- const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), {
+ const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), {
initialProps,
});
- const updatedProps = createMock({
+ const updatedProps = createMock({
...initialProps,
transactions: {
transactions_1: transaction1,
@@ -499,7 +496,7 @@ describe('useSearchHighlightAndScroll', () => {
it('should not trigger search for chat when report actions removed and focused', () => {
mockUseIsFocused.mockReturnValue(true);
- const chatProps = createMock({
+ const chatProps = createMock({
...baseProps,
queryJSON: {...baseProps.queryJSON, type: 'chat' as const},
reportActions: {
@@ -516,11 +513,11 @@ describe('useSearchHighlightAndScroll', () => {
},
});
- const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), {
+ const {rerender} = renderHook((props: UseSearchAutoRefetch) => useSearchAutoRefetch(props), {
initialProps: chatProps,
});
- const updatedProps = createMock({
+ const updatedProps = createMock({
...chatProps,
reportActions: {
reportActions_1: {
@@ -532,168 +529,4 @@ describe('useSearchHighlightAndScroll', () => {
rerender(updatedProps);
expect(search).not.toHaveBeenCalled();
});
-
- it('should clear the scroll trigger when the first new expense is already first', () => {
- const {rerender, result} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), {
- initialProps: baseProps,
- });
- const updatedProps = createMock({
- ...baseProps,
- searchResults: {
- ...baseProps.searchResults,
- data: {
- transactions_1: {
- transactionID: '1',
- },
- transactions_2: {
- transactionID: '2',
- },
- },
- },
- transactions: {
- transactions_1: {transactionID: '1'},
- transactions_2: {transactionID: '2'},
- transactions_3: {transactionID: '3'},
- },
- previousTransactions: {
- transactions_1: {transactionID: '1'},
- },
- });
- rerender(updatedProps);
- expect(result.current.newSearchResultKeys?.size).toBe(2);
-
- const scrollToIndex = jest.fn();
- const ref: NonNullable[1]> = {
- scrollAndHighlightItem: jest.fn(),
- scrollToIndex,
- updateFocusedIndex: jest.fn(),
- scrollToFocusedInput: jest.fn(),
- focusTextInput: jest.fn(),
- };
-
- const listItem1 = createMock({transactionID: '1'});
- const listItem2 = createMock({transactionID: '2'});
-
- result.current.handleSelectionListScroll([listItem1, listItem2], ref);
- result.current.handleSelectionListScroll([listItem2, listItem1], ref);
-
- expect(scrollToIndex).not.toHaveBeenCalled();
- });
-
- it('should return new search result keys for manually highlighted expenses', async () => {
- const spyOnMergeTransactionIdsHighlightOnSearchRoute = jest
- .spyOn(require('@libs/actions/Transaction'), 'mergeTransactionIdsHighlightOnSearchRoute')
- .mockImplementationOnce(jest.fn());
- // We need to mock requestAnimationFrame to mimic long Onyx merge overhead
- jest.spyOn(global, 'requestAnimationFrame').mockImplementation((callback: FrameRequestCallback) => {
- callback(performance.now());
- return 0;
- });
-
- await Onyx.merge(ONYXKEYS.TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE, {[baseProps.queryJSON.type]: {'3': true}});
-
- const {rerender, result} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), {
- initialProps: baseProps,
- });
- const updatedProps1 = createMock({
- ...baseProps,
- searchResults: {
- ...baseProps.searchResults,
- data: {
- transactions_1: {
- transactionID: '1',
- },
- transactions_2: {
- transactionID: '2',
- },
- },
- },
- transactions: {
- transactions_1: {transactionID: '1'},
- transactions_2: {transactionID: '2'},
- transactions_3: {transactionID: '3'},
- },
- previousTransactions: {
- transactions_1: {transactionID: '1'},
- },
- });
-
- // When there is no data yet, even if the transactionID has been added to manual highlight transactionIDs,
- // it still will not be included in newSearchResultKeys.
- rerender(updatedProps1);
- expect(result.current.newSearchResultKeys?.size).toBe(2);
- expect([...(result.current.newSearchResultKeys ?? new Set())]).not.toContain('transactions_3');
-
- // When the data contains the highlight transactionID, it will be highlighted.
- const updatedProps2 = createMock({
- ...updatedProps1,
- searchResults: {
- ...updatedProps1.searchResults,
- data: {
- transactions_1: {
- transactionID: '1',
- },
- transactions_2: {
- transactionID: '2',
- },
- transactions_3: {
- transactionID: '3',
- },
- },
- },
- });
-
- rerender(updatedProps2);
- expect(result.current.newSearchResultKeys?.size).toBe(1);
- expect([...(result.current.newSearchResultKeys ?? new Set())]).toContain('transactions_3');
-
- expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledWith(baseProps.queryJSON.type, {'3': false});
- });
-
- it('should return multiple new search result keys when there are multiple new chats', () => {
- const chatProps = createMock({
- ...baseProps,
- queryJSON: {...baseProps.queryJSON, type: 'chat' as const},
- reportActions: {
- reportActions_1: {
- '1': {actionName: 'CREATED', reportActionID: '1'},
- },
- },
- });
- const {rerender, result} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), {
- initialProps: chatProps,
- });
- const updatedProps = createMock({
- ...chatProps,
- searchResults: {
- ...baseProps.searchResults,
- data: {
- reportActions_1: {
- '1': {actionName: 'CREATED', reportActionID: '1'},
- },
- reportActions_2: {
- '2': {actionName: 'CREATED', reportActionID: '2'},
- },
- },
- },
- reportActions: {
- reportActions_1: {
- '1': {actionName: 'CREATED', reportActionID: '1'},
- },
- reportActions_2: {
- '2': {actionName: 'CREATED', reportActionID: '2'},
- },
- reportActions_3: {
- '3': {actionName: 'CREATED', reportActionID: '3'},
- },
- },
- previousReportActions: {
- reportActions_1: {
- '1': {actionName: 'CREATED', reportActionID: '1'},
- },
- },
- });
- rerender(updatedProps);
- expect(result.current.newSearchResultKeys?.size).toBe(2);
- });
});