diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 180b0c4ed8fcd..439fe4bfc1d78 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -916,6 +916,7 @@ const CONST = { EMPTY_ARRAY, EMPTY_OBJECT, DEFAULT_NUMBER_ID, + FAKE_REPORT_ID: 'FAKE_REPORT_ID', USE_EXPENSIFY_URL, EXPENSIFY_URL, EXPENSIFY_MOBILE_URL, diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 7af7c1496b2c0..4967c4496d273 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -593,11 +593,10 @@ const ROUTES = { }, MONEY_REQUEST_HOLD_REASON: { route: ':type/edit/reason/:transactionID?/:searchHash?', - getRoute: (type: ValueOf, transactionID: string, reportID: string, backTo: string, searchHash?: number) => { - const route = searchHash - ? (`${type as string}/edit/reason/${transactionID}/${searchHash}/?backTo=${backTo}&reportID=${reportID}` as const) - : (`${type as string}/edit/reason/${transactionID}/?backTo=${backTo}&reportID=${reportID}` as const); - return route; + getRoute: (type: ValueOf, transactionID: string, reportID: string | undefined, backTo: string, searchHash?: number) => { + const searchPart = searchHash ? `/${searchHash}` : ''; + const reportPart = reportID ? `&reportID=${reportID}` : ''; + return `${type as string}/edit/reason/${transactionID}${searchPart}/?backTo=${backTo}${reportPart}` as const; }, }, MONEY_REQUEST_CREATE: { diff --git a/src/libs/API/parameters/HoldMoneyRequestParams.ts b/src/libs/API/parameters/HoldMoneyRequestParams.ts index 357194d7ae56a..b22e421898d10 100644 --- a/src/libs/API/parameters/HoldMoneyRequestParams.ts +++ b/src/libs/API/parameters/HoldMoneyRequestParams.ts @@ -3,6 +3,8 @@ type HoldMoneyRequestParams = { comment: string; reportActionID: string; commentReportActionID: string; + transactionThreadReportID?: string; + createdReportActionIDForThread?: string; }; export default HoldMoneyRequestParams; diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index d61184750d6c4..d097e2818931d 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -1307,7 +1307,6 @@ function getOneTransactionThreadReportAction( if ( actionType && iouRequestTypesSet.has(actionType) && - action.childReportID && // Include deleted IOU reportActions if: // - they have an associated IOU transaction ID or // - the action is pending deletion and the user is offline @@ -1339,7 +1338,11 @@ function getOneTransactionThreadReportAction( * Returns a reportID if there is exactly one transaction thread for the report, and undefined otherwise. */ function getOneTransactionThreadReportID(...args: Parameters): string | undefined { - return getOneTransactionThreadReportAction(...args)?.childReportID; + const reportAction = getOneTransactionThreadReportAction(...args); + if (reportAction) { + // Since we don't always create transaction thread optimistically, we return CONST.FAKE_REPORT_ID + return reportAction.childReportID ?? CONST.FAKE_REPORT_ID; + } } /** diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 802ed123d6341..2578bcd62757a 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -4437,8 +4437,8 @@ const changeMoneyRequestHoldStatus = (reportAction: OnyxEntry): vo const transactionID = getOriginalMessage(reportAction)?.IOUTransactionID; - if (!transactionID || !reportAction.childReportID) { - Log.warn('Missing transactionID and reportAction.childReportID during the change of the money request hold status'); + if (!transactionID) { + Log.warn('Missing transactionID during the change of the money request hold status'); return; } @@ -4447,7 +4447,11 @@ const changeMoneyRequestHoldStatus = (reportAction: OnyxEntry): vo const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${moneyRequestReport.policyID}`] ?? null; if (isOnHold) { - unholdRequest(transactionID, reportAction.childReportID); + if (reportAction.childReportID) { + unholdRequest(transactionID, reportAction.childReportID); + } else { + Log.warn('Missing reportAction.childReportID during money request unhold'); + } } else { const activeRoute = encodeURIComponent(Navigation.getActiveRoute()); Navigation.navigate(ROUTES.MONEY_REQUEST_HOLD_REASON.getRoute(policy?.type ?? CONST.POLICY.TYPE.PERSONAL, transactionID, reportAction.childReportID, activeRoute)); @@ -7845,6 +7849,7 @@ function buildTransactionThread( reportAction: OnyxEntry, moneyRequestReport: OnyxEntry, existingTransactionThreadReportID?: string, + optimisticTransactionThreadReportID?: string, ): OptimisticChatReport { const participantAccountIDs = [...new Set([currentUserAccountID, Number(reportAction?.actorAccountID)])].filter(Boolean) as number[]; const existingTransactionThreadReport = getReportOrDraftReport(existingTransactionThreadReportID); @@ -7867,6 +7872,7 @@ function buildTransactionThread( notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, parentReportActionID: reportAction?.reportActionID, parentReportID: moneyRequestReport?.reportID, + optimisticReportID: optimisticTransactionThreadReportID, }); } @@ -9717,13 +9723,11 @@ function getAllAncestorReportActionIDs(report: Report | null | undefined, includ /** * Get optimistic data of parent report action - * @param reportID The reportID of the report that is updated + * @param report The report that is updated * @param lastVisibleActionCreated Last visible action created of the child report * @param type The type of action in the child report */ -function getOptimisticDataForParentReportAction(reportID: string | undefined, lastVisibleActionCreated: string, type: string): Array { - const report = getReportOrDraftReport(reportID); - +function getOptimisticDataForParentReportAction(report: Report | undefined, lastVisibleActionCreated: string, type: string): Array { if (!report || isEmptyObject(report)) { return []; } diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 08986b49961c0..3d607e83a9d68 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -18,6 +18,7 @@ import type { CreateWorkspaceParams, DeleteMoneyRequestParams, DetachReceiptParams, + HoldMoneyRequestParams, MergeDuplicatesParams, PayInvoiceParams, PayMoneyRequestParams, @@ -129,9 +130,11 @@ import { buildOptimisticSubmittedReportAction, buildOptimisticUnapprovedReportAction, buildOptimisticUnHoldReportAction, + buildTransactionThread, canBeAutoReimbursed, canUserPerformWriteAction as canUserPerformWriteActionReportUtils, findSelfDMReportID, + generateReportID, getAllHeldTransactions as getAllHeldTransactionsReportUtils, getAllPolicyReports, getApprovalChain, @@ -10992,17 +10995,30 @@ function adjustRemainingSplitShares(transaction: NonNullable notifyNewAction(currentReportID, userAccountID)); @@ -11253,7 +11346,7 @@ function unholdRequest(transactionID: string, reportID: string) { ]; API.write( - 'UnHoldRequest', + WRITE_COMMANDS.UNHOLD_MONEY_REQUEST, { transactionID, reportActionID: createdReportAction.reportActionID, diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index f20705aa5d2ac..4f16733329efe 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -802,7 +802,7 @@ function addActions(reportID: string, text = '', file?: FileObject) { ]; // Update optimistic data for parent report action if the report is a child report - const optimisticParentReportData = getOptimisticDataForParentReportAction(reportID, currentTime, CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + const optimisticParentReportData = getOptimisticDataForParentReportAction(report, currentTime, CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); optimisticParentReportData.forEach((parentReportData) => { if (isEmptyObject(parentReportData)) { return; @@ -1957,8 +1957,9 @@ function deleteReportComment(reportID: string | undefined, reportAction: ReportA // Update optimistic data for parent report action if the report is a child report and the reportAction has no visible child const childVisibleActionCount = reportAction.childVisibleActionCount ?? 0; if (childVisibleActionCount === 0) { + const originalReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${originalReportID}`]; const optimisticParentReportData = getOptimisticDataForParentReportAction( - originalReportID, + originalReport, optimisticReport?.lastVisibleActionCreated ?? '', CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, ); diff --git a/src/libs/actions/Task.ts b/src/libs/actions/Task.ts index 255d91532e66a..ff8f0f3fe71e1 100644 --- a/src/libs/actions/Task.ts +++ b/src/libs/actions/Task.ts @@ -307,7 +307,7 @@ function createTaskAndNavigate( }); // If needed, update optimistic data for parent report action of the parent report. - const optimisticParentReportData = ReportUtils.getOptimisticDataForParentReportAction(parentReportID, currentTime, CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + const optimisticParentReportData = ReportUtils.getOptimisticDataForParentReportAction(parentReport, currentTime, CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); optimisticParentReportData.forEach((parentReportData) => { if (isEmptyObject(parentReportData)) { return; @@ -1145,7 +1145,7 @@ function deleteTask(report: OnyxEntry) { const childVisibleActionCount = parentReportAction?.childVisibleActionCount ?? 0; if (childVisibleActionCount === 0) { const optimisticParentReportData = ReportUtils.getOptimisticDataForParentReportAction( - parentReport?.reportID, + parentReport, parentReport?.lastVisibleActionCreated ?? '', CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, ); diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index 5ecf5098cb9ae..59050e8f7caf7 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -43,6 +43,7 @@ import {getDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; import { getCombinedReportActions, getFilteredReportActionsForReportView, + getIOUActionForReportID, getOneTransactionThreadReportID, isCreatedAction, isDeletedParentAction, @@ -51,11 +52,14 @@ import { shouldReportActionBeVisible, } from '@libs/ReportActionsUtils'; import { + buildTransactionThread, canEditReportAction, canUserPerformWriteAction, findLastAccessedReport, + generateReportID, getParticipantsAccountIDsForDisplay, getReportOfflinePendingActionAndErrors, + getReportTransactions, isChatThread, isConciergeChatReport, isGroupChat, @@ -307,6 +311,7 @@ function ReportScreen({route, navigation}: ReportScreenProps) { const reportTransactionIDs = useMemo(() => visibleTransactions?.map((transaction) => transaction.transactionID), [visibleTransactions]); const transactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, reportActions ?? [], isOffline, reportTransactionIDs); + const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, {canBeMissing: true}); const [transactionThreadReportActions = getEmptyObject()] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`, { canBeMissing: true, }); @@ -469,6 +474,15 @@ function ReportScreen({route, navigation}: ReportScreenProps) { [firstRender, shouldShowNotFoundLinkedAction, reportID, isOptimisticDelete, reportMetadata?.isLoadingInitialReportActions, userLeavingStatus, currentReportIDFormRoute], ); + const createOneTransactionThreadReport = useCallback(() => { + const optimisticTransactionThreadReportID = generateReportID(); + const currentReportTransaction = getReportTransactions(reportID).filter((transaction) => transaction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); + const oneTransactionID = currentReportTransaction.at(0)?.transactionID; + const iouAction = getIOUActionForReportID(reportID, oneTransactionID); + const optimisticTransactionThread = buildTransactionThread(iouAction, report, undefined, optimisticTransactionThreadReportID); + openReport(optimisticTransactionThreadReportID, undefined, currentUserEmail ? [currentUserEmail] : [], optimisticTransactionThread, iouAction?.reportActionID); + }, [currentUserEmail, report, reportID]); + const fetchReport = useCallback(() => { if (reportMetadata.isOptimisticReport && report?.type === CONST.REPORT.TYPE.CHAT && !isPolicyExpenseChat(report)) { return; @@ -487,6 +501,13 @@ function ReportScreen({route, navigation}: ReportScreenProps) { openReport(reportIDFromRoute, '', [currentUserEmail], undefined, moneyRequestReportActionID, false, [], undefined, transactionID); return; } + + // If there is one transaction thread that has not yet been created, we should create it. + if (transactionThreadReportID === CONST.FAKE_REPORT_ID && !transactionThreadReport && parentReportAction?.childMoneyRequestCount === 1) { + createOneTransactionThreadReport(); + return; + } + openReport(reportIDFromRoute, reportActionIDFromRoute); }, [ reportMetadata.isOptimisticReport, @@ -495,8 +516,12 @@ function ReportScreen({route, navigation}: ReportScreenProps) { route.params?.moneyRequestReportActionID, route.params?.transactionID, currentUserEmail, + transactionThreadReportID, + transactionThreadReport, + parentReportAction?.childMoneyRequestCount, reportIDFromRoute, reportActionIDFromRoute, + createOneTransactionThreadReport, ]); const prevTransactionThreadReportID = usePrevious(transactionThreadReportID); diff --git a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx index 408c42874c3bf..e0aaade23d76f 100755 --- a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx +++ b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx @@ -165,6 +165,7 @@ function BaseReportActionContextMenu({ const [download] = useOnyx(`${ONYXKEYS.COLLECTION.DOWNLOAD}${sourceID}`, {canBeMissing: true}); const [childReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportAction?.childReportID}`, {canBeMissing: true}); + const [childReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportAction?.childReportID}`, {canBeMissing: true}); const [childChatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${childReport?.chatReportID}`, {canBeMissing: true}); const parentReportAction = getReportAction(childReport?.parentReportID, childReport?.parentReportActionID); const {reportActions: paginatedReportActions} = usePaginatedReportActions(childReport?.reportID); @@ -181,13 +182,16 @@ function BaseReportActionContextMenu({ const requestParentReportAction = useMemo(() => { if (isMoneyRequestReport || isInvoiceReport) { + if (transactionThreadReportID === CONST.FAKE_REPORT_ID) { + return Object.values(childReportActions ?? {}).find((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.IOU); + } if (!paginatedReportActions || !transactionThreadReport?.parentReportActionID) { return undefined; } return paginatedReportActions.find((action) => action.reportActionID === transactionThreadReport.parentReportActionID); } return parentReportAction; - }, [parentReportAction, isMoneyRequestReport, isInvoiceReport, paginatedReportActions, transactionThreadReport?.parentReportActionID]); + }, [parentReportAction, isMoneyRequestReport, isInvoiceReport, paginatedReportActions, transactionThreadReport?.parentReportActionID, transactionThreadReportID, childReportActions]); const moneyRequestAction = transactionThreadReportID ? requestParentReportAction : parentReportAction; const isChildReportArchived = useReportIsArchived(childReport?.reportID); diff --git a/tests/actions/IOUTest.ts b/tests/actions/IOUTest.ts index ed53774a3b928..854a7316c2534 100644 --- a/tests/actions/IOUTest.ts +++ b/tests/actions/IOUTest.ts @@ -4691,6 +4691,58 @@ describe('actions/IOU', () => { }); }); }); + + test('should create transaction thread optimistically when initialReportID is undefined', () => { + const iouReport = buildOptimisticIOUReport(1, 2, 100, '1', 'USD'); + const transaction = buildOptimisticTransaction({ + transactionParams: { + amount: 100, + currency: 'USD', + reportID: iouReport.reportID, + }, + }); + const transactionCollectionDataSet: TransactionCollectionDataSet = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`]: transaction, + }; + const iouAction: ReportAction = buildOptimisticIOUReportAction({ + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + amount: transaction.amount, + currency: transaction.currency, + comment: '', + participants: [], + transactionID: transaction.transactionID, + }); + const actions: OnyxInputValue = {[iouAction.reportActionID]: iouAction}; + const reportCollectionDataSet: ReportCollectionDataSet = { + [`${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`]: iouReport, + }; + const actionCollectionDataSet: ReportActionsCollectionDataSet = { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`]: actions, + }; + const comment = 'hold reason for new thread'; + + return waitForBatchedUpdates() + .then(() => Onyx.multiSet({...reportCollectionDataSet, ...transactionCollectionDataSet, ...actionCollectionDataSet})) + .then(() => { + // When an expense is put on hold without existing transaction thread (undefined initialReportID) + putOnHold(transaction.transactionID, comment, undefined); + return waitForBatchedUpdates(); + }) + .then(() => { + return new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`, + callback: (reportActions) => { + Onyx.disconnect(connection); + const updatedIOUAction = reportActions?.[iouAction.reportActionID]; + // Verify that IOU action now has childReportID set optimistically + expect(updatedIOUAction?.childReportID).toBeDefined(); + resolve(); + }, + }); + }); + }); + }); }); describe('unHoldRequest', () => { diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts index 89cacca083f63..05db10b1a504f 100644 --- a/tests/unit/ReportActionsUtilsTest.ts +++ b/tests/unit/ReportActionsUtilsTest.ts @@ -357,6 +357,84 @@ describe('ReportActionsUtils', () => { }); }); + describe('getOneTransactionThreadReportAction', () => { + const IOUReportID = `${ONYXKEYS.COLLECTION.REPORT}REPORT_IOU` as const; + const IOUTransactionID = `${ONYXKEYS.COLLECTION.TRANSACTION}TRANSACTION_IOU` as const; + const IOUExpenseTransactionID = `${ONYXKEYS.COLLECTION.TRANSACTION}TRANSACTION_EXPENSE` as const; + const mockChatReportID = `${ONYXKEYS.COLLECTION.REPORT}${mockChatReport.reportID}` as const; + const mockedReports: Record<`${typeof ONYXKEYS.COLLECTION.REPORT}${string}`, Report> = { + [IOUReportID]: {...mockIOUReport, reportID: IOUReportID}, + [mockChatReportID]: mockChatReport, + }; + // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style + const originalMessage = getOriginalMessage(mockIOUAction) as OriginalMessageIOU; + + const linkedActionWithChildReportID = { + ...mockIOUAction, + originalMessage: {...originalMessage, IOUTransactionID}, + childReportID: 'existingChildReportID', + }; + + const linkedActionWithoutChildReportID = { + ...mockIOUAction, + originalMessage: {...originalMessage, IOUTransactionID}, + childReportID: undefined, + }; + + const unlinkedAction = { + ...mockIOUAction, + originalMessage: {...originalMessage, IOUTransactionID: IOUExpenseTransactionID}, + }; + + const payAction = { + ...mockIOUAction, + originalMessage: { + ...originalMessage, + IOUTransactionID, + type: CONST.IOU.REPORT_ACTION_TYPE.PAY, + }, + }; + + it('should return action when single IOU action exists', () => { + const result = ReportActionsUtils.getOneTransactionThreadReportAction(mockedReports[IOUReportID], mockedReports[mockChatReportID], [linkedActionWithChildReportID], false, [ + IOUTransactionID, + ]); + expect(result).toEqual(linkedActionWithChildReportID); + }); + + it('should return undefined when no linked actions exist', () => { + const result = ReportActionsUtils.getOneTransactionThreadReportAction(mockedReports[IOUReportID], mockedReports[mockChatReportID], [unlinkedAction], false, [IOUTransactionID]); + expect(result).toBeUndefined(); + }); + + it('should return undefined when multiple IOU actions exist', () => { + const result = ReportActionsUtils.getOneTransactionThreadReportAction( + mockedReports[IOUReportID], + mockedReports[mockChatReportID], + [linkedActionWithChildReportID, linkedActionWithoutChildReportID], + false, + [IOUTransactionID], + ); + expect(result).toBeUndefined(); + }); + + it('should skip PAY actions and return valid IOU action', () => { + const result = ReportActionsUtils.getOneTransactionThreadReportAction( + mockedReports[IOUReportID], + mockedReports[mockChatReportID], + [payAction, linkedActionWithoutChildReportID], + false, + [IOUTransactionID], + ); + expect(result).toEqual(linkedActionWithoutChildReportID); + }); + + it('should return undefined when only PAY actions exist', () => { + const result = ReportActionsUtils.getOneTransactionThreadReportAction(mockedReports[IOUReportID], mockedReports[mockChatReportID], [payAction], false, [IOUTransactionID]); + expect(result).toBeUndefined(); + }); + }); + describe('getOneTransactionThreadReportID', () => { const IOUReportID = `${ONYXKEYS.COLLECTION.REPORT}REPORT_IOU` as const; const IOUTransactionID = `${ONYXKEYS.COLLECTION.TRANSACTION}TRANSACTION_IOU` as const; @@ -374,6 +452,12 @@ describe('ReportActionsUtils', () => { originalMessage: {...originalMessage, IOUTransactionID}, }; + const linkedCreateActionWithoutChildReportID = { + ...mockIOUAction, + originalMessage: {...originalMessage, IOUTransactionID}, + childReportID: undefined, + }; + const unlinkedCreateAction = { ...mockIOUAction, originalMessage: {...originalMessage, IOUTransactionID: IOUExpenseTransactionID}, @@ -415,6 +499,11 @@ describe('ReportActionsUtils', () => { expect(result).toEqual(linkedCreateAction.childReportID); }); + it('should return CONST.FAKE_REPORT_ID when action exists but childReportID is undefined', () => { + const result = getOneTransactionThreadReportID(mockedReports[IOUReportID], mockedReports[mockChatReportID], [linkedCreateActionWithoutChildReportID], false, [IOUTransactionID]); + expect(result).toEqual(CONST.FAKE_REPORT_ID); + }); + it('should return undefined for action with a transaction that is not linked to it', () => { const result = getOneTransactionThreadReportID(mockedReports[IOUReportID], mockedReports[mockChatReportID], [unlinkedCreateAction], false, [IOUTransactionID]); expect(result).toBeUndefined();