diff --git a/src/hooks/useCreateNewReport.tsx b/src/hooks/useCreateNewReport.tsx index 859c40eec592..b56fbef93aae 100644 --- a/src/hooks/useCreateNewReport.tsx +++ b/src/hooks/useCreateNewReport.tsx @@ -9,6 +9,7 @@ import {accountIDSelector, emailSelector} from '@selectors/Session'; import {useCallback} from 'react'; import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; +import useLocalize from './useLocalize'; import useOnyx from './useOnyx'; import usePermissions from './usePermissions'; @@ -18,6 +19,7 @@ import usePermissions from './usePermissions'; */ function useCreateNewReport() { const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const {translate} = useLocalize(); const {isBetaEnabled} = usePermissions(); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); const [accountID] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); @@ -32,9 +34,11 @@ function useCreateNewReport() { return useCallback( (policyID: string, shouldDismissEmptyReportsConfirmation = false) => { const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; - return createNewReport(currentUserPersonalDetails, hasViolations, isASAPSubmitBetaEnabled, policy, betas, isTrackIntentUser, false, shouldDismissEmptyReportsConfirmation); + return createNewReport(currentUserPersonalDetails, hasViolations, isASAPSubmitBetaEnabled, policy, betas, isTrackIntentUser, false, shouldDismissEmptyReportsConfirmation, { + translate, + }); }, - [betas, currentUserPersonalDetails, hasViolations, isASAPSubmitBetaEnabled, policies, isTrackIntentUser], + [betas, currentUserPersonalDetails, hasViolations, isASAPSubmitBetaEnabled, policies, isTrackIntentUser, translate], ); } diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index a0c8f81a4359..75dc9ffb7583 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -5927,7 +5927,7 @@ function getReportPreviewMessage(translate: LocalizedTranslate, params: GetRepor * 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 { +function getReportPreviewReportActionMessage(params: GetReportPreviewMessageBaseParams, translate?: LocalizedTranslate): 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; @@ -6007,7 +6007,7 @@ function getReportPreviewReportActionMessage(params: GetReportPreviewMessageBase const policyName = getPolicyName({report: parentReport ?? report, policy}); const payerName = isExpenseReport(report) ? policyName - : getDisplayNameForParticipant({accountID: report.managerID, shouldUseShortForm: !isPreviewMessageForParentChatReport, formatPhoneNumber: formatPhoneNumberPhoneUtils}); + : getDisplayNameForParticipant({accountID: report.managerID, shouldUseShortForm: !isPreviewMessageForParentChatReport, formatPhoneNumber: formatPhoneNumberPhoneUtils, translate}); const formattedAmount = convertToDisplayString(totalAmount, report.currency); @@ -6064,7 +6064,7 @@ function getReportPreviewReportActionMessage(params: GetReportPreviewMessageBase let actualPayerName = report.managerID === deprecatedCurrentUserAccountID && !isForListPreview ? '' - : getDisplayNameForParticipant({accountID: payerAccountID, shouldUseShortForm: true, formatPhoneNumber: formatPhoneNumberPhoneUtils}); + : getDisplayNameForParticipant({accountID: payerAccountID, shouldUseShortForm: true, formatPhoneNumber: formatPhoneNumberPhoneUtils, translate}); actualPayerName = actualPayerName && isForListPreview && !isPreviewMessageForParentChatReport ? `${actualPayerName}:` : actualPayerName; const payerDisplayName = isPreviewMessageForParentChatReport ? payerName : actualPayerName; @@ -6091,7 +6091,8 @@ function getReportPreviewReportActionMessage(params: GetReportPreviewMessageBase } if (report.isWaitingOnBankAccount) { - const submitterDisplayName = getDisplayNameForParticipant({accountID: report.ownerAccountID, shouldUseShortForm: true, formatPhoneNumber: formatPhoneNumberPhoneUtils}) ?? ''; + const submitterDisplayName = + getDisplayNameForParticipant({accountID: report.ownerAccountID, shouldUseShortForm: true, formatPhoneNumber: formatPhoneNumberPhoneUtils, translate}) ?? ''; return `started payment, but is waiting for ${submitterDisplayName} to add a bank account.`; } @@ -6120,13 +6121,13 @@ function getReportPreviewReportActionMessage(params: GetReportPreviewMessageBase // 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}) + ? getDisplayNameForParticipant({accountID: lastActorID, shouldUseShortForm: !isPreviewMessageForParentChatReport, formatPhoneNumber: formatPhoneNumberPhoneUtils, translate}) : ''; return `${requestorName ? `${requestorName}: ` : ''}${amountToDisplay}${comment ? ` for ${comment}` : ''}`; } if (containsNonReimbursable) { - const ownerName = getDisplayNameForParticipant({accountID: report.ownerAccountID, formatPhoneNumber: formatPhoneNumberPhoneUtils}) ?? ''; + const ownerName = getDisplayNameForParticipant({accountID: report.ownerAccountID, formatPhoneNumber: formatPhoneNumberPhoneUtils, translate}) ?? ''; return `${ownerName} spent ${formattedAmount}`; } return `${payerName ?? ''} owes ${formattedAmount}${comment ? ` for ${comment}` : ''}`; @@ -7846,9 +7847,10 @@ function buildOptimisticReportPreview( childReportID?: string, reportActionID?: string, delegateAccountIDParam: number | undefined = undefined, + translate?: LocalizedTranslate, ): ReportAction { const hasReceipt = hasReceiptTransactionUtils(transaction); - const message = getReportPreviewReportActionMessage({reportOrID: iouReport}); + const message = getReportPreviewReportActionMessage({reportOrID: iouReport}, translate); 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 @@ -8024,6 +8026,7 @@ function updateReportPreview( isPayRequest = false, comment = '', transaction?: OnyxEntry, + translate?: LocalizedTranslate, ): ReportAction { const hasReceipt = hasReceiptTransactionUtils(transaction); const recentReceiptTransactions = reportPreviewAction?.childRecentReceiptTransactionIDs ?? {}; @@ -8039,7 +8042,7 @@ function updateReportPreview( } } - const message = getReportPreviewReportActionMessage({reportOrID: iouReport, iouReportAction: reportPreviewAction}); + const message = getReportPreviewReportActionMessage({reportOrID: iouReport, iouReportAction: reportPreviewAction}, translate); const originalMessage = getOriginalMessage(reportPreviewAction); return { ...reportPreviewAction, diff --git a/src/libs/actions/IOU/Duplicate.ts b/src/libs/actions/IOU/Duplicate.ts index 8fc4f5b9e09b..d8345f6d4821 100644 --- a/src/libs/actions/IOU/Duplicate.ts +++ b/src/libs/actions/IOU/Duplicate.ts @@ -981,6 +981,7 @@ function duplicateReport({ const newReportName = translate('common.copyOfReportName', sourceReportName); const {reportPreviewReportActionID, ...newReport} = createNewReport(ownerPersonalDetails, false, isASAPSubmitBetaEnabled, targetPolicy, betas, isTrackIntentUser, false, undefined, { reportName: newReportName, + translate, }); const isCrossWorkspace = !!sourceReport && sourceReport.policyID !== targetPolicy.id; diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 818e6f2fd2db..8deea468f3f2 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -4650,7 +4650,7 @@ function createWorkspaceFromIOUPayment({ message: [ { type: CONST.REPORT.MESSAGE.TYPE.TEXT, - text: ReportUtils.getReportPreviewReportActionMessage({reportOrID: expenseReport, policy: newWorkspace}), + text: ReportUtils.getReportPreviewReportActionMessage({reportOrID: expenseReport, policy: newWorkspace}, localeTranslate), }, ], created: DateUtils.getDBTime(), diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index d4b43bfde706..69d07e92f2e9 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -4093,18 +4093,33 @@ function navigateToConciergeChat( } } -function buildNewReportOptimisticData( - policy: OnyxEntry, - reportID: string, - reportActionID: string, - ownerPersonalDetails: CurrentUserPersonalDetails, - reportPreviewReportActionID: string, - hasViolationsParam: boolean, - isASAPSubmitBetaEnabled: boolean, - betas: OnyxEntry, - isTrackIntentUser: boolean | undefined, - reportName?: string, -) { +type BuildNewReportOptimisticDataParams = { + policy: OnyxEntry; + reportID: string; + reportActionID: string; + ownerPersonalDetails: CurrentUserPersonalDetails; + reportPreviewReportActionID: string; + hasViolationsParam: boolean; + isASAPSubmitBetaEnabled: boolean; + betas: OnyxEntry; + isTrackIntentUser: boolean | undefined; + reportName?: string; + translate?: LocalizedTranslate; +}; + +function buildNewReportOptimisticData({ + policy, + reportID, + reportActionID, + ownerPersonalDetails, + reportPreviewReportActionID, + hasViolationsParam, + isASAPSubmitBetaEnabled, + betas, + isTrackIntentUser, + reportName, + translate, +}: BuildNewReportOptimisticDataParams) { const {accountID, login, email} = ownerPersonalDetails; const timeOfCreation = DateUtils.getDBTime(); const parentReport = getPolicyExpenseChat(accountID, policy?.id); @@ -4143,7 +4158,7 @@ function buildNewReportOptimisticData( pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, }; - const message = getReportPreviewReportActionMessage({reportOrID: optimisticReportData}); + const message = getReportPreviewReportActionMessage({reportOrID: optimisticReportData}, translate); const createReportActionMessage = [ { html: message, @@ -4351,16 +4366,16 @@ function createNewReport( isTrackIntentUser: boolean | undefined, shouldNotifyNewAction = false, shouldDismissEmptyReportsConfirmation?: boolean, - options: {managedCardTransactionID?: string; reportName?: string} = {}, + options: {managedCardTransactionID?: string; reportName?: string; translate?: LocalizedTranslate} = {}, ) { - const {managedCardTransactionID, reportName} = options; + const {managedCardTransactionID, reportName, translate} = options; const optimisticReportID = generateReportID(); const reportActionID = rand64(); const reportPreviewReportActionID = rand64(); - const {parentReportID, reportPreviewAction, optimisticData, successData, failureData, optimisticReportData} = buildNewReportOptimisticData( + const {parentReportID, reportPreviewAction, optimisticData, successData, failureData, optimisticReportData} = buildNewReportOptimisticData({ policy, - optimisticReportID, + reportID: optimisticReportID, reportActionID, ownerPersonalDetails, reportPreviewReportActionID, @@ -4369,7 +4384,8 @@ function createNewReport( betas, isTrackIntentUser, reportName, - ); + translate, + }); if (shouldDismissEmptyReportsConfirmation) { Onyx.merge(ONYXKEYS.NVP_EMPTY_REPORTS_CONFIRMATION_DISMISSED, true); @@ -7360,6 +7376,7 @@ function buildOptimisticChangePolicyData({ optimisticPolicyExpenseChatReport, reportPreviewAction, isTrackIntentUser, + translate, }: { report: Report; parentReport: OnyxEntry; @@ -7375,6 +7392,7 @@ function buildOptimisticChangePolicyData({ optimisticPolicyExpenseChatReport?: Report; reportPreviewAction: OnyxEntry; isTrackIntentUser: boolean | undefined; + translate: LocalizedTranslate; }) { const optimisticData: Array< OnyxUpdate< @@ -7633,7 +7651,7 @@ function buildOptimisticChangePolicyData({ // and set it as a parent of the moved report const policyExpenseChat = optimisticPolicyExpenseChatReport ?? getPolicyExpenseChat(report.ownerAccountID, policy.id); // TODO: delegateAccountIDParam will be threaded in PR 15 (https://github.com/Expensify/App/issues/66425) - const optimisticReportPreviewAction = buildOptimisticReportPreview(policyExpenseChat, report, '', null, undefined, undefined, undefined); + const optimisticReportPreviewAction = buildOptimisticReportPreview(policyExpenseChat, report, '', null, undefined, undefined, undefined, translate); const newPolicyExpenseChatReportID = policyExpenseChat?.reportID; @@ -7876,6 +7894,7 @@ function changeReportPolicy({ isReportLastVisibleArchived = false, reportPreviewAction, isTrackIntentUser, + translate, }: { report: Report; parentReport: OnyxEntry; @@ -7891,6 +7910,7 @@ function changeReportPolicy({ isReportLastVisibleArchived?: boolean; reportPreviewAction: OnyxEntry; isTrackIntentUser: boolean | undefined; + translate: LocalizedTranslate; }) { if (!report || !policy || report.policyID === policy.id || !isExpenseReport(report)) { return; @@ -7910,6 +7930,7 @@ function changeReportPolicy({ reportNextStep, reportPreviewAction, isTrackIntentUser, + translate, }); const params = { @@ -7944,6 +7965,7 @@ function changeReportPolicyAndInviteSubmitter({ reportActionsList, reportPreviewAction, isTrackIntentUser, + translate, }: { report: Report; parentReport: OnyxEntry; @@ -7960,6 +7982,7 @@ function changeReportPolicyAndInviteSubmitter({ reportActionsList: OnyxCollection; reportPreviewAction: OnyxEntry; isTrackIntentUser: boolean | undefined; + translate: LocalizedTranslate; }) { if (!report.reportID || !policy?.id || report.policyID === policy.id || !isExpenseReport(report) || !report.ownerAccountID || !submitterLogin) { return; @@ -8014,6 +8037,7 @@ function changeReportPolicyAndInviteSubmitter({ optimisticPolicyExpenseChatReport: membersChats.reportCreationData[submitterLogin], reportPreviewAction, isTrackIntentUser, + translate, }); const optimisticData = [...optimisticAddMembersData, ...optimisticChangePolicyData]; diff --git a/src/pages/DynamicReportChangeWorkspacePage.tsx b/src/pages/DynamicReportChangeWorkspacePage.tsx index d62c3f6cd517..9b3268c6a5c2 100644 --- a/src/pages/DynamicReportChangeWorkspacePage.tsx +++ b/src/pages/DynamicReportChangeWorkspacePage.tsx @@ -153,6 +153,7 @@ function DynamicReportChangeWorkspacePage({report}: DynamicReportChangeWorkspace reportActionsList: filteredReportActions, reportPreviewAction, isTrackIntentUser, + translate, }); return; } @@ -172,6 +173,7 @@ function DynamicReportChangeWorkspacePage({report}: DynamicReportChangeWorkspace isReportLastVisibleArchived, reportPreviewAction, isTrackIntentUser, + translate, }); }; diff --git a/tests/actions/PolicyTest.ts b/tests/actions/PolicyTest.ts index ce4831f49289..37ee27cb281b 100644 --- a/tests/actions/PolicyTest.ts +++ b/tests/actions/PolicyTest.ts @@ -7362,6 +7362,78 @@ describe('actions/Policy', () => { isIOUReportUsingReportSpy.mockRestore(); }); + it('updates the old chat report preview action with a message built from the provided localeTranslate', async () => { + await Onyx.set(ONYXKEYS.SESSION, {email: ESH_EMAIL, accountID: ESH_ACCOUNT_ID}); + await waitForBatchedUpdates(); + + const employeeAccountID = 210; + const iouReport: Report = { + ...createRandomReport(2, undefined), + reportID: '510', + type: CONST.REPORT.TYPE.IOU, + ownerAccountID: employeeAccountID, + chatReportID: '511', + policyID: 'oldPolicyID', + currency: CONST.CURRENCY.USD, + total: 1000, + isWaitingOnBankAccount: false, + }; + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, iouReport); + await waitForBatchedUpdates(); + + const previewActionID = 'preview-action-510'; + const reportPreviewAction = createMock({ + reportActionID: previewActionID, + actionName: CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW, + created: '2026-01-01 00:00:00', + }); + + const apiWriteSpy = jest.spyOn(APIModule, 'write').mockImplementation(() => Promise.resolve()); + const isIOUReportUsingReportSpy = jest.spyOn(ReportUtils, 'isIOUReportUsingReport').mockReturnValue(true); + + Policy.createWorkspaceFromIOUPayment({ + iouReport, + reportPreviewAction, + currentUserAccountID: 999, + currentUserEmail: 'custom@example.com', + iouReportOwnerEmail: 'owner@example.com', + currentUserLocalCurrency: CONST.CURRENCY.USD, + lastWorkspaceNumber: undefined, + localeTranslate: TestHelper.translateLocal, + reportActionsList: {}, + doesEmployeePersonalDetailExist: false, + }); + await waitForBatchedUpdates(); + + const workspaceFromIOUWriteCall = apiWriteSpy.mock.calls.find((call) => call.at(0) === WRITE_COMMANDS.CREATE_WORKSPACE_FROM_IOU_PAYMENT); + const writeOptions = requireRecord(requireCallArgument(workspaceFromIOUWriteCall, 2)); + + // The old chat's report preview action gets an optimistic message rebuilt via getReportPreviewReportActionMessage with the provided localeTranslate. + // The move also nulls the action under the old chat, so only match the update that carries the rebuilt action object. + const previewUpdate = requireRecordArrayProperty(writeOptions, 'optimisticData').find((update) => { + const actionValue = readProperty(readProperty(update, 'value'), previewActionID); + return typeof actionValue === 'object' && actionValue !== null; + }); + expect(previewUpdate).toBeDefined(); + + const updatedAction = requireRecord(readProperty(readProperty(previewUpdate, 'value'), previewActionID)); + const messages: unknown = readProperty(updatedAction, 'message'); + const firstMessage: unknown = Array.isArray(messages) ? messages.at(0) : undefined; + + // The rebuilt preview keeps the hardcoded-English copy for the moved expense report + expect(requireStringProperty(firstMessage, 'text')).toContain('owes'); + + // And the failure data clears the optimistic preview action from the new workspace chat again + const failureUpdate = requireRecordArrayProperty(writeOptions, 'failureData').find((update) => { + const value = readProperty(update, 'value'); + return value !== null && typeof value === 'object' && previewActionID in value && readProperty(value, previewActionID) === null; + }); + expect(failureUpdate).toBeDefined(); + + apiWriteSpy.mockRestore(); + isIOUReportUsingReportSpy.mockRestore(); + }); + it('should return undefined for non-IOU reports', () => { const nonIOUReport: Report = { ...createRandomReport(1, undefined), diff --git a/tests/actions/ReportTest.ts b/tests/actions/ReportTest.ts index 2081b4d7af6a..8939b58ee907 100644 --- a/tests/actions/ReportTest.ts +++ b/tests/actions/ReportTest.ts @@ -3497,6 +3497,7 @@ describe('actions/Report', () => { // When moving to another workspace Report.changeReportPolicy({ + translate: TestHelper.translateLocal, report: expenseReport, parentReport: undefined, policy: newPolicy, @@ -3557,6 +3558,7 @@ describe('actions/Report', () => { // When moving to another workspace Report.changeReportPolicy({ + translate: TestHelper.translateLocal, report: expenseReport, parentReport, policy: newPolicy, @@ -3627,6 +3629,7 @@ describe('actions/Report', () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${newPolicy.id}`, newPolicy); Report.changeReportPolicy({ + translate: TestHelper.translateLocal, report: expenseReport, parentReport: undefined, policy: newPolicy, @@ -3723,6 +3726,7 @@ describe('actions/Report', () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${newPolicy.id}`, newPolicy); Report.changeReportPolicy({ + translate: TestHelper.translateLocal, report: expenseReport, parentReport: undefined, policy: newPolicy, @@ -3807,6 +3811,7 @@ describe('actions/Report', () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${newPolicy.id}`, newPolicy); Report.changeReportPolicy({ + translate: TestHelper.translateLocal, report: expenseReport, parentReport: undefined, policy: newPolicy, @@ -3859,6 +3864,7 @@ describe('actions/Report', () => { // When moving to another workspace Report.changeReportPolicyAndInviteSubmitter({ + translate: TestHelper.translateLocal, report: expenseReport, parentReport: undefined, policy: createRandomPolicy(Number(2)), @@ -3951,6 +3957,7 @@ describe('actions/Report', () => { // Call changeReportPolicyAndInviteSubmitter Report.changeReportPolicyAndInviteSubmitter({ + translate: TestHelper.translateLocal, report: expenseReport, parentReport: undefined, policy: newPolicy, @@ -3996,6 +4003,7 @@ describe('actions/Report', () => { }; Report.changeReportPolicyAndInviteSubmitter({ + translate: TestHelper.translateLocal, report: expenseReport, parentReport: undefined, policy: createRandomPolicy(Number(2)), @@ -4027,6 +4035,7 @@ describe('actions/Report', () => { }; Report.changeReportPolicyAndInviteSubmitter({ + translate: TestHelper.translateLocal, report: expenseReport, parentReport: undefined, policy: targetPolicy, @@ -4057,6 +4066,7 @@ describe('actions/Report', () => { }; Report.changeReportPolicyAndInviteSubmitter({ + translate: TestHelper.translateLocal, report: expenseReport, parentReport: undefined, policy: createRandomPolicy(Number(2)), @@ -4087,6 +4097,7 @@ describe('actions/Report', () => { }; Report.changeReportPolicyAndInviteSubmitter({ + translate: TestHelper.translateLocal, report: expenseReport, parentReport: undefined, policy: createRandomPolicy(Number(2)), @@ -4119,6 +4130,7 @@ describe('actions/Report', () => { // Do not set personal details for ownerAccountID so getLoginByAccountID returns empty Report.changeReportPolicyAndInviteSubmitter({ + translate: TestHelper.translateLocal, report: expenseReport, parentReport: undefined, policy: createRandomPolicy(Number(2)), @@ -4172,6 +4184,7 @@ describe('actions/Report', () => { mockFetch.pause?.(); Report.changeReportPolicyAndInviteSubmitter({ + translate: TestHelper.translateLocal, report: expenseReport, parentReport: undefined, policy: targetPolicy, @@ -4620,6 +4633,7 @@ describe('actions/Report', () => { }; const policy = createRandomPolicy(Number(1)); Report.buildOptimisticChangePolicyData({ + translate: TestHelper.translateLocal, report, parentReport: undefined, policy, @@ -4674,6 +4688,7 @@ describe('actions/Report', () => { await waitForBatchedUpdates(); const {optimisticData, successData, failureData} = Report.buildOptimisticChangePolicyData({ + translate: TestHelper.translateLocal, report, parentReport: undefined, policy, @@ -4738,6 +4753,7 @@ describe('actions/Report', () => { await waitForBatchedUpdates(); const {optimisticData} = Report.buildOptimisticChangePolicyData({ + translate: TestHelper.translateLocal, report, parentReport: undefined, policy, @@ -4786,6 +4802,7 @@ describe('actions/Report', () => { await waitForBatchedUpdates(); const {optimisticData} = Report.buildOptimisticChangePolicyData({ + translate: TestHelper.translateLocal, report, parentReport: undefined, policy, @@ -4847,6 +4864,7 @@ describe('actions/Report', () => { await waitForBatchedUpdates(); const {optimisticData} = Report.buildOptimisticChangePolicyData({ + translate: TestHelper.translateLocal, report, parentReport: undefined, policy, @@ -4911,6 +4929,7 @@ describe('actions/Report', () => { const policy = createRandomPolicy(Number(1)); const {optimisticData, failureData} = Report.buildOptimisticChangePolicyData({ + translate: TestHelper.translateLocal, report, parentReport, policy, diff --git a/tests/unit/CreateNewReportTranslateTest.ts b/tests/unit/CreateNewReportTranslateTest.ts new file mode 100644 index 000000000000..3d6353e8f00e --- /dev/null +++ b/tests/unit/CreateNewReportTranslateTest.ts @@ -0,0 +1,66 @@ +import {createNewReport} from '@libs/actions/Report'; +import {getReportPreviewReportActionMessage} from '@libs/ReportUtils'; + +import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy} from '@src/types/onyx'; + +import Onyx from 'react-native-onyx'; + +import * as TestHelper from '../utils/TestHelper'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +jest.mock('@libs/ReportUtils', () => { + // jest.requireActual is typed as returning `any`, so this assignment is unavoidably unsafe. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const actual = jest.requireActual('@libs/ReportUtils'); + // Spreading the actual (untyped `any`) module into the mock makes the return intentionally unsafe. + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...actual, + __esModule: true, + getReportPreviewReportActionMessage: jest.fn(() => 'mock preview message'), + }; +}); + +const mockGetReportPreviewReportActionMessage = jest.mocked(getReportPreviewReportActionMessage); + +const policy: Policy = { + id: 'policy-translate-1', + name: 'Translate Test Policy', + role: 'admin', + type: CONST.POLICY.TYPE.TEAM, + owner: 'owner@test.com', + outputCurrency: CONST.CURRENCY.USD, + isPolicyExpenseChatEnabled: true, +}; + +describe('createNewReport translate threading', () => { + beforeAll(async () => { + Onyx.init({keys: ONYXKEYS}); + global.fetch = TestHelper.getGlobalFetchMock(); + IntlStore.load(CONST.LOCALES.EN); + await waitForBatchedUpdates(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('passes the provided translate function through to the stored report preview message', () => { + createNewReport({accountID: 1, email: 'owner@test.com'}, false, false, policy, [], false, false, undefined, {translate: TestHelper.translateLocal}); + + // The optimistic REPORT_PREVIEW message is built with the injected translate rather than the deprecated global + expect(mockGetReportPreviewReportActionMessage).toHaveBeenCalledTimes(1); + expect(mockGetReportPreviewReportActionMessage.mock.calls.at(0)?.[1]).toBe(TestHelper.translateLocal); + }); + + it('falls back to the deprecated translation global when no translate is provided', () => { + createNewReport({accountID: 1, email: 'owner@test.com'}, false, false, policy, [], false); + + // Callers that have not been migrated yet omit translate, so the preview message keeps the global-driven path + expect(mockGetReportPreviewReportActionMessage).toHaveBeenCalledTimes(1); + expect(mockGetReportPreviewReportActionMessage.mock.calls.at(0)?.[1]).toBeUndefined(); + }); +}); diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 40db9ca871ee..57767154637f 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -12762,6 +12762,38 @@ describe('ReportUtils', () => { expect(reportPreviewAction.childOwnerAccountID).toBe(iouReport.ownerAccountID); expect(reportPreviewAction.childManagerAccountID).toBe(iouReport.managerID); }); + + it('resolves the preview message participant names through the injected translate function', async () => { + const hiddenManagerAccountID = 357911; + const chatReport: Report = { + ...createRandomReport(101, undefined), + type: CONST.REPORT.TYPE.CHAT, + }; + const iouReport: Report = { + ...createRandomReport(201, undefined), + parentReportID: '1', + type: CONST.REPORT.TYPE.IOU, + ownerAccountID: 1, + managerID: hiddenManagerAccountID, + currency: CONST.CURRENCY.USD, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + isWaitingOnBankAccount: false, + }; + // A participant with no name resolves to the "hidden" copy, which is produced by the injected translate + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, { + [hiddenManagerAccountID]: {accountID: hiddenManagerAccountID, login: '', displayName: ''}, + }); + await waitForBatchedUpdates(); + + const translateWithMarker: LocalizedTranslate = (path, ...parameters) => (path === 'common.hidden' ? 'HiddenPreviewMarker' : translateLocal(path, ...parameters)); + + const reportPreviewAction = buildOptimisticReportPreview(chatReport, iouReport, '', null, undefined, undefined, undefined, translateWithMarker); + + // The stored preview message resolves the payer through the provided translate, proving the pass-through works + const [message] = Array.isArray(reportPreviewAction.message) ? reportPreviewAction.message : []; + expect(message?.text).toContain('HiddenPreviewMarker'); + }); }); describe('updateReportPreview', () => { @@ -12795,6 +12827,39 @@ describe('ReportUtils', () => { expect(updatedPreviewAction.childLastActorAccountID).toBe(currentUserAccountID); }); + + it('resolves the updated preview message participant names through the injected translate function', async () => { + const hiddenManagerAccountID = 357912; + const chatReport: Report = { + ...createRandomReport(102, undefined), + type: CONST.REPORT.TYPE.CHAT, + }; + const iouReport: Report = { + ...createRandomReport(202, undefined), + parentReportID: '1', + type: CONST.REPORT.TYPE.IOU, + ownerAccountID: 1, + managerID: hiddenManagerAccountID, + currency: CONST.CURRENCY.USD, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + isWaitingOnBankAccount: false, + }; + // A participant with no name resolves to the "hidden" copy, which is produced by the injected translate + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, { + [hiddenManagerAccountID]: {accountID: hiddenManagerAccountID, login: '', displayName: ''}, + }); + await waitForBatchedUpdates(); + + const translateWithMarker: LocalizedTranslate = (path, ...parameters) => (path === 'common.hidden' ? 'HiddenUpdateMarker' : translateLocal(path, ...parameters)); + + const reportPreviewAction = buildOptimisticReportPreview(chatReport, iouReport); + const updatedPreviewAction = updateReportPreview(iouReport, reportPreviewAction, false, '', undefined, translateWithMarker); + + // The refreshed preview message resolves the payer through the provided translate, proving the pass-through works + const [message] = Array.isArray(updatedPreviewAction.message) ? updatedPreviewAction.message : []; + expect(message?.text).toContain('HiddenUpdateMarker'); + }); }); describe('compute (Formula.ts for optimistic report names)', () => { @@ -16395,8 +16460,10 @@ describe('ReportUtils', () => { 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 + // The hardcoded English copy must not drift from the localized function, whether the participant-name + // translate is injected or comes from the deprecated global fallback expect(getReportPreviewReportActionMessage(params)).toBe(getReportPreviewMessage(englishTranslate, params)); + expect(getReportPreviewReportActionMessage(params, englishTranslate)).toBe(getReportPreviewMessage(englishTranslate, params)); }); }); @@ -16478,6 +16545,126 @@ describe('ReportUtils', () => { expect(result).toBe(getReportPreviewMessage(englishTranslate, {reportOrID: report})); expect(result).toContain('owes'); }); + + it('resolves participant display names through the injected translate function', async () => { + const hiddenManagerAccountID = 246810; + const iouReport: Report = { + ...LHNTestUtils.getFakeReport(), + reportID: 'preview-action-marker-report', + type: CONST.REPORT.TYPE.IOU, + currency: CONST.CURRENCY.USD, + managerID: hiddenManagerAccountID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, iouReport); + // A participant with no name resolves to the "hidden" copy, which is produced by the injected translate + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, { + [hiddenManagerAccountID]: {accountID: hiddenManagerAccountID, login: '', displayName: ''}, + }); + + // A translate that tags the hidden-participant fallback so we can prove the stored message used it + const translateWithMarker: LocalizedTranslate = (path, ...parameters) => + path === 'common.hidden' ? 'HiddenPreviewMarker' : translate(CONST.LOCALES.EN, path, ...parameters); + + const result = getReportPreviewReportActionMessage({reportOrID: iouReport}, translateWithMarker); + + // The manager's name resolves to the marker while the surrounding copy stays hardcoded English + expect(result).toContain('HiddenPreviewMarker'); + expect(result).toContain('owes'); + }); + + it('falls back to the deprecated translation global when no translate is provided', async () => { + const hiddenManagerAccountID = 246811; + const iouReport: Report = { + ...LHNTestUtils.getFakeReport(), + reportID: 'preview-action-fallback-report', + type: CONST.REPORT.TYPE.IOU, + currency: CONST.CURRENCY.USD, + managerID: hiddenManagerAccountID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, iouReport); + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, { + [hiddenManagerAccountID]: {accountID: hiddenManagerAccountID, login: '', displayName: ''}, + }); + + const englishTranslate: LocalizedTranslate = (path, ...parameters) => translate(CONST.LOCALES.EN, path, ...parameters); + + // Callers that haven't been migrated yet omit translate and keep the previous global-driven output + expect(getReportPreviewReportActionMessage({reportOrID: iouReport})).toBe(getReportPreviewReportActionMessage({reportOrID: iouReport}, englishTranslate)); + }); + + it('resolves the latest-expense requestor name through the injected translate function', async () => { + const hiddenRequestorAccountID = 246812; + const iouReport: Report = { + ...LHNTestUtils.getFakeReport(), + reportID: 'preview-action-requestor-report', + type: CONST.REPORT.TYPE.IOU, + currency: CONST.CURRENCY.USD, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, iouReport); + // The last actor has no name, so their slot resolves to the "hidden" copy produced by the injected translate + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, { + [hiddenRequestorAccountID]: {accountID: hiddenRequestorAccountID, login: '', displayName: ''}, + }); + const iouReportAction: ReportAction = { + ...LHNTestUtils.getFakeReportAction(), + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + actorAccountID: hiddenRequestorAccountID, + originalMessage: { + IOUReportID: iouReport.reportID, + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + amount: 12300, + currency: CONST.CURRENCY.USD, + }, + }; + + const translateWithMarker: LocalizedTranslate = (path, ...parameters) => + path === 'common.hidden' ? 'HiddenRequestorMarker' : translate(CONST.LOCALES.EN, path, ...parameters); + + const result = getReportPreviewReportActionMessage({reportOrID: iouReport, iouReportAction}, translateWithMarker); + + // The latest-expense preview prefixes the requestor resolved through the provided translate + expect(result).toBe('HiddenRequestorMarker: $123.00'); + }); + + it('resolves the owner name of non-reimbursable spend through the injected translate function', async () => { + const hiddenOwnerAccountID = 246813; + const iouReport: Report = { + ...LHNTestUtils.getFakeReport(), + reportID: 'preview-action-owner-report', + type: CONST.REPORT.TYPE.IOU, + currency: CONST.CURRENCY.USD, + ownerAccountID: hiddenOwnerAccountID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, iouReport); + // The report owner has no name, so their slot resolves to the "hidden" copy produced by the injected translate + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, { + [hiddenOwnerAccountID]: {accountID: hiddenOwnerAccountID, login: '', displayName: ''}, + }); + // A non-reimbursable transaction routes the preview into the "spent" branch + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}preview-owner-transaction`, { + transactionID: 'preview-owner-transaction', + reportID: iouReport.reportID, + reimbursable: false, + amount: 100, + currency: CONST.CURRENCY.USD, + }); + await waitForBatchedUpdates(); + + const translateWithMarker: LocalizedTranslate = (path, ...parameters) => (path === 'common.hidden' ? 'HiddenOwnerMarker' : translate(CONST.LOCALES.EN, path, ...parameters)); + + const result = getReportPreviewReportActionMessage({reportOrID: iouReport}, translateWithMarker); + + // The non-reimbursable preview resolves the owner through the provided translate + expect(result).toContain('HiddenOwnerMarker spent'); + }); }); });