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