From 997027d7996d21bbdeeb5b67c3d45c1fcdbd10df Mon Sep 17 00:00:00 2001 From: Bernhard Owen Josephus Date: Sun, 14 Dec 2025 23:39:48 +0800 Subject: [PATCH 01/13] show concierge system message after submit/approval action --- src/CONST/index.ts | 1 + src/hooks/useOriginalReportID.ts | 4 +- src/languages/de.ts | 1 + src/languages/en.ts | 2 + src/languages/es.ts | 1 + src/languages/fr.ts | 1 + src/languages/it.ts | 1 + src/languages/ja.ts | 1 + src/languages/nl.ts | 1 + src/languages/params.ts | 5 + src/languages/pl.ts | 1 + src/languages/pt-BR.ts | 1 + src/languages/zh-hans.ts | 1 + src/libs/OptionsListUtils/index.ts | 19 +- src/libs/ReportActionsUtils.ts | 65 +++- src/libs/ReportUtils.ts | 4 +- .../BaseReportActionContextMenu.tsx | 3 +- .../report/ContextMenu/ContextMenuActions.tsx | 21 +- src/types/onyx/OriginalMessage.ts | 15 + tests/unit/OptionsListUtilsTest.tsx | 24 +- tests/unit/ReportActionsUtilsTest.ts | 318 +++++++++++++++++- tests/unit/ReportUtilsTest.ts | 7 + 22 files changed, 477 insertions(+), 20 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 975cbd0052a7..ef816265fe2f 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1213,6 +1213,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 438a7f79fc6f..d34d232c9d87 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1570,6 +1570,7 @@ const translations: TranslationDeepObject = { }, }, chooseWorkspace: 'Arbeitsbereich auswählen', + routedDueToDEW: ({to}) => `bericht aufgrund eines benutzerdefinierten Genehmigungsworkflows an ${to} weitergeleitet`, }, transactionMerge: { listPage: { diff --git a/src/languages/en.ts b/src/languages/en.ts index 9eafd177a704..4bf5f859a89c 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -194,6 +194,7 @@ import type { RoleNamesParams, RoomNameReservedErrorParams, RoomRenamedToParams, + RoutedDueToDEWParams, RulesEnableWorkflowsParams, SecondaryLoginParams, SetTheDistanceMerchantParams, @@ -1541,6 +1542,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 c6810a535868..63278622b9ae 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1172,6 +1172,7 @@ const translations: TranslationDeepObject = { }, }, chooseWorkspace: 'Elige un espacio de trabajo', + routedDueToDEW: ({to}) => `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 f9b578835ca5..84ca446a1479 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1571,6 +1571,7 @@ const translations: TranslationDeepObject = { }, }, chooseWorkspace: 'Choisir un espace de travail', + routedDueToDEW: ({to}) => `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 a169b1e94713..651053ed9876 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1566,6 +1566,7 @@ const translations: TranslationDeepObject = { }, }, chooseWorkspace: 'Scegli uno spazio di lavoro', + routedDueToDEW: ({to}) => `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 52d58496a89b..8ba3b56452b2 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1567,6 +1567,7 @@ const translations: TranslationDeepObject = { }, }, chooseWorkspace: 'ワークスペースを選択', + routedDueToDEW: ({to}) => `カスタム承認ワークフローにより、${to} 宛にルーティングされたレポート`, }, transactionMerge: { listPage: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index e205c3130aff..8ffd2be2a075 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1564,6 +1564,7 @@ const translations: TranslationDeepObject = { }, }, chooseWorkspace: 'Kies een workspace', + routedDueToDEW: ({to}) => `rapport doorgestuurd naar ${to} vanwege aangepaste goedkeuringsworkflow`, }, transactionMerge: { listPage: { diff --git a/src/languages/params.ts b/src/languages/params.ts index 5efb1a2c5c82..ebcc27101c0f 100644 --- a/src/languages/params.ts +++ b/src/languages/params.ts @@ -973,6 +973,10 @@ type FocusModeUpdateParams = { priorityModePageUrl: string; }; +type RoutedDueToDEWParams = { + to: string; +}; + export type { SettlementAccountReconciliationParams, ToggleImportTitleParams, @@ -1291,4 +1295,5 @@ export type { ReportFieldParams, FocusModeUpdateParams, TagSelectionParams, + RoutedDueToDEWParams, }; diff --git a/src/languages/pl.ts b/src/languages/pl.ts index da8ef238832d..926a6dd73d12 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1563,6 +1563,7 @@ const translations: TranslationDeepObject = { }, }, chooseWorkspace: 'Wybierz przestrzeń roboczą', + routedDueToDEW: ({to}) => `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 e827ad66059f..579779fbbedc 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1562,6 +1562,7 @@ const translations: TranslationDeepObject = { }, }, chooseWorkspace: 'Escolha um workspace', + routedDueToDEW: ({to}) => `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 0ac0fd5f87e7..81581e755c7c 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1541,6 +1541,7 @@ const translations: TranslationDeepObject = { }, }, chooseWorkspace: '选择一个工作区', + routedDueToDEW: ({to}) => `报告因自定义审批工作流而转发至 ${to}`, }, transactionMerge: { listPage: { diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 8fb4177cd352..b0bd4cdb937f 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, @@ -54,6 +55,7 @@ import { getRoomAvatarUpdatedMessage, getRoomChangeLogMessage, getSortedReportActions, + getSortedReportActionsForDisplay, getTravelUpdateMessage, getUpdateRoomDescriptionMessage, isActionableAddPaymentCard, @@ -266,10 +268,13 @@ Onyx.connect({ continue; } + const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; + const reportNameValuePairs = allReportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`]; + const isReportArchived = !!reportNameValuePairs?.private_isArchived; + const isWriteActionAllowed = canUserPerformWriteAction(report, isReportArchived); const reportActionsArray = Object.values(reportActions[1] ?? {}); - let sortedReportActions = getSortedReportActions(reportActionsArray, true); + let sortedReportActions = getSortedReportActionsForDisplay(reportActionsArray, isWriteActionAllowed); allSortedReportActions[reportID] = sortedReportActions; - const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; const chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`]; // If the report is a one-transaction report and has , we need to return the combined reportActions so that the LHN can display modifications @@ -287,16 +292,11 @@ Onyx.connect({ lastReportActions[reportID] = firstReportAction; } - const reportNameValuePairs = allReportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`]; - const isReportArchived = !!reportNameValuePairs?.private_isArchived; - const isWriteActionAllowed = canUserPerformWriteAction(report, isReportArchived); - // The report is only visible if it is the last action not deleted that // does not match a closed or created state. const reportActionsForDisplay = sortedReportActions.filter( - (reportAction, actionKey) => + (reportAction) => (!(isWhisperAction(reportAction) && !isReportPreviewAction(reportAction) && !isMoneyRequestAction(reportAction)) || isActionableMentionWhisper(reportAction)) && - shouldReportActionBeVisible(reportAction, actionKey, isWriteActionAllowed) && reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED && reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, ); @@ -617,6 +617,7 @@ function getLastMessageTextForReport({ const reportID = report?.reportID; const lastReportAction = reportID ? lastVisibleReportActions[reportID] : undefined; const lastVisibleMessage = getLastVisibleMessage(report?.reportID); + console.log('last action', lastReportAction); // some types of actions are filtered out for lastReportAction, in some cases we need to check the actual last action const lastOriginalReportAction = reportID ? lastReportActions[reportID] : undefined; @@ -789,6 +790,8 @@ 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)) { + 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 bfc9c958ed86..bd6c36987468 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'; @@ -219,6 +220,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; } @@ -235,6 +240,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); } @@ -1099,6 +1108,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)) { + 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)) { + 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 @@ -1124,7 +1170,7 @@ function getSortedReportActionsForDisplay( } const baseURLAdjustedReportActions = filteredReportActions.map((reportAction) => replaceBaseURLInPolicyChangeLogAction(reportAction)); - return getSortedReportActions(baseURLAdjustedReportActions, true); + return getSortedReportActions(withDEWRoutedActionsArray(baseURLAdjustedReportActions), true); } /** @@ -1955,6 +2001,11 @@ function getReportActionMessageFragments(action: ReportAction): Message[] { return [{text: message, html: message, type: 'COMMENT'}]; } + if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.DYNAMIC_EXTERNAL_WORKFLOW_ROUTED)) { + 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); @@ -3299,6 +3350,13 @@ function getChangedApproverActionMessage>, + translate: LocaleContextProps['translate'], +) { + return translate('iou.routedDueToDEW', {to: getOriginalMessage(action)!.to}); +} + function isCardIssuedAction( reportAction: OnyxEntry, ): reportAction is ReportAction< @@ -3539,6 +3597,7 @@ export { getUpdatedApprovalRuleMessage, getRemovedFromApprovalChainMessage, getDemotedFromWorkspaceMessage, + getDynamicExternalWorkflowRoutedMessage, getReportAction, getReportActionHtml, getReportActionMessage, @@ -3611,8 +3670,10 @@ export { isWhisperAction, isSubmittedAction, isSubmittedAndClosedAction, + isDynamicExternalWorkflowSubmitAction, isMarkAsClosedAction, isApprovedAction, + isDynamicExternalWorkflowForwardedAction, isUnapprovedAction, isForwardedAction, isWhisperActionTargetedToOthers, @@ -3696,6 +3757,8 @@ export { filterOutDeprecatedReportActions, getActionableCardFraudAlertMessage, isSystemUserMentioned, + withDEWRoutedActionsArray, + withDEWRoutedActionsObject, }; export type {LastVisibleMessage}; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 0fcd11e1aacc..48e8551c9902 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -10766,6 +10766,7 @@ function shouldDisableThread(reportAction: OnyxInputOrEntry, repor 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); return ( @@ -10774,7 +10775,8 @@ function shouldDisableThread(reportAction: OnyxInputOrEntry, repor (isDeletedActionLocal && !reportAction?.childVisibleActionCount) || (isReportArchived && !reportAction?.childVisibleActionCount) || (isWhisperActionLocal && !isReportPreviewActionLocal && !isIOUAction) || - isThreadReportParentAction + isThreadReportParentAction || + isDynamicWorkflowRoutedAction ); } diff --git a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx index aba61908fd89..879c416a9d52 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, getSourceIDFromReportAction, @@ -162,6 +162,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 2aee575bc8f0..b5ca5558a550 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, getIntegrationSyncFailedMessage, @@ -280,7 +281,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; @@ -361,7 +365,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) { @@ -479,13 +486,16 @@ const ContextMenuActions: ContextMenuAction[] = [ const isWhisperAction = isWhisperActionReportActionsUtils(reportAction) || isActionableTrackExpense(reportAction); const isExpenseReportAction = isMoneyRequestAction(reportAction) || isReportPreviewActionReportActionsUtils(reportAction); const isTaskAction = isCreatedTaskReportAction(reportAction); + const isDynamicWorkflowRoutedAction = isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.DYNAMIC_EXTERNAL_WORKFLOW_ROUTED); + return ( !subscribed && !isWhisperAction && !isTaskAction && !isExpenseReportAction && !isThreadReportParentAction && - (shouldDisplayThreadReplies || (!isDeletedAction && !isArchivedRoom)) + (shouldDisplayThreadReplies || (!isDeletedAction && !isArchivedRoom)) && + !isDynamicWorkflowRoutedAction ); }, onPress: (closePopover, {reportAction, reportID}) => { @@ -839,6 +849,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 (content) { setClipboardMessage( content.replaceAll(/()(.*?)(<\/mention-user>)/gi, (match, openTag: string, innerContent: string, closeTag: string): string => { @@ -868,7 +880,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..a2342b9f7fd0 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,11 @@ type OriginalMessageDismissedViolation = { violationName: string; }; +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 +891,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 +1078,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 36405fddcf14..c83d4f3af344 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', () => { @@ -2803,6 +2803,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 14e476617b78..ffbfb8f349ab 100644 --- a/tests/unit/ReportActionsUtilsTest.ts +++ b/tests/unit/ReportActionsUtilsTest.ts @@ -9,7 +9,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'; @@ -1041,6 +1041,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', () => { @@ -1522,4 +1542,300 @@ describe('ReportActionsUtils', () => { expect(ReportActionsUtils.isDeletedAction(action)).toBe(false); }); }); + + 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: Partial[] = [ + {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 + expected.forEach((e, i) => { + expect(actual[i]).toEqual(expect.objectContaining(e)); + }); + }); + + 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 reportActions: ReportActions = { + 1: {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, created: '', reportActionID: '1'}, + 2: { + actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, + created: '', + reportActionID: '2', + originalMessage: {workflow: CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL, to: 'example@gmail.com'}, + }, + 3: {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, created: '', reportActionID: '3'}, + 4: { + actionName: CONST.REPORT.ACTIONS.TYPE.FORWARDED, + created: '', + reportActionID: '4', + originalMessage: {workflow: CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL, to: 'example2@gmail.com'}, + }, + }; + + // When extending the collection with DYNAMIC_EXTERNAL_WORKFLOW_ROUTED action + const expected: ReportActions = { + 1: {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, created: '', reportActionID: '1'}, + 2: { + actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, + created: '', + reportActionID: '2', + originalMessage: {workflow: CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL, to: 'example@gmail.com'}, + }, + '2DEW': {actionName: CONST.REPORT.ACTIONS.TYPE.DYNAMIC_EXTERNAL_WORKFLOW_ROUTED, reportActionID: '2DEW', originalMessage: {to: 'example@gmail.com'}} as ReportAction, + 3: {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, created: '', reportActionID: '3'}, + 4: { + actionName: CONST.REPORT.ACTIONS.TYPE.FORWARDED, + created: '', + reportActionID: '4', + originalMessage: {workflow: CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL, to: 'example2@gmail.com'}, + }, + '4DEW': {actionName: CONST.REPORT.ACTIONS.TYPE.DYNAMIC_EXTERNAL_WORKFLOW_ROUTED, reportActionID: '4DEW', originalMessage: {to: 'example2@gmail.com'}} as ReportAction, + }; + 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 reportActions: ReportActions = { + 1: {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, created: '', reportActionID: '1'}, + 2: {actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, created: '', reportActionID: '2'}, + 3: {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, created: '', reportActionID: '3'}, + 4: {actionName: CONST.REPORT.ACTIONS.TYPE.FORWARDED, created: '', reportActionID: '4'}, + }; + + // When extending the collection with DYNAMIC_EXTERNAL_WORKFLOW_ROUTED action + const expected: ReportActions = { + 1: {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, created: '', reportActionID: '1'}, + 2: {actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, created: '', reportActionID: '2'}, + 3: {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, created: '', reportActionID: '3'}, + 4: {actionName: CONST.REPORT.ACTIONS.TYPE.FORWARDED, created: '', reportActionID: '4'}, + }; + 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 b1b8b70312d1..99ad554df7d0 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -3355,6 +3355,13 @@ describe('ReportUtils', () => { expect(shouldDisableThread(reportAction, reportID, true)).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, reportID, true)).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 From a0a912a7f6465d2fbb82c0101e27832e9b4f78c3 Mon Sep 17 00:00:00 2001 From: Bernhard Owen Josephus Date: Sun, 14 Dec 2025 23:54:39 +0800 Subject: [PATCH 02/13] remove log --- src/libs/OptionsListUtils/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index b0bd4cdb937f..36a62bb909ef 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -617,7 +617,6 @@ function getLastMessageTextForReport({ const reportID = report?.reportID; const lastReportAction = reportID ? lastVisibleReportActions[reportID] : undefined; const lastVisibleMessage = getLastVisibleMessage(report?.reportID); - console.log('last action', lastReportAction); // some types of actions are filtered out for lastReportAction, in some cases we need to check the actual last action const lastOriginalReportAction = reportID ? lastReportActions[reportID] : undefined; From 7ef0b5cbc8b23df103de697529ac42c34ab24dc8 Mon Sep 17 00:00:00 2001 From: Bernhard Owen Josephus Date: Sun, 14 Dec 2025 23:57:20 +0800 Subject: [PATCH 03/13] add missing param --- src/languages/de.ts | 3 ++- src/languages/es.ts | 4 ++-- src/languages/fr.ts | 3 ++- src/languages/it.ts | 3 ++- src/languages/ja.ts | 3 ++- src/languages/nl.ts | 3 ++- src/languages/pl.ts | 3 ++- src/languages/pt-BR.ts | 3 ++- src/languages/zh-hans.ts | 3 ++- 9 files changed, 18 insertions(+), 10 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index d34d232c9d87..58e65a8add62 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -206,6 +206,7 @@ import type { RoleNamesParams, RoomNameReservedErrorParams, RoomRenamedToParams, + RoutedDueToDEWParams, RulesEnableWorkflowsParams, SecondaryLoginParams, SetTheDistanceMerchantParams, @@ -1570,7 +1571,7 @@ const translations: TranslationDeepObject = { }, }, chooseWorkspace: 'Arbeitsbereich auswählen', - routedDueToDEW: ({to}) => `bericht aufgrund eines benutzerdefinierten Genehmigungsworkflows an ${to} weitergeleitet`, + routedDueToDEW: ({to}: RoutedDueToDEWParams) => `bericht aufgrund eines benutzerdefinierten Genehmigungsworkflows an ${to} weitergeleitet`, }, transactionMerge: { listPage: { diff --git a/src/languages/es.ts b/src/languages/es.ts index 63278622b9ae..f30c985a2997 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 {BeginningOfChatHistoryParams, TagSelectionParams, ViolationsRterParams} from './params'; +import type {BeginningOfChatHistoryParams, RoutedDueToDEWParams, TagSelectionParams, ViolationsRterParams} from './params'; import type {TranslationDeepObject} from './types'; /* eslint-disable max-len */ @@ -1172,7 +1172,7 @@ const translations: TranslationDeepObject = { }, }, chooseWorkspace: 'Elige un espacio de trabajo', - routedDueToDEW: ({to}) => `informe enviado a ${to} debido a un flujo de aprobación personalizado`, + 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 84ca446a1479..37a6d1961612 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -206,6 +206,7 @@ import type { RoleNamesParams, RoomNameReservedErrorParams, RoomRenamedToParams, + RoutedDueToDEWParams, RulesEnableWorkflowsParams, SecondaryLoginParams, SetTheDistanceMerchantParams, @@ -1571,7 +1572,7 @@ const translations: TranslationDeepObject = { }, }, chooseWorkspace: 'Choisir un espace de travail', - routedDueToDEW: ({to}) => `rapport acheminé vers ${to} en raison d'un workflow d'approbation personnalisé`, + 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 651053ed9876..1a9eb378db14 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -206,6 +206,7 @@ import type { RoleNamesParams, RoomNameReservedErrorParams, RoomRenamedToParams, + RoutedDueToDEWParams, RulesEnableWorkflowsParams, SecondaryLoginParams, SetTheDistanceMerchantParams, @@ -1566,7 +1567,7 @@ const translations: TranslationDeepObject = { }, }, chooseWorkspace: 'Scegli uno spazio di lavoro', - routedDueToDEW: ({to}) => `rapporto inoltrato a ${to} a causa del flusso di lavoro di approvazione personalizzato`, + 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 8ba3b56452b2..7b783896a0c3 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -206,6 +206,7 @@ import type { RoleNamesParams, RoomNameReservedErrorParams, RoomRenamedToParams, + RoutedDueToDEWParams, RulesEnableWorkflowsParams, SecondaryLoginParams, SetTheDistanceMerchantParams, @@ -1567,7 +1568,7 @@ const translations: TranslationDeepObject = { }, }, chooseWorkspace: 'ワークスペースを選択', - routedDueToDEW: ({to}) => `カスタム承認ワークフローにより、${to} 宛にルーティングされたレポート`, + routedDueToDEW: ({to}: RoutedDueToDEWParams) => `カスタム承認ワークフローにより、${to} 宛にルーティングされたレポート`, }, transactionMerge: { listPage: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 8ffd2be2a075..d1cd8dbdeeff 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -206,6 +206,7 @@ import type { RoleNamesParams, RoomNameReservedErrorParams, RoomRenamedToParams, + RoutedDueToDEWParams, RulesEnableWorkflowsParams, SecondaryLoginParams, SetTheDistanceMerchantParams, @@ -1564,7 +1565,7 @@ const translations: TranslationDeepObject = { }, }, chooseWorkspace: 'Kies een workspace', - routedDueToDEW: ({to}) => `rapport doorgestuurd naar ${to} vanwege aangepaste goedkeuringsworkflow`, + routedDueToDEW: ({to}: RoutedDueToDEWParams) => `rapport doorgestuurd naar ${to} vanwege aangepaste goedkeuringsworkflow`, }, transactionMerge: { listPage: { diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 926a6dd73d12..e78b7e5686ca 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -206,6 +206,7 @@ import type { RoleNamesParams, RoomNameReservedErrorParams, RoomRenamedToParams, + RoutedDueToDEWParams, RulesEnableWorkflowsParams, SecondaryLoginParams, SetTheDistanceMerchantParams, @@ -1563,7 +1564,7 @@ const translations: TranslationDeepObject = { }, }, chooseWorkspace: 'Wybierz przestrzeń roboczą', - routedDueToDEW: ({to}) => `raport przekazany do ${to} z powodu niestandardowego procesu zatwierdzania`, + 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 579779fbbedc..5fe1645742f0 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -206,6 +206,7 @@ import type { RoleNamesParams, RoomNameReservedErrorParams, RoomRenamedToParams, + RoutedDueToDEWParams, RulesEnableWorkflowsParams, SecondaryLoginParams, SetTheDistanceMerchantParams, @@ -1562,7 +1563,7 @@ const translations: TranslationDeepObject = { }, }, chooseWorkspace: 'Escolha um workspace', - routedDueToDEW: ({to}) => `relatório encaminhado para ${to} devido ao fluxo de trabalho de aprovação personalizado`, + 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 81581e755c7c..4b356f4ee8d6 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -206,6 +206,7 @@ import type { RoleNamesParams, RoomNameReservedErrorParams, RoomRenamedToParams, + RoutedDueToDEWParams, RulesEnableWorkflowsParams, SecondaryLoginParams, SetTheDistanceMerchantParams, @@ -1541,7 +1542,7 @@ const translations: TranslationDeepObject = { }, }, chooseWorkspace: '选择一个工作区', - routedDueToDEW: ({to}) => `报告因自定义审批工作流而转发至 ${to}`, + routedDueToDEW: ({to}: RoutedDueToDEWParams) => `报告因自定义审批工作流而转发至 ${to}`, }, transactionMerge: { listPage: { From fb158b874fc33f883e38c84bca73e81ca086237e Mon Sep 17 00:00:00 2001 From: Bernhard Owen Josephus Date: Mon, 15 Dec 2025 14:23:23 +0800 Subject: [PATCH 04/13] lint --- src/libs/OptionsListUtils/index.ts | 2 +- src/libs/ReportActionsUtils.ts | 3 +- src/libs/ReportUtils.ts | 9 ++- src/libs/actions/IOU.ts | 8 ++- src/types/onyx/OriginalMessage.ts | 1 + tests/unit/ReportActionsUtilsTest.ts | 92 +++++++++++++++------------- tests/unit/SidebarUtilsTest.ts | 1 + 7 files changed, 70 insertions(+), 46 deletions(-) diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 36a62bb909ef..57347dc8ebc4 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -54,7 +54,6 @@ import { getRetractedMessage, getRoomAvatarUpdatedMessage, getRoomChangeLogMessage, - getSortedReportActions, getSortedReportActionsForDisplay, getTravelUpdateMessage, getUpdateRoomDescriptionMessage, @@ -790,6 +789,7 @@ function getLastMessageTextForReport({ } 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); } diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 7c8c815c79cf..745129a240ef 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -2006,6 +2006,7 @@ function getReportActionMessageFragments(action: ReportAction): Message[] { } 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'}]; } @@ -3358,7 +3359,7 @@ function getDynamicExternalWorkflowRoutedMessage( action: OnyxEntry>, translate: LocaleContextProps['translate'], ) { - return translate('iou.routedDueToDEW', {to: getOriginalMessage(action)!.to}); + return translate('iou.routedDueToDEW', {to: getOriginalMessage(action)?.to ?? ''}); } function isCardIssuedAction( diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index d06ee2e5f957..b77e7d26268e 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -7543,11 +7543,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, +): OptimisticSubmittedReportAction { const originalMessage = { amount, currency, expenseReportID, + workflow, }; const delegateAccountDetails = getPersonalDetailByEmail(delegateEmail); diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 3456d99b66ed..3f92cbce644d 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -11493,7 +11493,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/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts index a2342b9f7fd0..bb6feefb5039 100644 --- a/src/types/onyx/OriginalMessage.ts +++ b/src/types/onyx/OriginalMessage.ts @@ -831,6 +831,7 @@ type OriginalMessageDismissedViolation = { violationName: string; }; +/** Model of DYNAMIC_EXTERNAL_WORKFLOW_ROUTED report action */ type OriginalMessageDynamicExternalWorkflowRouted = { /** The approver of the report is submitted to */ to: string; diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts index 175870d189df..42e966761a25 100644 --- a/tests/unit/ReportActionsUtilsTest.ts +++ b/tests/unit/ReportActionsUtilsTest.ts @@ -1711,7 +1711,7 @@ describe('ReportActionsUtils', () => { ]; // When extending the array with DYNAMIC_EXTERNAL_WORKFLOW_ROUTED action - const expected: Partial[] = [ + const expected: Array> = [ {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, created: '', reportActionID: '1'}, { actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, @@ -1732,9 +1732,9 @@ describe('ReportActionsUtils', () => { const actual = ReportActionsUtils.withDEWRoutedActionsArray(reportActions); // Then DYNAMIC_EXTERNAL_WORKFLOW_ROUTED action should be added for each SUBMITTED and FORWARDED actions to the array - expected.forEach((e, i) => { - expect(actual[i]).toEqual(expect.objectContaining(e)); - }); + 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`, () => { @@ -1763,41 +1763,45 @@ describe('ReportActionsUtils', () => { 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 = { - 1: {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, created: '', reportActionID: '1'}, - 2: { - actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, - created: '', - reportActionID: '2', - originalMessage: {workflow: CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL, to: 'example@gmail.com'}, - }, - 3: {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, created: '', reportActionID: '3'}, - 4: { - actionName: CONST.REPORT.ACTIONS.TYPE.FORWARDED, - created: '', - reportActionID: '4', - originalMessage: {workflow: CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL, to: 'example2@gmail.com'}, - }, + [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 = { - 1: {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, created: '', reportActionID: '1'}, - 2: { - actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, - created: '', - reportActionID: '2', - originalMessage: {workflow: CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL, to: 'example@gmail.com'}, - }, - '2DEW': {actionName: CONST.REPORT.ACTIONS.TYPE.DYNAMIC_EXTERNAL_WORKFLOW_ROUTED, reportActionID: '2DEW', originalMessage: {to: 'example@gmail.com'}} as ReportAction, - 3: {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, created: '', reportActionID: '3'}, - 4: { - actionName: CONST.REPORT.ACTIONS.TYPE.FORWARDED, - created: '', - reportActionID: '4', - originalMessage: {workflow: CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL, to: 'example2@gmail.com'}, - }, - '4DEW': {actionName: CONST.REPORT.ACTIONS.TYPE.DYNAMIC_EXTERNAL_WORKFLOW_ROUTED, reportActionID: '4DEW', originalMessage: {to: 'example2@gmail.com'}} as ReportAction, + [firstAction.reportActionID]: firstAction, + [secondAction.reportActionID]: secondAction, + [secondDEWAction.reportActionID]: secondDEWAction, + [thirdAction.reportActionID]: thirdAction, + [fourthAction.reportActionID]: fourthAction, + [fourthDEWAction.reportActionID]: fourthDEWAction, }; const actual = ReportActionsUtils.withDEWRoutedActionsObject(reportActions); @@ -1807,19 +1811,23 @@ describe('ReportActionsUtils', () => { 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 = { - 1: {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, created: '', reportActionID: '1'}, - 2: {actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, created: '', reportActionID: '2'}, - 3: {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, created: '', reportActionID: '3'}, - 4: {actionName: CONST.REPORT.ACTIONS.TYPE.FORWARDED, created: '', reportActionID: '4'}, + [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 = { - 1: {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, created: '', reportActionID: '1'}, - 2: {actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, created: '', reportActionID: '2'}, - 3: {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, created: '', reportActionID: '3'}, - 4: {actionName: CONST.REPORT.ACTIONS.TYPE.FORWARDED, created: '', reportActionID: '4'}, + [firstAction.reportActionID]: firstAction, + [secondAction.reportActionID]: secondAction, + [thirdAction.reportActionID]: thirdAction, + [fourthAction.reportActionID]: fourthAction, }; const actual = ReportActionsUtils.withDEWRoutedActionsObject(reportActions); 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, From 11ef3ee5784eff0d81313a42b4763c5786a0ee86 Mon Sep 17 00:00:00 2001 From: Bernhard Owen Josephus Date: Tue, 16 Dec 2025 12:00:43 +0800 Subject: [PATCH 05/13] lint --- src/libs/actions/IOU.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 2b54da5769ba..3fe50a21fe1a 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -11501,7 +11501,7 @@ function submitReport( expenseReport.currency ?? '', expenseReport.reportID, adminAccountID, - policy?.approvalMode, + policy?.approvalMode ?? CONST.POLICY.APPROVAL_MODE.BASIC, ); // buildOptimisticNextStep is used in parallel From a5304d668096c88335daddb34ea24dc9a7913e20 Mon Sep 17 00:00:00 2001 From: Bernhard Owen Josephus Date: Wed, 17 Dec 2025 23:07:34 +0800 Subject: [PATCH 06/13] fix type --- tests/unit/ReportUtilsTest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index b417319557ad..046d9d368935 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -3401,7 +3401,7 @@ describe('ReportUtils', () => { const reportAction = { actionName: CONST.REPORT.ACTIONS.TYPE.DYNAMIC_EXTERNAL_WORKFLOW_ROUTED, } as ReportAction; - expect(shouldDisableThread(reportAction, reportID, true)).toBeTruthy(); + expect(shouldDisableThread(reportAction, false, false)).toBeTruthy(); }); describe('deleted threads', () => { From d826503f0704684a9ac67221aec28408e7535c22 Mon Sep 17 00:00:00 2001 From: Bernhard Owen Josephus Date: Thu, 18 Dec 2025 10:30:42 +0800 Subject: [PATCH 07/13] fix dew routed action detail is missing --- src/pages/Debug/ReportAction/DebugReportActionPage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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], ); From c5d3751a7bb978b65b206aacd07b3addf5dbd40a Mon Sep 17 00:00:00 2001 From: Bernhard Owen Josephus Date: Thu, 18 Dec 2025 10:34:12 +0800 Subject: [PATCH 08/13] don't show dew routed action until the report is submitted --- src/libs/ReportActionsUtils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 6b9a70d81088..cb87de14c534 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -1126,7 +1126,7 @@ function getDynamicExternalWorkflowRoutedAction( function withDEWRoutedActionsArray(reportActions: ReportAction[]): ReportAction[] { return reportActions.flatMap((reportAction) => { - if (isDynamicExternalWorkflowSubmitAction(reportAction) || isDynamicExternalWorkflowForwardedAction(reportAction)) { + if ((isDynamicExternalWorkflowSubmitAction(reportAction) || isDynamicExternalWorkflowForwardedAction(reportAction)) && getOriginalMessage(reportAction)?.to) { return [reportAction, getDynamicExternalWorkflowRoutedAction(reportAction)]; } return reportAction; @@ -1138,7 +1138,7 @@ function withDEWRoutedActionsObject(reportActions: OnyxEntry): On const [reportActionID, reportAction] = value; acc[reportActionID] = reportAction; - if (isDynamicExternalWorkflowSubmitAction(reportAction) || isDynamicExternalWorkflowForwardedAction(reportAction)) { + if ((isDynamicExternalWorkflowSubmitAction(reportAction) || isDynamicExternalWorkflowForwardedAction(reportAction)) && getOriginalMessage(reportAction)?.to) { const dynamicExternalWorkflowRoutedAction = getDynamicExternalWorkflowRoutedAction(reportAction); acc[dynamicExternalWorkflowRoutedAction.reportActionID] = dynamicExternalWorkflowRoutedAction; } From 3c87f0e5bd0557c62b083b218caf9c702f0ef059 Mon Sep 17 00:00:00 2001 From: Bernhard Owen Josephus Date: Thu, 18 Dec 2025 11:02:30 +0800 Subject: [PATCH 09/13] add test --- tests/unit/hooks/useOriginalReportID.test.tsx | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 tests/unit/hooks/useOriginalReportID.test.tsx diff --git a/tests/unit/hooks/useOriginalReportID.test.tsx b/tests/unit/hooks/useOriginalReportID.test.tsx new file mode 100644 index 000000000000..6225bc3f06c5 --- /dev/null +++ b/tests/unit/hooks/useOriginalReportID.test.tsx @@ -0,0 +1,53 @@ +import {renderHook} from '@testing-library/react-native'; +import {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 {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); + }); +}); From ff91890dcc1d7b9e4c14662d0b95ee7d3eb0dcf5 Mon Sep 17 00:00:00 2001 From: Bernhard Owen Josephus Date: Thu, 18 Dec 2025 11:04:52 +0800 Subject: [PATCH 10/13] lint --- tests/unit/hooks/useOriginalReportID.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/hooks/useOriginalReportID.test.tsx b/tests/unit/hooks/useOriginalReportID.test.tsx index 6225bc3f06c5..b8c55405796a 100644 --- a/tests/unit/hooks/useOriginalReportID.test.tsx +++ b/tests/unit/hooks/useOriginalReportID.test.tsx @@ -1,11 +1,11 @@ import {renderHook} from '@testing-library/react-native'; -import {ReactNode} from 'react'; +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 {ReportAction} from '@src/types/onyx'; +import type {ReportAction} from '@src/types/onyx'; describe('useOriginalReportID', () => { beforeAll(() => { From 80d821839290b9160cee3b7125e44463303e20ed Mon Sep 17 00:00:00 2001 From: Bernhard Owen Josephus Date: Thu, 18 Dec 2025 22:25:09 +0800 Subject: [PATCH 11/13] fix test --- src/libs/OptionsListUtils/index.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 0a6ee5be3799..4ba1b434371a 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -272,7 +272,7 @@ Onyx.connect({ const isReportArchived = !!reportNameValuePairs?.private_isArchived; const isWriteActionAllowed = canUserPerformWriteAction(report, isReportArchived); const reportActionsArray = Object.values(reportActions[1] ?? {}); - let sortedReportActions = getSortedReportActionsForDisplay(reportActionsArray, isWriteActionAllowed); + let sortedReportActions = getSortedReportActionsForDisplay(reportActionsArray, isWriteActionAllowed, true); allSortedReportActions[reportID] = sortedReportActions; const chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`]; @@ -294,8 +294,9 @@ Onyx.connect({ // The report is only visible if it is the last action not deleted that // does not match a closed or created state. const reportActionsForDisplay = sortedReportActions.filter( - (reportAction) => + (reportAction, actionKey) => (!(isWhisperAction(reportAction) && !isReportPreviewAction(reportAction) && !isMoneyRequestAction(reportAction)) || isActionableMentionWhisper(reportAction)) && + shouldReportActionBeVisible(reportAction, actionKey, isWriteActionAllowed) && reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED && reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, ); From 0aa8352ce60f805adf8c3b5ee8859bc979d00e5f Mon Sep 17 00:00:00 2001 From: Bernhard Owen Josephus Date: Thu, 18 Dec 2025 23:30:20 +0800 Subject: [PATCH 12/13] use getSortedReportActions back and apply withDEWRoutedActionsArray to it --- src/libs/OptionsListUtils/index.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 4ba1b434371a..1f6805b3313d 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -54,7 +54,7 @@ import { getRetractedMessage, getRoomAvatarUpdatedMessage, getRoomChangeLogMessage, - getSortedReportActionsForDisplay, + getSortedReportActions, getTravelUpdateMessage, getUpdateRoomDescriptionMessage, isActionableAddPaymentCard, @@ -82,6 +82,7 @@ import { isUnapprovedAction, isWhisperAction, shouldReportActionBeVisible, + withDEWRoutedActionsArray, } from '@libs/ReportActionsUtils'; import {computeReportName} from '@libs/ReportNameUtils'; import type {OptionData} from '@libs/ReportUtils'; @@ -267,13 +268,10 @@ Onyx.connect({ continue; } - const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; - const reportNameValuePairs = allReportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`]; - const isReportArchived = !!reportNameValuePairs?.private_isArchived; - const isWriteActionAllowed = canUserPerformWriteAction(report, isReportArchived); const reportActionsArray = Object.values(reportActions[1] ?? {}); - let sortedReportActions = getSortedReportActionsForDisplay(reportActionsArray, isWriteActionAllowed, 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}`]; // If the report is a one-transaction report and has , we need to return the combined reportActions so that the LHN can display modifications @@ -291,6 +289,10 @@ Onyx.connect({ lastReportActions[reportID] = firstReportAction; } + const reportNameValuePairs = allReportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`]; + const isReportArchived = !!reportNameValuePairs?.private_isArchived; + const isWriteActionAllowed = canUserPerformWriteAction(report, isReportArchived); + // The report is only visible if it is the last action not deleted that // does not match a closed or created state. const reportActionsForDisplay = sortedReportActions.filter( From 795998752f9adcce9f4e69efee3b3633a188f82e Mon Sep 17 00:00:00 2001 From: Bernhard Owen Josephus Date: Fri, 19 Dec 2025 22:25:58 +0800 Subject: [PATCH 13/13] make workflow type optional --- src/libs/ReportUtils.ts | 2 +- src/libs/actions/IOU.ts | 2 +- src/types/onyx/OriginalMessage.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 90af43a3160c..51e69b4d3734 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -7601,7 +7601,7 @@ function buildOptimisticSubmittedReportAction( currency: string, expenseReportID: string, adminAccountID: number | undefined, - workflow: ValueOf, + workflow: ValueOf | undefined, ): OptimisticSubmittedReportAction { const originalMessage = { amount, diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index e929acf28c5f..fa9aa59857f7 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -11644,7 +11644,7 @@ function submitReport( expenseReport.currency ?? '', expenseReport.reportID, adminAccountID, - policy?.approvalMode ?? CONST.POLICY.APPROVAL_MODE.BASIC, + policy?.approvalMode, ); // buildOptimisticNextStep is used in parallel diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts index bb6feefb5039..3aabb1ebefa6 100644 --- a/src/types/onyx/OriginalMessage.ts +++ b/src/types/onyx/OriginalMessage.ts @@ -237,7 +237,7 @@ type OriginalMessageSubmitted = { cc?: string; /** The workflow the report is submitted on */ - workflow: ValueOf; + workflow?: ValueOf; }; /** Model of `created` report action */ @@ -897,7 +897,7 @@ type OriginalMessageForwarded = { to?: string; /** The workflow the report is approved on */ - workflow: ValueOf; + workflow?: ValueOf; }; /**