diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv
index 7120c73ca5cf..33b530b89d9c 100644
--- a/config/eslint/eslint.seatbelt.tsv
+++ b/config/eslint/eslint.seatbelt.tsv
@@ -497,7 +497,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" 9
-"../../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 9e6a7b48a156..d7235959d289 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';
@@ -22,6 +24,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,
@@ -48,6 +51,7 @@ function GlobalModals() {
return (
<>
+ {isAuthenticated && }
{shouldRenderContextMenu && (
diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts
index 65112e36a7cc..2f5e48655ac4 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';
@@ -887,8 +888,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 */
+ RAM_ONLY_EXPENSE_ADDED_GROWL_TRANSACTION_IDS: 'expenseAddedGrowlTransactionIDs',
/** The report ID to be highlighted when returning to the workspace rooms page */
ROOM_ID_HIGHLIGHT_ON_ROOMS_PAGE: 'roomIDHighlightOnRoomsPage',
@@ -1907,7 +1908,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.RAM_ONLY_EXPENSE_ADDED_GROWL_TRANSACTION_IDS]: Record;
[ONYXKEYS.ROOM_ID_HIGHLIGHT_ON_ROOMS_PAGE]: string | null;
[ONYXKEYS.DOMAIN_GROUP_CREATE_PREFERRED_POLICY_ID]: string | undefined;
};
diff --git a/src/components/ExpenseAddedGrowl.tsx b/src/components/ExpenseAddedGrowl.tsx
new file mode 100644
index 000000000000..39d67566f209
--- /dev/null
+++ b/src/components/ExpenseAddedGrowl.tsx
@@ -0,0 +1,168 @@
+import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
+import useLocalize from '@hooks/useLocalize';
+import useOnyx from '@hooks/useOnyx';
+import {usePersonalDetailsByIDs} from '@hooks/usePersonalDetails';
+import useReportTransactions from '@hooks/useReportTransactions';
+
+import {createTransactionThreadReport, setOptimisticTransactionThread} from '@libs/actions/Report';
+import {mergeExpenseAddedGrowlTransactionIDs} from '@libs/actions/Transaction';
+import Log from '@libs/Log';
+import navigateToCreatedExpense from '@libs/Navigation/helpers/navigateToCreatedExpense';
+import Navigation from '@libs/Navigation/Navigation';
+import {getIOUActionForTransactionID} from '@libs/ReportActionsUtils';
+import {findSelfDMReportID, isInvoiceReport, isMoneyRequestReport} from '@libs/ReportUtils';
+
+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.RAM_ONLY_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 [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED);
+ const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID);
+ const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`);
+ const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`);
+ const reportID = transaction?.reportID;
+ const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`);
+
+ // 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}`);
+ const iouAction = getIOUActionForTransactionID(Object.values(reportActions ?? {}), transactionID);
+ const [personalDetails] = usePersonalDetailsByIDs([currentUserPersonalDetails?.accountID, iouAction?.actorAccountID]);
+
+ // A tracked expense lives in a chat, not an expense report. Leaving iouReportID undefined tells
+ // navigateToCreatedExpense to open the transaction thread directly instead of an expense report RHP.
+ const iouReport = isMoneyRequestReport(report) || isInvoiceReport(report) ? report : undefined;
+ const iouReportID = iouReport?.reportID;
+ const reportTransactions = useReportTransactions(iouReportID);
+
+ 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.getFocusedReportId() === 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 = () => {
+ let threadReportID = transaction?.transactionThreadReportID ?? iouAction?.childReportID;
+ if (threadReportID) {
+ setOptimisticTransactionThread(threadReportID, iouReport?.reportID, iouAction?.reportActionID, iouReport?.policyID);
+ } else {
+ const optimisticThread = createTransactionThreadReport({
+ introSelected,
+ conciergeChat,
+ currentUserLogin: currentUserPersonalDetails?.login ?? '',
+ currentUserAccountID: currentUserPersonalDetails?.accountID ?? CONST.DEFAULT_NUMBER_ID,
+ 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, reportTransactions});
+ };
+
+ return (
+ setActive((prev) => (prev?.nonce === dismissedNonce ? null : prev))}
+ />
+ );
+}
+
+export default ExpenseAddedGrowl;
diff --git a/src/components/GrowlNotification/GrowlNotificationContent.tsx b/src/components/GrowlNotification/GrowlNotificationContent.tsx
index 89951c9af8bb..bbc7ab9907c6 100644
--- a/src/components/GrowlNotification/GrowlNotificationContent.tsx
+++ b/src/components/GrowlNotification/GrowlNotificationContent.tsx
@@ -22,6 +22,7 @@ import {useSharedValue, withSpring} from 'react-native-reanimated';
import {scheduleOnRN} from 'react-native-worklets';
import GrowlNotificationContainer from './GrowlNotificationContainer';
+import GrowlNotificationPortal from './GrowlNotificationPortal';
const INACTIVE_OFFSET = CONST.GROWL.OFFSCREEN_OFFSET;
const INACTIVE_POSITION_Y = -INACTIVE_OFFSET;
@@ -130,51 +131,53 @@ function GrowlNotificationContent({bodyText, type, duration, action, nonce, onDi
.onStart(triggerDismiss);
return (
-
-
-
-
- {/* The dismiss target covers only the icon + text; the action Button is a sibling
- (not nested inside a pressable) so it stays independently focusable for screen
- readers and its press can't bubble into a dismiss. */}
-
-
- {bodyText}
-
- {!!action && (
-
- )}
-
-
-
-
+
+ {bodyText}
+
+ {!!action && (
+
+ )}
+
+
+
+
+
);
}
diff --git a/src/components/GrowlNotification/GrowlNotificationPortal/index.native.tsx b/src/components/GrowlNotification/GrowlNotificationPortal/index.native.tsx
new file mode 100644
index 000000000000..1878c9c379c1
--- /dev/null
+++ b/src/components/GrowlNotification/GrowlNotificationPortal/index.native.tsx
@@ -0,0 +1,8 @@
+import type React from 'react';
+
+/** Native has no shared DOM stacking context to escape, so growls render where they are mounted. */
+function GrowlNotificationPortal({children}: React.PropsWithChildren) {
+ return children;
+}
+
+export default GrowlNotificationPortal;
diff --git a/src/components/GrowlNotification/GrowlNotificationPortal/index.tsx b/src/components/GrowlNotification/GrowlNotificationPortal/index.tsx
new file mode 100644
index 000000000000..535fb208d6d4
--- /dev/null
+++ b/src/components/GrowlNotification/GrowlNotificationPortal/index.tsx
@@ -0,0 +1,13 @@
+import type React from 'react';
+
+import {createPortal} from 'react-dom';
+
+/**
+ * Renders growls at the document root. Inside the app's own stacking context they are covered by overlays that
+ * portal to the body themselves, such as the Side Panel, no matter how high their z-index is.
+ */
+function GrowlNotificationPortal({children}: React.PropsWithChildren) {
+ return createPortal(children, document.body);
+}
+
+export default GrowlNotificationPortal;
diff --git a/src/components/Search/ChatSearchView.tsx b/src/components/Search/ChatSearchView.tsx
index 04a7f719d6ea..6452607b8508 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';
@@ -51,7 +51,6 @@ function ChatSearchView({
onViewableItemsChanged,
contentContainerStyle,
containerStyle,
- ref,
}: ChatSearchViewProps) {
const {type} = queryJSON;
@@ -67,9 +66,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,
@@ -59,7 +58,6 @@ function ExpenseFlatSearchView({
onViewableItemsChanged,
contentContainerStyle,
containerStyle,
- ref,
}: ExpenseFlatSearchViewProps) {
const {type} = queryJSON;
@@ -93,9 +91,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 7e38851d4a08..7a0b1ebfe4c4 100644
--- a/src/components/Search/ExpenseGroupedSearchView.tsx
+++ b/src/components/Search/ExpenseGroupedSearchView.tsx
@@ -18,7 +18,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';
@@ -93,8 +93,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,
@@ -118,7 +117,6 @@ function ExpenseGroupedSearchView({
onViewableItemsChanged,
contentContainerStyle,
containerStyle,
- ref,
}: ExpenseGroupedSearchViewProps) {
const {type, groupBy} = queryJSON;
const {isLargeScreenWidth} = useResponsiveLayout();
@@ -216,26 +214,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 8c314f4eae4a..b735fa27e1ff 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';
@@ -57,7 +57,6 @@ function ExpenseReportSearchView({
onViewableItemsChanged,
contentContainerStyle,
containerStyle,
- ref,
}: ExpenseReportSearchViewProps) {
const {type} = queryJSON;
@@ -94,9 +93,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 [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportActionItem?.childReportID}`);
const [chatReportStable] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportStable?.chatReportID)}`, {selector: getStableReportSelector});
const styles = useThemeStyles();
+ const StyleUtils = useStyleUtils();
const {isSelected} = useRowSelection(item.keyForList);
- const {pressableStyle, pressableWrapperStyle} = useListItemHighlight({
- shouldHighlight: item?.shouldAnimateInHighlight ?? false,
- isSelected,
- });
+ const pressableStyle = [
+ styles.selectionListPressableItemWrapper,
+ styles.p0,
+ styles.textAlignLeft,
+ styles.overflowHidden,
+ // Background is applied on the parent wrapper, so keep this transparent
+ styles.bgTransparent,
+ isSelected && styles.activeComponentBG,
+ styles.mh0,
+ item.cursorStyle,
+ ];
const fsClass = FS.getChatFSClass(reportStable);
@@ -52,7 +60,7 @@ function ChatListItem({
return (
({
onDismissError={onDismissError}
onFocus={onFocus}
shouldSyncFocus={shouldSyncFocus}
- pressableWrapperStyle={pressableWrapperStyle}
+ pressableWrapperStyle={[styles.mh5, StyleUtils.getSearchRowBackgroundStyle(isSelected), styles.br2]}
hoverStyle={isSelected && styles.activeComponentBG}
>
({
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,
@@ -391,17 +390,6 @@ function ExpenseReportListItemInner({
[styles, isSelected, isLargeScreenWidth, isFirstItem, isLastItem, isPendingDelete, StyleUtils],
);
- // The animated style is applied inline, so the `borderRadius: 0` it carries wins over the static
- // `tableTopRadius`/`tableBottomRadius` below and squares off the list's outer corners. Skip it for the first
- // and last rows only, so every other row keeps its existing (already square) behavior.
- const shouldApplyAnimatedBorderRadius = !isLargeScreenWidth && !isFirstItem && !isLastItem;
- const animatedHighlightStyle = useRowHighlightAnimation({
- shouldHighlight: item?.shouldAnimateInHighlight ?? false,
- isSelected,
- borderRadius: 0,
- shouldApplyOtherStyles: shouldApplyAnimatedBorderRadius,
- });
-
const shouldShowViolationDescription = isOpenExpenseReport(reportItem) || isProcessingReport(reportItem);
// Show violation description if either:
@@ -498,7 +486,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 44346f9d630b..047b9d8000ec 100644
--- a/src/components/Search/SearchList/ListItem/GroupChildrenContainer.tsx
+++ b/src/components/Search/SearchList/ListItem/GroupChildrenContainer.tsx
@@ -1,5 +1,5 @@
import useExpandCollapseAnimation from '@hooks/useExpandCollapseAnimation';
-import useRowHighlightAnimation from '@hooks/useRowHighlightAnimation';
+import useStyleUtils from '@hooks/useStyleUtils';
import useThemeStyles from '@hooks/useThemeStyles';
import React from 'react';
@@ -33,6 +33,7 @@ function GroupChildrenContainer({
newTransactionID,
}: GroupChildrenContainerProps) {
const styles = useThemeStyles();
+ const StyleUtils = useStyleUtils();
const hasBorder = !isFirstItem;
const {isRendered, animatedStyle, onLayout} = useExpandCollapseAnimation(isExpanded, isExpanded && hasBorder, item.keyForList);
const isContentVisible = isExpanded || isRendered;
@@ -41,19 +42,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 = useRowHighlightAnimation({
- shouldHighlight: item?.shouldAnimateInHighlight ?? false,
- isSelected,
- 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 a30dadc0bdb1..752b5036841b 100644
--- a/src/components/Search/SearchList/ListItem/GroupHeader.tsx
+++ b/src/components/Search/SearchList/ListItem/GroupHeader.tsx
@@ -13,7 +13,6 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
import useOnyx from '@hooks/useOnyx';
import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
-import useRowHighlightAnimation from '@hooks/useRowHighlightAnimation';
import useStyleUtils from '@hooks/useStyleUtils';
import useSyncFocus from '@hooks/useSyncFocus';
import useTheme from '@hooks/useTheme';
@@ -189,12 +188,6 @@ function GroupHeader({
keyForList: item.groupKeyForList,
});
- const animatedHighlightStyle = useRowHighlightAnimation({
- shouldHighlight: item?.shouldAnimateInHighlight ?? false,
- isSelected: isItemSelected,
- shouldApplyOtherStyles: false,
- });
-
const handleSelectionButtonPress = () => {
onCheckboxPress(withOriginalKey(item), isExpenseReportType ? undefined : groupItem.transactions);
};
@@ -401,7 +394,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 90e38325d43f..bda6e047162f 100644
--- a/src/components/Search/SearchList/ListItem/TaskListItem.tsx
+++ b/src/components/Search/SearchList/ListItem/TaskListItem.tsx
@@ -4,10 +4,13 @@ import type {ListItem} from '@components/SelectionList/types';
import useOnyx from '@hooks/useOnyx';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
+import useStyleUtils from '@hooks/useStyleUtils';
import useThemeStyles from '@hooks/useThemeStyles';
import FS from '@libs/Fullstory';
+import variables from '@styles/variables';
+
import ONYXKEYS from '@src/ONYXKEYS';
import type {ReportAttributesDerivedValue} from '@src/types/onyx';
@@ -18,7 +21,6 @@ import {View} from 'react-native';
import type {TaskListItemProps, TaskListItemType} from './types';
-import useSearchTableItemHighlight from './hooks/useSearchTableItemHighlight';
import TaskListItemRow from './TaskListItemRow';
/**
@@ -47,15 +49,21 @@ function TaskListItem({
const liveTaskItem: TaskListItemType =
liveParentReportAttributeName && liveParentReportAttributeName !== taskItem.parentReportName ? {...taskItem, parentReportName: liveParentReportAttributeName} : taskItem;
const styles = useThemeStyles();
+ const StyleUtils = useStyleUtils();
const {isLargeScreenWidth} = useResponsiveLayout();
const {isSelected} = useRowSelection(item.keyForList);
- const {pressableStyle, pressableWrapperStyle} = useSearchTableItemHighlight({
- shouldHighlight: item?.shouldAnimateInHighlight ?? false,
- isSelected,
- isLastItem: !!isLastItem,
- });
+ const listItemPressableStyle = [
+ styles.selectionListPressableItemWrapper,
+ styles.pv3,
+ styles.ph3,
+ // Background is applied on the parent wrapper, so keep this transparent
+ styles.bgTransparent,
+ isSelected && styles.activeComponentBG,
+ styles.mh0,
+ isLargeScreenWidth && StyleUtils.getSearchTableRowPressableStyle(!!isLastItem, isSelected, {vertical: variables.tableRowPaddingVertical}),
+ ];
const listItemWrapperStyle = [
styles.flex1,
@@ -68,7 +76,7 @@ function TaskListItem({
return (
({
onLongPressRow={onLongPressRow}
shouldSyncFocus={shouldSyncFocus}
hoverStyle={isSelected && styles.activeComponentBG}
- pressableWrapperStyle={pressableWrapperStyle}
+ pressableWrapperStyle={[
+ styles.mh5,
+ StyleUtils.getSearchRowBackgroundStyle(isSelected),
+ !isLargeScreenWidth && styles.br2,
+ isLargeScreenWidth && isLastItem && [styles.tableBottomRadius, styles.overflowHidden],
+ ]}
>
({
{...styles.flexColumn, ...styles.alignItemsStretch},
];
- // The animated style is applied inline, so the `borderRadius: 0` it carries wins over the static
- // `tableTopRadius`/`tableBottomRadius` on the wrapper below. Skip it for the first and last rows only,
- // so every other row keeps its existing (already square) behavior.
- const animatedHighlightStyle = useRowHighlightAnimation({
- shouldHighlight: item?.shouldAnimateInHighlight ?? false,
- isSelected,
- borderRadius: 0,
- shouldApplyOtherStyles: !isFirstItem && !isLastItem,
- });
-
- // 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.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 b5911ff90a16..4c8248855e4d 100644
--- a/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemWide.tsx
+++ b/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemWide.tsx
@@ -6,7 +6,6 @@ import type {ListItem} from '@components/SelectionList/types';
import TransactionItemRow from '@components/TransactionItemRow';
import {useEditingCellState} from '@components/TransactionItemRow/EditableCell';
-import useRowHighlightAnimation from '@hooks/useRowHighlightAnimation';
import useStyleUtils from '@hooks/useStyleUtils';
import useSyncFocus from '@hooks/useSyncFocus';
import useTheme from '@hooks/useTheme';
@@ -144,13 +143,6 @@ function TransactionListItemWide({
},
];
- const animatedHighlightStyle = useRowHighlightAnimation({
- shouldHighlight: item?.shouldAnimateInHighlight ?? false,
- isSelected,
- borderRadius: 0,
- 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'];
@@ -97,7 +95,7 @@ const EMPTY_COLUMNS: SearchColumnType[] = [];
const EMPTY_HASHES: string[] = [];
const EMPTY_GROUP_ITEMS: TransactionGroupListItemType[] = [];
-/** How many leading rows the cap keeps: `rowLimit` rows on screen, stretched down to the last ticked or highlighted row. */
+/** How many leading rows the cap keeps: `rowLimit` rows on screen, stretched down to the last ticked row. */
function getRowLimitEnd(rows: SearchListItem[], rowLimit: number, selectedTransactions: SelectedTransactions | undefined, isOffline: boolean): number {
const isKeySelected = (key: string | undefined) => !!key && !!selectedTransactions?.[key]?.isSelected;
let shownRowCount = 0;
@@ -109,7 +107,7 @@ function getRowLimitEnd(rows: SearchListItem[], rowLimit: number, selectedTransa
}
shownRowCount += 1;
const isRowSelected = isKeySelected(row.keyForList) || (isTransactionGroupListItemType(row) && row.transactions.some((transaction) => isKeySelected(transaction.keyForList)));
- if (shownRowCount <= rowLimit || !!row.shouldAnimateInHighlight || isRowSelected) {
+ if (shownRowCount <= rowLimit || isRowSelected) {
end = index + 1;
}
}
@@ -122,19 +120,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,
- visibleRowLimit,
- selectedTransactions,
-}: UseSearchSnapshotParams): SearchSnapshotResult {
+function useSearchSnapshot({queryJSON, searchResults, transactions, reportActions, visibleRowLimit, selectedTransactions}: UseSearchSnapshotParams): SearchSnapshotResult {
const {type, sortBy, sortOrder, hash, groupBy} = queryJSON;
const {isOffline} = useNetwork();
@@ -356,8 +346,8 @@ function useSearchSnapshot({
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;
@@ -368,47 +358,13 @@ function useSearchSnapshot({
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 d51b2e0c2487..ef9119b6e73d 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';
@@ -17,7 +16,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';
@@ -246,7 +245,6 @@ function Search({
const {translate} = useLocalize();
const {getCurrencyDecimals} = useCurrencyListActions();
const delegateAccountID = useDelegateAccountID();
- const searchListRef = useRef | null>(null);
const savedSearchSelector = useCallback(
(searches: OnyxEntry) => {
@@ -275,7 +273,7 @@ function Search({
clearSelectedTransactions();
}, [validGroupBy, prevValidGroupBy, clearSelectedTransactions]);
- const {newSearchResultKeys, handleSelectionListScroll, newTransactions, hasQueuedHighlights} = useSearchHighlightAndScroll({
+ const {newTransactions} = useSearchAutoRefetch({
searchResults,
transactions,
previousTransactions,
@@ -285,7 +283,6 @@ function Search({
shouldCalculateTotals,
reportActions,
previousReportActions,
- shouldUseLiveData,
});
const {
@@ -309,23 +306,12 @@ function Search({
} = useSearchSnapshot({
queryJSON,
searchResults,
- newSearchResultKeys,
transactions,
reportActions,
visibleRowLimit: shouldUseLiveData ? liveRowLimit : undefined,
selectedTransactions,
});
- // 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);
@@ -385,14 +371,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.
@@ -1014,9 +992,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.
@@ -1399,7 +1376,6 @@ function Search({
};
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 5893f3202faf..6a5fd8d8e8de 100644
--- a/src/components/Search/searchViewProps.ts
+++ b/src/components/Search/searchViewProps.ts
@@ -6,18 +6,12 @@ import type {GetReportTableColumnStylesParams} from '@styles/utils';
import type {CardList, PolicyCategories, PolicyTagLists, 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 {OnyxCollection} from 'react-native-onyx';
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.
@@ -56,7 +50,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). */
@@ -77,9 +71,6 @@ type CommonSearchViewProps = {
contentContainerStyle: StyleProp;
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/components/SelectionList/ListItemComposed/hooks/useListItemHighlight.ts b/src/components/SelectionList/ListItemComposed/hooks/useListItemHighlight.ts
deleted file mode 100644
index c8b293ee938a..000000000000
--- a/src/components/SelectionList/ListItemComposed/hooks/useListItemHighlight.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import useRowHighlightAnimation from '@hooks/useRowHighlightAnimation';
-import useThemeStyles from '@hooks/useThemeStyles';
-
-type UseListItemHighlightParams = {
- /** Whether the row should play the highlight animation */
- shouldHighlight?: boolean;
-
- /** Whether the row is currently selected */
- isSelected?: boolean;
-};
-
-/**
- * Highlight animation plus the pressable styles that let it show through.
- * `isSelected` is not forwarded to the animation: selection is painted on the pressable, not the animated wrapper.
- */
-function useListItemHighlight({shouldHighlight = false, isSelected = false}: UseListItemHighlightParams = {}) {
- const styles = useThemeStyles();
-
- const animatedHighlightStyle = useRowHighlightAnimation({shouldHighlight, borderRadius: styles.selectionListPressableItemWrapper.borderRadius});
-
- return {
- animatedHighlightStyle,
- pressableStyle: [styles.selectionListPressableItemWrapper, styles.mh0, styles.bgTransparent, isSelected && styles.activeComponentBG],
- pressableWrapperStyle: [styles.mh5, animatedHighlightStyle],
- };
-}
-
-export default useListItemHighlight;
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 4f99429f78ec..38ec4cd0e630 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 type {SearchKey} from '@libs/SearchKeyUtils';
@@ -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 8f1b2c0dec19..8fa0cf2c99d2 100644
--- a/src/languages/de.ts
+++ b/src/languages/de.ts
@@ -1342,6 +1342,8 @@ const translations: TranslationDeepObject = {
: 'Bevor Sie Entfernungen erfassen, müssen Sie Ihre Privatadresse in Ihrem privaten Profil hinzufügen. Dieser Arbeitsbereich verwendet diese Adresse für Pendlerabzüge.',
cta: 'Heimatadresse hinzufügen',
},
+ expenseAdded: 'Ausgabe hinzugefügt',
+ invoiceSent: 'Rechnung gesendet',
amount: 'Betrag',
percent: 'Prozent',
date: 'Datum',
diff --git a/src/languages/el.ts b/src/languages/el.ts
index 35640ba3a380..f06bb7d476b5 100644
--- a/src/languages/el.ts
+++ b/src/languages/el.ts
@@ -1387,6 +1387,8 @@ const translations: TranslationDeepObject = {
: 'Πριν καταγράψετε αποστάσεις, πρέπει να προσθέσετε τη διεύθυνση κατοικίας σας στο ιδιωτικό προφίλ σας. Αυτός ο χώρος εργασίας χρησιμοποιεί αυτή τη διεύθυνση για εκπτώσεις μετακίνησης.',
cta: 'Προσθήκη οικιακής διεύθυνσης',
},
+ expenseAdded: 'Δαπάνη προστέθηκε',
+ invoiceSent: 'Τιμολόγιο στάλθηκε',
amount: 'Ποσό',
percent: 'Ποσοστό',
date: 'Ημερομηνία',
diff --git a/src/languages/en.ts b/src/languages/en.ts
index 01d0523f1066..fca760c799f0 100644
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -1440,6 +1440,8 @@ const translations = {
: 'Before you track distance, you need to add your home address to your private profile. This workspace uses this address for commuter deductions.',
cta: 'Add Home Address',
},
+ expenseAdded: 'Expense added',
+ invoiceSent: 'Invoice sent',
amount: 'Amount',
percent: 'Percent',
date: 'Date',
diff --git a/src/languages/es.ts b/src/languages/es.ts
index 24d35d42ed5b..fa71f5bb1212 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -1336,6 +1336,8 @@ const translations: TranslationDeepObject = {
: 'Antes de registrar distancias, tienes que añadir tu dirección de casa a tu perfil privado. Este espacio de trabajo utiliza esta dirección para las deducciones por desplazamientos al trabajo.',
cta: 'Añadir dirección de domicilio',
},
+ expenseAdded: 'Gasto añadido',
+ invoiceSent: 'Factura enviada',
amount: 'Importe',
percent: 'Porcentaje',
date: 'Fecha',
diff --git a/src/languages/fr.ts b/src/languages/fr.ts
index 88b4976a3a87..4159eda4f920 100644
--- a/src/languages/fr.ts
+++ b/src/languages/fr.ts
@@ -1348,6 +1348,8 @@ const translations: TranslationDeepObject = {
: 'Avant de suivre une distance, vous devez ajouter votre adresse personnelle à votre profil privé. Cet espace de travail utilise cette adresse pour les déductions liées aux trajets domicile-travail.',
cta: 'Ajouter une adresse domicile',
},
+ expenseAdded: 'Dépense ajoutée',
+ invoiceSent: 'Facture envoyée',
amount: 'Montant',
percent: 'Pourcentage',
date: 'Date',
diff --git a/src/languages/it.ts b/src/languages/it.ts
index fc633d94cd64..661100aeab25 100644
--- a/src/languages/it.ts
+++ b/src/languages/it.ts
@@ -1339,6 +1339,8 @@ const translations: TranslationDeepObject = {
: 'Prima di poter registrare la distanza, devi aggiungere il tuo indirizzo di casa al tuo profilo privato. Questo spazio di lavoro utilizza questo indirizzo per le detrazioni per i pendolari.',
cta: 'Aggiungi indirizzo di casa',
},
+ expenseAdded: 'Spesa aggiunta',
+ invoiceSent: 'Fattura inviata',
amount: 'Importo',
percent: 'Percentuale',
date: 'Data',
diff --git a/src/languages/ja.ts b/src/languages/ja.ts
index dbf81a6d2546..f2e16ebe28e5 100644
--- a/src/languages/ja.ts
+++ b/src/languages/ja.ts
@@ -1324,6 +1324,8 @@ const translations: TranslationDeepObject = {
: '距離を記録する前に、プライベートプロフィールに自宅住所を追加する必要があります。このワークスペースでは、その住所を通勤控除に使用します。',
cta: '自宅住所を追加',
},
+ expenseAdded: '経費を追加しました',
+ invoiceSent: '請求書を送信しました',
amount: '金額',
percent: 'パーセント',
date: '日付',
diff --git a/src/languages/nl.ts b/src/languages/nl.ts
index 951d4af4373d..76a7706c68a0 100644
--- a/src/languages/nl.ts
+++ b/src/languages/nl.ts
@@ -1340,6 +1340,8 @@ const translations: TranslationDeepObject = {
: 'Voordat je afstand kunt bijhouden, moet je je huisadres toevoegen aan je privéprofiel. Deze werkruimte gebruikt dit adres voor woon-werkverkeeraftrek.',
cta: 'Thuisadres toevoegen',
},
+ expenseAdded: 'Uitgave toegevoegd',
+ invoiceSent: 'Factuur verzonden',
amount: 'Bedrag',
percent: 'Procent',
date: 'Datum',
diff --git a/src/languages/pl.ts b/src/languages/pl.ts
index 96da1d98b218..0d896dcca1ea 100644
--- a/src/languages/pl.ts
+++ b/src/languages/pl.ts
@@ -1373,6 +1373,8 @@ const translations: TranslationDeepObject = {
: 'Zanim zaczniesz śledzić dystans, musisz dodać swój adres domowy do prywatnego profilu. To miejsce pracy używa tego adresu do odliczeń dojazdów.',
cta: 'Dodaj adres domowy',
},
+ expenseAdded: 'Dodano wydatek',
+ invoiceSent: 'Wysłano fakturę',
amount: 'Kwota',
percent: 'Procent',
date: 'Data',
diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts
index bca44cdac057..2ac779796513 100644
--- a/src/languages/pt-BR.ts
+++ b/src/languages/pt-BR.ts
@@ -1339,6 +1339,8 @@ const translations: TranslationDeepObject = {
: 'Antes de registrar a distância, você precisa adicionar seu endereço residencial ao seu perfil privado. Este workspace usa esse endereço para deduções de deslocamento.',
cta: 'Adicionar endereço residencial',
},
+ expenseAdded: 'Despesa adicionada',
+ invoiceSent: 'Fatura enviada',
amount: 'Valor',
percent: 'Porcentagem',
date: 'Data',
diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts
index 46cfeea05ee5..3c5b5cc8de8b 100644
--- a/src/languages/zh-hans.ts
+++ b/src/languages/zh-hans.ts
@@ -1287,6 +1287,8 @@ const translations: TranslationDeepObject = {
: '在开始记录里程之前,您需要在个人资料中添加您的家庭住址。此工作区会使用该地址计算通勤扣除。',
cta: '添加家庭住址',
},
+ expenseAdded: '已添加支出',
+ invoiceSent: '已发送发票',
amount: '金额',
percent: '百分比',
date: '日期',
diff --git a/src/libs/ExportOnyxState/masking.ts b/src/libs/ExportOnyxState/masking.ts
index ab9b84b12f1c..e35baaec2525 100644
--- a/src/libs/ExportOnyxState/masking.ts
+++ b/src/libs/ExportOnyxState/masking.ts
@@ -329,6 +329,7 @@ const safeOnyxKeys = new Set([
ONYXKEYS.PREFERRED_THEME,
ONYXKEYS.RAM_ONLY_ARE_TRANSLATIONS_LOADING,
ONYXKEYS.RAM_ONLY_CORPAY_PAY_MODAL,
+ ONYXKEYS.RAM_ONLY_EXPENSE_ADDED_GROWL_TRANSACTION_IDS,
ONYXKEYS.RAM_ONLY_IS_AUTHENTICATING_WITH_SHORT_LIVED_TOKEN,
ONYXKEYS.RAM_ONLY_IS_CHECKING_PENDING_WALLET_APPROVAL,
ONYXKEYS.RAM_ONLY_IS_CHECKING_PUBLIC_ROOM,
@@ -357,7 +358,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/dismissModalAndOpenReportInInboxTab.ts b/src/libs/Navigation/helpers/dismissModalAndOpenReportInInboxTab.ts
index 8edd6860c800..8074ab9c774d 100644
--- a/src/libs/Navigation/helpers/dismissModalAndOpenReportInInboxTab.ts
+++ b/src/libs/Navigation/helpers/dismissModalAndOpenReportInInboxTab.ts
@@ -1,3 +1,4 @@
+import {mergeExpenseAddedGrowlTransactionIDs} from '@libs/actions/Transaction';
import getIsNarrowLayout from '@libs/getIsNarrowLayout';
import Navigation, {navigationRef} from '@libs/Navigation/Navigation';
import {endSubmitFollowUpActionSpan, isTracking as isSubmitTracking, setPendingSubmitFollowUpAction} from '@libs/telemetry/submitFollowUpAction';
@@ -14,7 +15,7 @@ import setNavigationActionToMicrotaskQueue from './setNavigationActionToMicrotas
* After finishing the action in RHP from the Inbox tab, besides dismissing the modal, we should open the report.
* If the action is done from the report RHP, then we just want to dismiss the money request flow screens.
*/
-function dismissModalAndOpenReportInInboxTab(reportID: string | undefined, isInvoice: boolean | undefined, hasMultipleTransactions: boolean) {
+function dismissModalAndOpenReportInInboxTab(reportID: string | undefined, isInvoice: boolean | undefined, hasMultipleTransactions: boolean, transactionID?: string) {
const rootState = navigationRef.getRootState();
const hasActiveTracking = isSubmitTracking();
@@ -40,6 +41,11 @@ function dismissModalAndOpenReportInInboxTab(reportID: string | undefined, isInv
}
// When a report with one expense is opened in the wide RHP and the user adds another expense, RHP should be dismissed and ROUTES.SEARCH_MONEY_REQUEST_REPORT should be displayed.
if (hasMultipleTransactions && reportID) {
+ // This navigation opens the expense report that contains the new transaction, so consume its pending growl
+ // before the deferred navigation. Inferring the destination from navigation state races the queued action below.
+ if (transactionID) {
+ mergeExpenseAddedGrowlTransactionIDs({[transactionID]: null});
+ }
// On small screens, dismiss all modals and then navigate to the right report.
// On large screens, dismiss to the previous RHP first, then replace the current route with the new report.
const isNarrowLayout = getIsNarrowLayout();
diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts
index 74acd3ba3b8b..efa4399fe9c1 100644
--- a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts
+++ b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts
@@ -17,12 +17,25 @@ import isReportTopmostSplitNavigator from './isReportTopmostSplitNavigator';
import isSearchTopmostFullScreenRoute from './isSearchTopmostFullScreenRoute';
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;
/**
@@ -54,7 +67,7 @@ function getNavigateAfterCreateSearchNavigatorState() {
* 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,
@@ -76,7 +89,7 @@ function navigateAfterExpenseCreate({
// and open the report chat containing the IOU report
if (!isFromGlobalCreate || isUserOnInbox || !transactionID) {
if (shouldNavigate) {
- dismissModalAndOpenReportInInboxTab(activeReportID, isInvoice, hasMultipleTransactions);
+ dismissModalAndOpenReportInInboxTab(activeReportID, isInvoice, hasMultipleTransactions, transactionID);
}
if (shouldAddPendingNewTransactionIDs) {
addPendingNewTransactionIDs(activeReportID, transactionID);
diff --git a/src/libs/Navigation/helpers/navigateToCreatedExpense.ts b/src/libs/Navigation/helpers/navigateToCreatedExpense.ts
new file mode 100644
index 000000000000..80d57bd4ee92
--- /dev/null
+++ b/src/libs/Navigation/helpers/navigateToCreatedExpense.ts
@@ -0,0 +1,92 @@
+import {setActiveTransactionIDs} from '@libs/actions/TransactionThreadNavigation';
+import getIsNarrowLayout from '@libs/getIsNarrowLayout';
+import Navigation, {navigationRef} from '@libs/Navigation/Navigation';
+
+import CONST from '@src/CONST';
+import ROUTES from '@src/ROUTES';
+import type {Transaction} from '@src/types/onyx';
+
+import isReportOpenInRHP from './isReportOpenInRHP';
+import isReportTopmostSplitNavigator from './isReportTopmostSplitNavigator';
+import isSearchTopmostFullScreenRoute from './isSearchTopmostFullScreenRoute';
+import setNavigationActionToMicrotaskQueue from './setNavigationActionToMicrotaskQueue';
+
+type NavigateToCreatedExpenseParams = {
+ /** The transaction thread report to open. */
+ threadReportID: string;
+
+ /** The created transaction's ID. */
+ transactionID: string;
+
+ /** IOU report the transaction landed in, used to decide whether to stack the expense report underneath. */
+ iouReportID?: string;
+
+ /** Transactions currently belonging to the IOU report. */
+ reportTransactions: Transaction[];
+};
+
+function getCurrentRouteBackTo() {
+ const params = navigationRef.current?.getCurrentRoute()?.params;
+ if (typeof params !== 'object' || params === null || !('backTo' in params) || typeof params.backTo !== 'string') {
+ return undefined;
+ }
+ return params.backTo;
+}
+
+/**
+ * Opens a just-created expense when "View" is pressed on the "Expense added" growl. The user may have
+ * switched tabs while the growl was up, so the destination follows wherever they are now.
+ */
+function navigateToCreatedExpense({threadReportID, transactionID, iouReportID, reportTransactions}: NavigateToCreatedExpenseParams) {
+ // Don't reopen an expense the user is already looking at
+ const hasMultipleReportTransactions = iouReportID
+ ? reportTransactions.filter((transaction) => transaction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE).length > 1
+ : false;
+ const focusedReportID = Navigation.getFocusedReportId();
+ if (focusedReportID === threadReportID || (!hasMultipleReportTransactions && !!iouReportID && focusedReportID === iouReportID)) {
+ return;
+ }
+
+ const openOnInbox = isReportTopmostSplitNavigator() && !isSearchTopmostFullScreenRoute();
+
+ // When a report/expense is already open in the RHP the app's convention is to replace it rather than stack a second
+ // report RHP on top of it.
+ const forceReplace = isReportOpenInRHP(navigationRef.getRootState());
+ const backTo = forceReplace ? getCurrentRouteBackTo() : Navigation.getActiveRoute();
+
+ if (!openOnInbox) {
+ setActiveTransactionIDs([transactionID]).then(() => {
+ Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: threadReportID, backTo}), {forceReplace});
+ });
+ return;
+ }
+
+ if (getIsNarrowLayout()) {
+ Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(threadReportID, undefined, undefined, backTo), {forceReplace});
+ return;
+ }
+ if (iouReportID) {
+ // The replaced RHP's backTo can point at this same report, which turns into a not-here page once it's
+ // deleted. Drop it so deleting falls back to the report's chat instead.
+ Navigation.navigate(ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: iouReportID, backTo: forceReplace ? undefined : backTo}), {forceReplace});
+
+ // A multi-transaction report opens super wide RHP, so stack the thread RHP on top of it. A single-transaction
+ // report collapses to the thread itself, so the navigation above already landed on it.
+ if (hasMultipleReportTransactions) {
+ // Defer so the thread RHP stacks on top of the expense report navigation above. This is always a
+ // push (never a replace) - it stacks on the report we just opened, not on the previously-open one.
+ setNavigationActionToMicrotaskQueue(() => {
+ setActiveTransactionIDs([transactionID]).then(() => {
+ Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: threadReportID, backTo: Navigation.getActiveRoute()}));
+ });
+ });
+ }
+ return;
+ }
+
+ // A tracked expense has no expense report, so open the thread as a full report - the same way tapping
+ // the expense in its self-DM chat does.
+ Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(threadReportID, undefined, undefined, backTo), {forceReplace});
+}
+
+export default navigateToCreatedExpense;
diff --git a/src/libs/actions/IOU/Duplicate.ts b/src/libs/actions/IOU/Duplicate.ts
index 83de6000ea0b..6cfcf9461522 100644
--- a/src/libs/actions/IOU/Duplicate.ts
+++ b/src/libs/actions/IOU/Duplicate.ts
@@ -10,6 +10,7 @@ import {WRITE_COMMANDS} from '@libs/API/types';
import DateUtils from '@libs/DateUtils';
import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils';
import {getExistingTransactionID} from '@libs/IOUUtils';
+import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute';
import * as NumberUtils from '@libs/NumberUtils';
import Parser from '@libs/Parser';
import {getReimbursementChoice, isInstantSubmitEnabled, isPolicyAccessible, isSubmitAndClose} from '@libs/PolicyUtils';
@@ -70,6 +71,7 @@ import {getAllTransactions, getCurrentUserAccountIDFromSession} 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';
@@ -1371,6 +1373,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);
@@ -1442,6 +1445,9 @@ function bulkDuplicateExpenses({
if (result?.iouReport) {
optimisticIOUReport = result.iouReport;
}
+ if (result?.transactionID) {
+ lastDuplicateTransactionID = result.transactionID;
+ }
if (currentTargetReport && !currentTargetReport.iouReportID) {
currentTargetReport = {...currentTargetReport, iouReportID: currentOptimisticIOUReportID};
@@ -1449,6 +1455,9 @@ function bulkDuplicateExpenses({
}
playSound(SOUNDS.DONE);
+ if (isSearchTopmostFullScreenRoute()) {
+ 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 29423ffc82f0..100373b9eb71 100644
--- a/src/libs/actions/IOU/PerDiem.ts
+++ b/src/libs/actions/IOU/PerDiem.ts
@@ -68,8 +68,8 @@ import {
mergePolicyRecentlyUsedCategories,
mergePolicyRecentlyUsedCurrencies,
} from './MoneyRequestBuilder';
-import {highlightTransactionOnSearchRouteIfNeeded} from './NavigationHelpers';
import resolveWriteBarrier from './resolveWriteBarrier';
+import signalExpenseAddedGrowl from './signalExpenseAddedGrowl';
function removeSubrate(transaction: OnyxEntry, currentIndex: string) {
// Index comes from the route params and is a string
@@ -1199,7 +1199,9 @@ function submitPerDiemExpense(submitPerDiemExpenseInformation: PerDiemExpenseInf
{onWriteStarted: () => notifyNewAction(notifyReportID ?? chatReport.reportID, undefined, participantParams.payeeAccountID === currentUserAccountIDParam)},
);
- highlightTransactionOnSearchRouteIfNeeded(isFromGlobalCreate, transaction.transactionID, CONST.SEARCH.DATA_TYPES.EXPENSE);
+ if (isFromGlobalCreate) {
+ signalExpenseAddedGrowl(transaction.transactionID, CONST.SEARCH.DATA_TYPES.EXPENSE);
+ }
return {iouReport, transactionID: transaction.transactionID};
}
@@ -1222,7 +1224,7 @@ function submitPerDiemExpenseForSelfDM(submitPerDiemExpenseInformation: PerDiemE
getCurrencyDecimals,
writeBarrier,
} = submitPerDiemExpenseInformation;
- const {currency, comment = '', category, tag, created, customUnit, attendees, billable, reimbursable} = transactionParams;
+ const {currency, comment = '', category, tag, created, customUnit, attendees, billable, reimbursable, isFromGlobalCreate} = transactionParams;
if (isEmptyObject(policy) || !hasCompletePerDiemCustomUnit(customUnit)) {
return;
@@ -1282,6 +1284,10 @@ function submitPerDiemExpenseForSelfDM(submitPerDiemExpenseInformation: PerDiemE
resolveWriteBarrier({writeBarrier, optimisticWatchKey: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`}),
{onWriteStarted: () => notifyNewAction(chatReport.reportID, undefined, true)},
);
+
+ if (isFromGlobalCreate) {
+ signalExpenseAddedGrowl(transaction.transactionID, CONST.SEARCH.DATA_TYPES.EXPENSE);
+ }
}
export {
diff --git a/src/libs/actions/IOU/SendInvoice.ts b/src/libs/actions/IOU/SendInvoice.ts
index 15fdd2b74b97..a7d479a61134 100644
--- a/src/libs/actions/IOU/SendInvoice.ts
+++ b/src/libs/actions/IOU/SendInvoice.ts
@@ -45,9 +45,9 @@ import type BasePolicyParams from './types/BasePolicyParams';
import {getAllPersonalDetails} from '.';
import {getReceiptError, mergePolicyRecentlyUsedCategories, mergePolicyRecentlyUsedCurrencies} from './MoneyRequestBuilder';
-import {highlightTransactionOnSearchRouteIfNeeded} from './NavigationHelpers';
import resolveWriteBarrier from './resolveWriteBarrier';
import {getSearchOnyxUpdate} from './SearchUpdate';
+import signalExpenseAddedGrowl from './signalExpenseAddedGrowl';
type SendInvoiceInformation = {
senderWorkspaceID: string | undefined;
@@ -876,7 +876,9 @@ function sendInvoice({
onWriteStarted: () => notifyNewAction(invoiceRoom.reportID, undefined, true),
});
- highlightTransactionOnSearchRouteIfNeeded(isFromGlobalCreate, transactionID, CONST.SEARCH.DATA_TYPES.INVOICE);
+ if (isFromGlobalCreate) {
+ signalExpenseAddedGrowl(transactionID, CONST.SEARCH.DATA_TYPES.INVOICE);
+ }
}
export {getReceiverType, getSendInvoiceInformation, sendInvoice};
diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts
index c678691e4142..d61e381a7a83 100644
--- a/src/libs/actions/IOU/Split.ts
+++ b/src/libs/actions/IOU/Split.ts
@@ -87,9 +87,9 @@ import {
mergePolicyRecentlyUsedCategories,
mergePolicyRecentlyUsedCurrencies,
} from './MoneyRequestBuilder';
-import {highlightTransactionOnSearchRouteIfNeeded} from './NavigationHelpers';
import {addPendingNewTransactionIDs, isOneToTwoTransactionTransition} from './PendingNewTransactions';
import resolveWriteBarrier from './resolveWriteBarrier';
+import signalExpenseAddedGrowl from './signalExpenseAddedGrowl';
type IOURequestType = ValueOf;
@@ -2378,7 +2378,9 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest
{onWriteStarted: isMoneyRequestReport ? undefined : () => notifyNewAction(activeReportID, undefined, true)},
);
- highlightTransactionOnSearchRouteIfNeeded(isFromGlobalCreate, parameters.transactionID, CONST.SEARCH.DATA_TYPES.EXPENSE);
+ if (isFromGlobalCreate) {
+ signalExpenseAddedGrowl(parameters.transactionID, CONST.SEARCH.DATA_TYPES.EXPENSE);
+ }
return {iouReport: distanceIouReport, chatReportID: parameters.chatReportID, transactionID: parameters.transactionID};
}
diff --git a/src/libs/actions/IOU/SplitTransactionUpdate.ts b/src/libs/actions/IOU/SplitTransactionUpdate.ts
index 4311e3cbcb88..6ff6da69557e 100644
--- a/src/libs/actions/IOU/SplitTransactionUpdate.ts
+++ b/src/libs/actions/IOU/SplitTransactionUpdate.ts
@@ -46,7 +46,6 @@ import {
navigateBackOnDeleteTransaction,
updateOptimisticParentReportAction,
} from '@libs/ReportUtils';
-import {getCurrentSearchQueryJSON} from '@libs/SearchQueryUtils';
import {isTracking, setPendingSubmitFollowUpAction} from '@libs/telemetry/submitFollowUpAction';
import {
getChildTransactions,
@@ -57,7 +56,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';
@@ -84,6 +82,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';
@@ -2118,34 +2117,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 (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 (isSearchPageTopmostFullScreenRoute && !isReverseSplitOperation) {
+ signalExpenseAddedGrowl(getNewSplitTransactionIDs().at(-1), CONST.SEARCH.DATA_TYPES.EXPENSE);
+ }
if (!isSelfDMSplit) {
Navigation.navigateBackToLastSuperWideRHPScreen();
@@ -2189,11 +2167,16 @@ function updateSplitTransactionsFromSplitExpensesFlow(params: UpdateSplitTransac
setPendingSubmitFollowUpAction(CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.DISMISS_MODAL_AND_OPEN_REPORT, targetReportID);
}
+ // When the transaction thread is the topmost report (e.g. it was opened directly from the "Expense added" growl,
+ // without the expense report beneath it), replace it instead of removing it and pushing the expense report after the
+ // dismissal animation - otherwise the stack empties down to the chat report and it flashes into view in between.
+ const shouldReplaceTransactionThread = !!transactionThreadReportID && Navigation.getTopmostReportId() === transactionThreadReportID;
+
popReportsSplitNavigatorToReport(targetReportID);
- Navigation.dismissModalWithReport({reportID: targetReportID});
+ Navigation.dismissModalWithReport({reportID: targetReportID}, navigationRef, {forceReplace: shouldReplaceTransactionThread});
requestAnimationFrame(() => {
updateSplitTransactions({...params, isFromSplitExpensesFlow: true});
- if (!transactionThreadReportScreen?.key) {
+ if (shouldReplaceTransactionThread || !transactionThreadReportScreen?.key) {
return;
}
Navigation.removeScreenByKey(transactionThreadReportScreen.key);
diff --git a/src/libs/actions/IOU/TrackExpense.ts b/src/libs/actions/IOU/TrackExpense.ts
index 9273669a14e2..8681120043c2 100644
--- a/src/libs/actions/IOU/TrackExpense.ts
+++ b/src/libs/actions/IOU/TrackExpense.ts
@@ -132,10 +132,10 @@ import {
getReportPreviewReportAction,
getTransactionWithPreservedLocalReceiptSource,
} from './MoneyRequestBuilder';
-import {highlightTransactionOnSearchRouteIfNeeded} from './NavigationHelpers';
import {addPendingNewTransactionIDs, isOneToTwoTransactionTransition} from './PendingNewTransactions';
import resolveWriteBarrier, {IMMEDIATE} from './resolveWriteBarrier';
import {getSearchOnyxUpdate} from './SearchUpdate';
+import signalExpenseAddedGrowl from './signalExpenseAddedGrowl';
type TrackExpenseInformation = {
createdWorkspaceParams?: CreateWorkspaceParams;
@@ -1661,7 +1661,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,
@@ -2000,11 +2000,11 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep
deferredAPIWrite?.();
- if (!requestMoneyInformation.isRetry) {
- highlightTransactionOnSearchRouteIfNeeded(isFromGlobalCreate, transaction.transactionID, CONST.SEARCH.DATA_TYPES.EXPENSE);
+ if (!requestMoneyInformation.isRetry && isFromGlobalCreate) {
+ signalExpenseAddedGrowl(transaction.transactionID, CONST.SEARCH.DATA_TYPES.EXPENSE);
}
- return {iouReport};
+ return {iouReport, transactionID: transaction.transactionID};
}
/**
@@ -2963,13 +2963,15 @@ 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);
}
if (!isNotificationDeferredToWrite) {
notifyTrackedAction();
}
+
+ 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..a34b6cbcfa35
--- /dev/null
+++ b/src/libs/actions/IOU/signalExpenseAddedGrowl.ts
@@ -0,0 +1,16 @@
+import {mergeExpenseAddedGrowlTransactionIDs} from '@userActions/Transaction';
+
+import type {SearchDataTypes} from '@src/types/onyx/SearchResults';
+
+/**
+ * Queues the "Expense added" growl (toast) for a newly-created transaction. Signal whenever the flow leaves the
+ * user somewhere other than the expense's report - the growl suppresses itself if they end up on that report.
+ */
+function signalExpenseAddedGrowl(transactionID: string | undefined, dataType: SearchDataTypes) {
+ if (!transactionID) {
+ return;
+ }
+ mergeExpenseAddedGrowlTransactionIDs({[transactionID]: dataType});
+}
+
+export default signalExpenseAddedGrowl;
diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts
index c1d64e1b500c..fbcb8b28b1c0 100644
--- a/src/libs/actions/Transaction.ts
+++ b/src/libs/actions/Transaction.ts
@@ -1625,6 +1625,7 @@ function getChangeTransactionsReportOnyxData({
key: `${ONYXKEYS.COLLECTION.REPORT}${newIOUAction.childReportID}`,
value: {
parentReportID: targetReportID,
+ chatReportID: targetReportID,
parentReportActionID: optimisticMoneyRequestReportActionID,
policyID: reportID !== CONST.REPORT.UNREPORTED_REPORT_ID && newReport ? newReport.policyID : CONST.POLICY.ID_FAKE,
participants: isUnreported && shouldRemoveOtherParticipants ? {[accountID]: participants?.[accountID]} : participants,
@@ -1638,6 +1639,7 @@ function getChangeTransactionsReportOnyxData({
key: `${ONYXKEYS.COLLECTION.REPORT}${oldIOUAction.childReportID}`,
value: {
parentReportID: isUnreportedExpense ? selfDMReportID : oldReportID,
+ chatReportID: reports?.[`${ONYXKEYS.COLLECTION.REPORT}${oldIOUAction.childReportID}`]?.chatReportID,
parentReportActionID: oldIOUAction.reportActionID,
policyID: reports?.[`${ONYXKEYS.COLLECTION.REPORT}${oldIOUAction.childReportID}`]?.policyID,
},
@@ -2239,8 +2241,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.RAM_ONLY_EXPENSE_ADDED_GROWL_TRANSACTION_IDS, data);
}
function getDuplicateTransactionDetails(transactionID?: string) {
@@ -2278,7 +2280,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 43a398f6bbdc..ec5cd6c592e6 100644
--- a/src/pages/Share/SubmitDetailsPage.tsx
+++ b/src/pages/Share/SubmitDetailsPage.tsx
@@ -32,6 +32,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';
@@ -479,6 +480,10 @@ function SubmitDetailsPage({
rules,
});
}
+
+ // 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 15843b7d89ad..5a03e9b58f49 100644
--- a/src/setup/index.ts
+++ b/src/setup/index.ts
@@ -71,6 +71,7 @@ export default function () {
skippableCollectionMemberIDs: CONST.SKIPPABLE_COLLECTION_MEMBER_IDS,
snapshotMergeKeys: ['pendingAction', 'pendingFields'],
ramOnlyKeys: [
+ ONYXKEYS.RAM_ONLY_EXPENSE_ADDED_GROWL_TRANSACTION_IDS,
ONYXKEYS.RAM_ONLY_ARE_TRANSLATIONS_LOADING,
ONYXKEYS.RAM_ONLY_MOBILE_SELECTION_MODE,
ONYXKEYS.RAM_ONLY_IS_SIDEBAR_LOADED,
diff --git a/src/styles/index.ts b/src/styles/index.ts
index 99fca1fe20be..16bd6ff61dfc 100644
--- a/src/styles/index.ts
+++ b/src/styles/index.ts
@@ -3621,7 +3621,7 @@ const staticStyles = (theme: ThemeColors) =>
},
growlNotificationWrapper: {
- zIndex: 2,
+ zIndex: variables.growlNotificationZIndex,
},
growlNotificationContainer: {
diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts
index d6a406ef8dbc..895135311b65 100644
--- a/src/styles/utils/index.ts
+++ b/src/styles/utils/index.ts
@@ -1981,7 +1981,9 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({
borderColor: isSelected ? theme.buttonHoveredBG : theme.border,
}),
- getSearchTableHighlightBorderRadius: (isLargeScreenWidth: boolean): number => (isLargeScreenWidth ? 0 : variables.componentBorderRadius),
+ getSearchRowBackgroundStyle: (isSelected?: boolean): ViewStyle => ({
+ backgroundColor: isSelected ? theme.activeComponentBG : theme.highlightBG,
+ }),
getReportTableColumnStyles: (columnName: string, options: GetReportTableColumnStylesParams = {}): ViewStyle => {
const {
diff --git a/src/styles/variables.ts b/src/styles/variables.ts
index 0a55871c27d5..25977c4e489a 100644
--- a/src/styles/variables.ts
+++ b/src/styles/variables.ts
@@ -304,6 +304,7 @@ export default {
reportActionImagesDoubleImageHeight: 138,
reportActionImagesMultipleImageHeight: 110,
reportActionItemImagesMoreCornerTriangleWidth: 40,
+ growlNotificationZIndex: 10001,
popoverZIndex: 10000,
modalBaseZIndex: 9999,
autoCompleteSuggestionsZIndex: 9999,
diff --git a/tests/actions/IOU/PerDiemTest.ts b/tests/actions/IOU/PerDiemTest.ts
index aaeee06323f9..670ca6297765 100644
--- a/tests/actions/IOU/PerDiemTest.ts
+++ b/tests/actions/IOU/PerDiemTest.ts
@@ -8,6 +8,7 @@ import {
hasCompletePerDiemCustomUnit,
removeSubrate,
submitPerDiemExpense,
+ submitPerDiemExpenseForSelfDM,
updateSubrate,
} from '@libs/actions/IOU/PerDiem';
import type RequestMoneyParticipantParams from '@libs/actions/IOU/types/RequestMoneyParticipantParams';
@@ -1340,6 +1341,57 @@ describe('PerDiem', () => {
});
});
+ describe('submitPerDiemExpenseForSelfDM', () => {
+ const selfDMCustomUnit: TransactionCustomUnit = {
+ customUnitID: 'unit-1',
+ customUnitRateID: 'rate-1',
+ subRates: [{id: 'sub-1', name: 'Meals', quantity: 1, rate: 2500}],
+ attributes: {dates: {start: '2026-04-24', end: '2026-04-24'}},
+ };
+
+ const submitToSelfDM = (isFromGlobalCreate: boolean) =>
+ submitPerDiemExpenseForSelfDM({
+ dateFnsLocale: undefined,
+ getCurrencyDecimals: getCurrencyDecimalsLocal,
+ selfDMReport: undefined,
+ policy: {...createRandomPolicy(1)},
+ transactionParams: {
+ created: DateUtils.getDBTime(),
+ currency: CONST.CURRENCY.USD,
+ customUnit: selfDMCustomUnit,
+ isFromGlobalCreate,
+ },
+ currentUserAccountIDParam: currentUserPersonalDetails.accountID,
+ currentUserEmailParam: currentUserPersonalDetails.login ?? '',
+ quickAction: undefined,
+ optimisticChatReportID: 'self-dm-1',
+ delegateAccountID: undefined,
+ isTrackIntentUser: false,
+ });
+
+ it('should signal the expense added growl when submitted from global create', async () => {
+ // Given a per diem expense headed for the self-DM, started from the global create flow
+ // When it is submitted
+ submitToSelfDM(true);
+ await waitForBatchedUpdates();
+
+ // Then the new transaction is queued for the "Expense added" growl
+ const signal = await getOnyxValue(ONYXKEYS.RAM_ONLY_EXPENSE_ADDED_GROWL_TRANSACTION_IDS);
+ expect(Object.values(signal ?? {})).toEqual([CONST.SEARCH.DATA_TYPES.EXPENSE]);
+ });
+
+ it('should not signal the growl when submitted from within a report', async () => {
+ // Given a per diem expense headed for the self-DM, started from within a report
+ // When it is submitted
+ submitToSelfDM(false);
+ await waitForBatchedUpdates();
+
+ // Then nothing is queued for the growl
+ const signal = await getOnyxValue(ONYXKEYS.RAM_ONLY_EXPENSE_ADDED_GROWL_TRANSACTION_IDS);
+ expect(signal).toBeUndefined();
+ });
+ });
+
describe('hasCompletePerDiemCustomUnit', () => {
const completeCustomUnit: TransactionCustomUnit = {
customUnitID: 'unit-1',
diff --git a/tests/actions/IOU/SplitReportTotalsTest.ts b/tests/actions/IOU/SplitReportTotalsTest.ts
index dbd8117a5339..c0b4fffbd2ff 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';
@@ -413,33 +412,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'},
@@ -763,6 +735,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 = {
@@ -821,56 +805,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
@@ -945,10 +879,8 @@ 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 () => {
+ // Given an existing split is being reversed from the expense report
const existingChildTx = {
transactionID: 'child-tx-1',
reportID: EXPENSE_REPORT_ID,
@@ -968,50 +900,58 @@ describe('actions/IOU', () => {
updateSplitTransactionsFromSplitExpensesFlow(params);
await waitForBatchedUpdates();
- // Then nothing is highlighted — reverse splits create no new transactions
- expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).not.toHaveBeenCalled();
-
- spyOnMergeTransactionIdsHighlightOnSearchRoute.mockRestore();
+ // Then no expense-added growl is queued
+ const growlTransactionIDs = await getOnyxValue(ONYXKEYS.RAM_ONLY_EXPENSE_ADDED_GROWL_TRANSACTION_IDS);
+ expect(growlTransactionIDs?.['new-merged-tx']).toBeUndefined();
});
- it('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.
+ it('signals the "Expense added" growl when splitting from Spend', async () => {
+ // Given a transaction is being split from the Search tab
jest.mocked(isSearchTopmostFullScreenRoute).mockReturnValue(true);
- const 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'},
+ {transactionID: 'new-search-tx-1', reportID: EXPENSE_REPORT_ID, statusNum: 0, amount: 500, created: '2024-01-01'},
+ {transactionID: 'new-search-tx-2', reportID: EXPENSE_REPORT_ID, statusNum: 0, amount: 500, created: '2024-01-01'},
],
splitExpensesTotal: 1000,
},
});
- // When saving the split offline
+ // When saving the split
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],
- ]),
- );
+ // Then the latest new split is queued for the expense-added growl
+ const growlTransactionIDs = await getOnyxValue(ONYXKEYS.RAM_ONLY_EXPENSE_ADDED_GROWL_TRANSACTION_IDS);
+ expect(growlTransactionIDs?.['new-search-tx-2']).toBe(CONST.SEARCH.DATA_TYPES.EXPENSE);
+ });
- // 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();
+ it('does not signal the "Expense added" growl for a report without a parent outside Spend', async () => {
+ // Given a transaction in an Inbox report without a parent is being split
+ jest.mocked(isSearchTopmostFullScreenRoute).mockReturnValue(false);
+ const params = buildBaseParams({
+ transactionReport: {reportID: 'report-without-parent'},
+ transactionData: {
+ reportID: EXPENSE_REPORT_ID,
+ originalTransactionID: ORIGINAL_TX_ID,
+ splitExpenses: [
+ {transactionID: 'new-inbox-tx-1', reportID: EXPENSE_REPORT_ID, statusNum: 0, amount: 500, created: '2024-01-01'},
+ {transactionID: 'new-inbox-tx-2', reportID: EXPENSE_REPORT_ID, statusNum: 0, amount: 500, created: '2024-01-01'},
+ ],
+ splitExpensesTotal: 1000,
+ },
+ });
+
+ // When saving the split
+ updateSplitTransactionsFromSplitExpensesFlow(params);
+ await waitForBatchedUpdates();
- spyOnMergeTransactionIdsHighlightOnSearchRoute.mockRestore();
+ // Then no expense-added growl is queued
+ const growlTransactionIDs = await getOnyxValue(ONYXKEYS.RAM_ONLY_EXPENSE_ADDED_GROWL_TRANSACTION_IDS);
+ expect(growlTransactionIDs?.['new-inbox-tx-2']).toBeUndefined();
});
it('leaves no highlight flags anywhere when splits move out to a different report', async () => {
diff --git a/tests/actions/IOUTest/DuplicateTest.ts b/tests/actions/IOUTest/DuplicateTest.ts
index 96b967818290..f98a16e351a2 100644
--- a/tests/actions/IOUTest/DuplicateTest.ts
+++ b/tests/actions/IOUTest/DuplicateTest.ts
@@ -4,10 +4,12 @@ import type {RenderAPI} from '@testing-library/react-native';
import {bulkDuplicateExpenses, bulkDuplicateReports, duplicateExpenseTransaction, duplicateReport, mergeDuplicates, resolveDuplicates} from '@libs/actions/IOU/Duplicate';
import type {BulkDuplicateReportsParams, DuplicateReportParams} from '@libs/actions/IOU/Duplicate';
import {getReportPreviewReportAction} from '@libs/actions/IOU/MoneyRequestBuilder';
+import signalExpenseAddedGrowl from '@libs/actions/IOU/signalExpenseAddedGrowl';
import initOnyxDerivedValues from '@libs/actions/OnyxDerived';
import {addComment, openReport} from '@libs/actions/Report';
import type {MergeDuplicatesParams} from '@libs/API/parameters';
import {WRITE_COMMANDS} from '@libs/API/types';
+import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute';
import Navigation from '@libs/Navigation/Navigation';
import {getLoginsByAccountIDs} from '@libs/PersonalDetailsUtils';
import {getOriginalMessage, getReportAction} from '@libs/ReportActionsUtils';
@@ -41,6 +43,8 @@ import {formatPhoneNumber, getCurrencyDecimalsLocal, getGlobalFetchMock, getOnyx
import {isObject} from '../../utils/typeGuards';
import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates';
+jest.mock('@libs/actions/IOU/signalExpenseAddedGrowl', () => jest.fn());
+
const topMostReportID = '23423423';
jest.mock('@src/libs/Navigation/Navigation', () => ({
navigate: jest.fn(),
@@ -3808,6 +3812,8 @@ describe('actions/Duplicate', () => {
});
it('should create a single IOU report for multiple bulk-duplicated expenses', async () => {
+ // Given bulk duplication is initiated from Spend
+ jest.mocked(isSearchTopmostFullScreenRoute).mockReturnValue(true);
const tx1: Transaction = {
...createRandomTransaction(1),
transactionID: 'bulk_1',
@@ -3832,6 +3838,7 @@ describe('actions/Duplicate', () => {
[`${ONYXKEYS.COLLECTION.TRANSACTION}bulk_3`]: tx3,
};
+ // When the selected expenses are duplicated
bulkDuplicateExpenses({
isVendorMatchingBetaEnabled: false,
dateFnsLocale: undefined,
@@ -3869,9 +3876,15 @@ describe('actions/Duplicate', () => {
const iouReportIDs = new Set(requestMoneyCalls.map((call) => call[1].iouReportID));
expect(iouReportIDs.size).toBe(1);
+
+ // Then the expense-added growl is signaled
+ expect(signalExpenseAddedGrowl).toHaveBeenCalledTimes(1);
+ expect(jest.mocked(signalExpenseAddedGrowl).mock.calls.at(0)?.at(1)).toBe(CONST.SEARCH.DATA_TYPES.EXPENSE);
});
- it('should not defer auto submit when the last selected expense is unreported', async () => {
+ it('should not defer auto submit or signal the growl outside Spend when the last selected expense is unreported', async () => {
+ // Given bulk duplication is initiated outside Spend
+ jest.mocked(isSearchTopmostFullScreenRoute).mockReturnValue(false);
const reportedTransaction: Transaction = {
...createRandomTransaction(1),
transactionID: 'bulk_reported',
@@ -3890,6 +3903,7 @@ describe('actions/Duplicate', () => {
[`${ONYXKEYS.COLLECTION.TRANSACTION}bulk_unreported`]: unreportedTransaction,
};
+ // When the selected expenses are duplicated
bulkDuplicateExpenses({
isVendorMatchingBetaEnabled: false,
dateFnsLocale: undefined,
@@ -3927,6 +3941,9 @@ describe('actions/Duplicate', () => {
expect(requestMoneyCalls).toHaveLength(1);
expect(trackExpenseCalls).toHaveLength(1);
expect(requestMoneyCalls.at(0)?.[1].shouldDeferAutoSubmit).toBeFalsy();
+
+ // Then the expense-added growl is not signaled
+ expect(signalExpenseAddedGrowl).not.toHaveBeenCalled();
});
});
diff --git a/tests/actions/IOUTest/SplitTest.ts b/tests/actions/IOUTest/SplitTest.ts
index 2a78c91f74f7..f160dd5b2f1c 100644
--- a/tests/actions/IOUTest/SplitTest.ts
+++ b/tests/actions/IOUTest/SplitTest.ts
@@ -4496,7 +4496,7 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => {
// Then navigation should go to the expense report since it still exists
- expect(Navigation.dismissModalWithReport).toHaveBeenCalledWith({reportID: expenseReport.reportID});
+ expect(Navigation.dismissModalWithReport).toHaveBeenCalledWith({reportID: expenseReport.reportID}, expect.anything(), {forceReplace: false});
});
});
@@ -10068,7 +10068,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({
@@ -10078,8 +10078,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.RAM_ONLY_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..9ddfa9cee53a
--- /dev/null
+++ b/tests/unit/ExpenseAddedGrowlTest.tsx
@@ -0,0 +1,285 @@
+import {act, render} from '@testing-library/react-native';
+
+import ExpenseAddedGrowl from '@components/ExpenseAddedGrowl';
+import type GrowlNotificationContent from '@components/GrowlNotification/GrowlNotificationContent';
+
+import {createTransactionThreadReport, setOptimisticTransactionThread} from '@libs/actions/Report';
+import navigateToCreatedExpense from '@libs/Navigation/helpers/navigateToCreatedExpense';
+import {getOriginalMessage} from '@libs/ReportActionsUtils';
+
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {SearchDataTypes} from '@src/types/onyx/SearchResults';
+
+import type {ComponentProps} from 'react';
+
+import Onyx from 'react-native-onyx';
+
+import {actionR14932} from '../../__mocks__/reportData/actions';
+import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
+
+type GrowlContentProps = ComponentProps;
+
+const mockGetTopmostReportId = jest.fn();
+const mockGetFocusedReportId = jest.fn();
+
+const mockGrowlContent = jest.fn();
+jest.mock('@components/GrowlNotification/GrowlNotificationContent', () => (props: GrowlContentProps) => {
+ mockGrowlContent(props);
+ return null;
+});
+
+jest.mock('@libs/Navigation/Navigation', () => ({
+ getTopmostReportId: () => mockGetTopmostReportId(),
+ getFocusedReportId: () => mockGetFocusedReportId(),
+ getActiveRoute: () => '',
+}));
+jest.mock('@libs/Navigation/helpers/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 mockCreateTransactionThreadReport = jest.mocked(createTransactionThreadReport);
+const mockSetOptimisticTransactionThread = jest.mocked(setOptimisticTransactionThread);
+const mockNavigateToCreatedExpense = jest.mocked(navigateToCreatedExpense);
+
+const EXPENSE = CONST.SEARCH.DATA_TYPES.EXPENSE;
+const INVOICE = CONST.SEARCH.DATA_TYPES.INVOICE;
+
+function flush(mutate: () => unknown) {
+ 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.RAM_ONLY_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();
+ mockCreateTransactionThreadReport.mockReset();
+ mockGetTopmostReportId.mockReturnValue(undefined);
+ mockGetFocusedReportId.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);
+ expect(lastGrowlProps()?.action?.label).toBe('common.view');
+ });
+
+ 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
+ mockGetFocusedReportId.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 the growl when the expense's report is only left open on an inactive tab", async () => {
+ // Given report-1 sits in the Inbox stack the user isn't looking at, so only the central-pane read sees it
+ mockGetTopmostReportId.mockReturnValue('report-1');
+ mockGetFocusedReportId.mockReturnValue(undefined);
+ render();
+
+ // When an expense is created in report-1 from another tab, e.g. splitting an expense in Spend
+ await seedExpense('1', 'report-1');
+
+ // Then the growl still shows, because nothing on screen highlights the new expense
+ expect(mockGrowlContent).toHaveBeenCalled();
+ });
+
+ it('still shows for a tracked/unreported (self-DM) expense even when a report is open, since its reportID is UNREPORTED', async () => {
+ // Given the user is viewing some report
+ mockGetFocusedReportId.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.RAM_ONLY_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.RAM_ONLY_EXPENSE_ADDED_GROWL_TRANSACTION_IDS,
+ callback: (value) => {
+ Onyx.disconnect(connection);
+ resolve(value);
+ },
+ });
+ });
+ expect(remaining ?? {}).toEqual({});
+ });
+
+ it('shows the next queued expense after dismissal and ignores a stale dismissal', async () => {
+ // Given one growl is active and another expense is queued
+ render();
+ await seedExpense('1', 'report-1');
+ const firstGrowl = lastGrowlProps();
+ expect(firstGrowl).toBeDefined();
+
+ await seedExpense('2', 'report-2');
+ expect(lastGrowlProps()?.nonce).toBe(firstGrowl?.nonce);
+
+ // When the active growl is dismissed
+ await flush(() => firstGrowl?.onDismissed(firstGrowl.nonce));
+ const secondGrowl = lastGrowlProps();
+
+ // Then the queued expense is shown
+ expect(secondGrowl?.nonce).toBeGreaterThan(firstGrowl?.nonce ?? 0);
+
+ // When the old growl reports another, stale dismissal and a third expense is queued
+ await flush(() => firstGrowl?.onDismissed(firstGrowl.nonce));
+ await seedExpense('3', 'report-3');
+
+ // Then the current growl remains active until its own dismissal
+ expect(lastGrowlProps()?.nonce).toBe(secondGrowl?.nonce);
+ await flush(() => secondGrowl?.onDismissed(secondGrowl.nonce));
+ expect(lastGrowlProps()?.nonce).toBeGreaterThan(secondGrowl?.nonce ?? 0);
+ });
+
+ it('navigates to an existing transaction thread when View is pressed', async () => {
+ // Given an expense already has a transaction thread
+ render();
+ await flush(async () => {
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}1`, {
+ transactionID: '1',
+ reportID: 'report-1',
+ transactionThreadReportID: 'thread-1',
+ });
+ const signal: Record = {};
+ signal['1'] = EXPENSE;
+ await Onyx.merge(ONYXKEYS.RAM_ONLY_EXPENSE_ADDED_GROWL_TRANSACTION_IDS, signal);
+ });
+
+ // When View is pressed
+ lastGrowlProps()?.action?.onPress();
+
+ // Then the existing thread is initialized and opened without creating a replacement
+ expect(mockSetOptimisticTransactionThread).toHaveBeenCalledWith('thread-1', undefined, undefined, undefined);
+ expect(mockCreateTransactionThreadReport).not.toHaveBeenCalled();
+ expect(mockNavigateToCreatedExpense).toHaveBeenCalledWith({threadReportID: 'thread-1', transactionID: '1', iouReportID: undefined, reportTransactions: []});
+ });
+
+ it('resolves an existing transaction thread from the IOU action when View is pressed', async () => {
+ // Given the transaction thread is recorded on its IOU action
+ const transactionID = getOriginalMessage(actionR14932)?.IOUTransactionID;
+ if (!transactionID) {
+ throw new Error('Expected the IOU action fixture to contain a transaction ID');
+ }
+ const reportID = actionR14932.reportID;
+ render();
+ await flush(async () => {
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {transactionID, reportID});
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {[actionR14932.reportActionID]: actionR14932});
+ const signal: Record = {};
+ signal[transactionID] = EXPENSE;
+ await Onyx.merge(ONYXKEYS.RAM_ONLY_EXPENSE_ADDED_GROWL_TRANSACTION_IDS, signal);
+ });
+
+ // When View is pressed
+ lastGrowlProps()?.action?.onPress();
+
+ // Then the IOU action's thread is initialized and opened
+ expect(mockSetOptimisticTransactionThread).toHaveBeenCalledWith(actionR14932.childReportID, undefined, actionR14932.reportActionID, undefined);
+ expect(mockCreateTransactionThreadReport).not.toHaveBeenCalled();
+ expect(mockNavigateToCreatedExpense).toHaveBeenCalledWith({
+ threadReportID: actionR14932.childReportID,
+ transactionID,
+ iouReportID: undefined,
+ reportTransactions: [],
+ });
+ });
+
+ it('creates and navigates to a transaction thread when View is pressed without an existing thread', async () => {
+ // Given an expense does not have a transaction thread yet
+ mockCreateTransactionThreadReport.mockReturnValue({reportID: 'created-thread'});
+ render();
+ await seedExpense('1', 'report-1');
+
+ // When View is pressed
+ lastGrowlProps()?.action?.onPress();
+
+ // Then a thread is created from the current expense data and opened
+ expect(mockCreateTransactionThreadReport).toHaveBeenCalledWith(
+ expect.objectContaining({
+ currentUserLogin: 'me@example.com',
+ currentUserAccountID: 1,
+ transaction: expect.objectContaining({transactionID: '1', reportID: 'report-1'}),
+ }),
+ );
+ expect(mockSetOptimisticTransactionThread).not.toHaveBeenCalled();
+ expect(mockNavigateToCreatedExpense).toHaveBeenCalledWith({threadReportID: 'created-thread', transactionID: '1', iouReportID: undefined, reportTransactions: []});
+ });
+
+ 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 4aa25273d333..f3d2088da7d3 100644
--- a/tests/unit/Search/useSearchSnapshotTest.ts
+++ b/tests/unit/Search/useSearchSnapshotTest.ts
@@ -185,7 +185,6 @@ describe('useSearchSnapshot', () => {
useSearchSnapshot({
queryJSON: makeQueryJSON(),
searchResults,
- newSearchResultKeys: undefined,
transactions: undefined,
reportActions: undefined,
}),
@@ -211,7 +210,6 @@ describe('useSearchSnapshot', () => {
useSearchSnapshot({
queryJSON: makeQueryJSON(),
searchResults: undefined,
- newSearchResultKeys: undefined,
transactions: undefined,
reportActions: undefined,
}),
@@ -230,7 +228,6 @@ describe('useSearchSnapshot', () => {
useSearchSnapshot({
queryJSON: makeQueryJSON(),
searchResults,
- newSearchResultKeys: undefined,
transactions: undefined,
reportActions: undefined,
}),
@@ -250,7 +247,6 @@ describe('useSearchSnapshot', () => {
useSearchSnapshot({
queryJSON: makeQueryJSON(),
searchResults,
- newSearchResultKeys: undefined,
transactions: undefined,
reportActions: undefined,
}),
@@ -273,7 +269,6 @@ describe('useSearchSnapshot', () => {
groupBy: CONST.SEARCH.GROUP_BY.FROM,
}),
searchResults,
- newSearchResultKeys: undefined,
transactions: undefined,
reportActions: undefined,
}),
@@ -294,7 +289,6 @@ describe('useSearchSnapshot', () => {
useSearchSnapshot({
queryJSON: makeQueryJSON(),
searchResults,
- newSearchResultKeys: undefined,
transactions: undefined,
reportActions: undefined,
}),
@@ -319,7 +313,6 @@ describe('useSearchSnapshot', () => {
useSearchSnapshot({
queryJSON: makeQueryJSON(),
searchResults,
- newSearchResultKeys: undefined,
transactions: undefined,
reportActions: undefined,
}),
@@ -347,7 +340,6 @@ describe('useSearchSnapshot', () => {
useSearchSnapshot({
queryJSON: makeQueryJSON(),
searchResults,
- newSearchResultKeys: undefined,
transactions: undefined,
reportActions: undefined,
}),
@@ -357,24 +349,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,
@@ -392,7 +366,6 @@ describe('useSearchSnapshot', () => {
useSearchSnapshot({
queryJSON: makeQueryJSON({type}),
searchResults,
- newSearchResultKeys: undefined,
transactions: undefined,
reportActions: undefined,
}),
@@ -420,7 +393,6 @@ describe('useSearchSnapshot', () => {
const props = {
queryJSON: makeQueryJSON(),
searchResults,
- newSearchResultKeys: undefined,
transactions: undefined,
reportActions: undefined,
};
@@ -447,7 +419,6 @@ describe('useSearchSnapshot', () => {
useSearchSnapshot({
queryJSON: makeQueryJSON(),
searchResults,
- newSearchResultKeys: undefined,
transactions: undefined,
reportActions: undefined,
visibleRowLimit,
@@ -481,7 +452,6 @@ describe('useSearchSnapshot', () => {
useSearchSnapshot({
queryJSON: makeQueryJSON(),
searchResults,
- newSearchResultKeys: undefined,
transactions: undefined,
reportActions: undefined,
visibleRowLimit: 2,
@@ -505,7 +475,6 @@ describe('useSearchSnapshot', () => {
useSearchSnapshot({
queryJSON: makeQueryJSON(),
searchResults,
- newSearchResultKeys: undefined,
transactions: undefined,
reportActions: undefined,
visibleRowLimit: 3,
@@ -528,7 +497,6 @@ describe('useSearchSnapshot', () => {
useSearchSnapshot({
queryJSON: makeQueryJSON(),
searchResults,
- newSearchResultKeys: undefined,
transactions: undefined,
reportActions: undefined,
visibleRowLimit: 2,
@@ -558,7 +526,6 @@ describe('useSearchSnapshot', () => {
useSearchSnapshot({
queryJSON: makeQueryJSON({groupBy: CONST.SEARCH.GROUP_BY.FROM}),
searchResults,
- newSearchResultKeys: undefined,
transactions: undefined,
reportActions: undefined,
visibleRowLimit: 2,
@@ -570,34 +537,6 @@ describe('useSearchSnapshot', () => {
expect(result.current.filteredData).toEqual(groups.slice(0, 2).map((group) => expect.objectContaining({keyForList: group.keyForList, transactions: group.transactions})));
});
- it('keeps a highlighted row past visibleRowLimit on screen', () => {
- // Given a live list capped at two groups, where a new expense just landed in the last group
- const searchResults = makeSearchResults();
- mockUseOptimisticSearchTracking.mockReturnValue(trackingReturn(searchResults.data));
- const groups = Array.from({length: 5}, (_value, index) => ({
- groupID: `group${index}`,
- keyForList: `group${index}`,
- transactions: [{transactionID: `${index}`}],
- }));
- mockGetSections.mockReturnValue([groups, groups.length, false]);
- mockGetSortedSections.mockReturnValue(groups);
-
- // When the snapshot is projected with that expense queued for highlight
- const {result} = renderHook(() =>
- useSearchSnapshot({
- queryJSON: makeQueryJSON({groupBy: CONST.SEARCH.GROUP_BY.FROM}),
- searchResults,
- newSearchResultKeys: new Set([`${ONYXKEYS.COLLECTION.TRANSACTION}4`]),
- transactions: undefined,
- reportActions: undefined,
- visibleRowLimit: 2,
- }),
- );
-
- // Then the highlighted group still renders, or the scroll-to-new-expense has no row to land on
- expect(result.current.data.map((item) => item.keyForList)).toContain('group4');
- });
-
it('does not spend visibleRowLimit on rows being deleted online', () => {
// Given a live list capped at two rows, whose first two rows are being deleted while online
const searchResults = makeSearchResults();
@@ -615,7 +554,6 @@ describe('useSearchSnapshot', () => {
useSearchSnapshot({
queryJSON: makeQueryJSON(),
searchResults,
- newSearchResultKeys: undefined,
transactions: undefined,
reportActions: undefined,
visibleRowLimit: 2,
@@ -644,7 +582,6 @@ describe('useSearchSnapshot', () => {
useSearchSnapshot({
queryJSON: makeQueryJSON({groupBy: CONST.SEARCH.GROUP_BY.FROM}),
searchResults,
- newSearchResultKeys: undefined,
transactions: undefined,
reportActions: undefined,
visibleRowLimit: 2,
@@ -674,7 +611,6 @@ describe('useSearchSnapshot', () => {
useSearchSnapshot({
queryJSON: makeQueryJSON(),
searchResults,
- newSearchResultKeys: undefined,
transactions: undefined,
reportActions: undefined,
visibleRowLimit: 2,
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/dismissModalAndOpenReportInInboxTabTest.ts b/tests/unit/dismissModalAndOpenReportInInboxTabTest.ts
index 06800964c755..32befce4bec5 100644
--- a/tests/unit/dismissModalAndOpenReportInInboxTabTest.ts
+++ b/tests/unit/dismissModalAndOpenReportInInboxTabTest.ts
@@ -1,3 +1,4 @@
+import {mergeExpenseAddedGrowlTransactionIDs} from '@libs/actions/Transaction';
import dismissModalAndOpenReportInInboxTab from '@libs/Navigation/helpers/dismissModalAndOpenReportInInboxTab';
import type isReportOpenInRHP from '@libs/Navigation/helpers/isReportOpenInRHP';
import Navigation from '@libs/Navigation/Navigation';
@@ -17,6 +18,9 @@ jest.mock('@libs/Navigation/helpers/isReportOpenInSuperWideRHP', () => () => fal
jest.mock('@libs/Navigation/helpers/setNavigationActionToMicrotaskQueue', () => (callback: () => void) => {
callback();
});
+jest.mock('@libs/actions/Transaction', () => ({
+ mergeExpenseAddedGrowlTransactionIDs: jest.fn(),
+}));
jest.mock('@libs/getIsNarrowLayout', () => () => false);
jest.mock('@libs/telemetry/submitFollowUpAction', () => ({
isTracking: () => mockGetTrackingState(),
@@ -32,7 +36,7 @@ jest.mock('@libs/Navigation/Navigation', () => ({
navigate: jest.fn(),
navigationRef: {
getRootState: jest.fn(() => ({
- routes: [],
+ routes: [{state: {key: 'rhp-key'}}],
})),
},
}));
@@ -77,4 +81,32 @@ describe('dismissModalAndOpenReportInInboxTab', () => {
expect(Navigation.dismissToPreviousRHP).not.toHaveBeenCalled();
expect(Navigation.dismissModalWithReport).toHaveBeenCalledWith({reportID});
});
+
+ it('should remove the pending growl before navigating to a multi-expense report', () => {
+ // Given a new transaction will be shown in a multi-expense report
+ const reportID = 'report-123';
+ const transactionID = 'transaction-123';
+ mockIsReportOpenInRHP.mockReturnValue(true);
+
+ // When the modal is dismissed and the expense report navigation is queued
+ dismissModalAndOpenReportInInboxTab(reportID, undefined, true, transactionID);
+
+ // Then the pending growl is consumed before navigating to the expense report
+ expect(mergeExpenseAddedGrowlTransactionIDs).toHaveBeenCalledWith({
+ [transactionID]: null,
+ });
+ expect(Navigation.navigate).toHaveBeenCalled();
+ });
+
+ it('should keep the pending growl when navigating to an Inbox chat', () => {
+ // Given a new transaction will be shown in an Inbox chat rather than a multi-expense report
+ const reportID = 'report-123';
+ const transactionID = 'transaction-123';
+
+ // When the modal is dismissed and the chat is opened
+ dismissModalAndOpenReportInInboxTab(reportID, undefined, false, transactionID);
+
+ // Then the pending growl is kept
+ expect(mergeExpenseAddedGrowlTransactionIDs).not.toHaveBeenCalled();
+ });
});
diff --git a/tests/unit/navigateAfterExpenseCreateTest.ts b/tests/unit/navigateAfterExpenseCreateTest.ts
index f05703644965..bfd08b253a98 100644
--- a/tests/unit/navigateAfterExpenseCreateTest.ts
+++ b/tests/unit/navigateAfterExpenseCreateTest.ts
@@ -17,6 +17,8 @@ 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 mockGetCurrentRoute = jest.fn<{params?: Record} | undefined, []>();
+const mockGetFocusedReportId = jest.fn();
jest.mock('@libs/Navigation/helpers/isReportTopmostSplitNavigator', () => () => mockIsReportTopmostSplitNavigator());
jest.mock('@libs/Navigation/helpers/isSearchTopmostFullScreenRoute', () => () => mockIsSearchTopmostFullScreenRoute());
@@ -35,6 +37,9 @@ jest.mock('@libs/SearchQueryUtils', () => ({
buildCannedSearchQuery: jest.fn(({type}: {type: string}) => `type:${type}`),
getCurrentSearchQueryJSON: mockGetCurrentSearchQueryJSON,
}));
+jest.mock('@libs/actions/TransactionThreadNavigation', () => ({
+ setActiveTransactionIDs: jest.fn(() => Promise.resolve()),
+}));
jest.mock('@libs/Navigation/Navigation', () => ({
dismissModal: jest.fn(),
@@ -42,6 +47,8 @@ jest.mock('@libs/Navigation/Navigation', () => ({
dismissModalWithReport: jest.fn(),
pop: jest.fn(),
navigate: jest.fn(),
+ getActiveRoute: jest.fn(() => ''),
+ getFocusedReportId: () => mockGetFocusedReportId(),
revealRouteBeforeDismissingModal: jest.fn(),
isNavigationReady: jest.fn(() => Promise.resolve()),
getIsFullscreenPreInsertedUnderRHP: jest.fn(() => false),
@@ -51,6 +58,9 @@ jest.mock('@libs/Navigation/Navigation', () => ({
routes: [],
})),
isReady: jest.fn(() => true),
+ current: {
+ getCurrentRoute: () => mockGetCurrentRoute(),
+ },
},
}));
@@ -69,6 +79,8 @@ describe('navigateAfterExpenseCreate', () => {
mockIsReportOpenInRHP.mockReturnValue(false);
mockGetTrackingState.mockReturnValue(false);
mockGetCurrentSearchQueryJSON.mockReturnValue(undefined);
+ mockGetCurrentRoute.mockReturnValue(undefined);
+ mockGetFocusedReportId.mockReturnValue(undefined);
});
it('should dismiss to report when not from global create', () => {
@@ -138,7 +150,7 @@ describe('navigateAfterExpenseCreate', () => {
it('should NOT route a LOOKING_AROUND user to search when the destination is a real report (not the self-DM)', () => {
// A LOOKING_AROUND user who later has a workspace and submits to a real report/friend from the Inbox must open that
- // report, not be permanently misrouted to Search. isSelfDMDestination is false, so they are treated as "on inbox".
+ // report, not be permanently routed to Search. isSelfDMDestination is false, so they are treated as "on inbox".
mockIsReportTopmostSplitNavigator.mockReturnValue(true);
mockGetIsNarrowLayout.mockReturnValue(true);
diff --git a/tests/unit/navigateToCreatedExpenseTest.ts b/tests/unit/navigateToCreatedExpenseTest.ts
new file mode 100644
index 000000000000..edab7780a807
--- /dev/null
+++ b/tests/unit/navigateToCreatedExpenseTest.ts
@@ -0,0 +1,246 @@
+import type isReportOpenInRHP from '@libs/Navigation/helpers/isReportOpenInRHP';
+import navigateToCreatedExpense from '@libs/Navigation/helpers/navigateToCreatedExpense';
+import Navigation from '@libs/Navigation/Navigation';
+
+import CONST from '@src/CONST';
+import ROUTES from '@src/ROUTES';
+import type {Transaction} from '@src/types/onyx';
+
+import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
+
+const mockIsReportTopmostSplitNavigator = jest.fn();
+const mockIsSearchTopmostFullScreenRoute = jest.fn();
+const mockIsReportOpenInRHP = jest.fn, Parameters>();
+const mockGetIsNarrowLayout = jest.fn();
+const mockGetCurrentRoute = jest.fn<{params?: Record} | undefined, []>();
+const mockGetFocusedReportId = jest.fn();
+
+function buildTransaction(transactionID: string): Transaction {
+ return {transactionID, reportID: 'iou-1', amount: 0, created: '', currency: CONST.CURRENCY.USD, merchant: '', comment: {}};
+}
+
+jest.mock('@libs/Navigation/helpers/isReportTopmostSplitNavigator', () => () => mockIsReportTopmostSplitNavigator());
+jest.mock('@libs/Navigation/helpers/isSearchTopmostFullScreenRoute', () => () => mockIsSearchTopmostFullScreenRoute());
+jest.mock('@libs/Navigation/helpers/isReportOpenInRHP', () => (state: Parameters[0]) => mockIsReportOpenInRHP(state));
+jest.mock('@libs/Navigation/helpers/setNavigationActionToMicrotaskQueue', () => (callback: () => void) => {
+ callback();
+});
+jest.mock('@libs/getIsNarrowLayout', () => () => mockGetIsNarrowLayout());
+jest.mock('@libs/actions/TransactionThreadNavigation', () => ({
+ setActiveTransactionIDs: jest.fn(() => Promise.resolve()),
+}));
+
+jest.mock('@libs/Navigation/Navigation', () => ({
+ navigate: jest.fn(),
+ getActiveRoute: jest.fn(() => ''),
+ getFocusedReportId: () => mockGetFocusedReportId(),
+ navigationRef: {
+ getRootState: jest.fn(() => ({
+ routes: [],
+ })),
+ current: {
+ getCurrentRoute: () => mockGetCurrentRoute(),
+ },
+ },
+}));
+
+jest.mock('@react-navigation/native');
+
+describe('navigateToCreatedExpense', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockIsReportTopmostSplitNavigator.mockReturnValue(false);
+ mockIsSearchTopmostFullScreenRoute.mockReturnValue(false);
+ mockIsReportOpenInRHP.mockReturnValue(false);
+ mockGetCurrentRoute.mockReturnValue(undefined);
+ mockGetFocusedReportId.mockReturnValue(undefined);
+ });
+
+ it('should do nothing when the user already has the transaction thread open', async () => {
+ // Given the user opened the expense themselves before pressing "View"
+ mockIsReportTopmostSplitNavigator.mockReturnValue(true);
+ mockIsSearchTopmostFullScreenRoute.mockReturnValue(false);
+ mockGetIsNarrowLayout.mockReturnValue(true);
+ mockGetFocusedReportId.mockReturnValue('thread-1');
+
+ // When they press "View"
+ navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1', reportTransactions: []});
+ await waitForBatchedUpdates();
+
+ // Then no navigation happens, so the report is not pushed a second time
+ expect(Navigation.navigate).not.toHaveBeenCalled();
+ });
+
+ it('should do nothing when the user already has the collapsed expense report open', async () => {
+ // Given the user opened the single-transaction expense report, which renders the thread itself
+ mockIsReportTopmostSplitNavigator.mockReturnValue(true);
+ mockIsSearchTopmostFullScreenRoute.mockReturnValue(false);
+ mockGetIsNarrowLayout.mockReturnValue(true);
+ mockGetFocusedReportId.mockReturnValue('iou-1');
+
+ // When they press "View"
+ navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1', reportTransactions: [buildTransaction('txn-1')]});
+ await waitForBatchedUpdates();
+
+ // Then no navigation happens, so the same expense is not opened a second time
+ expect(Navigation.navigate).not.toHaveBeenCalled();
+ });
+
+ it('should still navigate when the focused expense report lists several transactions', async () => {
+ // Given the user is on an expense report holding more than one expense, so it shows a list rather than the thread
+ mockIsReportTopmostSplitNavigator.mockReturnValue(true);
+ mockIsSearchTopmostFullScreenRoute.mockReturnValue(false);
+ mockGetIsNarrowLayout.mockReturnValue(true);
+ mockGetFocusedReportId.mockReturnValue('iou-1');
+
+ // When they press "View"
+ navigateToCreatedExpense({
+ threadReportID: 'thread-1',
+ transactionID: 'txn-1',
+ iouReportID: 'iou-1',
+ reportTransactions: [buildTransaction('txn-1'), buildTransaction('txn-2')],
+ });
+ await waitForBatchedUpdates();
+
+ // Then the transaction thread still opens, since the list does not show the expense itself
+ expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute('thread-1', undefined, undefined, ''), {forceReplace: false});
+ });
+
+ it('should still navigate when the focused report is a different one', async () => {
+ // Given the user is viewing some other report
+ mockIsReportTopmostSplitNavigator.mockReturnValue(true);
+ mockIsSearchTopmostFullScreenRoute.mockReturnValue(false);
+ mockGetIsNarrowLayout.mockReturnValue(true);
+ mockGetFocusedReportId.mockReturnValue('some-other-report');
+
+ // When they press "View"
+ navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1', reportTransactions: []});
+ await waitForBatchedUpdates();
+
+ // Then the transaction thread still opens
+ expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute('thread-1', undefined, undefined, ''), {forceReplace: false});
+ });
+
+ it('should open the transaction thread in the Spend RHP when the user is on the Spend tab', async () => {
+ // Given the user is on the Spend tab
+ mockIsReportTopmostSplitNavigator.mockReturnValue(false);
+ mockIsSearchTopmostFullScreenRoute.mockReturnValue(true);
+
+ // When they open a newly-created expense
+ navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1', reportTransactions: []});
+ await waitForBatchedUpdates();
+
+ // Then the transaction thread opens in the Spend RHP
+ expect(Navigation.navigate).toHaveBeenCalledTimes(1);
+ expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SEARCH_REPORT.getRoute({reportID: 'thread-1', backTo: ''}), {forceReplace: false});
+ });
+
+ it('should replace the currently-open report instead of stacking when one is already open in the RHP', async () => {
+ // Given a report is already open in the RHP
+ mockIsReportTopmostSplitNavigator.mockReturnValue(false);
+ mockIsSearchTopmostFullScreenRoute.mockReturnValue(true);
+ mockIsReportOpenInRHP.mockReturnValue(true);
+
+ // When the user opens a newly-created expense
+ navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1', reportTransactions: []});
+ await waitForBatchedUpdates();
+
+ // Then the open report is replaced rather than stacked on
+ expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SEARCH_REPORT.getRoute({reportID: 'thread-1', backTo: ''}), {forceReplace: true});
+ });
+
+ it('should open the transaction thread as a full report when the user is on the Inbox tab on a narrow layout', () => {
+ // Given the user is on the Inbox tab on a narrow layout
+ mockIsReportTopmostSplitNavigator.mockReturnValue(true);
+ mockIsSearchTopmostFullScreenRoute.mockReturnValue(false);
+ mockGetIsNarrowLayout.mockReturnValue(true);
+
+ // When they open a newly-created expense
+ navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1', reportTransactions: []});
+
+ // Then the transaction thread opens as a full report
+ expect(Navigation.navigate).toHaveBeenCalledTimes(1);
+ expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute('thread-1', undefined, undefined, ''), {forceReplace: false});
+ });
+
+ it('should open the expense report then stack the thread RHP when the user is on the Inbox tab on a wide layout and the report has multiple transactions', async () => {
+ // Given the user is on the Inbox tab on a wide layout and the expense report holds several transactions
+ mockIsReportTopmostSplitNavigator.mockReturnValue(true);
+ mockIsSearchTopmostFullScreenRoute.mockReturnValue(false);
+ mockGetIsNarrowLayout.mockReturnValue(false);
+ // When they open a newly-created expense
+ navigateToCreatedExpense({
+ threadReportID: 'thread-1',
+ transactionID: 'txn-1',
+ iouReportID: 'iou-1',
+ reportTransactions: [buildTransaction('txn-1'), buildTransaction('txn-2')],
+ });
+ await waitForBatchedUpdates();
+
+ // Then the expense report opens with the thread RHP stacked on top of it
+ expect(Navigation.navigate).toHaveBeenNthCalledWith(1, ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: 'iou-1', backTo: ''}), {forceReplace: false});
+ expect(Navigation.navigate).toHaveBeenNthCalledWith(2, ROUTES.SEARCH_REPORT.getRoute({reportID: 'thread-1', backTo: ''}));
+ });
+
+ it('should open the expense report without the replaced RHP backTo, so deleting the report falls back to its chat', async () => {
+ // Given the user is on the Inbox tab on a wide layout with another report already open in the RHP
+ mockIsReportTopmostSplitNavigator.mockReturnValue(true);
+ mockIsSearchTopmostFullScreenRoute.mockReturnValue(false);
+ mockGetIsNarrowLayout.mockReturnValue(false);
+ mockIsReportOpenInRHP.mockReturnValue(true);
+ mockGetCurrentRoute.mockReturnValue({params: {backTo: '/home'}});
+
+ // When they open a newly-created expense
+ navigateToCreatedExpense({
+ threadReportID: 'thread-1',
+ transactionID: 'txn-1',
+ iouReportID: 'iou-1',
+ reportTransactions: [buildTransaction('txn-1'), buildTransaction('txn-2')],
+ });
+ await waitForBatchedUpdates();
+
+ // Then the expense report opens with no backTo instead of inheriting the replaced RHP's origin
+ expect(Navigation.navigate).toHaveBeenNthCalledWith(1, ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: 'iou-1'}), {forceReplace: true});
+ });
+
+ it('should open the expense report when the user is on the Inbox tab on a wide layout and the report has a single transaction', () => {
+ // Given the user is on the Inbox tab on a wide layout and the expense report holds one transaction
+ mockIsReportTopmostSplitNavigator.mockReturnValue(true);
+ mockIsSearchTopmostFullScreenRoute.mockReturnValue(false);
+ mockGetIsNarrowLayout.mockReturnValue(false);
+ // When they open a newly-created expense
+ navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1', reportTransactions: [buildTransaction('txn-1')]});
+
+ // Then only the expense report opens, since it collapses to the thread itself
+ expect(Navigation.navigate).toHaveBeenCalledTimes(1);
+ expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: 'iou-1', backTo: ''}), {forceReplace: false});
+ });
+
+ it('should open the transaction thread as a full report when there is no expense report (tracked/unreported self-DM expense) on the Inbox tab', () => {
+ // Given the user is on the Inbox tab on a wide layout
+ mockIsReportTopmostSplitNavigator.mockReturnValue(true);
+ mockIsSearchTopmostFullScreenRoute.mockReturnValue(false);
+ mockGetIsNarrowLayout.mockReturnValue(false);
+
+ // When they open a newly-created tracked expense, which has no expense report
+ navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: undefined, reportTransactions: []});
+
+ // Then the thread opens as a full report, matching how tapping it in its self-DM chat does
+ expect(Navigation.navigate).toHaveBeenCalledTimes(1);
+ expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute('thread-1', undefined, undefined, ''), {forceReplace: false});
+ });
+
+ it('should open the transaction thread in the Spend RHP for a tracked/unreported expense when the user is on the Spend tab', async () => {
+ // Given the user is on the Spend tab
+ mockIsReportTopmostSplitNavigator.mockReturnValue(false);
+ mockIsSearchTopmostFullScreenRoute.mockReturnValue(true);
+
+ // When they open a newly-created tracked expense, which has no expense report
+ navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: undefined, reportTransactions: []});
+ await waitForBatchedUpdates();
+
+ // Then the transaction thread still opens in the Spend RHP
+ expect(Navigation.navigate).toHaveBeenCalledTimes(1);
+ expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SEARCH_REPORT.getRoute({reportID: 'thread-1', backTo: ''}), {forceReplace: false});
+ });
+});
diff --git a/tests/unit/useListItemHighlightTest.ts b/tests/unit/useListItemHighlightTest.ts
deleted file mode 100644
index 5854b6965791..000000000000
--- a/tests/unit/useListItemHighlightTest.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-import {renderHook} from '@testing-library/react-native';
-
-import useListItemHighlight from '@components/SelectionList/ListItemComposed/hooks/useListItemHighlight';
-
-import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle';
-import useTheme from '@hooks/useTheme';
-import useThemeStyles from '@hooks/useThemeStyles';
-
-const animatedHighlightStyleMock = {backgroundColor: 'animated-highlight'};
-jest.mock('@hooks/useAnimatedHighlightStyle', () => jest.fn(() => animatedHighlightStyleMock));
-
-const mockUseAnimatedHighlightStyle = jest.mocked(useAnimatedHighlightStyle);
-
-type HookParams = Parameters[0];
-
-function renderHighlightHook(params?: HookParams) {
- const {result} = renderHook(() => ({
- styles: useThemeStyles(),
- theme: useTheme(),
- highlight: useListItemHighlight(params),
- }));
- return result.current;
-}
-
-describe('useListItemHighlight', () => {
- beforeEach(() => {
- mockUseAnimatedHighlightStyle.mockClear();
- });
-
- it.each<[string, HookParams]>([
- ['unselected', {shouldHighlight: true}],
- ['selected', {shouldHighlight: true, isSelected: true}],
- ])('configures the animation with the selection list border radius, the resting background and full style application when %s', (_name, params) => {
- const {styles, theme} = renderHighlightHook(params);
-
- expect(mockUseAnimatedHighlightStyle).toHaveBeenCalledWith({
- borderRadius: styles.selectionListPressableItemWrapper.borderRadius,
- shouldHighlight: true,
- highlightColor: theme.messageHighlightBG,
- backgroundColor: theme.highlightBG,
- shouldApplyOtherStyles: true,
- });
- });
-
- it('rests on the wrapper style and margins, staying transparent for the animated background', () => {
- const {styles, highlight} = renderHighlightHook();
-
- expect(highlight.pressableStyle).toContain(styles.selectionListPressableItemWrapper);
- expect(highlight.pressableStyle).toContain(styles.mh0);
- // Unconditional even when not highlighting: the highlight flag resets mid-animation, so an opaque
- // background here would mask the still-running animation on the wrapper underneath.
- expect(highlight.pressableStyle).toContain(styles.bgTransparent);
- expect(highlight.pressableWrapperStyle).toContain(styles.mh5);
- expect(highlight.pressableWrapperStyle).toContain(animatedHighlightStyleMock);
- });
-
- it('paints the selected background when selected', () => {
- const {styles, highlight} = renderHighlightHook({isSelected: true});
-
- expect(highlight.pressableStyle).toContain(styles.activeComponentBG);
- });
-
- it('returns the animated highlight style for rows that own their pressable layout', () => {
- const {highlight} = renderHighlightHook();
-
- expect(highlight.animatedHighlightStyle).toBe(animatedHighlightStyleMock);
- });
-});
diff --git a/tests/unit/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);
- });
});
diff --git a/tests/unit/useSearchTableItemHighlightTest.ts b/tests/unit/useSearchTableItemHighlightTest.ts
deleted file mode 100644
index edd1d518b937..000000000000
--- a/tests/unit/useSearchTableItemHighlightTest.ts
+++ /dev/null
@@ -1,93 +0,0 @@
-import {renderHook} from '@testing-library/react-native';
-
-import useSearchTableItemHighlight from '@components/Search/SearchList/ListItem/hooks/useSearchTableItemHighlight';
-
-import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle';
-import useStyleUtils from '@hooks/useStyleUtils';
-import useTheme from '@hooks/useTheme';
-import useThemeStyles from '@hooks/useThemeStyles';
-
-import variables from '@styles/variables';
-
-const animatedHighlightStyleMock = {backgroundColor: 'animated-highlight'};
-jest.mock('@hooks/useAnimatedHighlightStyle', () => jest.fn(() => animatedHighlightStyleMock));
-
-let mockIsLargeScreenWidth = false;
-jest.mock('@hooks/useResponsiveLayout', () => () => ({isLargeScreenWidth: mockIsLargeScreenWidth}));
-
-const mockUseAnimatedHighlightStyle = jest.mocked(useAnimatedHighlightStyle);
-
-type HookParams = Parameters[0];
-
-function renderHighlightHook(params?: HookParams) {
- const {result} = renderHook(() => ({
- styles: useThemeStyles(),
- StyleUtils: useStyleUtils(),
- theme: useTheme(),
- highlight: useSearchTableItemHighlight(params),
- }));
- return result.current;
-}
-
-describe('useSearchTableItemHighlight', () => {
- beforeEach(() => {
- mockUseAnimatedHighlightStyle.mockClear();
- mockIsLargeScreenWidth = false;
- });
-
- it.each([
- ['wide', true],
- ['narrow', false],
- ])('configures the animation on %s screens with the table border radius and layout styles only off-table', (_name, isLargeScreenWidth) => {
- mockIsLargeScreenWidth = isLargeScreenWidth;
- const {StyleUtils, theme} = renderHighlightHook();
-
- expect(mockUseAnimatedHighlightStyle).toHaveBeenCalledWith({
- borderRadius: StyleUtils.getSearchTableHighlightBorderRadius(isLargeScreenWidth),
- shouldHighlight: false,
- highlightColor: theme.messageHighlightBG,
- backgroundColor: theme.highlightBG,
- shouldApplyOtherStyles: !isLargeScreenWidth,
- });
- });
-
- it('adds table paddings and stays transparent for the animated background', () => {
- const {styles, highlight} = renderHighlightHook();
-
- expect(highlight.pressableStyle).toContain(styles.pv3);
- expect(highlight.pressableStyle).toContain(styles.ph3);
- expect(highlight.pressableStyle).toContain(styles.bgTransparent);
- });
-
- it('paints the selected background when selected', () => {
- const {styles, highlight} = renderHighlightHook({isSelected: true});
-
- expect(highlight.pressableStyle).toContain(styles.activeComponentBG);
- });
-
- it('applies the table row pressable style on wide screens', () => {
- mockIsLargeScreenWidth = true;
- const {StyleUtils, highlight} = renderHighlightHook({isSelected: true, isLastItem: true});
-
- expect(highlight.pressableStyle).toContainEqual(StyleUtils.getSearchTableRowPressableStyle(true, true, {vertical: variables.tableRowPaddingVertical}));
- });
-
- it('rounds and clips the last row on wide screens', () => {
- mockIsLargeScreenWidth = true;
- const {styles, highlight} = renderHighlightHook({isLastItem: true});
-
- expect(highlight.pressableWrapperStyle).toContainEqual([styles.tableBottomRadius, styles.overflowHidden]);
- });
-
- it('skips the last-row rounding on narrow screens', () => {
- const {styles, highlight} = renderHighlightHook({isLastItem: true});
-
- expect(highlight.pressableWrapperStyle).not.toContainEqual([styles.tableBottomRadius, styles.overflowHidden]);
- });
-
- it('returns the animated highlight style for rows that own their pressable layout', () => {
- const {highlight} = renderHighlightHook();
-
- expect(highlight.animatedHighlightStyle).toBe(animatedHighlightStyleMock);
- });
-});