diff --git a/src/libs/actions/IOU/BulkEdit.ts b/src/libs/actions/IOU/BulkEdit.ts index 102d8be1eeab..7932d6c0e6aa 100644 --- a/src/libs/actions/IOU/BulkEdit.ts +++ b/src/libs/actions/IOU/BulkEdit.ts @@ -12,8 +12,10 @@ import type {TransactionDetails} from '@libs/ReportUtils'; import { buildOptimisticCreatedReportAction, buildOptimisticModifiedExpenseReportAction, + buildTransactionThread, canEditFieldOfMoneyRequest, findSelfDMReportID, + generateReportID, getOutstandingChildRequest, getParsedComment, getTransactionDetails, @@ -24,7 +26,6 @@ import { } from '@libs/ReportUtils'; import {calculateTaxAmount, getAmount, getClearedPendingFields, getCurrency, getTaxValue, getUpdatedTransaction, isOnHold, isSplitChildTransaction} from '@libs/TransactionUtils'; import ViolationsUtils from '@libs/Violations/ViolationsUtils'; -import {createTransactionThreadReport} from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; @@ -79,9 +80,6 @@ type UpdateMultipleMoneyRequestsParams = { policyTags: OnyxCollection; hash?: number; allPolicies?: OnyxCollection; - introSelected: OnyxEntry; - betas: OnyxEntry; - currentUserLogin: string; currentUserAccountID: number; delegateAccountID: number | undefined; }; @@ -97,10 +95,7 @@ function updateMultipleMoneyRequests({ policyTags, hash, allPolicies, - introSelected, - betas, currentUserAccountID, - currentUserLogin, delegateAccountID, }: UpdateMultipleMoneyRequestsParams) { // Track running totals per report so multiple edits in the same report compound correctly. @@ -132,23 +127,20 @@ function updateMultipleMoneyRequests({ } } - let transactionThreadReportID = transaction.transactionThreadReportID ?? reportAction?.childReportID; + let transactionThreadReportID = reportAction?.childReportID ?? transaction.transactionThreadReportID; let transactionThread = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`] ?? null; // Offline-created expenses can be missing a transaction thread until it's opened once. - // Ensure the thread exists before adding optimistic MODIFIED_EXPENSE actions so + // Ensure the thread exists locally before adding optimistic MODIFIED_EXPENSE actions so // bulk-edit comments are visible immediately while still offline. + // We intentionally avoid calling createTransactionThreadReport here because it fires + // an OpenReport API command per expense, which floods the queue and hangs the UI on + // large reports (40-50+ expenses). The backend already creates the transaction thread + // when processing UpdateMoneyRequest, so we only need local Onyx state. let didCreateThreadInThisIteration = false; if (!transactionThreadReportID && iouReport?.reportID) { - const optimisticTransactionThread = createTransactionThreadReport({ - introSelected, - currentUserLogin, - currentUserAccountID, - betas, - iouReport, - iouReportAction: reportAction, - transaction, - }); + const optimisticTransactionThreadReportID = generateReportID(); + const optimisticTransactionThread = buildTransactionThread(reportAction, iouReport, currentUserAccountID, undefined, optimisticTransactionThreadReportID); if (optimisticTransactionThread?.reportID) { transactionThreadReportID = optimisticTransactionThread.reportID; transactionThread = optimisticTransactionThread; @@ -296,6 +288,38 @@ function updateMultipleMoneyRequests({ const snapshotSuccessData: Array> = []; const snapshotFailureData: Array> = []; + // If we created the transaction thread optimistically above, seed it into Onyx + // so the MODIFIED_EXPENSE action has somewhere to land. On success the server's + // OpenReport data (triggered by UpdateMoneyRequest) will overwrite these values. + // Also link the thread back: set childReportID on the parent IOU action and + // transactionThreadReportID on the transaction so subsequent offline edits of the + // same expense reuse this thread instead of generating a new one each time. + if (didCreateThreadInThisIteration && transactionThread && transactionThreadReportID) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, + value: transactionThread, + }); + // Link childReportID on the parent IOU report action + if (reportAction?.reportActionID && iouReport?.reportID) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`, + value: {[reportAction.reportActionID]: {childReportID: transactionThreadReportID}}, + }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`, + value: {[reportAction.reportActionID]: {childReportID: null}}, + }); + } + failureData.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, + value: null, + }); + } + // Pending fields for the transaction const pendingFields: OnyxTypes.Transaction['pendingFields'] = Object.fromEntries(Object.keys(transactionChanges).map((field) => [field, CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE])); const clearedPendingFields = getClearedPendingFields(transactionChanges); @@ -357,6 +381,9 @@ function updateMultipleMoneyRequests({ pendingFields, isLoading: false, errorFields: null, + // Link the optimistic thread back to the transaction so subsequent + // offline edits reuse it instead of generating a new thread each time. + ...(didCreateThreadInThisIteration && transactionThreadReportID ? {transactionThreadReportID} : {}), }, }); @@ -460,9 +487,8 @@ function updateMultipleMoneyRequests({ if (transactionThreadReportID) { // Backfill a CREATED action for threads never opened locally so // MoneyRequestView renders and the skeleton doesn't loop offline. - // Skip when the thread was just created above (openReport handles it). const threadReportActions = reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`] ?? {}; - const hasCreatedAction = didCreateThreadInThisIteration || Object.values(threadReportActions).some((action) => action?.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED); + const hasCreatedAction = Object.values(threadReportActions).some((action) => action?.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED); const optimisticCreatedValue: Record> = {}; if (!hasCreatedAction) { const optimisticCreatedAction = buildOptimisticCreatedReportAction({emailCreatingAction: CONST.REPORT.OWNER_EMAIL_FAKE}); @@ -528,6 +554,9 @@ function updateMultipleMoneyRequests({ ...transaction, pendingFields: clearedPendingFields, errorFields, + // Clear the optimistically added transactionThreadReportID so it doesn't + // persist after a failed request — the server never created this thread. + ...(didCreateThreadInThisIteration && transactionThreadReportID ? {transactionThreadReportID: null} : {}), }, }); diff --git a/src/pages/Search/SearchEditMultiple/SearchEditMultiplePage.tsx b/src/pages/Search/SearchEditMultiple/SearchEditMultiplePage.tsx index 217c5bdf6f99..92830da258eb 100644 --- a/src/pages/Search/SearchEditMultiple/SearchEditMultiplePage.tsx +++ b/src/pages/Search/SearchEditMultiple/SearchEditMultiplePage.tsx @@ -1,4 +1,4 @@ -import React, {useEffect} from 'react'; +import React, {useEffect, useState} from 'react'; import {View} from 'react-native'; import type {ValueOf} from 'type-fest'; import Button from '@components/Button'; @@ -44,7 +44,7 @@ function SearchEditMultiplePage() { const {currentSearchHash} = useSearchQueryContext(); const {currentSearchResults} = useSearchResultsContext(); const {clearSelectedTransactions} = useSearchSelectionActions(); - const {login: currentUserLogin, accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); + const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const delegateAccountID = useDelegateAccountID(); const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); @@ -54,8 +54,6 @@ function SearchEditMultiplePage() { const [allReportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS); const [allPolicyTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS); const [allPolicyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES); - const [betas] = useOnyx(ONYXKEYS.BETAS); - const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const snapshotData = currentSearchResults?.data; const mergedTransactions = withSnapshotTransactions(allTransactions, snapshotData); @@ -127,8 +125,10 @@ function SearchEditMultiplePage() { }; }, []); + const [isSaving, setIsSaving] = useState(false); + const save = () => { - if (!draftTransaction) { + if (!draftTransaction || isSaving) { return; } @@ -166,29 +166,32 @@ function SearchEditMultiplePage() { return; } - updateMultipleMoneyRequests({ - transactionIDs: selectedTransactionIDs, - changes, - policy, - reports: mergedReports, - transactions: mergedTransactions, - reportActions: mergedReportActions, - policyCategories: allPolicyCategories, - policyTags: allPolicyTags, - hash: currentSearchHash, - allPolicies: policies, - introSelected, - betas, - currentUserAccountID, - currentUserLogin: currentUserLogin ?? '', - delegateAccountID, - }); - // Bulk edit can start from report (ID-based selection) or search (map-based selection), - // so clear both stores to keep deselection behavior consistent. - clearSelectedTransactions(true); - clearSelectedTransactions(); + setIsSaving(true); + + // Defer the bulk edit loop so the loading spinner has a chance to paint + // before the synchronous Onyx writes block the JS thread. + requestAnimationFrame(() => { + updateMultipleMoneyRequests({ + transactionIDs: selectedTransactionIDs, + changes, + policy, + reports: mergedReports, + transactions: mergedTransactions, + reportActions: mergedReportActions, + policyCategories: allPolicyCategories, + policyTags: allPolicyTags, + hash: currentSearchHash, + allPolicies: policies, + currentUserAccountID, + delegateAccountID, + }); + // Bulk edit can start from report (ID-based selection) or search (map-based selection), + // so clear both stores to keep deselection behavior consistent. + clearSelectedTransactions(true); + clearSelectedTransactions(); - Navigation.dismissToPreviousRHP(); + Navigation.dismissToPreviousRHP(); + }); }; const currency = policy?.outputCurrency ?? CONST.CURRENCY.USD; @@ -324,6 +327,8 @@ function SearchEditMultiplePage() { large text={translate('common.save')} onPress={save} + isLoading={isSaving} + isDisabled={isSaving} style={[styles.m5]} /> diff --git a/tests/actions/IOUTest/BulkEditTest.ts b/tests/actions/IOUTest/BulkEditTest.ts index a0489c0b4695..a62d05df4c55 100644 --- a/tests/actions/IOUTest/BulkEditTest.ts +++ b/tests/actions/IOUTest/BulkEditTest.ts @@ -1,10 +1,10 @@ import Onyx from 'react-native-onyx'; -import type {OnyxKey} from 'react-native-onyx'; +import type {OnyxCollection, OnyxKey} from 'react-native-onyx'; import {clearBulkEditDraftTransaction, initBulkEditDraftTransaction, updateBulkEditDraftTransaction, updateMultipleMoneyRequests} from '@libs/actions/IOU/BulkEdit'; import CONST from '@src/CONST'; import * as API from '@src/libs/API'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Policy, Report} from '@src/types/onyx'; +import type {Policy, Report, ReportActions} from '@src/types/onyx'; import type Transaction from '@src/types/onyx/Transaction'; import createRandomPolicy, {createCategoryTaxExpenseRules} from '../../utils/collections/policies'; import {createRandomReport} from '../../utils/collections/reports'; @@ -12,7 +12,6 @@ import createRandomTransaction from '../../utils/collections/transaction'; import getOnyxValue from '../../utils/getOnyxValue'; import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; -const RORY_EMAIL = 'rory@expensifail.com'; const RORY_ACCOUNT_ID = 3; describe('actions/IOU/BulkEdit', () => { @@ -68,9 +67,6 @@ describe('actions/IOU/BulkEdit', () => { policyCategories: undefined, policyTags: {}, hash: undefined, - introSelected: undefined, - betas: undefined, - currentUserLogin: RORY_EMAIL, currentUserAccountID: RORY_ACCOUNT_ID, delegateAccountID: undefined, }); @@ -143,9 +139,6 @@ describe('actions/IOU/BulkEdit', () => { policyCategories: undefined, policyTags: {}, hash: undefined, - introSelected: undefined, - betas: undefined, - currentUserLogin: RORY_EMAIL, currentUserAccountID: RORY_ACCOUNT_ID, delegateAccountID: undefined, }); @@ -206,9 +199,6 @@ describe('actions/IOU/BulkEdit', () => { policyCategories: undefined, policyTags: {}, hash: undefined, - introSelected: undefined, - betas: undefined, - currentUserLogin: RORY_EMAIL, currentUserAccountID: RORY_ACCOUNT_ID, delegateAccountID: undefined, }); @@ -278,9 +268,6 @@ describe('actions/IOU/BulkEdit', () => { policyCategories: undefined, policyTags: {}, hash: undefined, - introSelected: undefined, - betas: undefined, - currentUserLogin: RORY_EMAIL, currentUserAccountID: RORY_ACCOUNT_ID, delegateAccountID: undefined, }); @@ -355,9 +342,6 @@ describe('actions/IOU/BulkEdit', () => { policyCategories: undefined, policyTags: {}, hash: undefined, - introSelected: undefined, - betas: undefined, - currentUserLogin: RORY_EMAIL, currentUserAccountID: RORY_ACCOUNT_ID, delegateAccountID: undefined, }); @@ -422,9 +406,6 @@ describe('actions/IOU/BulkEdit', () => { policyCategories: undefined, policyTags: {}, hash: undefined, - introSelected: undefined, - betas: undefined, - currentUserLogin: RORY_EMAIL, currentUserAccountID: RORY_ACCOUNT_ID, delegateAccountID: undefined, }); @@ -488,9 +469,6 @@ describe('actions/IOU/BulkEdit', () => { policyCategories: undefined, policyTags: {}, hash: undefined, - introSelected: undefined, - betas: undefined, - currentUserLogin: RORY_EMAIL, currentUserAccountID: RORY_ACCOUNT_ID, delegateAccountID: undefined, }); @@ -560,9 +538,6 @@ describe('actions/IOU/BulkEdit', () => { }, }, hash: undefined, - introSelected: undefined, - betas: undefined, - currentUserLogin: RORY_EMAIL, currentUserAccountID: RORY_ACCOUNT_ID, delegateAccountID: undefined, }); @@ -617,9 +592,6 @@ describe('actions/IOU/BulkEdit', () => { policyCategories: undefined, policyTags: {}, hash: undefined, - introSelected: undefined, - betas: undefined, - currentUserLogin: RORY_EMAIL, currentUserAccountID: RORY_ACCOUNT_ID, delegateAccountID: undefined, }); @@ -677,9 +649,6 @@ describe('actions/IOU/BulkEdit', () => { policyCategories: undefined, policyTags: {}, hash: undefined, - introSelected: undefined, - betas: undefined, - currentUserLogin: RORY_EMAIL, currentUserAccountID: RORY_ACCOUNT_ID, delegateAccountID: undefined, }); @@ -744,9 +713,6 @@ describe('actions/IOU/BulkEdit', () => { }, policyTags: {}, hash: undefined, - introSelected: undefined, - betas: undefined, - currentUserLogin: RORY_EMAIL, currentUserAccountID: RORY_ACCOUNT_ID, delegateAccountID: undefined, }); @@ -812,9 +778,6 @@ describe('actions/IOU/BulkEdit', () => { allPolicies: { [`${ONYXKEYS.COLLECTION.POLICY}${transactionPolicyID}`]: transactionPolicy, }, - introSelected: undefined, - betas: undefined, - currentUserLogin: RORY_EMAIL, currentUserAccountID: RORY_ACCOUNT_ID, delegateAccountID: undefined, }); @@ -891,9 +854,6 @@ describe('actions/IOU/BulkEdit', () => { allPolicies: { [`${ONYXKEYS.COLLECTION.POLICY}${transactionPolicyID}`]: txPolicy, }, - introSelected: undefined, - betas: undefined, - currentUserLogin: RORY_EMAIL, currentUserAccountID: RORY_ACCOUNT_ID, delegateAccountID: undefined, }); @@ -958,9 +918,6 @@ describe('actions/IOU/BulkEdit', () => { }, policyTags: {}, hash: undefined, - introSelected: undefined, - betas: undefined, - currentUserLogin: RORY_EMAIL, currentUserAccountID: RORY_ACCOUNT_ID, delegateAccountID: undefined, }); @@ -1037,9 +994,6 @@ describe('actions/IOU/BulkEdit', () => { [`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policy.id}`]: policyTagsForPolicy, }, hash: undefined, - introSelected: undefined, - betas: undefined, - currentUserLogin: RORY_EMAIL, currentUserAccountID: RORY_ACCOUNT_ID, delegateAccountID: undefined, }); @@ -1097,9 +1051,6 @@ describe('actions/IOU/BulkEdit', () => { policyCategories: undefined, policyTags: {}, hash: undefined, - introSelected: undefined, - betas: undefined, - currentUserLogin: RORY_EMAIL, currentUserAccountID: RORY_ACCOUNT_ID, delegateAccountID: undefined, }); @@ -1164,9 +1115,6 @@ describe('actions/IOU/BulkEdit', () => { policyCategories: undefined, policyTags: {}, hash: undefined, - introSelected: undefined, - betas: undefined, - currentUserLogin: 'test@example.com', currentUserAccountID: 1, delegateAccountID: undefined, }); @@ -1255,9 +1203,6 @@ describe('actions/IOU/BulkEdit', () => { policyTags: {}, hash: undefined, allPolicies, - introSelected: undefined, - betas: undefined, - currentUserLogin: RORY_EMAIL, currentUserAccountID: RORY_ACCOUNT_ID, delegateAccountID: undefined, }); @@ -1348,9 +1293,6 @@ describe('actions/IOU/BulkEdit', () => { policyTags: {}, hash: undefined, allPolicies, - introSelected: undefined, - betas: undefined, - currentUserLogin: RORY_EMAIL, currentUserAccountID: RORY_ACCOUNT_ID, delegateAccountID: undefined, }); @@ -1420,9 +1362,6 @@ describe('actions/IOU/BulkEdit', () => { policyCategories: undefined, policyTags: {}, hash: undefined, - introSelected: undefined, - betas: undefined, - currentUserLogin: RORY_EMAIL, currentUserAccountID: RORY_ACCOUNT_ID, delegateAccountID: undefined, }); @@ -1490,9 +1429,6 @@ describe('actions/IOU/BulkEdit', () => { policyCategories: undefined, policyTags: {}, hash: undefined, - introSelected: undefined, - betas: undefined, - currentUserLogin: RORY_EMAIL, currentUserAccountID: RORY_ACCOUNT_ID, delegateAccountID: undefined, }); @@ -1556,9 +1492,6 @@ describe('actions/IOU/BulkEdit', () => { policyCategories: undefined, policyTags: {}, hash: undefined, - introSelected: undefined, - betas: undefined, - currentUserLogin: RORY_EMAIL, currentUserAccountID: RORY_ACCOUNT_ID, delegateAccountID: undefined, }); @@ -1572,6 +1505,178 @@ describe('actions/IOU/BulkEdit', () => { writeSpy.mockRestore(); canEditFieldSpy.mockRestore(); }); + + it('creates an optimistic transaction thread when neither childReportID nor transactionThreadReportID exists', () => { + const transactionID = 'transaction-no-thread'; + const iouReportID = 'iou-no-thread'; + const policy = createRandomPolicy(20, CONST.POLICY.TYPE.TEAM); + + const iouReport: Report = { + ...createRandomReport(20, undefined), + reportID: iouReportID, + policyID: policy.id, + type: CONST.REPORT.TYPE.EXPENSE, + }; + + const reports = { + [`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`]: iouReport, + }; + + // Transaction has no transactionThreadReportID + const transaction: Transaction = { + ...createRandomTransaction(20), + transactionID, + reportID: iouReportID, + amount: 500, + currency: CONST.CURRENCY.USD, + }; + delete (transaction as Partial).transactionThreadReportID; + const transactions = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: transaction, + }; + + const canEditFieldSpy = jest.spyOn(require('@libs/ReportUtils'), 'canEditFieldOfMoneyRequest').mockReturnValue(true); + // eslint-disable-next-line rulesdir/no-multiple-api-calls + const writeSpy = jest.spyOn(API, 'write').mockImplementation(jest.fn()); + + updateMultipleMoneyRequests({ + transactionIDs: [transactionID], + changes: {merchant: 'Coffee Shop'}, + policy, + reports, + transactions, + reportActions: {}, + policyCategories: undefined, + policyTags: {}, + hash: undefined, + currentUserAccountID: RORY_ACCOUNT_ID, + delegateAccountID: undefined, + }); + + expect(writeSpy).toHaveBeenCalled(); + /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access */ + const onyxData = writeSpy.mock.calls.at(0)?.[2] as any; + const optimisticData = onyxData?.optimisticData as any[]; + + // An optimistic thread report should be created via SET + const optimisticReportSet = optimisticData.find( + (entry: any) => String(entry.key).startsWith(ONYXKEYS.COLLECTION.REPORT) && entry.onyxMethod === 'set' && entry.key !== `${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`, + ); + expect(optimisticReportSet).toBeDefined(); + const optimisticThreadReportID = String(optimisticReportSet.key).replace(ONYXKEYS.COLLECTION.REPORT, ''); + + // The transaction optimistic data should link back to the new thread via transactionThreadReportID + const transactionMerge = optimisticData.find((entry: any) => entry.key === `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); + expect(transactionMerge?.value?.transactionThreadReportID).toBe(optimisticThreadReportID); + /* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access */ + + writeSpy.mockRestore(); + canEditFieldSpy.mockRestore(); + }); + + it('prioritizes childReportID over transactionThreadReportID when resolving thread', () => { + const transactionID = 'transaction-priority'; + const childReportID = 'thread-from-action'; + const transactionThreadID = 'thread-from-transaction'; + const iouReportID = 'iou-priority'; + const reportActionID = 'action-priority'; + const policy = createRandomPolicy(21, CONST.POLICY.TYPE.TEAM); + + const childThread: Report = { + ...createRandomReport(21, undefined), + reportID: childReportID, + parentReportID: iouReportID, + policyID: policy.id, + }; + const transactionThread: Report = { + ...createRandomReport(22, undefined), + reportID: transactionThreadID, + parentReportID: iouReportID, + policyID: policy.id, + }; + const iouReport: Report = { + ...createRandomReport(23, undefined), + reportID: iouReportID, + policyID: policy.id, + type: CONST.REPORT.TYPE.EXPENSE, + }; + + const reports = { + [`${ONYXKEYS.COLLECTION.REPORT}${childReportID}`]: childThread, + [`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadID}`]: transactionThread, + [`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`]: iouReport, + }; + + // Transaction has transactionThreadReportID pointing to one thread + const transaction: Transaction = { + ...createRandomTransaction(21), + transactionID, + reportID: iouReportID, + transactionThreadReportID: transactionThreadID, + amount: 500, + currency: CONST.CURRENCY.USD, + }; + const transactions = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: transaction, + }; + + // Report action has childReportID pointing to a different thread + const reportActions = { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`]: { + [reportActionID]: { + reportActionID, + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + originalMessage: { + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + IOUTransactionID: transactionID, + }, + childReportID, + created: '2026-01-01 00:00:00', + }, + }, + } as OnyxCollection; + + const canEditFieldSpy = jest.spyOn(require('@libs/ReportUtils'), 'canEditFieldOfMoneyRequest').mockReturnValue(true); + // eslint-disable-next-line rulesdir/no-multiple-api-calls + const writeSpy = jest.spyOn(API, 'write').mockImplementation(jest.fn()); + + updateMultipleMoneyRequests({ + transactionIDs: [transactionID], + changes: {merchant: 'Priority Test'}, + policy, + reports, + transactions, + reportActions, + policyCategories: undefined, + policyTags: {}, + hash: undefined, + currentUserAccountID: RORY_ACCOUNT_ID, + delegateAccountID: undefined, + }); + + expect(writeSpy).toHaveBeenCalled(); + /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access */ + const onyxData = writeSpy.mock.calls.at(0)?.[2] as any; + const optimisticData = onyxData?.optimisticData as any[]; + + // No optimistic thread report should be created — the existing thread from childReportID should be used + const optimisticReportSet = optimisticData.find( + (entry: any) => String(entry.key).startsWith(ONYXKEYS.COLLECTION.REPORT) && entry.onyxMethod === 'set' && entry.key !== `${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`, + ); + expect(optimisticReportSet).toBeUndefined(); + + // The MODIFIED_EXPENSE report action should be written to the childReportID thread, not the transactionThreadReportID thread + const reportActionMerge = optimisticData.find((entry: any) => entry.key === `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${childReportID}`); + expect(reportActionMerge).toBeDefined(); + + // No report action should be written to the transactionThreadReportID thread + const wrongThreadReportAction = optimisticData.find((entry: any) => entry.key === `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadID}`); + expect(wrongThreadReportAction).toBeUndefined(); + /* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access */ + + writeSpy.mockRestore(); + canEditFieldSpy.mockRestore(); + }); }); describe('bulk edit draft transaction', () => {