From c0879ae92c38826b472c0a58246ed442134d5856 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Tue, 23 Sep 2025 00:20:37 +0530 Subject: [PATCH 1/3] Handle navigation properly when clicking on report link --- src/libs/ReportUtils.ts | 73 ++++++++++++++++++++++++++++------- tests/unit/ReportUtilsTest.ts | 73 +++++++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+), 15 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 14ee2d1713ab..c9f20282996f 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -110,6 +110,7 @@ import {isEmailPublicDomain} from './LoginUtils'; import {getForReportAction, getMovedReportID} from './ModifiedExpenseMessage'; import getStateFromPath from './Navigation/helpers/getStateFromPath'; import {isFullScreenName} from './Navigation/helpers/isNavigatorName'; +import isSearchTopmostFullScreenRoute from './Navigation/helpers/isSearchTopmostFullScreenRoute'; import {linkingConfig} from './Navigation/linkingConfig'; import Navigation, {navigationRef} from './Navigation/Navigation'; import type {MoneyRequestNavigatorParamList, ReportsSplitNavigatorParamList} from './Navigation/types'; @@ -6459,12 +6460,8 @@ function getDeletedTransactionMessage(action: ReportAction) { function getMovedTransactionMessage(report: OnyxEntry) { const reportName = getReportName(report) ?? report?.reportName ?? ''; - const reportUrl = `${environmentURL}/r/${report?.reportID}`; - const message = translateLocal('iou.movedTransaction', { - reportUrl, - reportName, - }); - return message; + const reportUrl = getReportURLForCurrentContext(report?.reportID); + return translateLocal('iou.movedTransaction', {reportUrl, reportName}); } function getUnreportedTransactionMessage() { @@ -6489,8 +6486,8 @@ function getMovedActionMessage(action: ReportAction, report: OnyxEntry) const toPolicyName = getPolicyNameByID(toPolicyID); return translateLocal('iou.movedAction', { shouldHideMovedReportUrl: !isDM(report), - movedReportUrl: `${environmentURL}/r/${movedReportID}`, - newParentReportUrl: `${environmentURL}/r/${newParentReportID}`, + movedReportUrl: getReportURLForCurrentContext(movedReportID), + newParentReportUrl: getReportURLForCurrentContext(newParentReportID), toPolicyName, }); } @@ -6814,8 +6811,8 @@ function buildOptimisticMovedReportAction( const movedActionMessage = [ { html: shouldHideMovedReportUrl - ? `moved this report to the ${policyName} workspace` - : `moved this report to the ${policyName} workspace`, + ? `moved this report to the ${policyName} workspace` + : `moved this report to the ${policyName} workspace`, text: `moved this report to the ${policyName} workspace`, type: CONST.REPORT.MESSAGE.TYPE.COMMENT, }, @@ -6902,11 +6899,8 @@ function buildOptimisticTransactionAction( targetReportID: string, ): ReportAction { const reportName = allReports?.[targetReportID]?.reportName ?? ''; - const url = `${environmentURL}/r/${targetReportID}`; - const [actionText, messageHtml] = - type === CONST.REPORT.ACTIONS.TYPE.MOVED_TRANSACTION - ? [`moved this expense to ${reportName}`, `moved this expense to ${reportName}`] - : ['moved this expense to your personal space', 'moved this expense to your personal space']; + const url = getReportURLForCurrentContext(targetReportID); + const [actionText, messageHtml] = [`moved this expense to ${reportName}`, `moved this expense to ${reportName}`]; return { actionName: type, @@ -11896,6 +11890,54 @@ function getReportStatusTranslation(stateNum?: number, statusNum?: number): stri return ''; } +function getReportURLForCurrentContext(reportID: string | undefined): string { + if (!reportID) { + return `${environmentURL}/r/`; + } + const isInSearchContext = isSearchTopmostFullScreenRoute(); + if (!isInSearchContext) { + return `${environmentURL}/${ROUTES.REPORT_WITH_ID.getRoute(reportID)}`; + } + + // Navigation can return routes with a leading slash or missing when still mounting. + // Normalize everything to match the path shape used by ROUTES helpers. + const normalizeRoute = (route?: string) => { + if (!route) { + return undefined; + } + return route.startsWith('/') ? route.substring(1) : route; + }; + + const activeRoute = normalizeRoute(Navigation.getActiveRoute()); + + let backToRoute: string | undefined; + + if (activeRoute) { + const [, queryString = ''] = activeRoute.split('?'); + if (queryString) { + const params = new URLSearchParams(queryString); + const encodedBackTo = params.get('backTo'); + if (encodedBackTo) { + // Prefer the backTo param when present; it points to the exact search state we left. + backToRoute = normalizeRoute(decodeURIComponent(encodedBackTo)); + } + } + + if (!backToRoute && activeRoute.startsWith(ROUTES.SEARCH_ROOT.route)) { + // Otherwise keep the current search route (preserves tab + filters) as the return target. + backToRoute = activeRoute; + } + } + + if (!backToRoute || !backToRoute.startsWith(ROUTES.SEARCH_ROOT.route)) { + // Fall back to the generic search home when we can't recover a valid route. + backToRoute = ROUTES.SEARCH_ROOT.route; + } + + const relativePath = ROUTES.SEARCH_MONEY_REQUEST_REPORT.getRoute({reportID, backTo: backToRoute}); + return `${environmentURL}/${relativePath}`; +} + export { areAllRequestsBeingSmartScanned, buildOptimisticAddCommentReportAction, @@ -12286,6 +12328,7 @@ export { isWorkspaceThread, isMoneyRequestReportEligibleForMerge, getReportStatusTranslation, + getReportURLForCurrentContext, getMovedActionMessage, excludeParticipantsForDisplay, getReportName, diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index c2736c703b2e..1ed6a77000cf 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -8,8 +8,11 @@ import useReportIsArchived from '@hooks/useReportIsArchived'; import {putOnHold} from '@libs/actions/IOU'; import type {OnboardingTaskLinks} from '@libs/actions/Welcome/OnboardingFlow'; import DateUtils from '@libs/DateUtils'; +import {getEnvironmentURL} from '@libs/Environment/Environment'; import getBase62ReportID from '@libs/getBase62ReportID'; import {translateLocal} from '@libs/Localize'; +import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; +import Navigation from '@libs/Navigation/Navigation'; import {getOriginalMessage, isWhisperAction} from '@libs/ReportActionsUtils'; import { buildOptimisticChatReport, @@ -56,6 +59,7 @@ import { getReportIDFromLink, getReportName, getReportStatusTranslation, + getReportURLForCurrentContext, getSearchReportName, getWorkspaceIcon, getWorkspaceNameUpdatedMessage, @@ -90,6 +94,7 @@ import {buildOptimisticTransaction} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; import type { Beta, OnyxInputOrEntry, @@ -140,6 +145,8 @@ import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; // Be sure to include the mocked permissions library or else the beta tests won't work jest.mock('@libs/Permissions'); +jest.mock('@libs/Navigation/helpers/isSearchTopmostFullScreenRoute', () => jest.fn()); + jest.mock('@libs/Navigation/Navigation', () => ({ setNavigationActionToMicrotaskQueue: jest.fn(), navigate: jest.fn(), @@ -151,6 +158,7 @@ jest.mock('@libs/Navigation/Navigation', () => ({ }, })), }, + getActiveRoute: jest.fn(() => 'search?q=type:report'), })); const testDate = DateUtils.getDBTime(); @@ -7291,6 +7299,71 @@ describe('ReportUtils', () => { }); }); + describe('getReportURLForCurrentContext', () => { + const flushPromises = () => + new Promise((resolve) => { + setImmediate(resolve); + }); + const mockIsSearchTopmostFullScreenRoute = jest.mocked(isSearchTopmostFullScreenRoute); + let environmentURL: string; + + beforeAll(async () => { + environmentURL = await getEnvironmentURL(); + await flushPromises(); + }); + + afterAll(() => { + mockIsSearchTopmostFullScreenRoute.mockRestore(); + }); + + const mockGetActiveRoute = Navigation.getActiveRoute as jest.Mock; + + beforeEach(() => { + mockIsSearchTopmostFullScreenRoute.mockReset(); + mockIsSearchTopmostFullScreenRoute.mockReturnValue(false); + mockGetActiveRoute.mockReset(); + mockGetActiveRoute.mockReturnValue('search?q=type:report'); + }); + + it('returns report route when not in search context', () => { + const reportID = '123'; + expect(getReportURLForCurrentContext(reportID)).toBe(`${environmentURL}/${ROUTES.REPORT_WITH_ID.getRoute(reportID)}`); + }); + + it('returns search route when in search context', () => { + const reportID = '456'; + mockIsSearchTopmostFullScreenRoute.mockReturnValue(true); + const encodedBackTo = 'search%3Fq%3Dtype%3Areport'; + mockGetActiveRoute.mockReturnValue(`search/r/999?backTo=${encodedBackTo}`); + expect(getReportURLForCurrentContext(reportID)).toBe(`${environmentURL}/${ROUTES.SEARCH_MONEY_REQUEST_REPORT.getRoute({reportID, backTo: 'search?q=type:report'})}`); + }); + + it('uses current search route when no backTo parameter is present', () => { + const reportID = '111'; + mockIsSearchTopmostFullScreenRoute.mockReturnValue(true); + mockGetActiveRoute.mockReturnValue('search?q=type:invoice'); + expect(getReportURLForCurrentContext(reportID)).toBe(`${environmentURL}/${ROUTES.SEARCH_MONEY_REQUEST_REPORT.getRoute({reportID, backTo: 'search?q=type:invoice'})}`); + }); + + it('normalizes leading slash in search routes', () => { + const reportID = '222'; + mockIsSearchTopmostFullScreenRoute.mockReturnValue(true); + mockGetActiveRoute.mockReturnValue('/search?q=type:card'); + expect(getReportURLForCurrentContext(reportID)).toBe(`${environmentURL}/${ROUTES.SEARCH_MONEY_REQUEST_REPORT.getRoute({reportID, backTo: 'search?q=type:card'})}`); + }); + + it('falls back to default search route when current route is unavailable', () => { + const reportID = '789'; + mockIsSearchTopmostFullScreenRoute.mockReturnValue(true); + mockGetActiveRoute.mockReturnValue(''); + expect(getReportURLForCurrentContext(reportID)).toBe(`${environmentURL}/${ROUTES.SEARCH_MONEY_REQUEST_REPORT.getRoute({reportID, backTo: ROUTES.SEARCH_ROOT.route})}`); + }); + + it('falls back to the base report path when reportID is missing', () => { + expect(getReportURLForCurrentContext(undefined)).toBe(`${environmentURL}/r/`); + }); + }); + describe('requiresManualSubmission', () => { it('should return true when manual submit is enabled', () => { const report: Report = { From 3558ed3b4c27a4fae44e38e467cbc739025435d1 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Fri, 10 Oct 2025 01:40:11 +0530 Subject: [PATCH 2/3] Updates --- src/libs/ReportUtils.ts | 2 +- tests/unit/ReportUtilsTest.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index c9f20282996f..6cab1f2fe20e 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -11929,7 +11929,7 @@ function getReportURLForCurrentContext(reportID: string | undefined): string { } } - if (!backToRoute || !backToRoute.startsWith(ROUTES.SEARCH_ROOT.route)) { + if (!backToRoute?.startsWith(ROUTES.SEARCH_ROOT.route)) { // Fall back to the generic search home when we can't recover a valid route. backToRoute = ROUTES.SEARCH_ROOT.route; } diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 1ed6a77000cf..68e3570eff05 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -158,7 +158,6 @@ jest.mock('@libs/Navigation/Navigation', () => ({ }, })), }, - getActiveRoute: jest.fn(() => 'search?q=type:report'), })); const testDate = DateUtils.getDBTime(); From 9b2af2bd19e96888e45bc7ab06cd233b34da581d Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Mon, 13 Oct 2025 12:46:01 +0530 Subject: [PATCH 3/3] cspell fix --- cspell.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cspell.json b/cspell.json index 5bd564724e21..31106a7455b0 100644 --- a/cspell.json +++ b/cspell.json @@ -759,7 +759,9 @@ "مثال", "moveElemsAttrsToGroup", "removeHiddenElems", - "moveGroupAttrsToElems" + "moveGroupAttrsToElems", + "Dtype", + "Areport" ], "ignorePaths": [ "src/languages/de.ts",