diff --git a/src/libs/DebugUtils.ts b/src/libs/DebugUtils.ts index 31a0a1ee5ef5..4e576fe7ba41 100644 --- a/src/libs/DebugUtils.ts +++ b/src/libs/DebugUtils.ts @@ -1487,10 +1487,21 @@ function getReasonAndReportActionForRBRInLHNRow( transactionViolations: OnyxCollection, hasViolations: boolean, reportErrors: Errors, + isOffline: boolean, isArchivedReport = false, ): RBRReasonAndReportAction | null { const {reason, reportAction} = - SidebarUtils.getReasonAndReportActionThatHasRedBrickRoad(report, chatReport, reportActions, hasViolations, reportErrors, transactions, transactionViolations, isArchivedReport) ?? {}; + SidebarUtils.getReasonAndReportActionThatHasRedBrickRoad( + report, + chatReport, + reportActions, + hasViolations, + reportErrors, + transactions, + isOffline, + transactionViolations, + isArchivedReport, + ) ?? {}; if (reason) { return {reason: `debug.reasonRBR.${reason}`, reportAction}; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 4f44b20ab5af..f41ba251a41d 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -9464,9 +9464,16 @@ function getAllReportErrors( return allReportErrors; } -function getReceiptUploadErrorReason(report: Report, chatReport: OnyxEntry, reportActions: OnyxEntry, transactions: OnyxCollection) { +function getReceiptUploadErrorReason( + report: Report, + chatReport: OnyxEntry, + reportActions: OnyxEntry, + transactions: OnyxCollection, + // We'll make it required eventually. Refactor issue: https://github.com/Expensify/App/issues/66407 + isOffline?: boolean, +) { const parentReportAction = getReportAction(report?.parentReportID, report?.parentReportActionID); - const transactionThreadReportAction = getOneTransactionThreadReportAction(report, chatReport, reportActions ?? []); + const transactionThreadReportAction = getOneTransactionThreadReportAction(report, chatReport, reportActions ?? [], isOffline); if (transactionThreadReportAction) { const transactionID = isMoneyRequestAction(transactionThreadReportAction) ? getOriginalMessage(transactionThreadReportAction)?.IOUTransactionID : undefined; const transaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index f85b14ff3336..c23158f71203 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -675,6 +675,7 @@ function getReasonAndReportActionThatHasRedBrickRoad( hasViolations: boolean, reportErrors: Errors, transactions: OnyxCollection, + isOffline: boolean, transactionViolations?: OnyxCollection, isReportArchived = false, reports?: OnyxCollection, @@ -709,7 +710,7 @@ function getReasonAndReportActionThatHasRedBrickRoad( }; } - return getReceiptUploadErrorReason(report, chatReport, reportActions, transactions); + return getReceiptUploadErrorReason(report, chatReport, reportActions, transactions, isOffline); } /** diff --git a/src/libs/actions/OnyxDerived/configs/reportAttributes.ts b/src/libs/actions/OnyxDerived/configs/reportAttributes.ts index 3038696ce6f7..eef1126e6797 100644 --- a/src/libs/actions/OnyxDerived/configs/reportAttributes.ts +++ b/src/libs/actions/OnyxDerived/configs/reportAttributes.ts @@ -1,4 +1,5 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import {getIsOffline} from '@libs/NetworkState'; import {computeReportName} from '@libs/ReportNameUtils'; import {generateIsEmptyReport, generateReportAttributes, hasVisibleReportFieldViolations, isArchivedReport, isValidReport} from '@libs/ReportUtils'; import SidebarUtils from '@libs/SidebarUtils'; @@ -89,11 +90,14 @@ export default createOnyxDerivedValueConfig({ ONYXKEYS.COLLECTION.POLICY_TAGS, ONYXKEYS.COLLECTION.REPORT_METADATA, ONYXKEYS.CONCIERGE_REPORT_ID, + ONYXKEYS.NETWORK, ], compute: ( [reports, preferredLocale, transactionViolations, reportActions, reportNameValuePairs, transactions, personalDetails, session, policies, policyTags], {currentValue, sourceValues}, ) => { + // Read the in-memory offline state directly (NETWORK is a dependency so recompute still fires when it changes). + const isOffline = getIsOffline(); // Check if display names changed when personal details are updated let displayNamesChanged = false; if (hasKeyTriggeredCompute(ONYXKEYS.PERSONAL_DETAILS_LIST, sourceValues)) { @@ -284,6 +288,7 @@ export default createOnyxDerivedValueConfig({ hasAnyViolations || hasFieldViolations, reportErrors, transactions, + isOffline, transactionViolations, !!isReportArchived, reports, diff --git a/src/pages/Debug/Report/DebugReportPage.tsx b/src/pages/Debug/Report/DebugReportPage.tsx index 1510b45cfddc..39429609b208 100644 --- a/src/pages/Debug/Report/DebugReportPage.tsx +++ b/src/pages/Debug/Report/DebugReportPage.tsx @@ -9,6 +9,7 @@ import Text from '@components/Text'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -60,6 +61,7 @@ function DebugReportPage({ const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`); const [transactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION); const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); + const {isOffline} = useNetwork(); const reportAttributesSelector = useCallback((attributes: OnyxEntry) => attributes?.reports?.[reportID], [reportID]); const [reportAttributes] = useOnyx( ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, @@ -95,6 +97,7 @@ function DebugReportPage({ transactionViolations, hasViolations, reportAttributes?.reportErrors ?? {}, + isOffline, isReportArchived, ) ?? {}; const hasRBR = !!reasonRBR; @@ -153,7 +156,7 @@ function DebugReportPage({ : undefined, }, ]; - }, [report, transactionViolations, isReportArchived, chatReport, reportActions, transactions, reportAttributes?.reportErrors, betas, priorityMode, draftComment, translate]); + }, [report, transactionViolations, isReportArchived, chatReport, reportActions, transactions, reportAttributes?.reportErrors, betas, priorityMode, draftComment, translate, isOffline]); const icons = useMemoizedLazyExpensifyIcons(['Eye']); diff --git a/tests/unit/DebugUtilsTest.ts b/tests/unit/DebugUtilsTest.ts index f80e1a568e5c..97219f58c1ed 100644 --- a/tests/unit/DebugUtilsTest.ts +++ b/tests/unit/DebugUtilsTest.ts @@ -1,5 +1,5 @@ import {renderHook} from '@testing-library/react-native'; -import type {OnyxCollection} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import useReportIsArchived from '@hooks/useReportIsArchived'; import DateUtils from '@libs/DateUtils'; @@ -1197,6 +1197,7 @@ describe('DebugUtils', () => { undefined, false, {}, + false, ) ?? {}; expect(reportAction).toBeUndefined(); }); @@ -1260,6 +1261,7 @@ describe('DebugUtils', () => { undefined, false, {}, + false, ) ?? {}; expect(reportAction).toBe(undefined); }); @@ -1327,8 +1329,16 @@ describe('DebugUtils', () => { }; const reportErrors = getAllReportErrors(MOCK_CHAT_REPORT, MOCK_CHAT_REPORT_ACTIONS, mockTransactions); const {reportAction} = - DebugUtils.getReasonAndReportActionForRBRInLHNRow(MOCK_CHAT_REPORT, chatReportR14932, MOCK_CHAT_REPORT_ACTIONS, mockTransactions, undefined, false, reportErrors) ?? - {}; + DebugUtils.getReasonAndReportActionForRBRInLHNRow( + MOCK_CHAT_REPORT, + chatReportR14932, + MOCK_CHAT_REPORT_ACTIONS, + mockTransactions, + undefined, + false, + reportErrors, + false, + ) ?? {}; expect(reportAction).toMatchObject(MOCK_CHAT_REPORT_ACTIONS['1']); }); it('returns correct report action which is a split bill and has an error', async () => { @@ -1400,7 +1410,8 @@ describe('DebugUtils', () => { }; const reportErrors = getAllReportErrors(MOCK_CHAT_REPORT, MOCK_REPORT_ACTIONS, mockTransactions); const {reportAction} = - DebugUtils.getReasonAndReportActionForRBRInLHNRow(MOCK_CHAT_REPORT, chatReportR14932, MOCK_REPORT_ACTIONS, mockTransactions, undefined, false, reportErrors) ?? {}; + DebugUtils.getReasonAndReportActionForRBRInLHNRow(MOCK_CHAT_REPORT, chatReportR14932, MOCK_REPORT_ACTIONS, mockTransactions, undefined, false, reportErrors, false) ?? + {}; expect(reportAction).toMatchObject(MOCK_REPORT_ACTIONS['3']); }); }); @@ -1458,6 +1469,7 @@ describe('DebugUtils', () => { undefined, false, reportErrors, + false, ) ?? {}; expect(reportAction).toMatchObject(MOCK_REPORT_ACTIONS['1']); }); @@ -1486,7 +1498,8 @@ describe('DebugUtils', () => { const reportErrors = getAllReportErrors(mockedReport, mockedReportActions, sharedAllTransactions); const {reason} = - DebugUtils.getReasonAndReportActionForRBRInLHNRow(mockedReport, chatReportR14932, mockedReportActions, sharedAllTransactions, undefined, false, reportErrors) ?? {}; + DebugUtils.getReasonAndReportActionForRBRInLHNRow(mockedReport, chatReportR14932, mockedReportActions, sharedAllTransactions, undefined, false, reportErrors, false) ?? + {}; expect(reason).toBe('debug.reasonRBR.hasErrors'); }); it('returns correct reason when there are violations', () => { @@ -1501,6 +1514,7 @@ describe('DebugUtils', () => { undefined, true, {}, + false, ) ?? {}; expect(reason).toBe('debug.reasonRBR.hasViolations'); }); @@ -1516,6 +1530,7 @@ describe('DebugUtils', () => { undefined, true, {}, + false, true, ) ?? {}; expect(reason).toBe(undefined); @@ -1572,9 +1587,92 @@ describe('DebugUtils', () => { reportID: '1', } as Transaction, }; - const {reason} = DebugUtils.getReasonAndReportActionForRBRInLHNRow(report, chatReportR14932, {}, violationTransactions, transactionViolations, false, {}) ?? {}; + const {reason} = DebugUtils.getReasonAndReportActionForRBRInLHNRow(report, chatReportR14932, {}, violationTransactions, transactionViolations, false, {}, false) ?? {}; expect(reason).toBe('debug.reasonRBR.hasTransactionThreadViolations'); }); + it('forwards isOffline through to SidebarUtils so the live IOU transaction-thread receipt error surfaces only when isOffline=false excludes the deleted pending-delete action', () => { + // Given: an expense report with two IOU actions — one live (with a receipt-errored transaction) and one deleted-with-pending-delete. + const OFFLINE_EXPENSE_REPORT: Report = { + reportID: '1', + type: CONST.REPORT.TYPE.EXPENSE, + chatReportID: '2', + }; + const OFFLINE_CHAT_REPORT: Report = { + reportID: '2', + }; + const liveTransactionID = 'tx-debug-live'; + const deletedTransactionID = 'tx-debug-deleted'; + const OFFLINE_REPORT_ACTIONS: OnyxEntry = { + // eslint-disable-next-line @typescript-eslint/naming-convention + '1': { + reportActionID: '1', + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + actorAccountID: 12345, + created: '2024-08-08 18:20:44.171', + message: [{type: 'TEXT', text: 'live'}], + originalMessage: { + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + IOUTransactionID: liveTransactionID, + amount: 10, + currency: CONST.CURRENCY.USD, + }, + } as ReportAction, + // eslint-disable-next-line @typescript-eslint/naming-convention + '2': { + reportActionID: '2', + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + actorAccountID: 12345, + created: '2024-08-08 18:25:44.171', + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + message: [{type: 'TEXT', text: '', html: ''}], + originalMessage: { + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + IOUTransactionID: deletedTransactionID, + amount: 20, + currency: CONST.CURRENCY.USD, + }, + } as ReportAction, + }; + const OFFLINE_TRANSACTIONS: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${liveTransactionID}`]: { + transactionID: liveTransactionID, + amount: 10, + errors: { + someErrorKey: {error: CONST.IOU.RECEIPT_ERROR}, + }, + } as unknown as Transaction, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${deletedTransactionID}`]: { + transactionID: deletedTransactionID, + amount: 20, + } as unknown as Transaction, + }; + + const offline = DebugUtils.getReasonAndReportActionForRBRInLHNRow( + OFFLINE_EXPENSE_REPORT, + OFFLINE_CHAT_REPORT, + OFFLINE_REPORT_ACTIONS, + OFFLINE_TRANSACTIONS, + {}, + false, + {}, + true, + ); + const online = DebugUtils.getReasonAndReportActionForRBRInLHNRow( + OFFLINE_EXPENSE_REPORT, + OFFLINE_CHAT_REPORT, + OFFLINE_REPORT_ACTIONS, + OFFLINE_TRANSACTIONS, + {}, + false, + {}, + false, + ); + + // Online: deleted pending-delete is skipped → 1 IOU thread → receipt error surfaces. + expect(online?.reason).toBe('debug.reasonRBR.hasErrors'); + // Offline: deleted pending-delete is counted → 2 IOU actions → no single thread → no receipt error via that path. + expect(offline).toBeNull(); + }); }); }); }); diff --git a/tests/unit/OnyxDerivedTest.tsx b/tests/unit/OnyxDerivedTest.tsx index 142bad735f98..236bafe3b329 100644 --- a/tests/unit/OnyxDerivedTest.tsx +++ b/tests/unit/OnyxDerivedTest.tsx @@ -126,9 +126,9 @@ describe('OnyxDerived', () => { const transaction = createRandomTransaction(1); // When the report attributes are recomputed with both report and transaction updates - reportAttributes.compute([reports, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined], {}); + reportAttributes.compute([reports, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined], {}); const reportAttributesComputedValue = reportAttributes.compute( - [reports, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined], + [reports, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined], { sourceValues: { [ONYXKEYS.COLLECTION.REPORT]: { diff --git a/tests/unit/SidebarUtilsTest.ts b/tests/unit/SidebarUtilsTest.ts index 31acccfe67cf..f45e30ab1b9f 100644 --- a/tests/unit/SidebarUtilsTest.ts +++ b/tests/unit/SidebarUtilsTest.ts @@ -127,6 +127,7 @@ describe('SidebarUtils', () => { false, {}, MOCK_TRANSACTIONS, + false, MOCK_TRANSACTION_VIOLATIONS as OnyxCollection, isReportArchived.current, ) ?? {}; @@ -157,6 +158,7 @@ describe('SidebarUtils', () => { false, reportErrors, MOCK_TRANSACTIONS, + false, MOCK_TRANSACTION_VIOLATIONS, isReportArchived.current, ) ?? {}; @@ -182,6 +184,7 @@ describe('SidebarUtils', () => { true, {}, MOCK_TRANSACTIONS, + false, MOCK_TRANSACTION_VIOLATIONS, isReportArchived.current, ) ?? {}; @@ -225,6 +228,7 @@ describe('SidebarUtils', () => { false, reportErrors, MOCK_TRANSACTIONS, + false, MOCK_TRANSACTION_VIOLATIONS, isReportArchived.current, ) ?? {}; @@ -255,6 +259,7 @@ describe('SidebarUtils', () => { false, reportErrors, MOCK_TRANSACTIONS, + false, MOCK_TRANSACTION_VIOLATIONS, isReportArchived.current, ) ?? {}; @@ -298,6 +303,7 @@ describe('SidebarUtils', () => { false, reportErrors, MOCK_TRANSACTIONS, + false, MOCK_TRANSACTION_VIOLATIONS, isReportArchived.current, ) ?? {}; @@ -322,6 +328,7 @@ describe('SidebarUtils', () => { false, {}, MOCK_TRANSACTIONS, + false, MOCK_TRANSACTION_VIOLATIONS, isReportArchived.current, ); @@ -434,6 +441,7 @@ describe('SidebarUtils', () => { false, {}, MOCK_TRANSACTIONS, + false, MOCK_TRANSACTION_VIOLATIONS, isReportArchived.current, ); @@ -521,6 +529,7 @@ describe('SidebarUtils', () => { false, reportErrors, MOCK_TRANSACTIONS, + false, MOCK_TRANSACTION_VIOLATIONS, isReportArchived.current, ); @@ -599,6 +608,7 @@ describe('SidebarUtils', () => { false, {}, MOCK_TRANSACTIONS, + false, MOCK_TRANSACTION_VIOLATIONS as OnyxCollection, isReportArchived.current, ); @@ -687,12 +697,141 @@ describe('SidebarUtils', () => { true, {}, {[transactionKey]: transaction}, + false, transactionViolations, false, ) ?? {}; expect(reason).toBe(CONST.RBR_REASONS.HAS_TRANSACTION_THREAD_VIOLATIONS); }); + + it('forwards isOffline: online treats deleted pending-delete IOU as skipped, so the live IOU is the single transaction thread and its receipt error surfaces', () => { + // Given: an expense report with TWO IOU actions — one live (with a receipt-errored transaction) and one already-deleted with pendingAction=delete. + const MOCK_REPORT: Report = { + reportID: '1', + type: CONST.REPORT.TYPE.EXPENSE, + chatReportID: '2', + }; + const MOCK_CHAT_REPORT: Report = { + reportID: '2', + }; + const liveTransactionID = 'tx-live'; + const deletedTransactionID = 'tx-deleted'; + const MOCK_REPORT_ACTIONS: OnyxEntry = { + // eslint-disable-next-line @typescript-eslint/naming-convention + '1': { + reportActionID: '1', + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + actorAccountID: 12345, + created: '2024-08-08 18:20:44.171', + message: [{type: 'TEXT', text: 'live'}], + originalMessage: { + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + IOUTransactionID: liveTransactionID, + amount: 10, + currency: CONST.CURRENCY.USD, + }, + } as ReportAction, + // eslint-disable-next-line @typescript-eslint/naming-convention + '2': { + reportActionID: '2', + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + actorAccountID: 12345, + created: '2024-08-08 18:25:44.171', + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + // Empty html marks this as a legacy-deleted comment. + message: [{type: 'TEXT', text: '', html: ''}], + originalMessage: { + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + IOUTransactionID: deletedTransactionID, + amount: 20, + currency: CONST.CURRENCY.USD, + }, + } as ReportAction, + }; + const MOCK_TRANSACTIONS: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${liveTransactionID}`]: { + transactionID: liveTransactionID, + amount: 10, + errors: { + someErrorKey: {error: CONST.IOU.RECEIPT_ERROR}, + }, + } as unknown as Transaction, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${deletedTransactionID}`]: { + transactionID: deletedTransactionID, + amount: 20, + } as unknown as Transaction, + }; + + // When: called with isOffline=false — the pending-delete action is skipped, leaving the live one as the single thread. + const onlineResult = SidebarUtils.getReasonAndReportActionThatHasRedBrickRoad(MOCK_REPORT, MOCK_CHAT_REPORT, MOCK_REPORT_ACTIONS, false, {}, MOCK_TRANSACTIONS, false, {}, false); + + expect(onlineResult?.reason).toBe(CONST.RBR_REASONS.HAS_ERRORS); + }); + + it('forwards isOffline: offline treats deleted pending-delete IOU as still counted, so there are 2 IOU actions and no single transaction thread is identified', () => { + // Given: same report setup as the previous test. + const MOCK_REPORT: Report = { + reportID: '1', + type: CONST.REPORT.TYPE.EXPENSE, + chatReportID: '2', + }; + const MOCK_CHAT_REPORT: Report = { + reportID: '2', + }; + const liveTransactionID = 'tx-live'; + const deletedTransactionID = 'tx-deleted'; + const MOCK_REPORT_ACTIONS: OnyxEntry = { + // eslint-disable-next-line @typescript-eslint/naming-convention + '1': { + reportActionID: '1', + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + actorAccountID: 12345, + created: '2024-08-08 18:20:44.171', + message: [{type: 'TEXT', text: 'live'}], + originalMessage: { + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + IOUTransactionID: liveTransactionID, + amount: 10, + currency: CONST.CURRENCY.USD, + }, + } as ReportAction, + // eslint-disable-next-line @typescript-eslint/naming-convention + '2': { + reportActionID: '2', + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + actorAccountID: 12345, + created: '2024-08-08 18:25:44.171', + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + message: [{type: 'TEXT', text: '', html: ''}], + originalMessage: { + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + IOUTransactionID: deletedTransactionID, + amount: 20, + currency: CONST.CURRENCY.USD, + }, + } as ReportAction, + }; + const MOCK_TRANSACTIONS: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${liveTransactionID}`]: { + transactionID: liveTransactionID, + amount: 10, + errors: { + someErrorKey: {error: CONST.IOU.RECEIPT_ERROR}, + }, + } as unknown as Transaction, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${deletedTransactionID}`]: { + transactionID: deletedTransactionID, + amount: 20, + } as unknown as Transaction, + }; + + // When: called with isOffline=true — the pending-delete action is included, making 2 IOU actions. + const offlineResult = SidebarUtils.getReasonAndReportActionThatHasRedBrickRoad(MOCK_REPORT, MOCK_CHAT_REPORT, MOCK_REPORT_ACTIONS, false, {}, MOCK_TRANSACTIONS, true, {}, false); + + // Then: no single transaction thread is identified, so the receipt error is not surfaced via that path. + expect(offlineResult).toBeNull(); + }); }); describe('shouldDisplayReportInLHN', () => {