diff --git a/src/CONST/index.ts b/src/CONST/index.ts index de9d7aca301a..b4ef59ea712b 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1229,6 +1229,7 @@ const CONST = { DELETED_TRANSACTION: 'DELETEDTRANSACTION', DISMISSED_VIOLATION: 'DISMISSEDVIOLATION', DONATION: 'DONATION', // Deprecated OldDot Action + DYNAMIC_EXTERNAL_WORKFLOW_ROUTED: 'DYNAMICEXTERNALWORKFLOWROUTED', EXPENSIFY_CARD_SYSTEM_MESSAGE: 'EXPENSIFYCARDSYSTEMMESSAGE', EXPORTED_TO_CSV: 'EXPORTCSV', // OldDot Action EXPORTED_TO_INTEGRATION: 'EXPORTINTEGRATION', // OldDot Action diff --git a/src/hooks/useOriginalReportID.ts b/src/hooks/useOriginalReportID.ts index 361a57aef3df..c49ca0c6e2e8 100644 --- a/src/hooks/useOriginalReportID.ts +++ b/src/hooks/useOriginalReportID.ts @@ -1,6 +1,6 @@ import {useMemo} from 'react'; import {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils'; -import {getOneTransactionThreadReportID} from '@libs/ReportActionsUtils'; +import {getOneTransactionThreadReportID, withDEWRoutedActionsObject} from '@libs/ReportActionsUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {OnyxInputOrEntry, ReportAction} from '@src/types/onyx'; @@ -21,7 +21,7 @@ import useTransactionsAndViolationsForReport from './useTransactionsAndViolation * */ function useOriginalReportID(reportID: string | undefined, reportAction: OnyxInputOrEntry>): string | undefined { - const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {canBeMissing: true}); + const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {canBeMissing: true, selector: withDEWRoutedActionsObject}); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {canBeMissing: true}); const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`, {canBeMissing: true}); const {isOffline} = useNetwork(); diff --git a/src/languages/de.ts b/src/languages/de.ts index ad5e52e25460..44483e224f77 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -139,6 +139,7 @@ import type { RoleNamesParams, RoomNameReservedErrorParams, RoomRenamedToParams, + RoutedDueToDEWParams, RulesEnableWorkflowsParams, SecondaryLoginParams, SetTheDistanceMerchantParams, @@ -1513,6 +1514,7 @@ const translations: TranslationDeepObject = { splitDates: 'Datumsangaben aufteilen', splitDateRange: ({startDate, endDate, count}: SplitDateRangeParams) => `${startDate} bis ${endDate} (${count} Tage)`, splitByDate: 'Nach Datum aufteilen', + routedDueToDEW: ({to}: RoutedDueToDEWParams) => `bericht aufgrund eines benutzerdefinierten Genehmigungsworkflows an ${to} weitergeleitet`, }, transactionMerge: { listPage: { diff --git a/src/languages/en.ts b/src/languages/en.ts index b05a57ea006d..e8fdd1372aed 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -127,6 +127,7 @@ import type { RoleNamesParams, RoomNameReservedErrorParams, RoomRenamedToParams, + RoutedDueToDEWParams, RulesEnableWorkflowsParams, SecondaryLoginParams, SetTheDistanceMerchantParams, @@ -1484,6 +1485,7 @@ const translations = { }, }, chooseWorkspace: 'Choose a workspace', + routedDueToDEW: ({to}: RoutedDueToDEWParams) => `report routed to ${to} due to custom approval workflow`, }, transactionMerge: { listPage: { diff --git a/src/languages/es.ts b/src/languages/es.ts index 50bcef1e672e..70beecfa2f2b 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -2,7 +2,7 @@ import {CONST as COMMON_CONST} from 'expensify-common'; import dedent from '@libs/StringUtils/dedent'; import CONST from '@src/CONST'; import type en from './en'; -import type {HarvestCreatedExpenseReportParams, SplitDateRangeParams, ViolationsRterParams} from './params'; +import type {HarvestCreatedExpenseReportParams, RoutedDueToDEWParams, SplitDateRangeParams, ViolationsRterParams} from './params'; import type {TranslationDeepObject} from './types'; /* eslint-disable max-len */ @@ -1186,6 +1186,7 @@ const translations: TranslationDeepObject = { }, }, chooseWorkspace: 'Elige un espacio de trabajo', + routedDueToDEW: ({to}: RoutedDueToDEWParams) => `informe enviado a ${to} debido a un flujo de aprobación personalizado`, }, transactionMerge: { listPage: { diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 6a2b374536a1..60bc62f70d36 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -139,6 +139,7 @@ import type { RoleNamesParams, RoomNameReservedErrorParams, RoomRenamedToParams, + RoutedDueToDEWParams, RulesEnableWorkflowsParams, SecondaryLoginParams, SetTheDistanceMerchantParams, @@ -1515,6 +1516,7 @@ const translations: TranslationDeepObject = { splitDates: 'Diviser les dates', splitDateRange: ({startDate, endDate, count}: SplitDateRangeParams) => `Du ${startDate} au ${endDate} (${count} jours)`, splitByDate: 'Scinder par date', + routedDueToDEW: ({to}: RoutedDueToDEWParams) => `rapport acheminé vers ${to} en raison d'un workflow d'approbation personnalisé`, }, transactionMerge: { listPage: { diff --git a/src/languages/it.ts b/src/languages/it.ts index 90380b79befb..bf89269c2a76 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -139,6 +139,7 @@ import type { RoleNamesParams, RoomNameReservedErrorParams, RoomRenamedToParams, + RoutedDueToDEWParams, RulesEnableWorkflowsParams, SecondaryLoginParams, SetTheDistanceMerchantParams, @@ -1508,6 +1509,7 @@ const translations: TranslationDeepObject = { splitDates: 'Dividi date', splitDateRange: ({startDate, endDate, count}: SplitDateRangeParams) => `${startDate} a ${endDate} (${count} giorni)`, splitByDate: 'Dividi per data', + routedDueToDEW: ({to}: RoutedDueToDEWParams) => `rapporto inoltrato a ${to} a causa del flusso di lavoro di approvazione personalizzato`, }, transactionMerge: { listPage: { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index e4f1d8b187ff..fe936aaf5252 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -139,6 +139,7 @@ import type { RoleNamesParams, RoomNameReservedErrorParams, RoomRenamedToParams, + RoutedDueToDEWParams, RulesEnableWorkflowsParams, SecondaryLoginParams, SetTheDistanceMerchantParams, @@ -1508,6 +1509,7 @@ const translations: TranslationDeepObject = { splitDates: '日付を分割', splitDateRange: ({startDate, endDate, count}: SplitDateRangeParams) => `${startDate} から ${endDate} まで(${count} 日間)`, splitByDate: '日付で分割', + routedDueToDEW: ({to}: RoutedDueToDEWParams) => `カスタム承認ワークフローにより、${to} 宛にルーティングされたレポート`, }, transactionMerge: { listPage: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index b954da96295a..a5a5fdf595d5 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -139,6 +139,7 @@ import type { RoleNamesParams, RoomNameReservedErrorParams, RoomRenamedToParams, + RoutedDueToDEWParams, RulesEnableWorkflowsParams, SecondaryLoginParams, SetTheDistanceMerchantParams, @@ -1507,6 +1508,7 @@ const translations: TranslationDeepObject = { splitDates: 'Datums splitsen', splitDateRange: ({startDate, endDate, count}: SplitDateRangeParams) => `${startDate} tot ${endDate} (${count} dagen)`, splitByDate: 'Splitsen op datum', + routedDueToDEW: ({to}: RoutedDueToDEWParams) => `rapport doorgestuurd naar ${to} vanwege aangepaste goedkeuringsworkflow`, }, transactionMerge: { listPage: { diff --git a/src/languages/params.ts b/src/languages/params.ts index a4ea71d7d01b..603aa154638c 100644 --- a/src/languages/params.ts +++ b/src/languages/params.ts @@ -771,6 +771,10 @@ type FocusModeUpdateParams = { priorityModePageUrl: string; }; +type RoutedDueToDEWParams = { + to: string; +}; + export type { SettlementAccountReconciliationParams, ToggleImportTitleParams, @@ -1022,6 +1026,7 @@ export type { NextStepParams, ReportFieldParams, FocusModeUpdateParams, + RoutedDueToDEWParams, UpdatedPolicyCustomUnitRateIndexParams, UpdatedPolicyCustomUnitRateEnabledParams, }; diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 21db80dcc16d..1844f257bda1 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -139,6 +139,7 @@ import type { RoleNamesParams, RoomNameReservedErrorParams, RoomRenamedToParams, + RoutedDueToDEWParams, RulesEnableWorkflowsParams, SecondaryLoginParams, SetTheDistanceMerchantParams, @@ -1505,6 +1506,7 @@ const translations: TranslationDeepObject = { splitDates: 'Podziel daty', splitDateRange: ({startDate, endDate, count}: SplitDateRangeParams) => `${startDate} do ${endDate} (${count} dni)`, splitByDate: 'Podziel według daty', + routedDueToDEW: ({to}: RoutedDueToDEWParams) => `raport przekazany do ${to} z powodu niestandardowego procesu zatwierdzania`, }, transactionMerge: { listPage: { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 64529b9ee4c5..da044e80aa76 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -139,6 +139,7 @@ import type { RoleNamesParams, RoomNameReservedErrorParams, RoomRenamedToParams, + RoutedDueToDEWParams, RulesEnableWorkflowsParams, SecondaryLoginParams, SetTheDistanceMerchantParams, @@ -1505,6 +1506,7 @@ const translations: TranslationDeepObject = { splitDates: 'Dividir datas', splitDateRange: ({startDate, endDate, count}: SplitDateRangeParams) => `${startDate} a ${endDate} (${count} dias)`, splitByDate: 'Dividir por data', + routedDueToDEW: ({to}: RoutedDueToDEWParams) => `relatório encaminhado para ${to} devido ao fluxo de trabalho de aprovação personalizado`, }, transactionMerge: { listPage: { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index f7db2794c0da..5c63013096f3 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -139,6 +139,7 @@ import type { RoleNamesParams, RoomNameReservedErrorParams, RoomRenamedToParams, + RoutedDueToDEWParams, RulesEnableWorkflowsParams, SecondaryLoginParams, SetTheDistanceMerchantParams, @@ -1482,6 +1483,7 @@ const translations: TranslationDeepObject = { splitDates: '拆分日期', splitDateRange: ({startDate, endDate, count}: SplitDateRangeParams) => `${startDate} 至 ${endDate}(${count} 天)`, splitByDate: '按日期拆分', + routedDueToDEW: ({to}: RoutedDueToDEWParams) => `报告因自定义审批工作流而转发至 ${to}`, }, transactionMerge: { listPage: { diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 26a9d0bfa670..863979a1513e 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -36,6 +36,7 @@ import { getActionableMentionWhisperMessage, getChangedApproverActionMessage, getCombinedReportActions, + getDynamicExternalWorkflowRoutedMessage, getExportIntegrationLastMessageText, getIOUReportIDFromReportActionPreview, getJoinRequestMessage, @@ -81,6 +82,7 @@ import { isUnapprovedAction, isWhisperAction, shouldReportActionBeVisible, + withDEWRoutedActionsArray, } from '@libs/ReportActionsUtils'; import {computeReportName} from '@libs/ReportNameUtils'; import type {OptionData} from '@libs/ReportUtils'; @@ -267,7 +269,7 @@ Onyx.connect({ } const reportActionsArray = Object.values(reportActions[1] ?? {}); - let sortedReportActions = getSortedReportActions(reportActionsArray, true); + let sortedReportActions = getSortedReportActions(withDEWRoutedActionsArray(reportActionsArray), true); allSortedReportActions[reportID] = sortedReportActions; const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; const chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`]; @@ -785,6 +787,9 @@ function getLastMessageTextForReport({ lastMessageTextFromReport = Parser.htmlToText(getUnreportedTransactionMessage(lastReportAction)); } else if (isActionableMentionWhisper(lastReportAction)) { lastMessageTextFromReport = Parser.htmlToText(getActionableMentionWhisperMessage(lastReportAction)); + } else if (isActionOfType(lastReportAction, CONST.REPORT.ACTIONS.TYPE.DYNAMIC_EXTERNAL_WORKFLOW_ROUTED)) { + // eslint-disable-next-line @typescript-eslint/no-deprecated + lastMessageTextFromReport = getDynamicExternalWorkflowRoutedMessage(lastReportAction, translateLocal); } // we do not want to show report closed in LHN for non archived report so use getReportLastMessage as fallback instead of lastMessageText from report diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 991da1344b92..9d62e9a83b96 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -23,6 +23,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; import {isCardPendingActivate} from './CardUtils'; import {getDecodedCategoryName} from './CategoryUtils'; import {convertAmountToDisplayString, convertToDisplayString, convertToShortDisplayString} from './CurrencyUtils'; +import DateUtils from './DateUtils'; import {getEnvironmentURL, getOldDotEnvironmentURL} from './Environment/Environment'; import getBase62ReportID from './getBase62ReportID'; import {isReportMessageAttachment} from './isReportMessageAttachment'; @@ -224,6 +225,10 @@ function isSubmittedAndClosedAction(reportAction: OnyxInputOrEntry return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.SUBMITTED_AND_CLOSED); } +function isDynamicExternalWorkflowSubmitAction(reportAction: OnyxInputOrEntry): reportAction is ReportAction { + return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.SUBMITTED) && getOriginalMessage(reportAction)?.workflow === CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL; +} + function isMarkAsClosedAction(reportAction: OnyxInputOrEntry): reportAction is ReportAction { return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.CLOSED) && !!getOriginalMessage(reportAction)?.amount; } @@ -240,6 +245,10 @@ function isForwardedAction(reportAction: OnyxInputOrEntry): report return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.FORWARDED); } +function isDynamicExternalWorkflowForwardedAction(reportAction: OnyxInputOrEntry): reportAction is ReportAction { + return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.FORWARDED) && getOriginalMessage(reportAction)?.workflow === CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL; +} + function isModifiedExpenseAction(reportAction: OnyxInputOrEntry): reportAction is ReportAction { return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE); } @@ -1104,6 +1113,43 @@ function getFilteredReportActionsForReportView(actions: ReportAction[]) { return actions.filter(isVisiblePreviewOrMoneyRequest); } +function getDynamicExternalWorkflowRoutedAction( + reportAction: ReportAction | ReportAction, +): ReportAction { + return { + reportActionID: `${reportAction.reportActionID}DEW`, + created: DateUtils.addMillisecondsFromDateTime(reportAction.created, 1), + actionName: CONST.REPORT.ACTIONS.TYPE.DYNAMIC_EXTERNAL_WORKFLOW_ROUTED, + actorAccountID: CONST.ACCOUNT_ID.CONCIERGE, + message: [{html: 'DYNAMIC_EXTERNAL_WORKFLOW', type: 'COMMENT', text: ''}], + originalMessage: { + to: getOriginalMessage(reportAction)?.to ?? '', + }, + }; +} + +function withDEWRoutedActionsArray(reportActions: ReportAction[]): ReportAction[] { + return reportActions.flatMap((reportAction) => { + if ((isDynamicExternalWorkflowSubmitAction(reportAction) || isDynamicExternalWorkflowForwardedAction(reportAction)) && getOriginalMessage(reportAction)?.to) { + return [reportAction, getDynamicExternalWorkflowRoutedAction(reportAction)]; + } + return reportAction; + }); +} + +function withDEWRoutedActionsObject(reportActions: OnyxEntry): OnyxEntry { + return Object.entries(reportActions ?? {}).reduce((acc, value) => { + const [reportActionID, reportAction] = value; + acc[reportActionID] = reportAction; + + if ((isDynamicExternalWorkflowSubmitAction(reportAction) || isDynamicExternalWorkflowForwardedAction(reportAction)) && getOriginalMessage(reportAction)?.to) { + const dynamicExternalWorkflowRoutedAction = getDynamicExternalWorkflowRoutedAction(reportAction); + acc[dynamicExternalWorkflowRoutedAction.reportActionID] = dynamicExternalWorkflowRoutedAction; + } + return acc; + }, {} as ReportActions); +} + /** * This method returns the report actions that are ready for display in the ReportActionsView. * The report actions need to be sorted by created timestamp first, and reportActionID second @@ -1129,7 +1175,7 @@ function getSortedReportActionsForDisplay( } const baseURLAdjustedReportActions = filteredReportActions.map((reportAction) => replaceBaseURLInPolicyChangeLogAction(reportAction)); - return getSortedReportActions(baseURLAdjustedReportActions, true); + return getSortedReportActions(withDEWRoutedActionsArray(baseURLAdjustedReportActions), true); } /** @@ -1946,6 +1992,12 @@ function getReportActionMessageFragments(action: ReportAction): Message[] { return [{text: message, html: message, type: 'COMMENT'}]; } + if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.DYNAMIC_EXTERNAL_WORKFLOW_ROUTED)) { + // eslint-disable-next-line @typescript-eslint/no-deprecated + const message = getDynamicExternalWorkflowRoutedMessage(action, translateLocal); + return [{text: message, html: `${message}`, type: 'COMMENT'}]; + } + const actionMessage = action.previousMessage ?? action.message; if (Array.isArray(actionMessage)) { return actionMessage.filter((item): item is Message => !!item); @@ -3291,6 +3343,13 @@ function getHarvestCreatedExpenseReportMessage(reportID: string | undefined, rep return translate('reportAction.harvestCreatedExpenseReport', {reportUrl, reportName}); } +function getDynamicExternalWorkflowRoutedMessage( + action: OnyxEntry>, + translate: LocaleContextProps['translate'], +) { + return translate('iou.routedDueToDEW', {to: getOriginalMessage(action)?.to ?? ''}); +} + function isCardIssuedAction( reportAction: OnyxEntry, ): reportAction is ReportAction< @@ -3531,6 +3590,7 @@ export { getUpdatedApprovalRuleMessage, getRemovedFromApprovalChainMessage, getDemotedFromWorkspaceMessage, + getDynamicExternalWorkflowRoutedMessage, getReportAction, getReportActionHtml, getReportActionMessage, @@ -3604,8 +3664,10 @@ export { isWhisperAction, isSubmittedAction, isSubmittedAndClosedAction, + isDynamicExternalWorkflowSubmitAction, isMarkAsClosedAction, isApprovedAction, + isDynamicExternalWorkflowForwardedAction, isUnapprovedAction, isForwardedAction, isWhisperActionTargetedToOthers, @@ -3690,6 +3752,8 @@ export { getActionableCardFraudAlertMessage, getHarvestCreatedExpenseReportMessage, isSystemUserMentioned, + withDEWRoutedActionsArray, + withDEWRoutedActionsObject, }; export type {LastVisibleMessage}; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index cb2f29f3180f..6666bbfb21cd 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -7596,11 +7596,18 @@ function buildOptimisticUnreportedTransactionAction(transactionThreadReportID: s * Builds an optimistic SUBMITTED report action with a randomly generated reportActionID. * */ -function buildOptimisticSubmittedReportAction(amount: number, currency: string, expenseReportID: string, adminAccountID: number | undefined): OptimisticSubmittedReportAction { +function buildOptimisticSubmittedReportAction( + amount: number, + currency: string, + expenseReportID: string, + adminAccountID: number | undefined, + workflow: ValueOf | undefined, +): OptimisticSubmittedReportAction { const originalMessage = { amount, currency, expenseReportID, + workflow, }; const delegateAccountDetails = getPersonalDetailByEmail(delegateEmail); @@ -10826,6 +10833,7 @@ function shouldDisableThread(reportAction: OnyxInputOrEntry, isThr const isReportPreviewActionLocal = isReportPreviewAction(reportAction); const isIOUAction = isMoneyRequestAction(reportAction); const isWhisperActionLocal = isWhisperAction(reportAction) || isActionableTrackExpense(reportAction); + const isDynamicWorkflowRoutedAction = isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.DYNAMIC_EXTERNAL_WORKFLOW_ROUTED); const isActionDisabled = CONST.REPORT.ACTIONS.THREAD_DISABLED.some((action: string) => action === reportAction?.actionName); const isManagerMcTestOwner = reportAction?.actorAccountID === CONST.ACCOUNT_ID.MANAGER_MCTEST; @@ -10836,7 +10844,8 @@ function shouldDisableThread(reportAction: OnyxInputOrEntry, isThr (isDeletedActionLocal && !reportAction?.childVisibleActionCount) || (isReportArchived && !reportAction?.childVisibleActionCount) || (isWhisperActionLocal && !isReportPreviewActionLocal && !isIOUAction) || - isThreadReportParentAction + isThreadReportParentAction || + isDynamicWorkflowRoutedAction ); } diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index c6095be3feeb..4092e5e2d2a2 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -11639,7 +11639,13 @@ function submitReport( const isCurrentUserManager = currentUserAccountIDParam === expenseReport.managerID; const isSubmitAndClosePolicy = isSubmitAndClose(policy); const adminAccountID = policy?.role === CONST.POLICY.ROLE.ADMIN ? currentUserAccountIDParam : undefined; - const optimisticSubmittedReportAction = buildOptimisticSubmittedReportAction(expenseReport?.total ?? 0, expenseReport.currency ?? '', expenseReport.reportID, adminAccountID); + const optimisticSubmittedReportAction = buildOptimisticSubmittedReportAction( + expenseReport?.total ?? 0, + expenseReport.currency ?? '', + expenseReport.reportID, + adminAccountID, + policy?.approvalMode, + ); // buildOptimisticNextStep is used in parallel // eslint-disable-next-line @typescript-eslint/no-deprecated diff --git a/src/pages/Debug/ReportAction/DebugReportActionPage.tsx b/src/pages/Debug/ReportAction/DebugReportActionPage.tsx index 634a43d0bca6..988edb5591d2 100644 --- a/src/pages/Debug/ReportAction/DebugReportActionPage.tsx +++ b/src/pages/Debug/ReportAction/DebugReportActionPage.tsx @@ -14,7 +14,7 @@ import DebugTabNavigator from '@libs/Navigation/DebugTabNavigator'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {DebugParamList} from '@libs/Navigation/types'; -import {getLinkedTransactionID} from '@libs/ReportActionsUtils'; +import {getLinkedTransactionID, withDEWRoutedActionsObject} from '@libs/ReportActionsUtils'; import DebugDetails from '@pages/Debug/DebugDetails'; import DebugJSON from '@pages/Debug/DebugJSON'; import Debug from '@userActions/Debug'; @@ -37,7 +37,7 @@ function DebugReportActionPage({ const getReportActionSelector = useCallback( (reportActions: OnyxEntry): OnyxEntry => { - return reportActions?.[reportActionID]; + return withDEWRoutedActionsObject(reportActions)?.[reportActionID]; }, [reportActionID], ); diff --git a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx index 7e1f7d09276e..3d68ad7e9b13 100755 --- a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx +++ b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx @@ -25,7 +25,7 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getMovedReportID} from '@libs/ModifiedExpenseMessage'; -import {getLinkedTransactionID, getOneTransactionThreadReportID, getOriginalMessage, getReportAction, isDeletedAction} from '@libs/ReportActionsUtils'; +import {getLinkedTransactionID, getOneTransactionThreadReportID, getOriginalMessage, getReportAction, isDeletedAction, withDEWRoutedActionsObject} from '@libs/ReportActionsUtils'; import { chatIncludesChronosWithID, getHarvestOriginalReportID, @@ -164,6 +164,7 @@ function BaseReportActionContextMenu({ const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${originalReportID}`, { canBeMissing: true, canEvict: false, + selector: withDEWRoutedActionsObject, }); const reportAction: OnyxEntry = useMemo(() => { diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx index 0239e119a3ba..383c6784a246 100644 --- a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx +++ b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx @@ -31,6 +31,7 @@ import { getChangedApproverActionMessage, getDefaultApproverUpdateMessage, getDeletedApprovalRuleMessage, + getDynamicExternalWorkflowRoutedMessage, getExportIntegrationMessageHTML, getForwardsToUpdateMessage, getHarvestCreatedExpenseReportMessage, @@ -287,7 +288,10 @@ type ContextMenuAction = (ContextMenuActionWithContent | ContextMenuActionWithIc const ContextMenuActions: ContextMenuAction[] = [ { isAnonymousAction: false, - shouldShow: ({type, reportAction}) => type === CONST.CONTEXT_MENU_TYPES.REPORT_ACTION && !!reportAction && 'message' in reportAction && !isMessageDeleted(reportAction), + shouldShow: ({type, reportAction}) => { + const isDynamicWorkflowRoutedAction = isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.DYNAMIC_EXTERNAL_WORKFLOW_ROUTED); + return type === CONST.CONTEXT_MENU_TYPES.REPORT_ACTION && !!reportAction && 'message' in reportAction && !isMessageDeleted(reportAction) && !isDynamicWorkflowRoutedAction; + }, renderContent: (closePopover, {reportID, reportAction, close: closeManually, openContextMenu, setIsEmojiPickerActive}) => { const isMini = !closePopover; @@ -369,7 +373,10 @@ const ContextMenuActions: ContextMenuAction[] = [ textTranslateKey: 'reportActionContextMenu.markAsUnread', icon: 'ChatBubbleUnread', successIcon: 'Checkmark', - shouldShow: ({type, isUnreadChat}) => type === CONST.CONTEXT_MENU_TYPES.REPORT_ACTION || (type === CONST.CONTEXT_MENU_TYPES.REPORT && !isUnreadChat), + shouldShow: ({type, reportAction, isUnreadChat}) => { + const isDynamicWorkflowRoutedAction = isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.DYNAMIC_EXTERNAL_WORKFLOW_ROUTED); + return (type === CONST.CONTEXT_MENU_TYPES.REPORT_ACTION && !isDynamicWorkflowRoutedAction) || (type === CONST.CONTEXT_MENU_TYPES.REPORT && !isUnreadChat); + }, onPress: (closePopover, {reportAction, reportID}) => { markCommentAsUnread(reportID, reportAction); if (closePopover) { @@ -866,6 +873,8 @@ const ContextMenuActions: ContextMenuAction[] = [ } else if (isActionableJoinRequest(reportAction)) { const displayMessage = getJoinRequestMessage(reportAction); Clipboard.setString(displayMessage); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.DYNAMIC_EXTERNAL_WORKFLOW_ROUTED)) { + setClipboardMessage(getDynamicExternalWorkflowRoutedMessage(reportAction, translate)); } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.CREATED) && isHarvestReport) { const harvestReportName = getReportName(harvestReport); const displayMessage = getHarvestCreatedExpenseReportMessage(harvestReport?.reportID, harvestReportName, translate); @@ -900,7 +909,8 @@ const ContextMenuActions: ContextMenuAction[] = [ // Only hide the copy link menu item when context menu is opened over img element. const isAttachmentTarget = menuTarget?.current && 'tagName' in menuTarget.current && menuTarget?.current.tagName === 'IMG' && isAttachment; - return type === CONST.CONTEXT_MENU_TYPES.REPORT_ACTION && !isAttachmentTarget && !isMessageDeleted(reportAction); + const isDynamicWorkflowRoutedAction = isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.DYNAMIC_EXTERNAL_WORKFLOW_ROUTED); + return type === CONST.CONTEXT_MENU_TYPES.REPORT_ACTION && !isAttachmentTarget && !isMessageDeleted(reportAction) && !isDynamicWorkflowRoutedAction; }, onPress: (closePopover, {reportAction, reportID}) => { const originalReportID = getOriginalReportID(reportID, reportAction); diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts index 63d2a6d5e112..3aabb1ebefa6 100644 --- a/src/types/onyx/OriginalMessage.ts +++ b/src/types/onyx/OriginalMessage.ts @@ -235,6 +235,9 @@ type OriginalMessageSubmitted = { /** Carbon copy list */ cc?: string; + + /** The workflow the report is submitted on */ + workflow?: ValueOf; }; /** Model of `created` report action */ @@ -828,6 +831,12 @@ type OriginalMessageDismissedViolation = { violationName: string; }; +/** Model of DYNAMIC_EXTERNAL_WORKFLOW_ROUTED report action */ +type OriginalMessageDynamicExternalWorkflowRouted = { + /** The approver of the report is submitted to */ + to: string; +}; + /** Model of `marked reimbursed` report action */ type OriginalMessageMarkedReimbursed = { /** Whether this action was created from NewDot */ @@ -883,6 +892,12 @@ type OriginalMessageForwarded = { /** Report ID of the expense */ expenseReportID: string; + + /** The login the approver who is acting on behalf of the vacationer */ + to?: string; + + /** The workflow the report is approved on */ + workflow?: ValueOf; }; /** @@ -1064,6 +1079,7 @@ type OriginalMessageMap = { [CONST.REPORT.ACTIONS.TYPE.CLOSED]: OriginalMessageClosed; [CONST.REPORT.ACTIONS.TYPE.CREATED]: OriginalMessageCreated; [CONST.REPORT.ACTIONS.TYPE.DISMISSED_VIOLATION]: OriginalMessageDismissedViolation; + [CONST.REPORT.ACTIONS.TYPE.DYNAMIC_EXTERNAL_WORKFLOW_ROUTED]: OriginalMessageDynamicExternalWorkflowRouted; [CONST.REPORT.ACTIONS.TYPE.EXPENSIFY_CARD_SYSTEM_MESSAGE]: never; [CONST.REPORT.ACTIONS.TYPE.EXPORTED_TO_CSV]: never; [CONST.REPORT.ACTIONS.TYPE.EXPORTED_TO_INTEGRATION]: OriginalMessageExportIntegration; diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index c5911c57b7f5..f83ea1925099 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -36,7 +36,7 @@ import { sortAlphabetically, } from '@libs/OptionsListUtils'; import Parser from '@libs/Parser'; -import {getChangedApproverActionMessage} from '@libs/ReportActionsUtils'; +import {getChangedApproverActionMessage, getDynamicExternalWorkflowRoutedMessage} from '@libs/ReportActionsUtils'; import { canCreateTaskInReport, canUserPerformWriteAction, @@ -57,7 +57,7 @@ import createRandomReportAction from '../utils/collections/reportActions'; import {createRandomReport, createRegularChat} from '../utils/collections/reports'; import createRandomTransaction from '../utils/collections/transaction'; import {getFakeAdvancedReportAction} from '../utils/LHNTestUtils'; -import {getNvpDismissedProductTraining, localeCompare} from '../utils/TestHelper'; +import {getNvpDismissedProductTraining, localeCompare, translateLocal} from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; jest.mock('@rnmapbox/maps', () => { @@ -2807,6 +2807,26 @@ describe('OptionsListUtils', () => { const lastMessage = getLastMessageTextForReport({report, lastActorDetails: null, isReportArchived: false}); expect(lastMessage).toBe(Parser.htmlToText(getMovedActionMessage(movedAction, report))); }); + it('DYNAMIC_EXTERNAL_WORKFLOW_ROUTED action', async () => { + // Given a DYNAMIC_EXTERNAL_WORKFLOW_ROUTED as the last action + const report: Report = createRandomReport(0, undefined); + const action: ReportAction = { + reportActionID: '1', + created: '', + actionName: CONST.REPORT.ACTIONS.TYPE.DYNAMIC_EXTERNAL_WORKFLOW_ROUTED, + message: [{type: 'COMMENT', text: ''}], + originalMessage: {to: 'example@gmail.com'}, + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, { + [action.reportActionID]: action, + }); + + // When getting the last message text for the report + const lastMessage = getLastMessageTextForReport({report, lastActorDetails: null, isReportArchived: false}); + + // Then it should return the DYNAMIC_EXTERNAL_WORKFLOW_ROUTED message + expect(lastMessage).toBe(Parser.htmlToText(getDynamicExternalWorkflowRoutedMessage(action, translateLocal))); + }); it('should return last visible message text when last action is hidden (e.g. whisper)', async () => { const report: Report = { ...createRandomReport(0, undefined), diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts index 9924a17095f9..b35fdf7c3236 100644 --- a/tests/unit/ReportActionsUtilsTest.ts +++ b/tests/unit/ReportActionsUtilsTest.ts @@ -11,7 +11,7 @@ import CONST from '../../src/CONST'; import * as ReportActionsUtils from '../../src/libs/ReportActionsUtils'; import {getCardIssuedMessage, getOneTransactionThreadReportID, getOriginalMessage, getSendMoneyFlowAction, isIOUActionMatchingTransactionList} from '../../src/libs/ReportActionsUtils'; import ONYXKEYS from '../../src/ONYXKEYS'; -import type {Card, OriginalMessageIOU, Report, ReportAction} from '../../src/types/onyx'; +import type {Card, OriginalMessageIOU, Report, ReportAction, ReportActions} from '../../src/types/onyx'; import createRandomReportAction from '../utils/collections/reportActions'; import {createRandomReport} from '../utils/collections/reports'; import * as LHNTestUtils from '../utils/LHNTestUtils'; @@ -1043,6 +1043,26 @@ describe('ReportActionsUtils', () => { const expectedFragments = ReportActionsUtils.getReportActionMessageFragments(action); expect(expectedFragments).toEqual([{text: expectedMessage, html: `${expectedMessage}`, type: 'COMMENT'}]); }); + + it('should return the correct fragment for the DYNAMIC_EXTERNAL_WORKFLOW_ROUTED action', () => { + // Given a DYNAMIC_EXTERNAL_WORKFLOW_ROUTED action + const action: ReportAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.DYNAMIC_EXTERNAL_WORKFLOW_ROUTED, + reportActionID: '1', + created: '1', + message: [], + originalMessage: { + to: 'example@gmail.com', + }, + }; + + // When getting the message fragments of the action + const expectedMessage = ReportActionsUtils.getDynamicExternalWorkflowRoutedMessage(action, translateLocal); + const expectedFragments = ReportActionsUtils.getReportActionMessageFragments(action); + + // Then it should return the correct message fragments + expect(expectedFragments).toEqual([{text: expectedMessage, html: `${expectedMessage}`, type: 'COMMENT'}]); + }); }); describe('getSendMoneyFlowAction', () => { @@ -1544,4 +1564,308 @@ describe('ReportActionsUtils', () => { expect(result).toBe(expectedMessage); }); }); + + describe('isDynamicExternalWorkflowSubmitAction', () => { + it('should return true for SUBMITTED action if workflow is DYNAMICEXTERNAL', () => { + // Given a report action with SUBMITTED action type and workflow is DYNAMICEXTERNAL + const action: ReportAction = { + ...createRandomReportAction(0), + actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, + created: '2025-11-21', + previousMessage: [], + message: [], + originalMessage: { + workflow: CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL, + amount: 1, + currency: CONST.CURRENCY.USD, + }, + }; + + // When checking if the action is a DEW submit action + const result = ReportActionsUtils.isDynamicExternalWorkflowSubmitAction(action); + + // Then it should return true + expect(result).toBe(true); + }); + + it('should return false for SUBMITTED action if workflow is not DYNAMICEXTERNAL', () => { + // Given a report action with SUBMITTED action type and workflow is not DYNAMICEXTERNAL + const action: ReportAction = { + ...createRandomReportAction(0), + actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, + created: '2025-11-21', + previousMessage: [], + message: [], + originalMessage: { + workflow: CONST.POLICY.APPROVAL_MODE.BASIC, + amount: 1, + currency: CONST.CURRENCY.USD, + }, + }; + + // When checking if the action is a DEW submit action + const result = ReportActionsUtils.isDynamicExternalWorkflowSubmitAction(action); + + // Then it should return false + expect(result).toBe(false); + }); + + it('should return false for non SUBMITTED action', () => { + // Given a report action with non SUBMITTED action type + const action: ReportAction = { + ...createRandomReportAction(0), + actionName: CONST.REPORT.ACTIONS.TYPE.APPROVED, + created: '2025-11-21', + previousMessage: [], + message: [], + originalMessage: { + expenseReportID: '1', + amount: 1, + currency: CONST.CURRENCY.USD, + }, + }; + + // When checking if the action is a DEW submit action + const result = ReportActionsUtils.isDynamicExternalWorkflowSubmitAction(action); + + // Then it should return false + expect(result).toBe(false); + }); + }); + + describe('isDynamicExternalWorkflowForwardedAction', () => { + it('should return true for FORWARDED action if workflow is DYNAMICEXTERNAL', () => { + // Given a report action with FORWARDED action type and workflow is DYNAMICEXTERNAL + const action: ReportAction = { + ...createRandomReportAction(0), + actionName: CONST.REPORT.ACTIONS.TYPE.FORWARDED, + created: '2025-11-21', + previousMessage: [], + message: [], + originalMessage: { + workflow: CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL, + expenseReportID: '1', + amount: 1, + currency: CONST.CURRENCY.USD, + }, + }; + + // When checking if the action is a DEW forwarded action + const result = ReportActionsUtils.isDynamicExternalWorkflowForwardedAction(action); + + // Then it should return true + expect(result).toBe(true); + }); + + it('should return false for FORWARDED action if workflow is not DYNAMICEXTERNAL', () => { + // Given a report action with FORWARDED action type and workflow is not DYNAMICEXTERNAL + const action: ReportAction = { + ...createRandomReportAction(0), + actionName: CONST.REPORT.ACTIONS.TYPE.FORWARDED, + created: '2025-11-21', + previousMessage: [], + message: [], + originalMessage: { + workflow: CONST.POLICY.APPROVAL_MODE.BASIC, + expenseReportID: '1', + amount: 1, + currency: CONST.CURRENCY.USD, + }, + }; + + // When checking if the action is a DEW forwarded action + const result = ReportActionsUtils.isDynamicExternalWorkflowForwardedAction(action); + + // Then it should return false + expect(result).toBe(false); + }); + + it('should return false for non FORWARDED action', () => { + // Given a report action with non FORWARDED action type + const action: ReportAction = { + ...createRandomReportAction(0), + actionName: CONST.REPORT.ACTIONS.TYPE.APPROVED, + created: '2025-11-21', + previousMessage: [], + message: [], + originalMessage: { + expenseReportID: '1', + amount: 1, + currency: CONST.CURRENCY.USD, + }, + }; + + // When checking if the action is a DEW forwarded action + const result = ReportActionsUtils.isDynamicExternalWorkflowForwardedAction(action); + + // Then it should return false + expect(result).toBe(false); + }); + }); + + describe('withDEWRoutedActionsArray', () => { + it('should add a DEW routed action for each DEW SUBMITTED and FORWARDED action', () => { + // Given a report actions array with DEW SUBMITTED and FORWARDED actions + const reportActions: ReportAction[] = [ + {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, created: '', reportActionID: '1'}, + { + actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, + created: '', + reportActionID: '2', + originalMessage: {workflow: CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL, to: 'example@gmail.com'}, + }, + {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, created: '', reportActionID: '3'}, + { + actionName: CONST.REPORT.ACTIONS.TYPE.FORWARDED, + created: '', + reportActionID: '4', + originalMessage: {workflow: CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL, to: 'example2@gmail.com'}, + }, + ]; + + // When extending the array with DYNAMIC_EXTERNAL_WORKFLOW_ROUTED action + const expected: Array> = [ + {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, created: '', reportActionID: '1'}, + { + actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, + created: '', + reportActionID: '2', + originalMessage: {workflow: CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL, to: 'example@gmail.com'}, + }, + {actionName: CONST.REPORT.ACTIONS.TYPE.DYNAMIC_EXTERNAL_WORKFLOW_ROUTED, reportActionID: '2DEW', originalMessage: {to: 'example@gmail.com'}}, + {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, created: '', reportActionID: '3'}, + { + actionName: CONST.REPORT.ACTIONS.TYPE.FORWARDED, + created: '', + reportActionID: '4', + originalMessage: {workflow: CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL, to: 'example2@gmail.com'}, + }, + {actionName: CONST.REPORT.ACTIONS.TYPE.DYNAMIC_EXTERNAL_WORKFLOW_ROUTED, reportActionID: '4DEW', originalMessage: {to: 'example2@gmail.com'}}, + ]; + const actual = ReportActionsUtils.withDEWRoutedActionsArray(reportActions); + + // Then DYNAMIC_EXTERNAL_WORKFLOW_ROUTED action should be added for each SUBMITTED and FORWARDED actions to the array + for (let i = 0; i < expected.length; i++) { + expect(actual.at(i)).toEqual(expect.objectContaining(expected.at(i))); + } + }); + + it(`should not add a DEW routed action if we don't have DEW SUBMITTED or FORWARDED action`, () => { + // Given a report actions array with no DEW SUBMITTED or FORWARDED actions + const reportActions: ReportAction[] = [ + {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, created: '', reportActionID: '1'}, + {actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, created: '', reportActionID: '2'}, + {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, created: '', reportActionID: '3'}, + {actionName: CONST.REPORT.ACTIONS.TYPE.FORWARDED, created: '', reportActionID: '4'}, + ]; + + // When extending the array with DYNAMIC_EXTERNAL_WORKFLOW_ROUTED action + const expected: ReportAction[] = [ + {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, created: '', reportActionID: '1'}, + {actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, created: '', reportActionID: '2'}, + {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, created: '', reportActionID: '3'}, + {actionName: CONST.REPORT.ACTIONS.TYPE.FORWARDED, created: '', reportActionID: '4'}, + ]; + const actual = ReportActionsUtils.withDEWRoutedActionsArray(reportActions); + + // Then no DYNAMIC_EXTERNAL_WORKFLOW_ROUTED action should be added to the array + expect(actual).toEqual(expected); + }); + }); + + describe('withDEWRoutedActionsObject', () => { + it('should add a DEW routed action for each DEW SUBMITTED and FORWARDED action', () => { + // Given a report actions collection with DEW SUBMITTED and FORWARDED actions + const firstAction = {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, created: '', reportActionID: '1'}; + const secondAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, + created: '', + reportActionID: '2', + originalMessage: {workflow: CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL, to: 'example@gmail.com'}, + }; + const thirdAction = {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, created: '', reportActionID: '3'}; + const fourthAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.FORWARDED, + created: '', + reportActionID: '4', + originalMessage: {workflow: CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL, to: 'example2@gmail.com'}, + }; + const reportActions: ReportActions = { + [firstAction.reportActionID]: firstAction, + [secondAction.reportActionID]: secondAction, + [thirdAction.reportActionID]: thirdAction, + [fourthAction.reportActionID]: fourthAction, + }; + + // When extending the collection with DYNAMIC_EXTERNAL_WORKFLOW_ROUTED action + const secondDEWAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.DYNAMIC_EXTERNAL_WORKFLOW_ROUTED, + reportActionID: '2DEW', + originalMessage: {to: 'example@gmail.com'}, + } as ReportAction; + const fourthDEWAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.DYNAMIC_EXTERNAL_WORKFLOW_ROUTED, + reportActionID: '4DEW', + originalMessage: {to: 'example2@gmail.com'}, + } as ReportAction; + const expected: ReportActions = { + [firstAction.reportActionID]: firstAction, + [secondAction.reportActionID]: secondAction, + [secondDEWAction.reportActionID]: secondDEWAction, + [thirdAction.reportActionID]: thirdAction, + [fourthAction.reportActionID]: fourthAction, + [fourthDEWAction.reportActionID]: fourthDEWAction, + }; + const actual = ReportActionsUtils.withDEWRoutedActionsObject(reportActions); + + // Then DYNAMIC_EXTERNAL_WORKFLOW_ROUTED action should be added for each SUBMITTED and FORWARDED actions to the collection + expect(actual).toMatchObject(expected); + }); + + it(`should not add a DEW routed action if we don't have DEW SUBMITTED or FORWARDED action`, () => { + // Given a report actions collection with no DEW SUBMITTED or FORWARDED actions + const firstAction = {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, created: '', reportActionID: '1'}; + const secondAction = {actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, created: '', reportActionID: '2'}; + const thirdAction = {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, created: '', reportActionID: '3'}; + const fourthAction = {actionName: CONST.REPORT.ACTIONS.TYPE.FORWARDED, created: '', reportActionID: '4'}; + const reportActions: ReportActions = { + [firstAction.reportActionID]: firstAction, + [secondAction.reportActionID]: secondAction, + [thirdAction.reportActionID]: thirdAction, + [fourthAction.reportActionID]: fourthAction, + }; + + // When extending the collection with DYNAMIC_EXTERNAL_WORKFLOW_ROUTED action + const expected: ReportActions = { + [firstAction.reportActionID]: firstAction, + [secondAction.reportActionID]: secondAction, + [thirdAction.reportActionID]: thirdAction, + [fourthAction.reportActionID]: fourthAction, + }; + const actual = ReportActionsUtils.withDEWRoutedActionsObject(reportActions); + + // Then no DYNAMIC_EXTERNAL_WORKFLOW_ROUTED action should be added to the collection + expect(actual).toEqual(expected); + }); + }); + + describe('getDynamicExternalWorkflowRoutedMessage', () => { + it('should return the routed message', () => { + // Given a DYNAMIC_EXTERNAL_WORKFLOW_ROUTED action + const to = 'example@gmail.com'; + const action: ReportAction = { + reportActionID: '1', + actionName: CONST.REPORT.ACTIONS.TYPE.DYNAMIC_EXTERNAL_WORKFLOW_ROUTED, + created: '', + originalMessage: {to}, + }; + + // When getting the DYNAMIC_EXTERNAL_WORKFLOW_ROUTED action message + const actual = ReportActionsUtils.getDynamicExternalWorkflowRoutedMessage(action, translateLocal); + + // Then it should return the routed due to DEW message with the correct "to" value + const expected = translateLocal('iou.routedDueToDEW', {to}); + expect(actual).toBe(expected); + }); + }); }); diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 46e6bd82d7b2..a2e6ee8dfb8f 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -3397,6 +3397,13 @@ describe('ReportUtils', () => { expect(isThreadDisabled).toBeTruthy(); }); + it('should disable on a DYNAMIC_EXTERNAL_WORKFLOW_ROUTED action', () => { + const reportAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.DYNAMIC_EXTERNAL_WORKFLOW_ROUTED, + } as ReportAction; + expect(shouldDisableThread(reportAction, false, false)).toBeTruthy(); + }); + describe('deleted threads', () => { it('should be enabled if the report action is not-deleted and child visible action count is 1', () => { // Given a normal report action with one child visible action count diff --git a/tests/unit/SidebarUtilsTest.ts b/tests/unit/SidebarUtilsTest.ts index e01ce850444c..81bfe9d42a2a 100644 --- a/tests/unit/SidebarUtilsTest.ts +++ b/tests/unit/SidebarUtilsTest.ts @@ -1943,6 +1943,7 @@ describe('SidebarUtils', () => { currency: 'USD', message: '', to: 'email1@test.com', + workflow: CONST.POLICY.APPROVAL_MODE.BASIC, }, previousMessage: undefined, automatic: false, diff --git a/tests/unit/hooks/useOriginalReportID.test.tsx b/tests/unit/hooks/useOriginalReportID.test.tsx new file mode 100644 index 000000000000..b8c55405796a --- /dev/null +++ b/tests/unit/hooks/useOriginalReportID.test.tsx @@ -0,0 +1,53 @@ +import {renderHook} from '@testing-library/react-native'; +import type {ReactNode} from 'react'; +import Onyx from 'react-native-onyx'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import useOriginalReportID from '@hooks/useOriginalReportID'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {ReportAction} from '@src/types/onyx'; + +describe('useOriginalReportID', () => { + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + }); + }); + + beforeEach(() => { + jest.clearAllMocks(); + Onyx.clear(); + }); + + it('should return the reportID given a DEW routed action', async () => { + // Given a report actions that contain DEW SUBMITTED action + const reportID = '1'; + const dewSubmittedAction: ReportAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, + created: '', + reportActionID: '1', + originalMessage: { + workflow: CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL, + to: 'example@gmail.com', + amount: 1, + currency: CONST.CURRENCY.USD, + }, + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { + [dewSubmittedAction.reportActionID]: dewSubmittedAction, + }); + + const wrapper = ({children}: {children: ReactNode}) => {children}; + + // When getting the original reportID of a DEW routed action that is derived from the DEW SUBMITTED action + const dewRoutedAction: ReportAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.DYNAMIC_EXTERNAL_WORKFLOW_ROUTED, + created: '', + reportActionID: '1DEW', + }; + const {result} = renderHook(() => useOriginalReportID(reportID, dewRoutedAction), {wrapper}); + + // Then it should successfully return the reportID of the action + expect(result.current).toBe(reportID); + }); +});