diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 8c12f8a3cfc6..4aa61d502442 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1754,6 +1754,7 @@ const CONST = { MERGED_WITH_CASH_TRANSACTION: 'MERGEDWITHCASHTRANSACTION', MODIFIED_EXPENSE: 'MODIFIEDEXPENSE', CONCIERGE_AUTO_MATCH_VENDOR: 'CONCIERGEAUTOMATCHVENDOR', + CONCIERGE_AUTO_SELECT_DISTANCE_RATE: 'CONCIERGEAUTOSELECTDISTANCERATE', MOVED: 'MOVED', MOVED_TRANSACTION: 'MOVEDTRANSACTION', UNREPORTED_TRANSACTION: 'UNREPORTEDTRANSACTION', @@ -5653,7 +5654,9 @@ const CONST = { OTHER_INVISIBLE_CHARACTERS: /[\u3164\u115f\u1160\uffa0\u2800]/g, SHORT_MENTION_HTML: /(.*?)<\/mention-short>/g, REPORT_ID_FROM_PATH: /(? = { prompt: 'Aktivieren Sie Tags im Workspace, um die Ausgabendetails zu bearbeiten oder den Tag aus dieser Ausgabe zu löschen.', confirmText: 'Tag löschen', }, + conciergeAutoSelectedDistanceRates: ({policyName}: {policyName: string}) => `Kilometersätze für den neuen Arbeitsbereich aktualisiert – ${policyName}`, }, transactionMerge: { listPage: { diff --git a/src/languages/el.ts b/src/languages/el.ts index ee582c483be2..06878a7ace80 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -1923,6 +1923,7 @@ const translations: TranslationDeepObject = { whatIsHoldExplainDM: 'Η αναμονή είναι σαν να πατάτε «παύση» σε μία δαπάνη μέχρι να είστε έτοιμοι να τη στείλετε.', holdIsLeftBehindDM: 'Οι δεσμευμένες δαπάνες δεν θα αποσταλούν μέχρι να καταργήσετε τη δέσμευση.', unholdWhenReadyDM: 'Αποδεσμεύστε τις δαπάνες όταν είστε έτοιμοι να τις στείλετε.', + conciergeAutoSelectedDistanceRates: ({policyName}: {policyName: string}) => `οι τιμές αποζημίωσης χιλιομέτρων ενημερώθηκαν για το νέο χώρο εργασίας - ${policyName}`, }, transactionMerge: { listPage: { diff --git a/src/languages/en.ts b/src/languages/en.ts index ae8676e96fc7..b4271d4e2f8f 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1919,6 +1919,7 @@ const translations = { correctRateError: 'Fix the rate error and try again.', AskToExplain: `. Explain`, conciergeAutoMatchedVendor: ({vendorName}: {vendorName: string}) => `Concierge matched this expense to ${vendorName}`, + conciergeAutoSelectedDistanceRates: ({policyName}: {policyName: string}) => `distance rates updated for the new workspace - ${policyName}`, rulesModifiedFields: { reimbursable: (value: boolean) => (value ? 'marked the expense as "reimbursable"' : 'marked the expense as "non-reimbursable"'), billable: (value: boolean) => (value ? 'marked the expense as "billable"' : 'marked the expense as "non-billable"'), diff --git a/src/languages/es.ts b/src/languages/es.ts index e8f4094813d5..69226fecd510 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1864,6 +1864,7 @@ const translations: TranslationDeepObject = { prompt: 'Habilita las etiquetas en el espacio de trabajo para editar los detalles del gasto o eliminar la etiqueta de este gasto.', confirmText: 'Eliminar etiqueta', }, + conciergeAutoSelectedDistanceRates: ({policyName}: {policyName: string}) => `se han actualizado las tarifas de kilometraje para el nuevo espacio de trabajo: ${policyName}`, }, transactionMerge: { listPage: { diff --git a/src/languages/fr.ts b/src/languages/fr.ts index c57e018546c4..fd2d5254a04c 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1879,6 +1879,7 @@ const translations: TranslationDeepObject = { prompt: 'Active les tags dans l’espace de travail pour modifier les détails de la dépense ou supprimer le tag de cette dépense.', confirmText: 'Supprimer le tag', }, + conciergeAutoSelectedDistanceRates: ({policyName}: {policyName: string}) => `les taux kilométriques ont été mis à jour pour le nouvel espace de travail - ${policyName}`, }, transactionMerge: { listPage: { diff --git a/src/languages/it.ts b/src/languages/it.ts index 99ae8352bfc0..7bc55e620d25 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1869,6 +1869,7 @@ const translations: TranslationDeepObject = { prompt: 'Abilita le etichette nello spazio di lavoro per modificare i dettagli della spesa o eliminare l’etichetta da questa spesa.', confirmText: 'Elimina tag', }, + conciergeAutoSelectedDistanceRates: ({policyName}: {policyName: string}) => `tariffe chilometriche aggiornate per il nuovo spazio di lavoro - ${policyName}`, }, transactionMerge: { listPage: { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index c0450874eb61..6fc27abaa4d1 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1851,6 +1851,7 @@ const translations: TranslationDeepObject = { prompt: 'ワークスペースでタグを有効にすると、この経費の詳細を編集したり、この経費からタグを削除したりできます。', confirmText: 'タグを削除', }, + conciergeAutoSelectedDistanceRates: ({policyName}: {policyName: string}) => `新しいワークスペース「${policyName}」の距離単価を更新しました`, }, transactionMerge: { listPage: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 510de3dc2229..6052b6346be2 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1865,6 +1865,7 @@ const translations: TranslationDeepObject = { prompt: 'Schakel tags in op de werkruimte om de onkostendetails te bewerken of de tag uit deze onkosten te verwijderen.', confirmText: 'Label verwijderen', }, + conciergeAutoSelectedDistanceRates: ({policyName}: {policyName: string}) => `kilometervergoedingen bijgewerkt voor de nieuwe workspace - ${policyName}`, }, transactionMerge: { listPage: { diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 9403c8a34383..bdd7458b2612 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1900,6 +1900,7 @@ const translations: TranslationDeepObject = { prompt: 'Włącz tagi w przestrzeni roboczej, aby edytować szczegóły wydatku lub usunąć ten tag z tego wydatku.', confirmText: 'Usuń znacznik', }, + conciergeAutoSelectedDistanceRates: ({policyName}: {policyName: string}) => `stawki za przejechany dystans zaktualizowane dla nowej przestrzeni roboczej – ${policyName}`, }, transactionMerge: { listPage: { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 7cde0d05774a..393bce145a5c 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1860,6 +1860,7 @@ const translations: TranslationDeepObject = { confirmText: 'Excluir categoria', }, tagDisabledAlert: {title: 'Tag desativada', prompt: 'Ative as tags no workspace para editar os detalhes da despesa ou excluir a tag desta despesa.', confirmText: 'Excluir tag'}, + conciergeAutoSelectedDistanceRates: ({policyName}: {policyName: string}) => `taxas de distância atualizadas para o novo workspace - ${policyName}`, }, transactionMerge: { listPage: { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index e4c4427028c2..f13b46be335a 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1793,6 +1793,7 @@ const translations: TranslationDeepObject = { deleteConfirmationSomePendingBYOC: '您确定要删除这些报销吗?其中一些处于待处理状态,如果入账后,我们可能会再次导入。', categoryDisabledAlert: {title: '类别已禁用', prompt: '在工作区中启用类别,以编辑报销详情或从此报销中删除该类别。', confirmText: '删除类别'}, tagDisabledAlert: {title: '标签已停用', prompt: '请在工作区中启用标签,以便编辑该报销的详细信息或从此报销中删除该标签。', confirmText: '删除标签'}, + conciergeAutoSelectedDistanceRates: ({policyName}: {policyName: string}) => `已为新工作区更新里程报销标准 - ${policyName}`, }, transactionMerge: { listPage: { diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 7bc22a1debc1..bd42588a12de 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -2525,6 +2525,12 @@ function getReportActionMessageFragments(translate: LocalizedTranslate, action: return [{text: message, html: `${message}`, type: 'COMMENT'}]; } + if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.CONCIERGE_AUTO_SELECT_DISTANCE_RATE)) { + const message = getConciergeAutoSelectDistanceRateMessage(translate, action); + // The helper returns plain text, so only the html fragment is encoded — a workspace name containing an entity like `©` would otherwise be parsed as markup. + return [{text: message, html: `${Str.htmlEncode(message)}`, type: 'COMMENT'}]; + } + if (isDynamicExternalWorkflowSubmitFailedAction(action)) { const failedSubmitReason = getDynamicExternalWorkflowSubmitFailedActionMessage(translate, action); return [{text: failedSubmitReason, html: `${failedSubmitReason}`, type: 'COMMENT'}]; @@ -3431,6 +3437,19 @@ function getWorkspaceCustomUnitRateUpdatedMessage(translate: LocalizedTranslate, return getReportActionText(action); } +/** + * Builds the Concierge system message explaining that the distance rates of a report's expenses were re-selected automatically. + */ +function getConciergeAutoSelectDistanceRateMessage(translate: LocalizedTranslate, action: ReportAction): string { + const policyName = isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.CONCIERGE_AUTO_SELECT_DISTANCE_RATE) ? getOriginalMessage(action)?.policyName : undefined; + + if (!policyName) { + return getReportActionText(action); + } + + return translate('iou.conciergeAutoSelectedDistanceRates', {policyName}); +} + function getWorkspaceCustomUnitRateDeletedMessage(translate: LocalizedTranslate, action: ReportAction): string { const {customUnitName, rateName} = getOriginalMessage(action as ReportAction) ?? {}; if (customUnitName && rateName) { @@ -5064,6 +5083,7 @@ export { getRemovedFromApprovalChainMessage, getDemotedFromWorkspaceMessage, getDynamicExternalWorkflowRoutedMessage, + getConciergeAutoSelectDistanceRateMessage, getReportAction, getReportActionHtml, getReportActionMessage, diff --git a/src/libs/ReportAlternateTextUtils.ts b/src/libs/ReportAlternateTextUtils.ts index 79bcc3b488cb..c5bfadefe44e 100644 --- a/src/libs/ReportAlternateTextUtils.ts +++ b/src/libs/ReportAlternateTextUtils.ts @@ -56,6 +56,7 @@ import { getCompanyAddressUpdateMessage, getCompanyCardConnectionBroken30DaysMessage, getCompanyCardConnectionBrokenMessage, + getConciergeAutoSelectDistanceRateMessage, getCurrencyConversionFeeMessage, getCurrencyDefaultTaxUpdateMessage, getCustomTaxNameUpdateMessage, @@ -763,6 +764,8 @@ function getLastMessageTextForReport({ lastMessageTextFromReport = Parser.htmlToText(getActionableMentionWhisperMessage(translate, lastReportAction, getPersonalDetailsListByIDs(targetAccountIDs, personalDetails))); } else if (isActionOfType(lastReportAction, CONST.REPORT.ACTIONS.TYPE.DYNAMIC_EXTERNAL_WORKFLOW_ROUTED)) { lastMessageTextFromReport = getDynamicExternalWorkflowRoutedMessage(lastReportAction, translate); + } else if (isActionOfType(lastReportAction, CONST.REPORT.ACTIONS.TYPE.CONCIERGE_AUTO_SELECT_DISTANCE_RATE)) { + lastMessageTextFromReport = getConciergeAutoSelectDistanceRateMessage(translate, lastReportAction); } if (isActionOfType(lastReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MAX_EXPENSE_AMOUNT)) { lastMessageTextFromReport = getPolicyChangeLogMaxExpenseAmountMessage(translate, lastReportAction, convertToDisplayString); diff --git a/src/libs/ReportNameUtils.ts b/src/libs/ReportNameUtils.ts index 416fe057a550..7ee06eea6b39 100644 --- a/src/libs/ReportNameUtils.ts +++ b/src/libs/ReportNameUtils.ts @@ -52,6 +52,7 @@ import { getCompanyAddressUpdateMessage, getCompanyCardConnectionBroken30DaysMessage, getCompanyCardConnectionBrokenMessage, + getConciergeAutoSelectDistanceRateMessage, getCreatedReportForUnapprovedTransactionsMessage, getCrossBorderReimbursedMessage, getCurrencyConversionFeeMessage, @@ -892,6 +893,10 @@ function computeReportNameBasedOnReportAction({ return getTravelUpdateMessage(translate, parentReportAction); } + if (isActionOfType(parentReportAction, CONST.REPORT.ACTIONS.TYPE.CONCIERGE_AUTO_SELECT_DISTANCE_RATE)) { + return getConciergeAutoSelectDistanceRateMessage(translate, parentReportAction); + } + if (isActionOfType(parentReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_CUSTOM_UNIT_RATE)) { return getWorkspaceCustomUnitRateAddedMessage(translate, dateFnsLocale, parentReportAction); } diff --git a/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx b/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx index 0c8f3c2c80fc..1b3cfcf86149 100644 --- a/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx +++ b/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx @@ -43,6 +43,7 @@ import { getCompanyAddressUpdateMessage, getCompanyCardConnectionBroken30DaysMessage, getCompanyCardConnectionBrokenMessage, + getConciergeAutoSelectDistanceRateMessage, getCreatedReportForUnapprovedTransactionsMessage, getCurrencyConversionFeeMessage, getCurrencyDefaultTaxUpdateMessage, @@ -1379,6 +1380,9 @@ const ContextMenuActions: ContextMenuAction[] = [ setClipboardMessage(getUpdatedCardFeedStatementPeriodMessage(translate, reportAction)); } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.TRAVEL_UPDATE)) { setClipboardMessage(getTravelUpdateMessage(translate, reportAction)); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.CONCIERGE_AUTO_SELECT_DISTANCE_RATE)) { + // setClipboardMessage treats its argument as HTML, and the helper returns plain text, so encode it to copy a workspace name containing an entity literally. + setClipboardMessage(Str.htmlEncode(getConciergeAutoSelectDistanceRateMessage(translate, reportAction))); } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AUDIT_RATE)) { setClipboardMessage(getUpdatedAuditRateMessage(translate, reportAction)); } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_APPROVER_RULE)) { diff --git a/src/pages/inbox/report/actionContents/ActionContentRouter.tsx b/src/pages/inbox/report/actionContents/ActionContentRouter.tsx index e02e80162456..8d6a20525ced 100644 --- a/src/pages/inbox/report/actionContents/ActionContentRouter.tsx +++ b/src/pages/inbox/report/actionContents/ActionContentRouter.tsx @@ -21,6 +21,7 @@ import { getCompanyCardConnectionBroken30DaysMessage, getCommuterExclusionMessage, getCompanyCardConnectionBrokenMessage, + getConciergeAutoSelectDistanceRateMessage, getDelegateSubmitMessage, getForwardedReportActionMessage, getIOUReportIDFromReportActionPreview, @@ -61,6 +62,7 @@ import type * as OnyxTypes from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; +import {Str} from 'expensify-common'; import React from 'react'; import ApprovalFlowContent, {isApprovalFlowAction} from './ApprovalFlowContent'; @@ -360,6 +362,14 @@ function ActionContentRouter({ ); } + if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.CONCIERGE_AUTO_SELECT_DISTANCE_RATE)) { + return ( + + {/* The helper returns plain text, so encode it before it becomes HTML or a workspace name containing an entity like `©` would be parsed as markup. */} + ${Str.htmlEncode(getConciergeAutoSelectDistanceRateMessage(translate, action))}`} /> + + ); + } if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.TRAVEL_NUDGE)) { return ( diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts index 96b413bacb97..5d9cda9f004d 100644 --- a/src/types/onyx/OriginalMessage.ts +++ b/src/types/onyx/OriginalMessage.ts @@ -955,6 +955,16 @@ type OriginalMessageConciergeAutoMatchVendor = { reasoning?: string; }; +/** + * Model of `concierge auto select distance rate` report action — posted on an expense report when the report's workspace changes and the distance rates of its expenses are + * re-selected automatically. The individual rate changes are described by a `MODIFIED_EXPENSE` action on each expense's transaction thread, so this action names no rate itself: + * one report can hold many distance expenses, and each can end up on a different rate. + */ +type OriginalMessageConciergeAutoSelectDistanceRate = { + /** Name of the workspace the report was moved to, whose rates were applied */ + policyName?: string; +}; + /** Policy rules modified fields. Each member holds the new value the rule wrote, not the current one */ type PolicyRulesModifiedFields = { merchant?: string; @@ -1615,6 +1625,7 @@ type OriginalMessageMap = { [CONST.REPORT.ACTIONS.TYPE.MERGED_WITH_CASH_TRANSACTION]: never; [CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE]: OriginalMessageModifiedExpense; [CONST.REPORT.ACTIONS.TYPE.CONCIERGE_AUTO_MATCH_VENDOR]: OriginalMessageConciergeAutoMatchVendor; + [CONST.REPORT.ACTIONS.TYPE.CONCIERGE_AUTO_SELECT_DISTANCE_RATE]: OriginalMessageConciergeAutoSelectDistanceRate; [CONST.REPORT.ACTIONS.TYPE.MOVED]: OriginalMessageMoved; [CONST.REPORT.ACTIONS.TYPE.MOVED_TRANSACTION]: OriginalMessageMovedTransaction; [CONST.REPORT.ACTIONS.TYPE.UNREPORTED_TRANSACTION]: OriginalMessageUnreportedTransaction; diff --git a/tests/unit/ModifiedExpenseMessageTest.ts b/tests/unit/ModifiedExpenseMessageTest.ts index 5de3d626f9a2..13aceb1d4b61 100644 --- a/tests/unit/ModifiedExpenseMessageTest.ts +++ b/tests/unit/ModifiedExpenseMessageTest.ts @@ -432,6 +432,70 @@ describe('ModifiedExpenseMessage', () => { }); }); + describe('when only the rate of a distance expense changes', () => { + const reportAction = { + ...createRandomReportAction(1), + actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE, + originalMessage: { + merchant: '1,234.56 mi @ $0.70 / mi', + oldMerchant: '1,234.56 mi @ $0.67 / mi', + amount: 86419, + currency: CONST.CURRENCY.USD, + oldAmount: 82716, + oldCurrency: CONST.CURRENCY.USD, + }, + }; + + // The backend groups the distance with a thousands separator, so the merchant only matches + // DISTANCE_MERCHANT once that separator is allowed. Without it the message wrongly says "distance". + it('says the rate changed even when the distance carries a thousands separator', () => { + const expectedResult = 'changed the rate to 1,234.56 mi @ $0.70 / mi (previously 1,234.56 mi @ $0.67 / mi), which updated the amount to $864.19 (previously $827.16)'; + + const result = getForReportAction({ + convertToDisplayString, + translate: translateLocal, + reportAction, + policy: undefined, + policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + currentUserLogin: CURRENT_USER_LOGIN, + }); + + expect(result).toEqual(expectedResult); + }); + }); + + describe('when only the distance of a distance expense changes', () => { + const reportAction = { + ...createRandomReportAction(1), + actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE, + originalMessage: { + merchant: '2,345.67 mi @ $0.67 / mi', + oldMerchant: '1,234.56 mi @ $0.67 / mi', + amount: 157159, + currency: CONST.CURRENCY.USD, + oldAmount: 82716, + oldCurrency: CONST.CURRENCY.USD, + }, + }; + + it('says the distance changed', () => { + const expectedResult = 'changed the distance to 2,345.67 mi @ $0.67 / mi (previously 1,234.56 mi @ $0.67 / mi), which updated the amount to $1,571.59 (previously $827.16)'; + + const result = getForReportAction({ + convertToDisplayString, + translate: translateLocal, + reportAction, + policy: undefined, + policyTags: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + currentUserLogin: CURRENT_USER_LOGIN, + }); + + expect(result).toEqual(expectedResult); + }); + }); + describe('when the amount and merchant are changed', () => { const reportAction = { ...createRandomReportAction(1), diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts index a9e7a2d9b77b..2cd9953b9e59 100644 --- a/tests/unit/ReportActionsUtilsTest.ts +++ b/tests/unit/ReportActionsUtilsTest.ts @@ -4,6 +4,7 @@ import {formatPhoneNumber} from '@libs/LocalePhoneNumber'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import getReportURLForCurrentContext from '@libs/Navigation/helpers/getReportURLForCurrentContext'; import {setHasRadio} from '@libs/NetworkState'; +import Parser from '@libs/Parser'; import {isExpenseReport} from '@libs/ReportUtils'; import IntlStore from '@src/languages/IntlStore'; @@ -11,6 +12,7 @@ import ROUTES from '@src/ROUTES'; import type {ValueOf} from 'type-fest'; +import {Str} from 'expensify-common'; import Onyx from 'react-native-onyx'; import type {CompanyAddressOriginalMessage, UpdateACHAccountOriginalMessage} from '../../src/libs/ReportActionsUtils'; @@ -88,6 +90,8 @@ import wrapOnyxWithWaitForBatchedUpdates from '../utils/wrapOnyxWithWaitForBatch type TakeControlAction = ReportAction; type TakeControlOriginalMessageFixture = NonNullable; +type ConciergeAutoSelectDistanceRateAction = ReportAction; + type LegacyReportActionFields = { message?: string; originalMessage?: string; @@ -1582,6 +1586,90 @@ describe('ReportActionsUtils', () => { }); }); + describe('getConciergeAutoSelectDistanceRateMessage', () => { + function buildConciergeAutoSelectDistanceRateAction( + originalMessage: ConciergeAutoSelectDistanceRateAction['originalMessage'], + backendText = 'rate updated by the backend', + ): ConciergeAutoSelectDistanceRateAction { + return { + actionName: CONST.REPORT.ACTIONS.TYPE.CONCIERGE_AUTO_SELECT_DISTANCE_RATE, + reportActionID: 'concierge-auto-select-distance-rate-1', + created: '2026-09-10 12:00:00.000', + message: [{type: CONST.REPORT.MESSAGE.TYPE.COMMENT, text: backendText, html: backendText}], + originalMessage, + }; + } + + it('should name the workspace the report moved to', () => { + // Given an action for a report whose workspace changed + const action = buildConciergeAutoSelectDistanceRateAction({policyName: "Hal's Burgers"}); + + // When building the message + const message = ReportActionsUtils.getConciergeAutoSelectDistanceRateMessage(translateLocal, action); + + // Then it should name the new workspace, and no individual rate, because each expense on the report can land on a different one + expect(message).toBe("distance rates updated for the new workspace - Hal's Burgers"); + }); + + it('should not escape a workspace name that contains markup', () => { + // Given a workspace name that looks like markup + const action = buildConciergeAutoSelectDistanceRateAction({policyName: 'Ops'}); + + // When building the message + const message = ReportActionsUtils.getConciergeAutoSelectDistanceRateMessage(translateLocal, action); + + // Then the name should be interpolated as-is, because this helper returns plain text + expect(message).toBe('distance rates updated for the new workspace - Ops'); + }); + + it('should fall back to the text of the action when the workspace name is missing', () => { + // Given an action without a workspace name + const backendText = 'rate updated by the backend'; + const action = buildConciergeAutoSelectDistanceRateAction({}, backendText); + + // When building the message + const message = ReportActionsUtils.getConciergeAutoSelectDistanceRateMessage(translateLocal, action); + + // Then it should fall back to the text the backend provided + expect(message).toBe(backendText); + }); + + it('should be used for the message fragments of the action', () => { + // Given a CONCIERGE_AUTO_SELECT_DISTANCE_RATE action + const action = buildConciergeAutoSelectDistanceRateAction({policyName: "Hal's Burgers"}); + + // When getting the message fragments of the action + const fragments = ReportActionsUtils.getReportActionMessageFragments(translateLocal, action); + + // Then the text fragment should be the message as-is, and the html fragment should carry its encoded form + const message = ReportActionsUtils.getConciergeAutoSelectDistanceRateMessage(translateLocal, action); + expect(fragments).toEqual([{text: message, html: `${Str.htmlEncode(message)}`, type: 'COMMENT'}]); + }); + + it('should keep a workspace name containing an HTML entity literal on the html fragment', () => { + // Given a workspace name that contains an entity-shaped substring, which workspace name validation allows because it only rejects angle-bracket tags + const action = buildConciergeAutoSelectDistanceRateAction({policyName: 'R&D ©'}); + + // When getting the message fragments of the action + const fragments = ReportActionsUtils.getReportActionMessageFragments(translateLocal, action); + + // Then the text fragment should stay plain + const message = 'distance rates updated for the new workspace - R&D ©'; + expect(fragments.at(0)?.text).toBe(message); + + // And decoding the html fragment should give that same string back, rather than parsing the entity into a copyright sign + expect(Parser.htmlToText(fragments.at(0)?.html ?? '')).toBe(message); + }); + + it('should be visible in the report', () => { + // Given a CONCIERGE_AUTO_SELECT_DISTANCE_RATE action + const action = buildConciergeAutoSelectDistanceRateAction({policyName: "Hal's Burgers"}); + + // Then the action should not be filtered out as an unsupported action type + expect(ReportActionsUtils.shouldReportActionBeVisible(action, action.reportActionID, true)).toBe(true); + }); + }); + describe('getReportActionText', () => { it('should return the backend-provided CARDFROZEN text', () => { const cardFrozenMessage = 'A A froze their Expensify Card (ending in 1384). New transactions will be declined until the card is unfrozen.'; diff --git a/tests/unit/ReportAlternateTextUtilsTest.ts b/tests/unit/ReportAlternateTextUtilsTest.ts index a20c084bf5d5..ca0df31c88e1 100644 --- a/tests/unit/ReportAlternateTextUtilsTest.ts +++ b/tests/unit/ReportAlternateTextUtilsTest.ts @@ -1294,6 +1294,41 @@ describe('ReportAlternateTextUtils', () => { }); expect(lastMessage).toBe(getCurrencyDefaultTaxUpdateMessage(translateLocal, action)); }); + it('CONCIERGE_AUTO_SELECT_DISTANCE_RATE action', async () => { + // Given a report whose last action is an automatic distance rate change + const report: Report = createRandomReport(0, undefined); + const action: ReportAction = { + ...createRandomReportAction(1), + actionName: CONST.REPORT.ACTIONS.TYPE.CONCIERGE_AUTO_SELECT_DISTANCE_RATE, + message: [{type: 'COMMENT', text: 'rate updated by the backend'}], + originalMessage: { + policyName: "Hal's Burgers", + }, + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, { + [action.reportActionID]: action, + }); + + // When getting the last message text of the report + const lastMessage = getLastMessageTextForReport({ + rules: undefined, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + personalDetails: undefined, + translate: translateLocal, + report, + lastActorDetails: null, + policy: undefined, + isReportArchived: false, + + currentUserLogin: CURRENT_USER_LOGIN, + }); + + // Then it should be built from the translation rather than the text the backend provided + expect(lastMessage).toBe("distance rates updated for the new workspace - Hal's Burgers"); + }); it('ADD_AGENT_RULE action', async () => { const report: Report = createRandomReport(0, undefined); const action: ReportAction = {