From 5286db97c918f0bfe1b21c0e4e6123132cd3d7e8 Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Fri, 14 Nov 2025 00:33:29 +0100 Subject: [PATCH 001/182] Handle the preExistingReportID for transaction threads and single expense reports --- src/libs/ReportUtils.ts | 1 + src/libs/actions/Report.ts | 39 +++++++++++++++++++++++++++----------- 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 105d3c5a628c..84ad0a038c73 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -12964,6 +12964,7 @@ export { getUnresolvedCardFraudAlertAction, shouldBlockSubmitDueToStrictPolicyRules, isWorkspaceChat, + isOneTransactionReport, }; export type { Ancestor, diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 9d8fd7c0547e..3d459cb91b31 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -150,6 +150,7 @@ import { isHiddenForCurrentUser, isIOUReportUsingReport, isMoneyRequestReport, + isOneTransactionReport, isOpenExpenseReport, isProcessingReport, isReportManuallyReimbursed, @@ -1866,25 +1867,41 @@ function handlePreexistingReport(report: Report) { // It is possible that we optimistically created a DM/group-DM for a set of users for which a report already exists. // In this case, the API will let us know by returning a preexistingReportID. // We should clear out the optimistically created report and re-route the user to the preexisting report. + const existingReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`]; let callback = () => { - const existingReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`]; - Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, null); - Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`, { - ...report, - reportID: preexistingReportID, - preexistingReportID: null, - // Replacing the existing report's participants to avoid duplicates - participants: existingReport?.participants ?? report.participants, - }); Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`, null); + + if (!parentReportActionID) { + Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`, { + ...report, + reportID: preexistingReportID, + preexistingReportID: null, + // Replacing the existing report's participants to avoid duplicates + participants: existingReport?.participants ?? report.participants, + }); + } else if (existingReport?.type === report.type) { + Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`, { + ...report, + reportID: preexistingReportID, + preexistingReportID: null, + }); + Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`, { + [parentReportActionID]: {childReportID: preexistingReportID}, + }); + } }; // Only re-route them if they are still looking at the optimistically created report - if (Navigation.getActiveRoute().includes(`/r/${reportID}`)) { + const activeRoute = Navigation.getActiveRoute(); + if (activeRoute.includes(`/r/${reportID}`) || activeRoute.includes(`/search/view/${reportID}`)) { const currCallback = callback; callback = () => { currCallback(); - Navigation.setParams({reportID: preexistingReportID.toString()}); + if (!isOneTransactionReport(existingReport)) { + Navigation.setParams({reportID: preexistingReportID.toString()}); + } else { + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(parentReportID)); + } }; // The report screen will listen to this event and transfer the draft comment to the existing report From 7f9c1bf8a5b345a2e87de8e95cdd19c053cfeea3 Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Fri, 14 Nov 2025 00:40:41 +0100 Subject: [PATCH 002/182] simplify the logic --- src/libs/actions/Report.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 3d459cb91b31..3a20dbcc029d 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -1880,7 +1880,7 @@ function handlePreexistingReport(report: Report) { // Replacing the existing report's participants to avoid duplicates participants: existingReport?.participants ?? report.participants, }); - } else if (existingReport?.type === report.type) { + } else { Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`, { ...report, reportID: preexistingReportID, @@ -1897,7 +1897,7 @@ function handlePreexistingReport(report: Report) { const currCallback = callback; callback = () => { currCallback(); - if (!isOneTransactionReport(existingReport)) { + if (parentReportActionID && !isOneTransactionReport(existingReport)) { Navigation.setParams({reportID: preexistingReportID.toString()}); } else { Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(parentReportID)); From 51fde84211669a0237427900282c2b44ab46cd76 Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Fri, 14 Nov 2025 00:42:22 +0100 Subject: [PATCH 003/182] correct the logic --- src/libs/actions/Report.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 3a20dbcc029d..0bb822e8bf00 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -1897,7 +1897,7 @@ function handlePreexistingReport(report: Report) { const currCallback = callback; callback = () => { currCallback(); - if (parentReportActionID && !isOneTransactionReport(existingReport)) { + if (!parentReportActionID || !isOneTransactionReport(existingReport)) { Navigation.setParams({reportID: preexistingReportID.toString()}); } else { Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(parentReportID)); From 9ee8e27bf4b56543c81f14ad146b4584279b07aa Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Fri, 14 Nov 2025 14:56:59 +0100 Subject: [PATCH 004/182] add comments --- src/libs/actions/Report.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 0bb822e8bf00..7c2b49b4703b 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -1865,6 +1865,7 @@ function handlePreexistingReport(report: Report) { // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.runAfterInteractions(() => { // It is possible that we optimistically created a DM/group-DM for a set of users for which a report already exists. + // Or we optimistically created a transaction thread chat report for an IOU report for an IOU report action that already has an associated child chat report. // In this case, the API will let us know by returning a preexistingReportID. // We should clear out the optimistically created report and re-route the user to the preexisting report. const existingReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`]; @@ -1873,6 +1874,7 @@ function handlePreexistingReport(report: Report) { Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`, null); if (!parentReportActionID) { + // Clear the optimistic DM/group-DM Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`, { ...report, reportID: preexistingReportID, @@ -1881,11 +1883,13 @@ function handlePreexistingReport(report: Report) { participants: existingReport?.participants ?? report.participants, }); } else { + // Clear the optimistic transaction thread report Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`, { ...report, reportID: preexistingReportID, preexistingReportID: null, }); + // Update the IOU report action to point to the preexisting transaction thread report Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`, { [parentReportActionID]: {childReportID: preexistingReportID}, }); @@ -1897,9 +1901,12 @@ function handlePreexistingReport(report: Report) { const currCallback = callback; callback = () => { currCallback(); + // isOneTransactionReport should have a correct result since we updated the IOU action child reportID above if (!parentReportActionID || !isOneTransactionReport(existingReport)) { + // We are either in a DM/group-DM or in a transaction thread report that its parent is not a one expense report Navigation.setParams({reportID: preexistingReportID.toString()}); } else { + // We need to navigate to the one expense report innstead of the transaction thread report Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(parentReportID)); } }; From 504ce9331175434eadb056ac9e0fea71560865a8 Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Fri, 14 Nov 2025 14:57:34 +0100 Subject: [PATCH 005/182] use parentReport to determine if the IOU report isOneTransactionReport --- src/libs/actions/Report.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 7c2b49b4703b..ad238ecce482 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -1869,6 +1869,7 @@ function handlePreexistingReport(report: Report) { // In this case, the API will let us know by returning a preexistingReportID. // We should clear out the optimistically created report and re-route the user to the preexisting report. const existingReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`]; + const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`]; let callback = () => { Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, null); Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`, null); @@ -1902,7 +1903,7 @@ function handlePreexistingReport(report: Report) { callback = () => { currCallback(); // isOneTransactionReport should have a correct result since we updated the IOU action child reportID above - if (!parentReportActionID || !isOneTransactionReport(existingReport)) { + if (!parentReportActionID || !isOneTransactionReport(parentReport)) { // We are either in a DM/group-DM or in a transaction thread report that its parent is not a one expense report Navigation.setParams({reportID: preexistingReportID.toString()}); } else { From 27ef3bb50a3c8b724d119e7c8d754a30c8e1269c Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Fri, 14 Nov 2025 14:57:53 +0100 Subject: [PATCH 006/182] If we are already on the parent one expense report, just call the API to fetch report data --- src/libs/actions/Report.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index ad238ecce482..4628dd416895 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -1920,6 +1920,9 @@ function handlePreexistingReport(report: Report) { }); return; + } else if (activeRoute.includes(`/r/${parentReportID}`) || activeRoute.includes(`/search/view/${parentReportID}`)) { + // We are already on the parent one expense report, so just call the API to fetch report data + openReport(parentReportID); } // In case the user is not on the report screen, we will transfer the report draft comment directly to the existing report From cf788bff9099c07fd6a5336a1d356f445db2bd23 Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Fri, 14 Nov 2025 14:58:45 +0100 Subject: [PATCH 007/182] correct comment --- src/libs/actions/Report.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 4628dd416895..aaf4888c94ef 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -1865,7 +1865,7 @@ function handlePreexistingReport(report: Report) { // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.runAfterInteractions(() => { // It is possible that we optimistically created a DM/group-DM for a set of users for which a report already exists. - // Or we optimistically created a transaction thread chat report for an IOU report for an IOU report action that already has an associated child chat report. + // Or we optimistically created a transaction thread chat report for an IOU report action that already has an associated child chat report. // In this case, the API will let us know by returning a preexistingReportID. // We should clear out the optimistically created report and re-route the user to the preexisting report. const existingReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`]; From fff5f3bd66962fa73f6a9b243609829642d09be2 Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Fri, 14 Nov 2025 15:45:33 +0100 Subject: [PATCH 008/182] fixes --- src/libs/actions/Report.ts | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index aaf4888c94ef..ac501a1c48e6 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -1866,6 +1866,7 @@ function handlePreexistingReport(report: Report) { InteractionManager.runAfterInteractions(() => { // It is possible that we optimistically created a DM/group-DM for a set of users for which a report already exists. // Or we optimistically created a transaction thread chat report for an IOU report action that already has an associated child chat report. + // Or we optimistically created a thread report under a comment that already has an associated child chat report. // In this case, the API will let us know by returning a preexistingReportID. // We should clear out the optimistically created report and re-route the user to the preexisting report. const existingReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`]; @@ -1884,31 +1885,37 @@ function handlePreexistingReport(report: Report) { participants: existingReport?.participants ?? report.participants, }); } else { - // Clear the optimistic transaction thread report + // Clear the optimistic thread report Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${preexistingReportID}`, { ...report, reportID: preexistingReportID, preexistingReportID: null, }); - // Update the IOU report action to point to the preexisting transaction thread report + // Update the parent report action to point to the preexisting thread report Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`, { [parentReportActionID]: {childReportID: preexistingReportID}, }); } }; + + const isParentOneTransactionReport = isOneTransactionReport(parentReport) + // Only re-route them if they are still looking at the optimistically created report const activeRoute = Navigation.getActiveRoute(); if (activeRoute.includes(`/r/${reportID}`) || activeRoute.includes(`/search/view/${reportID}`)) { const currCallback = callback; callback = () => { currCallback(); - // isOneTransactionReport should have a correct result since we updated the IOU action child reportID above - if (!parentReportActionID || !isOneTransactionReport(parentReport)) { - // We are either in a DM/group-DM or in a transaction thread report that its parent is not a one expense report += if (!parentReportActionID || !isParentOneTransactionReport) { + // We are either in a DM/group-DM, + // a transaction thread report that its parent is not a one expense report, + // or a thread under any comment + // navigate to the preexisting report chat Navigation.setParams({reportID: preexistingReportID.toString()}); } else { - // We need to navigate to the one expense report innstead of the transaction thread report - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(parentReportID)); + // We are in a transaction thread report under a one expense report, + // We need to navigate to the one expense report screen instead of the preexisting report chat + Navigation.setParams({reportID: parentReportID}); } }; @@ -1921,8 +1928,10 @@ function handlePreexistingReport(report: Report) { return; } else if (activeRoute.includes(`/r/${parentReportID}`) || activeRoute.includes(`/search/view/${parentReportID}`)) { - // We are already on the parent one expense report, so just call the API to fetch report data - openReport(parentReportID); + if (isParentOneTransactionReport) { + // We are already on the parent one expense report, so just call the API to fetch report data + openReport(parentReportID); + } } // In case the user is not on the report screen, we will transfer the report draft comment directly to the existing report From a46b7b4584c389dc11ee7df048b0e694a8cd0834 Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Fri, 14 Nov 2025 15:46:13 +0100 Subject: [PATCH 009/182] format --- src/libs/actions/Report.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index ac501a1c48e6..272c708cf87f 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -1898,7 +1898,7 @@ function handlePreexistingReport(report: Report) { } }; - const isParentOneTransactionReport = isOneTransactionReport(parentReport) + const isParentOneTransactionReport = isOneTransactionReport(parentReport); // Only re-route them if they are still looking at the optimistically created report const activeRoute = Navigation.getActiveRoute(); @@ -1906,7 +1906,7 @@ function handlePreexistingReport(report: Report) { const currCallback = callback; callback = () => { currCallback(); -= if (!parentReportActionID || !isParentOneTransactionReport) { + if (!parentReportActionID || !isParentOneTransactionReport) { // We are either in a DM/group-DM, // a transaction thread report that its parent is not a one expense report, // or a thread under any comment From 8739a69afeed764306d6f06469df20422b4eab96 Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Fri, 14 Nov 2025 15:55:59 +0100 Subject: [PATCH 010/182] fix lint --- src/libs/actions/Report.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 272c708cf87f..c8ac828d2458 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -1927,11 +1927,11 @@ function handlePreexistingReport(report: Report) { }); return; - } else if (activeRoute.includes(`/r/${parentReportID}`) || activeRoute.includes(`/search/view/${parentReportID}`)) { - if (isParentOneTransactionReport) { - // We are already on the parent one expense report, so just call the API to fetch report data - openReport(parentReportID); - } + } + + if (isParentOneTransactionReport && (activeRoute.includes(`/r/${parentReportID}`) || activeRoute.includes(`/search/view/${parentReportID}`))) { + // We are already on the parent one expense report, so just call the API to fetch report data + openReport(parentReportID); } // In case the user is not on the report screen, we will transfer the report draft comment directly to the existing report From 9cce6bfc3ff1ddc12633cedbf0a553c2868aad55 Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Fri, 14 Nov 2025 16:03:54 +0100 Subject: [PATCH 011/182] handle draft comment for one transaction report --- src/libs/actions/Report.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index c8ac828d2458..4da0514a7777 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -1937,12 +1937,15 @@ function handlePreexistingReport(report: Report) { // In case the user is not on the report screen, we will transfer the report draft comment directly to the existing report // after that clear the optimistically created report const draftReportComment = allReportDraftComments?.[`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`]; + + // If the parent report is a one transaction report, we want to copy the draft comment to the one transaction report instead of the preexisting thread report + const reportToCopyDraftTo = (parentReportID && isParentOneTransactionReport) ? parentReportID : preexistingReportID; if (!draftReportComment) { callback(); return; } - saveReportDraftComment(preexistingReportID, draftReportComment, callback); + saveReportDraftComment(reportToCopyDraftTo, draftReportComment, callback); }); } From d1d23b97c5534a7151173ddc40bc71a0e104abd7 Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Fri, 14 Nov 2025 16:07:39 +0100 Subject: [PATCH 012/182] prettier --- src/libs/actions/Report.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 4da0514a7777..26bd55d53155 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -1939,7 +1939,7 @@ function handlePreexistingReport(report: Report) { const draftReportComment = allReportDraftComments?.[`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`]; // If the parent report is a one transaction report, we want to copy the draft comment to the one transaction report instead of the preexisting thread report - const reportToCopyDraftTo = (parentReportID && isParentOneTransactionReport) ? parentReportID : preexistingReportID; + const reportToCopyDraftTo = parentReportID && isParentOneTransactionReport ? parentReportID : preexistingReportID; if (!draftReportComment) { callback(); return; From 7ef2a3b4f0ee64a7011d0d96087ad55ef567f952 Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Fri, 14 Nov 2025 17:29:48 +0100 Subject: [PATCH 013/182] clarify comments --- src/libs/actions/Report.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 26bd55d53155..b72c82b536cf 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -1907,14 +1907,17 @@ function handlePreexistingReport(report: Report) { callback = () => { currCallback(); if (!parentReportActionID || !isParentOneTransactionReport) { - // We are either in a DM/group-DM, - // a transaction thread report that its parent is not a one expense report, - // or a thread under any comment - // navigate to the preexisting report chat + // We are either in a DM/group-DM that do not have a parent report, + // a thread under any comment, + // or transaction thread report under an IOU report action that its parent IOU report is not a one expense report, + // we need to navigate to the preexisting report chat + // because we cleared the optimistically created report in the callback Navigation.setParams({reportID: preexistingReportID.toString()}); } else { // We are in a transaction thread report under a one expense report, // We need to navigate to the one expense report screen instead of the preexisting report chat + // because we cleared the optimistically created transaction thread report in the callback + // and the one transaction should be accessed via the one expense report screen Navigation.setParams({reportID: parentReportID}); } }; From 6a846c2306d91d7998e480f5ebc851dc850a8b5d Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Fri, 14 Nov 2025 17:34:11 +0100 Subject: [PATCH 014/182] clarify comment --- src/libs/actions/Report.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index b72c82b536cf..66b2ea43f23d 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -1914,7 +1914,7 @@ function handlePreexistingReport(report: Report) { // because we cleared the optimistically created report in the callback Navigation.setParams({reportID: preexistingReportID.toString()}); } else { - // We are in a transaction thread report under a one expense report, + // We are in a transaction thread report under an IOU report action where the parent IOU report is a one transaction report // We need to navigate to the one expense report screen instead of the preexisting report chat // because we cleared the optimistically created transaction thread report in the callback // and the one transaction should be accessed via the one expense report screen From bdc6ad8d3273c6afac6dba4f0db7fb5f95b7170a Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Fri, 14 Nov 2025 17:38:58 +0100 Subject: [PATCH 015/182] add missing callback --- src/libs/actions/Report.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 66b2ea43f23d..6d676381f70b 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -1934,7 +1934,9 @@ function handlePreexistingReport(report: Report) { if (isParentOneTransactionReport && (activeRoute.includes(`/r/${parentReportID}`) || activeRoute.includes(`/search/view/${parentReportID}`))) { // We are already on the parent one expense report, so just call the API to fetch report data + callback(); openReport(parentReportID); + return; } // In case the user is not on the report screen, we will transfer the report draft comment directly to the existing report @@ -1942,7 +1944,7 @@ function handlePreexistingReport(report: Report) { const draftReportComment = allReportDraftComments?.[`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`]; // If the parent report is a one transaction report, we want to copy the draft comment to the one transaction report instead of the preexisting thread report - const reportToCopyDraftTo = parentReportID && isParentOneTransactionReport ? parentReportID : preexistingReportID; + const reportToCopyDraftTo = !!parentReportID && isParentOneTransactionReport ? parentReportID : preexistingReportID; if (!draftReportComment) { callback(); return; From e1ec68170c6859f2ae8b5644b85c589f9995e110 Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Fri, 14 Nov 2025 17:39:24 +0100 Subject: [PATCH 016/182] move comment --- src/libs/actions/Report.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 6d676381f70b..11949b988fae 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -1933,8 +1933,8 @@ function handlePreexistingReport(report: Report) { } if (isParentOneTransactionReport && (activeRoute.includes(`/r/${parentReportID}`) || activeRoute.includes(`/search/view/${parentReportID}`))) { - // We are already on the parent one expense report, so just call the API to fetch report data callback(); + // We are already on the parent one expense report, so just call the API to fetch report data openReport(parentReportID); return; } From dc81223291d8f29c97dbaed00b42d70c7f5f7988 Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Fri, 14 Nov 2025 18:42:47 +0100 Subject: [PATCH 017/182] Fix test failure --- src/libs/ReportUtils.ts | 4 +++- src/libs/actions/Report.ts | 13 ++++++++++++- tests/actions/EnforceActionExportRestrictions.ts | 8 ++++++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 84ad0a038c73..ddb3256e54c7 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -2532,6 +2532,9 @@ function hasOnlyNonReimbursableTransactions(iouReportID: string | undefined): bo /** * Checks if a report has only one transaction associated with it + * NOTE: This function should not be exported because it accesses module-level Onyx data (allReportActions, allReports) + * which can become stale. Each file should implement its own version using local Onyx collections. + * See tests/actions/EnforceActionExportRestrictions.ts for more details. */ function isOneTransactionReport(report: OnyxEntry): boolean { const reportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.reportID}`] ?? ([] as ReportAction[]); @@ -12964,7 +12967,6 @@ export { getUnresolvedCardFraudAlertAction, shouldBlockSubmitDueToStrictPolicyRules, isWorkspaceChat, - isOneTransactionReport, }; export type { Ancestor, diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 11949b988fae..3066cae0bdb7 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -150,7 +150,6 @@ import { isHiddenForCurrentUser, isIOUReportUsingReport, isMoneyRequestReport, - isOneTransactionReport, isOpenExpenseReport, isProcessingReport, isReportManuallyReimbursed, @@ -794,6 +793,18 @@ function reportActionsExist(reportID: string): boolean { return allReportActions?.[reportID] !== undefined; } +/** + * Checks if a report has only one transaction associated with it + * NOTE: This function should not be exported because it accesses module-level Onyx data (allReportActions, allReports) + * which can become stale. Each file should implement its own version using local Onyx collections. + * See tests/actions/EnforceActionExportRestrictions.ts for more details. + */ +function isOneTransactionReport(report: OnyxEntry): boolean { + const reportActions = allReportActions?.[`${report?.reportID}`] ?? ([] as ReportAction[]); + const chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`]; + return !!ReportActionsUtils.getOneTransactionThreadReportID(report, chatReport, reportActions); +} + function updateChatName(reportID: string, reportName: string, type: typeof CONST.REPORT.CHAT_TYPE.GROUP | typeof CONST.REPORT.CHAT_TYPE.TRIP_ROOM) { const optimisticData: OnyxUpdate[] = [ { diff --git a/tests/actions/EnforceActionExportRestrictions.ts b/tests/actions/EnforceActionExportRestrictions.ts index 5c467834715d..7dac2ad8fd31 100644 --- a/tests/actions/EnforceActionExportRestrictions.ts +++ b/tests/actions/EnforceActionExportRestrictions.ts @@ -6,6 +6,7 @@ import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as ReportUtils from '@libs/ReportUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; import * as Policy from '@userActions/Policy/Policy'; +import * as Report from '@userActions/Report'; import * as Task from '@userActions/Task'; // There are some methods that are OK to use inside an action file, but should not be exported. These are typically methods that look up and return Onyx data. @@ -106,6 +107,13 @@ describe('Policy', () => { }); }); +describe('Report', () => { + it('does not export isOneTransactionReport', () => { + // @ts-expect-error the test is asserting that it's undefined, so the TS error is normal + expect(Report.isOneTransactionReport).toBeUndefined(); + }); +}); + describe('TransactionUtils', () => { it('does not export getTransaction', () => { // @ts-expect-error the test is asserting that it's undefined, so the TS error is normal From b47a81c121ed520bc3b16685a7365a228dc335f0 Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Fri, 14 Nov 2025 21:41:19 +0100 Subject: [PATCH 018/182] Add automated tests --- tests/actions/ReportTest.ts | 213 ++++++++++++++++++++++++++++++++++++ 1 file changed, 213 insertions(+) diff --git a/tests/actions/ReportTest.ts b/tests/actions/ReportTest.ts index 55e0147df037..457d9b96f5dd 100644 --- a/tests/actions/ReportTest.ts +++ b/tests/actions/ReportTest.ts @@ -2634,4 +2634,217 @@ describe('actions/Report', () => { expect(reportsCollectionAfter).toBeUndefined(); }); }); + + describe('handlePreexistingReport', () => { + beforeEach(async () => { + await Onyx.clear(); + global.fetch = TestHelper.getGlobalFetchMock(); + }); + + it('should handle preexistingReportID for one-transaction report', async () => { + // Given an IOU report with one transaction + const iouReportID = '9999'; + const chatReportID = '8888'; + const optimisticReportID = '1234'; + const preexistingReportID = '5555'; + const iouReportAction = { + reportActionID: '1', + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + originalMessage: { + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + IOUTransactionID: 'trans123', + }, + childReportID: preexistingReportID, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`, { + reportID: iouReportID, + type: CONST.REPORT.TYPE.IOU, + chatReportID, + }); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`, { + reportID: chatReportID, + type: CONST.REPORT.TYPE.CHAT, + }); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, { + [iouReportAction.reportActionID]: iouReportAction, + }); + + // Create the optimistic report + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${optimisticReportID}`, { + reportID: optimisticReportID, + type: CONST.REPORT.TYPE.CHAT, + parentReportID: iouReportID, + parentReportActionID: '1', + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, { + [iouReportAction.reportActionID]: { + childReportID: optimisticReportID, + }, + }); + + // When OpenReport API is called, it returns preexistingReportID and reportID + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${optimisticReportID}`, { + reportID: optimisticReportID, + preexistingReportID, + }); + + await waitForBatchedUpdates(); + + // Then handlePreexistingReport is called + const report = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${optimisticReportID}`); + if (report) { + Report.handlePreexistingReport(report); + } + + await waitForBatchedUpdates(); + + // Then the optimistic report should be cleared + const optimisticReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${optimisticReportID}`); + expect(optimisticReport).toBeFalsy(); + + // Then the childReportID of the IOU action should be updated + const iouReportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`); + expect(iouReportActions?.['1']?.childReportID).toBe(preexistingReportID); + }); + + it('should handle preexistingReportID for multi-transaction report', async () => { + // Given an IOU report with multiple transactions + const iouReportID = '9999'; + const chatReportID = '8888'; + const optimisticReportID = '1234'; + const preexistingReportID = '5555'; + const iouReportAction1 = { + reportActionID: '1', + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + originalMessage: { + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + IOUTransactionID: 'trans123', + }, + childReportID: preexistingReportID, + }; + const iouReportAction2 = { + reportActionID: '2', + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + originalMessage: { + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + IOUTransactionID: 'trans456', + }, + childReportID: '6666', + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`, { + reportID: iouReportID, + type: CONST.REPORT.TYPE.IOU, + chatReportID, + }); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`, { + reportID: chatReportID, + type: CONST.REPORT.TYPE.CHAT, + }); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, { + [iouReportAction1.reportActionID]: iouReportAction1, + [iouReportAction2.reportActionID]: iouReportAction2, + }); + + // Given that we create an optimistic transaction thread report + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${optimisticReportID}`, { + reportID: optimisticReportID, + type: CONST.REPORT.TYPE.CHAT, + parentReportID: iouReportID, + parentReportActionID: '1', + }); + + // Given that we update the childReportID of the first IOU action points to the optimistic report + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, { + [iouReportAction1.reportActionID]: { + childReportID: optimisticReportID, + }, + }); + + // When OpenReport API is called, it returns preexistingReportID and reportID + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${optimisticReportID}`, { + reportID: optimisticReportID, + preexistingReportID, + }); + + await waitForBatchedUpdates(); + + // When handlePreexistingReport is called + const report = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${optimisticReportID}`); + if (report) { + Report.handlePreexistingReport(report); + } + + await waitForBatchedUpdates(); + + // Then the optimistic report should be cleared + const optimisticReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${optimisticReportID}`); + expect(optimisticReport).toBeFalsy(); + + // Then the childReportID of the IOU action should be updated + const iouReportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`); + expect(iouReportActions?.[iouReportAction1.reportActionID]?.childReportID).toBe(preexistingReportID); + }); + + it('should handle preexistingReportID for thread under comment', async () => { + // Given a parent chat report with a comment + const chatReportID = '9999'; + const optimisticReportID = '1234'; + const preexistingReportID = '5555'; + const commentReportAction = { + reportActionID: '1', + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + message: [{type: 'TEXT', text: 'Test comment'}], + childReportID: preexistingReportID, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`, { + reportID: chatReportID, + type: CONST.REPORT.TYPE.CHAT, + }); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`, { + [commentReportAction.reportActionID]: commentReportAction, + }); + + // Given that we create an optimistic thread report + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${optimisticReportID}`, { + reportID: optimisticReportID, + type: CONST.REPORT.TYPE.CHAT, + parentReportID: chatReportID, + parentReportActionID: '1', + }); + + // Given that we update the childReportID of the comment action points to the optimistic report + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`, { + [commentReportAction.reportActionID]: { + childReportID: optimisticReportID, + }, + }); + + // When OpenReport API is called, it returns preexistingReportID and reportID + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${optimisticReportID}`, { + reportID: optimisticReportID, + preexistingReportID, + }); + + await waitForBatchedUpdates(); + + // When handlePreexistingReport is called + const report = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${optimisticReportID}`); + if (report) { + Report.handlePreexistingReport(report); + } + + await waitForBatchedUpdates(); + + // Then the optimistic report should be cleared + const optimisticReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${optimisticReportID}`); + expect(optimisticReport).toBeFalsy(); + + // And the parent report action should be updated to point to preexisting thread + const parentChatReportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`); + expect(parentChatReportActions?.['1']?.childReportID).toBe(preexistingReportID); + }); + }); }); From e6a7c58cbdd55c557ea959997f93a7c4934d7393 Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Mon, 24 Nov 2025 17:31:22 +0100 Subject: [PATCH 019/182] Fix comment value discarded by a side effect --- .../ComposerWithSuggestions.tsx | 8 ++++++ .../SilentCommentUpdater/index.tsx | 26 +++++++++++++++++-- .../SilentCommentUpdater/types.ts | 3 +++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx index 3c062deaadbd..7704926ef9d6 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx @@ -276,6 +276,9 @@ function ComposerWithSuggestions({ // The ref to check whether the comment saving is in progress const isCommentPendingSaved = useRef(false); + // The ref to check whether we're transitioning to a preexisting report + const isTransitioningToPreExistingReport = useRef(false); + const animatedRef = useAnimatedRef(); /** * Set the TextInput Ref @@ -313,6 +316,10 @@ function ComposerWithSuggestions({ callback(); return; } + + // Mark that we're transitioning to a preexisting report + // This prevents SilentCommentUpdater from overwriting the draft + isTransitioningToPreExistingReport.current = true; saveReportDraftComment(preexistingReportID, commentRef.current, callback); }); @@ -876,6 +883,7 @@ function ComposerWithSuggestions({ updateComment={updateComment} commentRef={commentRef} isCommentPendingSaved={isCommentPendingSaved} + isTransitioningToPreExistingReport={isTransitioningToPreExistingReport} /> )} diff --git a/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/index.tsx b/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/index.tsx index efe1c79c28d8..1c9cbacf1ae5 100644 --- a/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/index.tsx +++ b/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/index.tsx @@ -11,7 +11,7 @@ import type SilentCommentUpdaterProps from './types'; * It is connected to the actual draft comment in onyx. The comment in onyx might updates multiple times, and we want to avoid * re-rendering a UI component for that. That's why the side effect was moved down to a separate component. */ -function SilentCommentUpdater({commentRef, reportID, value, updateComment, isCommentPendingSaved}: SilentCommentUpdaterProps) { +function SilentCommentUpdater({commentRef, reportID, value, updateComment, isCommentPendingSaved, isTransitioningToPreExistingReport}: SilentCommentUpdaterProps) { const [comment = '', commentResult] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`, {canBeMissing: true}); const prevCommentProp = usePrevious(comment); const prevReportId = usePrevious(reportID); @@ -22,6 +22,15 @@ function SilentCommentUpdater({commentRef, reportID, value, updateComment, isCom if (isLoadingOnyxValue(commentResult)) { return; } + + // Skip sync when transitioning to a preexisting report + // This prevents overwriting the just-saved draft with an empty string + if (isTransitioningToPreExistingReport.current && reportID !== prevReportId) { + // eslint-disable-next-line no-param-reassign, react-compiler/react-compiler + isTransitioningToPreExistingReport.current = false; + return; + } + // Value state does not have the same value as comment props when the comment gets changed from another tab. // In this case, we should synchronize the value between tabs. const shouldSyncComment = prevCommentProp !== comment && value !== comment && !isCommentPendingSaved.current; @@ -33,7 +42,20 @@ function SilentCommentUpdater({commentRef, reportID, value, updateComment, isCom } updateComment(comment ?? ''); - }, [prevCommentProp, prevPreferredLocale, prevReportId, comment, preferredLocale, reportID, updateComment, value, commentRef, isCommentPendingSaved, commentResult]); + }, [ + prevCommentProp, + prevPreferredLocale, + prevReportId, + comment, + preferredLocale, + reportID, + updateComment, + value, + commentRef, + isCommentPendingSaved, + isTransitioningToPreExistingReport, + commentResult, + ]); return null; } diff --git a/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/types.ts b/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/types.ts index 2768dfc1250a..2e971d55989e 100644 --- a/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/types.ts +++ b/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/types.ts @@ -13,6 +13,9 @@ type SilentCommentUpdaterProps = { /** The ref to check whether the comment saving is in progress */ isCommentPendingSaved: React.RefObject; + + /** The ref to check whether we're transitioning to a preexisting report */ + isTransitioningToPreExistingReport: React.RefObject; }; export default SilentCommentUpdaterProps; From 83313952e300d0fa2d0cf6958e0118c7e7c8248a Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Mon, 24 Nov 2025 18:05:33 +0100 Subject: [PATCH 020/182] ignore SearchTransaction deprecation errors --- src/libs/ReportUtils.ts | 48 +++++++++++++++++++++++++++++++++++------ 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 974ea497fda7..5c5a9f77b3dc 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -75,6 +75,7 @@ import type {NotificationPreference, Participants, Participant as ReportParticip import type {Message, OldDotReportAction, ReportActions} from '@src/types/onyx/ReportAction'; import type {PendingChatMember} from '@src/types/onyx/ReportMetadata'; import type {OnyxData} from '@src/types/onyx/Request'; +// eslint-disable-next-line @typescript-eslint/no-deprecated import type {SearchTransaction} from '@src/types/onyx/SearchResults'; import type {Comment, TransactionChanges, WaypointCollection} from '@src/types/onyx/Transaction'; import type {FileObject} from '@src/types/utils/Attachment'; @@ -929,6 +930,7 @@ type GetReportNameParams = { parentReportActionParam?: OnyxInputOrEntry; personalDetails?: Partial; invoiceReceiverPolicy?: OnyxEntry; + // eslint-disable-next-line @typescript-eslint/no-deprecated - SearchTransaction transactions?: SearchTransaction[]; reports?: Report[]; policies?: Policy[]; @@ -2251,7 +2253,11 @@ function findLastAccessedReport(ignoreDomainRooms: boolean, openOnAdminRoom = fa /** * Whether the provided report has expenses */ -function hasExpenses(reportID?: string, transactions?: SearchTransaction[] | Array>): boolean { +function hasExpenses( + reportID?: string, + // eslint-disable-next-line @typescript-eslint/no-deprecated - SearchTransaction + transactions?: SearchTransaction[] | Array>, +): boolean { if (transactions) { return !!transactions?.find((transaction) => transaction?.reportID === reportID); } @@ -2261,7 +2267,11 @@ function hasExpenses(reportID?: string, transactions?: SearchTransaction[] | Arr /** * Whether the provided report is a closed expense report with no expenses */ -function isClosedExpenseReportWithNoExpenses(report: OnyxEntry, transactions?: SearchTransaction[] | Array>): boolean { +function isClosedExpenseReportWithNoExpenses( + report: OnyxEntry, + // eslint-disable-next-line @typescript-eslint/no-deprecated - SearchTransaction + transactions?: SearchTransaction[] | Array>, +): boolean { if (!report?.statusNum || report.statusNum !== CONST.REPORT.STATUS_NUM.CLOSED || !isExpenseReport(report)) { return false; } @@ -4487,6 +4497,7 @@ function canEditMoneyRequest( isChatReportArchived = false, report?: OnyxInputOrEntry, policy?: OnyxEntry, + // eslint-disable-next-line @typescript-eslint/no-deprecated - SearchTransaction linkedTransaction?: OnyxEntry | SearchTransaction, ): boolean { const isDeleted = isDeletedAction(reportAction); @@ -4629,6 +4640,7 @@ function canEditFieldOfMoneyRequest( isDeleteAction?: boolean, isChatReportArchived = false, outstandingReportsByPolicyID?: OutstandingReportsByPolicyIDDerivedValue, + // eslint-disable-next-line @typescript-eslint/no-deprecated - SearchTransaction linkedTransaction?: OnyxEntry | SearchTransaction, report?: OnyxInputOrEntry, policy?: OnyxEntry, @@ -4927,7 +4939,11 @@ function areAllRequestsBeingSmartScanned(iouReportID: string | undefined, report * * NOTE: This method is only meant to be used inside this action file. Do not export and use it elsewhere. Use useOnyx instead. */ -function getLinkedTransaction(reportAction: OnyxEntry, transactions?: SearchTransaction[]): OnyxEntry | SearchTransaction { +function getLinkedTransaction( + reportAction: OnyxEntry, + // eslint-disable-next-line @typescript-eslint/no-deprecated - SearchTransaction + transactions?: SearchTransaction[], +): OnyxEntry | SearchTransaction { let transactionID: string | undefined; if (isMoneyRequestAction(reportAction)) { @@ -4982,6 +4998,7 @@ function getTransactionReportName({ reports, }: { reportAction: OnyxEntry; + // eslint-disable-next-line @typescript-eslint/no-deprecated - SearchTransaction transactions?: SearchTransaction[]; reports?: Report[]; }): string { @@ -5612,6 +5629,7 @@ function getReportName( personalDetails?: Partial, invoiceReceiverPolicy?: OnyxEntry, reportAttributes?: ReportAttributesDerivedValue['reports'], + // eslint-disable-next-line @typescript-eslint/no-deprecated - SearchTransaction transactions?: SearchTransaction[], isReportArchived?: boolean, reports?: Report[], @@ -8941,6 +8959,7 @@ function hasViolations( reportID: string | undefined, transactionViolations: OnyxCollection, shouldShowInReview?: boolean, + // eslint-disable-next-line @typescript-eslint/no-deprecated - SearchTransaction reportTransactions?: SearchTransaction[], ): boolean { const transactions = reportTransactions ?? getReportTransactions(reportID); @@ -8954,6 +8973,7 @@ function hasWarningTypeViolations( reportID: string | undefined, transactionViolations: OnyxCollection, shouldShowInReview?: boolean, + // eslint-disable-next-line @typescript-eslint/no-deprecated - SearchTransaction reportTransactions?: SearchTransaction[], ): boolean { const transactions = reportTransactions ?? getReportTransactions(reportID); @@ -8987,6 +9007,7 @@ function hasNoticeTypeViolations( reportID: string | undefined, transactionViolations: OnyxCollection, shouldShowInReview?: boolean, + // eslint-disable-next-line @typescript-eslint/no-deprecated - SearchTransaction reportTransactions?: SearchTransaction[], ): boolean { const transactions = reportTransactions ?? getReportTransactions(reportID); @@ -8996,7 +9017,12 @@ function hasNoticeTypeViolations( /** * Checks to see if a report contains any type of violation */ -function hasAnyViolations(reportID: string | undefined, transactionViolations: OnyxCollection, reportTransactions?: SearchTransaction[]) { +function hasAnyViolations( + reportID: string | undefined, + transactionViolations: OnyxCollection, + // eslint-disable-next-line @typescript-eslint/no-deprecated - SearchTransaction + reportTransactions?: SearchTransaction[], +) { return ( hasViolations(reportID, transactionViolations, undefined, reportTransactions) || hasNoticeTypeViolations(reportID, transactionViolations, true, reportTransactions) || @@ -9020,11 +9046,13 @@ function shouldBlockSubmitDueToStrictPolicyRules( reportID: string | undefined, transactionViolations: OnyxCollection, areStrictPolicyRulesEnabled: boolean, + // eslint-disable-next-line @typescript-eslint/no-deprecated - SearchTransaction reportTransactions?: Transaction[] | SearchTransaction[], ) { if (!areStrictPolicyRulesEnabled) { return false; } + // eslint-disable-next-line @typescript-eslint/no-deprecated - SearchTransaction return hasAnyViolations(reportID, transactionViolations, reportTransactions as SearchTransaction[]); } @@ -10378,7 +10406,11 @@ function getAllHeldTransactions(iouReportID?: string): Transaction[] { /** * Check if Report has any held expenses */ -function hasHeldExpenses(iouReportID?: string, allReportTransactions?: SearchTransaction[]): boolean { +function hasHeldExpenses( + iouReportID?: string, + // eslint-disable-next-line @typescript-eslint/no-deprecated - SearchTransaction + allReportTransactions?: SearchTransaction[], +): boolean { const iouReportTransactions = getReportTransactions(iouReportID); const transactions = allReportTransactions ?? iouReportTransactions; return transactions.some((transaction) => isOnHoldTransactionUtils(transaction)); @@ -10387,7 +10419,11 @@ function hasHeldExpenses(iouReportID?: string, allReportTransactions?: SearchTra /** * Check if all expenses in the Report are on hold */ -function hasOnlyHeldExpenses(iouReportID?: string, allReportTransactions?: SearchTransaction[]): boolean { +function hasOnlyHeldExpenses( + iouReportID?: string, + // eslint-disable-next-line @typescript-eslint/no-deprecated - SearchTransaction + allReportTransactions?: SearchTransaction[], +): boolean { const transactionsByIouReportID = getReportTransactions(iouReportID); const reportTransactions = allReportTransactions ?? transactionsByIouReportID; return reportTransactions.length > 0 && !reportTransactions.some((transaction) => !isOnHoldTransactionUtils(transaction)); From ff2834e79972e036ad2d4fd04c217e24ce1dd31f Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Mon, 24 Nov 2025 18:16:59 +0100 Subject: [PATCH 021/182] remove comment --- src/libs/ReportUtils.ts | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 5c5a9f77b3dc..dd786a4022cb 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -930,7 +930,7 @@ type GetReportNameParams = { parentReportActionParam?: OnyxInputOrEntry; personalDetails?: Partial; invoiceReceiverPolicy?: OnyxEntry; - // eslint-disable-next-line @typescript-eslint/no-deprecated - SearchTransaction + // eslint-disable-next-line @typescript-eslint/no-deprecated transactions?: SearchTransaction[]; reports?: Report[]; policies?: Policy[]; @@ -2255,7 +2255,7 @@ function findLastAccessedReport(ignoreDomainRooms: boolean, openOnAdminRoom = fa */ function hasExpenses( reportID?: string, - // eslint-disable-next-line @typescript-eslint/no-deprecated - SearchTransaction + // eslint-disable-next-line @typescript-eslint/no-deprecated transactions?: SearchTransaction[] | Array>, ): boolean { if (transactions) { @@ -2269,7 +2269,7 @@ function hasExpenses( */ function isClosedExpenseReportWithNoExpenses( report: OnyxEntry, - // eslint-disable-next-line @typescript-eslint/no-deprecated - SearchTransaction + // eslint-disable-next-line @typescript-eslint/no-deprecated transactions?: SearchTransaction[] | Array>, ): boolean { if (!report?.statusNum || report.statusNum !== CONST.REPORT.STATUS_NUM.CLOSED || !isExpenseReport(report)) { @@ -4497,7 +4497,7 @@ function canEditMoneyRequest( isChatReportArchived = false, report?: OnyxInputOrEntry, policy?: OnyxEntry, - // eslint-disable-next-line @typescript-eslint/no-deprecated - SearchTransaction + // eslint-disable-next-line @typescript-eslint/no-deprecated linkedTransaction?: OnyxEntry | SearchTransaction, ): boolean { const isDeleted = isDeletedAction(reportAction); @@ -4640,7 +4640,7 @@ function canEditFieldOfMoneyRequest( isDeleteAction?: boolean, isChatReportArchived = false, outstandingReportsByPolicyID?: OutstandingReportsByPolicyIDDerivedValue, - // eslint-disable-next-line @typescript-eslint/no-deprecated - SearchTransaction + // eslint-disable-next-line @typescript-eslint/no-deprecated linkedTransaction?: OnyxEntry | SearchTransaction, report?: OnyxInputOrEntry, policy?: OnyxEntry, @@ -4941,7 +4941,7 @@ function areAllRequestsBeingSmartScanned(iouReportID: string | undefined, report */ function getLinkedTransaction( reportAction: OnyxEntry, - // eslint-disable-next-line @typescript-eslint/no-deprecated - SearchTransaction + // eslint-disable-next-line @typescript-eslint/no-deprecated transactions?: SearchTransaction[], ): OnyxEntry | SearchTransaction { let transactionID: string | undefined; @@ -4998,7 +4998,7 @@ function getTransactionReportName({ reports, }: { reportAction: OnyxEntry; - // eslint-disable-next-line @typescript-eslint/no-deprecated - SearchTransaction + // eslint-disable-next-line @typescript-eslint/no-deprecated transactions?: SearchTransaction[]; reports?: Report[]; }): string { @@ -5629,7 +5629,7 @@ function getReportName( personalDetails?: Partial, invoiceReceiverPolicy?: OnyxEntry, reportAttributes?: ReportAttributesDerivedValue['reports'], - // eslint-disable-next-line @typescript-eslint/no-deprecated - SearchTransaction + // eslint-disable-next-line @typescript-eslint/no-deprecated transactions?: SearchTransaction[], isReportArchived?: boolean, reports?: Report[], @@ -8959,7 +8959,7 @@ function hasViolations( reportID: string | undefined, transactionViolations: OnyxCollection, shouldShowInReview?: boolean, - // eslint-disable-next-line @typescript-eslint/no-deprecated - SearchTransaction + // eslint-disable-next-line @typescript-eslint/no-deprecated reportTransactions?: SearchTransaction[], ): boolean { const transactions = reportTransactions ?? getReportTransactions(reportID); @@ -8973,7 +8973,7 @@ function hasWarningTypeViolations( reportID: string | undefined, transactionViolations: OnyxCollection, shouldShowInReview?: boolean, - // eslint-disable-next-line @typescript-eslint/no-deprecated - SearchTransaction + // eslint-disable-next-line @typescript-eslint/no-deprecated reportTransactions?: SearchTransaction[], ): boolean { const transactions = reportTransactions ?? getReportTransactions(reportID); @@ -9007,7 +9007,7 @@ function hasNoticeTypeViolations( reportID: string | undefined, transactionViolations: OnyxCollection, shouldShowInReview?: boolean, - // eslint-disable-next-line @typescript-eslint/no-deprecated - SearchTransaction + // eslint-disable-next-line @typescript-eslint/no-deprecated reportTransactions?: SearchTransaction[], ): boolean { const transactions = reportTransactions ?? getReportTransactions(reportID); @@ -9020,7 +9020,7 @@ function hasNoticeTypeViolations( function hasAnyViolations( reportID: string | undefined, transactionViolations: OnyxCollection, - // eslint-disable-next-line @typescript-eslint/no-deprecated - SearchTransaction + // eslint-disable-next-line @typescript-eslint/no-deprecated reportTransactions?: SearchTransaction[], ) { return ( @@ -9046,13 +9046,13 @@ function shouldBlockSubmitDueToStrictPolicyRules( reportID: string | undefined, transactionViolations: OnyxCollection, areStrictPolicyRulesEnabled: boolean, - // eslint-disable-next-line @typescript-eslint/no-deprecated - SearchTransaction + // eslint-disable-next-line @typescript-eslint/no-deprecated reportTransactions?: Transaction[] | SearchTransaction[], ) { if (!areStrictPolicyRulesEnabled) { return false; } - // eslint-disable-next-line @typescript-eslint/no-deprecated - SearchTransaction + // eslint-disable-next-line @typescript-eslint/no-deprecated return hasAnyViolations(reportID, transactionViolations, reportTransactions as SearchTransaction[]); } @@ -10408,7 +10408,7 @@ function getAllHeldTransactions(iouReportID?: string): Transaction[] { */ function hasHeldExpenses( iouReportID?: string, - // eslint-disable-next-line @typescript-eslint/no-deprecated - SearchTransaction + // eslint-disable-next-line @typescript-eslint/no-deprecated allReportTransactions?: SearchTransaction[], ): boolean { const iouReportTransactions = getReportTransactions(iouReportID); @@ -10421,7 +10421,7 @@ function hasHeldExpenses( */ function hasOnlyHeldExpenses( iouReportID?: string, - // eslint-disable-next-line @typescript-eslint/no-deprecated - SearchTransaction + // eslint-disable-next-line @typescript-eslint/no-deprecated allReportTransactions?: SearchTransaction[], ): boolean { const transactionsByIouReportID = getReportTransactions(iouReportID); From 8697203002818b8d2c094551acb8e376fbe1b5b2 Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Mon, 24 Nov 2025 18:32:05 +0100 Subject: [PATCH 022/182] ignore SearchTransaction deprecation errors --- src/libs/ReportUtils.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index dd786a4022cb..e3a923e37afe 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -4943,6 +4943,7 @@ function getLinkedTransaction( reportAction: OnyxEntry, // eslint-disable-next-line @typescript-eslint/no-deprecated transactions?: SearchTransaction[], + // eslint-disable-next-line @typescript-eslint/no-deprecated ): OnyxEntry | SearchTransaction { let transactionID: string | undefined; From 1655a9ca677b36ab2bdb30f1fc5312db77b99262 Mon Sep 17 00:00:00 2001 From: nkdengineer Date: Thu, 11 Dec 2025 12:13:38 +0700 Subject: [PATCH 023/182] fix: After editing expense details, page not scrolled and focused to system message --- src/libs/Navigation/Navigation.ts | 7 ++ .../getSearchTopmostReportParams.ts | 64 +++++++++++++++++++ src/libs/actions/IOU.ts | 6 ++ 3 files changed, 77 insertions(+) create mode 100644 src/libs/Navigation/getSearchTopmostReportParams.ts diff --git a/src/libs/Navigation/Navigation.ts b/src/libs/Navigation/Navigation.ts index 46b717d0302f..cd6e6b60014b 100644 --- a/src/libs/Navigation/Navigation.ts +++ b/src/libs/Navigation/Navigation.ts @@ -36,6 +36,7 @@ import {linkingConfig} from './linkingConfig'; import {SPLIT_TO_SIDEBAR} from './linkingConfig/RELATIONS'; import navigationRef from './navigationRef'; import type {NavigationPartialRoute, NavigationRef, NavigationRoute, NavigationStateRoute, ReportsSplitNavigatorParamList, RootNavigatorParamList, State} from './types'; +import getSearchTopmostReportParams from './getSearchTopmostReportParams'; // Routes which are part of the flow to set up 2FA const SET_UP_2FA_ROUTES = new Set([ @@ -109,6 +110,11 @@ function canNavigate(methodName: string, params: CanNavigateParams = {}): boolea */ const getTopmostReportId = (state = navigationRef.getState()) => getTopmostReportParams(state)?.reportID; +/** + * Extracts from the topmost report its id which also include the RHP report and search money request report. + */ +const getSearchTopmostReportId = (state = navigationRef.getRootState()) => getSearchTopmostReportParams(state)?.reportID; + /** * Extracts from the topmost report its action id. */ @@ -786,6 +792,7 @@ export default { isValidateLoginFlow, dismissToFirstRHP, dismissToSecondRHP, + getSearchTopmostReportId, }; export {navigationRef}; diff --git a/src/libs/Navigation/getSearchTopmostReportParams.ts b/src/libs/Navigation/getSearchTopmostReportParams.ts new file mode 100644 index 000000000000..e4a6fdf5da2b --- /dev/null +++ b/src/libs/Navigation/getSearchTopmostReportParams.ts @@ -0,0 +1,64 @@ +import type {NavigationState, PartialState} from '@react-navigation/native'; +import NAVIGATORS from '@src/NAVIGATORS'; +import SCREENS from '@src/SCREENS'; +import type {ReportsSplitNavigatorParamList, RootNavigatorParamList, SearchReportParamList} from './types'; + +// This function is in a separate file than Navigation.ts to avoid cyclic dependency. + +type State = NavigationState | NavigationState | PartialState; + +function getReportRHPParams(state: State): SearchReportParamList[typeof SCREENS.SEARCH.REPORT_RHP] | undefined { + const lastRoute = state?.routes?.at(-1); + if (!lastRoute || lastRoute.name !== NAVIGATORS.RIGHT_MODAL_NAVIGATOR) { + return; + } + const searchReportRoute = lastRoute.state?.routes?.findLast((route) => route?.name === SCREENS.RIGHT_MODAL.SEARCH_REPORT); + if (!searchReportRoute) { + return; + } + const topmostRHPReport = searchReportRoute.state?.routes?.findLast((route) => route?.name === SCREENS.SEARCH.REPORT_RHP); + if (!topmostRHPReport) { + return; + } + return topmostRHPReport?.params as SearchReportParamList[typeof SCREENS.SEARCH.REPORT_RHP]; +} + +/** + * Find the last visited report screen (Inbox report/RHP report/Search money request report) in the navigation state and get its params. + * + * @param state - The react-navigation state + * @returns - It's possible that there is no report screen + */ + +function getSearchTopmostReportParams(state: State): ReportsSplitNavigatorParamList[typeof SCREENS.REPORT] | undefined { + if (!state) { + return; + } + + const RHPReportParams = getReportRHPParams(state); + if (RHPReportParams) { + return RHPReportParams; + } + + const topmostReportsSplitNavigator = state.routes?.findLast((route) => route.name === NAVIGATORS.REPORTS_SPLIT_NAVIGATOR || route.name === NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR); + + if (!topmostReportsSplitNavigator) { + return; + } + + let topmostReport; + + if (topmostReportsSplitNavigator.name === NAVIGATORS.REPORTS_SPLIT_NAVIGATOR) { + topmostReport = topmostReportsSplitNavigator.state?.routes.findLast((route) => route.name === SCREENS.REPORT); + } else { + topmostReport = topmostReportsSplitNavigator.state?.routes.findLast((route) => route.name === SCREENS.SEARCH.MONEY_REQUEST_REPORT); + } + + if (!topmostReport) { + return; + } + + return topmostReport?.params as ReportsSplitNavigatorParamList[typeof SCREENS.REPORT]; +} + +export default getSearchTopmostReportParams; diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 0f95c838d528..742b88f04231 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -5076,6 +5076,7 @@ function updateMoneyRequestDate({ removeTransactionFromDuplicateTransactionViolation(data.onyxData, transactionID, transactions, transactionViolations); } const {params, onyxData} = data; + notifyNewAction(Navigation.getSearchTopmostReportId(), currentUserAccountIDParam); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_DATE, params, onyxData); } @@ -5176,6 +5177,7 @@ function updateMoneyRequestMerchant( }); } const {params, onyxData} = data; + notifyNewAction(Navigation.getSearchTopmostReportId(), currentUserAccountIDParam); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_MERCHANT, params, onyxData); } @@ -5239,6 +5241,7 @@ function updateMoneyRequestTag( currentUserEmailParam, isASAPSubmitBetaEnabled, }); + notifyNewAction(Navigation.getSearchTopmostReportId(), currentUserAccountIDParam); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_TAG, params, onyxData); } @@ -5459,6 +5462,7 @@ function updateMoneyRequestCategory({ isASAPSubmitBetaEnabled, hash, }); + notifyNewAction(Navigation.getSearchTopmostReportId(), currentUserAccountIDParam); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_CATEGORY, params, onyxData); } @@ -5498,6 +5502,7 @@ function updateMoneyRequestDescription( } const {params, onyxData} = data; params.description = parsedComment; + notifyNewAction(Navigation.getSearchTopmostReportId(), currentUserAccountIDParam); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_DESCRIPTION, params, onyxData); } @@ -8647,6 +8652,7 @@ function updateMoneyRequestAmountAndCurrency({ removeTransactionFromDuplicateTransactionViolation(data.onyxData, transactionID, transactions, transactionViolations); } const {params, onyxData} = data; + notifyNewAction(Navigation.getSearchTopmostReportId(), currentUserAccountIDParam); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_AMOUNT_AND_CURRENCY, params, onyxData); } From afe49fa4cfbee6f26667cac88fd4527a74354d14 Mon Sep 17 00:00:00 2001 From: nkdengineer Date: Thu, 11 Dec 2025 12:36:13 +0700 Subject: [PATCH 024/182] fix lint --- src/libs/Navigation/Navigation.ts | 2 +- tests/actions/IOUTest.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/libs/Navigation/Navigation.ts b/src/libs/Navigation/Navigation.ts index cd6e6b60014b..a81c2afbae0e 100644 --- a/src/libs/Navigation/Navigation.ts +++ b/src/libs/Navigation/Navigation.ts @@ -21,6 +21,7 @@ import ROUTES from '@src/ROUTES'; import SCREENS, {PROTECTED_SCREENS} from '@src/SCREENS'; import type {Account} from '@src/types/onyx'; import getInitialSplitNavigatorState from './AppNavigator/createSplitNavigator/getInitialSplitNavigatorState'; +import getSearchTopmostReportParams from './getSearchTopmostReportParams'; import originalCloseRHPFlow from './helpers/closeRHPFlow'; import getStateFromPath from './helpers/getStateFromPath'; import getTopmostReportParams from './helpers/getTopmostReportParams'; @@ -36,7 +37,6 @@ import {linkingConfig} from './linkingConfig'; import {SPLIT_TO_SIDEBAR} from './linkingConfig/RELATIONS'; import navigationRef from './navigationRef'; import type {NavigationPartialRoute, NavigationRef, NavigationRoute, NavigationStateRoute, ReportsSplitNavigatorParamList, RootNavigatorParamList, State} from './types'; -import getSearchTopmostReportParams from './getSearchTopmostReportParams'; // Routes which are part of the flow to set up 2FA const SET_UP_2FA_ROUTES = new Set([ diff --git a/tests/actions/IOUTest.ts b/tests/actions/IOUTest.ts index c67d079be66c..ba4ebb20c49d 100644 --- a/tests/actions/IOUTest.ts +++ b/tests/actions/IOUTest.ts @@ -124,6 +124,7 @@ jest.mock('@src/libs/Navigation/Navigation', () => ({ dismissModalWithReport: jest.fn(), goBack: jest.fn(), getTopmostReportId: jest.fn(() => topMostReportID), + getSearchTopmostReportId: jest.fn(() => topMostReportID), setNavigationActionToMicrotaskQueue: jest.fn(), removeScreenByKey: jest.fn(), isNavigationReady: jest.fn(() => Promise.resolve()), From 54b3cd37eb3bab81f6e31da9a487b56899f3c4bd Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Tue, 16 Dec 2025 22:38:01 +0100 Subject: [PATCH 025/182] fix react compiler error --- .../ComposerWithSuggestions.tsx | 6 ++++++ .../SilentCommentUpdater/index.tsx | 14 +++++++++++--- .../SilentCommentUpdater/types.ts | 3 +++ 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx index 614fe75999d1..7218a2ab4894 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx @@ -283,6 +283,11 @@ function ComposerWithSuggestions({ // The ref to check whether we're transitioning to a preexisting report const isTransitioningToPreExistingReport = useRef(false); + // Callback to clear the transitioning flag - passed to SilentCommentUpdater to avoid prop mutation + const handleTransitionToPreExistingReportComplete = useCallback(() => { + isTransitioningToPreExistingReport.current = false; + }, []); + const animatedRef = useAnimatedRef(); /** * Set the TextInput Ref @@ -904,6 +909,7 @@ function ComposerWithSuggestions({ commentRef={commentRef} isCommentPendingSaved={isCommentPendingSaved} isTransitioningToPreExistingReport={isTransitioningToPreExistingReport} + onTransitionToPreExistingReportComplete={handleTransitionToPreExistingReportComplete} /> )} diff --git a/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/index.tsx b/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/index.tsx index 1c9cbacf1ae5..8e832d3fa4f8 100644 --- a/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/index.tsx +++ b/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/index.tsx @@ -11,7 +11,15 @@ import type SilentCommentUpdaterProps from './types'; * It is connected to the actual draft comment in onyx. The comment in onyx might updates multiple times, and we want to avoid * re-rendering a UI component for that. That's why the side effect was moved down to a separate component. */ -function SilentCommentUpdater({commentRef, reportID, value, updateComment, isCommentPendingSaved, isTransitioningToPreExistingReport}: SilentCommentUpdaterProps) { +function SilentCommentUpdater({ + commentRef, + reportID, + value, + updateComment, + isCommentPendingSaved, + isTransitioningToPreExistingReport, + onTransitionToPreExistingReportComplete, +}: SilentCommentUpdaterProps) { const [comment = '', commentResult] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`, {canBeMissing: true}); const prevCommentProp = usePrevious(comment); const prevReportId = usePrevious(reportID); @@ -26,8 +34,7 @@ function SilentCommentUpdater({commentRef, reportID, value, updateComment, isCom // Skip sync when transitioning to a preexisting report // This prevents overwriting the just-saved draft with an empty string if (isTransitioningToPreExistingReport.current && reportID !== prevReportId) { - // eslint-disable-next-line no-param-reassign, react-compiler/react-compiler - isTransitioningToPreExistingReport.current = false; + onTransitionToPreExistingReportComplete(); return; } @@ -54,6 +61,7 @@ function SilentCommentUpdater({commentRef, reportID, value, updateComment, isCom commentRef, isCommentPendingSaved, isTransitioningToPreExistingReport, + onTransitionToPreExistingReportComplete, commentResult, ]); diff --git a/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/types.ts b/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/types.ts index 2e971d55989e..c11d2e73dc8b 100644 --- a/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/types.ts +++ b/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/types.ts @@ -16,6 +16,9 @@ type SilentCommentUpdaterProps = { /** The ref to check whether we're transitioning to a preexisting report */ isTransitioningToPreExistingReport: React.RefObject; + + /** Callback to clear the transitioning flag after transition to preexisting report is complete */ + onTransitionToPreExistingReportComplete: () => void; }; export default SilentCommentUpdaterProps; From f6c6c9f0a9006e79974b9121839b4db90e8d59ff Mon Sep 17 00:00:00 2001 From: truph01 Date: Wed, 17 Dec 2025 14:15:27 +0700 Subject: [PATCH 026/182] fix: remove missing description violation once turn off rule --- src/libs/Violations/ViolationsUtils.ts | 3 ++- src/libs/actions/Policy/Policy.ts | 13 ++++++++++++- src/pages/workspace/WorkspaceMoreFeaturesPage.tsx | 2 +- .../workspace/upgrade/WorkspaceUpgradePage.tsx | 4 +++- 4 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts index ecade80af3ef..0888217bb769 100644 --- a/src/libs/Violations/ViolationsUtils.ts +++ b/src/libs/Violations/ViolationsUtils.ts @@ -381,7 +381,8 @@ const ViolationsUtils = { isControlPolicy; const shouldCategoryShowOverLimitViolation = canCalculateAmountViolations && !isInvoiceTransaction && typeof categoryOverLimit === 'number' && expenseAmount > categoryOverLimit && isControlPolicy; - const shouldShowMissingComment = !isInvoiceTransaction && policyCategories?.[categoryName ?? '']?.areCommentsRequired && !updatedTransaction.comment?.comment && isControlPolicy; + const shouldShowMissingComment = + !isInvoiceTransaction && policyCategories?.[categoryName ?? '']?.areCommentsRequired && !updatedTransaction.comment?.comment && isControlPolicy && policy?.areRulesEnabled; const hasFutureDateViolation = transactionViolations.some((violation) => violation.name === 'futureDate'); // Add 'futureDate' violation if transaction date is in the future and policy type is corporate if (!hasFutureDateViolation && shouldDisplayFutureDateViolation) { diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 4f053035f3c0..7e222de99411 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -7,6 +7,7 @@ import Onyx from 'react-native-onyx'; import type {TupleToUnion, ValueOf} from 'type-fest'; import type {ReportExportType} from '@components/ButtonWithDropdownMenu/types'; import type {LocaleContextProps} from '@components/LocaleContextProvider'; +import type PolicyData from '@hooks/usePolicyData/types'; import * as API from '@libs/API'; import type { AddBillingCardAndRequestWorkspaceOwnerChangeParams, @@ -4451,7 +4452,7 @@ const DISABLED_MAX_EXPENSE_VALUES: Pick { if (!policyID) { diff --git a/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx b/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx index 13f1d223938c..0c1573257584 100644 --- a/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx +++ b/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx @@ -8,6 +8,7 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; +import usePolicyData from '@hooks/usePolicyData'; import useThemeStyles from '@hooks/useThemeStyles'; import {updateQuickbooksOnlineSyncClasses, updateQuickbooksOnlineSyncCustomers, updateQuickbooksOnlineSyncLocations} from '@libs/actions/connections/QuickbooksOnline'; import {updateXeroMappings} from '@libs/actions/connections/Xero'; @@ -75,6 +76,7 @@ function WorkspaceUpgradePage({route}: WorkspaceUpgradePageProps) { const canPerformUpgrade = useMemo(() => canModifyPlan(ownerPolicies, policy), [ownerPolicies, policy]); const isUpgraded = useMemo(() => isControlPolicy(policy), [policy]); + const policyData = usePolicyData(policyID); const perDiemCustomUnit = getPerDiemCustomUnit(policy); const categoryId = route.params?.categoryId; @@ -174,7 +176,7 @@ function WorkspaceUpgradePage({route}: WorkspaceUpgradePageProps) { } break; case CONST.UPGRADE_FEATURE_INTRO_MAPPING.rules.id: - enablePolicyRules(policyID, true, false); + enablePolicyRules(policyID, true, false, policyData); break; case CONST.UPGRADE_FEATURE_INTRO_MAPPING.companyCards.id: enableCompanyCards(policyID, true, false); From b929ccbcd35677b1a4622863311036112b082488 Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Wed, 17 Dec 2025 13:25:32 +0100 Subject: [PATCH 027/182] fix: draft comment not copied from the optimistic transaction thread to the one-expense iou report --- src/libs/actions/Report.ts | 6 ++++-- .../ComposerWithSuggestions/ComposerWithSuggestions.tsx | 5 +++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 9fd4dd39837f..339c696aa711 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -1973,6 +1973,9 @@ function handlePreexistingReport(report: Report) { const isParentOneTransactionReport = isOneTransactionReport(parentReport); + // If the parent report is a one transaction report, we want to copy the draft comment to the one transaction report instead of the preexisting thread report + const reportToCopyDraftTo = !!parentReportID && isParentOneTransactionReport ? parentReportID : preexistingReportID; + // Only re-route them if they are still looking at the optimistically created report const activeRoute = Navigation.getActiveRoute(); if (activeRoute.includes(`/r/${reportID}`) || activeRoute.includes(`/search/view/${reportID}`)) { @@ -1999,6 +2002,7 @@ function handlePreexistingReport(report: Report) { // This will allow the newest draft comment to be transferred to the existing report DeviceEventEmitter.emit(`switchToPreExistingReport_${reportID}`, { preexistingReportID, + reportToCopyDraftTo, callback, }); @@ -2016,8 +2020,6 @@ function handlePreexistingReport(report: Report) { // after that clear the optimistically created report const draftReportComment = allReportDraftComments?.[`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`]; - // If the parent report is a one transaction report, we want to copy the draft comment to the one transaction report instead of the preexisting thread report - const reportToCopyDraftTo = !!parentReportID && isParentOneTransactionReport ? parentReportID : preexistingReportID; if (!draftReportComment) { callback(); return; diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx index 7218a2ab4894..5b94de485e9c 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx @@ -144,6 +144,7 @@ type ComposerWithSuggestionsProps = Partial & type SwitchToCurrentReportProps = { preexistingReportID: string; + reportToCopyDraftTo: string; callback: () => void; }; @@ -320,7 +321,7 @@ function ComposerWithSuggestions({ ); useEffect(() => { - const switchToCurrentReport = DeviceEventEmitter.addListener(`switchToPreExistingReport_${reportID}`, ({preexistingReportID, callback}: SwitchToCurrentReportProps) => { + const switchToCurrentReport = DeviceEventEmitter.addListener(`switchToPreExistingReport_${reportID}`, ({reportToCopyDraftTo, callback}: SwitchToCurrentReportProps) => { if (!commentRef.current) { callback(); return; @@ -329,7 +330,7 @@ function ComposerWithSuggestions({ // Mark that we're transitioning to a preexisting report // This prevents SilentCommentUpdater from overwriting the draft isTransitioningToPreExistingReport.current = true; - saveReportDraftComment(preexistingReportID, commentRef.current, callback); + saveReportDraftComment(reportToCopyDraftTo, commentRef.current, callback); }); return () => { From 6ef29f95b9880bf793bc26bdfb9b3fbb8c5ef1f4 Mon Sep 17 00:00:00 2001 From: nkdengineer Date: Thu, 25 Dec 2025 13:05:07 +0700 Subject: [PATCH 028/182] resolve conflict --- src/libs/Navigation/getSearchTopmostReportParams.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/libs/Navigation/getSearchTopmostReportParams.ts b/src/libs/Navigation/getSearchTopmostReportParams.ts index e4a6fdf5da2b..9cbcd953ad57 100644 --- a/src/libs/Navigation/getSearchTopmostReportParams.ts +++ b/src/libs/Navigation/getSearchTopmostReportParams.ts @@ -1,26 +1,22 @@ import type {NavigationState, PartialState} from '@react-navigation/native'; import NAVIGATORS from '@src/NAVIGATORS'; import SCREENS from '@src/SCREENS'; -import type {ReportsSplitNavigatorParamList, RootNavigatorParamList, SearchReportParamList} from './types'; +import type {ReportsSplitNavigatorParamList, RightModalNavigatorParamList, RootNavigatorParamList} from './types'; // This function is in a separate file than Navigation.ts to avoid cyclic dependency. type State = NavigationState | NavigationState | PartialState; -function getReportRHPParams(state: State): SearchReportParamList[typeof SCREENS.SEARCH.REPORT_RHP] | undefined { +function getReportRHPParams(state: State): RightModalNavigatorParamList[typeof SCREENS.RIGHT_MODAL.SEARCH_REPORT] | undefined { const lastRoute = state?.routes?.at(-1); if (!lastRoute || lastRoute.name !== NAVIGATORS.RIGHT_MODAL_NAVIGATOR) { return; } - const searchReportRoute = lastRoute.state?.routes?.findLast((route) => route?.name === SCREENS.RIGHT_MODAL.SEARCH_REPORT); - if (!searchReportRoute) { - return; - } - const topmostRHPReport = searchReportRoute.state?.routes?.findLast((route) => route?.name === SCREENS.SEARCH.REPORT_RHP); + const topmostRHPReport = lastRoute.state?.routes?.findLast((route) => route?.name === SCREENS.RIGHT_MODAL.SEARCH_REPORT); if (!topmostRHPReport) { return; } - return topmostRHPReport?.params as SearchReportParamList[typeof SCREENS.SEARCH.REPORT_RHP]; + return topmostRHPReport?.params as RightModalNavigatorParamList[typeof SCREENS.RIGHT_MODAL.SEARCH_REPORT]; } /** From f660c8dcf5451df60a1410fd1d96f86926dd5bb6 Mon Sep 17 00:00:00 2001 From: rayane-d <77965000+rayane-d@users.noreply.github.com> Date: Sat, 27 Dec 2025 23:29:40 +0100 Subject: [PATCH 029/182] Update src/libs/actions/Report.ts Co-authored-by: Eugene Voloshchak --- src/libs/actions/Report.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 339c696aa711..3f58be9c3bb6 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -1978,7 +1978,7 @@ function handlePreexistingReport(report: Report) { // Only re-route them if they are still looking at the optimistically created report const activeRoute = Navigation.getActiveRoute(); - if (activeRoute.includes(`/r/${reportID}`) || activeRoute.includes(`/search/view/${reportID}`)) { + if (activeRoute.includes(ROUTES.REPORT_WITH_ID.getRoute(reportID)) || activeRoute.includes(ROUTES.SEARCH_REPORT.getRoute({reportID}))) { const currCallback = callback; callback = () => { currCallback(); From 418613ba68d0bb26c985a85bd23de5d2f26fc763 Mon Sep 17 00:00:00 2001 From: Abdelhafidh Belalia <16493223+s77rt@users.noreply.github.com> Date: Fri, 2 Jan 2026 01:02:57 +0100 Subject: [PATCH 030/182] return hidden if notif pref is empty --- src/libs/ReportUtils.ts | 5 ++++- tests/unit/ReportUtilsTest.ts | 15 ++++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 8841ebf6e397..7585f05d0570 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -1862,7 +1862,10 @@ function getDefaultNotificationPreferenceForReport(report: OnyxEntry): V */ function getReportNotificationPreference(report: OnyxEntry): ValueOf { const participant = currentUserAccountID ? report?.participants?.[currentUserAccountID] : undefined; - return participant?.notificationPreference ?? CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; + + // Empty notification preference should return `hidden` + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + return participant?.notificationPreference || CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; } /** diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index aa3d81f7dbfd..06ae535f8ecc 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -72,6 +72,7 @@ import { getReportActionActorAccountID, getReportIDFromLink, getReportName as getReportNameDeprecated, + getReportNotificationPreference, getReportOrDraftReport, getReportPreviewMessage, getReportStatusTranslation, @@ -136,7 +137,7 @@ import type { import type {ErrorFields, Errors, OnyxValueWithOfflineFeedback} from '@src/types/onyx/OnyxCommon'; import type {JoinWorkspaceResolution} from '@src/types/onyx/OriginalMessage'; import type {ACHAccount, PolicyReportField} from '@src/types/onyx/Policy'; -import type {Participant, Participants} from '@src/types/onyx/Report'; +import type {NotificationPreference, Participant, Participants} from '@src/types/onyx/Report'; import {toCollectionDataSet} from '@src/types/utils/CollectionDataSet'; import {actionR14932 as mockIOUAction} from '../../__mocks__/reportData/actions'; import {chatReportR14932 as mockedChatReport, iouReportR14932 as mockIOUReport} from '../../__mocks__/reportData/reports'; @@ -10731,4 +10732,16 @@ describe('ReportUtils', () => { await Onyx.clear(); }); }); + + describe('getReportNotificationPreference', () => { + it('should return hidden if notification preference is empty', () => { + const report: Report = { + ...LHNTestUtils.getFakeReport([currentUserAccountID]), + participants: { + [currentUserAccountID]: {notificationPreference: '' as NotificationPreference}, + }, + }; + expect(getReportNotificationPreference(report)).toBe(CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN); + }); + }); }); From ac7bb0deb830cb91d62eab9d0425025a64849d40 Mon Sep 17 00:00:00 2001 From: truph01 Date: Mon, 5 Jan 2026 11:12:38 +0700 Subject: [PATCH 031/182] fix: unit test --- tests/unit/ViolationUtilsTest.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/ViolationUtilsTest.ts b/tests/unit/ViolationUtilsTest.ts index 4fa2b34fafa8..3fed2e049c77 100644 --- a/tests/unit/ViolationUtilsTest.ts +++ b/tests/unit/ViolationUtilsTest.ts @@ -291,6 +291,7 @@ describe('getViolationsOnyxData', () => { }); it('should add category specific violations', () => { + policy.areRulesEnabled = true; const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policy, policyTags, policyCategories, false, false); expect(result.value).toEqual(expect.arrayContaining([categoryOverLimitViolation, categoryReceiptRequiredViolation, categoryMissingCommentViolation, ...transactionViolations])); }); From a143d57561975be48be68a5e9923cf4d0e2d2f55 Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Tue, 6 Jan 2026 21:02:36 +0100 Subject: [PATCH 032/182] Refactor isOneTransactionReport usage --- src/libs/ReportUtils.ts | 6 +++--- src/libs/actions/Report.ts | 13 +------------ tests/actions/EnforceActionExportRestrictions.ts | 7 ------- 3 files changed, 4 insertions(+), 22 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 0cd3d6a46d9f..3036b9e929d6 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -2616,9 +2616,6 @@ function hasOnlyNonReimbursableTransactions(iouReportID: string | undefined): bo /** * Checks if a report has only one transaction associated with it - * NOTE: This function should not be exported because it accesses module-level Onyx data (allReportActions, allReports) - * which can become stale. Each file should implement its own version using local Onyx collections. - * See tests/actions/EnforceActionExportRestrictions.ts for more details. */ function isOneTransactionReport(report: OnyxEntry): boolean { return report?.transactionCount === 1; @@ -2626,6 +2623,9 @@ function isOneTransactionReport(report: OnyxEntry): boolean { /** * Checks if a report has only one transaction associated with it + * NOTE: This function should not be exported because it accesses module-level Onyx data (allReportActions, allReports) + * which can become stale. Each file should implement its own version using local Onyx collections. + * See tests/actions/EnforceActionExportRestrictions.ts for more details. * @deprecated - Use isOneTransactionReport instead */ function isOneTransactionReportDeprecated(report: OnyxEntry): boolean { diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index bf6c113db8f6..d3f1c5dfcba9 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -158,6 +158,7 @@ import { isHiddenForCurrentUser, isIOUReportUsingReport, isMoneyRequestReport, + isOneTransactionReport, isOpenExpenseReport, isProcessingReport, isReportManuallyReimbursed, @@ -761,18 +762,6 @@ function reportActionsExist(reportID: string): boolean { return allReportActions?.[reportID] !== undefined; } -/** - * Checks if a report has only one transaction associated with it - * NOTE: This function should not be exported because it accesses module-level Onyx data (allReportActions, allReports) - * which can become stale. Each file should implement its own version using local Onyx collections. - * See tests/actions/EnforceActionExportRestrictions.ts for more details. - */ -function isOneTransactionReport(report: OnyxEntry): boolean { - const reportActions = allReportActions?.[`${report?.reportID}`] ?? ([] as ReportAction[]); - const chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`]; - return !!ReportActionsUtils.getOneTransactionThreadReportID(report, chatReport, reportActions); -} - function updateChatName(reportID: string, oldReportName: string | undefined, reportName: string, type: typeof CONST.REPORT.CHAT_TYPE.GROUP | typeof CONST.REPORT.CHAT_TYPE.TRIP_ROOM) { const optimisticData: Array> = [ { diff --git a/tests/actions/EnforceActionExportRestrictions.ts b/tests/actions/EnforceActionExportRestrictions.ts index c61f35c2f72c..7a16c8a4e425 100644 --- a/tests/actions/EnforceActionExportRestrictions.ts +++ b/tests/actions/EnforceActionExportRestrictions.ts @@ -106,13 +106,6 @@ describe('Policy', () => { }); }); -describe('Report', () => { - it('does not export isOneTransactionReport', () => { - // @ts-expect-error the test is asserting that it's undefined, so the TS error is normal - expect(Report.isOneTransactionReport).toBeUndefined(); - }); -}); - describe('TransactionUtils', () => { it('does not export getTransaction', () => { // @ts-expect-error the test is asserting that it's undefined, so the TS error is normal From cef0e0ab3a61aa31d5f2ed58aca1a3c27592f1c7 Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Tue, 6 Jan 2026 23:15:43 +0100 Subject: [PATCH 033/182] fix lint --- tests/actions/EnforceActionExportRestrictions.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/actions/EnforceActionExportRestrictions.ts b/tests/actions/EnforceActionExportRestrictions.ts index 7a16c8a4e425..8720f641149e 100644 --- a/tests/actions/EnforceActionExportRestrictions.ts +++ b/tests/actions/EnforceActionExportRestrictions.ts @@ -5,7 +5,6 @@ import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as ReportUtils from '@libs/ReportUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; import * as Policy from '@userActions/Policy/Policy'; -import * as Report from '@userActions/Report'; import * as Task from '@userActions/Task'; // There are some methods that are OK to use inside an action file, but should not be exported. These are typically methods that look up and return Onyx data. From ae331221ac02feb9fa94394a1356d782289ba83b Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Wed, 7 Jan 2026 00:21:31 +0100 Subject: [PATCH 034/182] fix navigation bugs by fixing callback execution order in handlePreexistingReport function --- src/libs/actions/Report.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index d3f1c5dfcba9..2eacc1124a42 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -1984,7 +1984,6 @@ function handlePreexistingReport(report: Report) { if (activeRoute.includes(ROUTES.REPORT_WITH_ID.getRoute(reportID)) || activeRoute.includes(ROUTES.SEARCH_REPORT.getRoute({reportID}))) { const currCallback = callback; callback = () => { - currCallback(); if (!parentReportActionID || !isParentOneTransactionReport) { // We are either in a DM/group-DM that do not have a parent report, // a thread under any comment, @@ -1999,6 +1998,7 @@ function handlePreexistingReport(report: Report) { // and the one transaction should be accessed via the one expense report screen Navigation.setParams({reportID: parentReportID}); } + currCallback(); }; // The report screen will listen to this event and transfer the draft comment to the existing report From 872b0a160dcafda0b74d1036b76601e0f7c241ad Mon Sep 17 00:00:00 2001 From: dmkt9 Date: Wed, 7 Jan 2026 10:25:56 +0700 Subject: [PATCH 035/182] Standardize product behavior using "Global Create" --- src/ONYXKEYS.ts | 4 + src/hooks/useSearchHighlightAndScroll.ts | 48 +++++++++- src/libs/TransactionUtils/index.ts | 8 +- src/libs/actions/IOU/SendInvoice.ts | 15 ++-- src/libs/actions/IOU/index.ts | 89 ++++++++++++++++--- .../step/IOURequestStepConfirmation.tsx | 5 ++ tests/actions/IOUTest.ts | 40 ++++++++- tests/unit/useSearchHighlightAndScrollTest.ts | 77 ++++++++++++++++ 8 files changed, 263 insertions(+), 23 deletions(-) diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index c0e14dfed9f7..86af7997f9a5 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -645,6 +645,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_', @@ -1379,6 +1382,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 bb2826105dc4..6751c6e2b2d2 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 369a80cc643f..b1ea4b57c8ba 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -72,7 +72,7 @@ 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'; // eslint-disable-next-line @typescript-eslint/no-deprecated -import type {SearchTransaction} from '@src/types/onyx/SearchResults'; +import type {SearchDataTypes, SearchTransaction} from '@src/types/onyx/SearchResults'; import type { Comment, Receipt, @@ -2447,6 +2447,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, @@ -2573,6 +2578,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 dc3f9d285fef..be5c05d15928 100644 --- a/src/libs/actions/IOU/SendInvoice.ts +++ b/src/libs/actions/IOU/SendInvoice.ts @@ -34,7 +34,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, mergePolicyRecentlyUsedCategories, mergePolicyRecentlyUsedCurrencies, handleNavigateAfterExpenseCreate} from '.'; import type {BasePolicyParams} from '.'; type SendInvoiceInformation = { @@ -65,6 +65,7 @@ type SendInvoiceOptions = { companyWebsite?: string; policyRecentlyUsedCategories?: OnyxEntry; policyRecentlyUsedTags?: OnyxEntry; + isFromGlobalCreate?: boolean; }; type BuildOnyxDataForInvoiceParams = { @@ -675,6 +676,7 @@ function sendInvoice({ companyWebsite, policyRecentlyUsedCategories, policyRecentlyUsedTags, + isFromGlobalCreate, }: SendInvoiceOptions) { const parsedComment = getParsedComment(transaction?.comment?.comment?.trim() ?? ''); if (transaction?.comment) { @@ -738,11 +740,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 6cc6cfdbe687..535293e1a9a7 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'; // eslint-disable-next-line @typescript-eslint/no-deprecated @@ -196,7 +197,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'; @@ -227,6 +228,7 @@ import { isPerDiemRequest as isPerDiemRequestTransactionUtils, isScanning, isScanRequest as isScanRequestTransactionUtils, + mergeTransactionIdsHighlightOnSearchRoute, removeTransactionFromDuplicateTransactionViolation, } from '@libs/TransactionUtils'; import ViolationsUtils from '@libs/Violations/ViolationsUtils'; @@ -279,6 +281,7 @@ type BaseTransactionParams = { billable?: boolean; reimbursable?: boolean; customUnitRateID?: string; + isFromGlobalCreate?: boolean; }; type InitMoneyRequestParams = { @@ -650,6 +653,7 @@ type TrackExpenseTransactionParams = { customUnitRateID?: string; attendees?: Attendee[]; isLinkedTrackedExpenseReportArchived?: boolean; + isFromGlobalCreate?: boolean; }; type TrackExpenseAccountantParams = { @@ -940,9 +944,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; @@ -965,6 +969,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 */ @@ -5760,6 +5810,7 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep customUnitRateID, isTestDrive, isLinkedTrackedExpenseReportArchived, + isFromGlobalCreate, } = transactionParams; const testDriveCommentReportActionID = isTestDrive ? NumberUtils.rand64() : undefined; @@ -5946,9 +5997,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) { @@ -5956,6 +6004,15 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep } } + if (!requestMoneyInformation.isRetry) { + handleNavigateAfterExpenseCreate({ + activeReportID: backToReport ?? activeReportID, + transactionID: transaction.transactionID, + isFromGlobalCreate, + shouldHandleNavigation, + }); + } + if (activeReportID && !isMoneyRequestReport) { Navigation.setNavigationActionToMicrotaskQueue(() => setTimeout(() => { @@ -5984,7 +6041,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) || @@ -6069,7 +6126,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); @@ -6118,6 +6175,7 @@ function trackExpense(params: CreateTrackExpenseParams) { linkedTrackedExpenseReportID, customUnitRateID, attendees, + isFromGlobalCreate, } = transactionData; const isMoneyRequestReport = isMoneyRequestReportReportUtils(report); const currentChatReport = isMoneyRequestReport ? getReportOrDraftReport(report?.chatReportID) : report; @@ -6386,10 +6444,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); @@ -7934,6 +7997,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 @@ -8113,7 +8177,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); @@ -14476,6 +14540,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 e9ca980e28df..1c0a744b1932 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -605,6 +605,7 @@ function IOURequestStepConfirmation({ originalTransactionID: item.comment?.originalTransactionID, source: item.comment?.source, isLinkedTrackedExpenseReportArchived, + isFromGlobalCreate: item?.isFromGlobalCreate, }, shouldHandleNavigation: index === transactions.length - 1, shouldGenerateTransactionThreadReport, @@ -685,6 +686,7 @@ function IOURequestStepConfirmation({ billable: transaction.billable, reimbursable: transaction.reimbursable, attendees: transaction.comment?.attendees, + isFromGlobalCreate: transaction.isFromGlobalCreate, }, isASAPSubmitBetaEnabled, currentUserAccountIDParam: currentUserPersonalDetails.accountID, @@ -758,6 +760,7 @@ function IOURequestStepConfirmation({ customUnitRateID, attendees: item.comment?.attendees, isLinkedTrackedExpenseReportArchived, + isFromGlobalCreate: item?.isFromGlobalCreate, }, accountantParams: { accountant: item.accountant, @@ -826,6 +829,7 @@ function IOURequestStepConfirmation({ reimbursable: transaction.reimbursable, attendees: transaction.comment?.attendees, receipt: isManualDistanceRequest ? receiptFiles[transaction.transactionID] : undefined, + isFromGlobalCreate: transaction.isFromGlobalCreate, }, backToReport, isASAPSubmitBetaEnabled, @@ -1006,6 +1010,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 5d312250b8f4..52d7ab5b0598 100644 --- a/tests/actions/IOUTest.ts +++ b/tests/actions/IOUTest.ts @@ -27,6 +27,7 @@ import { getIOUReportActionToApproveOrPay, getPerDiemExpenseInformation, getReportPreviewAction, + handleNavigateAfterExpenseCreate, initMoneyRequest, initSplitExpense, markRejectViolationAsResolved, @@ -63,6 +64,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'; @@ -78,7 +80,7 @@ import { } from '@libs/ReportActionsUtils'; import type {OptimisticChatReport} from '@libs/ReportUtils'; import {buildOptimisticIOUReport, buildOptimisticIOUReportAction, buildTransactionThread, createDraftTransactionAndNavigateToParticipantSelector, isIOUReport} from '@libs/ReportUtils'; -import {buildOptimisticTransaction, getValidWaypoints, isDistanceRequest as isDistanceRequestUtil} from '@libs/TransactionUtils'; +import {buildOptimisticTransaction, getValidWaypoints, isDistanceRequest as isDistanceRequestUtil, mergeTransactionIdsHighlightOnSearchRoute} from '@libs/TransactionUtils'; import type {IOUAction} from '@src/CONST'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; @@ -147,6 +149,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; @@ -11434,4 +11437,39 @@ describe('actions/IOU', () => { isTransactionDuplicated(mockCashExpenseTransaction, duplicatedTransaction); }); }); + + it('handleNavigateAfterExpenseCreate', async () => { + const mockedIsReportTopmostSplitNavigator = isReportTopmostSplitNavigator as jest.MockedFunction; + const spyOnMergeTransactionIdsHighlightOnSearchRoute = jest.spyOn(require('@libs/TransactionUtils'), 'mergeTransactionIdsHighlightOnSearchRoute'); + 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: '1', isFromGlobalCreate: false}); + expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledTimes(0); + + handleNavigateAfterExpenseCreate({activeReportID: '1', isFromGlobalCreate: true}); + expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledTimes(0); + + mockedIsReportTopmostSplitNavigator.mockReturnValue(true); + handleNavigateAfterExpenseCreate({activeReportID: '1', isFromGlobalCreate: true, transactionID: '1'}); + expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledTimes(0); + + // When NOT on the Inbox tab + mockedIsReportTopmostSplitNavigator.mockReturnValue(false); + handleNavigateAfterExpenseCreate({activeReportID: '1', isFromGlobalCreate: true, transactionID: '1'}); + + // then mergeTransactionIdsHighlightOnSearchRoute will be called + expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledTimes(1); + expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledWith(CONST.SEARCH.DATA_TYPES.EXPENSE, {'1': true}); + spyOnMergeTransactionIdsHighlightOnSearchRoute.mockClear(); + + //If expense is an invoice + handleNavigateAfterExpenseCreate({activeReportID: '1', isFromGlobalCreate: true, transactionID: '1', isInvoice: true}); + + expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledTimes(1); + expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledWith(CONST.SEARCH.DATA_TYPES.INVOICE, {'1': true}); + spyOnMergeTransactionIdsHighlightOnSearchRoute.mockReset(); + }); }); diff --git a/tests/unit/useSearchHighlightAndScrollTest.ts b/tests/unit/useSearchHighlightAndScrollTest.ts index 8bf7650b979f..8d01f4e613cb 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, From d9db0f787571a228e888e799dbd42ec3f39d3362 Mon Sep 17 00:00:00 2001 From: dmkt9 Date: Wed, 7 Jan 2026 10:46:35 +0700 Subject: [PATCH 036/182] fix prettier --- src/libs/actions/IOU/SendInvoice.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/actions/IOU/SendInvoice.ts b/src/libs/actions/IOU/SendInvoice.ts index be5c05d15928..2809488775f9 100644 --- a/src/libs/actions/IOU/SendInvoice.ts +++ b/src/libs/actions/IOU/SendInvoice.ts @@ -34,7 +34,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, handleNavigateAfterExpenseCreate} from '.'; +import {getAllPersonalDetails, getReceiptError, getSearchOnyxUpdate, handleNavigateAfterExpenseCreate, mergePolicyRecentlyUsedCategories, mergePolicyRecentlyUsedCurrencies} from '.'; import type {BasePolicyParams} from '.'; type SendInvoiceInformation = { From e6915af997fb5d457a24bd40bdd7573b070e0fa4 Mon Sep 17 00:00:00 2001 From: dmkt9 Date: Wed, 7 Jan 2026 11:04:04 +0700 Subject: [PATCH 037/182] fix lint --- src/libs/actions/IOU/SendInvoice.ts | 2 -- tests/actions/IOUTest.ts | 20 +++++++++++--------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/libs/actions/IOU/SendInvoice.ts b/src/libs/actions/IOU/SendInvoice.ts index 2809488775f9..b9d2f2aca61c 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 { diff --git a/tests/actions/IOUTest.ts b/tests/actions/IOUTest.ts index 2f952e89e827..5b0a1a01b2bd 100644 --- a/tests/actions/IOUTest.ts +++ b/tests/actions/IOUTest.ts @@ -81,7 +81,7 @@ import { } from '@libs/ReportActionsUtils'; import type {OptimisticChatReport} from '@libs/ReportUtils'; import {buildOptimisticIOUReport, buildOptimisticIOUReportAction, buildTransactionThread, createDraftTransactionAndNavigateToParticipantSelector, isIOUReport} from '@libs/ReportUtils'; -import {buildOptimisticTransaction, getValidWaypoints, isDistanceRequest as isDistanceRequestUtil, mergeTransactionIdsHighlightOnSearchRoute} from '@libs/TransactionUtils'; +import {buildOptimisticTransaction, getValidWaypoints, isDistanceRequest as isDistanceRequestUtil} from '@libs/TransactionUtils'; import type {IOUAction} from '@src/CONST'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; @@ -11964,35 +11964,37 @@ describe('actions/IOU', () => { 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: '1', isFromGlobalCreate: false}); + handleNavigateAfterExpenseCreate({activeReportID, isFromGlobalCreate: false}); expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledTimes(0); - handleNavigateAfterExpenseCreate({activeReportID: '1', isFromGlobalCreate: true}); + handleNavigateAfterExpenseCreate({activeReportID, isFromGlobalCreate: true}); expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledTimes(0); mockedIsReportTopmostSplitNavigator.mockReturnValue(true); - handleNavigateAfterExpenseCreate({activeReportID: '1', isFromGlobalCreate: true, transactionID: '1'}); + handleNavigateAfterExpenseCreate({activeReportID, isFromGlobalCreate: true, transactionID}); expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledTimes(0); // When NOT on the Inbox tab mockedIsReportTopmostSplitNavigator.mockReturnValue(false); - handleNavigateAfterExpenseCreate({activeReportID: '1', isFromGlobalCreate: true, transactionID: '1'}); + handleNavigateAfterExpenseCreate({activeReportID, isFromGlobalCreate: true, transactionID}); // then mergeTransactionIdsHighlightOnSearchRoute will be called expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledTimes(1); - expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledWith(CONST.SEARCH.DATA_TYPES.EXPENSE, {'1': true}); + expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledWith(CONST.SEARCH.DATA_TYPES.EXPENSE, {[transactionID]: true}); spyOnMergeTransactionIdsHighlightOnSearchRoute.mockClear(); - //If expense is an invoice - handleNavigateAfterExpenseCreate({activeReportID: '1', isFromGlobalCreate: true, transactionID: '1', isInvoice: true}); + // If expense is an invoice + handleNavigateAfterExpenseCreate({activeReportID, isFromGlobalCreate: true, transactionID, isInvoice: true}); expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledTimes(1); - expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledWith(CONST.SEARCH.DATA_TYPES.INVOICE, {'1': true}); + expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledWith(CONST.SEARCH.DATA_TYPES.INVOICE, {[transactionID]: true}); spyOnMergeTransactionIdsHighlightOnSearchRoute.mockReset(); }); }); From 9645a644d9d53eadfd030014cc6fc8147addd0bb Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Fri, 9 Jan 2026 16:39:23 -0800 Subject: [PATCH 038/182] feat: update amounts/percentages split logic to match OD --- src/libs/actions/IOU/index.ts | 115 +++++++-- src/types/onyx/IOU.ts | 3 + tests/unit/SplitExpenseAutoAdjustmentTest.ts | 258 +++++++++++++++++++ 3 files changed, 360 insertions(+), 16 deletions(-) create mode 100644 tests/unit/SplitExpenseAutoAdjustmentTest.ts diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index c1edfd8a9d6d..205bd6775ec1 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -13609,7 +13609,7 @@ function markRejectViolationAsResolved(transactionID: string, reportID?: string) function initSplitExpenseItemData( transaction: OnyxEntry, - {amount, transactionID, reportID, created}: {amount?: number; transactionID?: string; reportID?: string; created?: string} = {}, + {amount, transactionID, reportID, created, isManuallyEdited}: {amount?: number; transactionID?: string; reportID?: string; created?: string; isManuallyEdited?: boolean} = {}, ): SplitExpense { const transactionDetails = getTransactionDetails(transaction); const currentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transaction?.reportID}`]; @@ -13625,6 +13625,7 @@ function initSplitExpenseItemData( statusNum: currentReport?.statusNum ?? 0, reportID: reportID ?? transaction?.reportID ?? String(CONST.DEFAULT_NUMBER_ID), reimbursable: transactionDetails?.reimbursable, + isManuallyEdited: isManuallyEdited ?? false, }; } @@ -13644,7 +13645,8 @@ function initSplitExpense(transactions: OnyxCollection, r if (isExpenseSplit) { const relatedTransactions = getChildTransactions(transactions, reports, originalTransactionID); const transactionDetails = getTransactionDetails(originalTransaction); - const splitExpenses = relatedTransactions.map((currentTransaction) => initSplitExpenseItemData(currentTransaction)); + // Mark existing child transactions as manually edited (locked) since we're editing existing splits + const splitExpenses = relatedTransactions.map((currentTransaction) => initSplitExpenseItemData(currentTransaction, {isManuallyEdited: true})); const draftTransaction = buildOptimisticTransaction({ originalTransactionID, transactionParams: { @@ -13672,9 +13674,18 @@ function initSplitExpense(transactions: OnyxCollection, r const transactionDetails = getTransactionDetails(transaction); const transactionDetailsAmount = transactionDetails?.amount ?? 0; + // New splits start as unedited (isManuallyEdited: false) so they participate in auto-redistribution const splitExpenses = [ - initSplitExpenseItemData(transaction, {amount: calculateIOUAmount(1, transactionDetailsAmount, transactionDetails?.currency ?? '', false), transactionID: NumberUtils.rand64()}), - initSplitExpenseItemData(transaction, {amount: calculateIOUAmount(1, transactionDetailsAmount, transactionDetails?.currency ?? '', true), transactionID: NumberUtils.rand64()}), + initSplitExpenseItemData(transaction, { + amount: calculateIOUAmount(1, transactionDetailsAmount, transactionDetails?.currency ?? '', false), + transactionID: NumberUtils.rand64(), + isManuallyEdited: false, + }), + initSplitExpenseItemData(transaction, { + amount: calculateIOUAmount(1, transactionDetailsAmount, transactionDetails?.currency ?? '', true), + transactionID: NumberUtils.rand64(), + isManuallyEdited: false, + }), ]; const draftTransaction = buildOptimisticTransaction({ @@ -13738,22 +13749,54 @@ function initDraftSplitExpenseDataForEdit(draftTransaction: OnyxEntry, draftTransaction: OnyxEntry) { if (!transaction || !draftTransaction) { return; } - Onyx.merge(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transaction.transactionID}`, { + const newTransactionID = NumberUtils.rand64(); + const newSplit = initSplitExpenseItemData(transaction, { + amount: 0, + transactionID: newTransactionID, + reportID: draftTransaction?.reportID, + isManuallyEdited: false, + }); + + const existingSplits = draftTransaction.comment?.splitExpenses ?? []; + const updatedSplitExpenses = [...existingSplits, newSplit]; + + // Get total amount and currency for redistribution + const total = getAmount(draftTransaction, undefined, undefined, true, true); + const currency = getCurrency(draftTransaction); + const originalTransactionID = draftTransaction.comment?.originalTransactionID ?? transaction.transactionID; + + // Calculate sum of manually edited splits + const editedSum = updatedSplitExpenses.filter((split) => split.isManuallyEdited).reduce((sum, split) => sum + split.amount, 0); + + // Find all unedited splits (including the new one) + const uneditedSplits = updatedSplitExpenses.filter((split) => !split.isManuallyEdited); + const uneditedCount = uneditedSplits.length; + + // Redistribute remaining amount among unedited splits + const remaining = total - editedSum; + const lastUneditedIndex = uneditedCount - 1; + let uneditedIndex = 0; + + const redistributedSplitExpenses = updatedSplitExpenses.map((split) => { + if (split.isManuallyEdited) { + return split; + } + const isLast = uneditedIndex === lastUneditedIndex; + const newAmount = calculateIOUAmount(lastUneditedIndex, remaining, currency, isLast, true); + uneditedIndex += 1; + return {...split, amount: newAmount}; + }); + + Onyx.merge(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}`, { comment: { - splitExpenses: [ - ...(draftTransaction.comment?.splitExpenses ?? []), - initSplitExpenseItemData(transaction, { - amount: 0, - transactionID: NumberUtils.rand64(), - reportID: draftTransaction?.reportID, - }), - ], + splitExpenses: redistributedSplitExpenses, splitsStartDate: null, splitsEndDate: null, }, @@ -13792,6 +13835,8 @@ function evenlyDistributeSplitExpenseAmounts(draftTransaction: OnyxEntry ({ ...splitExpense, amount: calculateIOUAmount(splitCount - 1, total, currency, index === lastIndex, true), + // Reset isManuallyEdited since user explicitly requested even distribution + isManuallyEdited: false, })); Onyx.merge(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}`, { @@ -13906,19 +13951,57 @@ function updateSplitExpenseAmountField(draftTransaction: OnyxEntry { + const splitExpenses = draftTransaction.comment?.splitExpenses ?? []; + const originalTransactionID = draftTransaction.comment?.originalTransactionID; + const total = getAmount(draftTransaction, undefined, undefined, true, true); + const currency = getCurrency(draftTransaction); + + // Mark the edited split and update its amount + const splitWithUpdatedAmount = splitExpenses.map((splitExpense) => { if (splitExpense.transactionID === currentItemTransactionID) { return { ...splitExpense, amount, + isManuallyEdited: true, }; } return splitExpense; }); - Onyx.merge(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${draftTransaction?.comment?.originalTransactionID}`, { + // Find unedited splits (excluding the one being edited) + const uneditedSplits = splitWithUpdatedAmount.filter((split) => !split.isManuallyEdited); + + // If no unedited splits remain, just save the updated amounts without redistribution + if (uneditedSplits.length === 0) { + Onyx.merge(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}`, { + comment: { + splitExpenses: splitWithUpdatedAmount, + }, + }); + return; + } + + // Sum amounts of manually edited splits (the updated split is already marked as edited) + const editedSum = splitWithUpdatedAmount.filter((split) => split.isManuallyEdited).reduce((sum, split) => sum + split.amount, 0); + + // Redistribute remaining amount among unedited splits + const remaining = total - editedSum; + const lastUneditedIndex = uneditedSplits.length - 1; + let uneditedIndex = 0; + + const redistriutedSplitExpenses = splitWithUpdatedAmount.map((split) => { + if (split.isManuallyEdited) { + return split; + } + const isLast = uneditedIndex === lastUneditedIndex; + const newAmount = calculateIOUAmount(lastUneditedIndex, remaining, currency, isLast, true); + uneditedIndex += 1; + return {...split, amount: newAmount}; + }); + + Onyx.merge(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}`, { comment: { - splitExpenses: updatedSplitExpenses, + splitExpenses: redistriutedSplitExpenses, }, }); } diff --git a/src/types/onyx/IOU.ts b/src/types/onyx/IOU.ts index 5858e25da3b6..d10145463b10 100644 --- a/src/types/onyx/IOU.ts +++ b/src/types/onyx/IOU.ts @@ -159,6 +159,9 @@ type SplitExpense = { /** Whether the split expense is reimbursable (out-of-pocket) or non-reimbursable (company spend) */ reimbursable?: boolean; + + /** Whether this split has been manually edited by the user (locks the value from auto-adjustment) */ + isManuallyEdited?: boolean; }; /** Model of IOU request */ diff --git a/tests/unit/SplitExpenseAutoAdjustmentTest.ts b/tests/unit/SplitExpenseAutoAdjustmentTest.ts new file mode 100644 index 000000000000..318276aac126 --- /dev/null +++ b/tests/unit/SplitExpenseAutoAdjustmentTest.ts @@ -0,0 +1,258 @@ +import Onyx from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; +import {addSplitExpenseField, evenlyDistributeSplitExpenseAmounts, updateSplitExpenseAmountField} from '@libs/actions/IOU'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Transaction} from '@src/types/onyx'; +import type {SplitExpense} from '@src/types/onyx/IOU'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +/** + * Tests for the split expense auto-adjustment feature. + * When splitting an expense: + * - Unedited splits auto-adjust to sum to 100%/total amount + * - Manually edited splits are "locked" and preserved + * - Adding a new split redistributes among unedited splits + */ +describe('Split Expense Auto-Adjustment', () => { + const ORIGINAL_TRANSACTION_ID = 'originalTx123'; + const REPORT_ID = 'report123'; + const CURRENCY = 'USD'; + const TOTAL_AMOUNT = 1000; // $10.00 in cents + + // Helper to create a mock draft transaction + const createMockDraftTransaction = (splitExpenses: SplitExpense[], amount = TOTAL_AMOUNT): OnyxEntry => + ({ + transactionID: ORIGINAL_TRANSACTION_ID, + reportID: REPORT_ID, + amount, + currency: CURRENCY, + comment: { + originalTransactionID: ORIGINAL_TRANSACTION_ID, + splitExpenses, + }, + }) as unknown as Transaction; + + // Helper to create a split expense + const createSplitExpense = (transactionID: string, amount: number, isManuallyEdited = false): SplitExpense => ({ + transactionID, + amount, + created: '2024-01-01', + isManuallyEdited, + }); + + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + }); + }); + + beforeEach(() => { + return Onyx.clear().then(waitForBatchedUpdates); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('addSplitExpenseField', () => { + it('should redistribute evenly when adding a split to 2 unedited splits', async () => { + // Setup: 2 splits at $5/$5 (50/50) + const initialSplits = [createSplitExpense('split1', 500, false), createSplitExpense('split2', 500, false)]; + + const mockTransaction = createMockDraftTransaction(initialSplits); + + await Onyx.set(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, mockTransaction as Transaction); + await waitForBatchedUpdates(); + + // Action: Add a third split + addSplitExpenseField(mockTransaction, mockTransaction); + await waitForBatchedUpdates(); + + // Verify: Should be 3 splits at ~$3.33/$3.33/$3.34 (33/33/34%) + const draftTransaction = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, + callback: (value) => { + Onyx.disconnect(connection); + resolve(value); + }, + }); + }); + + const splitExpenses = draftTransaction?.comment?.splitExpenses ?? []; + expect(splitExpenses.length).toBe(3); + + // Total should equal original amount + const totalAmount = splitExpenses.reduce((sum, split) => sum + split.amount, 0); + expect(totalAmount).toBe(TOTAL_AMOUNT); + + // All splits should be unedited + expect(splitExpenses.every((split) => !split.isManuallyEdited)).toBe(true); + }); + + it('should preserve edited splits when adding a new split', async () => { + // Setup: 2 splits - one edited at $3, one unedited at $7 + const initialSplits = [ + createSplitExpense('split1', 300, true), // Edited/locked + createSplitExpense('split2', 700, false), // Unedited + ]; + + const mockTransaction = createMockDraftTransaction(initialSplits); + + await Onyx.set(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, mockTransaction as Transaction); + await waitForBatchedUpdates(); + + // Action: Add a third split + addSplitExpenseField(mockTransaction, mockTransaction); + await waitForBatchedUpdates(); + + // Verify + const draftTransaction = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, + callback: (value) => { + Onyx.disconnect(connection); + resolve(value); + }, + }); + }); + + const splitExpenses = draftTransaction?.comment?.splitExpenses ?? []; + expect(splitExpenses.length).toBe(3); + + // Edited split should remain at $3 and locked + const editedSplit = splitExpenses.find((s) => s.transactionID === 'split1'); + expect(editedSplit?.amount).toBe(300); + expect(editedSplit?.isManuallyEdited).toBe(true); + + // Remaining $7 should be split between 2 unedited splits + const uneditedSplits = splitExpenses.filter((s) => !s.isManuallyEdited); + expect(uneditedSplits.length).toBe(2); + const uneditedTotal = uneditedSplits.reduce((sum, s) => sum + s.amount, 0); + expect(uneditedTotal).toBe(700); // $7 total + + // Total should equal original amount + const totalAmount = splitExpenses.reduce((sum, split) => sum + split.amount, 0); + expect(totalAmount).toBe(TOTAL_AMOUNT); + }); + }); + + describe('updateSplitExpenseAmountField', () => { + it('should mark edited split and redistribute remaining to unedited splits', async () => { + // Setup: 3 unedited splits at $3.33/$3.33/$3.34 + const initialSplits = [createSplitExpense('split1', 333, false), createSplitExpense('split2', 333, false), createSplitExpense('split3', 334, false)]; + + const mockTransaction = createMockDraftTransaction(initialSplits); + + await Onyx.set(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, mockTransaction as Transaction); + await waitForBatchedUpdates(); + + // Action: Edit split1 to $3.00 + updateSplitExpenseAmountField(mockTransaction, 'split1', 300); + await waitForBatchedUpdates(); + + // Verify + const draftTransaction = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, + callback: (value) => { + Onyx.disconnect(connection); + resolve(value); + }, + }); + }); + + const splitExpenses = draftTransaction?.comment?.splitExpenses ?? []; + + // Edited split should be locked at $3 + const editedSplit = splitExpenses.find((s) => s.transactionID === 'split1'); + expect(editedSplit?.amount).toBe(300); + expect(editedSplit?.isManuallyEdited).toBe(true); + + // Remaining $7 should be split between 2 unedited splits + const uneditedSplits = splitExpenses.filter((s) => !s.isManuallyEdited); + expect(uneditedSplits.length).toBe(2); + const uneditedTotal = uneditedSplits.reduce((sum, s) => sum + s.amount, 0); + expect(uneditedTotal).toBe(700); + + // Total should equal original amount + const totalAmount = splitExpenses.reduce((sum, split) => sum + split.amount, 0); + expect(totalAmount).toBe(TOTAL_AMOUNT); + }); + + it('should not redistribute when all splits are manually edited', async () => { + // Setup: 2 manually edited splits + const initialSplits = [createSplitExpense('split1', 400, true), createSplitExpense('split2', 600, true)]; + + const mockTransaction = createMockDraftTransaction(initialSplits); + + await Onyx.set(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, mockTransaction as Transaction); + await waitForBatchedUpdates(); + + // Action: Edit split1 to $5.00 + updateSplitExpenseAmountField(mockTransaction, 'split1', 500); + await waitForBatchedUpdates(); + + // Verify: split2 should remain unchanged + const draftTransaction = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, + callback: (value) => { + Onyx.disconnect(connection); + resolve(value); + }, + }); + }); + + const splitExpenses = draftTransaction?.comment?.splitExpenses ?? []; + + expect(splitExpenses.find((s) => s.transactionID === 'split1')?.amount).toBe(500); + expect(splitExpenses.find((s) => s.transactionID === 'split2')?.amount).toBe(600); + + // Note: Total now exceeds original amount (user error case) + const totalAmount = splitExpenses.reduce((sum, split) => sum + split.amount, 0); + expect(totalAmount).toBe(1100); + }); + }); + + describe('evenlyDistributeSplitExpenseAmounts', () => { + it('should reset isManuallyEdited and distribute evenly', async () => { + // Setup: 3 splits with some manually edited + const initialSplits = [createSplitExpense('split1', 300, true), createSplitExpense('split2', 400, true), createSplitExpense('split3', 300, false)]; + + const mockTransaction = createMockDraftTransaction(initialSplits); + + await Onyx.set(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, mockTransaction as Transaction); + await waitForBatchedUpdates(); + + // Action: Make splits even + evenlyDistributeSplitExpenseAmounts(mockTransaction); + await waitForBatchedUpdates(); + + // Verify + const draftTransaction = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, + callback: (value) => { + Onyx.disconnect(connection); + resolve(value); + }, + }); + }); + + const splitExpenses = draftTransaction?.comment?.splitExpenses ?? []; + + // All splits should now be unedited + expect(splitExpenses.every((split) => !split.isManuallyEdited)).toBe(true); + + // Total should equal original amount + const totalAmount = splitExpenses.reduce((sum, split) => sum + split.amount, 0); + expect(totalAmount).toBe(TOTAL_AMOUNT); + + // Should be distributed as $3.33/$3.33/$3.34 + const amounts = splitExpenses.map((s) => s.amount).sort((a, b) => a - b); + expect(amounts).toEqual([333, 333, 334]); + }); + }); +}); From c8e695fe74726a9440f363b53526b91334cdbd4c Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Fri, 9 Jan 2026 18:13:45 -0800 Subject: [PATCH 039/182] fix: lint, tests and spellcheck --- src/libs/actions/IOU/index.ts | 4 ++-- tests/actions/IOUTest.ts | 6 ++++-- tests/unit/SplitExpenseAutoAdjustmentTest.ts | 13 ++++++------- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index 205bd6775ec1..2966b747d5a0 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -13989,7 +13989,7 @@ function updateSplitExpenseAmountField(draftTransaction: OnyxEntry { + const redistributedSplitExpenses = splitWithUpdatedAmount.map((split) => { if (split.isManuallyEdited) { return split; } @@ -14001,7 +14001,7 @@ function updateSplitExpenseAmountField(draftTransaction: OnyxEntry { category: 'Food', tags: ['lunch'], created: DateUtils.getDBTime(), + isManuallyEdited: true, // Lock the existing split so new split gets remaining amount }, ], attendees: [], @@ -8516,7 +8517,7 @@ describe('actions/IOU', () => { const splitExpenses = updatedDraftTransaction?.comment?.splitExpenses; expect(splitExpenses).toHaveLength(2); - expect(splitExpenses?.[1].amount).toBe(0); + expect(splitExpenses?.[1].amount).toBe(50); // New split gets remaining 50 from total 100 - 50 locked expect(splitExpenses?.[1].description).toBe('Test comment'); expect(splitExpenses?.[1].category).toBe('Food'); expect(splitExpenses?.[1].tags).toEqual(['lunch']); @@ -8558,6 +8559,7 @@ describe('actions/IOU', () => { tags: ['lunch'], created: DateUtils.getDBTime(), reimbursable: false, // Existing split - not reimbursable + isManuallyEdited: true, // Lock the existing split so new split gets remaining amount }, ], attendees: [], @@ -8582,7 +8584,7 @@ describe('actions/IOU', () => { // Verify: The new split should have reimbursable: false (not counted as out-of-pocket) expect(splitExpenses?.[1].reimbursable).toBe(false); - expect(splitExpenses?.[1].amount).toBe(0); + expect(splitExpenses?.[1].amount).toBe(50); // New split gets remaining 50 from total 100 - 50 locked expect(splitExpenses?.[1].description).toBe('Card transaction'); expect(splitExpenses?.[1].category).toBe('Food'); expect(splitExpenses?.[1].tags).toEqual(['lunch']); diff --git a/tests/unit/SplitExpenseAutoAdjustmentTest.ts b/tests/unit/SplitExpenseAutoAdjustmentTest.ts index 318276aac126..257d186a4fb6 100644 --- a/tests/unit/SplitExpenseAutoAdjustmentTest.ts +++ b/tests/unit/SplitExpenseAutoAdjustmentTest.ts @@ -1,7 +1,6 @@ import Onyx from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; import {addSplitExpenseField, evenlyDistributeSplitExpenseAmounts, updateSplitExpenseAmountField} from '@libs/actions/IOU'; -import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Transaction} from '@src/types/onyx'; import type {SplitExpense} from '@src/types/onyx/IOU'; @@ -21,7 +20,7 @@ describe('Split Expense Auto-Adjustment', () => { const TOTAL_AMOUNT = 1000; // $10.00 in cents // Helper to create a mock draft transaction - const createMockDraftTransaction = (splitExpenses: SplitExpense[], amount = TOTAL_AMOUNT): OnyxEntry => + const createMockDraftTransaction = (splitExpenses: SplitExpense[], amount = TOTAL_AMOUNT): Transaction => ({ transactionID: ORIGINAL_TRANSACTION_ID, reportID: REPORT_ID, @@ -62,7 +61,7 @@ describe('Split Expense Auto-Adjustment', () => { const mockTransaction = createMockDraftTransaction(initialSplits); - await Onyx.set(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, mockTransaction as Transaction); + await Onyx.set(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, mockTransaction); await waitForBatchedUpdates(); // Action: Add a third split @@ -100,7 +99,7 @@ describe('Split Expense Auto-Adjustment', () => { const mockTransaction = createMockDraftTransaction(initialSplits); - await Onyx.set(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, mockTransaction as Transaction); + await Onyx.set(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, mockTransaction); await waitForBatchedUpdates(); // Action: Add a third split @@ -145,7 +144,7 @@ describe('Split Expense Auto-Adjustment', () => { const mockTransaction = createMockDraftTransaction(initialSplits); - await Onyx.set(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, mockTransaction as Transaction); + await Onyx.set(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, mockTransaction); await waitForBatchedUpdates(); // Action: Edit split1 to $3.00 @@ -187,7 +186,7 @@ describe('Split Expense Auto-Adjustment', () => { const mockTransaction = createMockDraftTransaction(initialSplits); - await Onyx.set(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, mockTransaction as Transaction); + await Onyx.set(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, mockTransaction); await waitForBatchedUpdates(); // Action: Edit split1 to $5.00 @@ -223,7 +222,7 @@ describe('Split Expense Auto-Adjustment', () => { const mockTransaction = createMockDraftTransaction(initialSplits); - await Onyx.set(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, mockTransaction as Transaction); + await Onyx.set(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, mockTransaction); await waitForBatchedUpdates(); // Action: Make splits even From 323ca9c9e1b381258fbaba132e4a2275349a36d0 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Sat, 10 Jan 2026 15:08:55 -0800 Subject: [PATCH 040/182] fix: allow editing negative amount splits --- src/components/MoneyRequestAmountInput.tsx | 5 +++++ src/components/NumberWithSymbolForm.tsx | 21 +++++++++++++------ .../SplitListItem/SplitAmountInput.tsx | 1 + 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/components/MoneyRequestAmountInput.tsx b/src/components/MoneyRequestAmountInput.tsx index 55a8192f985b..cb02e8af488c 100644 --- a/src/components/MoneyRequestAmountInput.tsx +++ b/src/components/MoneyRequestAmountInput.tsx @@ -95,6 +95,9 @@ type MoneyRequestAmountInputProps = { /** Whether to allow flipping amount */ allowFlippingAmount?: boolean; + /** Whether to allow direct negative input (for split amounts where value is already negative) */ + allowNegativeInput?: boolean; + /** The testID of the input. Used to locate this view in end-to-end tests. */ testID?: string; @@ -161,6 +164,7 @@ function MoneyRequestAmountInput({ shouldWrapInputInContainer = true, isNegative = false, allowFlippingAmount = false, + allowNegativeInput = false, toggleNegative, clearNegative, ref, @@ -256,6 +260,7 @@ function MoneyRequestAmountInput({ autoGrowExtraSpace={autoGrowExtraSpace} submitBehavior={submitBehavior} allowFlippingAmount={allowFlippingAmount} + allowNegativeInput={allowNegativeInput} toggleNegative={toggleNegative} clearNegative={clearNegative} onFocus={props.onFocus} diff --git a/src/components/NumberWithSymbolForm.tsx b/src/components/NumberWithSymbolForm.tsx index bc41aa9045b4..912386290077 100644 --- a/src/components/NumberWithSymbolForm.tsx +++ b/src/components/NumberWithSymbolForm.tsx @@ -79,6 +79,9 @@ type NumberWithSymbolFormProps = { /** Whether to allow flipping amount */ allowFlippingAmount?: boolean; + /** Whether to allow direct negative input (for split amounts where value is already negative) */ + allowNegativeInput?: boolean; + /** Whether the input is disabled or not */ disabled?: boolean; @@ -144,6 +147,7 @@ function NumberWithSymbolForm({ shouldWrapInputInContainer = true, isNegative = false, allowFlippingAmount = false, + allowNegativeInput = false, toggleNegative, clearNegative, ref, @@ -218,11 +222,13 @@ function NumberWithSymbolForm({ const newNumberWithoutSpaces = stripSpacesFromAmount(newNumber); const rawFinalNumber = newNumberWithoutSpaces.includes('.') ? stripCommaFromAmount(newNumberWithoutSpaces) : replaceCommasWithPeriod(newNumberWithoutSpaces); - const finalNumber = handleNegativeAmountFlipping(rawFinalNumber, allowFlippingAmount, toggleNegative); + // When allowNegativeInput is true, keep negative sign as-is (for split amounts) + // When allowFlippingAmount is true, strip the negative sign and call toggleNegative + const finalNumber = allowNegativeInput ? rawFinalNumber : handleNegativeAmountFlipping(rawFinalNumber, allowFlippingAmount, toggleNegative); // Use a shallow copy of selection to trigger setSelection // More info: https://github.com/Expensify/App/issues/16385 - if (!validateAmount(finalNumber, decimals, maxLength)) { + if (!validateAmount(finalNumber, decimals, maxLength, allowNegativeInput)) { setSelection((prevSelection) => ({...prevSelection})); return; } @@ -253,11 +259,14 @@ function NumberWithSymbolForm({ // Remove spaces from the new number because Safari on iOS adds spaces when pasting a copied number // More info: https://github.com/Expensify/App/issues/16974 const newNumberWithoutSpaces = stripSpacesFromAmount(text); - const replacedCommasNumber = handleNegativeAmountFlipping(replaceCommasWithPeriod(newNumberWithoutSpaces), allowFlippingAmount, toggleNegative); + // When allowNegativeInput is true, keep negative sign as-is + const replacedCommasNumber = allowNegativeInput + ? replaceCommasWithPeriod(newNumberWithoutSpaces) + : handleNegativeAmountFlipping(replaceCommasWithPeriod(newNumberWithoutSpaces), allowFlippingAmount, toggleNegative); - const withLeadingZero = addLeadingZero(replacedCommasNumber); + const withLeadingZero = addLeadingZero(replacedCommasNumber, allowNegativeInput); - if (!validateAmount(withLeadingZero, decimals, maxLength)) { + if (!validateAmount(withLeadingZero, decimals, maxLength, allowNegativeInput)) { setSelection((prevSelection) => ({...prevSelection})); return; } @@ -280,7 +289,7 @@ function NumberWithSymbolForm({ // Modifies the number to match changed decimals. useEffect(() => { // If the number supports decimals, we can return - if (validateAmount(currentNumber, decimals, maxLength, allowFlippingAmount)) { + if (validateAmount(currentNumber, decimals, maxLength, allowNegativeInput || allowFlippingAmount)) { return; } diff --git a/src/components/SelectionList/ListItem/SplitListItem/SplitAmountInput.tsx b/src/components/SelectionList/ListItem/SplitListItem/SplitAmountInput.tsx index adf09095df74..94de8bc750a8 100644 --- a/src/components/SelectionList/ListItem/SplitListItem/SplitAmountInput.tsx +++ b/src/components/SelectionList/ListItem/SplitListItem/SplitAmountInput.tsx @@ -54,6 +54,7 @@ function SplitAmountInput({splitItem, formattedOriginalAmount, contentWidth, onS shouldWrapInputInContainer={false} onFocus={focusHandler} onBlur={onInputBlur} + allowNegativeInput /> ); } From 255c7eb48a6c4fb60b9c558312dfac22b4731eed Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Mon, 12 Jan 2026 22:32:41 +0100 Subject: [PATCH 041/182] Refactor route checks in handlePreexistingReport function to use ROUTES constants --- src/libs/actions/Report.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 2eacc1124a42..4a7129f510b3 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -2012,7 +2012,10 @@ function handlePreexistingReport(report: Report) { return; } - if (isParentOneTransactionReport && (activeRoute.includes(`/r/${parentReportID}`) || activeRoute.includes(`/search/view/${parentReportID}`))) { + if ( + isParentOneTransactionReport && + (activeRoute.includes(ROUTES.REPORT_WITH_ID.getRoute(parentReportID)) || activeRoute.includes(ROUTES.SEARCH_REPORT.getRoute({reportID: parentReportID}))) + ) { callback(); // We are already on the parent one expense report, so just call the API to fetch report data openReport(parentReportID); From c344420447bc67a72c8964c1793f2375dbb0721c Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Mon, 12 Jan 2026 22:42:22 +0100 Subject: [PATCH 042/182] clarify comment --- src/libs/actions/Report.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 53c12cb75261..ccfe92cc4157 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -2006,13 +2006,13 @@ function handlePreexistingReport(report: Report) { // a thread under any comment, // or transaction thread report under an IOU report action that its parent IOU report is not a one expense report, // we need to navigate to the preexisting report chat - // because we cleared the optimistically created report in the callback + // because we will clear the optimistically created report in the currCallback Navigation.setParams({reportID: preexistingReportID.toString()}); } else { // We are in a transaction thread report under an IOU report action where the parent IOU report is a one transaction report // We need to navigate to the one expense report screen instead of the preexisting report chat - // because we cleared the optimistically created transaction thread report in the callback - // and the one transaction should be accessed via the one expense report screen + // because we will clear the optimistically created transaction thread report in the currCallback + // and the one transaction should be accessed via the one expense report screen and not the preexisting report chat Navigation.setParams({reportID: parentReportID}); } currCallback(); From b1b5e45d5a805b97035817ee5c0f83f849e78ed5 Mon Sep 17 00:00:00 2001 From: dmkt9 Date: Tue, 13 Jan 2026 15:45:17 +0700 Subject: [PATCH 043/182] Standardize product behavior using "Global Create" --- src/hooks/useSearchHighlightAndScroll.ts | 2 +- src/libs/TransactionUtils/index.ts | 7 ------- src/libs/actions/IOU/index.ts | 3 +-- src/libs/actions/Transaction.ts | 6 ++++++ 4 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/hooks/useSearchHighlightAndScroll.ts b/src/hooks/useSearchHighlightAndScroll.ts index 6751c6e2b2d2..d723bf050915 100644 --- a/src/hooks/useSearchHighlightAndScroll.ts +++ b/src/hooks/useSearchHighlightAndScroll.ts @@ -5,9 +5,9 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {SearchQueryJSON} from '@components/Search/types'; import type {SearchListItem, SelectionListHandle, TransactionGroupListItemType, TransactionListItemType} from '@components/SelectionListWithSections/types'; import {search} from '@libs/actions/Search'; +import {mergeTransactionIdsHighlightOnSearchRoute} from '@libs/actions/Transaction'; 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'; diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 0781d84b4677..d7a040d75ac4 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -71,7 +71,6 @@ 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, @@ -2528,11 +2527,6 @@ 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, @@ -2663,7 +2657,6 @@ export { getConvertedAmount, shouldShowExpenseBreakdown, isTimeRequest, - mergeTransactionIdsHighlightOnSearchRoute, }; export type {TransactionChanges}; diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index 8fbb905a4f64..5db054562c14 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -229,7 +229,6 @@ import { isPerDiemRequest as isPerDiemRequestTransactionUtils, isScanning, isScanRequest as isScanRequestTransactionUtils, - mergeTransactionIdsHighlightOnSearchRoute, removeTransactionFromDuplicateTransactionViolation, } from '@libs/TransactionUtils'; import ViolationsUtils from '@libs/Violations/ViolationsUtils'; @@ -240,7 +239,7 @@ import {buildOptimisticPolicyRecentlyUsedTags, getPolicyTagsData} from '@userAct import type {GuidedSetupData} from '@userActions/Report'; import {buildInviteToRoomOnyxData, completeOnboarding, getCurrentUserAccountID, notifyNewAction, optimisticReportLastData} from '@userActions/Report'; import {clearAllRelatedReportActionErrors} from '@userActions/ReportActions'; -import {sanitizeRecentWaypoints} from '@userActions/Transaction'; +import {mergeTransactionIdsHighlightOnSearchRoute, sanitizeRecentWaypoints} from '@userActions/Transaction'; import {removeDraftSplitTransaction, removeDraftTransaction, removeDraftTransactions} from '@userActions/TransactionEdit'; import {getOnboardingMessages} from '@userActions/Welcome/OnboardingFlow'; import type {IOUAction, IOUActionParams, IOUType} from '@src/CONST'; diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts index f6704b993154..ca0ec609081e 100644 --- a/src/libs/actions/Transaction.ts +++ b/src/libs/actions/Transaction.ts @@ -47,6 +47,7 @@ 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 {WaypointCollection} from '@src/types/onyx/Transaction'; import type TransactionState from '@src/types/utils/TransactionStateType'; import {getPolicyTagsData} from './Policy/Tag'; @@ -1449,6 +1450,10 @@ function getDraftTransactions(draftTransactions?: OnyxCollection): return Object.values(draftTransactions ?? allTransactionDrafts ?? {}).filter((transaction): transaction is Transaction => !!transaction); } +function mergeTransactionIdsHighlightOnSearchRoute(type: SearchDataTypes, data: Record | null) { + return Onyx.merge(ONYXKEYS.TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE, {[type]: data}); +} + export { saveWaypoint, removeWaypoint, @@ -1467,4 +1472,5 @@ export { revert, changeTransactionsReport, setTransactionReport, + mergeTransactionIdsHighlightOnSearchRoute, }; From 08b19b362766befe5ab04388253eb4abd806d719 Mon Sep 17 00:00:00 2001 From: dmkt9 Date: Tue, 13 Jan 2026 16:06:12 +0700 Subject: [PATCH 044/182] fix tests --- tests/actions/IOUTest.ts | 2 +- tests/unit/useSearchHighlightAndScrollTest.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/actions/IOUTest.ts b/tests/actions/IOUTest.ts index ef59a3c2d209..e72333281adb 100644 --- a/tests/actions/IOUTest.ts +++ b/tests/actions/IOUTest.ts @@ -11390,7 +11390,7 @@ describe('actions/IOU', () => { it('handleNavigateAfterExpenseCreate', async () => { const mockedIsReportTopmostSplitNavigator = isReportTopmostSplitNavigator as jest.MockedFunction; - const spyOnMergeTransactionIdsHighlightOnSearchRoute = jest.spyOn(require('@libs/TransactionUtils'), 'mergeTransactionIdsHighlightOnSearchRoute'); + const spyOnMergeTransactionIdsHighlightOnSearchRoute = jest.spyOn(require('@libs/actions/Transaction'), 'mergeTransactionIdsHighlightOnSearchRoute'); const activeReportID = '1'; const transactionID = '1'; mockedIsReportTopmostSplitNavigator.mockReturnValue(false); diff --git a/tests/unit/useSearchHighlightAndScrollTest.ts b/tests/unit/useSearchHighlightAndScrollTest.ts index 8d01f4e613cb..0e4e011b2a92 100644 --- a/tests/unit/useSearchHighlightAndScrollTest.ts +++ b/tests/unit/useSearchHighlightAndScrollTest.ts @@ -258,7 +258,9 @@ describe('useSearchHighlightAndScroll', () => { }); it('should return new search result keys for manually highlighted expenses', async () => { - const spyOnMergeTransactionIdsHighlightOnSearchRoute = jest.spyOn(require('@libs/TransactionUtils'), 'mergeTransactionIdsHighlightOnSearchRoute').mockImplementationOnce(jest.fn()); + const spyOnMergeTransactionIdsHighlightOnSearchRoute = jest + .spyOn(require('@libs/actions/Transaction'), 'mergeTransactionIdsHighlightOnSearchRoute') + .mockImplementationOnce(jest.fn()); await Onyx.merge(ONYXKEYS.TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE, {[baseProps.queryJSON.type]: {'3': true}}); From 6b3ebd382644a348c880e184b233d3aeeb4a625b Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Wed, 14 Jan 2026 09:58:29 +0100 Subject: [PATCH 045/182] Enhance handlePreexistingReport function to preserve draft comments from the optimistic report when we are already on the parent one expense report --- src/libs/actions/Report.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index ccfe92cc4157..6bd809a69674 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -2030,12 +2030,25 @@ function handlePreexistingReport(report: Report) { } if ( + parentReportID && isParentOneTransactionReport && (activeRoute.includes(ROUTES.REPORT_WITH_ID.getRoute(parentReportID)) || activeRoute.includes(ROUTES.SEARCH_REPORT.getRoute({reportID: parentReportID}))) ) { - callback(); - // We are already on the parent one expense report, so just call the API to fetch report data - openReport(parentReportID); + // Check if there's a draft to preserve from the optimistic report + const draftReportComment = allReportDraftComments?.[`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`]; + + if (draftReportComment) { + // Transfer draft to parent report before clearing optimistic report + saveReportDraftComment(parentReportID, draftReportComment, () => { + callback(); + // We are already on the parent one expense report, so just call the API to fetch report data + openReport(parentReportID); + }); + } else { + callback(); + // We are already on the parent one expense report, so just call the API to fetch report data + openReport(parentReportID); + } return; } From 2e1b81c8ad55ab63b439df8d08d39f5b53cb5dc6 Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Wed, 14 Jan 2026 10:09:59 +0100 Subject: [PATCH 046/182] conditionally update parent report action only if it exists --- src/libs/actions/Report.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 6bd809a69674..5e05ed7ed9b1 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -1985,9 +1985,12 @@ function handlePreexistingReport(report: Report) { preexistingReportID: null, }); // Update the parent report action to point to the preexisting thread report - Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`, { - [parentReportActionID]: {childReportID: preexistingReportID}, - }); + const parentReportAction = parentReportID ? allReportActions?.[parentReportID]?.[parentReportActionID] : null; + if (parentReportAction) { + Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`, { + [parentReportActionID]: {childReportID: preexistingReportID}, + }); + } } }; From e15edb28b44e6e35cd8d958f5edc59765a14eac2 Mon Sep 17 00:00:00 2001 From: Getabalew Tesfaye Date: Wed, 14 Jan 2026 12:51:56 +0300 Subject: [PATCH 047/182] fix: use correct key for distance rates when duplicating --- src/libs/PolicyUtils.ts | 8 ++++---- src/libs/actions/Policy/Policy.ts | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 203735d086d5..66cf42122ef5 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -280,17 +280,17 @@ function hasEligibleActiveAdminFromWorkspaces(policies: OnyxCollection | return false; } -function getCustomUnitsForDuplication(policy: Policy, isCustomUnitsOptionSelected: boolean, isPerDiemOptionSelected: boolean): Record | undefined { +function getCustomUnitsForDuplication(policy: Policy, isDistanceRatesOptionSelected: boolean, isPerDiemOptionSelected: boolean): Record | undefined { const customUnits = policy?.customUnits; - if ((!isCustomUnitsOptionSelected && !isPerDiemOptionSelected) || !customUnits || Object.keys(customUnits).length === 0) { + if ((!isDistanceRatesOptionSelected && !isPerDiemOptionSelected) || !customUnits || Object.keys(customUnits).length === 0) { return undefined; } - if (isCustomUnitsOptionSelected && isPerDiemOptionSelected) { + if (isDistanceRatesOptionSelected && isPerDiemOptionSelected) { return customUnits; } - if (isCustomUnitsOptionSelected) { + if (isDistanceRatesOptionSelected) { const distanceCustomUnit = Object.values(customUnits).find((customUnit) => customUnit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE); if (!distanceCustomUnit) { return undefined; diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 907585f692a7..c183e7e49110 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -2690,7 +2690,7 @@ function buildDuplicatePolicyData(policy: Policy, options: DuplicatePolicyDataOp const isTaxesOptionSelected = parts?.taxes; const isTagsOptionSelected = parts?.tags; const isInvoicesOptionSelected = parts?.invoices; - const isCustomUnitsOptionSelected = parts?.customUnits; + const isDistanceRatesOptionSelected = parts?.distance; const isRulesOptionSelected = parts?.expenses; const isWorkflowsOptionSelected = parts?.exportLayouts; const isPerDiemOptionSelected = parts?.perDiem; @@ -2721,7 +2721,7 @@ function buildDuplicatePolicyData(policy: Policy, options: DuplicatePolicyDataOp ...policy, areCategoriesEnabled: true, areTagsEnabled: isTagsOptionSelected, - areDistanceRatesEnabled: isCustomUnitsOptionSelected, + areDistanceRatesEnabled: isDistanceRatesOptionSelected, areInvoicesEnabled: isInvoicesOptionSelected, areRulesEnabled: isRulesOptionSelected, areWorkflowsEnabled: isWorkflowsOptionSelected, @@ -2737,7 +2737,7 @@ function buildDuplicatePolicyData(policy: Policy, options: DuplicatePolicyDataOp name: policyName, fieldList: isReportsOptionSelected ? policy?.fieldList : undefined, connections: isConnectionsOptionSelected ? policy?.connections : undefined, - customUnits: getCustomUnitsForDuplication(policy, isCustomUnitsOptionSelected, isPerDiemOptionSelected), + customUnits: getCustomUnitsForDuplication(policy, isDistanceRatesOptionSelected, isPerDiemOptionSelected), taxRates: isTaxesOptionSelected ? policy?.taxRates : undefined, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, pendingFields: { From 6577e8182423ceef39fa586ee9a04b7d4ed4e961 Mon Sep 17 00:00:00 2001 From: Getabalew Tesfaye Date: Wed, 14 Jan 2026 13:08:18 +0300 Subject: [PATCH 048/182] fix: update tests --- tests/actions/PolicyTest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/actions/PolicyTest.ts b/tests/actions/PolicyTest.ts index 4b80b3891504..a023cd20130e 100644 --- a/tests/actions/PolicyTest.ts +++ b/tests/actions/PolicyTest.ts @@ -289,7 +289,7 @@ describe('actions/Policy', () => { perDiem: true, reimbursements: true, expenses: true, - customUnits: true, + distance: true, invoices: true, exportLayouts: true, }, From eefc5ab988db032ae5704868bd6ad183861a2353 Mon Sep 17 00:00:00 2001 From: Faizan Shoukat Abbasi Date: Thu, 15 Jan 2026 03:52:06 +0500 Subject: [PATCH 049/182] Refactored ConfirmModal usage to useConfirmModal --- src/pages/EditReportFieldPage.tsx | 39 +++-- src/pages/ReportParticipantDetailsPage.tsx | 54 ++++--- src/pages/RoomMemberDetailsPage.tsx | 54 ++++--- src/pages/Travel/TravelTerms.tsx | 37 ++--- .../PopoverReportActionContextMenu.tsx | 139 +++++++----------- 5 files changed, 140 insertions(+), 183 deletions(-) diff --git a/src/pages/EditReportFieldPage.tsx b/src/pages/EditReportFieldPage.tsx index 7a4b8631b446..e28e819f1776 100644 --- a/src/pages/EditReportFieldPage.tsx +++ b/src/pages/EditReportFieldPage.tsx @@ -1,13 +1,14 @@ import {Str} from 'expensify-common'; import React, {useState} from 'react'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; -import ConfirmModal from '@components/ConfirmModal'; import type {FormOnyxValues} from '@components/Form/types'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import * as Expensicons from '@components/Icon/Expensicons'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; import {useSession} from '@components/OnyxListItemProvider'; import type {PopoverMenuItem} from '@components/PopoverMenu'; import ScreenWrapper from '@components/ScreenWrapper'; +import useConfirmModal from '@hooks/useConfirmModal'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; @@ -50,8 +51,8 @@ function EditReportFieldPage({route}: EditReportFieldPageProps) { const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, {canBeMissing: true}); const hasViolations = hasViolationsReportUtils(report?.reportID, transactionViolations, session?.accountID ?? CONST.DEFAULT_NUMBER_ID, session?.email ?? ''); - const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false); const {translate} = useLocalize(); + const {showConfirmModal} = useConfirmModal(); const isReportFieldTitle = isReportFieldOfTypeTitle(reportField); const reportFieldsEnabled = ((isPaidGroupPolicyExpenseReport(report) || isInvoiceReport(report)) && !!policy?.areReportFieldsEnabled) || isReportFieldTitle; const hasOtherViolations = @@ -78,11 +79,21 @@ function EditReportFieldPage({route}: EditReportFieldPageProps) { }; const handleReportFieldDelete = () => { - setIsDeleteModalVisible(false); - goBack(); - setTimeout(() => { - deleteReportField(report.reportID, reportField); - }, CONST.ANIMATED_TRANSITION); + showConfirmModal({ + title: translate('workspace.reportFields.delete'), + prompt: translate('workspace.reportFields.deleteConfirmation'), + confirmText: translate('common.delete'), + cancelText: translate('common.cancel'), + danger: true, + shouldEnableNewFocusManagement: true, + }).then((result) => { + if (result.action === ModalActions.CONFIRM) { + goBack(); + setTimeout(() => { + deleteReportField(report.reportID, reportField); + }, CONST.ANIMATED_TRANSITION); + } + }); }; const fieldValue = isReportFieldTitle ? (report.reportName ?? '') : (reportField.value ?? reportField.defaultValue); @@ -121,7 +132,7 @@ function EditReportFieldPage({route}: EditReportFieldPageProps) { const isReportFieldDeletable = reportField.deletable && reportField?.fieldID !== CONST.REPORT_FIELD_TITLE_FIELD_ID; if (isReportFieldDeletable) { - menuItems.push({icon: Expensicons.Trashcan, text: translate('common.delete'), onSelected: () => setIsDeleteModalVisible(true), shouldCallAfterModalHide: true}); + menuItems.push({icon: Expensicons.Trashcan, text: translate('common.delete'), onSelected: handleReportFieldDelete, shouldCallAfterModalHide: true}); } const fieldName = Str.UCFirst(reportField.name); @@ -139,18 +150,6 @@ function EditReportFieldPage({route}: EditReportFieldPageProps) { onBackButtonPress={goBack} /> - setIsDeleteModalVisible(false)} - prompt={translate('workspace.reportFields.deleteConfirmation')} - confirmText={translate('common.delete')} - cancelText={translate('common.cancel')} - danger - shouldEnableNewFocusManagement - /> - {(reportField.type === CONST.REPORT_FIELD_TYPES.TEXT || isReportFieldTitle) && ( { - setIsRemoveMemberConfirmModalVisible(false); - removeFromGroupChat(report?.reportID, [accountID]); - Navigation.goBack(backTo); - }, [backTo, report?.reportID, accountID]); + + const handleRemoveUser = useCallback(() => { + showConfirmModal({ + danger: true, + title: translate('workspace.people.removeGroupMemberButtonTitle'), + prompt: translate('workspace.people.removeMemberPrompt', {memberName: displayName}), + confirmText: translate('common.remove'), + cancelText: translate('common.cancel'), + }).then((result) => { + if (result.action === ModalActions.CONFIRM) { + removeFromGroupChat(report?.reportID, [accountID]); + Navigation.goBack(backTo); + } + }); + }, [showConfirmModal, translate, displayName, report?.reportID, accountID, backTo]); const navigateToProfile = useCallback(() => { Navigation.navigate(ROUTES.PROFILE.getRoute(accountID, Navigation.getActiveRoute())); @@ -95,26 +105,14 @@ function ReportParticipantDetails({report, route}: ReportParticipantDetailsPageP )} {isCurrentUserAdmin && ( - <> - + +
From b28b0801fe8e81b8701892751858584968cd37e9 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Thu, 29 Jan 2026 06:08:08 +0530 Subject: [PATCH 116/182] rm gcse --- docs/_layouts/default.html | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html index d888cc244e46..8e0a9bcba70d 100644 --- a/docs/_layouts/default.html +++ b/docs/_layouts/default.html @@ -17,7 +17,6 @@ - From 4bf1eae992dd78c55be93442f19ebdd955a16030 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Thu, 29 Jan 2026 06:09:04 +0530 Subject: [PATCH 117/182] custom styles for search results --- docs/_sass/_search-bar.scss | 296 ++++++++++++++---------------------- 1 file changed, 110 insertions(+), 186 deletions(-) diff --git a/docs/_sass/_search-bar.scss b/docs/_sass/_search-bar.scss index fcf81a1fbf05..0381ee5cab0e 100644 --- a/docs/_sass/_search-bar.scss +++ b/docs/_sass/_search-bar.scss @@ -4,10 +4,7 @@ .search-icon { margin: auto 0px; -} - -.gsc-input-box { - border: 0 !important; + margin-left: auto; } #sidebar-search { @@ -39,34 +36,30 @@ } .searchbar-title-wrapper { - padding: 20px; + display: flex; + align-items: center; + padding: 16px 20px; } .search-title { font-size: 17px; - padding-bottom: 20px; + margin: 0; + padding: 0; + line-height: 24px; } #toggle-search-close { - margin: auto; - margin-left: 0px; + display: flex; + align-items: center; margin-right: 10px; } /* Sidebar Layer */ #sidebar-layer { position: fixed; - - /* Sit on top of the page content */ display: none; - - /* Hidden by default */ width: 100%; - - /* Full width (cover the whole page) */ height: 100%; - - /* Full height (cover the whole page) */ top: 0; left: 0; right: 0; @@ -75,207 +68,138 @@ z-index: 1; } -/* All gsc id & class are Google Search relate gcse_0 is the search bar parent & gcse_1 is the search result list parent */ -#___gcse_0 { - margin-left: 20px; - margin-top: -8px; +.search-form { + display: flex; + flex-direction: row; + align-items: center; + gap: 12px; + margin: 0 20px; } -/* This input is in #___gcse_0 search bar */ -input#gsc-i-id1.gsc-input { - background-image: none !important; - background-color: var(--color-appBG) !important; - padding: 15px 0px 0px !important; - pointer-events: auto; - color: var(--color-text) !important; - font-family: 'Expensify Neue', 'Segoe UI Emoji', 'Noto Color Emoji' !important; +.search-input-wrapper { + position: relative; + display: flex; + flex-direction: column; + justify-content: flex-end; + border: 1px solid var(--color-borders); + border-radius: 8px; + padding: 7px 12px; + flex: 1; + height: 52px; + min-height: 52px; + box-sizing: border-box; } -/* These below #gsc-iw-id1, .gsc-input-box & .gsib_a are inner wrapper of search bar input */ -#gsc-iw-id1 { - background-color: var(--color-appBG); - border-bottom: var(--color-borders) 2px solid !important; - border-bottom-left-radius: 0px; +.search-label { + font-family: 'Expensify Neue', 'Segoe UI Emoji', 'Noto Color Emoji'; + color: var(--color-text-supporting); pointer-events: none; - - &:focus-within { - border-bottom: var(--color-accent) 2px solid !important; - } -} - -.gsc-input-box .gsib_a { - padding: 0px 0px 4px 0px; -} - -.search-icon { - margin-left: auto; -} - -.gsst_b, -.gsst_a { - padding: 0px !important; -} -/* This is the close icon on search bar */ -.gsib_b .gsst_a .gscb_a { - color: var(--color-icons); - padding: 8px 6px 0px 6px !important; - pointer-events: auto; - - &:hover { - color: var(--color-text); - } -} - -/* This is to manage hover on parent close icon and make it the same effect on close icon */ -.gsst_a:hover { - .gscb_a { - color: var(--color-text) !important; - } -} - -/* Manage Google Search label animation */ -input#gsc-i-id1:focus + label.search-label, -input#gsc-i-id1:valid + label.search-label, -input#gsc-i-id1:active + label.search-label { - transform: translateY(-100%) scale(0.8); -} - -label.search-label { - display: block; - position: absolute; - margin-top: -20px; font-size: 15px; - font-family: 'Expensify Neue', 'Segoe UI Emoji', 'Noto Color Emoji'; + line-height: 20px; + transition: all 200ms ease-in-out; + position: absolute; + top: 50%; + left: 12px; transform: translateY(-50%); - left: 20px; - pointer-events: none; - color: var(--color-text-supporting); - transform-origin: left top; - user-select: none; - transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1), color 150ms cubic-bezier(0.4, 0, 0.2, 1), top 500ms; -} - -/* Hide the relevance, Ads, Branding, find more button & etc sections */ -.gsc-above-wrapper-area, -.gsc-webResult.gsc-result .gsc-url-top, -.gsc-results-wrapper-visible .gsc-adBlock, -.gcsc-more-maybe-branding-root, -.gcsc-find-more-on-google-root { - display: none; -} - -.gsc-control-cse { - background-color: var(--color-appBG) !important; - border: var(--color-appBG) !important; - font-family: 'Expensify Neue', 'Helvetica Neue', 'Helvetica', Arial, sans-serif !important; -} - -.gsc-webResult.gsc-result { - border-color: var(--color-appBG) !important; - background-color: var(--color-appBG) !important; } -/* Hide the scrollbar */ -.gsc-control-cse::-webkit-scrollbar { - display: none; +.search-input { + border: none; + outline: none; + background: transparent; + color: var(--color-text); + font-family: 'Expensify Neue', 'Segoe UI Emoji', 'Noto Color Emoji'; + font-size: 15px; + line-height: 20px; + width: 100%; + padding: 0; } -.gs-title * { - font-weight: bold; - color: var(--color-link) !important; +.search-input:focus ~ .search-label, +.search-input:valid ~ .search-label { + font-size: 13px; + line-height: 16px; + top: 7px; + transform: translateY(0); } -/* Change the Google Search Button icon into Expensify icon button */ -.gsc-search-button.gsc-search-button-v2 { - padding: 10px; - margin-left: 15px; - margin-right: 20px; - border-radius: 25px; +.search-button { background-color: var(--color-button-success-background); - border-color: var(--color-appBG) !important; - cursor: pointer; + border: none; + border-radius: 50%; width: 40px; height: 40px; -} - -.gsc-search-button.gsc-search-button-v2:hover, -.gsc-search-button.gsc-search-button-v2:focus { - background-color: var(--color-button-success-background-hover); -} + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + flex-shrink: 0; -.gsc-search-button.gsc-search-button-v2 svg { - fill: var(--color-button-text); - height: auto; - width: auto; -} + &:hover { + background-color: var(--color-button-success-background-hover); + } -.gsc-search-button.gsc-search-button-v2 svg path { - fill-rule: evenodd; - clip-rule: evenodd; + .base-icon { + filter: brightness(0) invert(1); + } } -.gsc-resultsbox-visible .gsc-webResult .gsc-result { - border-bottom: none; +.search-results { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 12px; } -/* Change Font the Google Search result */ -.gsc-control-cse .gsc-table-result { - font-family: 'Expensify Neue', 'Helvetica Neue', 'Helvetica', Arial, sans-serif !important; +.search-result-item { + display: flex; + flex-direction: row; + align-items: center; + gap: 12px; + padding: 12px 20px; + text-decoration: none; + cursor: pointer; + &:hover { + background-color: var(--color-highlightBG); + } } -/* Change Font result Paragraph color */ -.gsc-results .gs-webResult:not(.gs-no-results-result):not(.gs-error-result) .gs-snippet, -.gs-fileFormatType { - color: var(--color-text-supporting); +.search-result-content { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; + min-width: 0; } -/* Change the color of the Google Search Suggestion font */ -.gs-spelling.gs-result { +.search-result-title { + font-weight: 700; + font-size: 15px; + line-height: 20px; color: var(--color-text); } -/* Pagination related style */ -.gsc-resultsbox-visible .gsc-results .gsc-cursor-box { - text-align: center; -} - -.gsc-resultsbox-visible .gsc-results .gsc-cursor-box .gsc-cursor-page { - margin: 4px; - width: 28px; - height: 28px; - border-radius: 25px; - display: inline-block; - line-height: 2.5; - background-color: var(--color-accent); - font-weight: bold; - font-size: 11px; -} - -/* Change the color & background of Google Search Pagination */ -.gsc-cursor-next-page, -.gsc-cursor-final-page { - color: var(--color-text); - background-color: var(--color-appBG); +.search-result-description { + font-weight: 400; + font-size: 13px; + line-height: 16px; + color: var(--color-text-supporting); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; } -/* Change the color & background of Google Search Current Page */ -.gsc-resultsbox-visible .gsc-results .gsc-cursor-box .gsc-cursor-page.gsc-cursor-current-page { - background-color: var(--color-accent); - color: var(--color-text); - - &:hover { - text-decoration: none; - background-color: var(--color-accent); - } +.search-result-item > .base-icon { + flex-shrink: 0; + width: 20px; + height: 20px; } -/* Change the color & background of Google Search of Other Page */ -.gsc-resultsbox-visible .gsc-results .gsc-cursor-box .gsc-cursor-page { - background-color: var(--color-button-background); - color: var(--color-text); - - &:hover { - background-color: var(--color-button-background-hover); - text-decoration: none; - } +.search-loading, +.search-no-results, +.search-error { + padding: 0 20px; + color: var(--color-text-supporting); + font-size: 15px; } From 8c7feaae9d329047ba2cf986b0daf025422920e2 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Thu, 29 Jan 2026 06:09:21 +0530 Subject: [PATCH 118/182] hook in search from searchhelpsiteapi --- docs/assets/js/main.js | 151 +++++++++++++++++------------------------ 1 file changed, 63 insertions(+), 88 deletions(-) diff --git a/docs/assets/js/main.js b/docs/assets/js/main.js index 1c896e0f21d5..c5e7d5f4c8f0 100644 --- a/docs/assets/js/main.js +++ b/docs/assets/js/main.js @@ -101,7 +101,7 @@ function closeSidebarOnClickOutside(event) { function openSidebar() { document.getElementById('sidebar-layer').style.display = 'block'; - document.getElementById('gsc-i-id1').focus(); + document.getElementById('search-input').focus(); // Make body unscrollable const yAxis = document.documentElement.style.getPropertyValue('y-axis'); @@ -109,8 +109,6 @@ function openSidebar() { body.style.position = 'fixed'; body.style.top = `-${yAxis}`; - document.getElementById('gsc-i-id1').focus(); - // Close the sidebar when clicking sidebar layer (outside the sidebar search) const sidebarLayer = document.getElementById('sidebar-layer'); if (sidebarLayer) { @@ -118,52 +116,72 @@ function openSidebar() { } } -// Function to adapt & fix cropped SVG viewBox from Google based on viewport (Mobile or Tablet-Desktop) -function changeSVGViewBoxGoogle() { - // Get all inline Google SVG elements on the page - const svgsGoogle = document.querySelectorAll('svg[data-source]:not(.logo), .gsc-search-button.gsc-search-button-v2 svg'); - - Array.from(svgsGoogle).forEach((svg) => { - // Set the viewBox attribute to '0 0 13 13' to make the svg fit in the mobile view - svg.setAttribute('viewBox', '0 0 20 20'); - svg.setAttribute('height', '16'); - svg.setAttribute('width', '16'); - }); -} +/** + * Search the help site using the SearchHelpsite API. + * + * @param {string} query + */ +function searchHelpsite(query) { + const resultsContainer = document.getElementById('search-results'); + if (!query.trim()) { + resultsContainer.innerHTML = ''; + return; + } -// Function to insert element after another -// In this case, we insert the label element after the Google Search Input so we can have the same label animation effect -function insertElementAfter(referenceNode, newNode) { - referenceNode.parentNode.insertBefore(newNode, referenceNode.nextSibling); -} + resultsContainer.innerHTML = '
Searching...
'; + + const formData = new FormData(); + formData.append('command', 'SearchHelpsite'); + formData.append('query', query.trim()); + + fetch('https://www.expensify.com/api/SearchHelpsite', { + method: 'POST', + body: formData, + }) + .then((response) => response.json()) + .then((data) => { + const results = data.searchResults || []; + if (results.length === 0) { + resultsContainer.innerHTML = '
No results found
'; + return; + } -// Update the ICON for search input. -/* Change the path of the Google Search Button icon into Expensify icon */ -function updateGoogleSearchIcon() { - const node = document.querySelector('.gsc-search-button.gsc-search-button-v2 svg path'); - node.setAttribute( - 'd', - 'M8 1c3.9 0 7 3.1 7 7 0 1.4-.4 2.7-1.1 3.8l5.2 5.2c.6.6.6 1.5 0 2.1-.6.6-1.5.6-2.1 0l-5.2-5.2C10.7 14.6 9.4 15 8 15c-3.9 0-7-3.1-7-7s3.1-7 7-7zm0 3c2.2 0 4 1.8 4 4s-1.8 4-4 4-4-1.8-4-4 1.8-4 4-4z', - ); + resultsContainer.innerHTML = results + .map( + (result) => + ` +
+
${result.url.split('/').pop().replace(/-/g, ' ')}
+ ${result.description ? `
${result.description}
` : ''} +
+ +
`, + ) + .join(''); + }) + .catch(() => { + resultsContainer.innerHTML = '
Something went wrong. Please try again.
'; + }); } -// Need to wait up until page is load, so the svg viewBox can be changed -// And the search label can be inserted -window.addEventListener('load', () => { - changeSVGViewBoxGoogle(); +function initSearch() { + const searchInput = document.getElementById('search-input'); + const searchButton = document.getElementById('search-button'); - updateGoogleSearchIcon(); - - // Add required into the search input - const searchInput = document.getElementById('gsc-i-id1'); - searchInput.setAttribute('required', ''); + if (searchInput) { + searchInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + searchHelpsite(searchInput.value); + } + }); + } - // Insert search label after the search input - const searchLabel = document.createElement('label'); - searchLabel.classList.add('search-label'); - searchLabel.innerHTML = 'Search for something...'; - insertElementAfter(searchInput, searchLabel); -}); + if (searchButton) { + searchButton.addEventListener('click', () => { + searchHelpsite(searchInput.value); + }); + } +} const FIXED_HEADER_HEIGHT = 80; @@ -256,6 +274,8 @@ window.addEventListener('DOMContentLoaded', () => { }); } + initSearch(); + document.getElementById('header-button').addEventListener('click', toggleHeaderMenu); // Back button doesn't exist on all the pages @@ -318,48 +338,3 @@ window.addEventListener('hashchange', () => { }); }); -// We need to pass the results from readyCallback to renderedCallback so we make two part callback here to customize the results from GCSE API -const makeTwoPartCallback = () => { - let customResults = []; - const readyCallback = (name, q, promos, results, resultsDiv) => { - customResults = []; - results.forEach((result) => { - const {ogUrl, ogSiteName} = result.richSnippet.metatags; - - let newOgSiteName; - if (ogUrl.includes('expensify-classic')) { - newOgSiteName = 'Expensify Classic'; - } else if (ogUrl.includes('travel')) { - newOgSiteName = 'Expensify Travel'; - } else { - newOgSiteName = 'New Expensify'; - } - - result.title = result.title.replace(`- ${ogSiteName}`, `• ${newOgSiteName}`); - result.titleNoFormatting = result.titleNoFormatting.replace(`- ${ogSiteName}`, `• ${newOgSiteName}`); - if (!result.title.endsWith(` • ${newOgSiteName}`)) { - result.title = result.title + ` • ${newOgSiteName}`; - } - customResults.push(result); - }); - }; - const renderedCallback = (name, q, promos, results) => { - for (let i = 0; i < results.length; ++i) { - const div = results[i]; - const result = customResults[i]; - const titleElement = div.querySelector('a.gs-title'); - titleElement.innerHTML = result.title; - } - }; - return {readyCallback, renderedCallback}; -}; - -const {readyCallback: webResultsReadyCallback, renderedCallback: webResultsRenderedCallback} = makeTwoPartCallback(); - -window.__gcse || (window.__gcse = {}); -window.__gcse.searchCallbacks = { - web: { - ready: webResultsReadyCallback, - rendered: webResultsRenderedCallback, - }, -}; From 36af5dac3d1c9ae65926a903b8db4b3ba5d27b00 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Thu, 29 Jan 2026 06:17:37 +0530 Subject: [PATCH 119/182] add search templates item loading no-results error --- docs/_includes/search-result-item.html | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 docs/_includes/search-result-item.html diff --git a/docs/_includes/search-result-item.html b/docs/_includes/search-result-item.html new file mode 100644 index 000000000000..c63e5d9ecd5e --- /dev/null +++ b/docs/_includes/search-result-item.html @@ -0,0 +1,21 @@ + + + + + + + From 686ebd8b0d8239d55e2da76f0a5683a5924efc82 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Thu, 29 Jan 2026 06:17:38 +0530 Subject: [PATCH 120/182] add include for search result item template --- docs/_layouts/default.html | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html index 8e0a9bcba70d..de778ab1b720 100644 --- a/docs/_layouts/default.html +++ b/docs/_layouts/default.html @@ -50,6 +50,7 @@ {% include sidebar-search.html id="sidebar-layer" %} + {% include search-result-item.html %}
From 2135fa2bbeaeb94c3b3692aa0264dc7a4c501c99 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Thu, 29 Jan 2026 06:17:38 +0530 Subject: [PATCH 121/182] add cloneTemplate and use templates for results --- docs/assets/js/main.js | 45 ++++++++++++++++++++++++++++-------------- 1 file changed, 30 insertions(+), 15 deletions(-) diff --git a/docs/assets/js/main.js b/docs/assets/js/main.js index c5e7d5f4c8f0..57298d5e6875 100644 --- a/docs/assets/js/main.js +++ b/docs/assets/js/main.js @@ -116,6 +116,16 @@ function openSidebar() { } } +/** + * Clone a template element by its ID. + * + * @param {string} templateId + * @returns {DocumentFragment} + */ +function cloneTemplate(templateId) { + return document.getElementById(templateId).content.cloneNode(true); +} + /** * Search the help site using the SearchHelpsite API. * @@ -128,7 +138,8 @@ function searchHelpsite(query) { return; } - resultsContainer.innerHTML = '
Searching...
'; + resultsContainer.innerHTML = ''; + resultsContainer.appendChild(cloneTemplate('search-loading-template')); const formData = new FormData(); formData.append('command', 'SearchHelpsite'); @@ -141,26 +152,30 @@ function searchHelpsite(query) { .then((response) => response.json()) .then((data) => { const results = data.searchResults || []; + resultsContainer.innerHTML = ''; + if (results.length === 0) { - resultsContainer.innerHTML = '
No results found
'; + resultsContainer.appendChild(cloneTemplate('search-no-results-template')); return; } - resultsContainer.innerHTML = results - .map( - (result) => - ` -
-
${result.url.split('/').pop().replace(/-/g, ' ')}
- ${result.description ? `
${result.description}
` : ''} -
- -
`, - ) - .join(''); + results.forEach((result) => { + const item = cloneTemplate('search-result-item-template'); + const link = item.querySelector('.search-result-item'); + link.href = result.url; + link.querySelector('.search-result-title').textContent = result.url.split('/').pop().replace(/-/g, ' '); + const description = link.querySelector('.search-result-description'); + if (result.description) { + description.textContent = result.description; + } else { + description.remove(); + } + resultsContainer.appendChild(item); + }); }) .catch(() => { - resultsContainer.innerHTML = '
Something went wrong. Please try again.
'; + resultsContainer.innerHTML = ''; + resultsContainer.appendChild(cloneTemplate('search-error-template')); }); } From 09f0023fe4fe921d9e1d2106b5ffe7ece2776963 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Thu, 29 Jan 2026 06:31:27 +0530 Subject: [PATCH 122/182] run prettier --- docs/assets/js/main.js | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/assets/js/main.js b/docs/assets/js/main.js index 57298d5e6875..847f9b411e1a 100644 --- a/docs/assets/js/main.js +++ b/docs/assets/js/main.js @@ -352,4 +352,3 @@ window.addEventListener('hashchange', () => { behavior: 'smooth', }); }); - From 30a15049f3652ab9849454bdbd399e5acc884638 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Thu, 29 Jan 2026 06:33:54 +0530 Subject: [PATCH 123/182] cleanup --- docs/assets/js/main.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/assets/js/main.js b/docs/assets/js/main.js index 847f9b411e1a..9e895bf756bf 100644 --- a/docs/assets/js/main.js +++ b/docs/assets/js/main.js @@ -116,6 +116,12 @@ function openSidebar() { } } +const SEARCH_API_URL = 'https://www.expensify.com/api/SearchHelpsite'; + +function getTitleFromURL(url) { + return url.split('/').pop().replace(/-/g, ' '); +} + /** * Clone a template element by its ID. * @@ -145,7 +151,7 @@ function searchHelpsite(query) { formData.append('command', 'SearchHelpsite'); formData.append('query', query.trim()); - fetch('https://www.expensify.com/api/SearchHelpsite', { + fetch('https://www.expensify.com.dev/api/SearchHelpsite', { method: 'POST', body: formData, }) From c1c61499d8e24a057d24c30a4b0ed342a5ff3f3f Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Thu, 29 Jan 2026 06:34:27 +0530 Subject: [PATCH 124/182] cleanup --- docs/assets/js/main.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/assets/js/main.js b/docs/assets/js/main.js index 9e895bf756bf..5cfffec33031 100644 --- a/docs/assets/js/main.js +++ b/docs/assets/js/main.js @@ -151,7 +151,7 @@ function searchHelpsite(query) { formData.append('command', 'SearchHelpsite'); formData.append('query', query.trim()); - fetch('https://www.expensify.com.dev/api/SearchHelpsite', { + fetch(SEARCH_API_URL, { method: 'POST', body: formData, }) @@ -169,7 +169,7 @@ function searchHelpsite(query) { const item = cloneTemplate('search-result-item-template'); const link = item.querySelector('.search-result-item'); link.href = result.url; - link.querySelector('.search-result-title').textContent = result.url.split('/').pop().replace(/-/g, ' '); + link.querySelector('.search-result-title').textContent = getTitleFromURL(result.url); const description = link.querySelector('.search-result-description'); if (result.description) { description.textContent = result.description; From d9ac866f376682d6300b51589a2398021e17fb4a Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Thu, 29 Jan 2026 07:19:42 +0530 Subject: [PATCH 125/182] filter unlisted articles --- docs/assets/js/main.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/assets/js/main.js b/docs/assets/js/main.js index 5cfffec33031..a92b58fe35e3 100644 --- a/docs/assets/js/main.js +++ b/docs/assets/js/main.js @@ -157,7 +157,7 @@ function searchHelpsite(query) { }) .then((response) => response.json()) .then((data) => { - const results = data.searchResults || []; + const results = (data.searchResults || []).filter((result) => !result.url.includes('/Unlisted/')); resultsContainer.innerHTML = ''; if (results.length === 0) { From cab475b131dd6fd890bd8257df1542c0633a193d Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Thu, 29 Jan 2026 09:46:27 +0530 Subject: [PATCH 126/182] add tabindex on search-result-item anchor --- docs/_includes/search-result-item.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/_includes/search-result-item.html b/docs/_includes/search-result-item.html index c63e5d9ecd5e..99a97307558f 100644 --- a/docs/_includes/search-result-item.html +++ b/docs/_includes/search-result-item.html @@ -1,5 +1,5 @@