diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 91b60f5acff8a..35492d85251e7 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -644,6 +644,9 @@ const ONYXKEYS = { /** Keeps track of whether the "Confirm Navigate to Expensify Classic" modal is opened */ IS_OPEN_CONFIRM_NAVIGATE_EXPENSIFY_CLASSIC_MODAL_OPEN: 'IsOpenConfirmNavigateExpensifyClassicModalOpen', + /** The transaction IDs to be highlighted when opening the Expenses search route page */ + TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE: 'transactionIdsHighlightOnSearchRoute', + /** Collection Keys */ COLLECTION: { DOMAIN: 'domain_', @@ -1378,6 +1381,7 @@ type OnyxValuesMapping = { [ONYXKEYS.HAS_DENIED_CONTACT_IMPORT_PROMPT]: boolean | undefined; [ONYXKEYS.IS_OPEN_CONFIRM_NAVIGATE_EXPENSIFY_CLASSIC_MODAL_OPEN]: boolean; [ONYXKEYS.PERSONAL_POLICY_ID]: string; + [ONYXKEYS.TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE]: Record>; }; type OnyxDerivedValuesMapping = { diff --git a/src/hooks/useSearchHighlightAndScroll.ts b/src/hooks/useSearchHighlightAndScroll.ts index bb2826105dc40..6751c6e2b2d2d 100644 --- a/src/hooks/useSearchHighlightAndScroll.ts +++ b/src/hooks/useSearchHighlightAndScroll.ts @@ -7,10 +7,13 @@ import type {SearchListItem, SelectionListHandle, TransactionGroupListItemType, import {search} from '@libs/actions/Search'; import {isReportActionEntry} from '@libs/SearchUIUtils'; import type {SearchKey} from '@libs/SearchUIUtils'; +import {mergeTransactionIdsHighlightOnSearchRoute} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {ReportActions, SearchResults, Transaction} from '@src/types/onyx'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; import useNetwork from './useNetwork'; +import useOnyx from './useOnyx'; import usePrevious from './usePrevious'; type UseSearchHighlightAndScroll = { @@ -52,6 +55,12 @@ function useSearchHighlightAndScroll({ 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, { + canBeMissing: true, + selector: transactionIDsToHighlightSelector, + }); + const existingSearchResultIDs = useMemo(() => { if (!searchResults?.data) { return []; @@ -201,11 +210,20 @@ function useSearchHighlightAndScroll({ } 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) => !previousTransactionIDs.includes(id) && !highlightedIDs.current.has(id)); + 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 (!triggeredByHookRef.current || newTransactionIDs.length === 0 || !hasNewItemsRef.current) { + if (newTransactionIDs.length === 0) { return; } @@ -217,7 +235,31 @@ function useSearchHighlightAndScroll({ } setNewSearchResultKeys(newKeys); } - }, [searchResults?.data, previousSearchResults, isChat]); + }, [searchResults?.data, previousSearchResults, isChat, transactionIDsToHighlight]); + + // Reset transactionIDsToHighlight after they have been highlighted + useEffect(() => { + if (isEmptyObject(transactionIDsToHighlight) || newSearchResultKeys === null) { + return; + } + + const highlightedTransactionIDs = Object.keys(transactionIDsToHighlight).filter( + (id) => transactionIDsToHighlight[id] && newSearchResultKeys?.has(`${ONYXKEYS.COLLECTION.TRANSACTION}${id}`), + ); + + const timer = setTimeout(() => { + mergeTransactionIdsHighlightOnSearchRoute(queryJSON.type, Object.fromEntries(highlightedTransactionIDs.map((id) => [id, false]))); + }, CONST.ANIMATED_HIGHLIGHT_START_DURATION); + return () => clearTimeout(timer); + }, [transactionIDsToHighlight, queryJSON.type, newSearchResultKeys]); + + // 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(() => { diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 95a20b642728e..b61ebb1f2a5fb 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -71,6 +71,7 @@ import type {Attendee, Participant, SplitExpense} from '@src/types/onyx/IOU'; import type {Errors, PendingAction} from '@src/types/onyx/OnyxCommon'; import type {CurrentUserPersonalDetails} from '@src/types/onyx/PersonalDetails'; import type {OnyxData} from '@src/types/onyx/Request'; +import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; import type { Comment, Receipt, @@ -2460,6 +2461,11 @@ function shouldReuseInitialTransaction( return !isMultiScanEnabled || (transactions.length === 1 && (!initialTransaction.receipt?.source || initialTransaction.receipt?.isTestReceipt === true)); } +function mergeTransactionIdsHighlightOnSearchRoute(type: SearchDataTypes, data: Record | null) { + // eslint-disable-next-line rulesdir/prefer-actions-set-data + return Onyx.merge(ONYXKEYS.TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE, {[type]: data}); +} + export { buildOptimisticTransaction, calculateTaxAmount, @@ -2587,6 +2593,7 @@ export { getOriginalAmountForDisplay, getOriginalCurrencyForDisplay, shouldShowExpenseBreakdown, + mergeTransactionIdsHighlightOnSearchRoute, }; export type {TransactionChanges}; diff --git a/src/libs/actions/IOU/SendInvoice.ts b/src/libs/actions/IOU/SendInvoice.ts index dc3f9d285feff..b9d2f2aca61cc 100644 --- a/src/libs/actions/IOU/SendInvoice.ts +++ b/src/libs/actions/IOU/SendInvoice.ts @@ -8,8 +8,6 @@ import DateUtils from '@libs/DateUtils'; import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; import {formatPhoneNumber} from '@libs/LocalePhoneNumber'; import Log from '@libs/Log'; -import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; -import Navigation from '@libs/Navigation/Navigation'; import {getReportActionHtml, getReportActionText} from '@libs/ReportActionsUtils'; import type {OptimisticChatReport, OptimisticCreatedReportAction, OptimisticIOUReportAction} from '@libs/ReportUtils'; import { @@ -34,7 +32,7 @@ import type {InvoiceReceiver, InvoiceReceiverType} from '@src/types/onyx/Report' import type {OnyxData} from '@src/types/onyx/Request'; import type {Receipt} from '@src/types/onyx/Transaction'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import {getAllPersonalDetails, getReceiptError, getSearchOnyxUpdate, mergePolicyRecentlyUsedCategories, mergePolicyRecentlyUsedCurrencies} from '.'; +import {getAllPersonalDetails, getReceiptError, getSearchOnyxUpdate, handleNavigateAfterExpenseCreate, mergePolicyRecentlyUsedCategories, mergePolicyRecentlyUsedCurrencies} from '.'; import type {BasePolicyParams} from '.'; type SendInvoiceInformation = { @@ -65,6 +63,7 @@ type SendInvoiceOptions = { companyWebsite?: string; policyRecentlyUsedCategories?: OnyxEntry; policyRecentlyUsedTags?: OnyxEntry; + isFromGlobalCreate?: boolean; }; type BuildOnyxDataForInvoiceParams = { @@ -675,6 +674,7 @@ function sendInvoice({ companyWebsite, policyRecentlyUsedCategories, policyRecentlyUsedTags, + isFromGlobalCreate, }: SendInvoiceOptions) { const parsedComment = getParsedComment(transaction?.comment?.comment?.trim() ?? ''); if (transaction?.comment) { @@ -738,11 +738,12 @@ function sendInvoice({ // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.runAfterInteractions(() => removeDraftTransaction(CONST.IOU.OPTIMISTIC_TRANSACTION_ID)); - if (isSearchTopmostFullScreenRoute()) { - Navigation.dismissModal(); - } else { - Navigation.dismissModalWithReport({reportID: invoiceRoom.reportID}); - } + handleNavigateAfterExpenseCreate({ + activeReportID: invoiceRoom.reportID, + transactionID, + isFromGlobalCreate, + isInvoice: true, + }); notifyNewAction(invoiceRoom.reportID, currentUserAccountID); } diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index 4caa9400bc6fc..3793671fc92bb 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -66,6 +66,7 @@ import * as Localize from '@libs/Localize'; import Log from '@libs/Log'; import {validateAmount} from '@libs/MoneyRequestUtils'; import isReportOpenInRHP from '@libs/Navigation/helpers/isReportOpenInRHP'; +import isReportTopmostSplitNavigator from '@libs/Navigation/helpers/isReportTopmostSplitNavigator'; import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; import {isOffline} from '@libs/Network/NetworkStore'; @@ -199,7 +200,7 @@ import { shouldEnableNegative, updateReportPreview, } from '@libs/ReportUtils'; -import {getCurrentSearchQueryJSON} from '@libs/SearchQueryUtils'; +import {buildCannedSearchQuery, getCurrentSearchQueryJSON} from '@libs/SearchQueryUtils'; import {getSuggestedSearches} from '@libs/SearchUIUtils'; import playSound, {SOUNDS} from '@libs/Sound'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; @@ -230,6 +231,7 @@ import { isPerDiemRequest as isPerDiemRequestTransactionUtils, isScanning, isScanRequest as isScanRequestTransactionUtils, + mergeTransactionIdsHighlightOnSearchRoute, removeTransactionFromDuplicateTransactionViolation, } from '@libs/TransactionUtils'; import ViolationsUtils from '@libs/Violations/ViolationsUtils'; @@ -281,6 +283,7 @@ type BaseTransactionParams = { billable?: boolean; reimbursable?: boolean; customUnitRateID?: string; + isFromGlobalCreate?: boolean; }; type InitMoneyRequestParams = { @@ -652,6 +655,7 @@ type TrackExpenseTransactionParams = { customUnitRateID?: string; attendees?: Attendee[]; isLinkedTrackedExpenseReportArchived?: boolean; + isFromGlobalCreate?: boolean; }; type TrackExpenseAccountantParams = { @@ -950,9 +954,9 @@ Onyx.connect({ * If the action is done from the report RHP, then we just want to dismiss the money request flow screens. * It is a helper function used only in this file. */ -function dismissModalAndOpenReportInInboxTab(reportID?: string) { +function dismissModalAndOpenReportInInboxTab(reportID?: string, isInvoice?: boolean) { const rootState = navigationRef.getRootState(); - if (isReportOpenInRHP(rootState)) { + if (!isInvoice && isReportOpenInRHP(rootState)) { const rhpKey = rootState.routes.at(-1)?.state?.key; if (rhpKey) { const hasMultipleTransactions = Object.values(allTransactions).filter((transaction) => transaction?.reportID === reportID).length > 0; @@ -975,6 +979,52 @@ function dismissModalAndOpenReportInInboxTab(reportID?: string) { Navigation.dismissModalWithReport({reportID}); } +/** + * Helper to navigate after an expense is created in order to standardize the post‑creation experience + * when creating an expense from the global create button. + * If the expense is created from the global create button then: + * - If it is created on the inbox tab, it will open the chat report containing that expense. + * - If it is created elsewhere, it will navigate to Reports > Expense and highlight the newly created expense. + */ +function handleNavigateAfterExpenseCreate({ + activeReportID, + transactionID, + isFromGlobalCreate, + isInvoice, + shouldHandleNavigation = true, +}: { + activeReportID?: string; + transactionID?: string; + isFromGlobalCreate?: boolean; + isInvoice?: boolean; + shouldHandleNavigation?: boolean; +}) { + const isUserOnInbox = isReportTopmostSplitNavigator(); + + // 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 (shouldHandleNavigation) { + dismissModalAndOpenReportInInboxTab(activeReportID, isInvoice); + } + return; + } + + const type = isInvoice ? CONST.SEARCH.DATA_TYPES.INVOICE : CONST.SEARCH.DATA_TYPES.EXPENSE; + // We mark this transaction to be highlighted when opening the expense search route page + mergeTransactionIdsHighlightOnSearchRoute(type, {[transactionID]: true}); + + if (!shouldHandleNavigation) { + return; + } + const queryString = buildCannedSearchQuery({type}); + Navigation.dismissModal(); + Navigation.setNavigationActionToMicrotaskQueue(() => { + Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: queryString})); + }); +} + /** * Find the report preview action from given chat report and iou report */ @@ -5808,6 +5858,7 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep customUnitRateID, isTestDrive, isLinkedTrackedExpenseReportArchived, + isFromGlobalCreate, } = transactionParams; const testDriveCommentReportActionID = isTestDrive ? NumberUtils.rand64() : undefined; @@ -5994,9 +6045,6 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep if (shouldHandleNavigation) { // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.runAfterInteractions(() => removeDraftTransactions()); - if (!requestMoneyInformation.isRetry) { - dismissModalAndOpenReportInInboxTab(backToReport ?? activeReportID); - } const trackReport = Navigation.getReportRouteByID(linkedTrackedExpenseReportAction?.childReportID); if (trackReport?.key) { @@ -6004,6 +6052,15 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep } } + if (!requestMoneyInformation.isRetry) { + handleNavigateAfterExpenseCreate({ + activeReportID: backToReport ?? activeReportID, + transactionID: transaction.transactionID, + isFromGlobalCreate, + shouldHandleNavigation, + }); + } + if (activeReportID && !isMoneyRequestReport) { Navigation.setNavigationActionToMicrotaskQueue(() => setTimeout(() => { @@ -6032,7 +6089,7 @@ function submitPerDiemExpense(submitPerDiemExpenseInformation: PerDiemExpenseInf policyRecentlyUsedCurrencies, } = submitPerDiemExpenseInformation; const {payeeAccountID} = participantParams; - const {currency, comment = '', category, tag, created, customUnit, attendees} = transactionParams; + const {currency, comment = '', category, tag, created, customUnit, attendees, isFromGlobalCreate} = transactionParams; if ( isEmptyObject(policyParams.policy) || @@ -6117,7 +6174,7 @@ function submitPerDiemExpense(submitPerDiemExpenseInformation: PerDiemExpenseInf // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.runAfterInteractions(() => removeDraftTransaction(CONST.IOU.OPTIMISTIC_TRANSACTION_ID)); - dismissModalAndOpenReportInInboxTab(activeReportID); + handleNavigateAfterExpenseCreate({activeReportID, transactionID: transaction.transactionID, isFromGlobalCreate}); if (activeReportID) { notifyNewAction(activeReportID, payeeAccountID); @@ -6170,6 +6227,7 @@ function trackExpense(params: CreateTrackExpenseParams) { linkedTrackedExpenseReportID, customUnitRateID, attendees, + isFromGlobalCreate, } = transactionData; const isMoneyRequestReport = isMoneyRequestReportReportUtils(report); const currentChatReport = isMoneyRequestReport ? getReportOrDraftReport(report?.chatReportID) : report; @@ -6442,10 +6500,15 @@ function trackExpense(params: CreateTrackExpenseParams) { if (shouldHandleNavigation) { // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.runAfterInteractions(() => removeDraftTransactions()); + } - if (!params.isRetry) { - dismissModalAndOpenReportInInboxTab(activeReportID); - } + if (!params.isRetry) { + handleNavigateAfterExpenseCreate({ + activeReportID, + transactionID: transaction?.transactionID, + isFromGlobalCreate, + shouldHandleNavigation, + }); } notifyNewAction(activeReportID, payeeAccountID); @@ -8008,6 +8071,7 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest splitShares = {}, attendees, receipt, + isFromGlobalCreate, } = transactionParams; // If the report is an iou or expense report, we should get the linked chat report to be passed to the getMoneyRequestInformation function @@ -8187,7 +8251,7 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.runAfterInteractions(() => removeDraftTransaction(CONST.IOU.OPTIMISTIC_TRANSACTION_ID)); const activeReportID = isMoneyRequestReport && report?.reportID ? report.reportID : parameters.chatReportID; - dismissModalAndOpenReportInInboxTab(backToReport ?? activeReportID); + handleNavigateAfterExpenseCreate({activeReportID: backToReport ?? activeReportID, isFromGlobalCreate, transactionID: parameters.transactionID}); if (!isMoneyRequestReport) { notifyNewAction(activeReportID, userAccountID); @@ -14757,6 +14821,7 @@ export { getAllPersonalDetails, getReceiptError, getSearchOnyxUpdate, + handleNavigateAfterExpenseCreate, }; export type { GPSPoint as GpsPoint, diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index aa9b65d5d7120..fa0b07b178e69 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -607,6 +607,7 @@ function IOURequestStepConfirmation({ originalTransactionID: item.comment?.originalTransactionID, source: item.comment?.source, isLinkedTrackedExpenseReportArchived, + isFromGlobalCreate: item?.isFromGlobalCreate, }, shouldHandleNavigation: index === transactions.length - 1, shouldGenerateTransactionThreadReport, @@ -688,6 +689,7 @@ function IOURequestStepConfirmation({ billable: transaction.billable, reimbursable: transaction.reimbursable, attendees: transaction.comment?.attendees, + isFromGlobalCreate: transaction.isFromGlobalCreate, }, isASAPSubmitBetaEnabled, currentUserAccountIDParam: currentUserPersonalDetails.accountID, @@ -761,6 +763,7 @@ function IOURequestStepConfirmation({ customUnitRateID, attendees: item.comment?.attendees, isLinkedTrackedExpenseReportArchived, + isFromGlobalCreate: item?.isFromGlobalCreate, }, accountantParams: { accountant: item.accountant, @@ -835,6 +838,7 @@ function IOURequestStepConfirmation({ reimbursable: transaction.reimbursable, attendees: transaction.comment?.attendees, receipt: isManualDistanceRequest ? receiptFiles[transaction.transactionID] : undefined, + isFromGlobalCreate: transaction.isFromGlobalCreate, }, backToReport, isASAPSubmitBetaEnabled, @@ -1015,6 +1019,7 @@ function IOURequestStepConfirmation({ policyTagList: policyTags, policyCategories, policyRecentlyUsedCategories, + isFromGlobalCreate: transaction?.isFromGlobalCreate, policyRecentlyUsedTags, }); return; diff --git a/tests/actions/IOUTest.ts b/tests/actions/IOUTest.ts index 307ba204dea47..5b0a1a01b2bde 100644 --- a/tests/actions/IOUTest.ts +++ b/tests/actions/IOUTest.ts @@ -28,6 +28,7 @@ import { getPerDiemExpenseInformation, getReportOriginalCreationTimestamp, getReportPreviewAction, + handleNavigateAfterExpenseCreate, initMoneyRequest, initSplitExpense, markRejectViolationAsResolved, @@ -64,6 +65,7 @@ import {subscribeToUserEvents} from '@libs/actions/User'; import type {ApiCommand} from '@libs/API/types'; import {WRITE_COMMANDS} from '@libs/API/types'; import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; +import isReportTopmostSplitNavigator from '@libs/Navigation/helpers/isReportTopmostSplitNavigator'; import Navigation from '@libs/Navigation/Navigation'; import {rand64} from '@libs/NumberUtils'; import {getLoginsByAccountIDs} from '@libs/PersonalDetailsUtils'; @@ -148,6 +150,7 @@ jest.mock('@src/libs/actions/Report', () => { }; }); jest.mock('@libs/Navigation/helpers/isSearchTopmostFullScreenRoute', () => jest.fn()); +jest.mock('@libs/Navigation/helpers/isReportTopmostSplitNavigator', () => jest.fn()); const unapprovedCashHash = 71801560; const unapprovedCashSimilarSearchHash = 1832274510; @@ -11957,4 +11960,41 @@ describe('actions/IOU', () => { expect(result).toBe(reportCreatedTimestamp); }); }); + + it('handleNavigateAfterExpenseCreate', async () => { + const mockedIsReportTopmostSplitNavigator = isReportTopmostSplitNavigator as jest.MockedFunction; + const spyOnMergeTransactionIdsHighlightOnSearchRoute = jest.spyOn(require('@libs/TransactionUtils'), 'mergeTransactionIdsHighlightOnSearchRoute'); + const activeReportID = '1'; + const transactionID = '1'; + mockedIsReportTopmostSplitNavigator.mockReturnValue(false); + + // When on the Inbox tab, or NOT from the "global create" button, or without a transactionID, + // the function dismissModalAndOpenReportInInboxTab will always be called to handle it, + // so mergeTransactionIdsHighlightOnSearchRoute will never be invoked. + 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); + + // When NOT on the Inbox tab + mockedIsReportTopmostSplitNavigator.mockReturnValue(false); + handleNavigateAfterExpenseCreate({activeReportID, isFromGlobalCreate: true, transactionID}); + + // then mergeTransactionIdsHighlightOnSearchRoute will be called + expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledTimes(1); + expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledWith(CONST.SEARCH.DATA_TYPES.EXPENSE, {[transactionID]: true}); + spyOnMergeTransactionIdsHighlightOnSearchRoute.mockClear(); + + // If expense is an invoice + handleNavigateAfterExpenseCreate({activeReportID, isFromGlobalCreate: true, transactionID, isInvoice: true}); + + expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledTimes(1); + expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledWith(CONST.SEARCH.DATA_TYPES.INVOICE, {[transactionID]: true}); + spyOnMergeTransactionIdsHighlightOnSearchRoute.mockReset(); + }); }); diff --git a/tests/unit/useSearchHighlightAndScrollTest.ts b/tests/unit/useSearchHighlightAndScrollTest.ts index 8bf7650b979fe..8d01f4e613cb7 100644 --- a/tests/unit/useSearchHighlightAndScrollTest.ts +++ b/tests/unit/useSearchHighlightAndScrollTest.ts @@ -1,9 +1,11 @@ /* eslint-disable @typescript-eslint/naming-convention */ import {renderHook} from '@testing-library/react-native'; +import Onyx from 'react-native-onyx'; import useSearchHighlightAndScroll from '@hooks/useSearchHighlightAndScroll'; import type {UseSearchHighlightAndScroll} from '@hooks/useSearchHighlightAndScroll'; import {search} from '@libs/actions/Search'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; jest.mock('@libs/actions/Search'); jest.mock('@react-navigation/native', () => ({ @@ -255,6 +257,81 @@ describe('useSearchHighlightAndScroll', () => { 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/TransactionUtils'), 'mergeTransactionIdsHighlightOnSearchRoute').mockImplementationOnce(jest.fn()); + + await Onyx.merge(ONYXKEYS.TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE, {[baseProps.queryJSON.type]: {'3': true}}); + + const {rerender, result} = renderHook((props: UseSearchHighlightAndScroll) => useSearchHighlightAndScroll(props), { + initialProps: baseProps, + }); + const updatedProps1 = { + ...baseProps, + searchResults: { + ...baseProps.searchResults, + data: { + transactions_1: { + transactionID: '1', + }, + transactions_2: { + transactionID: '2', + }, + }, + }, + transactions: { + '1': {transactionID: '1'}, + '2': {transactionID: '2'}, + '3': {transactionID: '3'}, + }, + previousTransactions: { + '1': {transactionID: '1'}, + }, + }; + + // 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. + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + rerender(updatedProps1); + expect(result.current.newSearchResultKeys?.size).toBe(2); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + expect([...result.current.newSearchResultKeys!]).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', + }, + }, + }, + }; + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + rerender(updatedProps2); + expect(result.current.newSearchResultKeys?.size).toBe(1); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + expect([...result.current.newSearchResultKeys!]).toContain('transactions_3'); + + // Wait 1s for the timer in useSearchHighlightAndScroll to complete. + await new Promise((resolve) => { + setTimeout(resolve, 1000); + }); + + expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledTimes(1); + expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledWith(baseProps.queryJSON.type, {'3': false}); + }); + it('should return multiple new search result keys when there are multiple new chats', () => { const chatProps = { ...baseProps,