From 8c3674faf57083ff361cf9d665f99b7721db0691 Mon Sep 17 00:00:00 2001 From: Shubham Agrawal Date: Sun, 5 Jul 2026 23:51:23 +0530 Subject: [PATCH] Refactor: ReportUtils from translateLocal to translate from hook (part 7) --- src/libs/OptionsListUtils/index.ts | 6 +- src/libs/ReportUtils.ts | 301 ++++++++++++++++-- src/libs/actions/Policy/Policy.ts | 2 +- src/libs/actions/Report/index.ts | 4 +- .../report/ContextMenu/ContextMenuActions.tsx | 5 +- tests/perf-test/ReportUtils.perf-test.ts | 4 +- tests/unit/OptionsListUtilsTest.tsx | 4 +- tests/unit/ReportUtilsTest.ts | 84 ++++- tests/unit/SidebarUtilsTest.ts | 6 +- 9 files changed, 352 insertions(+), 64 deletions(-) diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 95204ae05c6d..67ab8d74aef0 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -776,7 +776,7 @@ function getLastMessageTextForReport({ } } } else if (isMoneyRequestAction(lastReportAction)) { - const properSchemaForMoneyRequestMessage = getReportPreviewMessage({ + const properSchemaForMoneyRequestMessage = getReportPreviewMessage(translate, { reportOrID: report, iouReportAction: lastReportAction, shouldConsiderScanningReceiptOrPendingRoute: true, @@ -799,7 +799,7 @@ function getLastMessageTextForReport({ const reportName = reportAttributesDerived?.[iouReport.reportID]?.reportName ?? ''; lastMessageTextFromReport = formatReportLastMessageText(reportName); } else { - const reportPreviewMessage = getReportPreviewMessage({ + const reportPreviewMessage = getReportPreviewMessage(translate, { reportOrID: !isEmptyObject(iouReport) ? iouReport : null, iouReportAction: lastIOUMoneyReportAction ?? lastReportAction, shouldConsiderScanningReceiptOrPendingRoute: true, @@ -1072,7 +1072,7 @@ function getLastMessageTextForReport({ lastMessageTextFromReport = formatReportLastMessageText( Parser.htmlToText( - getReportPreviewMessage({ + getReportPreviewMessage(translate, { reportOrID: report, iouReportAction: lastReportAction, shouldConsiderScanningReceiptOrPendingRoute: true, diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 2c440fcec374..5132ec19af8a 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -5579,21 +5579,37 @@ type GetReportPreviewMessageBaseParams = { }; /** - * Get expense message for an IOU report. + * Get the report preview message to copy to the clipboard. + * + * This returns the report name (or the report action's child report name as a fallback), which is + * derived from report action content. Report action text is in English only, so this never needs + * `translateLocal`. */ -function getReportPreviewMessage(params: GetReportPreviewMessageBaseParams & {isCopyAction?: false}): string; -function getReportPreviewMessage(params: GetReportPreviewMessageBaseParams & {isCopyAction: true; reportAttributes?: ReportAttributesDerivedValue['reports']}): string; -function getReportPreviewMessage(params: GetReportPreviewMessageBaseParams & {isCopyAction?: boolean; reportAttributes?: ReportAttributesDerivedValue['reports']}): string { +function getReportPreviewMessageForCopy( + params: Pick & {reportAttributes?: ReportAttributesDerivedValue['reports']}, +): string { + const {reportOrID, iouReportAction = null, reportAttributes} = params; + const originalReportAction = params.originalReportAction ?? iouReportAction; + const report = typeof reportOrID === 'string' ? getReport(reportOrID, deprecatedAllReports) : reportOrID; + if (report) { + return getReportName(report, reportAttributes ?? reportAttributesDerivedValue) || (originalReportAction?.childReportName ?? ''); + } + return originalReportAction?.childReportName ?? ''; +} + +/** + * Get the expense message for an IOU report. + * + * Like the other report-action message getters (e.g. `getMovedTransactionMessage`), this takes + * `translate` as a parameter rather than reaching for `translateLocal`. To build the English-only + * text that gets stored on a report action, use {@link getReportPreviewReportActionMessage}. To copy the + * (English-only) report action content, use {@link getReportPreviewMessageForCopy}. + */ +function getReportPreviewMessage(translate: LocalizedTranslate, params: GetReportPreviewMessageBaseParams): string { const {reportOrID, iouReportAction = null, shouldConsiderScanningReceiptOrPendingRoute = false, isPreviewMessageForParentChatReport = false, policy, isForListPreview = false} = params; const originalReportAction = params.originalReportAction ?? iouReportAction; const report = typeof reportOrID === 'string' ? getReport(reportOrID, deprecatedAllReports) : reportOrID; const reportActionMessage = getReportActionHtml(iouReportAction); - if (params.isCopyAction) { - if (report) { - return getReportName(report, params.reportAttributes ?? reportAttributesDerivedValue) || (originalReportAction?.childReportName ?? ''); - } - return originalReportAction?.childReportName ?? ''; - } if (isEmptyObject(report) || !report?.reportID) { // This iouReport may be unavailable for one of the following reasons: @@ -5617,16 +5633,16 @@ function getReportPreviewMessage(params: GetReportPreviewMessageBaseParams & {is if (!isEmptyObject(linkedTransaction)) { if (isScanning(linkedTransaction)) { - return translateLocal('iou.receiptScanning', {count: 1}); + return translate('iou.receiptScanning', {count: 1}); } if (hasMissingSmartscanFieldsTransactionUtils(linkedTransaction, report)) { - return translateLocal('iou.receiptMissingDetails'); + return translate('iou.receiptMissingDetails'); } const amount = getTransactionAmount(linkedTransaction, !isEmptyObject(report) && isExpenseReport(report), linkedTransaction?.reportID === CONST.REPORT.UNREPORTED_REPORT_ID) ?? 0; const formattedAmount = convertToDisplayString(amount, getCurrency(linkedTransaction)) ?? ''; - return translateLocal('iou.didSplitAmount', formattedAmount, getMerchantOrDescription(linkedTransaction)); + return translate('iou.didSplitAmount', formattedAmount, getMerchantOrDescription(linkedTransaction)); } } @@ -5641,7 +5657,7 @@ function getReportPreviewMessage(params: GetReportPreviewMessageBaseParams & {is if (amount && currency) { const formattedAmount = convertToDisplayString(amount, currency); - return translateLocal('iou.trackedAmount', formattedAmount, comment); + return translate('iou.trackedAmount', formattedAmount, comment); } return reportActionMessage; @@ -5649,11 +5665,11 @@ function getReportPreviewMessage(params: GetReportPreviewMessageBaseParams & {is if (!isEmptyObject(linkedTransaction)) { if (isScanning(linkedTransaction)) { - return translateLocal('iou.receiptScanning', {count: 1}); + return translate('iou.receiptScanning', {count: 1}); } if (hasMissingSmartscanFieldsTransactionUtils(linkedTransaction, report)) { - return translateLocal('iou.receiptMissingDetails'); + return translate('iou.receiptMissingDetails'); } const amount = getTransactionAmount(linkedTransaction, !isEmptyObject(report) && isExpenseReport(report), linkedTransaction?.reportID === CONST.REPORT.UNREPORTED_REPORT_ID) ?? 0; @@ -5661,7 +5677,7 @@ function getReportPreviewMessage(params: GetReportPreviewMessageBaseParams & {is const merchantOrComment = getMerchantOrDescription(linkedTransaction); - return translateLocal('iou.trackedAmount', formattedAmount, merchantOrComment); + return translate('iou.trackedAmount', formattedAmount, merchantOrComment); } } @@ -5677,7 +5693,7 @@ function getReportPreviewMessage(params: GetReportPreviewMessageBaseParams & {is const formattedAmount = convertToDisplayString(totalAmount, report.currency); if (isReportApproved({report}) && isReportInGroupPolicy(report)) { - return translateLocal('iou.managerApprovedAmount', payerName ?? '', formattedAmount); + return translate('iou.managerApprovedAmount', payerName ?? '', formattedAmount); } const reportPolicy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`]; @@ -5688,11 +5704,11 @@ function getReportPreviewMessage(params: GetReportPreviewMessageBaseParams & {is } if (!isEmptyObject(linkedTransaction) && isScanning(linkedTransaction)) { - return translateLocal('iou.receiptScanning', {count: numberOfScanningReceipts}); + return translate('iou.receiptScanning', {count: numberOfScanningReceipts}); } if (!isEmptyObject(linkedTransaction) && isFetchingWaypointsFromServer(linkedTransaction) && !getTransactionAmount(linkedTransaction)) { - return translateLocal('iou.fieldPending'); + return translate('iou.fieldPending'); } const originalMessage = !isEmptyObject(iouReportAction) && isMoneyRequestAction(iouReportAction) ? getOriginalMessage(iouReportAction) : undefined; @@ -5721,7 +5737,7 @@ function getReportPreviewMessage(params: GetReportPreviewMessageBaseParams & {is if (isFromInvoice) { translatePhraseKey = originalMessage?.payAsBusiness ? 'iou.settleInvoiceBusiness' : 'iou.settleInvoicePersonal'; const currentBankAccount = getBankAccountFromID(originalMessage?.bankAccountID); - return translateLocal(translatePhraseKey, formattedAmount, currentBankAccount?.accountData?.accountNumber?.slice(-4) ?? ''); + return translate(translatePhraseKey, formattedAmount, currentBankAccount?.accountData?.accountNumber?.slice(-4) ?? ''); } } @@ -5734,22 +5750,22 @@ function getReportPreviewMessage(params: GetReportPreviewMessageBaseParams & {is actualPayerName = actualPayerName && isForListPreview && !isPreviewMessageForParentChatReport ? `${actualPayerName}:` : actualPayerName; const payerDisplayName = isPreviewMessageForParentChatReport ? payerName : actualPayerName; if (translatePhraseKey === 'iou.businessBankAccount') { - return translateLocal(translatePhraseKey, '', originalMessage?.accountNumber?.slice(-4) ?? reportPolicy?.achAccount?.accountNumber?.slice(-4) ?? ''); + return translate(translatePhraseKey, '', originalMessage?.accountNumber?.slice(-4) ?? reportPolicy?.achAccount?.accountNumber?.slice(-4) ?? ''); } if (translatePhraseKey === 'iou.automaticallyPaidWithExpensify' || translatePhraseKey === 'iou.paidWithExpensify') { - return translateLocal(translatePhraseKey, payerDisplayName ?? ''); + return translate(translatePhraseKey, payerDisplayName ?? ''); } if (translatePhraseKey === 'iou.paidElsewhere') { - return getElsewherePaymentReportActionMessage(translateLocal, originalMessage, payerDisplayName ?? undefined); + return getElsewherePaymentReportActionMessage(translate, originalMessage, payerDisplayName ?? undefined); } if (translatePhraseKey === 'iou.payerPaidAmount') { - return translateLocal(translatePhraseKey, formattedAmount, payerDisplayName ?? ''); + return translate(translatePhraseKey, formattedAmount, payerDisplayName ?? ''); } } if (report.isWaitingOnBankAccount) { const submitterDisplayName = getDisplayNameForParticipant({accountID: report.ownerAccountID, shouldUseShortForm: true, formatPhoneNumber: formatPhoneNumberPhoneUtils}) ?? ''; - return translateLocal('iou.waitingOnBankAccount', submitterDisplayName); + return translate('iou.waitingOnBankAccount', submitterDisplayName); } const lastActorID = iouReportAction?.actorAccountID; @@ -5779,17 +5795,230 @@ function getReportPreviewMessage(params: GetReportPreviewMessageBaseParams & {is lastActorID && lastActorID !== deprecatedCurrentUserAccountID ? getDisplayNameForParticipant({accountID: lastActorID, shouldUseShortForm: !isPreviewMessageForParentChatReport, formatPhoneNumber: formatPhoneNumberPhoneUtils}) : ''; - return `${requestorName ? `${requestorName}: ` : ''}${translateLocal('iou.expenseAmount', amountToDisplay, comment)}`; + return `${requestorName ? `${requestorName}: ` : ''}${translate('iou.expenseAmount', amountToDisplay, comment)}`; } if (containsNonReimbursable) { - return translateLocal( - 'iou.payerSpentAmount', - formattedAmount, - getDisplayNameForParticipant({accountID: report.ownerAccountID, formatPhoneNumber: formatPhoneNumberPhoneUtils}) ?? '', - ); + return translate('iou.payerSpentAmount', formattedAmount, getDisplayNameForParticipant({accountID: report.ownerAccountID, formatPhoneNumber: formatPhoneNumberPhoneUtils}) ?? ''); + } + return translate('iou.payerOwesAmount', formattedAmount, payerName ?? '', comment); +} + +/** + * Build the English-only report preview message that gets stored on a report action. + * + * Report action text is English only. This mirrors {@link getReportPreviewMessage} but hardcodes the + * English strings (the same approach as {@link buildOptimisticTransactionAction}) instead of routing through + * `translate`/`translateLocal`. That keeps the stored text in English regardless of the viewer's locale and + * avoids depending on the async-loaded locale bundles. For a localized preview shown in the UI, call + * {@link getReportPreviewMessage} with the caller's `translate`. + * + * IMPORTANT: keep the English strings here in sync with the `iou.*` entries in `en.ts` and with the branching + * in {@link getReportPreviewMessage}. + */ +function getReportPreviewReportActionMessage(params: GetReportPreviewMessageBaseParams): string { + const {reportOrID, iouReportAction = null, shouldConsiderScanningReceiptOrPendingRoute = false, isPreviewMessageForParentChatReport = false, policy, isForListPreview = false} = params; + const originalReportAction = params.originalReportAction ?? iouReportAction; + const report = typeof reportOrID === 'string' ? getReport(reportOrID, deprecatedAllReports) : reportOrID; + const reportActionMessage = getReportActionHtml(iouReportAction); + + if (isEmptyObject(report) || !report?.reportID) { + // If the report is empty, we display the report name to avoid showing "payer owes 0" + return !!originalReportAction?.childReportName && originalReportAction?.childMoneyRequestCount === 0 ? originalReportAction?.childReportName : reportActionMessage; + } + + const allReportTransactions = getReportTransactions(report.reportID); + const transactionsWithReceipts = allReportTransactions.filter(hasReceiptTransactionUtils); + const numberOfScanningReceipts = transactionsWithReceipts.filter(isScanning).length; + + if (!isEmptyObject(iouReportAction) && !isIOUReport(report) && iouReportAction && isSplitBillReportAction(iouReportAction)) { + // This covers group chats where the last action is a split expense action + const linkedTransaction = getLinkedTransaction(iouReportAction); + if (isEmptyObject(linkedTransaction)) { + return reportActionMessage; + } + + if (!isEmptyObject(linkedTransaction)) { + if (isScanning(linkedTransaction)) { + return 'Receipt scanning...'; + } + + if (hasMissingSmartscanFieldsTransactionUtils(linkedTransaction, report)) { + return 'Receipt missing details'; + } + + const amount = getTransactionAmount(linkedTransaction, !isEmptyObject(report) && isExpenseReport(report), linkedTransaction?.reportID === CONST.REPORT.UNREPORTED_REPORT_ID) ?? 0; + const formattedAmount = convertToDisplayString(amount, getCurrency(linkedTransaction)) ?? ''; + const comment = getMerchantOrDescription(linkedTransaction); + return `split ${formattedAmount}${comment ? ` for ${comment}` : ''}`; + } + } + + if (!isEmptyObject(iouReportAction) && !isIOUReport(report) && !isExpenseReport(report) && iouReportAction && isTrackExpenseAction(iouReportAction)) { + // This covers group chats where the last action is a track expense action + const linkedTransaction = getLinkedTransaction(iouReportAction); + if (isEmptyObject(linkedTransaction)) { + const originalMessage = getOriginalMessage(iouReportAction); + const amount = originalMessage?.amount; + const currency = originalMessage?.currency; + const comment = originalMessage?.comment; + + if (amount && currency) { + const formattedAmount = convertToDisplayString(amount, currency); + return `tracking ${formattedAmount}${comment ? ` for ${comment}` : ''}`; + } + + return reportActionMessage; + } + + if (!isEmptyObject(linkedTransaction)) { + if (isScanning(linkedTransaction)) { + return 'Receipt scanning...'; + } + + if (hasMissingSmartscanFieldsTransactionUtils(linkedTransaction, report)) { + return 'Receipt missing details'; + } + + const amount = getTransactionAmount(linkedTransaction, !isEmptyObject(report) && isExpenseReport(report), linkedTransaction?.reportID === CONST.REPORT.UNREPORTED_REPORT_ID) ?? 0; + const formattedAmount = convertToDisplayString(amount, getCurrency(linkedTransaction)) ?? ''; + + const merchantOrComment = getMerchantOrDescription(linkedTransaction); + + return `tracking ${formattedAmount}${merchantOrComment ? ` for ${merchantOrComment}` : ''}`; + } + } + + const containsNonReimbursable = hasNonReimbursableTransactions(report.reportID); + const {totalDisplaySpend: totalAmount} = getMoneyRequestSpendBreakdown(report); + + const parentReport = getParentReport(report); + const policyName = getPolicyName({report: parentReport ?? report, policy}); + const payerName = isExpenseReport(report) + ? policyName + : getDisplayNameForParticipant({accountID: report.managerID, shouldUseShortForm: !isPreviewMessageForParentChatReport, formatPhoneNumber: formatPhoneNumberPhoneUtils}); + + const formattedAmount = convertToDisplayString(totalAmount, report.currency); + + if (isReportApproved({report}) && isReportInGroupPolicy(report)) { + return `${payerName ?? ''} approved ${formattedAmount}`; + } + + const reportPolicy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`]; + + let linkedTransaction; + if (!isEmptyObject(iouReportAction) && shouldConsiderScanningReceiptOrPendingRoute && iouReportAction && isMoneyRequestAction(iouReportAction)) { + linkedTransaction = getLinkedTransaction(iouReportAction); + } + + if (!isEmptyObject(linkedTransaction) && isScanning(linkedTransaction)) { + return numberOfScanningReceipts === 1 ? 'Receipt scanning...' : 'Receipts scanning...'; + } + + if (!isEmptyObject(linkedTransaction) && isFetchingWaypointsFromServer(linkedTransaction) && !getTransactionAmount(linkedTransaction)) { + return 'Pending...'; + } + + const originalMessage = !isEmptyObject(iouReportAction) && isMoneyRequestAction(iouReportAction) ? getOriginalMessage(iouReportAction) : undefined; + + // Show Paid preview message if it's settled or if the amount is paid & stuck at receivers end for only chat reports. + if (isSettled(report.reportID) || (report.isWaitingOnBankAccount && isPreviewMessageForParentChatReport)) { + // A settled report preview message can come in three formats "paid ... elsewhere" or "paid ... with Expensify" + let translatePhraseKey: TranslationPaths = 'iou.paidElsewhere'; + if (isPreviewMessageForParentChatReport) { + translatePhraseKey = 'iou.payerPaidAmount'; + } else if ( + [CONST.IOU.PAYMENT_TYPE.VBBA, CONST.IOU.PAYMENT_TYPE.EXPENSIFY].some((paymentType) => paymentType === originalMessage?.paymentType) || + !!reportActionMessage.match(/ (with Expensify|using Expensify)$/) || + report.isWaitingOnBankAccount + ) { + translatePhraseKey = 'iou.paidWithExpensify'; + const isFromInvoice = !!originalMessage?.bankAccountID; + if (originalMessage?.automaticAction) { + translatePhraseKey = 'iou.automaticallyPaidWithExpensify'; + } + + if (originalMessage?.paymentType === CONST.IOU.PAYMENT_TYPE.VBBA) { + translatePhraseKey = 'iou.businessBankAccount'; + } + + if (isFromInvoice) { + const currentBankAccount = getBankAccountFromID(originalMessage?.bankAccountID); + const last4Digits = currentBankAccount?.accountData?.accountNumber?.slice(-4) ?? ''; + return originalMessage?.payAsBusiness ? `paid ${formattedAmount} with business account ${last4Digits}` : `paid ${formattedAmount} with personal account ${last4Digits}`; + } + } + + const payerAccountID = iouReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? iouReportAction?.actorAccountID : report.managerID; + let actualPayerName = + report.managerID === deprecatedCurrentUserAccountID && !isForListPreview + ? '' + : getDisplayNameForParticipant({accountID: payerAccountID, shouldUseShortForm: true, formatPhoneNumber: formatPhoneNumberPhoneUtils}); + + actualPayerName = actualPayerName && isForListPreview && !isPreviewMessageForParentChatReport ? `${actualPayerName}:` : actualPayerName; + const payerDisplayName = isPreviewMessageForParentChatReport ? payerName : actualPayerName; + if (translatePhraseKey === 'iou.businessBankAccount') { + const last4Digits = originalMessage?.accountNumber?.slice(-4) ?? reportPolicy?.achAccount?.accountNumber?.slice(-4) ?? ''; + return `paid with bank account ${last4Digits}`; + } + if (translatePhraseKey === 'iou.automaticallyPaidWithExpensify') { + return `${payerDisplayName ? `${payerDisplayName} ` : ''}paid with Expensify via workspace rules`; + } + if (translatePhraseKey === 'iou.paidWithExpensify') { + return `${payerDisplayName ? `${payerDisplayName} ` : ''}paid with wallet`; + } + if (translatePhraseKey === 'iou.paidElsewhere') { + if (originalMessage?.isSubmitterMarkedPaymentReceived) { + return `${payerDisplayName ? `${payerDisplayName} ` : ''}received payment`; + } + const trimmedComment = originalMessage?.comment?.trim(); + return `${payerDisplayName ? `${payerDisplayName} ` : ''}marked as paid${trimmedComment ? `, saying "${trimmedComment}"` : ''}`; + } + if (translatePhraseKey === 'iou.payerPaidAmount') { + return `${payerDisplayName ? `${payerDisplayName} ` : ''}paid ${formattedAmount}`; + } + } + + if (report.isWaitingOnBankAccount) { + const submitterDisplayName = getDisplayNameForParticipant({accountID: report.ownerAccountID, shouldUseShortForm: true, formatPhoneNumber: formatPhoneNumberPhoneUtils}) ?? ''; + return `started payment, but is waiting for ${submitterDisplayName} to add a bank account.`; + } + + const lastActorID = iouReportAction?.actorAccountID; + let amount = originalMessage?.amount; + let currency = originalMessage?.currency ? originalMessage?.currency : report.currency; + + if (!isEmptyObject(linkedTransaction)) { + amount = getTransactionAmount(linkedTransaction, isExpenseReport(report)); + currency = getCurrency(linkedTransaction); + } + + if (isEmptyObject(linkedTransaction) && !isEmptyObject(iouReportAction)) { + linkedTransaction = getLinkedTransaction(iouReportAction); + } + + let comment = !isEmptyObject(linkedTransaction) ? getMerchantOrDescription(linkedTransaction) : undefined; + if (!isEmptyObject(originalReportAction) && isReportPreviewAction(originalReportAction) && getNumberOfMoneyRequests(originalReportAction) !== 1) { + comment = undefined; + } + + // if we have the amount in the originalMessage and lastActorID, we can use that to display the preview message for the latest expense + if (amount !== undefined && lastActorID && !isPreviewMessageForParentChatReport) { + const amountToDisplay = convertToDisplayString(Math.abs(amount), currency); + + // We only want to show the actor name in the preview if it's not the current user who took the action + const requestorName = + lastActorID && lastActorID !== deprecatedCurrentUserAccountID + ? getDisplayNameForParticipant({accountID: lastActorID, shouldUseShortForm: !isPreviewMessageForParentChatReport, formatPhoneNumber: formatPhoneNumberPhoneUtils}) + : ''; + return `${requestorName ? `${requestorName}: ` : ''}${amountToDisplay}${comment ? ` for ${comment}` : ''}`; + } + + if (containsNonReimbursable) { + const ownerName = getDisplayNameForParticipant({accountID: report.ownerAccountID, formatPhoneNumber: formatPhoneNumberPhoneUtils}) ?? ''; + return `${ownerName} spent ${formattedAmount}`; } - return translateLocal('iou.payerOwesAmount', formattedAmount, payerName ?? '', comment); + return `${payerName ?? ''} owes ${formattedAmount}${comment ? ` for ${comment}` : ''}`; } /** @@ -7561,7 +7790,7 @@ function buildOptimisticReportPreview( delegateAccountIDParam: number | undefined = undefined, ): ReportAction { const hasReceipt = hasReceiptTransactionUtils(transaction); - const message = getReportPreviewMessage({reportOrID: iouReport}); + const message = getReportPreviewReportActionMessage({reportOrID: iouReport}); const created = DateUtils.getDBTime(); const reportActorAccountID = (isInvoiceReport(iouReport) || isExpenseReport(iouReport) ? iouReport?.ownerAccountID : iouReport?.managerID) ?? -1; // Falls back to module-level delegateEmail (from Onyx.connect) for callers not yet migrated; will be removed in https://github.com/Expensify/App/issues/66425 @@ -7752,7 +7981,7 @@ function updateReportPreview( } } - const message = getReportPreviewMessage({reportOrID: iouReport, iouReportAction: reportPreviewAction}); + const message = getReportPreviewReportActionMessage({reportOrID: iouReport, iouReportAction: reportPreviewAction}); const originalMessage = getOriginalMessage(reportPreviewAction); return { ...reportPreviewAction, @@ -13510,6 +13739,8 @@ export { getReportOfflinePendingActionAndErrors, getReportParticipantsTitle, getReportPreviewMessage, + getReportPreviewMessageForCopy, + getReportPreviewReportActionMessage, getReportRecipientAccountIDs, shouldCurrentUserSubmitReport, canSubmitAndIsAwaitingForCurrentUser, diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index d4a36d01ea08..4532c3b43181 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -4590,7 +4590,7 @@ function createWorkspaceFromIOUPayment( message: [ { type: CONST.REPORT.MESSAGE.TYPE.TEXT, - text: ReportUtils.getReportPreviewMessage({reportOrID: expenseReport, policy: newWorkspace}), + text: ReportUtils.getReportPreviewReportActionMessage({reportOrID: expenseReport, policy: newWorkspace}), }, ], created: DateUtils.getDBTime(), diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index dfaee0078f28..2d715cf53722 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -142,7 +142,7 @@ import { getReportMetadata, getReportNotificationPreference, getReportOrDraftReport, - getReportPreviewMessage, + getReportPreviewReportActionMessage, getReportTransactions, hasOutstandingChildRequest, isAdminRoom, @@ -4033,7 +4033,7 @@ function buildNewReportOptimisticData( pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, }; - const message = getReportPreviewMessage({reportOrID: optimisticReportData}); + const message = getReportPreviewReportActionMessage({reportOrID: optimisticReportData}); const createReportActionMessage = [ { html: message, diff --git a/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx b/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx index fc56df3582ea..b40f06e88c11 100644 --- a/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx +++ b/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx @@ -182,7 +182,7 @@ import { getReimbursementDeQueuedOrCanceledActionMessage, getReimbursementQueuedActionMessage, getReportOrDraftReport, - getReportPreviewMessage, + getReportPreviewMessageForCopy, getUnreportedTransactionMessage, getWorkspaceNameUpdatedMessage, isExpenseReport, @@ -929,10 +929,9 @@ const ContextMenuActions: ContextMenuAction[] = [ const content = selection || messageHtml; if (isReportPreviewAction) { const iouReportID = getIOUReportIDFromReportActionPreview(reportAction); - const displayMessage = getReportPreviewMessage({ + const displayMessage = getReportPreviewMessageForCopy({ reportOrID: iouReportID, iouReportAction: reportAction, - isCopyAction: true, reportAttributes, }); Clipboard.setString(displayMessage); diff --git a/tests/perf-test/ReportUtils.perf-test.ts b/tests/perf-test/ReportUtils.perf-test.ts index 3cc41fffc789..3018e58b2b2f 100644 --- a/tests/perf-test/ReportUtils.perf-test.ts +++ b/tests/perf-test/ReportUtils.perf-test.ts @@ -9,7 +9,7 @@ import { getIcons, getIconsForParticipants, getIOUReportActionDisplayMessage, - getReportPreviewMessage, + getReportPreviewReportActionMessage, getReportRecipientAccountIDs, getTransactionDetails, getWorkspaceChats, @@ -150,7 +150,7 @@ describe('ReportUtils', () => { await waitForBatchedUpdates(); await measureFunction(() => - getReportPreviewMessage({ + getReportPreviewReportActionMessage({ reportOrID: report, iouReportAction: reportAction, shouldConsiderScanningReceiptOrPendingRoute: shouldConsiderReceiptBeingScanned, diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index aa3d7424746c..7616dfc87521 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -67,7 +67,7 @@ import { formatReportLastMessageText, getMovedActionMessage, getMovedTransactionMessage, - getReportPreviewMessage, + getReportPreviewReportActionMessage, getReportTransactions, isCanceledTaskReport, isExpensifyOnlyParticipantInReport, @@ -4471,7 +4471,7 @@ describe('OptionsListUtils', () => { [iouAction.reportActionID]: iouAction, }); await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, transaction); - const reportPreviewMessage = getReportPreviewMessage({ + const reportPreviewMessage = getReportPreviewReportActionMessage({ reportOrID: iouReport, iouReportAction: iouAction, shouldConsiderScanningReceiptOrPendingRoute: true, diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 7402d515eb1f..241dad1ccb85 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -2,7 +2,7 @@ import {beforeAll} from '@jest/globals'; import {act, renderHook} from '@testing-library/react-native'; -import type {LocaleContextProps} from '@components/LocaleContextProvider'; +import type {LocaleContextProps, LocalizedTranslate} from '@components/LocaleContextProvider'; import type PolicyData from '@hooks/usePolicyData/types'; import useReportIsArchived from '@hooks/useReportIsArchived'; @@ -116,6 +116,8 @@ import { getReportIDFromLink, getReportOrDraftReport, getReportPreviewMessage, + getReportPreviewMessageForCopy, + getReportPreviewReportActionMessage, getReportStatusTranslation, getReportSubtitlePrefix, getTaskAssigneeChatOnyxData, @@ -14965,8 +14967,8 @@ describe('ReportUtils', () => { childMoneyRequestCount: 0, }; - // When we call getReportPreviewMessage - const result = getReportPreviewMessage({reportOrID: report, iouReportAction: reportAction, originalReportAction: reportAction}); + // When we call getReportPreviewReportActionMessage + const result = getReportPreviewReportActionMessage({reportOrID: report, iouReportAction: reportAction, originalReportAction: reportAction}); // Then it should return the childReportName instead of "payer owes $0" expect(result).toBe('Expense Report 2025-01-15'); @@ -14985,13 +14987,13 @@ describe('ReportUtils', () => { message: [{html: 'payer owes $100', type: 'COMMENT', text: 'payer owes $100'}], }; - // When we call getReportPreviewMessage - const result = getReportPreviewMessage({reportOrID: report, iouReportAction: reportAction, originalReportAction: reportAction}); + // When we call getReportPreviewReportActionMessage + const result = getReportPreviewReportActionMessage({reportOrID: report, iouReportAction: reportAction, originalReportAction: reportAction}); // Then it should return the message from the report action (not the childReportName) expect(result).toBe('payer owes $100'); }); - it('should return expense report name when isCopyAction is true', async () => { + it('getReportPreviewMessageForCopy should return the expense report name', async () => { const report = LHNTestUtils.getFakeReport(); report.reportName = 'Expense Report 2025-01-15'; const reportAction: ReportAction = { @@ -15001,14 +15003,14 @@ describe('ReportUtils', () => { childMoneyRequestCount: 0, }; - // When we call getReportPreviewMessage with isCopyAction = true - const result = getReportPreviewMessage({reportOrID: report, iouReportAction: reportAction, originalReportAction: reportAction, isCopyAction: true}); + // When we call getReportPreviewMessageForCopy + const result = getReportPreviewMessageForCopy({reportOrID: report, iouReportAction: reportAction, originalReportAction: reportAction}); // Then it should return the childReportName instead of "payer owes $0" expect(result).toBe('Expense Report 2025-01-15'); }); - it('should use the report name from the reportAttributes param when isCopyAction is true', async () => { + it('getReportPreviewMessageForCopy should use the report name from the reportAttributes param', async () => { const report = LHNTestUtils.getFakeReport(); report.reportName = 'Stale Report Name'; const reportAction: ReportAction = { @@ -15028,11 +15030,10 @@ describe('ReportUtils', () => { }; // When called with reportAttributes that provide a report name, it should be preferred over the report's own name - const result = getReportPreviewMessage({ + const result = getReportPreviewMessageForCopy({ reportOrID: report, iouReportAction: reportAction, originalReportAction: reportAction, - isCopyAction: true, reportAttributes, }); expect(result).toBe('Computed Report Name'); @@ -15079,17 +15080,74 @@ describe('ReportUtils', () => { originalMessage: {...payOriginalMessage, accountNumber: 'XXXXXX4321'}, }; - const result = getReportPreviewMessage({reportOrID: settledReport, iouReportAction: actionWithAccountNumber, originalReportAction: actionWithAccountNumber}); + const result = getReportPreviewReportActionMessage({reportOrID: settledReport, iouReportAction: actionWithAccountNumber, originalReportAction: actionWithAccountNumber}); // Then the preview shows the last 4 digits of that account, not the policy default expect(result).toBe(translate(CONST.LOCALES.EN, 'iou.businessBankAccount', '', '4321')); }); it('falls back to the policy default bank account when the action has no accountNumber', () => { - const result = getReportPreviewMessage({reportOrID: settledReport, iouReportAction: payReportAction, originalReportAction: payReportAction}); + const result = getReportPreviewReportActionMessage({reportOrID: settledReport, iouReportAction: payReportAction, originalReportAction: payReportAction}); expect(result).toBe(translate(CONST.LOCALES.EN, 'iou.businessBankAccount', '', '0000')); }); + + it('matches the localized getReportPreviewMessage output when translated to English', () => { + const englishTranslate: LocalizedTranslate = (path, ...parameters) => translate(CONST.LOCALES.EN, path, ...parameters); + const params = {reportOrID: settledReport, iouReportAction: payReportAction, originalReportAction: payReportAction}; + + // The hardcoded English copy must not drift from the localized function + expect(getReportPreviewReportActionMessage(params)).toBe(getReportPreviewMessage(englishTranslate, params)); + }); + }); + + describe('getReportPreviewMessage (localized)', () => { + const expenseReport: Report = { + ...LHNTestUtils.getFakeReport(), + reportID: 'preview-localized-report', + type: CONST.REPORT.TYPE.EXPENSE, + currency: CONST.CURRENCY.USD, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + + beforeEach(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, expenseReport); + }); + + it('uses the injected translate function (not translateLocal) so output follows the passed locale, while getReportPreviewReportActionMessage stays English', async () => { + const params = {reportOrID: expenseReport}; + const englishTranslate: LocalizedTranslate = (path, ...parameters) => translate(CONST.LOCALES.EN, path, ...parameters); + const spanishTranslate: LocalizedTranslate = (path, ...parameters) => translate(CONST.LOCALES.ES, path, ...parameters); + + await IntlStore.load(CONST.LOCALES.ES).then(waitForBatchedUpdates); + + // The localized preview differs between English and Spanish... + expect(getReportPreviewMessage(spanishTranslate, params)).not.toBe(getReportPreviewMessage(englishTranslate, params)); + // ...but the report-action-message variant is always the English text, regardless of the loaded locale + expect(getReportPreviewReportActionMessage(params)).toBe(getReportPreviewMessage(englishTranslate, params)); + }); + }); + + describe('getReportPreviewReportActionMessage (hardcoded English)', () => { + it('returns the English "owes" message for an expense report and matches en.ts', async () => { + const report: Report = { + ...LHNTestUtils.getFakeReport(), + reportID: 'preview-en-owes-report', + type: CONST.REPORT.TYPE.EXPENSE, + currency: CONST.CURRENCY.USD, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, report); + + const englishTranslate: LocalizedTranslate = (path, ...parameters) => translate(CONST.LOCALES.EN, path, ...parameters); + const result = getReportPreviewReportActionMessage({reportOrID: report}); + + // The hardcoded English string must match the en.ts translation produced by the localized function + expect(result).toBe(getReportPreviewMessage(englishTranslate, {reportOrID: report})); + expect(result).toContain('owes'); + }); }); }); diff --git a/tests/unit/SidebarUtilsTest.ts b/tests/unit/SidebarUtilsTest.ts index f18474089139..2b1a3b34f789 100644 --- a/tests/unit/SidebarUtilsTest.ts +++ b/tests/unit/SidebarUtilsTest.ts @@ -8,7 +8,7 @@ import DateUtils from '@libs/DateUtils'; import {getLastActorDisplayName} from '@libs/OptionsListUtils'; import type * as PolicyUtils from '@libs/PolicyUtils'; import {getOriginalMessage, getReportActionMessageText} from '@libs/ReportActionsUtils'; -import {formatReportLastMessageText, generateReportID, getAllReportErrors, getReasonAndReportActionThatRequiresAttention, getReportPreviewMessage} from '@libs/ReportUtils'; +import {formatReportLastMessageText, generateReportID, getAllReportErrors, getReasonAndReportActionThatRequiresAttention, getReportPreviewReportActionMessage} from '@libs/ReportUtils'; import SidebarUtils, {_buildSortKey, _categorizeReportsForLHN, _combineReportCategories, _sortCategorizedReports} from '@libs/SidebarUtils'; import initOnyxDerivedValues from '@userActions/OnyxDerived'; @@ -3445,7 +3445,7 @@ describe('SidebarUtils', () => { currentUserLogin: CURRENT_USER_LOGIN, }); - const reportPreviewMessage = getReportPreviewMessage({ + const reportPreviewMessage = getReportPreviewReportActionMessage({ reportOrID: iouReport, iouReportAction: iouAction, shouldConsiderScanningReceiptOrPendingRoute: true, @@ -3555,7 +3555,7 @@ describe('SidebarUtils', () => { currentUserLogin: CURRENT_USER_LOGIN, }); - const reportPreviewMessage = getReportPreviewMessage({ + const reportPreviewMessage = getReportPreviewReportActionMessage({ reportOrID: iouReport, iouReportAction: iouAction, shouldConsiderScanningReceiptOrPendingRoute: true,