diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index d673456d2bfc..74256440c65a 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -783,9 +783,6 @@ const ONYXKEYS = { /** Whether the user has denied the contact import permission prompt */ HAS_DENIED_CONTACT_IMPORT_PROMPT: 'hasDeniedContactImportPrompt', - /** The transaction IDs to be highlighted when opening the Expenses search route page */ - TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE: 'transactionIdsHighlightOnSearchRoute', - /** The report ID to be highlighted when returning to the workspace rooms page */ ROOM_ID_HIGHLIGHT_ON_ROOMS_PAGE: 'roomIDHighlightOnRoomsPage', @@ -1723,7 +1720,6 @@ type OnyxValuesMapping = { [ONYXKEYS.NVP_REPORT_DETAILS_COLUMNS]: string[]; [ONYXKEYS.HAS_DENIED_CONTACT_IMPORT_PROMPT]: boolean | undefined; [ONYXKEYS.PERSONAL_POLICY_ID]: string; - [ONYXKEYS.TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE]: Record>; [ONYXKEYS.ROOM_ID_HIGHLIGHT_ON_ROOMS_PAGE]: string | null; [ONYXKEYS.DOMAIN_GROUP_CREATE_PREFERRED_POLICY_ID]: string | undefined; }; diff --git a/src/components/MoneyRequestHeaderSecondaryActions.tsx b/src/components/MoneyRequestHeaderSecondaryActions.tsx index dc640cf73251..67101231b4b0 100644 --- a/src/components/MoneyRequestHeaderSecondaryActions.tsx +++ b/src/components/MoneyRequestHeaderSecondaryActions.tsx @@ -33,6 +33,7 @@ import initSplitExpense from '@libs/actions/SplitExpenses'; import {setNameValuePair} from '@libs/actions/User'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getExistingTransactionID} from '@libs/IOUUtils'; +import {surfaceExpenseCreatedFeedback} from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import type {ReportsSplitNavigatorParamList, RightModalNavigatorParamList} from '@libs/Navigation/types'; @@ -243,11 +244,12 @@ function MoneyRequestHeaderSecondaryActions({reportID, onBackButtonPress}: Money const optimisticIOUReportID = generateReportID(); const activePolicyCategoriesMap = defaultPolicyCategories ?? {}; + let lastDuplicate: {iouReportID?: string; transactionID?: string} | undefined; for (const item of transactions) { const existingTransactionID = getExistingTransactionID(item.linkedTrackedExpenseReportAction); const existingTransactionDraft = existingTransactionID ? transactionDrafts?.[existingTransactionID] : undefined; - duplicateTransactionAction({ + const result = duplicateTransactionAction({ transaction: item, optimisticChatReportID, optimisticIOUReportID, @@ -271,6 +273,13 @@ function MoneyRequestHeaderSecondaryActions({reportID, onBackButtonPress}: Money delegateAccountID, policyTagList, }); + if (result?.transactionID) { + lastDuplicate = {iouReportID: result.iouReport?.reportID, transactionID: result.transactionID}; + } + } + + if (lastDuplicate?.transactionID) { + surfaceExpenseCreatedFeedback(lastDuplicate); } }; diff --git a/src/components/Search/ChatSearchView.tsx b/src/components/Search/ChatSearchView.tsx index 21f390a3fa3e..ecc9af8aa876 100644 --- a/src/components/Search/ChatSearchView.tsx +++ b/src/components/Search/ChatSearchView.tsx @@ -4,7 +4,7 @@ import CONST from '@src/CONST'; import type {NativeSyntheticEvent} from 'react-native'; -import React, {useImperativeHandle} from 'react'; +import React from 'react'; import type {SearchListItem} from './SearchList/ListItem/types'; import type {CommonSearchViewProps} from './searchViewProps'; @@ -41,7 +41,6 @@ function ChatSearchView({ SearchTableHeader: searchTableHeader, tableHeaderVisible, hasLoadedAllTransactions, - newTransactions, onSelectRow, ListFooterComponent, onEndReached, @@ -49,7 +48,6 @@ function ChatSearchView({ onScroll, contentContainerStyle, containerStyle, - ref, }: ChatSearchViewProps) { const {type} = queryJSON; @@ -65,9 +63,6 @@ function ChatSearchView({ const selectedItemsLength = data.reduce((acc, item) => acc + (item.keyForList && selectedTransactions[item.keyForList]?.isSelected ? 1 : 0), 0); const totalItems = data.filter((item) => !isRowDeleted(item)).length; - // Flat data maps 1:1 to the rendered list, so highlight-scroll-to-index is the same as scroll-to-data-index. - useImperativeHandle(ref, () => ({scrollToIndex: scrollToListIndex}), [scrollToListIndex]); - const renderItem = (item: SearchListItem, index: number, isItemFocused: boolean, onFocus?: (event: NativeSyntheticEvent) => void) => ( // Chat rows never animate their exit (only grouped expenses do), so the wrapper just preserves the overflow clip. {modal} diff --git a/src/components/Search/ExpenseFlatSearchView.tsx b/src/components/Search/ExpenseFlatSearchView.tsx index e4081d3bbf0a..3c2a52bb4105 100644 --- a/src/components/Search/ExpenseFlatSearchView.tsx +++ b/src/components/Search/ExpenseFlatSearchView.tsx @@ -4,7 +4,7 @@ import CONST from '@src/CONST'; import type {NativeSyntheticEvent} from 'react-native'; -import React, {useImperativeHandle} from 'react'; +import React from 'react'; import type {SearchListItem} from './SearchList/ListItem/types'; import type {CommonSearchViewProps, TransactionViewExtras} from './searchViewProps'; @@ -32,8 +32,7 @@ const isRowDeleted = (item: SearchListItem) => item.pendingAction === CONST.RED_ * 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`). + * `BaseSearchList`. */ function ExpenseFlatSearchView({ queryJSON, @@ -47,7 +46,6 @@ function ExpenseFlatSearchView({ SearchTableHeader: searchTableHeader, tableHeaderVisible, hasLoadedAllTransactions, - newTransactions, onSelectRow, ListFooterComponent, onEndReached, @@ -55,7 +53,6 @@ function ExpenseFlatSearchView({ onScroll, contentContainerStyle, containerStyle, - ref, }: ExpenseFlatSearchViewProps) { const {type} = queryJSON; @@ -89,9 +86,6 @@ function ExpenseFlatSearchView({ const selectedItemsLength = data.reduce((acc, item) => acc + (item.keyForList && selectedTransactions[item.keyForList]?.isSelected ? 1 : 0), 0); const totalItems = data.filter((item) => !isRowDeleted(item)).length; - // Flat data maps 1:1 to the rendered list, so highlight-scroll-to-index is the same as scroll-to-data-index. - useImperativeHandle(ref, () => ({scrollToIndex: scrollToListIndex}), [scrollToListIndex]); - const renderItem = (item: SearchListItem, index: number, isItemFocused: boolean, onFocus?: (event: NativeSyntheticEvent) => void) => { const isDisabled = isRowDeleted(item); // Only expense row exits animate; invoice and trip transaction lists do not (matches the legacy per-type gate). @@ -171,7 +165,6 @@ function ExpenseFlatSearchView({ ListFooterComponent={ListFooterComponent} onLayout={onLayout} contentContainerStyle={contentContainerStyle} - newTransactions={newTransactions} isAttendeesEnabledForMovingPolicy={isAttendeesEnabledForMovingPolicy} nonPersonalAndWorkspaceCards={nonPersonalAndWorkspaceCards} /> diff --git a/src/components/Search/ExpenseGroupedSearchView.tsx b/src/components/Search/ExpenseGroupedSearchView.tsx index dc834187779d..57b0798ecd5a 100644 --- a/src/components/Search/ExpenseGroupedSearchView.tsx +++ b/src/components/Search/ExpenseGroupedSearchView.tsx @@ -15,7 +15,7 @@ import type {Transaction} from '@src/types/onyx'; import type {NativeSyntheticEvent} from 'react-native'; -import React, {useImperativeHandle, useState} from 'react'; +import React, {useState} from 'react'; import type {SearchListItem} from './SearchList/ListItem/types'; import type {CommonSearchViewProps, TransactionViewExtras} from './searchViewProps'; @@ -65,7 +65,7 @@ function buildSplitGroupData(data: SearchListItem[], shouldSplitGroups: boolean) }; } -/** Maps each group's `keyForList` to the id of a just-created transaction inside it, for the highlight animation. */ +/** Maps each group's `keyForList` to the id of a newly-added transaction inside it, used to refetch the group's child snapshot. */ function buildNewTransactionIDMap(data: SearchListItem[], newTransactions: Transaction[], groupBy: SearchQueryJSON['groupBy']) { const map = new Map(); if (newTransactions.length === 0) { @@ -110,7 +110,6 @@ function ExpenseGroupedSearchView({ onScroll, contentContainerStyle, containerStyle, - ref, }: ExpenseGroupedSearchViewProps) { const {type, groupBy} = queryJSON; const {isLargeScreenWidth} = useResponsiveLayout(); @@ -173,26 +172,6 @@ function ExpenseGroupedSearchView({ const firstVisibleIndex = listData.findIndex(isItemVisible); const lastVisibleIndex = listData.findLastIndex(isItemVisible); - // The router highlights by source-data index; remap it to the split-list index before scrolling. - useImperativeHandle( - ref, - () => ({ - scrollToIndex: (index: number, animated = true) => { - if (!shouldSplit) { - scrollToListIndex(index, animated); - return; - } - const item = data.at(index); - if (!item) { - return; - } - const splitIndex = item.keyForList ? listData.findIndex((listItem) => listItem.keyForList === `header_${item.keyForList}`) : -1; - scrollToListIndex(splitIndex !== -1 ? splitIndex : index, animated); - }, - }), - [data, listData, shouldSplit, scrollToListIndex], - ); - const getItemType = (item: SearchListItem) => { if (!shouldSplit) { return undefined; @@ -347,7 +326,6 @@ function ExpenseGroupedSearchView({ ListFooterComponent={ListFooterComponent} onLayout={onLayout} contentContainerStyle={contentContainerStyle} - newTransactions={newTransactions} isAttendeesEnabledForMovingPolicy={isAttendeesEnabledForMovingPolicy} nonPersonalAndWorkspaceCards={nonPersonalAndWorkspaceCards} stickyHeaderIndices={stickyHeaderIndices} diff --git a/src/components/Search/ExpenseReportSearchView.tsx b/src/components/Search/ExpenseReportSearchView.tsx index 1a8ba373e619..8a1e509cc4ec 100644 --- a/src/components/Search/ExpenseReportSearchView.tsx +++ b/src/components/Search/ExpenseReportSearchView.tsx @@ -6,7 +6,7 @@ import CONST from '@src/CONST'; import type {NativeSyntheticEvent} from 'react-native'; -import React, {useImperativeHandle} from 'react'; +import React from 'react'; import type {SearchListItem} from './SearchList/ListItem/types'; import type {CommonSearchViewProps} from './searchViewProps'; @@ -47,7 +47,6 @@ function ExpenseReportSearchView({ SearchTableHeader: searchTableHeader, tableHeaderVisible, hasLoadedAllTransactions, - newTransactions, onSelectRow, ListFooterComponent, onEndReached, @@ -55,7 +54,6 @@ function ExpenseReportSearchView({ onScroll, contentContainerStyle, containerStyle, - ref, }: ExpenseReportSearchViewProps) { const {type} = queryJSON; @@ -92,9 +90,6 @@ function ExpenseReportSearchView({ emptyReports.reduce((acc, item) => acc + (isRowSelected(item.keyForList, selectedTransactions) ? 1 : 0), 0); const totalItems = flattenedTransactions.filter((item) => !isRowDeleted(item)).length + emptyReports.filter((item) => !isRowDeleted(item)).length; - // Report data maps 1:1 to the rendered list, so highlight-scroll-to-index is the same as scroll-to-data-index. - useImperativeHandle(ref, () => ({scrollToIndex: scrollToListIndex}), [scrollToListIndex]); - const renderItem = (item: SearchListItem, index: number, isItemFocused: boolean, onFocus?: (event: NativeSyntheticEvent) => void) => ( // Report rows never animate their exit (only grouped expenses do), so the wrapper just preserves the overflow clip. {modal} diff --git a/src/components/Search/SearchList/BaseSearchList/index.tsx b/src/components/Search/SearchList/BaseSearchList/index.tsx index 5f7273b9f6f8..8def180d07f5 100644 --- a/src/components/Search/SearchList/BaseSearchList/index.tsx +++ b/src/components/Search/SearchList/BaseSearchList/index.tsx @@ -64,7 +64,6 @@ function BaseSearchList({ onLayout, contentContainerStyle, flattenedItemsLength, - newTransactions, isAttendeesEnabledForMovingPolicy, nonPersonalAndWorkspaceCards, stickyHeaderIndices, @@ -171,8 +170,8 @@ function BaseSearchList({ }, [setHasKeyBeenPressed]); const extraData = useMemo( - () => [focusedIndex, columns, newTransactions, nonPersonalAndWorkspaceCards, isAttendeesEnabledForMovingPolicy], - [focusedIndex, columns, newTransactions, nonPersonalAndWorkspaceCards, isAttendeesEnabledForMovingPolicy], + () => [focusedIndex, columns, nonPersonalAndWorkspaceCards, isAttendeesEnabledForMovingPolicy], + [focusedIndex, columns, nonPersonalAndWorkspaceCards, isAttendeesEnabledForMovingPolicy], ); return ( diff --git a/src/components/Search/SearchList/BaseSearchList/types.ts b/src/components/Search/SearchList/BaseSearchList/types.ts index a726f5faffa9..41bb1c9d01e5 100644 --- a/src/components/Search/SearchList/BaseSearchList/types.ts +++ b/src/components/Search/SearchList/BaseSearchList/types.ts @@ -2,7 +2,7 @@ import type {SearchListItem} from '@components/Search/SearchList/ListItem/types' import type {SearchColumnType} from '@components/Search/types'; import type {ExtendedTargetedEvent} from '@components/SelectionList/ListItem/types'; -import type {CardList, Transaction} from '@src/types/onyx'; +import type {CardList} from '@src/types/onyx'; import type {FlashListProps, FlashListRef} from '@shopify/flash-list'; import type {RefObject} from 'react'; @@ -32,9 +32,6 @@ type BaseSearchListProps = Pick< /** The columns that might change to trigger re-render via extraData */ columns: SearchColumnType[]; - /** The transactions that might trigger re-render via extraData */ - newTransactions: Transaction[]; - /** The length of the flattened items in the list */ flattenedItemsLength: number; diff --git a/src/components/Search/TaskSearchView.tsx b/src/components/Search/TaskSearchView.tsx index 9779d5fb3be5..bdf98891543d 100644 --- a/src/components/Search/TaskSearchView.tsx +++ b/src/components/Search/TaskSearchView.tsx @@ -4,7 +4,7 @@ import CONST from '@src/CONST'; import type {NativeSyntheticEvent} from 'react-native'; -import React, {useImperativeHandle} from 'react'; +import React from 'react'; import type {SearchListItem} from './SearchList/ListItem/types'; import type {CommonSearchViewProps} from './searchViewProps'; @@ -40,7 +40,6 @@ function TaskSearchView({ SearchTableHeader: searchTableHeader, tableHeaderVisible, hasLoadedAllTransactions, - newTransactions, onSelectRow, ListFooterComponent, onEndReached, @@ -48,7 +47,6 @@ function TaskSearchView({ onScroll, contentContainerStyle, containerStyle, - ref, }: TaskSearchViewProps) { const {type} = queryJSON; @@ -62,9 +60,6 @@ function TaskSearchView({ 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) => ( // Task rows never animate their exit (only grouped expenses do), so the wrapper just preserves the overflow clip. {modal} diff --git a/src/components/Search/hooks/useSearchSnapshot.ts b/src/components/Search/hooks/useSearchSnapshot.ts index 310ad1ff8c88..195b226a88be 100644 --- a/src/components/Search/hooks/useSearchSnapshot.ts +++ b/src/components/Search/hooks/useSearchSnapshot.ts @@ -1,5 +1,5 @@ import {useSearchQueryContext, useSearchResultsContext} from '@components/Search/SearchContext'; -import type {ReportActionListItemType, SearchListItem, TransactionGroupListItemType, TransactionListItemType} from '@components/Search/SearchList/ListItem/types'; +import type {SearchListItem, TransactionGroupListItemType, TransactionListItemType} from '@components/Search/SearchList/ListItem/types'; import type {SearchColumnType, SearchData, SearchQueryJSON} from '@components/Search/types'; import useActionLoadingReportIDs from '@hooks/useActionLoadingReportIDs'; @@ -70,10 +70,8 @@ type UseSearchSnapshotParams = { queryJSON: Readonly; /** The current search snapshot, owned by the ancestor and passed in. */ searchResults: SearchResults | undefined; - /** Keys flagged for the post-create highlight animation. */ - newSearchResultKeys: Set | null | undefined; /** Full TRANSACTION + REPORT_ACTIONS collections used by the optimistic-row tracking. Threaded in from - * the parent (which already subscribes to them for the highlight hook) so we don't open duplicate + * the parent (which already subscribes to them for the auto-refetch hook) so we don't open duplicate * full-collection reads. */ transactions: OptimisticTrackingParams['transactions']; reportActions: OptimisticTrackingParams['reportActions']; @@ -90,11 +88,11 @@ const hashToString = (queryHash?: number) => (queryHash || queryHash === 0 ? Str * Single data layer for the Search screen. * * Owns the live inputs `getSections` needs for sort/group correctness, runs the deferral-gated - * sort/group/paginate projection, stamps the post-create highlight, enriches grouped views with their - * per-group sub-snapshots, and absorbs the optimistic-row resilience. Returns the sorted rows plus the - * list-level meta and the optimistic-tracking carriers that `` consumes. + * sort/group/paginate projection, enriches grouped views with their per-group sub-snapshots, and + * absorbs the optimistic-row resilience. Returns the sorted rows plus the list-level meta and the + * optimistic-tracking carriers that `` consumes. */ -function useSearchSnapshot({queryJSON, searchResults, newSearchResultKeys, transactions, reportActions}: UseSearchSnapshotParams): SearchSnapshotResult { +function useSearchSnapshot({queryJSON, searchResults, transactions, reportActions}: UseSearchSnapshotParams): SearchSnapshotResult { const {type, sortBy, sortOrder, hash, groupBy} = queryJSON; const {isOffline} = useNetwork(); @@ -298,7 +296,7 @@ function useSearchSnapshot({queryJSON, searchResults, newSearchResultKeys, trans convertToDisplayString, ]); - // Stage 3: sort the (enriched) data, then stamp the post-create highlight on each row. getSortedSections + // Stage 3: sort the (enriched) data, then stamp the query hash on each row. getSortedSections // accepts the full section union; our SearchListItem[] is a compatible subset of that input. const chartData = useMemo(() => { if (!shouldComputeSections) { @@ -310,47 +308,13 @@ function useSearchSnapshot({queryJSON, searchResults, newSearchResultKeys, trans policyTags, fallbackPolicyID: policyForMovingExpensesID, }).map((item) => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- chat variant rows are report actions - const reportActionID = (item as ReportActionListItemType).reportActionID; - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- non-chat variant rows carry a transactionID - const transactionID = (item as TransactionListItemType).transactionID; - const baseKey = isChat ? `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportActionID}` : `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`; - - const isBaseKeyMatch = !!newSearchResultKeys?.has(baseKey); - - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- group rows expose nested transactions - const groupTransactionsForHighlight = (item as TransactionGroupListItemType)?.transactions; - const isAnyTransactionMatch = - !isChat && - groupTransactionsForHighlight?.some((transaction) => { - const transactionKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`; - return !!newSearchResultKeys?.has(transactionKey); - }); - - const shouldAnimateInHighlight = isBaseKeyMatch || isAnyTransactionMatch; - - if (item.shouldAnimateInHighlight === shouldAnimateInHighlight && item.hash === hash) { + if (item.hash === hash) { return item; } - return {...item, shouldAnimateInHighlight, hash}; + return {...item, hash}; }); - }, [ - shouldComputeSections, - type, - filteredData, - localeCompare, - translate, - sortBy, - sortOrder, - validGroupBy, - policyCategories, - policyTags, - policyForMovingExpensesID, - isChat, - newSearchResultKeys, - hash, - ]); + }, [shouldComputeSections, type, filteredData, localeCompare, translate, sortBy, sortOrder, validGroupBy, policyCategories, policyTags, policyForMovingExpensesID, hash]); // Keep the optimistic row visible across a snapshot-replacement gap for up to // OPTIMISTIC_ROLLBACK_GRACE_MS until the new snapshot picks it up or the grace expires. diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index d827d2157fa2..07b6da5c8543 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -1,6 +1,5 @@ import FullPageErrorView from '@components/BlockingViews/FullPageErrorView'; import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; -import type {SelectionListHandle} from '@components/SelectionList/types'; import SearchRowSkeleton from '@components/Skeletons/SearchRowSkeleton'; import {useWideRHPActions} from '@components/WideRHPContextProvider'; @@ -14,7 +13,7 @@ import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSaveSortedReportIDs from '@hooks/useSaveSortedReportIDs'; -import useSearchHighlightAndScroll from '@hooks/useSearchHighlightAndScroll'; +import useSearchAutoRefetch from '@hooks/useSearchAutoRefetch'; import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotals'; import useStableArrayReference from '@hooks/useStableArrayReference'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -184,7 +183,6 @@ function Search({ const previousReportActions = usePrevious(reportActions); const {translate} = useLocalize(); - const searchListRef = useRef | null>(null); const savedSearchSelector = useCallback((searches: OnyxEntry) => searches?.[hash], [hash]); const [savedSearch] = useOnyx(ONYXKEYS.SAVED_SEARCHES, { @@ -207,7 +205,7 @@ function Search({ clearSelectedTransactions(); }, [validGroupBy, prevValidGroupBy, clearSelectedTransactions]); - const {newSearchResultKeys, handleSelectionListScroll, newTransactions, hasQueuedHighlights} = useSearchHighlightAndScroll({ + const {newTransactions} = useSearchAutoRefetch({ searchResults, transactions, previousTransactions, @@ -217,7 +215,6 @@ function Search({ shouldCalculateTotals, reportActions, previousReportActions, - shouldUseLiveData, }); const { @@ -236,17 +233,7 @@ function Search({ hasPendingWriteOnMountRef, skipDeferralOnFocusRef, rearmTracking, - } = useSearchSnapshot({queryJSON, searchResults, newSearchResultKeys, transactions, reportActions}); - - // Mirror `hasQueuedHighlights` into a ref so the post-create-flow `useFocusEffect` - // (which has empty deps) can read the latest value without re-creating its callback. - // Used to skip the deferral that would otherwise hide the freshly-added row from - // FlashList during the RHP dismiss transition, which would prevent the highlight - // animation from ever firing on it. - const hasQueuedHighlightsRef = useRef(hasQueuedHighlights); - useEffect(() => { - hasQueuedHighlightsRef.current = hasQueuedHighlights; - }, [hasQueuedHighlights]); + } = useSearchSnapshot({queryJSON, searchResults, transactions, reportActions}); // There's a race condition in Onyx which makes it return data from the previous Search, so in addition to checking that the data is loaded // we also need to check that the searchResults matches the type and status of the current search @@ -307,14 +294,6 @@ function Search({ return; } - // If the highlight hook already queued rows for the post-create animation, - // skip the skeleton-during-transition defer. Otherwise FlashList stays empty - // for ~1s while the RHP dismiss transition runs, the row never mounts inside - // the 300ms highlight window, and `useAnimatedHighlightStyle` never fires. - if (hasQueuedHighlightsRef.current) { - return; - } - // Show skeleton while the RHP dismiss animation plays. The transition // hasn't started yet when useFocusEffect fires (it begins after paint), // so waitForUpcomingTransition defers until the animation actually ends. @@ -749,9 +728,8 @@ function Search({ const onLayout = useCallback(() => { onLayoutBase(); - handleSelectionListScroll(stableSortedData, searchListRef.current); onContentReady?.(); - }, [onLayoutBase, handleSelectionListScroll, stableSortedData, onContentReady]); + }, [onLayoutBase, onContentReady]); // Must be a ref, not state: cancelNavigationSpans is called during render // (inside conditional returns), so using setState would trigger infinite re-renders. @@ -1060,7 +1038,6 @@ function Search({ const isTransactionListView = type !== CONST.SEARCH.DATA_TYPES.CHAT && type !== CONST.SEARCH.DATA_TYPES.TASK && type !== CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT; const commonViewProps: CommonSearchViewProps = { - ref: searchListRef, queryJSON, data: stableSortedData, columns: columnsToShow, diff --git a/src/components/Search/searchViewProps.ts b/src/components/Search/searchViewProps.ts index 40da64005c12..bc9d48696982 100644 --- a/src/components/Search/searchViewProps.ts +++ b/src/components/Search/searchViewProps.ts @@ -4,17 +4,11 @@ import type {ModifiedMouseEvent} from '@libs/Navigation/helpers/openInternalRout import type {CardList, Transaction} from '@src/types/onyx'; import type React from 'react'; -import type {ForwardedRef} from 'react'; import type {NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle} from 'react-native'; import type {SearchListItem} from './SearchList/ListItem/types'; import type {SearchColumnType, SearchQueryJSON} from './types'; -/** Imperative handle the router uses for highlight-driven scrolling. */ -type SearchListHandle = { - scrollToIndex: (index: number, animated?: boolean) => void; -}; - /** * Props shared by every dedicated Search view. The router (``) builds these once and spreads * them into whichever view renders; each view narrows to its own extras on top of this shape. @@ -47,7 +41,7 @@ type CommonSearchViewProps = { /** Whether everything has been loaded (gates the fully-checked select-all state). */ hasLoadedAllTransactions?: boolean; - /** Rows flagged for the post-create highlight animation (feeds BaseSearchList extraData). */ + /** Transactions newly added since the previous render, used to refetch the matching expanded group's snapshot. */ newTransactions: Transaction[]; /** The navigation handler for a row tap (owned by the router). */ @@ -70,9 +64,6 @@ type CommonSearchViewProps = { /** Outer container style for the list wrapper. */ containerStyle: StyleProp; - - /** Imperative handle for highlight-driven scrolling, set by the router. */ - ref?: ForwardedRef; }; /** Extra props specific to the transaction views (expense/invoice/trip): attendee tracking and card rendering. */ diff --git a/src/hooks/useExpenseActions.ts b/src/hooks/useExpenseActions.ts index d4b0b7f1906a..9d5d39e1bb1f 100644 --- a/src/hooks/useExpenseActions.ts +++ b/src/hooks/useExpenseActions.ts @@ -12,6 +12,7 @@ import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getExistingTransactionID} from '@libs/IOUUtils'; import Log from '@libs/Log'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; +import {surfaceExpenseCreatedFeedback} from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; import Navigation from '@libs/Navigation/Navigation'; import {isTrackOnboardingChoice} from '@libs/OnboardingUtils'; import {isPolicyAccessible} from '@libs/PolicyUtils'; @@ -252,11 +253,12 @@ function useExpenseActions({reportID, isReportInSearch = false, backTo, onDuplic const optimisticIOUReportID = generateReportID(); const activePolicyCategories = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${defaultExpensePolicy?.id}`] ?? {}; + let lastDuplicate: {iouReportID?: string; transactionID?: string} | undefined; for (const item of transactionList) { const existingTransactionID = getExistingTransactionID(item.linkedTrackedExpenseReportAction); const existingTransactionDraft = existingTransactionID ? transactionDrafts?.[existingTransactionID] : undefined; - duplicateTransactionAction({ + const result = duplicateTransactionAction({ transaction: item, optimisticChatReportID, optimisticIOUReportID, @@ -280,6 +282,13 @@ function useExpenseActions({reportID, isReportInSearch = false, backTo, onDuplic delegateAccountID, policyTagList, }); + if (result?.transactionID) { + lastDuplicate = {iouReportID: result.iouReport?.reportID, transactionID: result.transactionID}; + } + } + + if (lastDuplicate?.transactionID) { + surfaceExpenseCreatedFeedback(lastDuplicate); } }; diff --git a/src/hooks/useSearchAutoRefetch.ts b/src/hooks/useSearchAutoRefetch.ts new file mode 100644 index 000000000000..1633bf45ff65 --- /dev/null +++ b/src/hooks/useSearchAutoRefetch.ts @@ -0,0 +1,205 @@ +import type {SearchQueryJSON} from '@components/Search/types'; + +import {search} from '@libs/actions/Search'; +import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; +import TransitionTracker from '@libs/Navigation/TransitionTracker'; +import {isReportActionEntry} from '@libs/SearchUIUtils'; +import type {SearchKey} from '@libs/SearchUIUtils'; + +import CONST from '@src/CONST'; +import type {ReportActions, SearchResults, Transaction} from '@src/types/onyx'; + +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; + +import {useIsFocused} from '@react-navigation/native'; +import {useEffect, useRef} from 'react'; + +import useNetwork from './useNetwork'; + +type UseSearchAutoRefetchParams = { + /** Current search snapshot. */ + searchResults: OnyxEntry; + + /** Live transaction collection. */ + transactions: OnyxCollection; + + /** Previous transaction collection. */ + previousTransactions: OnyxCollection; + + /** Live report-actions collection. */ + reportActions: OnyxCollection; + + /** Previous report-actions collection. */ + previousReportActions: OnyxCollection; + + /** Parsed query driving the refetch. */ + queryJSON: SearchQueryJSON; + + /** Suggested-search key passed through to the refetch. */ + searchKey: SearchKey | undefined; + + /** Pagination offset passed through to the refetch. */ + offset: number; + + /** Whether the refetch should recompute list totals. */ + shouldCalculateTotals: boolean; +}; + +function useSearchAutoRefetch({ + searchResults, + transactions, + previousTransactions, + reportActions, + previousReportActions, + queryJSON, + searchKey, + offset, + shouldCalculateTotals, +}: UseSearchAutoRefetchParams) { + const isFocused = useIsFocused(); + const {isOffline} = useNetwork(); + const searchTriggeredRef = useRef(false); + const hasPendingSearchRef = useRef(false); + const isChat = queryJSON.type === CONST.SEARCH.DATA_TYPES.CHAT; + const searchResultsData = searchResults?.data; + + const prevTransactionsIDs = Object.keys(previousTransactions ?? {}); + const newTransactions: Transaction[] = []; + if (prevTransactionsIDs.length > 0) { + const previousIDs = new Set(prevTransactionsIDs); + for (const [id, transaction] of Object.entries(transactions ?? {})) { + if (!previousIDs.has(id) && transaction) { + newTransactions.push(transaction); + } + } + } + + useEffect(() => { + const previousTransactionIDsLocal = Object.keys(previousTransactions ?? {}); + const transactionsIDs = Object.keys(transactions ?? {}); + + const reportActionsIDs = Object.values(reportActions ?? {}) + .map((actions) => Object.keys(actions ?? {})) + .flat(); + const previousReportActionsIDs = Object.values(previousReportActions ?? {}) + .map((actions) => Object.keys(actions ?? {})) + .flat(); + + if ((previousTransactionIDsLocal.length === 0 && previousReportActionsIDs.length === 0) || searchTriggeredRef.current) { + return; + } + + const previousTransactionsIDsSet = new Set(previousTransactionIDsLocal); + const previousReportActionsIDsSet = new Set(previousReportActionsIDs); + const hasTransactionsIDsChange = transactionsIDs.length !== previousTransactionIDsLocal.length || transactionsIDs.some((id) => !previousTransactionsIDsSet.has(id)); + const hasReportActionsIDsChange = reportActionsIDs.some((id) => !previousReportActionsIDsSet.has(id)); + + if ((!isChat && hasTransactionsIDsChange) || hasReportActionsIDsChange || hasPendingSearchRef.current) { + // An RHP layered on top of Search makes `isFocused` false but keeps Search as the topmost + // fullscreen route, so we still want to refetch — otherwise the snapshot can't reflect + // entries the user creates from the RHP until they close it. + const isSearchStillActive = isFocused || isSearchTopmostFullScreenRoute(); + if (!isSearchStillActive || isOffline) { + hasPendingSearchRef.current = true; + return; + } + hasPendingSearchRef.current = false; + + const newIDs = isChat ? reportActionsIDs : transactionsIDs; + let currentSearchResultIDs: string[] = []; + if (searchResultsData) { + currentSearchResultIDs = isChat ? extractReportActionIDsFromSearchResults(searchResultsData) : extractTransactionIDsFromSearchResults(searchResultsData); + } + const existingSearchResultIDsSet = new Set(currentSearchResultIDs); + const hasAGenuinelyNewID = newIDs.some((id) => !existingSearchResultIDsSet.has(id)); + + // Only skip search if there are no new items AND search results aren't empty. + // This ensures deletions that result in empty data still trigger search. + if (!hasAGenuinelyNewID && currentSearchResultIDs.length > 0) { + const newIDsSet = new Set(newIDs); + const hasDeletedID = currentSearchResultIDs.some((id) => !newIDsSet.has(id)); + if (!hasDeletedID) { + return; + } + } + + TransitionTracker.runAfterTransitions({ + callback: () => { + search({queryJSON, searchKey, offset, shouldCalculateTotals, isLoading: !!searchResults?.search?.isLoading}); + }, + }); + + searchTriggeredRef.current = true; + } + }, [ + isFocused, + transactions, + previousTransactions, + queryJSON, + searchKey, + offset, + shouldCalculateTotals, + reportActions, + previousReportActions, + isChat, + searchResultsData, + isOffline, + searchResults?.search?.isLoading, + ]); + + useEffect(() => { + // For live data, isLoading is always false, so we also need to reset when searchResultsData changes. + // For snapshot data, we wait for isLoading to become false after the API call completes. + if (searchResults?.search?.isLoading) { + return; + } + + searchTriggeredRef.current = false; + }, [searchResults?.search?.isLoading, searchResultsData]); + + return {newTransactions}; +} + +function getTransactionIDFromValue(value: unknown): string | undefined { + if (!value || typeof value !== 'object' || !('transactionID' in value)) { + return undefined; + } + const {transactionID} = value; + return typeof transactionID === 'string' && transactionID ? transactionID : undefined; +} + +function extractTransactionIDsFromSearchResults(searchResultsData: Partial): string[] { + const transactionIDs: string[] = []; + + for (const item of Object.values(searchResultsData)) { + if (!item || typeof item !== 'object') { + continue; + } + + const itemTransactionID = getTransactionIDFromValue(item); + if (itemTransactionID) { + transactionIDs.push(itemTransactionID); + } + + if ('transactions' in item && Array.isArray(item.transactions)) { + for (const transaction of item.transactions) { + const transactionID = getTransactionIDFromValue(transaction); + if (transactionID) { + transactionIDs.push(transactionID); + } + } + } + } + + return transactionIDs; +} + +function extractReportActionIDsFromSearchResults(searchResultsData: Partial): string[] { + return Object.keys(searchResultsData ?? {}) + .filter(isReportActionEntry) + .map((key) => Object.keys(searchResultsData[key] ?? {})) + .flat(); +} + +export default useSearchAutoRefetch; +export type {UseSearchAutoRefetchParams}; diff --git a/src/hooks/useSearchHighlightAndScroll.ts b/src/hooks/useSearchHighlightAndScroll.ts deleted file mode 100644 index b11c67ac96c8..000000000000 --- a/src/hooks/useSearchHighlightAndScroll.ts +++ /dev/null @@ -1,362 +0,0 @@ -import type {SearchListItem, TransactionGroupListItemType, TransactionListItemType} from '@components/Search/SearchList/ListItem/types'; -import type {SearchQueryJSON} from '@components/Search/types'; -import type {SelectionListHandle} from '@components/SelectionList/types'; - -import {search} from '@libs/actions/Search'; -import {mergeTransactionIdsHighlightOnSearchRoute} from '@libs/actions/Transaction'; -import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; -import TransitionTracker from '@libs/Navigation/TransitionTracker'; -import {isReportActionEntry} from '@libs/SearchUIUtils'; -import type {SearchKey} from '@libs/SearchUIUtils'; - -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {ReportActions, SearchResults, Transaction} from '@src/types/onyx'; - -import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; - -import {useIsFocused} from '@react-navigation/native'; -import {useCallback, useEffect, useRef, useState} from 'react'; - -import useNetwork from './useNetwork'; -import useOnyx from './useOnyx'; -import usePrevious from './usePrevious'; - -type UseSearchHighlightAndScroll = { - searchResults: OnyxEntry; - transactions: OnyxCollection; - previousTransactions: OnyxCollection; - reportActions: OnyxCollection; - previousReportActions: OnyxCollection; - queryJSON: SearchQueryJSON; - searchKey: SearchKey | undefined; - offset: number; - shouldCalculateTotals: boolean; - shouldUseLiveData: boolean; -}; - -/** - * Hook used to trigger a search when a new transaction or report action is added and handle highlighting and scrolling. - */ -function useSearchHighlightAndScroll({ - searchResults, - transactions, - previousTransactions, - reportActions, - previousReportActions, - queryJSON, - searchKey, - offset, - shouldCalculateTotals, - shouldUseLiveData, -}: UseSearchHighlightAndScroll) { - const isFocused = useIsFocused(); - const {isOffline} = useNetwork(); - // Ref to track if the search was triggered by this hook - const triggeredByHookRef = useRef(false); - const searchTriggeredRef = useRef(false); - const hasNewItemsRef = useRef(false); - const previousSearchResults = usePrevious(searchResults?.data); - const [newSearchResultKeys, setNewSearchResultKeys] = useState | null>(null); - const highlightedIDs = useRef>(new Set()); - const initializedRef = useRef(false); - const hasPendingSearchRef = useRef(false); - const isChat = queryJSON.type === CONST.SEARCH.DATA_TYPES.CHAT; - - const transactionIDsToHighlightSelector = useCallback((allTransactionIDs: OnyxEntry>>) => allTransactionIDs?.[queryJSON.type], [queryJSON.type]); - const [transactionIDsToHighlight] = useOnyx(ONYXKEYS.TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE, { - selector: transactionIDsToHighlightSelector, - }); - const searchResultsData = searchResults?.data; - - const prevTransactionsIDs = Object.keys(previousTransactions ?? {}); - const newTransactions: Transaction[] = []; - if (prevTransactionsIDs.length > 0) { - const previousIDs = new Set(prevTransactionsIDs); - for (const [id, transaction] of Object.entries(transactions ?? {})) { - if (!previousIDs.has(id) && transaction) { - newTransactions.push(transaction); - } - } - } - - // Trigger search when a new report action is added while on chat or when a new transaction is added for the other search types. - useEffect(() => { - const previousTransactionIDsLocal = Object.keys(previousTransactions ?? {}); - const transactionsIDs = Object.keys(transactions ?? {}); - - const reportActionsIDs = Object.values(reportActions ?? {}) - .map((actions) => Object.keys(actions ?? {})) - .flat(); - const previousReportActionsIDs = Object.values(previousReportActions ?? {}) - .map((actions) => Object.keys(actions ?? {})) - .flat(); - - // Only proceed if we have previous data to compare against - // This prevents triggering on initial data load - if ((previousTransactionIDsLocal.length === 0 && previousReportActionsIDs.length === 0) || searchTriggeredRef.current) { - return; - } - - const previousTransactionsIDsSet = new Set(previousTransactionIDsLocal); - const previousReportActionsIDsSet = new Set(previousReportActionsIDs); - const hasTransactionsIDsChange = transactionsIDs.length !== previousTransactionIDsLocal.length || transactionsIDs.some((id) => !previousTransactionsIDsSet.has(id)); - const hasReportActionsIDsChange = reportActionsIDs.some((id) => !previousReportActionsIDsSet.has(id)); - - // Check if there is a change in the transactions or report actions list - if ((!isChat && hasTransactionsIDsChange) || hasReportActionsIDsChange || hasPendingSearchRef.current) { - // Skip if offline, or if the user has navigated to a different fullscreen page entirely. - // An RHP layered on top of Search makes `isFocused` false but keeps Search as the topmost - // fullscreen route, so we still want to refetch — otherwise the snapshot can't reflect - // entries the user creates from the RHP until they close it. - const isSearchStillActive = isFocused || isSearchTopmostFullScreenRoute(); - if (!isSearchStillActive || isOffline) { - hasPendingSearchRef.current = true; - return; - } - hasPendingSearchRef.current = false; - - const newIDs = isChat ? reportActionsIDs : transactionsIDs; - let currentSearchResultIDs: string[] = []; - if (searchResultsData) { - currentSearchResultIDs = isChat ? extractReportActionIDsFromSearchResults(searchResultsData) : extractTransactionIDsFromSearchResults(searchResultsData); - } - const existingSearchResultIDsSet = new Set(currentSearchResultIDs); - const hasAGenuinelyNewID = newIDs.some((id) => !existingSearchResultIDsSet.has(id)); - - // Only skip search if there are no new items AND search results aren't empty - // This ensures deletions that result in empty data still trigger search - if (!hasAGenuinelyNewID && currentSearchResultIDs.length > 0) { - const newIDsSet = new Set(newIDs); - const hasDeletedID = currentSearchResultIDs.some((id) => !newIDsSet.has(id)); - if (!hasDeletedID) { - return; - } - } - // We only want to highlight new items if the addition of transactions or report actions triggered the search. - // This is because, on deletion of items, the backend sometimes returns old items in place of the deleted ones. - // We don't want to highlight these old items, even if they appear new in the current search results. - hasNewItemsRef.current = isChat ? reportActionsIDs.length > previousReportActionsIDs.length : transactionsIDs.length > previousTransactionIDsLocal.length; - - // Set the flag indicating the search is triggered by the hook - triggeredByHookRef.current = true; - - // Trigger the search - TransitionTracker.runAfterTransitions({ - callback: () => { - search({queryJSON, searchKey, offset, shouldCalculateTotals, isLoading: !!searchResults?.search?.isLoading}); - }, - }); - - // Set the ref to prevent further triggers until reset - searchTriggeredRef.current = true; - } - }, [ - isFocused, - transactions, - previousTransactions, - queryJSON, - searchKey, - offset, - shouldCalculateTotals, - reportActions, - previousReportActions, - isChat, - searchResultsData, - isOffline, - searchResults?.search?.isLoading, - ]); - - useEffect(() => { - // For live data, isLoading is always false, so we also need to reset when searchResultsData changes - // For snapshot data, we wait for isLoading to become false after the API call completes - if (searchResults?.search?.isLoading) { - return; - } - - searchTriggeredRef.current = false; - }, [searchResults?.search?.isLoading, shouldUseLiveData, searchResultsData]); - - // Initialize the set with existing IDs only once - useEffect(() => { - if (initializedRef.current || !searchResultsData) { - return; - } - - const initialIDs = isChat ? extractReportActionIDsFromSearchResults(searchResultsData) : extractTransactionIDsFromSearchResults(searchResultsData); - highlightedIDs.current = new Set(initialIDs); - initializedRef.current = true; - }, [searchResultsData, isChat]); - - // Detect new items (transactions or report actions) - useEffect(() => { - if (!previousSearchResults || !searchResults?.data) { - return; - } - if (isChat) { - const previousReportActionIDs = extractReportActionIDsFromSearchResults(previousSearchResults); - const currentReportActionIDs = extractReportActionIDsFromSearchResults(searchResults.data); - - // Find new report action IDs that are not in the previousReportActionIDs and not already highlighted - const newReportActionIDs = currentReportActionIDs.filter((id) => !previousReportActionIDs.includes(id) && !highlightedIDs.current.has(id)); - - if (!triggeredByHookRef.current || newReportActionIDs.length === 0 || !hasNewItemsRef.current) { - return; - } - - const newKeys = new Set(); - for (const id of newReportActionIDs) { - const newReportActionKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${id}`; - highlightedIDs.current.add(newReportActionKey); - newKeys.add(newReportActionKey); - } - setNewSearchResultKeys(newKeys); - } else { - const previousTransactionIDs = extractTransactionIDsFromSearchResults(previousSearchResults); - const currentTransactionIDs = extractTransactionIDsFromSearchResults(searchResults.data); - const manualHighlightTransactionIDs = new Set(Object.keys(transactionIDsToHighlight ?? {}).filter((id) => !!transactionIDsToHighlight?.[id])); - - // Find new transaction IDs that are not in the previousTransactionIDs and not already highlighted - const newTransactionIDs = currentTransactionIDs.filter((id) => { - if (manualHighlightTransactionIDs.has(id)) { - return true; - } - if (!triggeredByHookRef.current || !hasNewItemsRef.current) { - return false; - } - return !previousTransactionIDs.includes(id) && !highlightedIDs.current.has(id); - }); - - if (newTransactionIDs.length === 0) { - return; - } - - const newKeys = new Set(); - const consumedManualIDs: string[] = []; - for (const id of newTransactionIDs) { - const newTransactionKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${id}`; - highlightedIDs.current.add(newTransactionKey); - newKeys.add(newTransactionKey); - if (manualHighlightTransactionIDs.has(id)) { - consumedManualIDs.push(id); - } - } - setNewSearchResultKeys(newKeys); - - // Clear consumed manual highlight flags so subsequent detect runs don't re-highlight the same IDs. - if (consumedManualIDs.length > 0) { - mergeTransactionIdsHighlightOnSearchRoute(queryJSON.type, Object.fromEntries(consumedManualIDs.map((id) => [id, false]))); - } - } - }, [searchResults?.data, previousSearchResults, isChat, transactionIDsToHighlight, queryJSON.type]); - - // Remove transactionIDsToHighlight when the user leaves the current search type - useEffect( - () => () => { - mergeTransactionIdsHighlightOnSearchRoute(queryJSON.type, null); - }, - [queryJSON.type], - ); - - // Reset newSearchResultKey after it's been used - useEffect(() => { - if (newSearchResultKeys === null) { - return; - } - - const timer = setTimeout(() => { - setNewSearchResultKeys(null); - }, CONST.ANIMATED_HIGHLIGHT_START_DURATION); - - return () => clearTimeout(timer); - }, [newSearchResultKeys]); - - /** - * Callback to handle scrolling to the new search result. - */ - const handleSelectionListScroll = (data: SearchListItem[], ref: SelectionListHandle | null) => { - // Early return if there's no ref, new transaction wasn't brought in by this hook - // or there's no new search result key - const newSearchResultKey = newSearchResultKeys?.values().next().value; - if (!ref || !triggeredByHookRef.current || !newSearchResultKey) { - return; - } - - // Extract the transaction/report action ID from the newSearchResultKey - const newID = newSearchResultKey.replace(isChat ? ONYXKEYS.COLLECTION.REPORT_ACTIONS : ONYXKEYS.COLLECTION.TRANSACTION, ''); - - // Find the index of the new transaction/report action in the data array - const indexOfNewItem = data.findIndex((item) => { - if (isChat) { - if ('reportActionID' in item && item.reportActionID === newID) { - return true; - } - } else { - // Handle TransactionListItemType - if ('transactionID' in item && item.transactionID === newID) { - return true; - } - - // Handle TransactionGroupListItemType with transactions array - if ('transactions' in item && Array.isArray(item.transactions)) { - return item.transactions.some((transaction) => transaction?.transactionID === newID); - } - } - - return false; - }); - - // Early return if the new item is not found in the data array - if (indexOfNewItem <= 0) { - return; - } - - // Perform the scrolling action - ref.scrollToIndex(indexOfNewItem); - // Reset the trigger flag to prevent unintended future scrolls and highlights - triggeredByHookRef.current = false; - }; - - const hasQueuedHighlights = newSearchResultKeys !== null && newSearchResultKeys.size > 0; - - return {newSearchResultKeys, handleSelectionListScroll, newTransactions, hasQueuedHighlights}; -} - -/** - * Helper function to extract transaction IDs from search results data. - */ -function extractTransactionIDsFromSearchResults(searchResultsData: Partial): string[] { - const transactionIDs: string[] = []; - - for (const item of Object.values(searchResultsData)) { - // Check for transactionID directly on the item (TransactionListItemType) - if ((item as TransactionListItemType)?.transactionID) { - transactionIDs.push((item as TransactionListItemType).transactionID); - } - - // Check for transactions array within the item (TransactionGroupListItemType) - if (Array.isArray((item as TransactionGroupListItemType)?.transactions)) { - for (const transaction of (item as TransactionGroupListItemType).transactions) { - if (!transaction?.transactionID) { - continue; - } - transactionIDs.push(transaction.transactionID); - } - } - } - - return transactionIDs; -} - -/** - * Helper function to extract report action IDs from search results data. - */ -function extractReportActionIDsFromSearchResults(searchResultsData: Partial): string[] { - return Object.keys(searchResultsData ?? {}) - .filter(isReportActionEntry) - .map((key) => Object.keys(searchResultsData[key] ?? {})) - .flat(); -} - -export default useSearchHighlightAndScroll; -export type {UseSearchHighlightAndScroll}; diff --git a/src/languages/de.ts b/src/languages/de.ts index ab7159789b5f..b0bf69401388 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1236,6 +1236,8 @@ const translations: TranslationDeepObject = { }, iou: { amount: 'Betrag', + expenseAdded: 'Ausgabe hinzugefügt', + invoiceSent: 'Rechnung gesendet', percent: 'Prozent', date: 'Datum', taxAmount: 'Steuerbetrag', diff --git a/src/languages/en.ts b/src/languages/en.ts index b38453141ed1..ac9b8f511554 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1304,6 +1304,8 @@ const translations = { }, iou: { amount: 'Amount', + expenseAdded: 'Expense added', + invoiceSent: 'Invoice sent', percent: 'Percent', date: 'Date', taxAmount: 'Tax amount', diff --git a/src/languages/es.ts b/src/languages/es.ts index 80f5e64f2d4c..c865fd609bc0 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1195,6 +1195,8 @@ const translations: TranslationDeepObject = { }, iou: { amount: 'Importe', + expenseAdded: 'Gasto añadido', + invoiceSent: 'Factura enviada', percent: 'Porcentaje', date: 'Fecha', taxAmount: 'Importe del impuesto', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 2c80ad56187f..eb17f55ef01a 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1240,6 +1240,8 @@ const translations: TranslationDeepObject = { }, iou: { amount: 'Montant', + expenseAdded: 'Dépense ajoutée', + invoiceSent: 'Facture envoyée', percent: 'Pourcentage', date: 'Date', taxAmount: 'Montant de la taxe', diff --git a/src/languages/it.ts b/src/languages/it.ts index c6cce232c27a..40867132aae4 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1236,6 +1236,8 @@ const translations: TranslationDeepObject = { }, iou: { amount: 'Importo', + expenseAdded: 'Spesa aggiunta', + invoiceSent: 'Fattura inviata', percent: 'Percentuale', date: 'Data', taxAmount: 'Importo imposta', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index cd0d1dd46a70..8a376f07e170 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1218,6 +1218,8 @@ const translations: TranslationDeepObject = { }, iou: { amount: '金額', + expenseAdded: '経費を追加しました', + invoiceSent: '請求書を送信しました', percent: 'パーセント', date: '日付', taxAmount: '税額', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 983cfb709e44..99e43c6a8b5b 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1234,6 +1234,8 @@ const translations: TranslationDeepObject = { }, iou: { amount: 'Bedrag', + expenseAdded: 'Uitgave toegevoegd', + invoiceSent: 'Factuur verzonden', percent: 'Procent', date: 'Datum', taxAmount: 'Belastingbedrag', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 8ba7155c1df9..14309c1c468b 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1230,6 +1230,8 @@ const translations: TranslationDeepObject = { }, iou: { amount: 'Kwota', + expenseAdded: 'Dodano wydatek', + invoiceSent: 'Wysłano fakturę', percent: 'Procent', date: 'Data', taxAmount: 'Kwota podatku', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 6e1b8d8a3984..cfc66ed023f3 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1234,6 +1234,8 @@ const translations: TranslationDeepObject = { }, iou: { amount: 'Valor', + expenseAdded: 'Despesa adicionada', + invoiceSent: 'Fatura enviada', percent: 'Porcentagem', date: 'Data', taxAmount: 'Valor do imposto', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 7ad0e99a0c82..748170334ead 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1187,6 +1187,8 @@ const translations: TranslationDeepObject = { }, iou: { amount: '金额', + expenseAdded: '已添加支出', + invoiceSent: '已发送发票', percent: '百分比', date: '日期', taxAmount: '税额', diff --git a/src/libs/ExportOnyxState/common.ts b/src/libs/ExportOnyxState/common.ts index 36dd807d3927..a919e069d3a2 100644 --- a/src/libs/ExportOnyxState/common.ts +++ b/src/libs/ExportOnyxState/common.ts @@ -285,7 +285,6 @@ const safeOnyxKeys = new Set([ ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_FAILED, ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_PENDING, ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_SUCCESSFUL, - ONYXKEYS.TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE, ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS, ONYXKEYS.TRAVEL_INVOICE_STATEMENT, ONYXKEYS.VALIDATE_DOMAIN_TWO_FACTOR_CODE, diff --git a/src/libs/IOUAmountSubmission.ts b/src/libs/IOUAmountSubmission.ts index a3db906c5cc3..25148f6643a9 100644 --- a/src/libs/IOUAmountSubmission.ts +++ b/src/libs/IOUAmountSubmission.ts @@ -38,7 +38,9 @@ import { navigateToParticipantPage, resolveOptimisticChatReportID, } from './IOUUtils'; -import cleanupAfterSkipConfirmSubmit from './Navigation/helpers/cleanupAfterSkipConfirmSubmit'; +import cleanupAfterExpenseCreate from './Navigation/helpers/cleanupAfterExpenseCreate'; +import cleanupAndNavigateAfterExpenseCreate from './Navigation/helpers/cleanupAndNavigateAfterExpenseCreate'; +import {surfaceExpenseCreatedFeedback} from './Navigation/helpers/navigateAfterExpenseCreate'; import {submitWithDismissFirst} from './Navigation/helpers/submitWithDismissFirst'; import Navigation from './Navigation/Navigation'; import {rand64} from './NumberUtils'; @@ -291,7 +293,6 @@ function submitSkipConfirmationExpense(args: SubmitAmountArgs, ctx: SubmitAmount storedTransaction, policyRecentlyUsedCurrencies, allPersonalDetails, - action, currentUserPersonalDetails, isTrackIntentUser, } = args; @@ -307,8 +308,9 @@ function submitSkipConfirmationExpense(args: SubmitAmountArgs, ctx: SubmitAmount const draftTransactionIDsList = Object.keys(transactionDrafts ?? {}); const isSelfTourViewed = hasSeenTourSelector(onboarding) ?? false; const executeExpenseWrite = (overrides: WriteOverrides) => { + let result: {iouReport?: OnyxEntry; transactionID?: string; transactionThreadReportID?: string} | undefined; if (isTrackExpenseSubmit) { - trackExpense({ + result = trackExpense({ report, isDraftPolicy: false, isDraftChatReport: !!isDraftChatReport, @@ -341,7 +343,7 @@ function submitSkipConfirmationExpense(args: SubmitAmountArgs, ctx: SubmitAmount }); } else { const existingTransactionDraft = existingTransactionID ? transactionDrafts?.[existingTransactionID] : undefined; - requestMoney({ + result = requestMoney({ report, betas, participantParams: { @@ -377,16 +379,40 @@ function submitSkipConfirmationExpense(args: SubmitAmountArgs, ctx: SubmitAmount isTrackIntentUser, }); } - cleanupAfterSkipConfirmSubmit(overrides.shouldHandleNavigation, { - report, - action, - draftTransactionIDs: draftTransactionIDsList, - transactionID: existingTransactionID ?? optimisticTransactionID, - isFromGlobalCreate: getIsFromGlobalCreate(transaction), - backToReport, - optimisticChatReportID, - linkedTrackedExpenseReportAction: transaction?.linkedTrackedExpenseReportAction, - }); + + const buildTransactionThreadParams = { + currentUserLogin: currentUserEmail, + currentUserAccountID, + betas, + introSelected, + }; + if (overrides.shouldHandleNavigation) { + cleanupAndNavigateAfterExpenseCreate({ + report: isMoneyRequestReport(report) ? report : undefined, + action: CONST.IOU.ACTION.CREATE, + draftTransactionIDs: draftTransactionIDsList, + transactionID: result?.transactionID, + iouReportID: result?.iouReport?.reportID, + transactionThreadReportID: result?.transactionThreadReportID, + isFromGlobalCreate: getIsFromGlobalCreate(transaction), + backToReport, + optimisticChatReportID, + linkedTrackedExpenseReportAction: transaction?.linkedTrackedExpenseReportAction, + buildTransactionThreadParams, + }); + } else { + cleanupAfterExpenseCreate({ + draftTransactionIDs: draftTransactionIDsList, + linkedTrackedExpenseReportAction: transaction?.linkedTrackedExpenseReportAction, + }); + surfaceExpenseCreatedFeedback({ + iouReportID: result?.iouReport?.reportID, + transactionID: result?.transactionID, + transactionThreadReportID: result?.transactionThreadReportID, + isMoneyRequestReport: isMoneyRequestReport(report), + buildTransactionThreadParams, + }); + } }; submitWithDismissFirst({ executeWrite: executeExpenseWrite, diff --git a/src/libs/Navigation/helpers/cleanupAfterSkipConfirmSubmit.ts b/src/libs/Navigation/helpers/cleanupAfterSkipConfirmSubmit.ts deleted file mode 100644 index 86a219a591de..000000000000 --- a/src/libs/Navigation/helpers/cleanupAfterSkipConfirmSubmit.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type {CleanupAndNavigateAfterExpenseCreateParams} from './cleanupAndNavigateAfterExpenseCreate'; - -import cleanupAfterExpenseCreate from './cleanupAfterExpenseCreate'; -import cleanupAndNavigateAfterExpenseCreate from './cleanupAndNavigateAfterExpenseCreate'; - -/** - * Skip-confirmation cleanup dispatcher: `shouldHandleNavigation` (from `submitWithDismissFirst`) picks - * cleanup-only vs cleanup-and-navigate. The skip-confirm analog of `useExpenseSubmission`'s `performPostBatchCleanup`. - */ -function cleanupAfterSkipConfirmSubmit(shouldHandleNavigation: boolean, params: CleanupAndNavigateAfterExpenseCreateParams) { - if (shouldHandleNavigation) { - cleanupAndNavigateAfterExpenseCreate(params); - return; - } - cleanupAfterExpenseCreate({draftTransactionIDs: params.draftTransactionIDs, linkedTrackedExpenseReportAction: params.linkedTrackedExpenseReportAction}); -} - -export default cleanupAfterSkipConfirmSubmit; diff --git a/src/libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate.ts index 8b85cf2d2fb8..1e8ed05dd33e 100644 --- a/src/libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate.ts @@ -6,6 +6,8 @@ import type DeepValueOf from '@src/types/utils/DeepValueOf'; import type {OnyxEntry} from 'react-native-onyx'; +import type {BuildTransactionThreadParams} from './navigateAfterExpenseCreate'; + import cleanupAfterExpenseCreate from './cleanupAfterExpenseCreate'; import navigateAfterExpenseCreate from './navigateAfterExpenseCreate'; @@ -13,24 +15,40 @@ type CleanupAndNavigateAfterExpenseCreateParams = { report: OnyxEntry; draftTransactionIDs: string[] | undefined; transactionID: string | undefined; + + /** IOU report the expense landed in; lets the "Expense added" growl resolve its "View" deep link. */ + iouReportID?: string; + + /** Transaction thread report the growl's "View" action opens. */ + transactionThreadReportID?: string; isFromGlobalCreate: boolean | undefined; backToReport?: string; optimisticChatReportID?: string; isInvoice?: boolean; linkedTrackedExpenseReportAction?: OnyxEntry; action: DeepValueOf; + + /** Show the "Expense added" growl even on the non-global-create path (e.g. the Share flow, whose drafts never set isFromGlobalCreate). */ + shouldAlwaysShowFeedback?: boolean; + + /** Data the "View" action needs to build the transaction thread. */ + buildTransactionThreadParams?: BuildTransactionThreadParams; }; function cleanupAndNavigateAfterExpenseCreate({ report, draftTransactionIDs, transactionID, + iouReportID, + transactionThreadReportID, isFromGlobalCreate, backToReport, optimisticChatReportID, isInvoice, linkedTrackedExpenseReportAction, action, + shouldAlwaysShowFeedback, + buildTransactionThreadParams, }: CleanupAndNavigateAfterExpenseCreateParams) { cleanupAfterExpenseCreate({ draftTransactionIDs, @@ -45,13 +63,16 @@ function cleanupAndNavigateAfterExpenseCreate({ navigateAfterExpenseCreate({ activeReportID: finalActiveReportID, + iouReportID, transactionID, + transactionThreadReportID, isFromGlobalCreate, isInvoice, hasMultipleTransactions, shouldAddPendingNewTransactionIDs, + shouldAlwaysShowFeedback, + buildTransactionThreadParams, }); } export default cleanupAndNavigateAfterExpenseCreate; -export type {CleanupAndNavigateAfterExpenseCreateParams}; diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts index 9cea646bc6c1..0e7ba3ccdf04 100644 --- a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts @@ -1,36 +1,326 @@ import {addPendingNewTransactionIDs} from '@libs/actions/IOU/PendingNewTransactions'; +import {createTransactionThreadReport, setOptimisticTransactionThread} from '@libs/actions/Report'; +import {setActiveTransactionIDs} from '@libs/actions/TransactionThreadNavigation'; +import {runAfterDeferredWrite} from '@libs/deferredLayoutWrite'; import getIsNarrowLayout from '@libs/getIsNarrowLayout'; +import Growl from '@libs/Growl'; +import {translateLocal} from '@libs/Localize'; import Log from '@libs/Log'; import {getPreservedNavigatorState} from '@libs/Navigation/AppNavigator/createSplitNavigator/usePreserveNavigatorState'; import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; +import {getIOUActionForTransactionID} from '@libs/ReportActionsUtils'; +import {getReportTransactions} from '@libs/ReportUtils'; import {buildCannedSearchQuery, getCurrentSearchQueryJSON} from '@libs/SearchQueryUtils'; import {setPendingSubmitFollowUpAction} from '@libs/telemetry/submitFollowUpAction'; import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; +import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; +import type {Beta, IntroSelected, Report, ReportAction, ReportActions, Transaction} from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import Onyx from 'react-native-onyx'; import dismissModalAndOpenReportInInboxTab from './dismissModalAndOpenReportInInboxTab'; +import getTopmostFullScreenRoute from './getTopmostFullScreenRoute'; +import isReportOpenInRHP from './isReportOpenInRHP'; import isReportTopmostSplitNavigator from './isReportTopmostSplitNavigator'; import isSearchTopmostFullScreenRoute from './isSearchTopmostFullScreenRoute'; +import setNavigationActionToMicrotaskQueue from './setNavigationActionToMicrotaskQueue'; + +type BuildTransactionThreadParams = { + /** Current user's email. */ + currentUserLogin: string; + + /** Current user's account ID. */ + currentUserAccountID: number; + + /** Enabled betas. */ + betas: Beta[] | undefined; + + /** The onboarding intro selection. */ + introSelected: IntroSelected | undefined; +}; type NavigateAfterExpenseCreateParams = { + /** Report to open when navigation stays on the Inbox tab. */ activeReportID?: string; + + /** IOU report the transaction landed in, used to resolve the growl's "View" deep link. */ + iouReportID?: string; + + /** The created transaction's ID. */ transactionID?: string; + + /** Transaction thread report the growl's "View" action opens. */ + transactionThreadReportID?: string; + + /** Whether the expense was created from the global create (FAB). */ isFromGlobalCreate?: boolean; + + /** Whether the expense is an invoice. */ isInvoice?: boolean; + + /** Whether the target report already holds other transactions. */ hasMultipleTransactions: boolean; + + /** Mark the new transaction for the in-report row highlight. */ shouldAddPendingNewTransactionIDs?: boolean; - shouldNavigate?: boolean; + + /** Show the "Expense added" growl even on the non-global-create path (e.g. the Share flow, whose drafts never set isFromGlobalCreate). */ + shouldAlwaysShowFeedback?: boolean; + + /** Data the "View" action needs to build the transaction thread. */ + buildTransactionThreadParams?: BuildTransactionThreadParams; +}; + +type ShowExpenseAddedGrowlParams = { + /** IOU report the transaction landed in, used to resolve the growl's "View" deep link. */ + iouReportID?: string; + + /** The created transaction's ID. */ + transactionID?: string; + + /** Transaction thread report the growl's "View" action opens. */ + transactionThreadReportID?: string; + + /** Whether this confirmation is for an invoice (changes the toast copy from "Expense added"). */ + isInvoice?: boolean; + + /** Data the "View" action needs to build the transaction thread when no thread ID is known yet. */ + buildTransactionThreadParams?: BuildTransactionThreadParams; }; -function getNavigateAfterCreateSearchNavigatorState() { - const rootState = navigationRef.getRootState(); - const tabNavigatorRoute = rootState?.routes?.findLast((route) => route.name === NAVIGATORS.TAB_NAVIGATOR); - const tabState = tabNavigatorRoute?.state ?? (tabNavigatorRoute?.key ? getPreservedNavigatorState(tabNavigatorRoute.key) : undefined); - const searchNavigatorRoute = tabState?.routes?.findLast((route) => route.name === NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR); - return searchNavigatorRoute?.state ?? (searchNavigatorRoute?.key ? getPreservedNavigatorState(searchNavigatorRoute.key) : undefined); +type NavigateToCreatedExpenseParams = { + /** The transaction thread report to open. */ + threadReportID: string; + + /** The created transaction's ID. */ + transactionID: string; + + /** IOU report the transaction landed in, used to decide whether to stack the expense report underneath. */ + iouReportID?: string; +}; + +/** + * Navigates to a just-created expense, choosing the destination from the surface the user + * is currently looking at (they may have switched tabs while the growl was up) + * - Spend tab: the transaction thread RHP within Spend (report shown underneath via the wide RHP) + * - Inbox tab, narrow layout: the transaction thread as a full report view + * - Inbox tab, wide layout, with an expense report: the expense report - a multi-transaction report opens super + * wide with the specific thread RHP stacked on top; a single-transaction report collapses to the thread itself + * - Inbox tab, wide layout, tracked/unreported expense (no expense report): the transaction thread RHP directly + */ +function navigateToCreatedExpense({threadReportID, transactionID, iouReportID}: NavigateToCreatedExpenseParams) { + const backTo = Navigation.getActiveRoute(); + 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()); + + if (!openOnInbox) { + setActiveTransactionIDs([transactionID]).then(() => { + Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: threadReportID, backTo}), {forceReplace}); + }); + return; + } + + if (getIsNarrowLayout()) { + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(threadReportID, undefined, undefined, backTo), {forceReplace}); + return; + } + if (iouReportID) { + Navigation.navigate(ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: iouReportID, backTo}), {forceReplace}); + + // A multi-transaction report opens super wide (see SearchMoneyRequestReportPage's `shouldShowSuperWideRHP`), + // so stack the specific thread RHP on top of it. A single-transaction report collapses to the thread + // itself, so the expense report navigation above already lands on it. + const hasMultipleReportTransactions = + getReportTransactions(iouReportID).filter((transaction) => transaction?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE).length > 1; + if (hasMultipleReportTransactions) { + // Defer so the thread RHP stacks on top of the expense report navigation above. This is always a + // push (never a replace) — it stacks on the report we just opened, not on the previously-open one. + setNavigationActionToMicrotaskQueue(() => { + setActiveTransactionIDs([transactionID]).then(() => { + Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: threadReportID, backTo: Navigation.getActiveRoute()})); + }); + }); + } + return; + } + + // Tracked/unreported expense: there's no expense report to open, so go straight to the transaction thread. + setActiveTransactionIDs([transactionID]).then(() => { + Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: threadReportID, backTo}), {forceReplace}); + }); +} + +/** + * Shows the "Expense added" growl with a "View" action that deep-links to the new expense's RHP. + * + * The IOU action's optimistic data is typically not yet in the Onyx cache when this runs — the + * `API.write` is deferred (deferred-for-search pattern) until Search's content layout flushes the + * channel. `runAfterDeferredWrite` shows the growl right after that write applies (its channel's own + * safety timeout guarantees it fires). If no write is pending, the growl shows immediately. + * + * The transaction thread itself is only materialized (context merge, or optimistic creation with + * its OpenReport write) when the user actually presses "View" — matching how every other thread + * navigation entry point builds the thread at navigation time, and keeping untapped growls free + * of Onyx/API side effects. + * + * Callers must only invoke this for expenses that resolve to a single transaction thread (a provided + * thread ID, an IOU report to build one from, or the created transaction). Splits fan out into one + * thread per participant, so they skip the growl at the call site rather than showing a dead "View". + */ +function showExpenseAddedGrowl({ + iouReportID, + transactionID, + transactionThreadReportID: providedTransactionThreadReportID, + isInvoice, + buildTransactionThreadParams, +}: ShowExpenseAddedGrowlParams) { + if (!transactionID) { + return; + } + + // eslint-disable-next-line @typescript-eslint/no-deprecated -- imperative module (not a React component); no useLocalize hook available here + const growlMessage = isInvoice ? translateLocal('iou.invoiceSent') : translateLocal('iou.expenseAdded'); + + // Reads the freshest IOU report and its IOU action - the server-created transaction thread's + // childReportID lands on the IOU action only after creation, so a value captured at growl-show time + // would be stale and would fabricate a duplicate optimistic thread. + const resolveIOUReportAndAction = async (): Promise<{iouReport: OnyxEntry; iouAction: OnyxEntry}> => { + if (!iouReportID) { + return {iouReport: undefined, iouAction: undefined}; + } + const [iouReport, iouReportActions] = await Promise.all([ + new Promise>((resolve) => { + const connection = Onyx.connectWithoutView({ + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`, + callback: (value) => { + Onyx.disconnect(connection); + resolve(value); + }, + }); + }), + new Promise>((resolve) => { + const connection = Onyx.connectWithoutView({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, + callback: (value) => { + Onyx.disconnect(connection); + resolve(value); + }, + }); + }), + ]); + const iouAction = getIOUActionForTransactionID(Object.values(iouReportActions ?? {}), transactionID); + return {iouReport, iouAction}; + }; + + // Resolve the created transaction fresh from Onyx by its ID rather than capturing an object at + // feedback-creation time. + const resolveTransaction = (): Promise> => + new Promise>((resolve) => { + const connection = Onyx.connectWithoutView({ + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + callback: (value) => { + Onyx.disconnect(connection); + resolve(value); + }, + }); + }); + + const buildThreadFromOnyx = async (): Promise => { + const {iouReport, iouAction} = await resolveIOUReportAndAction(); + const threadReportID = providedTransactionThreadReportID ?? iouAction?.childReportID; + if (threadReportID) { + setOptimisticTransactionThread(threadReportID, iouReport?.reportID, iouAction?.reportActionID, iouReport?.policyID); + return threadReportID; + } + const transaction = await resolveTransaction(); + const optimisticThread = createTransactionThreadReport({ + introSelected: buildTransactionThreadParams?.introSelected, + currentUserLogin: buildTransactionThreadParams?.currentUserLogin ?? '', + currentUserAccountID: buildTransactionThreadParams?.currentUserAccountID ?? CONST.DEFAULT_NUMBER_ID, + betas: buildTransactionThreadParams?.betas, + iouReport, + iouReportAction: iouAction, + transaction, + }); + return optimisticThread?.reportID; + }; + + const showGrowl = () => { + const navigateToExpenseRHP = () => { + // Everything resolves at press time (not growl-show time): the thread is only materialized + // if the user actually taps "View" (with the freshest data available by then), and navigation + // matches whatever surface the user is looking at, even if they switched tabs while the + // growl was up. + buildThreadFromOnyx().then((threadReportID) => { + if (!threadReportID) { + Log.warn('[showExpenseAddedGrowl] Unable to resolve transaction thread reportID on View press.'); + return; + } + navigateToCreatedExpense({threadReportID, transactionID, iouReportID}); + }); + }; + // eslint-disable-next-line @typescript-eslint/no-deprecated -- imperative module (not a React component); no useLocalize hook available here + Growl.success(growlMessage, CONST.GROWL.DURATION_WITH_ACTION, {label: translateLocal('common.view'), onPress: navigateToExpenseRHP}); + }; + + // The optimistic IOU action may still be sitting inside a deferred write (the deferred-for-search + // pattern), in which case a provided thread ID isn't enough yet - its report data is in that same + // write, so "View" would open a broken RHP. Show the growl once the pending write has applied, + // if nothing is pending, this runs synchronously. + runAfterDeferredWrite(showGrowl); +} + +type SurfaceExpenseCreatedFeedbackParams = { + /** IOU report the transaction landed in, used to resolve the growl's "View" deep link. */ + iouReportID?: string; + + /** The created transaction. */ + transactionID?: string; + + /** Transaction thread report the growl's "View" action opens. */ + transactionThreadReportID?: string; + + /** + * Whether the expense was added from within its own expense report (i.e. the report table is the + * surface in front of the user). When true we highlight the new row instead of showing a growl. + */ + isMoneyRequestReport?: boolean; + + /** Whether the expense is an invoice (changes the growl copy). */ + isInvoice?: boolean; + + /** Data the "View" action needs to build the transaction thread. */ + buildTransactionThreadParams?: BuildTransactionThreadParams; +}; + +/** + * Surfaces post-create feedback when navigation is owned elsewhere (the dismiss-first orchestrator) or + * isn't needed. Single decision point shared by every expense type: + * - if the user is looking at the expense report's table (in-report add) → highlight the new row; + * - otherwise → show the "Expense added" growl with a "View" deep link. + */ +function surfaceExpenseCreatedFeedback({ + iouReportID, + transactionID, + transactionThreadReportID, + isMoneyRequestReport, + isInvoice, + buildTransactionThreadParams, +}: SurfaceExpenseCreatedFeedbackParams) { + if (isMoneyRequestReport && iouReportID && transactionID) { + addPendingNewTransactionIDs(iouReportID, transactionID); + return; + } + showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadReportID, isInvoice, buildTransactionThreadParams}); } /** @@ -42,63 +332,97 @@ function getNavigateAfterCreateSearchNavigatorState() { */ function navigateAfterExpenseCreate({ activeReportID, + + iouReportID, transactionID, + transactionThreadReportID: providedTransactionThreadReportID, + isFromGlobalCreate, + isInvoice, + hasMultipleTransactions, shouldAddPendingNewTransactionIDs = false, - shouldNavigate = true, + shouldAlwaysShowFeedback = false, + buildTransactionThreadParams, }: NavigateAfterExpenseCreateParams) { const isUserOnInbox = isReportTopmostSplitNavigator(); + const isUserOnSpend = isSearchTopmostFullScreenRoute(); - // If the expense is not created from global create or is currently on the inbox tab, - // we just need to dismiss the money request flow screens - // and open the report chat containing the IOU report - if (!isFromGlobalCreate || isUserOnInbox || !transactionID) { - if (shouldNavigate) { - dismissModalAndOpenReportInInboxTab(activeReportID, isInvoice, hasMultipleTransactions); - } + // If the expense is not created from global create or there is no transaction to link, + // we just need to dismiss the money request flow screens and open the report chat + // containing the IOU report. No growl is shown in this case (landing in the report is + // the feedback), unless the caller opts in via shouldAlwaysShowFeedback. + if (!isFromGlobalCreate || !transactionID) { + dismissModalAndOpenReportInInboxTab(activeReportID, isInvoice, hasMultipleTransactions); if (shouldAddPendingNewTransactionIDs) { addPendingNewTransactionIDs(activeReportID, transactionID); } + if (shouldAlwaysShowFeedback && transactionID) { + showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadReportID: providedTransactionThreadReportID, isInvoice, buildTransactionThreadParams}); + } return; } - if (!shouldNavigate) { + // From global create on the Inbox tab: stay on Inbox (dismiss the flow and open the report + // chat containing the IOU report) and show the "Expense added" growl on top of it. We don't + // redirect to Spend here - the growl just shows. + if (isUserOnInbox) { + dismissModalAndOpenReportInInboxTab(activeReportID, isInvoice, hasMultipleTransactions); + if (shouldAddPendingNewTransactionIDs) { + addPendingNewTransactionIDs(activeReportID, transactionID); + } + showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadReportID: providedTransactionThreadReportID, isInvoice, buildTransactionThreadParams}); return; } const type = isInvoice ? CONST.SEARCH.DATA_TYPES.INVOICE : CONST.SEARCH.DATA_TYPES.EXPENSE; - // When already on Search ROOT with the same type (expense vs invoice), we navigate to the same screen (no-op or refresh); record as dismiss_modal_only. - // When on another Search sub-tab (e.g. Chats), or on Search with a different type (e.g. on Invoice, submitting expense), record as navigate_to_search. - const searchNavigatorState = getNavigateAfterCreateSearchNavigatorState(); + // Navigate to Search (if not already there), then show the "View" growl on + // Search once the optimistic IOU action lands in Onyx. Runs whether or not the user is + // already on Spend — on the SEARCH_PRE_INSERT fast path the orchestrator pre-inserts Search + // and dismisses the modal before createTransaction runs, so by the time we get here the user + // is already on Search (isUserOnSpend=true). The growl must still fire in that case. + // + // Why this works without an explicit deferred-write flush: Search's content onLayout callback + // calls flushDeferredWrite('search') as soon as the actual list (not the skeleton) renders. + // That fires API.write, which applies optimistic data → our reportActions subscription fires + // → we have a valid iouAction to build the thread from → show growl with working "View" link. + const queryString = buildCannedSearchQuery({type}); + // SEARCH_FULLSCREEN_NAVIGATOR is nested inside TAB_NAVIGATOR, not at the root, and its `state` + // can be undefined when the split navigator isn't actively rendered - so we resolve it via the + // same TAB_NAVIGATOR traversal isSearchTopmostFullScreenRoute uses, then fall back to the + // preserved navigator state for the search navigator's own inner routes. + const searchNavigatorRoute = isUserOnSpend ? getTopmostFullScreenRoute() : undefined; + const searchNavigatorState = searchNavigatorRoute?.state ?? (searchNavigatorRoute?.key ? getPreservedNavigatorState(searchNavigatorRoute.key) : undefined); const lastSearchRoute = searchNavigatorState?.routes?.at(-1); - const isSearchTopmost = isSearchTopmostFullScreenRoute(); - const alreadyOnSearchRoot = isSearchTopmost && lastSearchRoute?.name === SCREENS.SEARCH.ROOT; + const alreadyOnSearchRoot = isUserOnSpend && lastSearchRoute?.name === SCREENS.SEARCH.ROOT; const currentSearchQueryJSON = alreadyOnSearchRoot ? getCurrentSearchQueryJSON() : undefined; const isSameSearchType = currentSearchQueryJSON?.type === type; - const followUpAction = alreadyOnSearchRoot && isSameSearchType ? CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.DISMISS_MODAL_ONLY : CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.NAVIGATE_TO_SEARCH; - setPendingSubmitFollowUpAction(followUpAction); - const queryString = buildCannedSearchQuery({type}); + setPendingSubmitFollowUpAction( + alreadyOnSearchRoot && isSameSearchType ? CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.DISMISS_MODAL_ONLY : CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.NAVIGATE_TO_SEARCH, + ); + const navigateToSearch = () => { - // On the fast path, onConfirm already cleared the flag and dismissed the modal, - // so this branch is only reached on the slow path (user submitted before the - // 300ms pre-insert timer fired). - if (getIsNarrowLayout() && Navigation.getIsFullscreenPreInsertedUnderRHP()) { + const isNarrow = getIsNarrowLayout(); + const isFullscreenPreInserted = Navigation.getIsFullscreenPreInsertedUnderRHP(); + if (isNarrow && isFullscreenPreInserted) { Navigation.clearFullscreenPreInsertedFlag(); Navigation.dismissModal(); - } else if (getIsNarrowLayout()) { + return; + } + if (isNarrow) { const isRHPStillOnTop = navigationRef.getRootState()?.routes?.at(-1)?.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR; if (!alreadyOnSearchRoot || !isSameSearchType || isRHPStillOnTop) { Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: queryString}), {forceReplace: true}); - } else { - Log.info('[IOU] navigateToSearch: already on matching Search root with RHP dismissed - no-op'); } - } else { - Navigation.revealRouteBeforeDismissingModal(ROUTES.SEARCH_ROOT.getRoute({query: queryString})); + return; } + if (alreadyOnSearchRoot && isSameSearchType) { + return; + } + Navigation.revealRouteBeforeDismissingModal(ROUTES.SEARCH_ROOT.getRoute({query: queryString})); }; if (navigationRef.isReady()) { @@ -106,6 +430,10 @@ function navigateAfterExpenseCreate({ } else { Navigation.isNavigationReady().then(navigateToSearch); } + + showExpenseAddedGrowl({iouReportID, transactionID, transactionThreadReportID: providedTransactionThreadReportID, isInvoice, buildTransactionThreadParams}); } export default navigateAfterExpenseCreate; +export {surfaceExpenseCreatedFeedback, navigateToCreatedExpense}; +export type {BuildTransactionThreadParams}; diff --git a/src/libs/actions/IOU/Duplicate.ts b/src/libs/actions/IOU/Duplicate.ts index 16ec5d43afc5..cc6956b50038 100644 --- a/src/libs/actions/IOU/Duplicate.ts +++ b/src/libs/actions/IOU/Duplicate.ts @@ -7,6 +7,7 @@ import {WRITE_COMMANDS} from '@libs/API/types'; import DateUtils from '@libs/DateUtils'; import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; import {getExistingTransactionID} from '@libs/IOUUtils'; +import {surfaceExpenseCreatedFeedback} from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; import * as NumberUtils from '@libs/NumberUtils'; import Parser from '@libs/Parser'; import {isInstantSubmitEnabled, isPolicyAccessible, isSubmitAndClose} from '@libs/PolicyUtils'; @@ -1144,6 +1145,8 @@ function bulkDuplicateExpenses({ let currentTargetReport = targetReport; let optimisticIOUReport: OnyxEntry; + let lastDuplicate: {iouReportID?: string; transactionID?: string} | undefined; + // When instant-submit + submit-and-close is active AND the duplicated // expenses are all non-reimbursable (or the policy disables reimbursement // entirely), canAddTransaction will reject the optimistic report after @@ -1223,12 +1226,20 @@ function bulkDuplicateExpenses({ optimisticIOUReport = result.iouReport; } + if (result?.transactionID) { + lastDuplicate = {iouReportID: result.iouReport?.reportID, transactionID: result.transactionID}; + } + if (currentTargetReport && !currentTargetReport.iouReportID) { currentTargetReport = {...currentTargetReport, iouReportID: currentOptimisticIOUReportID}; } } playSound(SOUNDS.DONE); + + if (lastDuplicate?.transactionID) { + surfaceExpenseCreatedFeedback(lastDuplicate); + } } type BulkDuplicateReportsParams = { diff --git a/src/libs/actions/IOU/MoneyRequest.ts b/src/libs/actions/IOU/MoneyRequest.ts index f5e26f4eb45e..7d4c27b93a1a 100644 --- a/src/libs/actions/IOU/MoneyRequest.ts +++ b/src/libs/actions/IOU/MoneyRequest.ts @@ -133,6 +133,7 @@ function createTransaction({ }: CreateTransactionParams) { const draftTransactionIDs = Object.keys(allTransactionDrafts ?? {}); + let lastResult: {iouReport?: OnyxEntry; transactionID?: string; transactionThreadReportID?: string} | undefined; for (const [index, receiptFile] of files.entries()) { const transaction = transactions.find((item) => item.transactionID === receiptFile.transactionID); const receipt: Receipt = receiptFile.file ?? {}; @@ -152,7 +153,7 @@ function createTransaction({ iouType, }); if (iouType === CONST.IOU.TYPE.TRACK && report) { - trackExpense({ + lastResult = trackExpense({ report, isDraftPolicy: false, existingTransaction: transaction, @@ -195,7 +196,7 @@ function createTransaction({ const existingTransactionID = getExistingTransactionID(transaction?.linkedTrackedExpenseReportAction); const existingTransactionDraft = existingTransactionID ? allTransactionDrafts?.[existingTransactionID] : undefined; - requestMoney({ + lastResult = requestMoney({ report, betas, participantParams: { @@ -237,6 +238,8 @@ function createTransaction({ }); } } + + return lastResult; } function getMoneyRequestParticipantOptions( diff --git a/src/libs/actions/IOU/NavigationHelpers.ts b/src/libs/actions/IOU/NavigationHelpers.ts index dbbbd2f241cb..71e595fe93f3 100644 --- a/src/libs/actions/IOU/NavigationHelpers.ts +++ b/src/libs/actions/IOU/NavigationHelpers.ts @@ -1,10 +1,6 @@ import sharedDismissModalAndOpenReportInInboxTab from '@libs/Navigation/helpers/dismissModalAndOpenReportInInboxTab'; -import isReportTopmostSplitNavigator from '@libs/Navigation/helpers/isReportTopmostSplitNavigator'; import navigateAfterExpenseCreate from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; - -import {mergeTransactionIdsHighlightOnSearchRoute} from '@userActions/Transaction'; - -import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; +import type {BuildTransactionThreadParams} from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; import {getAllTransactions} from './index'; @@ -19,17 +15,6 @@ function dismissModalAndOpenReportInInboxTab(reportID?: string, isInvoice?: bool sharedDismissModalAndOpenReportInInboxTab(reportID, isInvoice, hasMultipleTransactions); } -/** - * 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. @@ -39,21 +24,35 @@ function highlightTransactionOnSearchRouteIfNeeded(isFromGlobalCreate: boolean | */ function handleNavigateAfterExpenseCreate({ activeReportID, + iouReportID, transactionID, + transactionThreadReportID, isFromGlobalCreate, isInvoice, shouldAddPendingNewTransactionIDs = false, - shouldNavigate = true, + buildTransactionThreadParams, }: { activeReportID?: string; + iouReportID?: string; transactionID?: string; + transactionThreadReportID?: string; isFromGlobalCreate?: boolean; isInvoice?: boolean; shouldAddPendingNewTransactionIDs?: boolean; - shouldNavigate?: boolean; + buildTransactionThreadParams?: BuildTransactionThreadParams; }) { const hasMultipleTransactions = Object.values(getAllTransactions()).filter((transaction) => transaction?.reportID === activeReportID).length > 0; - navigateAfterExpenseCreate({activeReportID, transactionID, isFromGlobalCreate, isInvoice, hasMultipleTransactions, shouldAddPendingNewTransactionIDs, shouldNavigate}); + navigateAfterExpenseCreate({ + activeReportID, + iouReportID, + transactionID, + transactionThreadReportID, + isFromGlobalCreate, + isInvoice, + hasMultipleTransactions, + shouldAddPendingNewTransactionIDs, + buildTransactionThreadParams, + }); } -export {dismissModalAndOpenReportInInboxTab, handleNavigateAfterExpenseCreate, highlightTransactionOnSearchRouteIfNeeded}; +export {dismissModalAndOpenReportInInboxTab, handleNavigateAfterExpenseCreate}; diff --git a/src/libs/actions/IOU/PerDiem.ts b/src/libs/actions/IOU/PerDiem.ts index 9292a1f7c02b..4303bb182eef 100644 --- a/src/libs/actions/IOU/PerDiem.ts +++ b/src/libs/actions/IOU/PerDiem.ts @@ -67,7 +67,6 @@ import { mergePolicyRecentlyUsedCategories, mergePolicyRecentlyUsedCurrencies, } from './MoneyRequestBuilder'; -import {highlightTransactionOnSearchRouteIfNeeded} from './NavigationHelpers'; function removeSubrate(transaction: OnyxEntry, currentIndex: string) { // Index comes from the route params and is a string @@ -933,7 +932,7 @@ function submitPerDiemExpense(submitPerDiemExpenseInformation: PerDiemExpenseInf delegateAccountID, isTrackIntentUser, } = submitPerDiemExpenseInformation; - const {currency, comment = '', category, tag, created, customUnit, attendees, isFromGlobalCreate} = transactionParams; + const {currency, comment = '', category, tag, created, customUnit, attendees} = transactionParams; if ( isEmptyObject(policyParams.policy) || @@ -1041,13 +1040,11 @@ function submitPerDiemExpense(submitPerDiemExpenseInformation: PerDiemExpenseInf TransitionTracker.runAfterTransitions({callback: () => removeDraftTransaction(CONST.IOU.OPTIMISTIC_TRANSACTION_ID), waitForUpcomingTransition: true}); - highlightTransactionOnSearchRouteIfNeeded(isFromGlobalCreate, transaction.transactionID, CONST.SEARCH.DATA_TYPES.EXPENSE); - if (activeReportID) { notifyNewAction(activeReportID, undefined, participantParams.payeeAccountID === currentUserAccountIDParam); } - return {iouReport, transactionID: transaction.transactionID}; + return {iouReport, transactionID: transaction.transactionID, transactionThreadReportID}; } /** @@ -1127,6 +1124,8 @@ function submitPerDiemExpenseForSelfDM(submitPerDiemExpenseInformation: PerDiemE TransitionTracker.runAfterTransitions({callback: () => removeDraftTransaction(CONST.IOU.OPTIMISTIC_TRANSACTION_ID), waitForUpcomingTransition: true}); notifyNewAction(chatReport.reportID, undefined, true); + + return {transactionID: transaction.transactionID, transactionThreadReportID}; } export { diff --git a/src/libs/actions/IOU/SendInvoice.ts b/src/libs/actions/IOU/SendInvoice.ts index b78e32a89051..7066fed6bf3b 100644 --- a/src/libs/actions/IOU/SendInvoice.ts +++ b/src/libs/actions/IOU/SendInvoice.ts @@ -40,7 +40,6 @@ import type BasePolicyParams from './types/BasePolicyParams'; import {getAllPersonalDetails} from '.'; import {getReceiptError, mergePolicyRecentlyUsedCategories, mergePolicyRecentlyUsedCurrencies} from './MoneyRequestBuilder'; -import {highlightTransactionOnSearchRouteIfNeeded} from './NavigationHelpers'; import {getSearchOnyxUpdate} from './SearchUpdate'; type SendInvoiceInformation = { @@ -83,7 +82,6 @@ type SendInvoiceOptions = { companyWebsite?: string; policyRecentlyUsedCategories?: OnyxEntry; policyRecentlyUsedTags?: OnyxEntry; - isFromGlobalCreate?: boolean; senderPolicyTags: OnyxEntry; // TODO: delegateAccountID will be made required in PR 12 when all callers pass the value (https://github.com/Expensify/App/issues/66425) delegateAccountID?: number | undefined; @@ -737,7 +735,6 @@ function sendInvoice({ companyWebsite, policyRecentlyUsedCategories, policyRecentlyUsedTags, - isFromGlobalCreate = false, senderPolicyTags, delegateAccountID, }: SendInvoiceOptions) { @@ -813,9 +810,9 @@ function sendInvoice({ onDeferred: () => addOptimization(CONST.TELEMETRY.SUBMIT_OPTIMIZATION.DEFERRED_WRITE), }); - highlightTransactionOnSearchRouteIfNeeded(isFromGlobalCreate, transactionID, CONST.SEARCH.DATA_TYPES.INVOICE); - notifyNewAction(invoiceRoom.reportID, undefined, true); + + return {invoiceReportID, transactionID, transactionThreadReportID}; } export {getReceiverType, getSendInvoiceInformation, sendInvoice}; diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts index 697fa680bbc9..d9b125e34b0a 100644 --- a/src/libs/actions/IOU/Split.ts +++ b/src/libs/actions/IOU/Split.ts @@ -9,6 +9,8 @@ import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; import {calculateAmount as calculateIOUAmount, updateIOUOwnerAndTotal} from '@libs/IOUUtils'; import {formatPhoneNumber} from '@libs/LocalePhoneNumber'; import * as Localize from '@libs/Localize'; +import isReportTopmostSplitNavigator from '@libs/Navigation/helpers/isReportTopmostSplitNavigator'; +import {surfaceExpenseCreatedFeedback} from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; import Navigation from '@libs/Navigation/Navigation'; import TransitionTracker from '@libs/Navigation/TransitionTracker'; import {roundToTwoDecimalPlaces} from '@libs/NumberUtils'; @@ -30,7 +32,6 @@ import { getReimbursableTotal, getReportNotificationPreference, getReportOrDraftReport, - getReportTransactions, getTransactionDetails, getUnheldReimbursableTotal, hasViolations as hasViolationsReportUtils, @@ -87,8 +88,7 @@ import { mergePolicyRecentlyUsedCategories, mergePolicyRecentlyUsedCurrencies, } from './MoneyRequestBuilder'; -import {dismissModalAndOpenReportInInboxTab, highlightTransactionOnSearchRouteIfNeeded} from './NavigationHelpers'; -import {addPendingNewTransactionIDs, isOneToTwoTransactionTransition} from './PendingNewTransactions'; +import {dismissModalAndOpenReportInInboxTab, handleNavigateAfterExpenseCreate} from './NavigationHelpers'; type IOURequestType = ValueOf; @@ -129,12 +129,23 @@ type CreateDistanceRequestInformation = { existingTransaction?: OnyxEntry; transactionParams: DistanceRequestTransactionParams; policyParams?: BasePolicyParams; + + /** Report the flow started from, post-create navigation returns there instead of the written-to chat. */ + backToReport?: string; isASAPSubmitBetaEnabled: boolean; transactionViolations: OnyxCollection; quickAction: OnyxEntry; policyRecentlyUsedCurrencies: string[]; recentWaypoints: OnyxEntry; customUnitPolicyID?: string; + /** + * Whether the action owns the post-create flow: dismiss the money request screens, navigate to the + * destination and surface the "Expense added" feedback. Defaults to true. + */ + shouldHandleNavigation?: boolean; + + /** Defer the API.write until Search flushes it (the deferred-for-search pattern). */ + shouldDeferForSearch?: boolean; shouldPlaySound?: boolean; personalDetails: OnyxEntry; betas: OnyxEntry; @@ -1945,12 +1956,15 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest existingTransaction, transactionParams, policyParams = {}, + backToReport, isASAPSubmitBetaEnabled, transactionViolations, quickAction, policyRecentlyUsedCurrencies, recentWaypoints = [], customUnitPolicyID, + shouldHandleNavigation = true, + shouldDeferForSearch = false, shouldPlaySound: shouldPlaySoundParam = true, personalDetails, betas, @@ -2231,19 +2245,45 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest API.write(WRITE_COMMANDS.CREATE_DISTANCE_REQUEST, parameters, onyxData); }; - const activeReportID = isMoneyRequestReport && report?.reportID ? report.reportID : parameters.chatReportID; - - if (isOneToTwoTransactionTransition(isMoneyRequestReport, getReportTransactions(moneyRequestReportID))) { - addPendingNewTransactionIDs(activeReportID, parameters.transactionID); - } - deferOrExecuteWrite(apiWrite, { - shouldDeferForSearch: false, + shouldDeferForSearch: shouldDeferForSearch || !!(shouldHandleNavigation && isFromGlobalCreate && !isReportTopmostSplitNavigator()), optimisticWatchKey: `${ONYXKEYS.COLLECTION.TRANSACTION}${parameters.transactionID}`, onDeferred: () => addOptimization(CONST.TELEMETRY.SUBMIT_OPTIMIZATION.DEFERRED_WRITE), }); - highlightTransactionOnSearchRouteIfNeeded(isFromGlobalCreate, parameters.transactionID, CONST.SEARCH.DATA_TYPES.EXPENSE); + const activeReportID = isMoneyRequestReport && report?.reportID ? report.reportID : parameters.chatReportID; + + if (shouldHandleNavigation) { + TransitionTracker.runAfterTransitions({callback: () => removeDraftTransaction(CONST.IOU.OPTIMISTIC_TRANSACTION_ID), waitForUpcomingTransition: true}); + const navigationActiveReportID = backToReport ?? activeReportID; + handleNavigateAfterExpenseCreate({ + activeReportID: navigationActiveReportID, + + iouReportID: parameters.iouReportID, + isFromGlobalCreate, + + transactionID: parameters.transactionID, + transactionThreadReportID: parameters.transactionThreadReportID, + // Pending IDs are the fallback highlight when diff detection can't run (see useNewTransactions): + // the chat preview card fresh-mounts on a chat destination's first expense, and the report table + // fresh-mounts on the in-report 1→2 transition. + shouldAddPendingNewTransactionIDs: isMoneyRequestReport || navigationActiveReportID === parameters.chatReportID, + }); + } else { + // Dismiss-first paths (orchestrator owns navigation). Surface feedback wherever the user lands: + // highlight the new row for in-report adds, otherwise the "Expense added" growl with "View". + // Splits fan out into one transaction thread per participant, so there's no single thread for + // "View" to open - skip the growl for them entirely. + if (iouType !== CONST.IOU.TYPE.SPLIT) { + surfaceExpenseCreatedFeedback({ + iouReportID: parameters.iouReportID, + transactionID: parameters.transactionID, + transactionThreadReportID: parameters.transactionThreadReportID, + isMoneyRequestReport, + }); + } + removeDraftTransaction(CONST.IOU.OPTIMISTIC_TRANSACTION_ID); + } if (!isMoneyRequestReport) { notifyNewAction(activeReportID, undefined, true); diff --git a/src/libs/actions/IOU/SplitTransactionUpdate.ts b/src/libs/actions/IOU/SplitTransactionUpdate.ts index 5719edf8aa64..50683a4f9c78 100644 --- a/src/libs/actions/IOU/SplitTransactionUpdate.ts +++ b/src/libs/actions/IOU/SplitTransactionUpdate.ts @@ -8,6 +8,7 @@ import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; import {calculateAmount as calculateIOUAmount} from '@libs/IOUUtils'; import Log from '@libs/Log'; import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; +import {surfaceExpenseCreatedFeedback} from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; import popReportsSplitNavigatorToReport from '@libs/Navigation/helpers/popReportsSplitNavigatorToReport'; import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; import TransitionTracker from '@libs/Navigation/TransitionTracker'; @@ -1860,6 +1861,8 @@ function updateSplitTransactionsFromSplitExpensesFlow(params: UpdateSplitTransac const splitExpenses = params.transactionData?.splitExpenses ?? []; const originalTransactionID = params.transactionData?.originalTransactionID ?? CONST.IOU.OPTIMISTIC_TRANSACTION_ID; const allChildTransactions = getChildTransactions(params.allTransactionsList, originalTransactionID, false); + const existingChildTransactionIDs = new Set(allChildTransactions.map((tx) => tx?.transactionID).filter(Boolean)); + const lastNewSplitExpense = [...splitExpenses].reverse().find((splitExpense) => !!splitExpense.transactionID && !existingChildTransactionIDs.has(splitExpense.transactionID)); const originalChildTransactions = allChildTransactions.filter((tx) => tx?.reportID !== CONST.REPORT.UNREPORTED_REPORT_ID); const hasEditableSplitExpensesLeft = splitExpenses.some((expense) => (expense.statusNum ?? 0) < CONST.REPORT.STATUS_NUM.SUBMITTED); const isReverseSplitOperation = @@ -1909,6 +1912,9 @@ function updateSplitTransactionsFromSplitExpensesFlow(params: UpdateSplitTransac updateSplitTransactions({...params, isFromSplitExpensesFlow: true}); }); params?.searchContext?.clearSelectedTransactions?.(true); + if (lastNewSplitExpense?.transactionID) { + surfaceExpenseCreatedFeedback({iouReportID: lastNewSplitExpense.reportID, transactionID: lastNewSplitExpense.transactionID}); + } return; } @@ -1932,7 +1938,6 @@ function updateSplitTransactionsFromSplitExpensesFlow(params: UpdateSplitTransac // We skip existing transactions (already in allChildTransactions), reverse splits (no new transactions are created), // and the last-transaction case (the report navigates away before the highlight renders). if (params.expenseReport?.reportID && !isReverseSplitOperation && !isLastTransactionInReport) { - const existingChildTransactionIDs = new Set(allChildTransactions.map((tx) => tx?.transactionID).filter(Boolean)); for (const splitExpense of splitExpenses) { if (!splitExpense.transactionID || existingChildTransactionIDs.has(splitExpense.transactionID)) { continue; @@ -1946,6 +1951,10 @@ function updateSplitTransactionsFromSplitExpensesFlow(params: UpdateSplitTransac Navigation.navigateBackToLastSuperWideRHPScreen(); } + if (isSearchPageTopmostFullScreenRoute && lastNewSplitExpense?.transactionID) { + surfaceExpenseCreatedFeedback({iouReportID: lastNewSplitExpense.reportID, transactionID: lastNewSplitExpense.transactionID}); + } + // After the modal is dismissed, remove the transaction thread report screen // to avoid navigating back to a report removed by the split transaction. requestAnimationFrame(() => { diff --git a/src/libs/actions/IOU/TrackExpense.ts b/src/libs/actions/IOU/TrackExpense.ts index 1e37ab32823f..77a5d2704772 100644 --- a/src/libs/actions/IOU/TrackExpense.ts +++ b/src/libs/actions/IOU/TrackExpense.ts @@ -47,7 +47,6 @@ import { getParsedComment, getReportOrDraftReport, getReportRecipientAccountIDs, - getReportTransactions, isDraftReport, isHiddenForCurrentUser, isMoneyRequestReport as isMoneyRequestReportReportUtils, @@ -120,8 +119,6 @@ import type { import {deleteMoneyRequest, getCleanUpTransactionThreadReportOnyxData, getNavigationUrlOnMoneyRequestDelete} from './DeleteMoneyRequest'; import {getAllReports, getAllTransactionDrafts, getAllTransactions, getAllTransactionViolations} from './index'; import {buildMinimalTransactionForFormula, getMoneyRequestInformation, getReceiptError, getReportPreviewAction, getTransactionWithPreservedLocalReceiptSource} from './MoneyRequestBuilder'; -import {highlightTransactionOnSearchRouteIfNeeded} from './NavigationHelpers'; -import {addPendingNewTransactionIDs, isOneToTwoTransactionTransition} from './PendingNewTransactions'; import {getSearchOnyxUpdate} from './SearchUpdate'; type TrackExpenseInformation = { @@ -1621,7 +1618,7 @@ function convertTrackedExpenseToRequest(convertTrackedExpenseParams: ConvertTrac /** * Submit expense to another user */ -function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouReport?: OnyxTypes.Report} { +function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouReport?: OnyxTypes.Report; transactionID?: string; transactionThreadReportID?: string} { const { report, existingIOUReport, @@ -1680,7 +1677,6 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep count, rate, unit, - isFromGlobalCreate = false, } = transactionParams; const testDriveCommentReportActionID = isTestDrive ? NumberUtils.rand64() : undefined; @@ -1904,10 +1900,6 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep } } - if (isOneToTwoTransactionTransition(isMoneyRequestReport, getReportTransactions(moneyRequestReportID))) { - addPendingNewTransactionIDs(activeReportID, transaction.transactionID); - } - if (deferredAPIWrite) { deferOrExecuteWrite(deferredAPIWrite, { shouldDeferForSearch: false, @@ -1917,10 +1909,6 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep }); } - if (!requestMoneyInformation.isRetry) { - highlightTransactionOnSearchRouteIfNeeded(isFromGlobalCreate, transaction.transactionID, CONST.SEARCH.DATA_TYPES.EXPENSE); - } - if (activeReportID && !isMoneyRequestReport) { Navigation.setNavigationActionToMicrotaskQueue(() => setTimeout(() => { @@ -1929,7 +1917,7 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep ); } - return {iouReport}; + return {iouReport, transactionID: transaction.transactionID, transactionThreadReportID: transactionThreadReportID ?? iouAction?.childReportID}; } /** @@ -2447,7 +2435,6 @@ function trackExpense(params: CreateTrackExpenseParams) { attendees, odometerStart, odometerEnd, - isFromGlobalCreate = false, gpsCoordinates, distanceRequestType, } = transactionData; @@ -2856,11 +2843,9 @@ function trackExpense(params: CreateTrackExpenseParams) { } } - if (!params.isRetry) { - highlightTransactionOnSearchRouteIfNeeded(isFromGlobalCreate, transaction?.transactionID, CONST.SEARCH.DATA_TYPES.EXPENSE); - } - notifyNewAction(activeReportID, undefined, payeeAccountID === currentUserAccountIDParam); + + return {iouReport, transactionID: transaction?.transactionID, transactionThreadReportID: transactionThreadReportID ?? iouAction?.childReportID}; } /** diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts index 39d9f173b32a..9ffc8ecd219d 100644 --- a/src/libs/actions/Transaction.ts +++ b/src/libs/actions/Transaction.ts @@ -80,7 +80,6 @@ import type { } from '@src/types/onyx'; import type {OriginalMessageIOU, OriginalMessageModifiedExpense} from '@src/types/onyx/OriginalMessage'; import type {OnyxData} from '@src/types/onyx/Request'; -import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; import type {Waypoint, WaypointCollection} from '@src/types/onyx/Transaction'; import type TransactionState from '@src/types/utils/TransactionStateType'; @@ -1934,10 +1933,6 @@ function getDefaultP2PMileageRate() { API.read(READ_COMMANDS.GET_DEFAULT_P2P_MILEAGE_RATE, null); } -function mergeTransactionIdsHighlightOnSearchRoute(type: SearchDataTypes, data: Record | null) { - return Onyx.merge(ONYXKEYS.TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE, {[type]: data}); -} - function getDuplicateTransactionDetails(transactionID?: string) { if (!transactionID) { return; @@ -1970,6 +1965,5 @@ export { changeTransactionsReport, setTransactionReport, getDefaultP2PMileageRate, - mergeTransactionIdsHighlightOnSearchRoute, getDuplicateTransactionDetails, }; diff --git a/src/libs/deferredLayoutWrite.ts b/src/libs/deferredLayoutWrite.ts index e648d600da1b..367c3395586b 100644 --- a/src/libs/deferredLayoutWrite.ts +++ b/src/libs/deferredLayoutWrite.ts @@ -56,6 +56,12 @@ type DeferredChannel = { * instead of creating a new deferred channel. */ flushRequested?: boolean; + + /** + * One-shot callbacks registered via `runAfterDeferredWrite`, run right after `write()` applies the + * optimistic data. Lets consumers sequence work that needs the data to exist. + */ + onFlush?: Array<() => void>; }; const channels = new Map(); @@ -140,6 +146,30 @@ function flushDeferredWrite(key: string) { clearChannelTimeout(channel); channels.delete(key); channel.write(); + if (channel.onFlush) { + for (const cb of channel.onFlush) { + cb(); + } + } +} + +/** + * Run `callback` once the pending optimistic write has been applied to Onyx, after the next flush of the + * most-recently-registered active channel or immediately if nothing is pending. + * Used to sequence work that depends on the optimistic data existing. + * + * IMPORTANT: only call this synchronously right after the deferred write it depends on was registered. + * It attaches to whatever the latest active channel is at call time, so any intervening async work (or an + * unrelated deferred write) can cause it to hook onto the wrong channel. + * This is an interim helper, not a general-purpose pattern. + */ +function runAfterDeferredWrite(callback: () => void) { + const channel = [...channels.values()].reverse().find((c) => !c.isReserved); + if (!channel) { + callback(); + return; + } + channel.onFlush = [...(channel.onFlush ?? []), callback]; } /** @@ -292,5 +322,6 @@ export { hasDeferredWriteForReport, getOptimisticWatchKey, deferOrExecuteWrite, + runAfterDeferredWrite, resetForTesting, }; diff --git a/src/pages/Share/SubmitDetailsPage.tsx b/src/pages/Share/SubmitDetailsPage.tsx index a3560c573308..372c54118215 100644 --- a/src/pages/Share/SubmitDetailsPage.tsx +++ b/src/pages/Share/SubmitDetailsPage.tsx @@ -261,6 +261,8 @@ function SubmitDetailsPage({ // `transaction.transactionID` is the draft placeholder; mirror the action's `existingTransactionID ?? rand64()` chain so cleanup nav targets the created expense. const optimisticTransactionID = existingTransactionID ?? rand64(); + let iouReportID: string | undefined; + let transactionThreadReportID: string | undefined; // This path skips createTransaction, so log the submit milestone here to map the draft id to the final one. logReceiptSubmitted({ @@ -272,7 +274,7 @@ function SubmitDetailsPage({ }); if (isSelfDM(report)) { - trackExpense({ + const trackExpenseResult = trackExpense({ report: report ?? {reportID: reportOrAccountID}, isDraftPolicy: false, isDraftChatReport: !!reportDraft, @@ -314,10 +316,12 @@ function SubmitDetailsPage({ delegateAccountID, reportActionsList: undefined, }); + iouReportID = trackExpenseResult?.iouReport?.reportID; + transactionThreadReportID = trackExpenseResult?.transactionThreadReportID; } else { const existingTransactionDraft = existingTransactionID ? transactionDrafts?.[existingTransactionID] : undefined; - requestMoney({ + const {iouReport} = requestMoney({ report: reportToSubmit, participantParams: {payeeEmail: currentUserPersonalDetails.login, payeeAccountID: currentUserPersonalDetails.accountID, participant}, policyParams: {policy, policyTagList: policyTagsForRequestMoney, policyCategories, policyRecentlyUsedCategories, policyRecentlyUsedTags}, @@ -360,15 +364,26 @@ function SubmitDetailsPage({ isTrackIntentUser, delegateAccountID, }); + iouReportID = iouReport?.reportID; } cleanupAndNavigateAfterExpenseCreate({ report: isSelfDM(report) ? report : reportToSubmit, action: CONST.IOU.ACTION.CREATE, draftTransactionIDs, transactionID: optimisticTransactionID, + iouReportID, + transactionThreadReportID, isFromGlobalCreate: getIsFromGlobalCreate(transaction), optimisticChatReportID: reportOrAccountID, linkedTrackedExpenseReportAction: transaction.linkedTrackedExpenseReportAction, + // Share drafts never set isFromGlobalCreate, but the user still deserves the "Expense added" growl. + shouldAlwaysShowFeedback: true, + buildTransactionThreadParams: { + currentUserLogin: currentUserPersonalDetails.login ?? '', + currentUserAccountID: currentUserPersonalDetails.accountID, + betas, + introSelected, + }, }); }; diff --git a/src/pages/iou/request/step/IOURequestStepCompanyInfo.tsx b/src/pages/iou/request/step/IOURequestStepCompanyInfo.tsx index 42763de1717e..339026b1fe23 100644 --- a/src/pages/iou/request/step/IOURequestStepCompanyInfo.tsx +++ b/src/pages/iou/request/step/IOURequestStepCompanyInfo.tsx @@ -105,7 +105,7 @@ function IOURequestStepCompanyInfo({route, report, transaction}: IOURequestStepC ); reserveSearchChannelIfGlobalCreate(!!isFromGlobalCreate); const invoiceChatReportID = report?.reportID ? undefined : reportID; - sendInvoice({ + const invoiceResult = sendInvoice({ currentUserAccountID: currentUserPersonalDetails.accountID, transaction, policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [], @@ -118,14 +118,15 @@ function IOURequestStepCompanyInfo({route, report, transaction}: IOURequestStepC companyWebsite, policyRecentlyUsedCategories, policyRecentlyUsedTags, - isFromGlobalCreate, senderPolicyTags: policyTags ?? {}, }); cleanupAndNavigateAfterExpenseCreate({ report: undefined, action: CONST.IOU.ACTION.CREATE, draftTransactionIDs, - transactionID: transaction?.transactionID, + transactionID: invoiceResult?.transactionID ?? transaction?.transactionID, + iouReportID: invoiceResult?.invoiceReportID, + transactionThreadReportID: invoiceResult?.transactionThreadReportID, isFromGlobalCreate, optimisticChatReportID: report?.reportID ?? reportID, isInvoice: true, diff --git a/src/pages/iou/request/step/IOURequestStepDistance.tsx b/src/pages/iou/request/step/IOURequestStepDistance.tsx index 71c08d365564..9ee1f008fdcf 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistance.tsx @@ -377,7 +377,6 @@ function IOURequestStepDistance({ const navigateToNextStep = useDistanceNavigation({ iouType, - action, report, policy, transaction, diff --git a/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts b/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts index 4244211a0834..fa8beac358f3 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts +++ b/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts @@ -10,20 +10,22 @@ import {createDistanceRequest, resetSplitShares} from '@libs/actions/IOU/Split'; import {trackExpense} from '@libs/actions/IOU/TrackExpense'; import {getCurrencySymbol} from '@libs/CurrencyUtils'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; -import {calculateDefaultReimbursable, getExistingTransactionID, navigateToConfirmationPage, navigateToParticipantPage} from '@libs/IOUUtils'; +import {calculateDefaultReimbursable, navigateToConfirmationPage, navigateToParticipantPage} from '@libs/IOUUtils'; import {toLocaleDigit} from '@libs/LocaleDigitUtils'; -import cleanupAfterSkipConfirmSubmit from '@libs/Navigation/helpers/cleanupAfterSkipConfirmSubmit'; +import cleanupAfterExpenseCreate from '@libs/Navigation/helpers/cleanupAfterExpenseCreate'; +import cleanupAndNavigateAfterExpenseCreate from '@libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate'; +import {surfaceExpenseCreatedFeedback} from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; import {submitWithDismissFirst} from '@libs/Navigation/helpers/submitWithDismissFirst'; import Navigation from '@libs/Navigation/Navigation'; import {roundToTwoDecimalPlaces} from '@libs/NumberUtils'; -import {getPolicyExpenseChat, isSelfDM} from '@libs/ReportUtils'; +import {getPolicyExpenseChat, isMoneyRequestReport, isSelfDM} from '@libs/ReportUtils'; import shouldUseDefaultExpensePolicy from '@libs/shouldUseDefaultExpensePolicy'; import {cancelSpan} from '@libs/telemetry/activeSpans'; import {getDefaultTaxCode, getDistanceRequestType, getIsFromGlobalCreate, getValidWaypoints} from '@libs/TransactionUtils'; import {setTransactionReport} from '@userActions/Transaction'; -import type {IOUAction, IOUType} from '@src/CONST'; +import type {IOUType} from '@src/CONST'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; import type {TranslationParameters, TranslationPaths} from '@src/languages/types'; @@ -100,7 +102,6 @@ type MoneyRequestStepDistanceNavigationParams = { optimisticTransactionID: string; optimisticChatReportID: string | undefined; reportDraft: OnyxEntry | undefined; - action: IOUAction; isTrackIntentUser: boolean | undefined; delegateAccountID: number | undefined; policyTagList: PolicyTagLists; @@ -197,7 +198,6 @@ function handleMoneyRequestStepDistanceNavigation({ optimisticTransactionID, optimisticChatReportID, reportDraft, - action, isTrackIntentUser, delegateAccountID, policyTagList, @@ -216,7 +216,6 @@ function handleMoneyRequestStepDistanceNavigation({ } const distance = manualDistance ?? gpsDistance ?? odometerDistance; - const transactionIsFromGlobalCreate = getIsFromGlobalCreate(transaction); const transactionLinkedTrackedExpenseReportAction = transaction?.linkedTrackedExpenseReportAction; // If a reportID exists in the report object, it's because either: @@ -276,9 +275,8 @@ function handleMoneyRequestStepDistanceNavigation({ if (isCreatingTrackExpense && participant) { submitWithDismissFirst({ - // trackExpense is a void action with no navigation params; submitWithDismissFirst owns dismiss/reveal and cleanup runs after. executeWrite: (overrides) => { - trackExpense({ + const result = trackExpense({ report, isDraftPolicy: false, existingTransaction: transaction, @@ -331,16 +329,39 @@ function handleMoneyRequestStepDistanceNavigation({ delegateAccountID, reportActionsList: undefined, }); - cleanupAfterSkipConfirmSubmit(overrides.shouldHandleNavigation, { - report, - action, - draftTransactionIDs, - transactionID: getExistingTransactionID(transactionLinkedTrackedExpenseReportAction) ?? optimisticTransactionID, - isFromGlobalCreate: transactionIsFromGlobalCreate, - backToReport, - optimisticChatReportID, - linkedTrackedExpenseReportAction: transactionLinkedTrackedExpenseReportAction, - }); + + const buildTransactionThreadParams = { + currentUserLogin: currentUserLogin ?? '', + currentUserAccountID, + betas, + introSelected, + }; + if (overrides.shouldHandleNavigation) { + cleanupAndNavigateAfterExpenseCreate({ + report: isMoneyRequestReport(report) ? report : undefined, + action: CONST.IOU.ACTION.CREATE, + draftTransactionIDs, + transactionID: result?.transactionID, + iouReportID: result?.iouReport?.reportID, + transactionThreadReportID: result?.transactionThreadReportID, + isFromGlobalCreate: getIsFromGlobalCreate(transaction), + optimisticChatReportID, + linkedTrackedExpenseReportAction: transactionLinkedTrackedExpenseReportAction, + buildTransactionThreadParams, + }); + } else { + cleanupAfterExpenseCreate({ + draftTransactionIDs, + linkedTrackedExpenseReportAction: transactionLinkedTrackedExpenseReportAction, + }); + surfaceExpenseCreatedFeedback({ + iouReportID: result?.iouReport?.reportID, + transactionID: result?.transactionID, + transactionThreadReportID: result?.transactionThreadReportID, + isMoneyRequestReport: isMoneyRequestReport(report), + buildTransactionThreadParams, + }); + } }, destinationReportID: report?.reportID ?? selfDMReport?.reportID, telemetryContext: { @@ -358,7 +379,7 @@ function handleMoneyRequestStepDistanceNavigation({ submitWithDismissFirst({ executeWrite: (overrides) => { - const {transactionID: writtenDistanceTransactionID} = createDistanceRequest({ + createDistanceRequest({ report, participants, currentUserLogin: currentUserLogin ?? '', @@ -391,6 +412,9 @@ function handleMoneyRequestStepDistanceNavigation({ taxCode: distanceTaxCode, taxAmount: distanceTaxAmount, }, + shouldHandleNavigation: overrides.shouldHandleNavigation, + shouldDeferForSearch: false, + backToReport, isASAPSubmitBetaEnabled, transactionViolations, quickAction, @@ -405,14 +429,8 @@ function handleMoneyRequestStepDistanceNavigation({ isTrackIntentUser, delegateAccountID, }); - cleanupAfterSkipConfirmSubmit(overrides.shouldHandleNavigation, { - report, - action, + cleanupAfterExpenseCreate({ draftTransactionIDs, - transactionID: writtenDistanceTransactionID, - isFromGlobalCreate: transactionIsFromGlobalCreate, - backToReport, - optimisticChatReportID, linkedTrackedExpenseReportAction: transactionLinkedTrackedExpenseReportAction, }); }, diff --git a/src/pages/iou/request/step/IOURequestStepDistance/hooks/useDistanceNavigation.ts b/src/pages/iou/request/step/IOURequestStepDistance/hooks/useDistanceNavigation.ts index b96ae0e3b4cd..563e87b32960 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance/hooks/useDistanceNavigation.ts +++ b/src/pages/iou/request/step/IOURequestStepDistance/hooks/useDistanceNavigation.ts @@ -9,7 +9,7 @@ import {generateReportID, isMoneyRequestReport as isMoneyRequestReportReportUtil import handleMoneyRequestStepDistanceNavigation from '@pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation'; -import type {IOUAction, IOUType} from '@src/CONST'; +import type {IOUType} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Route} from '@src/ROUTES'; import type {Beta, IntroSelected, PersonalDetailsList, Policy, RecentWaypoint, Report, Transaction} from '@src/types/onyx'; @@ -26,9 +26,6 @@ type UseDistanceNavigationParams = { /** Type of IOU flow (request, split, track, etc.). */ iouType: IOUType; - /** Route param: the IOU action (create / edit). */ - action: IOUAction; - /** The chat/expense report that owns this transaction. */ report: OnyxEntry; @@ -107,7 +104,6 @@ type UseDistanceNavigationParams = { function useDistanceNavigation({ iouType, - action, report, policy, transaction, @@ -156,7 +152,6 @@ function useDistanceNavigation({ handleMoneyRequestStepDistanceNavigation({ iouType, - action, report, policy, transaction, diff --git a/src/pages/iou/request/step/IOURequestStepDistance/hooks/useOdometerNavigation.ts b/src/pages/iou/request/step/IOURequestStepDistance/hooks/useOdometerNavigation.ts index 90bc2807c0f0..f62314b56fd1 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance/hooks/useOdometerNavigation.ts +++ b/src/pages/iou/request/step/IOURequestStepDistance/hooks/useOdometerNavigation.ts @@ -9,7 +9,7 @@ import {generateReportID, isMoneyRequestReport as isMoneyRequestReportReportUtil import handleMoneyRequestStepDistanceNavigation from '@pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation'; -import type {IOUAction, IOUType} from '@src/CONST'; +import type {IOUType} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Beta, IntroSelected, OdometerDraft, PersonalDetailsList, Policy, RecentWaypoint, Report, Transaction} from '@src/types/onyx'; import type {ReportAttributesDerivedValue} from '@src/types/onyx/DerivedValues'; @@ -24,9 +24,6 @@ type UseOdometerNavigationParams = { /** Type of IOU flow (request, split, track, etc.). */ iouType: IOUType; - /** Route param: the IOU action (create / edit). */ - action: IOUAction; - /** The chat/expense report that owns this transaction. */ report: OnyxEntry; @@ -113,7 +110,6 @@ type NavigateOptions = { function useOdometerNavigation({ iouType, - action, report, policy, transaction, @@ -160,7 +156,6 @@ function useOdometerNavigation({ handleMoneyRequestStepDistanceNavigation({ iouType, - action, report, policy, transaction, diff --git a/src/pages/iou/request/step/IOURequestStepDistanceGPS/index.native.tsx b/src/pages/iou/request/step/IOURequestStepDistanceGPS/index.native.tsx index 991198073ef6..72377ff9d23b 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceGPS/index.native.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceGPS/index.native.tsx @@ -130,7 +130,6 @@ function IOURequestStepDistanceGPS({ handleMoneyRequestStepDistanceNavigation({ iouType, - action, report, policy, transaction, diff --git a/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx b/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx index 1fc2ae19097f..ed37a664de6c 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx @@ -253,7 +253,6 @@ function IOURequestStepDistanceManual({ handleMoneyRequestStepDistanceNavigation({ iouType, - action, report, policy, transaction, diff --git a/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx b/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx index 61f22c109c77..1de7e5614806 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx @@ -204,7 +204,6 @@ function IOURequestStepDistanceMap({ const navigateToNextStep = useDistanceNavigation({ iouType, - action, report, policy, transaction, diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index 3002e5689eff..83c1e81d6653 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -219,7 +219,6 @@ function IOURequestStepDistanceOdometer({ const navigateToNextStep = useOdometerNavigation({ iouType, - action, report, policy, transaction, diff --git a/src/pages/iou/request/step/IOURequestStepScan/ScanRouter.tsx b/src/pages/iou/request/step/IOURequestStepScan/ScanRouter.tsx index 89125ae666dd..66c08f49ab0b 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/ScanRouter.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/ScanRouter.tsx @@ -33,7 +33,6 @@ type ScanRouterProps = { type NonGlobalCreateProps = { report: OnyxEntry; - action: IOUAction; iouType: IOUType; reportID: string; transactionID: string; @@ -49,7 +48,7 @@ const policyRequiresTagOrCategorySelector = (policy: OnyxEntry) => !!pol * Owns the policy + skip-confirmation subscriptions so the edit and global-create branches don't pay for them. * Decides between SkipConfirmation (quick action) and FromReport (report (+) entry). */ -function ScanNonGlobalCreate({report, action, iouType, reportID, transactionID, transaction, backToReport}: NonGlobalCreateProps) { +function ScanNonGlobalCreate({report, iouType, reportID, transactionID, transaction, backToReport}: NonGlobalCreateProps) { const [policyRequiresTagOrCategory] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`, {selector: policyRequiresTagOrCategorySelector}); const [skipConfirmation] = useOnyx(`${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${transactionID}`); const shouldSkipConfirmation = !!skipConfirmation && !!report?.reportID && !(isPolicyExpenseChat(report) && policyRequiresTagOrCategory); @@ -58,7 +57,6 @@ function ScanNonGlobalCreate({report, action, iouType, reportID, transactionID, return ( ; - action: IOUAction; iouType: IOUType; reportID: string; transactionID: string; @@ -74,7 +75,7 @@ type ScanSkipConfirmationProps = WithCurrentUserPersonalDetailsProps & { * Handles GPS permission flow, split bills, and direct submission. * Most complex variant with the most Onyx subscriptions. */ -function ScanSkipConfirmation({report, action, iouType, reportID, transactionID, transaction, backToReport, currentUserPersonalDetails}: ScanSkipConfirmationProps) { +function ScanSkipConfirmation({report, iouType, reportID, transactionID, transaction, backToReport, currentUserPersonalDetails}: ScanSkipConfirmationProps) { const policy = usePolicy(report?.policyID); const {policyForMovingExpenses} = usePolicyForMovingExpenses(); const isArchived = useReportIsArchived(report?.reportID); @@ -190,16 +191,14 @@ function ScanSkipConfirmation({report, action, iouType, reportID, transactionID, const submitDirectly = (files: ReceiptFile[], locationPermissionGranted: boolean) => { cancelShutterSpans(); - const {chatReportID, optimisticChatReportID} = resolveChatTargetForScan({ + const {optimisticChatReportID} = resolveChatTargetForScan({ iouType, participant, report, currentUserAccountID: currentUserPersonalDetails.accountID, }); const optimisticTransactionIDs = files.map(() => rand64()); - const lastOptimisticTransactionID = optimisticTransactionIDs.at(-1) ?? transactionID; const linkedTrackedExpenseReportAction = transaction?.linkedTrackedExpenseReportAction; - const isFromGlobalCreate = getIsFromGlobalCreate(transaction); const firstReceiptFile = files.at(0); @@ -236,14 +235,8 @@ function ScanSkipConfirmation({report, action, iouType, reportID, transactionID, shouldHandleNavigation: overrides.shouldHandleNavigation, shouldDeferForSearch: false, }); - cleanupAfterSkipConfirmSubmit(overrides.shouldHandleNavigation, { - report, - action, + cleanupAfterExpenseCreate({ draftTransactionIDs, - transactionID: getExistingTransactionID(linkedTrackedExpenseReportAction) ?? lastOptimisticTransactionID, - isFromGlobalCreate, - backToReport, - optimisticChatReportID: chatReportID, linkedTrackedExpenseReportAction, }); }, @@ -278,7 +271,6 @@ function ScanSkipConfirmation({report, action, iouType, reportID, transactionID, report, currentUserAccountID: currentUserPersonalDetails.accountID, currentUserEmail: currentUserPersonalDetails.login, - backToReport, shouldGenerateTransactionThreadReport: false, isASAPSubmitBetaEnabled, transactionViolations, @@ -306,42 +298,63 @@ function ScanSkipConfirmation({report, action, iouType, reportID, transactionID, const scanDestinationReportID = iouType === CONST.IOU.TYPE.TRACK ? (report?.reportID ?? selfDMReport?.reportID) : report?.reportID; submitWithDismissFirst({ executeWrite: (overrides) => { - // Cleanup runs after each write (not once up front) so a stalled GPS lookup can't clear the draft before the expense exists. - const runCleanup = () => - cleanupAfterSkipConfirmSubmit(overrides.shouldHandleNavigation, { - report, - action, - draftTransactionIDs, - transactionID: lastOptimisticTransactionID, - isFromGlobalCreate, - backToReport, - optimisticChatReportID: chatReportID, - linkedTrackedExpenseReportAction, - }); + // Runs after each write (not once up front) so a stalled GPS lookup can't clear the draft before the expense exists. + const finish = (result: {iouReport?: OnyxEntry; transactionID?: string; transactionThreadReportID?: string} | undefined) => { + const lastTransaction = transactions.at(-1); + const buildTransactionThreadParams = { + currentUserLogin: currentUserPersonalDetails.login ?? '', + currentUserAccountID: currentUserPersonalDetails.accountID, + betas, + introSelected, + }; + if (overrides.shouldHandleNavigation) { + cleanupAndNavigateAfterExpenseCreate({ + report: isMoneyRequestReportReportUtils(report) ? report : undefined, + action: CONST.IOU.ACTION.CREATE, + draftTransactionIDs, + transactionID: result?.transactionID, + iouReportID: result?.iouReport?.reportID, + transactionThreadReportID: result?.transactionThreadReportID, + isFromGlobalCreate: getIsFromGlobalCreate(lastTransaction), + backToReport, + optimisticChatReportID, + linkedTrackedExpenseReportAction, + buildTransactionThreadParams, + }); + } else { + cleanupAfterExpenseCreate({draftTransactionIDs, linkedTrackedExpenseReportAction}); + surfaceExpenseCreatedFeedback({ + iouReportID: result?.iouReport?.reportID, + transactionID: result?.transactionID, + transactionThreadReportID: result?.transactionThreadReportID, + isMoneyRequestReport: isMoneyRequestReportReportUtils(report), + buildTransactionThreadParams, + }); + } + }; if (locationPermissionGranted) { getCurrentPosition( (successData) => { - createTransaction({ - ...baseParams, - gpsPoint: { - lat: successData.coords.latitude, - long: successData.coords.longitude, - }, - }); - runCleanup(); + finish( + createTransaction({ + ...baseParams, + gpsPoint: { + lat: successData.coords.latitude, + long: successData.coords.longitude, + }, + }), + ); }, (errorData) => { Log.info('[ScanSkipConfirmation] getCurrentPosition failed', false, errorData); // When there is an error, the money can still be requested, it just won't include the GPS coordinates - createTransaction(baseParams); - runCleanup(); + finish(createTransaction(baseParams)); }, ); return; } - createTransaction(baseParams); - runCleanup(); + finish(createTransaction(baseParams)); }, destinationReportID: scanDestinationReportID, telemetryContext: { diff --git a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts index 4e40e78e941b..6444d8954031 100644 --- a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts +++ b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts @@ -24,7 +24,7 @@ import cleanupAfterExpenseCreate from '@libs/Navigation/helpers/cleanupAfterExpe import cleanupAndNavigateAfterExpenseCreate from '@libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate'; import dismissModalAndOpenReportInInboxTabHelper from '@libs/Navigation/helpers/dismissModalAndOpenReportInInboxTab'; import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; -import navigateAfterExpenseCreate from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; +import navigateAfterExpenseCreate, {surfaceExpenseCreatedFeedback} from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; import {rand64, roundToTwoDecimalPlaces} from '@libs/NumberUtils'; import {isTrackOnboardingChoice} from '@libs/OnboardingUtils'; import {isTaxTrackingEnabled} from '@libs/PolicyUtils'; @@ -318,6 +318,8 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { navigateBackToReport, lastOptimisticTransactionID, preResolvedChatTarget, + iouReportID, + transactionThreadReportID, }: { participant: Participant; shouldHandleNavigation: boolean; @@ -326,17 +328,33 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { navigateBackToReport: string | undefined; lastOptimisticTransactionID: string | undefined; preResolvedChatTarget?: {report: OnyxEntry; chatReportID: string}; + iouReportID?: string; + transactionThreadReportID?: string; }) { const lastTransaction = transactions.at(-1); // Action bailed mid-batch — keep drafts for retry. if (!allTransactionsCreated) { return; } + // Move-from-track (SUBMIT/CATEGORIZE/SHARE) reuses the tracked transaction's ID — mirror the builder's `existingTransactionID ?? optimisticTransactionID`. + const lastTransactionID = getExistingTransactionID(lastTransaction?.linkedTrackedExpenseReportAction) ?? lastOptimisticTransactionID; + const buildTransactionThreadParams = { + currentUserLogin: currentUserPersonalDetails.login ?? '', + currentUserAccountID: currentUserPersonalDetails.accountID, + betas, + introSelected, + }; if (!shouldHandleNavigation) { cleanupAfterExpenseCreate({draftTransactionIDs, linkedTrackedExpenseReportAction: lastTransaction?.linkedTrackedExpenseReportAction}); + surfaceExpenseCreatedFeedback({ + iouReportID, + transactionID: lastTransactionID, + transactionThreadReportID, + isMoneyRequestReport, + buildTransactionThreadParams, + }); return; } - // requestMoney passes the chat it wrote to (iouReport.chatReportID) as preResolvedChatTarget; trackExpense is void so it still derives (self-DM case). const {report: resolvedReport, chatReportID} = preResolvedChatTarget ?? resolveChatTargetForSubmitCleanup({ @@ -346,17 +364,18 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { fallbackOptimisticChatReportID, action, }); - // Move-from-track (SUBMIT/CATEGORIZE/SHARE) reuses the tracked transaction's ID — mirror the builder's `existingTransactionID ?? optimisticTransactionID`. - const lastTransactionID = getExistingTransactionID(lastTransaction?.linkedTrackedExpenseReportAction) ?? lastOptimisticTransactionID; cleanupAndNavigateAfterExpenseCreate({ report: resolvedReport, action, draftTransactionIDs, transactionID: lastTransactionID, + iouReportID, + transactionThreadReportID, isFromGlobalCreate: getIsFromGlobalCreate(lastTransaction), backToReport: navigateBackToReport, optimisticChatReportID: chatReportID, linkedTrackedExpenseReportAction: lastTransaction?.linkedTrackedExpenseReportAction, + buildTransactionThreadParams, }); } @@ -381,6 +400,7 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { let existingIOUReport: Report | undefined; let allTransactionsCreated = true; let lastOptimisticTransactionID: string | undefined; + let lastTransactionThreadReportID: string | undefined; for (const item of transactions) { lastOptimisticTransactionID = rand64(); @@ -430,7 +450,7 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { } } - const {iouReport} = requestMoneyIOUActions({ + const {iouReport, transactionThreadReportID} = requestMoneyIOUActions({ report, existingIOUReport, optimisticChatReportID, @@ -505,6 +525,7 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { delegateAccountID, }); existingIOUReport = iouReport; + lastTransactionThreadReportID = transactionThreadReportID; if (!iouReport) { allTransactionsCreated = false; } @@ -521,6 +542,8 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { report: isExpenseReport ? report : undefined, chatReportID: isExpenseReport ? optimisticChatReportID : (existingIOUReport?.chatReportID ?? optimisticChatReportID), }, + iouReportID: existingIOUReport?.reportID, + transactionThreadReportID: lastTransactionThreadReportID, }); } @@ -535,7 +558,7 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { } if (isTrackExpense) { const optimisticChatReportID = selfDMReport?.reportID ?? generateReportID(); - submitPerDiemExpenseForSelfDM({ + const result = submitPerDiemExpenseForSelfDM({ selfDMReport, policy, transactionParams: { @@ -558,6 +581,20 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { }); if (shouldHandleNavigation) { dismissModalAndOpenReportInInboxTabHelper(optimisticChatReportID, false, false); + // Keep the historical dismiss-to-self-DM navigation, but match the other flows' + // global-create growl. There's no iouReportID in the self-DM, so the growl resolves + // its "View" link from the transaction thread directly. + if (getIsFromGlobalCreate(transaction)) { + surfaceExpenseCreatedFeedback({ + transactionID: result?.transactionID, + transactionThreadReportID: result?.transactionThreadReportID, + }); + } + } else { + surfaceExpenseCreatedFeedback({ + transactionID: result?.transactionID, + transactionThreadReportID: result?.transactionThreadReportID, + }); } } else { const isExpenseReport = isMoneyRequestReportReportUtils(report); @@ -618,14 +655,24 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { const isOneToTwoTransition = !backToReport && isOneToTwoTransactionTransition(isMoneyRequestReport, reportTransactions); if (result && targetReportID) { - navigateAfterExpenseCreate({ - activeReportID: targetReportID, - transactionID: result.transactionID, - isFromGlobalCreate: getIsFromGlobalCreate(transaction), - hasMultipleTransactions: reportTransactions.length > 0, - shouldAddPendingNewTransactionIDs: (shouldHandleNavigation && targetReportID === chatReportID) || isOneToTwoTransition, - shouldNavigate: shouldHandleNavigation, - }); + if (shouldHandleNavigation) { + navigateAfterExpenseCreate({ + activeReportID: targetReportID, + iouReportID: result.iouReport?.reportID, + transactionID: result.transactionID, + transactionThreadReportID: result.transactionThreadReportID, + isFromGlobalCreate: getIsFromGlobalCreate(transaction), + hasMultipleTransactions: reportTransactions.length > 0, + shouldAddPendingNewTransactionIDs: targetReportID === chatReportID || isOneToTwoTransition, + }); + } else { + surfaceExpenseCreatedFeedback({ + iouReportID: result.iouReport?.reportID, + transactionID: result.transactionID, + transactionThreadReportID: result.transactionThreadReportID, + isMoneyRequestReport, + }); + } } } } @@ -649,6 +696,7 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { // non-self route report) so getTrackExpenseInformation defaults to the self-DM instead of the route report. const trackReport = isSelfDMDestination ? undefined : report; const policyExpenseChatReportActions = getAllPolicyExpenseChatReportActions(allReports, allReportActions); + let lastTrackResult: {iouReport?: Report; transactionID?: string; transactionThreadReportID?: string} | undefined; let lastOptimisticTransactionID: string | undefined; for (const item of transactions) { lastOptimisticTransactionID = rand64(); @@ -657,7 +705,7 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { const itemDistance = isManualDistanceRequest || isOdometerDistanceRequest || isGPSDistanceRequest ? (item.comment?.customUnit?.quantity ?? undefined) : undefined; const email = currentUserPersonalDetails.email ?? ''; - trackExpenseIOUActions({ + lastTrackResult = trackExpenseIOUActions({ report: trackReport, isDraftPolicy, isDraftChatReport, @@ -731,24 +779,24 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { fallbackOptimisticChatReportID: optimisticSelfDMReportID, navigateBackToReport: undefined, lastOptimisticTransactionID, - // trackExpense wrote to optimisticSelfDMReportID, so resolve the self-DM nav target directly. This - // suppresses the route-report fallback in resolveChatTargetForSubmitCleanup, which would otherwise keep a - // policy/group/source route report as the target when the self-DM report isn't loaded yet (fresh account - // or before Onyx hydration) — leaving first-time self-DM creates navigating against the wrong report. + // trackExpense wrote to optimisticSelfDMReportID, so resolve the self-DM nav target directly instead of + // falling back to a route report that may not be loaded yet on a first-time self-DM create. preResolvedChatTarget: isSelfDMDestination ? {report: selfDMReport, chatReportID: optimisticSelfDMReportID} : undefined, + iouReportID: lastTrackResult?.iouReport?.reportID, + transactionThreadReportID: lastTrackResult?.transactionThreadReportID, }); } - function createDistanceRequest(trimmedComment: string, shouldHandleNavigation = true) { + function createDistanceRequest(trimmedComment: string, shouldHandleNavigation = true, shouldDeferForSearch = false) { if (!transaction) { return; } - const participant = selectedParticipantsForRequest.at(0); - if (!participant) { + // Without a selected participant the action would fall back to an empty participant object and build an invalid request. + if (selectedParticipantsForRequest.length === 0) { return; } - const {chatReportID: distanceChatReportID, transactionID: distanceTransactionID} = createDistanceRequestIOUActions({ + createDistanceRequestIOUActions({ report, participants: selectedParticipantsForRequest, currentUserLogin: currentUserPersonalDetails.login ?? '', @@ -787,6 +835,7 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { gpsCoordinates: isGPSDistanceRequest ? getStringifiedGPSCoordinates(gpsDraftDetails) : undefined, distanceRequestType, }, + backToReport, isASAPSubmitBetaEnabled, transactionViolations: transactionViolationsRef.current, quickAction, @@ -794,24 +843,12 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { personalDetails, recentWaypoints, betas, + shouldHandleNavigation, + shouldDeferForSearch, previousOdometerDraft: odometerDraft, isTrackIntentUser, delegateAccountID, }); - - const isExpenseReport = isMoneyRequestReportReportUtils(report); - performPostBatchCleanup({ - participant, - shouldHandleNavigation, - allTransactionsCreated: true, - fallbackOptimisticChatReportID: distanceChatReportID, - navigateBackToReport: backToReport, - lastOptimisticTransactionID: distanceTransactionID, - preResolvedChatTarget: { - report: isExpenseReport ? report : undefined, - chatReportID: isExpenseReport ? '' : distanceChatReportID, - }, - }); } function createTransaction(locationPermissionGranted = false, shouldHandleNavigation = true) { @@ -830,7 +867,8 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { // Telemetry spans (SPAN_SUBMIT_EXPENSE, SPAN_SUBMIT_TO_DESTINATION_VISIBLE) // are started by SubmitExpenseOrchestrator before calling createTransaction. if (!isTrackExpense && !isSelfDMDestination && isDistanceRequest && !isMovingTransactionFromTrackExpense && !isUnreported) { - createDistanceRequest(trimmedComment, shouldHandleNavigation); + const shouldDeferDistanceForSearch = iouType === CONST.IOU.TYPE.SPLIT && isDeferredSearchSubmit; + createDistanceRequest(trimmedComment, shouldHandleNavigation, shouldDeferDistanceForSearch); markSubmitExpenseEnd(); return; } @@ -963,7 +1001,7 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { const invoiceChatReport = !isEmptyObject(report) && report?.reportID ? report : existingInvoiceReport; const invoiceChatReportID = invoiceChatReport ? undefined : reportID; - sendInvoice({ + const invoiceResult = sendInvoice({ currentUserAccountID: currentUserPersonalDetails.accountID, transaction, policyRecentlyUsedCurrencies, @@ -974,7 +1012,6 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { policyTagList: policyTags, policyCategories, policyRecentlyUsedCategories, - isFromGlobalCreate: getIsFromGlobalCreate(transaction), policyRecentlyUsedTags, senderPolicyTags: senderWorkspacePolicyTags ?? {}, }); @@ -983,13 +1020,21 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { report: undefined, action, draftTransactionIDs, - transactionID: transaction?.transactionID, + transactionID: invoiceResult?.transactionID ?? transaction?.transactionID, + iouReportID: invoiceResult?.invoiceReportID, + transactionThreadReportID: invoiceResult?.transactionThreadReportID, isFromGlobalCreate: getIsFromGlobalCreate(transaction), optimisticChatReportID: invoiceChatReport?.reportID ?? invoiceChatReportID, isInvoice: true, }); } else { cleanupAfterExpenseCreate({draftTransactionIDs}); + surfaceExpenseCreatedFeedback({ + iouReportID: invoiceResult?.invoiceReportID, + transactionID: invoiceResult?.transactionID, + transactionThreadReportID: invoiceResult?.transactionThreadReportID, + isInvoice: true, + }); } markSubmitExpenseEnd(); return; diff --git a/tests/actions/IOU/MoneyRequestTest.ts b/tests/actions/IOU/MoneyRequestTest.ts index 6c4c0337198c..afac5b1dd33e 100644 --- a/tests/actions/IOU/MoneyRequestTest.ts +++ b/tests/actions/IOU/MoneyRequestTest.ts @@ -73,19 +73,18 @@ jest.mock('@libs/getCurrentPosition'); // Fire executeWrite synchronously so downstream writes can be asserted. jest.mock('@libs/Navigation/helpers/submitWithDismissFirst', () => jest.requireActual('../../__mocks__/submitWithDismissFirst')); -// Cleanup helpers are spies so the move-from-track cleanup-id contract can be asserted. -const mockCleanupAndNavigateAfterExpenseCreate = jest.fn(); const mockCleanupAfterExpenseCreate = jest.fn(); -jest.mock('@libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate', () => ({ +jest.mock('@libs/Navigation/helpers/cleanupAfterExpenseCreate', () => ({ __esModule: true, default: (...args: unknown[]): void => { - mockCleanupAndNavigateAfterExpenseCreate(...args); + mockCleanupAfterExpenseCreate(...args); }, })); -jest.mock('@libs/Navigation/helpers/cleanupAfterExpenseCreate', () => ({ +const mockCleanupAndNavigateAfterExpenseCreate = jest.fn(); +jest.mock('@libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate', () => ({ __esModule: true, default: (...args: unknown[]): void => { - mockCleanupAfterExpenseCreate(...args); + mockCleanupAndNavigateAfterExpenseCreate(...args); }, })); @@ -189,7 +188,6 @@ describe('MoneyRequest', () => { }), ); - // Deferral is channel-driven; the action no longer receives a shouldDeferForSearch / shouldHandleNavigation flag. const lastTrackExpenseParams = jest.mocked(TrackExpense.trackExpense).mock.calls.at(-1)?.at(0); expect(lastTrackExpenseParams && 'shouldDeferForSearch' in lastTrackExpenseParams).toBeFalsy(); expect(lastTrackExpenseParams && 'shouldHandleNavigation' in lastTrackExpenseParams).toBeFalsy(); @@ -762,7 +760,7 @@ describe('MoneyRequest', () => { expect.objectContaining({ report: baseParams.report, isDraftPolicy: false, - // Action is nav-free — UI owns navigation; draft + optimistic IDs are threaded in. + // The action owns post-create navigation; draft + optimistic IDs are threaded in alongside shouldHandleNavigation. existingTransaction: baseParams.transaction, transactionParams: expect.objectContaining({ amount: 0, @@ -782,7 +780,7 @@ describe('MoneyRequest', () => { expect(Split.createDistanceRequest).not.toHaveBeenCalled(); }); - it('should pass the existing tracked transaction ID (not the optimistic id) to cleanup for a move-from-track distance submission', async () => { + it('should pass the moved transaction linkedTrackedExpenseReportAction to cleanup for a move-from-track distance submission', async () => { const EXISTING_TRACKED_TRANSACTION_ID = 'tracked-transaction-88'; const linkedTrackedExpenseReportAction = { reportActionID: 'linked-action-1', @@ -813,15 +811,16 @@ describe('MoneyRequest', () => { await waitForBatchedUpdates(); expect(mockCleanupAndNavigateAfterExpenseCreate).toHaveBeenCalledTimes(1); + // Navigate-path cleanup carries the moved transaction's linkedTrackedExpenseReportAction + // (used to release the original tracked expense thread screen). expect(mockCleanupAndNavigateAfterExpenseCreate).toHaveBeenCalledWith( expect.objectContaining({ - transactionID: EXISTING_TRACKED_TRANSACTION_ID, + linkedTrackedExpenseReportAction, }), ); }); - // createDistanceRequest writes under the draft transaction, so cleanup must target that id — not the UI's optimistic one. - it('should pass the written transaction id from createDistanceRequest (not the draft or optimistic id) to cleanup for a non-track distance submission', async () => { + it('should pass the draft transaction ids to cleanup for a non-track distance submission', async () => { handleMoneyRequestStepDistanceNavigation({ ...baseParams, shouldSkipConfirmation: true, @@ -834,9 +833,9 @@ describe('MoneyRequest', () => { await waitForBatchedUpdates(); - expect(mockCleanupAndNavigateAfterExpenseCreate).toHaveBeenCalledWith( + expect(mockCleanupAfterExpenseCreate).toHaveBeenCalledWith( expect.objectContaining({ - transactionID: 'distance-written-id', + draftTransactionIDs: [baseParams.transactionID], }), ); }); @@ -970,10 +969,6 @@ describe('MoneyRequest', () => { policyRecentlyUsedCurrencies: [], }), ); - const distanceParams = jest.mocked(Split.createDistanceRequest).mock.calls.at(-1)?.at(0); - expect(distanceParams && 'shouldHandleNavigation' in distanceParams).toBeFalsy(); - expect(distanceParams && 'shouldDeferForSearch' in distanceParams).toBeFalsy(); - expect(distanceParams && 'backToReport' in distanceParams).toBeFalsy(); }); it('should call createDistanceRequest for non-TRACK iouType when not from manual distance step and skipping confirmation', () => { @@ -1013,10 +1008,6 @@ describe('MoneyRequest', () => { policyRecentlyUsedCurrencies: [], }), ); - const distanceParams = jest.mocked(Split.createDistanceRequest).mock.calls.at(-1)?.at(0); - expect(distanceParams && 'shouldHandleNavigation' in distanceParams).toBeFalsy(); - expect(distanceParams && 'shouldDeferForSearch' in distanceParams).toBeFalsy(); - expect(distanceParams && 'backToReport' in distanceParams).toBeFalsy(); }); it('should navigate to confirmation page when not skipping confirmation', async () => { diff --git a/tests/actions/IOU/SplitReportTotalsTest.ts b/tests/actions/IOU/SplitReportTotalsTest.ts index f4a808d61990..bd58ad23665f 100644 --- a/tests/actions/IOU/SplitReportTotalsTest.ts +++ b/tests/actions/IOU/SplitReportTotalsTest.ts @@ -1,10 +1,8 @@ -import {handleNavigateAfterExpenseCreate} from '@libs/actions/IOU/NavigationHelpers'; import {addPendingNewTransactionIDs} from '@libs/actions/IOU/PendingNewTransactions'; import '@libs/actions/IOU/MoneyRequest'; import {createSplitsAndOnyxData} from '@libs/actions/IOU/Split'; import {updateSplitTransactionsFromSplitExpensesFlow} from '@libs/actions/IOU/SplitTransactionUpdate'; import initOnyxDerivedValues from '@libs/actions/OnyxDerived'; -import isReportTopmostSplitNavigator from '@libs/Navigation/helpers/isReportTopmostSplitNavigator'; import {rand64} from '@libs/NumberUtils'; import type * as PolicyUtils from '@libs/PolicyUtils'; @@ -432,33 +430,6 @@ describe('actions/IOU', () => { }); }); - it('handleNavigateAfterExpenseCreate', async () => { - const mockedIsReportTopmostSplitNavigator = isReportTopmostSplitNavigator as jest.MockedFunction; - const spyOnMergeTransactionIdsHighlightOnSearchRoute = jest.spyOn(require('@libs/actions/Transaction'), 'mergeTransactionIdsHighlightOnSearchRoute'); - const activeReportID = '1'; - const transactionID = '1'; - mockedIsReportTopmostSplitNavigator.mockReturnValue(false); - - handleNavigateAfterExpenseCreate({activeReportID, isFromGlobalCreate: false}); - expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledTimes(0); - - handleNavigateAfterExpenseCreate({activeReportID, isFromGlobalCreate: true}); - expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledTimes(0); - - mockedIsReportTopmostSplitNavigator.mockReturnValue(true); - handleNavigateAfterExpenseCreate({activeReportID, isFromGlobalCreate: true, transactionID}); - expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledTimes(0); - - mockedIsReportTopmostSplitNavigator.mockReturnValue(false); - handleNavigateAfterExpenseCreate({activeReportID, isFromGlobalCreate: true, transactionID}); - expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledTimes(0); - - handleNavigateAfterExpenseCreate({activeReportID, isFromGlobalCreate: true, transactionID, isInvoice: true}); - expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledTimes(0); - - spyOnMergeTransactionIdsHighlightOnSearchRoute.mockReset(); - }); - describe('createSplitsAndOnyxData', () => { const mockPersonalDetails: PersonalDetailsList = { [RORY_ACCOUNT_ID]: {accountID: RORY_ACCOUNT_ID, login: RORY_EMAIL, displayName: 'Rory'}, diff --git a/tests/actions/IOUTest/SplitTest.ts b/tests/actions/IOUTest/SplitTest.ts index e2e8c5f9481b..edb0961bdc83 100644 --- a/tests/actions/IOUTest/SplitTest.ts +++ b/tests/actions/IOUTest/SplitTest.ts @@ -7631,20 +7631,6 @@ describe('createDistanceRequest', () => { expect(result.transactionID).toBeTruthy(); }); - 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({ - ...getDefaultDistanceRequestParams({reportID: '123'}, {amount: 1, isFromGlobalCreate: true}, recentWaypoints), - participants: [], - }); - - await waitForBatchedUpdates(); - - const highlight = await getOnyxValue(ONYXKEYS.TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE); - expect(Object.keys(highlight?.[CONST.SEARCH.DATA_TYPES.EXPENSE] ?? {})).toContain(result.transactionID); - }); - it('flags the new transaction for the 1→2 highlight fallback when added to an expense report that already has one transaction', async () => { const recentWaypoints = (await getOnyxValue(ONYXKEYS.NVP_RECENT_WAYPOINTS)) ?? []; const existingTransactionID = 'existing-txn-one-to-two'; diff --git a/tests/ui/IOURequestStepAmountDraftTest.tsx b/tests/ui/IOURequestStepAmountDraftTest.tsx index 619ea1fae771..a86aaa2ba4a6 100644 --- a/tests/ui/IOURequestStepAmountDraftTest.tsx +++ b/tests/ui/IOURequestStepAmountDraftTest.tsx @@ -105,9 +105,9 @@ jest.mock('@libs/Navigation/Navigation', () => { jest.mock('@libs/Navigation/helpers/submitWithDismissFirst', () => jest.requireActual('../__mocks__/submitWithDismissFirst')); -// Action-assertion test: post-create navigation is exercised elsewhere; keep the nav helpers inert here. -jest.mock('@libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate', () => jest.fn()); +// Action-assertion test: post-create navigation is exercised elsewhere; keep the cleanup helpers inert here. jest.mock('@libs/Navigation/helpers/cleanupAfterExpenseCreate', () => jest.fn()); +jest.mock('@libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate', () => jest.fn()); jest.mock('@react-navigation/native', () => { const mockRef = { @@ -264,10 +264,10 @@ describe('IOURequestStepAmount - draft transactions coverage', () => { }), ); - // The same optimisticTransactionID must reach the action AND post-create cleanup nav, else the destination report highlights the wrong transaction. + // The action receives the UI-resolved optimistic id; the view owns post-create navigation, so on the + // shouldHandleNavigation=true fast path the feedback runs through cleanupAndNavigateAfterExpenseCreate. const requestMoneyArg = jest.mocked(TrackExpense.requestMoney).mock.calls.at(0)?.[0]; - const cleanupArg = jest.mocked(cleanupAndNavigateAfterExpenseCreate).mock.calls.at(0)?.[0]; expect(typeof requestMoneyArg?.optimisticTransactionID).toBe('string'); - expect(cleanupArg?.transactionID).toBe(requestMoneyArg?.optimisticTransactionID); + expect(cleanupAndNavigateAfterExpenseCreate).toHaveBeenCalledTimes(1); }); }); diff --git a/tests/ui/ScanSkipConfirmationTest.tsx b/tests/ui/ScanSkipConfirmationTest.tsx index 6b21c513d9f4..144a18811466 100644 --- a/tests/ui/ScanSkipConfirmationTest.tsx +++ b/tests/ui/ScanSkipConfirmationTest.tsx @@ -32,7 +32,7 @@ let capturedCreateTransactionArg: CreateTransactionArg | undefined; const mockCreateTransaction = jest.fn((arg: CreateTransactionArg) => { capturedCreateTransactionArg = arg; }); -const mockCleanupAfterSkipConfirmSubmit = jest.fn(); +const mockCleanupAfterExpenseCreate = jest.fn(); const mockResolveChatTargetForScan = jest.fn(() => ({report: undefined, chatReportID: 'chat-resolved', optimisticChatReportID: 'optimistic-resolved'})); // Fire the write synchronously with the fallback override so createTransaction + cleanup run inline. const mockSubmitWithDismissFirst = jest.fn((params: {executeWrite: (overrides: {shouldHandleNavigation: boolean}) => void}) => params.executeWrite({shouldHandleNavigation: true})); @@ -87,16 +87,16 @@ jest.mock('@libs/Navigation/helpers/submitWithDismissFirst', () => ({ submitWithDismissFirst: (params: {executeWrite: (overrides: {shouldHandleNavigation: boolean}) => void}) => mockSubmitWithDismissFirst(params), })); -jest.mock('@libs/Navigation/helpers/cleanupAfterSkipConfirmSubmit', () => ({ +jest.mock('@libs/Navigation/helpers/cleanupAfterExpenseCreate', () => ({ __esModule: true, - default: (shouldHandleNavigation: boolean, params: Record) => { - mockCleanupAfterSkipConfirmSubmit(shouldHandleNavigation, params); + default: (params: Record) => { + mockCleanupAfterExpenseCreate(params); }, })); jest.mock('@pages/iou/request/step/resolveChatTarget', () => ({ + __esModule: true, resolveChatTargetForScan: () => mockResolveChatTargetForScan(), - resolveChatTargetForSubmitCleanup: jest.fn(), })); const REPORT_ID = '1'; @@ -185,7 +185,7 @@ describe('ScanSkipConfirmation submit orchestration', () => { }); await waitForBatchedUpdates(); - // The action layer must not navigate; the view layer drives dismiss-first + cleanup itself. + // The view layer threads the UI-resolved optimistic ids into the action and runs cleanup-only after dismiss-first; the action owns post-create navigation. expect(mockSubmitWithDismissFirst).toHaveBeenCalledTimes(1); expect(mockCreateTransaction).toHaveBeenCalledTimes(1); @@ -193,7 +193,7 @@ describe('ScanSkipConfirmation submit orchestration', () => { expect(capturedCreateTransactionArg?.optimisticTransactionIDs).toHaveLength(1); expect(capturedCreateTransactionArg?.optimisticChatReportID).toBe('optimistic-resolved'); - expect(mockCleanupAfterSkipConfirmSubmit).toHaveBeenCalledTimes(1); - expect(mockCleanupAfterSkipConfirmSubmit).toHaveBeenCalledWith(true, expect.objectContaining({optimisticChatReportID: 'chat-resolved'})); + // Cleanup is cleanup-only (draft ids + linked tracked action); it no longer receives navigation params. + expect(mockCleanupAfterExpenseCreate).toHaveBeenCalledTimes(1); }); }); diff --git a/tests/unit/IOUAmountSubmissionTest.ts b/tests/unit/IOUAmountSubmissionTest.ts index 9e4dfed47f47..adf793cf5291 100644 --- a/tests/unit/IOUAmountSubmissionTest.ts +++ b/tests/unit/IOUAmountSubmissionTest.ts @@ -34,7 +34,7 @@ jest.mock('@libs/Navigation/helpers/submitWithDismissFirst', () => ({ }), })); -jest.mock('@libs/Navigation/helpers/cleanupAfterSkipConfirmSubmit', () => jest.fn()); +jest.mock('@libs/Navigation/helpers/cleanupAfterExpenseCreate', () => jest.fn()); const mockSendMoneyElsewhere = jest.fn(); const mockSendMoneyWithWallet = jest.fn(); diff --git a/tests/unit/Search/useSearchSnapshotTest.ts b/tests/unit/Search/useSearchSnapshotTest.ts index a2c0b2531f1f..bf32eccb6b1a 100644 --- a/tests/unit/Search/useSearchSnapshotTest.ts +++ b/tests/unit/Search/useSearchSnapshotTest.ts @@ -179,7 +179,6 @@ describe('useSearchSnapshot', () => { useSearchSnapshot({ queryJSON: makeQueryJSON(), searchResults, - newSearchResultKeys: undefined, transactions: undefined, reportActions: undefined, }), @@ -189,7 +188,7 @@ describe('useSearchSnapshot', () => { expect(mockGetSortedSections).toHaveBeenCalled(); expect(result.current.chartData).toHaveLength(1); expect(result.current.chartData.at(0)?.keyForList).toBe('1'); - // data is the stabilized passthrough of the (highlight-stamped) chartData in this mock setup. + // data is the stabilized passthrough of chartData in this mock setup. expect(result.current.data).toBe(result.current.chartData); expect(result.current.columns).toEqual(['merchant']); expect(result.current.isLoading).toBe(false); @@ -205,7 +204,6 @@ describe('useSearchSnapshot', () => { useSearchSnapshot({ queryJSON: makeQueryJSON(), searchResults: undefined, - newSearchResultKeys: undefined, transactions: undefined, reportActions: undefined, }), @@ -224,7 +222,6 @@ describe('useSearchSnapshot', () => { useSearchSnapshot({ queryJSON: makeQueryJSON(), searchResults, - newSearchResultKeys: undefined, transactions: undefined, reportActions: undefined, }), @@ -244,7 +241,6 @@ describe('useSearchSnapshot', () => { useSearchSnapshot({ queryJSON: makeQueryJSON(), searchResults, - newSearchResultKeys: undefined, transactions: undefined, reportActions: undefined, }), @@ -267,7 +263,6 @@ describe('useSearchSnapshot', () => { groupBy: CONST.SEARCH.GROUP_BY.FROM, }), searchResults, - newSearchResultKeys: undefined, transactions: undefined, reportActions: undefined, }), @@ -288,7 +283,6 @@ describe('useSearchSnapshot', () => { useSearchSnapshot({ queryJSON: makeQueryJSON(), searchResults, - newSearchResultKeys: undefined, transactions: undefined, reportActions: undefined, }), @@ -313,7 +307,6 @@ describe('useSearchSnapshot', () => { useSearchSnapshot({ queryJSON: makeQueryJSON(), searchResults, - newSearchResultKeys: undefined, transactions: undefined, reportActions: undefined, }), @@ -341,7 +334,6 @@ describe('useSearchSnapshot', () => { useSearchSnapshot({ queryJSON: makeQueryJSON(), searchResults, - newSearchResultKeys: undefined, transactions: undefined, reportActions: undefined, }), @@ -351,24 +343,6 @@ describe('useSearchSnapshot', () => { expect(result.current.hasCachedOptimisticItem).toBe(true); }); - it('stamps the post-create highlight on matching rows (newSearchResultKeys)', () => { - const searchResults = makeSearchResults(); - mockUseOptimisticSearchTracking.mockReturnValue(trackingReturn(searchResults.data)); - mockGetSortedSections.mockReturnValue([{transactionID: '7', keyForList: '7'}]); - - const {result} = renderHook(() => - useSearchSnapshot({ - queryJSON: makeQueryJSON(), - searchResults, - newSearchResultKeys: new Set([`${ONYXKEYS.COLLECTION.TRANSACTION}7`]), - transactions: undefined, - reportActions: undefined, - }), - ); - - expect(result.current.chartData.at(0)).toEqual(expect.objectContaining({shouldAnimateInHighlight: true})); - }); - it('passes the query type through to getSortedSections for each variant shape', () => { const variants = [ CONST.SEARCH.DATA_TYPES.CHAT, @@ -386,7 +360,6 @@ describe('useSearchSnapshot', () => { useSearchSnapshot({ queryJSON: makeQueryJSON({type}), searchResults, - newSearchResultKeys: undefined, transactions: undefined, reportActions: undefined, }), @@ -414,7 +387,6 @@ describe('useSearchSnapshot', () => { const props = { queryJSON: makeQueryJSON(), searchResults, - newSearchResultKeys: undefined, transactions: undefined, reportActions: undefined, }; diff --git a/tests/unit/cleanupAfterSkipConfirmSubmitTest.ts b/tests/unit/cleanupAfterSkipConfirmSubmitTest.ts deleted file mode 100644 index f6d9de3ed130..000000000000 --- a/tests/unit/cleanupAfterSkipConfirmSubmitTest.ts +++ /dev/null @@ -1,51 +0,0 @@ -import cleanupAfterExpenseCreate from '@libs/Navigation/helpers/cleanupAfterExpenseCreate'; -import cleanupAfterSkipConfirmSubmit from '@libs/Navigation/helpers/cleanupAfterSkipConfirmSubmit'; -import cleanupAndNavigateAfterExpenseCreate from '@libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate'; -import type {CleanupAndNavigateAfterExpenseCreateParams} from '@libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate'; - -import CONST from '@src/CONST'; -import type {Report, ReportAction} from '@src/types/onyx'; - -import type {OnyxEntry} from 'react-native-onyx'; - -jest.mock('@libs/Navigation/helpers/cleanupAfterExpenseCreate', () => jest.fn()); -jest.mock('@libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate', () => jest.fn()); - -const chatReport = {reportID: 'chat-1'} as Report; -const linkedTrackedExpenseReportAction = {childReportID: 'child-1'} as OnyxEntry; - -const params: CleanupAndNavigateAfterExpenseCreateParams = { - action: CONST.IOU.ACTION.CREATE, - report: chatReport, - draftTransactionIDs: ['txn-1', 'txn-2'], - transactionID: 'txn-1', - isFromGlobalCreate: false, - backToReport: 'back-to-this-report', - optimisticChatReportID: 'optimistic-1', - linkedTrackedExpenseReportAction, -}; - -describe('cleanupAfterSkipConfirmSubmit', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('should delegate to cleanupAndNavigateAfterExpenseCreate with the full params when shouldHandleNavigation is true', () => { - cleanupAfterSkipConfirmSubmit(true, params); - - expect(cleanupAndNavigateAfterExpenseCreate).toHaveBeenCalledTimes(1); - expect(cleanupAndNavigateAfterExpenseCreate).toHaveBeenCalledWith(params); - expect(cleanupAfterExpenseCreate).not.toHaveBeenCalled(); - }); - - it('should delegate only the cleanup-relevant subset to cleanupAfterExpenseCreate when shouldHandleNavigation is false', () => { - cleanupAfterSkipConfirmSubmit(false, params); - - expect(cleanupAfterExpenseCreate).toHaveBeenCalledTimes(1); - expect(cleanupAfterExpenseCreate).toHaveBeenCalledWith({ - draftTransactionIDs: ['txn-1', 'txn-2'], - linkedTrackedExpenseReportAction, - }); - expect(cleanupAndNavigateAfterExpenseCreate).not.toHaveBeenCalled(); - }); -}); diff --git a/tests/unit/deferredLayoutWriteTest.ts b/tests/unit/deferredLayoutWriteTest.ts index 1c21b4e2ff30..c96d5f8650c5 100644 --- a/tests/unit/deferredLayoutWriteTest.ts +++ b/tests/unit/deferredLayoutWriteTest.ts @@ -8,6 +8,7 @@ import { registerDeferredWrite, reserveDeferredWriteChannel, resetForTesting, + runAfterDeferredWrite, } from '@libs/deferredLayoutWrite'; import CONST from '@src/CONST'; @@ -330,4 +331,53 @@ describe('deferredLayoutWrite', () => { expect(hasDeferredWriteForReport(CONST.DEFERRED_LAYOUT_WRITE_KEYS.DISMISS_MODAL, 'report-A')).toBe(false); }); }); + + describe('runAfterDeferredWrite', () => { + it('runs the callback immediately when no write is pending', () => { + const callback = jest.fn(); + runAfterDeferredWrite(callback); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('runs the callback after the pending write flushes, not before', () => { + const apiWrite = jest.fn(); + registerDeferredWrite('test', apiWrite); + + const callback = jest.fn(); + runAfterDeferredWrite(callback); + expect(callback).not.toHaveBeenCalled(); + + flushDeferredWrite('test'); + expect(apiWrite).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('runs the callback when the pending write fires via its safety timeout', () => { + registerDeferredWrite('test', jest.fn(), {safetyTimeoutMs: 3000}); + + const callback = jest.fn(); + runAfterDeferredWrite(callback); + expect(callback).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(3000); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('runs the callback only once even across multiple flushes', () => { + registerDeferredWrite('test', jest.fn()); + const callback = jest.fn(); + runAfterDeferredWrite(callback); + + flushDeferredWrite('test'); + flushDeferredWrite('test'); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('ignores reserved-but-unregistered channels (runs immediately)', () => { + reserveDeferredWriteChannel(CONST.DEFERRED_LAYOUT_WRITE_KEYS.SEARCH); + const callback = jest.fn(); + runAfterDeferredWrite(callback); + expect(callback).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/tests/unit/hooks/useExpenseSubmission.test.ts b/tests/unit/hooks/useExpenseSubmission.test.ts index c0397ff93694..c59b0c27407b 100644 --- a/tests/unit/hooks/useExpenseSubmission.test.ts +++ b/tests/unit/hooks/useExpenseSubmission.test.ts @@ -17,7 +17,6 @@ const mockSubmitPerDiemExpenseAction = jest.fn(); const mockSubmitPerDiemExpenseForSelfDMAction = jest.fn(); const mockCleanupAfterExpenseCreate = jest.fn(); const mockCleanupAndNavigateAfterExpenseCreate = jest.fn(); -const mockResolveChatTargetForSubmitCleanup = jest.fn(); const mockSendInvoiceAction = jest.fn(); jest.mock('@userActions/IOU/TrackExpense', () => ({ @@ -45,10 +44,6 @@ jest.mock('@libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate', () => default: (...args: unknown[]) => mockCleanupAndNavigateAfterExpenseCreate(...args), })); -jest.mock('@pages/iou/request/step/resolveChatTarget', () => ({ - resolveChatTargetForSubmitCleanup: (...args: unknown[]) => mockResolveChatTargetForSubmitCleanup(...args), -})); - jest.mock('@hooks/useLocalize', () => ({ __esModule: true, default: () => ({translate: jest.fn((key: string) => key), toLocaleDigit: jest.fn((digit: string) => digit)}), @@ -197,7 +192,6 @@ describe('useExpenseSubmission orchestrator-suppressed cleanup', () => { jest.clearAllMocks(); await Onyx.clear(); mockRequestMoneyAction.mockReturnValue({iouReport: {reportID: 'iou-1'}}); - mockResolveChatTargetForSubmitCleanup.mockReturnValue({report: {reportID: REPORT_ID}, chatReportID: 'fallback-id', optimisticChatReportID: undefined}); }); describe('requestMoney path', () => { @@ -220,7 +214,7 @@ describe('useExpenseSubmission orchestrator-suppressed cleanup', () => { expect(mockCleanupAndNavigateAfterExpenseCreate).not.toHaveBeenCalled(); }); - it('calls cleanupAndNavigateAfterExpenseCreate (which composes cleanup) when shouldHandleNavigation=true', async () => { + it('runs cleanupAndNavigateAfterExpenseCreate (nav + feedback) when shouldHandleNavigation=true', async () => { const {result} = renderHook(() => useExpenseSubmission(buildParams())); await waitForBatchedUpdatesWithAct(); @@ -231,13 +225,12 @@ describe('useExpenseSubmission orchestrator-suppressed cleanup', () => { expect(mockRequestMoneyAction).toHaveBeenCalledTimes(1); expect(mockCleanupAndNavigateAfterExpenseCreate).toHaveBeenCalledTimes(1); - // cleanupAndNavigate is mocked here, so it never calls through to the real cleanupAfterExpenseCreate. expect(mockCleanupAfterExpenseCreate).not.toHaveBeenCalled(); }); - it('passes the existing tracked transaction ID (not a fresh optimistic id) to cleanup for a move-from-track SUBMIT', async () => { - // Move-from-track SUBMIT: the action writes the transaction under the EXISTING tracked transaction id, - // so cleanup must reference that same id — not a fresh rand64() optimistic one. + it('passes the moved transaction linkedTrackedExpenseReportAction through cleanupAndNavigateAfterExpenseCreate for a move-from-track SUBMIT', async () => { + // Move-from-track SUBMIT carries the moved transaction's linkedTrackedExpenseReportAction + // (used to release the original tracked expense draft) into the post-create nav/cleanup. const EXISTING_TRACKED_TRANSACTION_ID = 'tracked-transaction-99'; const linkedTrackedExpenseReportAction = buildReportAction({ reportActionID: 'linked-action-1', @@ -267,16 +260,16 @@ describe('useExpenseSubmission orchestrator-suppressed cleanup', () => { }); await waitForBatchedUpdatesWithAct(); + expect(mockCleanupAfterExpenseCreate).not.toHaveBeenCalled(); expect(mockCleanupAndNavigateAfterExpenseCreate).toHaveBeenCalledTimes(1); expect(mockCleanupAndNavigateAfterExpenseCreate).toHaveBeenCalledWith( expect.objectContaining({ - transactionID: EXISTING_TRACKED_TRANSACTION_ID, + linkedTrackedExpenseReportAction, }), ); }); - // F2: requestMoney returns the chat it wrote to via {iouReport}; the UI reads that instead of re-deriving it through resolveChatTargetForSubmitCleanup. - it('uses iouReport.chatReportID for cleanup nav and does not re-derive it via resolveChatTargetForSubmitCleanup', async () => { + it('threads the returned iouReport into cleanupAndNavigateAfterExpenseCreate', async () => { mockRequestMoneyAction.mockReturnValue({iouReport: {reportID: 'iou-1', chatReportID: 'iou-chat-77'}}); const {result} = renderHook(() => useExpenseSubmission(buildParams())); @@ -287,8 +280,14 @@ describe('useExpenseSubmission orchestrator-suppressed cleanup', () => { }); await waitForBatchedUpdatesWithAct(); - expect(mockResolveChatTargetForSubmitCleanup).not.toHaveBeenCalled(); - expect(mockCleanupAndNavigateAfterExpenseCreate).toHaveBeenCalledWith(expect.objectContaining({optimisticChatReportID: 'iou-chat-77'})); + expect(mockCleanupAfterExpenseCreate).not.toHaveBeenCalled(); + expect(mockCleanupAndNavigateAfterExpenseCreate).toHaveBeenCalledTimes(1); + expect(mockCleanupAndNavigateAfterExpenseCreate).toHaveBeenCalledWith( + expect.objectContaining({ + iouReportID: 'iou-1', + optimisticChatReportID: 'iou-chat-77', + }), + ); }); it('routes tracked per diem SUBMIT through requestMoney so the original tracked expense is moved', async () => { @@ -360,7 +359,7 @@ describe('useExpenseSubmission orchestrator-suppressed cleanup', () => { expect(mockCleanupAndNavigateAfterExpenseCreate).not.toHaveBeenCalled(); }); - it('calls cleanupAndNavigateAfterExpenseCreate when shouldHandleNavigation=true', async () => { + it('runs cleanupAndNavigateAfterExpenseCreate (nav + feedback) when shouldHandleNavigation=true', async () => { const {result} = renderHook(() => useExpenseSubmission(buildParams({iouType: CONST.IOU.TYPE.TRACK}))); await waitForBatchedUpdatesWithAct(); @@ -449,7 +448,6 @@ describe('useExpenseSubmission action-bailout safety', () => { beforeEach(async () => { jest.clearAllMocks(); await Onyx.clear(); - mockResolveChatTargetForSubmitCleanup.mockReturnValue({report: {reportID: REPORT_ID}, chatReportID: 'fallback-id', optimisticChatReportID: undefined}); }); it('skips requestMoney entirely (including the action call) when SUBMIT batch is missing linked-track metadata', async () => { diff --git a/tests/unit/navigateAfterExpenseCreateTest.ts b/tests/unit/navigateAfterExpenseCreateTest.ts index b7da2c18731e..a2061caba028 100644 --- a/tests/unit/navigateAfterExpenseCreateTest.ts +++ b/tests/unit/navigateAfterExpenseCreateTest.ts @@ -1,35 +1,46 @@ -import navigateAfterExpenseCreate from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; +import Growl from '@libs/Growl'; +import navigateAfterExpenseCreate, {navigateToCreatedExpense} from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; import Navigation from '@libs/Navigation/Navigation'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; +import type {Transaction} from '@src/types/onyx'; -const mockIsReportTopmostSplitNavigator = jest.fn(); -const mockIsSearchTopmostFullScreenRoute = jest.fn(); -const mockIsReportOpenInRHP = jest.fn(); -const mockGetIsNarrowLayout = jest.fn(); -const mockGetTrackingState = jest.fn(); +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +const mockIsReportTopmostSplitNavigator = jest.fn(); +const mockIsSearchTopmostFullScreenRoute = jest.fn(); +const mockIsReportOpenInRHP = jest.fn(); +const mockGetIsNarrowLayout = jest.fn(); +const mockGetTrackingState = jest.fn(); // Declared but assigned after jest.mock hoisting - use require() to access the mock in tests let mockSetPendingSubmitFollowUpAction: jest.Mock; -const mockGetCurrentSearchQueryJSON = jest.fn(); +const mockGetCurrentSearchQueryJSON = jest.fn(); +const mockGetReportTransactions = jest.fn>, [string | undefined]>(); -jest.mock('@libs/Navigation/helpers/isReportTopmostSplitNavigator', () => () => mockIsReportTopmostSplitNavigator() as boolean); -jest.mock('@libs/Navigation/helpers/isSearchTopmostFullScreenRoute', () => () => mockIsSearchTopmostFullScreenRoute() as boolean); -jest.mock('@libs/Navigation/helpers/isReportOpenInRHP', () => () => mockIsReportOpenInRHP() as boolean); -jest.mock('@libs/Navigation/helpers/isReportOpenInSuperWideRHP', () => () => false as boolean); +jest.mock('@libs/Navigation/helpers/isReportTopmostSplitNavigator', () => () => mockIsReportTopmostSplitNavigator()); +jest.mock('@libs/Navigation/helpers/isSearchTopmostFullScreenRoute', () => () => mockIsSearchTopmostFullScreenRoute()); +jest.mock('@libs/Navigation/helpers/isReportOpenInRHP', () => () => mockIsReportOpenInRHP()); +jest.mock('@libs/Navigation/helpers/isReportOpenInSuperWideRHP', () => () => false); jest.mock('@libs/Navigation/helpers/setNavigationActionToMicrotaskQueue', () => (callback: () => void) => { callback(); }); -jest.mock('@libs/getIsNarrowLayout', () => () => mockGetIsNarrowLayout() as boolean); +jest.mock('@libs/getIsNarrowLayout', () => () => mockGetIsNarrowLayout()); jest.mock('@libs/telemetry/submitFollowUpAction', () => ({ - isTracking: (...args: unknown[]) => mockGetTrackingState(...args) as boolean, + isTracking: (...args: unknown[]) => mockGetTrackingState(...args), endSubmitFollowUpActionSpan: jest.fn(), setPendingSubmitFollowUpAction: jest.fn(), })); jest.mock('@libs/SearchQueryUtils', () => ({ buildCannedSearchQuery: jest.fn(({type}: {type: string}) => `type:${type}`), - getCurrentSearchQueryJSON: () => mockGetCurrentSearchQueryJSON() as undefined, + 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/Navigation/Navigation', () => ({ dismissModal: jest.fn(), @@ -37,6 +48,7 @@ jest.mock('@libs/Navigation/Navigation', () => ({ dismissModalWithReport: jest.fn(), pop: jest.fn(), navigate: jest.fn(), + getActiveRoute: jest.fn(() => ''), revealRouteBeforeDismissingModal: jest.fn(), isNavigationReady: jest.fn(() => Promise.resolve()), getIsFullscreenPreInsertedUnderRHP: jest.fn(() => false), @@ -51,6 +63,20 @@ jest.mock('@libs/Navigation/Navigation', () => ({ jest.mock('@react-navigation/native'); +jest.mock('@libs/Growl', () => ({ + __esModule: true, + default: {success: jest.fn()}, +})); + +jest.mock('@libs/actions/Report', () => ({ + createTransactionThreadReport: jest.fn(), + setOptimisticTransactionThread: jest.fn(), +})); + +jest.mock('@libs/actions/TransactionThreadNavigation', () => ({ + setActiveTransactionIDs: jest.fn(() => Promise.resolve()), +})); + describe('navigateAfterExpenseCreate', () => { beforeAll(() => { const followUpMock = require('@libs/telemetry/submitFollowUpAction') as {setPendingSubmitFollowUpAction: jest.Mock}; @@ -62,8 +88,9 @@ describe('navigateAfterExpenseCreate', () => { mockIsReportTopmostSplitNavigator.mockReturnValue(false); mockIsSearchTopmostFullScreenRoute.mockReturnValue(false); mockIsReportOpenInRHP.mockReturnValue(false); - mockGetTrackingState.mockReturnValue(null); + mockGetTrackingState.mockReturnValue(false); mockGetCurrentSearchQueryJSON.mockReturnValue(undefined); + mockGetReportTransactions.mockReturnValue([]); }); it('should dismiss to report when not from global create', () => { @@ -76,6 +103,49 @@ describe('navigateAfterExpenseCreate', () => { expect(Navigation.dismissModalWithReport).toHaveBeenCalledWith({reportID: 'report-123'}); expect(Navigation.navigate).not.toHaveBeenCalled(); + expect(Growl.success).not.toHaveBeenCalled(); + }); + + it('should show the expense added growl on the non-global path when shouldAlwaysShowFeedback is set', async () => { + navigateAfterExpenseCreate({ + activeReportID: 'report-123', + transactionID: 'txn-1', + transactionThreadReportID: 'thread-1', + isFromGlobalCreate: false, + hasMultipleTransactions: false, + shouldAlwaysShowFeedback: true, + }); + + expect(Navigation.dismissModalWithReport).toHaveBeenCalledWith({reportID: 'report-123'}); + await waitForBatchedUpdates(); + const [growlText, growlDuration, growlAction] = jest.mocked(Growl.success).mock.calls.at(0) ?? []; + expect(growlText).toEqual(expect.any(String)); + expect(growlDuration).toEqual(expect.any(Number)); + expect(growlAction?.label).toEqual(expect.any(String)); + expect(growlAction?.onPress).toEqual(expect.any(Function)); + }); + + it('should defer thread materialization to View press instead of growl-show time', async () => { + const {setOptimisticTransactionThread} = require('@libs/actions/Report') as {setOptimisticTransactionThread: jest.Mock}; + + navigateAfterExpenseCreate({ + activeReportID: 'report-123', + transactionID: 'txn-1', + transactionThreadReportID: 'thread-1', + isFromGlobalCreate: false, + hasMultipleTransactions: false, + shouldAlwaysShowFeedback: true, + }); + await waitForBatchedUpdates(); + + // Showing the growl must not touch the thread report. + expect(setOptimisticTransactionThread).not.toHaveBeenCalled(); + + const growlAction = jest.mocked(Growl.success).mock.calls.at(0)?.[2]; + growlAction?.onPress(); + await waitForBatchedUpdates(); + + expect(setOptimisticTransactionThread).toHaveBeenCalledWith('thread-1', undefined, undefined, undefined); }); it('should dismiss to report when user is on inbox tab', () => { @@ -158,4 +228,100 @@ describe('navigateAfterExpenseCreate', () => { expect(Navigation.dismissModal).toHaveBeenCalled(); expect(Navigation.navigate).not.toHaveBeenCalled(); }); + + describe('Share flow self-DM track path (View action)', () => { + it('should open the transaction thread on View when a transactionThreadReportID is provided but there is no iouReportID (tracked self-DM expense)', async () => { + mockIsReportTopmostSplitNavigator.mockReturnValue(true); + mockGetIsNarrowLayout.mockReturnValue(true); + + navigateAfterExpenseCreate({ + activeReportID: 'self-dm-1', + transactionID: 'txn-1', + transactionThreadReportID: 'thread-1', + iouReportID: undefined, + isFromGlobalCreate: false, + hasMultipleTransactions: false, + shouldAlwaysShowFeedback: true, + }); + await waitForBatchedUpdates(); + + const growlAction = jest.mocked(Growl.success).mock.calls.at(0)?.[2]; + growlAction?.onPress(); + await waitForBatchedUpdates(); + + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute('thread-1', undefined, undefined, ''), {forceReplace: false}); + }); + }); + + describe('navigateToCreatedExpense', () => { + it('should open the transaction thread in the Spend RHP when the user is on the Spend tab', async () => { + mockIsReportTopmostSplitNavigator.mockReturnValue(false); + mockIsSearchTopmostFullScreenRoute.mockReturnValue(true); + + navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'}); + await waitForBatchedUpdates(); + + expect(Navigation.navigate).toHaveBeenCalledTimes(1); + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SEARCH_REPORT.getRoute({reportID: 'thread-1', backTo: ''}), {forceReplace: false}); + }); + + it('should replace the currently-open report instead of stacking when one is already open in the RHP', async () => { + mockIsReportTopmostSplitNavigator.mockReturnValue(false); + mockIsSearchTopmostFullScreenRoute.mockReturnValue(true); + mockIsReportOpenInRHP.mockReturnValue(true); + + navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'}); + await waitForBatchedUpdates(); + + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SEARCH_REPORT.getRoute({reportID: 'thread-1', backTo: ''}), {forceReplace: true}); + }); + + it('should open the transaction thread as a full report when the user is on the Inbox tab on a narrow layout', () => { + mockIsReportTopmostSplitNavigator.mockReturnValue(true); + mockIsSearchTopmostFullScreenRoute.mockReturnValue(false); + mockGetIsNarrowLayout.mockReturnValue(true); + + navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'}); + + expect(Navigation.navigate).toHaveBeenCalledTimes(1); + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute('thread-1', undefined, undefined, ''), {forceReplace: false}); + }); + + it('should open the expense report then stack the thread RHP when the user is on the Inbox tab on a wide layout and the report has multiple transactions', async () => { + mockIsReportTopmostSplitNavigator.mockReturnValue(true); + mockIsSearchTopmostFullScreenRoute.mockReturnValue(false); + mockGetIsNarrowLayout.mockReturnValue(false); + mockGetReportTransactions.mockReturnValue([{transactionID: 'txn-1'}, {transactionID: 'txn-2'}]); + + navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'}); + await waitForBatchedUpdates(); + + expect(Navigation.navigate).toHaveBeenNthCalledWith(1, ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: 'iou-1', backTo: ''}), {forceReplace: false}); + expect(Navigation.navigate).toHaveBeenNthCalledWith(2, ROUTES.SEARCH_REPORT.getRoute({reportID: 'thread-1', backTo: ''})); + }); + + it('should open the expense report when the user is on the Inbox tab on a wide layout and the report has a single transaction', () => { + mockIsReportTopmostSplitNavigator.mockReturnValue(true); + mockIsSearchTopmostFullScreenRoute.mockReturnValue(false); + mockGetIsNarrowLayout.mockReturnValue(false); + mockGetReportTransactions.mockReturnValue([{transactionID: 'txn-1'}]); + + navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: 'iou-1'}); + + expect(Navigation.navigate).toHaveBeenCalledTimes(1); + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: 'iou-1', backTo: ''}), {forceReplace: false}); + }); + + it('should open the transaction thread directly when there is no expense report (tracked/unreported expense)', async () => { + mockIsReportTopmostSplitNavigator.mockReturnValue(true); + mockIsSearchTopmostFullScreenRoute.mockReturnValue(false); + mockGetIsNarrowLayout.mockReturnValue(false); + + navigateToCreatedExpense({threadReportID: 'thread-1', transactionID: 'txn-1', iouReportID: undefined}); + await waitForBatchedUpdates(); + + expect(Navigation.navigate).toHaveBeenCalledTimes(1); + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SEARCH_REPORT.getRoute({reportID: 'thread-1', backTo: ''}), {forceReplace: false}); + }); + }); }); diff --git a/tests/unit/resolveChatTargetTest.ts b/tests/unit/resolveChatTargetTest.ts deleted file mode 100644 index aac57807bafc..000000000000 --- a/tests/unit/resolveChatTargetTest.ts +++ /dev/null @@ -1,233 +0,0 @@ -import {getChatByParticipants, getReportOrDraftReport, isDeprecatedGroupDM, isGroupChat, isMoneyRequestReport, isPolicyExpenseChat, isSelfDM} from '@libs/ReportUtils'; - -import {resolveChatTargetForSubmitCleanup} from '@pages/iou/request/step/resolveChatTarget'; - -import CONST from '@src/CONST'; -import type {Report} from '@src/types/onyx'; -import type {Participant} from '@src/types/onyx/IOU'; - -jest.mock('@libs/ReportUtils', () => ({ - getChatByParticipants: jest.fn(), - getReportOrDraftReport: jest.fn(), - isMoneyRequestReport: jest.fn(), - isPolicyExpenseChat: jest.fn(), - isSelfDM: jest.fn(), - isGroupChat: jest.fn(), - isDeprecatedGroupDM: jest.fn(), -})); - -// Stub IOUUtils (only resolveChatTargetForScan reaches it) to keep the test bundle light. -jest.mock('@libs/IOUUtils', () => ({ - resolveOptimisticChatReportID: jest.fn(), -})); - -const CURRENT_USER_ACCOUNT_ID = 1; -const PARTICIPANT_ACCOUNT_ID = 42; -const OTHER_PARTICIPANT_ACCOUNT_ID = 99; -const FALLBACK = 'fallback-optimistic-id'; - -describe('resolveChatTargetForSubmitCleanup', () => { - beforeEach(() => { - jest.clearAllMocks(); - (getChatByParticipants as jest.Mock).mockReturnValue(undefined); - (getReportOrDraftReport as jest.Mock).mockReturnValue({reportID: 'cached'}); - (isMoneyRequestReport as jest.Mock).mockReturnValue(false); - (isPolicyExpenseChat as jest.Mock).mockReturnValue(false); - (isSelfDM as jest.Mock).mockReturnValue(false); - (isGroupChat as jest.Mock).mockReturnValue(false); - (isDeprecatedGroupDM as jest.Mock).mockReturnValue(false); - }); - - it('should keep the source report when it is a money-request (IOU/expense) report so navigation lands back on it', () => { - const participant: Participant = {accountID: PARTICIPANT_ACCOUNT_ID}; - const report = {reportID: 'iou-report-1'} as Report; - (isMoneyRequestReport as jest.Mock).mockReturnValue(true); - - const result = resolveChatTargetForSubmitCleanup({ - participant, - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - report, - fallbackOptimisticChatReportID: FALLBACK, - action: CONST.IOU.ACTION.CREATE, - }); - - expect(isMoneyRequestReport).toHaveBeenCalledWith(report); - expect(getChatByParticipants).not.toHaveBeenCalled(); - expect(result).toEqual({report, chatReportID: FALLBACK, optimisticChatReportID: undefined}); - }); - - it('should keep the source report when participants match (action keeps parentChatReport even if getChatByParticipants returns no match)', () => { - const participant: Participant = {accountID: PARTICIPANT_ACCOUNT_ID}; - const report = {reportID: 'chat-A', participants: {[PARTICIPANT_ACCOUNT_ID]: {}, [CURRENT_USER_ACCOUNT_ID]: {}}} as unknown as Report; - (getChatByParticipants as jest.Mock).mockReturnValue(undefined); - - const result = resolveChatTargetForSubmitCleanup({ - participant, - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - report, - fallbackOptimisticChatReportID: FALLBACK, - action: CONST.IOU.ACTION.CREATE, - }); - - expect(result).toEqual({report, chatReportID: FALLBACK, optimisticChatReportID: undefined}); - }); - - it('should keep the source report for special chat types (policyExpenseChat / selfDM / groupChat / deprecatedGroupDM) regardless of participant match', () => { - const participant: Participant = {accountID: PARTICIPANT_ACCOUNT_ID}; - const report = {reportID: 'self-dm-1'} as Report; - (isSelfDM as jest.Mock).mockReturnValue(true); - - const result = resolveChatTargetForSubmitCleanup({ - participant, - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - report, - fallbackOptimisticChatReportID: FALLBACK, - action: CONST.IOU.ACTION.CREATE, - }); - - expect(result).toEqual({report, chatReportID: FALLBACK, optimisticChatReportID: undefined}); - }); - - it('should keep the source report when participant.isPolicyExpenseChat=true (action skips participant validation)', () => { - const participant: Participant = {isPolicyExpenseChat: true, reportID: 'workspace-1'}; - const report = {reportID: 'some-report'} as Report; - - const result = resolveChatTargetForSubmitCleanup({ - participant, - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - report, - fallbackOptimisticChatReportID: FALLBACK, - action: CONST.IOU.ACTION.CREATE, - }); - - expect(result).toEqual({report, chatReportID: FALLBACK, optimisticChatReportID: undefined}); - }); - - it('should resolve to participant.reportID when participant is policyExpenseChat and no source report', () => { - const participant: Participant = {isPolicyExpenseChat: true, reportID: 'workspace-1'}; - - const result = resolveChatTargetForSubmitCleanup({ - participant, - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - report: undefined, - fallbackOptimisticChatReportID: FALLBACK, - action: CONST.IOU.ACTION.CREATE, - }); - - expect(result).toEqual({report: undefined, chatReportID: 'workspace-1', optimisticChatReportID: undefined}); - }); - - it('should fall back to optimisticChatReportID when participant.isPolicyExpenseChat targets a report not present in the Onyx cache (mirrors action behavior)', () => { - const participant: Participant = {isPolicyExpenseChat: true, reportID: 'workspace-uncached'}; - (getReportOrDraftReport as jest.Mock).mockReturnValue(undefined); - - const result = resolveChatTargetForSubmitCleanup({ - participant, - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - report: undefined, - fallbackOptimisticChatReportID: FALLBACK, - action: CONST.IOU.ACTION.CREATE, - }); - - expect(result).toEqual({report: undefined, chatReportID: FALLBACK, optimisticChatReportID: undefined}); - }); - - it('should resolve to the existing 1:1 DM via getChatByParticipants when participant differs from source report', () => { - const participant: Participant = {accountID: OTHER_PARTICIPANT_ACCOUNT_ID}; - const report = {reportID: 'chat-A', participants: {[PARTICIPANT_ACCOUNT_ID]: {}, [CURRENT_USER_ACCOUNT_ID]: {}}} as unknown as Report; - (getChatByParticipants as jest.Mock).mockReturnValue({reportID: 'chat-B'}); - - const result = resolveChatTargetForSubmitCleanup({ - participant, - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - report, - fallbackOptimisticChatReportID: FALLBACK, - action: CONST.IOU.ACTION.CREATE, - }); - - expect(getChatByParticipants).toHaveBeenCalledWith([OTHER_PARTICIPANT_ACCOUNT_ID, CURRENT_USER_ACCOUNT_ID]); - expect(result).toEqual({report: undefined, chatReportID: 'chat-B', optimisticChatReportID: undefined}); - }); - - it('should fall back to optimisticChatReportID and discard report when participant changed to a brand-new contact (no existing chat)', () => { - const participant: Participant = {accountID: OTHER_PARTICIPANT_ACCOUNT_ID}; - const report = {reportID: 'chat-A', participants: {[PARTICIPANT_ACCOUNT_ID]: {}, [CURRENT_USER_ACCOUNT_ID]: {}}} as unknown as Report; - (getChatByParticipants as jest.Mock).mockReturnValue(undefined); - - const result = resolveChatTargetForSubmitCleanup({ - participant, - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - report, - fallbackOptimisticChatReportID: FALLBACK, - action: CONST.IOU.ACTION.CREATE, - }); - - expect(result).toEqual({report: undefined, chatReportID: FALLBACK, optimisticChatReportID: undefined}); - }); - - it('should fall back to optimisticChatReportID when both report and participant are empty', () => { - const participant: Participant = {}; - - const result = resolveChatTargetForSubmitCleanup({ - participant, - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - report: undefined, - fallbackOptimisticChatReportID: FALLBACK, - action: CONST.IOU.ACTION.CREATE, - }); - - expect(result).toEqual({report: undefined, chatReportID: FALLBACK, optimisticChatReportID: undefined}); - }); - - describe('tracked-expense submit (action === SUBMIT)', () => { - it('should NOT keep the self-DM source — the action writes to the participant 1:1 chat, not back to self-DM', () => { - const participant: Participant = {accountID: PARTICIPANT_ACCOUNT_ID}; - const report = {reportID: 'self-dm-1'} as Report; - (isSelfDM as jest.Mock).mockReturnValue(true); - (getChatByParticipants as jest.Mock).mockReturnValue({reportID: 'one-on-one-chat'}); - - const result = resolveChatTargetForSubmitCleanup({ - participant, - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - report, - fallbackOptimisticChatReportID: FALLBACK, - action: CONST.IOU.ACTION.SUBMIT, - }); - - expect(getChatByParticipants).toHaveBeenCalledWith([PARTICIPANT_ACCOUNT_ID, CURRENT_USER_ACCOUNT_ID]); - expect(result).toEqual({report: undefined, chatReportID: 'one-on-one-chat', optimisticChatReportID: undefined}); - }); - - it('should resolve to the participant policy-expense chat (not the self-DM source) for a workspace submit', () => { - const participant: Participant = {isPolicyExpenseChat: true, reportID: 'workspace-1'}; - const report = {reportID: 'self-dm-1'} as Report; - (isSelfDM as jest.Mock).mockReturnValue(true); - - const result = resolveChatTargetForSubmitCleanup({ - participant, - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - report, - fallbackOptimisticChatReportID: FALLBACK, - action: CONST.IOU.ACTION.SUBMIT, - }); - - expect(result).toEqual({report: undefined, chatReportID: 'workspace-1', optimisticChatReportID: undefined}); - }); - - it('should still keep the source when it is a money-request report even for SUBMIT (mirrors action: isMoneyRequestReport ? report.reportID)', () => { - const participant: Participant = {accountID: PARTICIPANT_ACCOUNT_ID}; - const report = {reportID: 'iou-report-1'} as Report; - (isMoneyRequestReport as jest.Mock).mockReturnValue(true); - - const result = resolveChatTargetForSubmitCleanup({ - participant, - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - report, - fallbackOptimisticChatReportID: FALLBACK, - action: CONST.IOU.ACTION.SUBMIT, - }); - - expect(result).toEqual({report, chatReportID: FALLBACK, optimisticChatReportID: undefined}); - }); - }); -}); diff --git a/tests/unit/useSearchHighlightAndScrollTest.ts b/tests/unit/useSearchAutoRefetchTest.ts similarity index 54% rename from tests/unit/useSearchHighlightAndScrollTest.ts rename to tests/unit/useSearchAutoRefetchTest.ts index dc812f75fa56..f5fc2b7f9c03 100644 --- a/tests/unit/useSearchHighlightAndScrollTest.ts +++ b/tests/unit/useSearchAutoRefetchTest.ts @@ -1,8 +1,8 @@ /* eslint-disable @typescript-eslint/naming-convention */ import {renderHook} from '@testing-library/react-native'; -import useSearchHighlightAndScroll from '@hooks/useSearchHighlightAndScroll'; -import type {UseSearchHighlightAndScroll} from '@hooks/useSearchHighlightAndScroll'; +import useSearchAutoRefetch from '@hooks/useSearchAutoRefetch'; +import type {UseSearchAutoRefetchParams} from '@hooks/useSearchAutoRefetch'; import {search} from '@libs/actions/Search'; @@ -10,11 +10,24 @@ import ONYXKEYS from '@src/ONYXKEYS'; import Onyx from 'react-native-onyx'; +const mockUseIsFocused = jest.fn((): boolean => true); +const mockUseNetwork = jest.fn((): {isOffline: boolean} => ({isOffline: false})); + jest.mock('@libs/actions/Search'); +jest.mock('@hooks/useNetwork', () => ({ + __esModule: true, + default: () => mockUseNetwork(), +})); jest.mock('@react-navigation/native', () => ({ - useIsFocused: jest.fn(() => true), + useIsFocused: () => mockUseIsFocused(), createNavigationContainerRef: () => ({}), })); +// The unfocused path also consults isSearchTopmostFullScreenRoute (an RHP over Search keeps refetching); +// the stubbed navigationRef above can't answer that, so model "not on Search" explicitly. +jest.mock('@libs/Navigation/helpers/isSearchTopmostFullScreenRoute', () => ({ + __esModule: true, + default: jest.fn(() => false), +})); jest.mock('@rnmapbox/maps', () => ({ __esModule: true, default: {}, @@ -22,21 +35,23 @@ jest.mock('@rnmapbox/maps', () => ({ setAccessToken: jest.fn(), })); -const mockUseIsFocused = jest.fn().mockReturnValue(true); +beforeEach(() => { + mockUseIsFocused.mockReturnValue(true); + mockUseNetwork.mockReturnValue({isOffline: false}); +}); afterEach(() => { jest.clearAllMocks(); }); -describe('useSearchHighlightAndScroll', () => { +describe('useSearchAutoRefetch', () => { beforeAll(async () => { Onyx.init({ keys: ONYXKEYS, }); }); - const baseProps: UseSearchHighlightAndScroll = { - shouldUseLiveData: false, + const baseProps: UseSearchAutoRefetchParams = { searchResults: { data: { personalDetailsList: {}, @@ -72,7 +87,7 @@ describe('useSearchHighlightAndScroll', () => { }; it('should not trigger search when collections are empty', () => { - renderHook(() => useSearchHighlightAndScroll(baseProps)); + renderHook(() => useSearchAutoRefetch(baseProps)); expect(search).not.toHaveBeenCalled(); }); @@ -83,7 +98,7 @@ describe('useSearchHighlightAndScroll', () => { previousTransactions: {'1': {transactionID: '1'}}, }; - const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), { + const {rerender} = renderHook((props: UseSearchAutoRefetchParams) => useSearchAutoRefetch(props), { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error initialProps, @@ -107,13 +122,26 @@ describe('useSearchHighlightAndScroll', () => { it('should not trigger search when not focused', () => { mockUseIsFocused.mockReturnValue(false); - const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), { - initialProps: baseProps, + // Non-empty previous data so the hook gets past the initial-load early return and the focus + // check is what actually suppresses the search. + const initialProps = { + ...baseProps, + transactions: {'1': {transactionID: '1'}}, + previousTransactions: {'1': {transactionID: '1'}}, + }; + + const {rerender} = renderHook((props: UseSearchAutoRefetchParams) => useSearchAutoRefetch(props), { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + initialProps, }); const updatedProps = { - ...baseProps, - transactions: {'1': {transactionID: '1'}}, + ...initialProps, + transactions: { + '1': {transactionID: '1'}, + '2': {transactionID: '2'}, + }, }; // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -123,8 +151,6 @@ describe('useSearchHighlightAndScroll', () => { }); it('should trigger search for chat when report actions added and focused', () => { - mockUseIsFocused.mockReturnValue(true); - const chatProps = { ...baseProps, queryJSON: {...baseProps.queryJSON, type: 'chat' as const}, @@ -140,7 +166,7 @@ describe('useSearchHighlightAndScroll', () => { }, }; - const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), { + const {rerender} = renderHook((props: UseSearchAutoRefetchParams) => useSearchAutoRefetch(props), { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error initialProps: chatProps, @@ -175,7 +201,7 @@ describe('useSearchHighlightAndScroll', () => { }, }; - const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), { + const {rerender} = renderHook((props: UseSearchAutoRefetchParams) => useSearchAutoRefetch(props), { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error initialProps, @@ -194,197 +220,99 @@ describe('useSearchHighlightAndScroll', () => { expect(search).not.toHaveBeenCalled(); }); - it('should not trigger search for chat when report actions removed and focused', () => { - mockUseIsFocused.mockReturnValue(true); + it('should defer search while offline and fire it exactly once after reconnect', () => { + mockUseNetwork.mockReturnValue({isOffline: true}); - const chatProps = { + const initialProps = { ...baseProps, - queryJSON: {...baseProps.queryJSON, type: 'chat' as const}, - reportActions: { - reportActions_1: { - '1': {actionName: 'EXISTING', reportActionID: '1'}, - '2': {actionName: 'ADDCOMMENT', reportActionID: '2'}, - }, - }, - previousReportActions: { - reportActions_1: { - '1': {actionName: 'EXISTING', reportActionID: '1'}, - '2': {actionName: 'ADDCOMMENT', reportActionID: '2'}, - }, - }, + transactions: {'1': {transactionID: '1'}}, + previousTransactions: {'1': {transactionID: '1'}}, }; - const {rerender} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), { + const {rerender} = renderHook((props: UseSearchAutoRefetchParams) => useSearchAutoRefetch(props), { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error - initialProps: chatProps, + initialProps, }); - const updatedProps = { - ...chatProps, - reportActions: { - reportActions_1: { - '1': {actionName: 'EXISTING', reportActionID: '1'}, - }, - }, - }; - - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error - rerender(updatedProps); - expect(search).not.toHaveBeenCalled(); - }); - - it('should return multiple new search result keys when there are multiple new expenses', () => { - const {rerender, result} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), { - initialProps: baseProps, - }); - const updatedProps = { + // A new transaction lands while offline: the search must be stashed, not fired. + const offlineChangeProps = { ...baseProps, - searchResults: { - ...baseProps.searchResults, - data: { - transactions_1: { - transactionID: '1', - }, - transactions_2: { - transactionID: '2', - }, - }, - }, transactions: { '1': {transactionID: '1'}, '2': {transactionID: '2'}, - '3': {transactionID: '3'}, - }, - previousTransactions: { - '1': {transactionID: '1'}, }, + previousTransactions: {'1': {transactionID: '1'}}, }; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error - rerender(updatedProps); - expect(result.current.newSearchResultKeys?.size).toBe(2); - }); - - 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}}); + rerender(offlineChangeProps); + expect(search).not.toHaveBeenCalled(); - const {rerender, result} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), { - initialProps: baseProps, - }); - const updatedProps1 = { + // Reconnect with the diff no longer detectable (previous caught up) - only the pending + // flag stashed while offline can trigger the search now. + mockUseNetwork.mockReturnValue({isOffline: false}); + const reconnectedProps = { ...baseProps, - searchResults: { - ...baseProps.searchResults, - data: { - transactions_1: { - transactionID: '1', - }, - transactions_2: { - transactionID: '2', - }, - }, - }, transactions: { '1': {transactionID: '1'}, '2': {transactionID: '2'}, - '3': {transactionID: '3'}, }, previousTransactions: { '1': {transactionID: '1'}, + '2': {transactionID: '2'}, }, - } as unknown as UseSearchHighlightAndScroll; - - // When there is no data yet, even if the transactionID has been added to manual highlight transactionIDs, - // it still will not be included in newSearchResultKeys. - rerender(updatedProps1); - expect(result.current.newSearchResultKeys?.size).toBe(2); - expect([...(result.current.newSearchResultKeys ?? new Set())]).not.toContain('transactions_3'); - - // When the data contains the highlight transactionID, it will be highlighted. - const updatedProps2 = { - ...updatedProps1, - searchResults: { - ...updatedProps1.searchResults, - data: { - transactions_1: { - transactionID: '1', - }, - transactions_2: { - transactionID: '2', - }, - transactions_3: { - transactionID: '3', - }, - }, - }, - } as unknown as UseSearchHighlightAndScroll; + }; - rerender(updatedProps2); - expect(result.current.newSearchResultKeys?.size).toBe(1); - expect([...(result.current.newSearchResultKeys ?? new Set())]).toContain('transactions_3'); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + rerender(reconnectedProps); + expect(search).toHaveBeenCalledTimes(1); - expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledWith(baseProps.queryJSON.type, {'3': false}); + // Further data-identical updates must not re-fire. + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + rerender({...reconnectedProps, transactions: {...reconnectedProps.transactions}}); + expect(search).toHaveBeenCalledTimes(1); }); - it('should return multiple new search result keys when there are multiple new chats', () => { + it('should not trigger search for chat when report actions removed and focused', () => { const chatProps = { ...baseProps, queryJSON: {...baseProps.queryJSON, type: 'chat' as const}, reportActions: { reportActions_1: { '1': {actionName: 'EXISTING', reportActionID: '1'}, + '2': {actionName: 'ADDCOMMENT', reportActionID: '2'}, + }, + }, + previousReportActions: { + reportActions_1: { + '1': {actionName: 'EXISTING', reportActionID: '1'}, + '2': {actionName: 'ADDCOMMENT', reportActionID: '2'}, }, }, }; - const {rerender, result} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), { + + const {rerender} = renderHook((props: UseSearchAutoRefetchParams) => useSearchAutoRefetch(props), { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error initialProps: chatProps, }); + const updatedProps = { ...chatProps, - searchResults: { - ...baseProps.searchResults, - data: { - reportActions_1: { - '1': {actionName: 'EXISTING', reportActionID: '1'}, - }, - reportActions_2: { - '2': {actionName: 'EXISTING', reportActionID: '2'}, - }, - }, - }, reportActions: { reportActions_1: { '1': {actionName: 'EXISTING', reportActionID: '1'}, }, - reportActions_2: { - '2': {actionName: 'EXISTING', reportActionID: '2'}, - }, - reportActions_3: { - '3': {actionName: 'EXISTING', reportActionID: '3'}, - }, - }, - previousReportActions: { - reportActions_1: { - '1': {actionName: 'EXISTING', reportActionID: '1'}, - }, }, }; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error rerender(updatedProps); - expect(result.current.newSearchResultKeys?.size).toBe(2); + expect(search).not.toHaveBeenCalled(); }); });