From 3257aed8007ec8448ffde06f1b41b29f38a6a5a2 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Thu, 8 Jan 2026 23:49:13 +0100 Subject: [PATCH 01/35] Add DEW support for approve action --- src/CONST/index.ts | 1 + src/components/MoneyReportHeader.tsx | 29 +- src/languages/de.ts | 1 + src/languages/en.ts | 1 + 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/pl.ts | 1 + src/languages/pt-BR.ts | 1 + src/languages/zh-hans.ts | 1 + src/libs/NextStepUtils.ts | 21 +- src/libs/OptionsListUtils/index.ts | 10 +- src/libs/ReportActionsUtils.ts | 49 +++ src/libs/ReportUtils.ts | 19 +- src/libs/TransactionPreviewUtils.ts | 26 +- src/libs/actions/IOU/index.ts | 218 +++++++++---- src/libs/actions/Search.ts | 38 ++- src/pages/Search/SearchPage.tsx | 10 +- .../home/report/PureReportActionItem.tsx | 10 + src/types/onyx/OriginalMessage.ts | 1 + tests/unit/ReportActionsUtilsTest.ts | 297 ++++++++++++++++++ 23 files changed, 641 insertions(+), 98 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index cd678f72d1cd..f21a31b5082d 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1233,6 +1233,7 @@ const CONST = { DELETED_ACCOUNT: 'DELETEDACCOUNT', // Deprecated OldDot Action DELETED_TRANSACTION: 'DELETEDTRANSACTION', DEW_SUBMIT_FAILED: 'DEWSUBMITFAILED', + DEW_APPROVE_FAILED: 'DEWAPPROVEFAILED', DISMISSED_VIOLATION: 'DISMISSEDVIOLATION', DONATION: 'DONATION', // Deprecated OldDot Action DYNAMIC_EXTERNAL_WORKFLOW_ROUTED: 'DYNAMICEXTERNALWORKFLOWROUTED', diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 0751d9a2e57b..197b7d744eea 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -46,7 +46,8 @@ import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import type {ReportsSplitNavigatorParamList, RightModalNavigatorParamList, SearchFullscreenNavigatorParamList} from '@libs/Navigation/types'; import { - buildOptimisticNextStepForDEWOfflineSubmission, + buildOptimisticNextStepForDEWOffline, + buildOptimisticNextStepForDynamicExternalWorkflowApproveError, buildOptimisticNextStepForDynamicExternalWorkflowError, buildOptimisticNextStepForPreventSelfApprovalsEnabled, buildOptimisticNextStepForStrictPolicyRuleViolations, @@ -54,7 +55,7 @@ import { import type {KYCFlowEvent, TriggerKYCFlow} from '@libs/PaymentUtils'; import {selectPaymentType} from '@libs/PaymentUtils'; import {getConnectedIntegration, getValidConnectedIntegration, hasDynamicExternalWorkflow} from '@libs/PolicyUtils'; -import {getIOUActionForReportID, getOriginalMessage, getReportAction, hasPendingDEWSubmit, isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import {getIOUActionForReportID, getOriginalMessage, getReportAction, hasPendingDEWApprove, hasPendingDEWSubmit, isMoneyRequestAction} from '@libs/ReportActionsUtils'; import {getAllExpensesToHoldIfApplicable, getReportPrimaryAction, isMarkAsResolvedAction} from '@libs/ReportPrimaryActionUtils'; import {getSecondaryExportReportActions, getSecondaryReportActions} from '@libs/ReportSecondaryActionUtils'; import { @@ -480,8 +481,9 @@ function MoneyReportHeader({ let optimisticNextStep = isBlockSubmitDueToPreventSelfApproval ? buildOptimisticNextStepForPreventSelfApprovalsEnabled() : nextStep; - // Check for DEW submit failed or pending - show appropriate next step - if (isDEWBetaEnabled && hasDynamicExternalWorkflow(policy) && moneyRequestReport?.statusNum === CONST.REPORT.STATUS_NUM.OPEN) { + // Check for DEW submit/approve failed or pending - show appropriate next step + const isDEWPolicy = isDEWBetaEnabled && hasDynamicExternalWorkflow(policy); + if (isDEWPolicy && (moneyRequestReport?.statusNum === CONST.REPORT.STATUS_NUM.OPEN || moneyRequestReport?.statusNum === CONST.REPORT.STATUS_NUM.SUBMITTED)) { const reportActionsObject = reportActions.reduce((acc, action) => { if (action.reportActionID) { acc[action.reportActionID] = action; @@ -489,10 +491,19 @@ function MoneyReportHeader({ return acc; }, {}); const {errors} = getAllReportActionsErrorsAndReportActionThatRequiresAttention(moneyRequestReport, reportActionsObject); - if (errors?.dewSubmitFailed) { - optimisticNextStep = buildOptimisticNextStepForDynamicExternalWorkflowError(theme.danger); - } else if (isOffline && hasPendingDEWSubmit(reportMetadata, hasDynamicExternalWorkflow(policy))) { - optimisticNextStep = buildOptimisticNextStepForDEWOfflineSubmission(); + + if (moneyRequestReport?.statusNum === CONST.REPORT.STATUS_NUM.OPEN) { + if (errors?.dewSubmitFailed) { + optimisticNextStep = buildOptimisticNextStepForDynamicExternalWorkflowError(theme.danger); + } else if (isOffline && hasPendingDEWSubmit(reportMetadata, isDEWPolicy)) { + optimisticNextStep = buildOptimisticNextStepForDEWOffline(); + } + } else if (moneyRequestReport?.statusNum === CONST.REPORT.STATUS_NUM.SUBMITTED) { + if (errors?.dewApproveFailed) { + optimisticNextStep = buildOptimisticNextStepForDynamicExternalWorkflowApproveError(theme.danger); + } else if (isOffline && hasPendingDEWApprove(reportMetadata, isDEWPolicy)) { + optimisticNextStep = buildOptimisticNextStepForDEWOffline(); + } } } @@ -599,7 +610,7 @@ function MoneyReportHeader({ }; const confirmApproval = () => { - if (hasDynamicExternalWorkflow(policy)) { + if (hasDynamicExternalWorkflow(policy) && !isDEWBetaEnabled) { showDWEModal(); return; } diff --git a/src/languages/de.ts b/src/languages/de.ts index 736eab6999d3..92b63430c9b6 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1248,6 +1248,7 @@ const translations: TranslationDeepObject = { submitted: ({memo}: SubmittedWithMemoParams) => `Eingereicht${memo ? `, mit dem Hinweis ${memo}` : ''}`, automaticallySubmitted: `eingereicht über verspätete Einreichungen`, queuedToSubmitViaDEW: 'in die Warteschlange gestellt zur Einreichung über benutzerdefinierten Genehmigungsworkflow', + queuedToApproveViaDEW: 'in die Warteschlange gestellt zur Genehmigung über benutzerdefinierten Genehmigungsworkflow', trackedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `Verfolgung von ${formattedAmount}${comment ? `für ${comment}` : ''}`, splitAmount: ({amount}: SplitAmountParams) => `Split ${amount}`, didSplitAmount: ({formattedAmount, comment}: DidSplitAmountMessageParams) => `aufteilen ${formattedAmount}${comment ? `für ${comment}` : ''}`, diff --git a/src/languages/en.ts b/src/languages/en.ts index a0c754e62dbb..46032cfc8486 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1226,6 +1226,7 @@ const translations = { submitted: ({memo}: SubmittedWithMemoParams) => `submitted${memo ? `, saying ${memo}` : ''}`, automaticallySubmitted: `submitted via delay submissions`, queuedToSubmitViaDEW: 'queued to submit via custom approval workflow', + queuedToApproveViaDEW: 'queued to approve via custom approval workflow', trackedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `tracking ${formattedAmount}${comment ? ` for ${comment}` : ''}`, splitAmount: ({amount}: SplitAmountParams) => `split ${amount}`, didSplitAmount: ({formattedAmount, comment}: DidSplitAmountMessageParams) => `split ${formattedAmount}${comment ? ` for ${comment}` : ''}`, diff --git a/src/languages/es.ts b/src/languages/es.ts index 2ed90a809ac4..2e952a2f4189 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -934,6 +934,7 @@ const translations: TranslationDeepObject = { submitted: ({memo}) => `enviado${memo ? `, dijo ${memo}` : ''}`, automaticallySubmitted: `envió mediante retrasar envíos`, queuedToSubmitViaDEW: 'en cola para enviar a través del flujo de aprobación personalizado', + queuedToApproveViaDEW: 'en cola para aprobar a través del flujo de aprobación personalizado', trackedAmount: ({formattedAmount, comment}) => `realizó un seguimiento de ${formattedAmount}${comment ? ` para ${comment}` : ''}`, splitAmount: ({amount}) => `dividir ${amount}`, didSplitAmount: ({formattedAmount, comment}) => `dividió ${formattedAmount}${comment ? ` para ${comment}` : ''}`, diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 6974bab3986c..b2af52d95121 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1247,6 +1247,7 @@ const translations: TranslationDeepObject = { submitted: ({memo}: SubmittedWithMemoParams) => `envoyé${memo ? `, indiquant ${memo}` : ''}`, automaticallySubmitted: `soumis via retarder les soumissions`, queuedToSubmitViaDEW: "en file d'attente pour être soumis via le workflow d'approbation personnalisé", + queuedToApproveViaDEW: "en file d'attente pour approbation via le workflow d'approbation personnalisé", trackedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `suivi de ${formattedAmount}${comment ? `pour ${comment}` : ''}`, splitAmount: ({amount}: SplitAmountParams) => `diviser ${amount}`, didSplitAmount: ({formattedAmount, comment}: DidSplitAmountMessageParams) => `Diviser ${formattedAmount}${comment ? `pour ${comment}` : ''}`, diff --git a/src/languages/it.ts b/src/languages/it.ts index 0c6a41bf918b..d1d57e1f3bb6 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1243,6 +1243,7 @@ const translations: TranslationDeepObject = { submitted: ({memo}: SubmittedWithMemoParams) => `inviato${memo ? `, dicendo ${memo}` : ''}`, automaticallySubmitted: `inviato tramite invio ritardato`, queuedToSubmitViaDEW: "in coda per l'invio tramite flusso di approvazione personalizzato", + queuedToApproveViaDEW: "in coda per l'approvazione tramite flusso di approvazione personalizzato", trackedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `monitoraggio ${formattedAmount}${comment ? `per ${comment}` : ''}`, splitAmount: ({amount}: SplitAmountParams) => `dividi ${amount}`, didSplitAmount: ({formattedAmount, comment}: DidSplitAmountMessageParams) => `dividi ${formattedAmount}${comment ? `per ${comment}` : ''}`, diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 385fc00e960b..df2e8037644a 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1244,6 +1244,7 @@ const translations: TranslationDeepObject = { submitted: ({memo}: SubmittedWithMemoParams) => `送信済み${memo ? `、メモ「${memo}」と述べています` : ''}`, automaticallySubmitted: `提出を遅らせるを通じて送信されました`, queuedToSubmitViaDEW: 'カスタム承認ワークフローを介して送信待ちキューに入れられました', + queuedToApproveViaDEW: 'カスタム承認ワークフローを介して承認待ちキューに入れられました', trackedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `${formattedAmount}${comment ? `${comment} 用` : ''} を追跡中`, splitAmount: ({amount}: SplitAmountParams) => `${amount} を分割`, didSplitAmount: ({formattedAmount, comment}: DidSplitAmountMessageParams) => `分割 ${formattedAmount}${comment ? `${comment} 用` : ''}`, diff --git a/src/languages/nl.ts b/src/languages/nl.ts index d405ca615944..13ab57ed3ab8 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1243,6 +1243,7 @@ const translations: TranslationDeepObject = { submitted: ({memo}: SubmittedWithMemoParams) => `ingediend${memo ? `, met de melding ${memo}` : ''}`, automaticallySubmitted: `ingediend via indiening uitstellen`, queuedToSubmitViaDEW: 'in wachtrij geplaatst om in te dienen via aangepaste goedkeuringswerkstroom', + queuedToApproveViaDEW: 'in wachtrij geplaatst om goed te keuren via aangepaste goedkeuringswerkstroom', trackedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `bijhouden ${formattedAmount}${comment ? `voor ${comment}` : ''}`, splitAmount: ({amount}: SplitAmountParams) => `${amount} splits`, didSplitAmount: ({formattedAmount, comment}: DidSplitAmountMessageParams) => `splitsen ${formattedAmount}${comment ? `voor ${comment}` : ''}`, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index a60e02d7c502..993ee22466a7 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1242,6 +1242,7 @@ const translations: TranslationDeepObject = { submitted: ({memo}: SubmittedWithMemoParams) => `wysłano${memo ? `, mówiąc ${memo}` : ''}`, automaticallySubmitted: `wysłane przez opóźnij przesyłanie`, queuedToSubmitViaDEW: 'w kolejce do przesłania przez niestandardowy przepływ zatwierdzania', + queuedToApproveViaDEW: 'w kolejce do zatwierdzenia przez niestandardowy przepływ zatwierdzania', trackedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `śledzenie ${formattedAmount}${comment ? `dla ${comment}` : ''}`, splitAmount: ({amount}: SplitAmountParams) => `podziel ${amount}`, didSplitAmount: ({formattedAmount, comment}: DidSplitAmountMessageParams) => `podziel ${formattedAmount}${comment ? `dla ${comment}` : ''}`, diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 5c6d81a4e554..ace884fe5c42 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1242,6 +1242,7 @@ const translations: TranslationDeepObject = { submitted: ({memo}: SubmittedWithMemoParams) => `enviado${memo ? `, dizendo ${memo}` : ''}`, automaticallySubmitted: `enviado via atrasar envios`, queuedToSubmitViaDEW: 'enfileirado para envio via fluxo de aprovação personalizado', + queuedToApproveViaDEW: 'enfileirado para aprovação via fluxo de aprovação personalizado', trackedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `rastreamento de ${formattedAmount}${comment ? `para ${comment}` : ''}`, splitAmount: ({amount}: SplitAmountParams) => `dividir ${amount}`, didSplitAmount: ({formattedAmount, comment}: DidSplitAmountMessageParams) => `dividir ${formattedAmount}${comment ? `para ${comment}` : ''}`, diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 1783d8af0106..01f3d584c90c 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1225,6 +1225,7 @@ const translations: TranslationDeepObject = { submitted: ({memo}: SubmittedWithMemoParams) => `已提交${memo ? `,备注为 ${memo}` : ''}`, automaticallySubmitted: `通过 延迟提交 提交`, queuedToSubmitViaDEW: '已排队等待通过自定义审批工作流提交', + queuedToApproveViaDEW: '已排队等待通过自定义审批工作流审批', trackedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `正在跟踪 ${formattedAmount}${comment ? `为 ${comment}` : ''}`, splitAmount: ({amount}: SplitAmountParams) => `拆分 ${amount}`, didSplitAmount: ({formattedAmount, comment}: DidSplitAmountMessageParams) => `拆分 ${formattedAmount}${comment ? `为 ${comment}` : ''}`, diff --git a/src/libs/NextStepUtils.ts b/src/libs/NextStepUtils.ts index 998d886d6017..36cd6780a25f 100644 --- a/src/libs/NextStepUtils.ts +++ b/src/libs/NextStepUtils.ts @@ -348,7 +348,23 @@ function buildOptimisticNextStepForDynamicExternalWorkflowError(iconFill?: strin return optimisticNextStep; } -function buildOptimisticNextStepForDEWOfflineSubmission() { +function buildOptimisticNextStepForDynamicExternalWorkflowApproveError(iconFill?: string) { + const optimisticNextStep: ReportNextStepDeprecated = { + type: 'alert', + icon: CONST.NEXT_STEP.ICONS.DOT_INDICATOR, + iconFill, + message: [ + { + text: "This report can't be approved. Please review the comments to resolve.", + type: 'alert-text', + }, + ], + }; + + return optimisticNextStep; +} + +function buildOptimisticNextStepForDEWOffline() { const optimisticNextStep: ReportNextStepDeprecated = { type: 'neutral', icon: CONST.NEXT_STEP.ICONS.HOURGLASS, @@ -734,7 +750,8 @@ export { buildOptimisticNextStepForPreventSelfApprovalsEnabled, buildOptimisticNextStepForStrictPolicyRuleViolations, buildOptimisticNextStepForDynamicExternalWorkflowError, - buildOptimisticNextStepForDEWOfflineSubmission, + buildOptimisticNextStepForDynamicExternalWorkflowApproveError, + buildOptimisticNextStepForDEWOffline, // eslint-disable-next-line @typescript-eslint/no-deprecated buildNextStepNew, }; diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 339f1c9a81f8..74b573333e57 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -55,6 +55,7 @@ import { getSortedReportActions, getTravelUpdateMessage, getUpdateRoomDescriptionMessage, + hasPendingDEWApprove, hasPendingDEWSubmit, isActionableAddPaymentCard, isActionableJoinRequest, @@ -64,6 +65,7 @@ import { isClosedAction, isCreatedTaskReportAction, isDeletedParentAction, + isDynamicExternalWorkflowApproveFailedAction, isDynamicExternalWorkflowSubmitFailedAction, isInviteOrRemovedAction, isMarkAsClosedAction, @@ -723,14 +725,20 @@ function getLastMessageTextForReport({ } } else if (isActionOfType(lastReportAction, CONST.REPORT.ACTIONS.TYPE.APPROVED)) { const {automaticAction} = getOriginalMessage(lastReportAction) ?? {}; + const isDEWPolicy = hasDynamicExternalWorkflow(policy); + const isPendingAdd = lastReportAction.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD; + if (automaticAction) { // eslint-disable-next-line @typescript-eslint/no-deprecated lastMessageTextFromReport = Parser.htmlToText(translateLocal('iou.automaticallyApproved')); + } else if (hasPendingDEWApprove(reportMetadata, isDEWPolicy) && isPendingAdd) { + // eslint-disable-next-line @typescript-eslint/no-deprecated + lastMessageTextFromReport = translateLocal('iou.queuedToApproveViaDEW'); } else { // eslint-disable-next-line @typescript-eslint/no-deprecated lastMessageTextFromReport = translateLocal('iou.approvedMessage'); } - } else if (isDynamicExternalWorkflowSubmitFailedAction(lastReportAction)) { + } else if (isDynamicExternalWorkflowSubmitFailedAction(lastReportAction) || isDynamicExternalWorkflowApproveFailedAction(lastReportAction)) { // eslint-disable-next-line @typescript-eslint/no-deprecated lastMessageTextFromReport = getOriginalMessage(lastReportAction)?.message ?? translateLocal('iou.error.genericCreateFailureMessage'); } else if (isUnapprovedAction(lastReportAction)) { diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 9a2705aa2365..ca0231372288 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -305,6 +305,52 @@ function hasPendingDEWSubmit(reportMetadata: OnyxEntry, isDEWPol return reportMetadata?.pendingExpenseAction === CONST.EXPENSE_PENDING_ACTION.SUBMIT; } +function isDynamicExternalWorkflowApproveFailedAction(reportAction: OnyxInputOrEntry): reportAction is ReportAction { + return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.DEW_APPROVE_FAILED); +} + +function getMostRecentActiveDEWApproveFailedAction(reportActions: OnyxEntry | ReportAction[]): ReportAction | undefined { + const actionsArray = Array.isArray(reportActions) ? reportActions : Object.values(reportActions ?? {}); + + // Find the most recent DEW_APPROVE_FAILED action + const mostRecentDewApproveFailedAction = actionsArray + .filter((action): action is ReportAction => isDynamicExternalWorkflowApproveFailedAction(action)) + .reduce((latest, current) => { + if (!latest || (current.created && latest.created && current.created > latest.created)) { + return current; + } + return latest; + }, undefined); + + if (!mostRecentDewApproveFailedAction) { + return undefined; + } + + // Find the most recent APPROVED or FORWARDED action (successful approval supersedes the DEW failure) + const mostRecentApprovalAction = actionsArray + .filter((action): action is ReportAction => isApprovedAction(action) || isForwardedAction(action)) + .reduce((latest, current) => { + if (!latest || (current.created && latest.created && current.created > latest.created)) { + return current; + } + return latest; + }, undefined); + + // Return the DEW action if there's no approval action, or if DEW_APPROVE_FAILED is more recent + if (!mostRecentApprovalAction || mostRecentDewApproveFailedAction.created > mostRecentApprovalAction.created) { + return mostRecentDewApproveFailedAction; + } + + return undefined; +} + +function hasPendingDEWApprove(reportMetadata: OnyxEntry, isDEWPolicy: boolean): boolean { + if (!isDEWPolicy) { + return false; + } + return reportMetadata?.pendingExpenseAction === CONST.EXPENSE_PENDING_ACTION.APPROVE; +} + function isDynamicExternalWorkflowForwardedAction(reportAction: OnyxInputOrEntry): reportAction is ReportAction { return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.FORWARDED) && getOriginalMessage(reportAction)?.workflow === CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL; } @@ -3627,6 +3673,9 @@ export { isDynamicExternalWorkflowSubmitFailedAction, getMostRecentActiveDEWSubmitFailedAction, hasPendingDEWSubmit, + isDynamicExternalWorkflowApproveFailedAction, + getMostRecentActiveDEWApproveFailedAction, + hasPendingDEWApprove, isWhisperActionTargetedToOthers, isTagModificationAction, isIOUActionMatchingTransactionList, diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index d74ac180b90b..341d23018686 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -190,6 +190,7 @@ import { getLastVisibleMessage as getLastVisibleMessageActionUtils, getLastVisibleMessage as getLastVisibleMessageReportActionsUtils, getMessageOfOldDotReportAction, + getMostRecentActiveDEWApproveFailedAction, getMostRecentActiveDEWSubmitFailedAction, getNumberOfMoneyRequests, getOneTransactionThreadReportID, @@ -228,12 +229,14 @@ import { isActionableJoinRequestPending, isActionableTrackExpense, isActionOfType, + isApprovedAction, isApprovedOrSubmittedReportAction, isCardIssuedAction, isCreatedTaskReportAction, isCurrentActionUnread, isDeletedAction, isDeletedParentAction, + isDynamicExternalWorkflowApproveFailedAction, isDynamicExternalWorkflowSubmitFailedAction, isExportIntegrationAction, isIntegrationMessageAction, @@ -9228,6 +9231,15 @@ function getAllReportActionsErrorsAndReportActionThatRequiresAttention( } } + // Check for DEW approve failures on SUBMITTED status reports (GBR) + if (!isReportArchived && report?.statusNum === CONST.REPORT.STATUS_NUM.SUBMITTED) { + const mostRecentActiveDEWApproveAction = getMostRecentActiveDEWApproveFailedAction(reportActionsArray); + if (mostRecentActiveDEWApproveAction) { + reportActionErrors.dewApproveFailed = getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericCreateFailureMessage'); + reportAction = mostRecentActiveDEWApproveAction; + } + } + return { errors: reportActionErrors, reportAction, @@ -12551,7 +12563,12 @@ function selectFilteredReportActions( const actions = Object.values(actionsGroup ?? {}); const filteredActions = actions.filter( (action): action is ReportAction => - isExportIntegrationAction(action) || isIntegrationMessageAction(action) || isDynamicExternalWorkflowSubmitFailedAction(action) || isSubmittedAction(action), + isExportIntegrationAction(action) || + isIntegrationMessageAction(action) || + isDynamicExternalWorkflowSubmitFailedAction(action) || + isDynamicExternalWorkflowApproveFailedAction(action) || + isSubmittedAction(action) || + isApprovedAction(action), ); return [reportId, filteredActions]; }), diff --git a/src/libs/TransactionPreviewUtils.ts b/src/libs/TransactionPreviewUtils.ts index 58106ca75ed5..e3fe6d2de0b5 100644 --- a/src/libs/TransactionPreviewUtils.ts +++ b/src/libs/TransactionPreviewUtils.ts @@ -11,7 +11,15 @@ import {isCategoryMissing} from './CategoryUtils'; import {convertToDisplayString} from './CurrencyUtils'; import DateUtils from './DateUtils'; import {getPolicy, hasDynamicExternalWorkflow} from './PolicyUtils'; -import {getMostRecentActiveDEWSubmitFailedAction, getOriginalMessage, isDynamicExternalWorkflowSubmitFailedAction, isMessageDeleted, isMoneyRequestAction} from './ReportActionsUtils'; +import { + getMostRecentActiveDEWApproveFailedAction, + getMostRecentActiveDEWSubmitFailedAction, + getOriginalMessage, + isDynamicExternalWorkflowApproveFailedAction, + isDynamicExternalWorkflowSubmitFailedAction, + isMessageDeleted, + isMoneyRequestAction, +} from './ReportActionsUtils'; import { hasActionWithErrorsForTransaction, hasReceiptError, @@ -269,9 +277,16 @@ function getTransactionPreviewTextAndTranslationPaths({ } if (RBRMessage === undefined && hasDynamicExternalWorkflow(policy)) { - const dewFailedAction = getMostRecentActiveDEWSubmitFailedAction(reportActions); - if (dewFailedAction && isDynamicExternalWorkflowSubmitFailedAction(dewFailedAction)) { - const originalMessage = getOriginalMessage(dewFailedAction); + const dewSubmitFailedAction = getMostRecentActiveDEWSubmitFailedAction(reportActions); + if (dewSubmitFailedAction && isDynamicExternalWorkflowSubmitFailedAction(dewSubmitFailedAction)) { + const originalMessage = getOriginalMessage(dewSubmitFailedAction); + const dewErrorMessage = originalMessage?.message; + RBRMessage = dewErrorMessage ? {text: dewErrorMessage} : {translationPath: 'iou.error.other'}; + } + + const dewApproveFailedAction = getMostRecentActiveDEWApproveFailedAction(reportActions); + if (dewApproveFailedAction && isDynamicExternalWorkflowApproveFailedAction(dewApproveFailedAction)) { + const originalMessage = getOriginalMessage(dewApproveFailedAction); const dewErrorMessage = originalMessage?.message; RBRMessage = dewErrorMessage ? {text: dewErrorMessage} : {translationPath: 'iou.error.other'}; } @@ -418,7 +433,8 @@ function createTransactionPreviewConditionals({ const hasErrorOrOnHold = hasFieldErrors || (!isFullySettled && !isFullyApproved && isTransactionOnHold); const hasReportViolationsOrActionErrors = (isReportOwner(iouReport) && hasReportViolations(iouReport?.reportID)) || hasActionWithErrorsForTransaction(iouReport?.reportID, transaction); const isDEWSubmitFailed = hasDynamicExternalWorkflow(policy) && !!getMostRecentActiveDEWSubmitFailedAction(reportActions); - const shouldShowRBR = hasAnyViolations || hasErrorOrOnHold || hasReportViolationsOrActionErrors || hasReceiptError(transaction) || isDEWSubmitFailed; + const isDEWApproveFailed = hasDynamicExternalWorkflow(policy) && !!getMostRecentActiveDEWApproveFailedAction(reportActions); + const shouldShowRBR = hasAnyViolations || hasErrorOrOnHold || hasReportViolationsOrActionErrors || hasReceiptError(transaction) || isDEWSubmitFailed || isDEWApproveFailed; // When there are no settled transactions in duplicates, show the "Keep this one" button const shouldShowKeepButton = areThereDuplicates; diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index 72aab3add116..74fbd0c719b6 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -9906,93 +9906,116 @@ function approveMoneyRequest( } const optimisticApprovedReportAction = buildOptimisticApprovedReportAction(total, expenseReport.currency ?? '', expenseReport.reportID); + const isDEWPolicy = hasDynamicExternalWorkflow(policy); + const shouldAddOptimisticApproveAction = !isDEWPolicy || isOffline(); + const nextApproverAccountID = getNextApproverAccountID(expenseReport); const predictedNextStatus = !nextApproverAccountID ? CONST.REPORT.STATUS_NUM.APPROVED : CONST.REPORT.STATUS_NUM.SUBMITTED; const predictedNextState = !nextApproverAccountID ? CONST.REPORT.STATE_NUM.APPROVED : CONST.REPORT.STATE_NUM.SUBMITTED; const managerID = !nextApproverAccountID ? expenseReport.managerID : nextApproverAccountID; // buildOptimisticNextStep is used in parallel - // eslint-disable-next-line @typescript-eslint/no-deprecated - const optimisticNextStepDeprecated = buildNextStepNew({ - report: expenseReport, - policy, - currentUserAccountIDParam, - currentUserEmailParam, - hasViolations, - isASAPSubmitBetaEnabled, - predictedNextStatus, - }); - const optimisticNextStep = buildOptimisticNextStep({ - report: expenseReport, - policy, - currentUserAccountIDParam, - currentUserEmailParam, - hasViolations, - isASAPSubmitBetaEnabled, - predictedNextStatus, - }); + const optimisticNextStepDeprecated = isDEWPolicy + ? null + : // eslint-disable-next-line @typescript-eslint/no-deprecated + buildNextStepNew({ + report: expenseReport, + policy, + currentUserAccountIDParam, + currentUserEmailParam, + hasViolations, + isASAPSubmitBetaEnabled, + predictedNextStatus, + }); + const optimisticNextStep = isDEWPolicy + ? null + : buildOptimisticNextStep({ + report: expenseReport, + policy, + currentUserAccountIDParam, + currentUserEmailParam, + hasViolations, + isASAPSubmitBetaEnabled, + predictedNextStatus, + }); const chatReport = getReportOrDraftReport(expenseReport.chatReportID); - const optimisticReportActionsData: OnyxUpdate = { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, - value: { - [optimisticApprovedReportAction.reportActionID]: { - ...(optimisticApprovedReportAction as OnyxTypes.ReportAction), - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + const optimisticData: OnyxUpdate[] = []; + + if (shouldAddOptimisticApproveAction) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, + value: { + [optimisticApprovedReportAction.reportActionID]: { + ...(optimisticApprovedReportAction as OnyxTypes.ReportAction), + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, }, - }, - }; + }); + } + const updatedExpenseReport = { ...expenseReport, - lastMessageText: getReportActionText(optimisticApprovedReportAction), - lastMessageHtml: getReportActionHtml(optimisticApprovedReportAction), - stateNum: predictedNextState, - statusNum: predictedNextStatus, - managerID, - nextStep: optimisticNextStep ?? undefined, - pendingFields: { - partial: full ? null : CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, - nextStep: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, - }, + ...(shouldAddOptimisticApproveAction + ? { + lastMessageText: getReportActionText(optimisticApprovedReportAction), + lastMessageHtml: getReportActionHtml(optimisticApprovedReportAction), + } + : {}), + // For DEW policies, don't optimistically update stateNum, statusNum, managerID, or nextStep + // because DEW determines the actual workflow on the backend + ...(isDEWPolicy + ? {} + : { + stateNum: predictedNextState, + statusNum: predictedNextStatus, + managerID, + nextStep: optimisticNextStep ?? undefined, + pendingFields: { + partial: full ? null : CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + nextStep: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }), }; - const optimisticIOUReportData: OnyxUpdate = { + optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, value: updatedExpenseReport, - }; + }); - let optimisticChatReportData: OnyxUpdate | undefined; if (chatReport) { - optimisticChatReportData = { + optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.chatReportID}`, value: { hasOutstandingChildRequest: hasOutstandingChildRequest(chatReport, updatedExpenseReport, currentUserEmail), }, - }; + }); } - const optimisticNextStepData: OnyxUpdate = { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport.reportID}`, - value: optimisticNextStepDeprecated, - }; - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const optimisticData: OnyxUpdate[] = [optimisticIOUReportData, optimisticReportActionsData, optimisticNextStepData, ...(optimisticChatReportData ? [optimisticChatReportData] : [])]; + if (!isDEWPolicy) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport.reportID}`, + value: optimisticNextStepDeprecated, + }); + } - const successData: OnyxUpdate[] = [ - { + if (isDEWPolicy) { + optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${expenseReport.reportID}`, value: { - [optimisticApprovedReportAction.reportActionID]: { - pendingAction: null, - }, + pendingExpenseAction: CONST.EXPENSE_PENDING_ACTION.APPROVE, }, - }, - { + }); + } + + const successData: OnyxUpdate[] = []; + + if (!isDEWPolicy) { + successData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, value: { @@ -10001,29 +10024,54 @@ function approveMoneyRequest( nextStep: null, }, }, - }, - ]; + }); + } - const failureData: OnyxUpdate[] = [ - { + if (shouldAddOptimisticApproveAction) { + successData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, value: { [optimisticApprovedReportAction.reportActionID]: { - errors: getMicroSecondOnyxErrorWithTranslationKey('iou.error.other'), + pendingAction: null, }, }, + }); + } + + if (isDEWPolicy) { + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${expenseReport.reportID}`, + value: { + pendingExpenseAction: null, + }, + }); + } + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, + value: { + statusNum: expenseReport.statusNum, + stateNum: expenseReport.stateNum, + ...(isDEWPolicy + ? {} + : { + nextStep: expenseReport.nextStep ?? null, + pendingFields: { + partial: null, + nextStep: null, + }, + }), + }, }, { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.chatReportID}`, value: { hasOutstandingChildRequest: chatReport?.hasOutstandingChildRequest, - nextStep: expenseReport.nextStep ?? null, - pendingFields: { - partial: null, - nextStep: null, - }, }, }, { @@ -10033,6 +10081,38 @@ function approveMoneyRequest( }, ]; + if (shouldAddOptimisticApproveAction) { + if (isDEWPolicy) { + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, + value: { + [optimisticApprovedReportAction.reportActionID]: null, + }, + }); + } else { + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, + value: { + [optimisticApprovedReportAction.reportActionID]: { + errors: getMicroSecondOnyxErrorWithTranslationKey('iou.error.other'), + }, + }, + }); + } + } + + if (isDEWPolicy) { + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${expenseReport.reportID}`, + value: { + pendingExpenseAction: null, + }, + }); + } + // Clear hold reason of all transactions if we approve all requests if (full && hasHeldExpenses) { const heldTransactions = getAllHeldTransactionsReportUtils(expenseReport.reportID); diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index b035ddb48284..674c0f38d682 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -123,11 +123,11 @@ function handleActionButtonPress({ onDelegateAccessRestricted?.(); return; } - if (hasDynamicExternalWorkflow(snapshotPolicy)) { + if (hasDynamicExternalWorkflow(snapshotPolicy) && !isDEWBetaEnabled) { onDEWModalOpen?.(); return; } - approveMoneyRequestOnSearch(hash, item.reportID ? [item.reportID] : [], currentSearchKey); + approveMoneyRequestOnSearch(hash, item.reportID ? [item.reportID] : [], snapshotPolicy, currentSearchKey); return; case CONST.SEARCH.ACTION_TYPES.SUBMIT: { if (hasDynamicExternalWorkflow(snapshotPolicy) && !isDEWBetaEnabled) { @@ -529,12 +529,22 @@ function submitMoneyRequestOnSearch(hash: number, reportList: Report[], policy: API.write(WRITE_COMMANDS.SUBMIT_REPORT, parameters, {optimisticData, successData, failureData}); } -function approveMoneyRequestOnSearch(hash: number, reportIDList: string[], currentSearchKey?: SearchKey) { +function approveMoneyRequestOnSearch(hash: number, reportIDList: string[], policy?: Policy, currentSearchKey?: SearchKey) { + const isDEWPolicy = hasDynamicExternalWorkflow(policy); + const optimisticData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE_COLLECTION, key: ONYXKEYS.COLLECTION.REPORT_METADATA, - value: Object.fromEntries(reportIDList.map((reportID) => [`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`, {isActionLoading: true}])), + value: Object.fromEntries( + reportIDList.map((reportID) => [ + `${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`, + { + isActionLoading: true, + ...(isDEWPolicy ? {pendingExpenseAction: CONST.EXPENSE_PENDING_ACTION.APPROVE} : {}), + }, + ]), + ), }, ]; @@ -542,7 +552,15 @@ function approveMoneyRequestOnSearch(hash: number, reportIDList: string[], curre { onyxMethod: Onyx.METHOD.MERGE_COLLECTION, key: ONYXKEYS.COLLECTION.REPORT_METADATA, - value: Object.fromEntries(reportIDList.map((reportID) => [`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`, {isActionLoading: false}])), + value: Object.fromEntries( + reportIDList.map((reportID) => [ + `${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`, + { + isActionLoading: false, + ...(isDEWPolicy ? {pendingExpenseAction: null} : {}), + }, + ]), + ), }, ]; @@ -562,7 +580,15 @@ function approveMoneyRequestOnSearch(hash: number, reportIDList: string[], curre { onyxMethod: Onyx.METHOD.MERGE_COLLECTION, key: ONYXKEYS.COLLECTION.REPORT_METADATA, - value: Object.fromEntries(reportIDList.map((reportID) => [`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`, {isActionLoading: false}])), + value: Object.fromEntries( + reportIDList.map((reportID) => [ + `${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`, + { + isActionLoading: false, + ...(isDEWPolicy ? {pendingExpenseAction: null} : {}), + }, + ]), + ), }, { onyxMethod: Onyx.METHOD.MERGE_COLLECTION, diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 69b047535121..30a5eadd6599 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -555,12 +555,11 @@ function SearchPage({route}: SearchPageProps) { const selectedPolicyIDList = selectedReports.length ? selectedReports.map((report) => report.policyID) : Object.values(selectedTransactions).map((transaction) => transaction.policyID); - const hasDEWPolicy = selectedPolicyIDList.some((policyID) => { - const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; - return hasDynamicExternalWorkflow(policy); - }); + const dewPolicy = selectedPolicyIDList + .map((policyID) => policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]) + .find((policy) => hasDynamicExternalWorkflow(policy)); - if (hasDEWPolicy && !isDEWBetaEnabled) { + if (dewPolicy && !isDEWBetaEnabled) { setIsDEWModalVisible(true); return; } @@ -571,6 +570,7 @@ function SearchPage({route}: SearchPageProps) { approveMoneyRequestOnSearch( hash, reportIDList.filter((reportID) => reportID !== undefined), + dewPolicy, ); // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.runAfterInteractions(() => { diff --git a/src/pages/home/report/PureReportActionItem.tsx b/src/pages/home/report/PureReportActionItem.tsx index b8ded6c076cc..acf78527906a 100644 --- a/src/pages/home/report/PureReportActionItem.tsx +++ b/src/pages/home/report/PureReportActionItem.tsx @@ -116,6 +116,7 @@ import { getWorkspaceTagUpdateMessage, getWorkspaceTaxUpdateMessage, getWorkspaceUpdateFieldMessage, + hasPendingDEWApprove, hasPendingDEWSubmit, isActionableAddPaymentCard, isActionableCardFraudAlert, @@ -132,6 +133,7 @@ import { isCreatedTaskReportAction, isDeletedAction, isDeletedParentAction as isDeletedParentActionUtils, + isDynamicExternalWorkflowApproveFailedAction, isDynamicExternalWorkflowSubmitFailedAction, isIOURequestReportAction, isMarkAsClosedAction, @@ -1244,18 +1246,26 @@ function PureReportActionItem({ } } else if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.APPROVED)) { const wasAutoApproved = getOriginalMessage(action)?.automaticAction ?? false; + const isDEWPolicy = hasDynamicExternalWorkflow(policy); + const isPendingAdd = action.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD; + if (wasAutoApproved) { children = ( ${translate('iou.automaticallyApproved')}`} /> ); + } else if (hasPendingDEWApprove(reportMetadata, isDEWPolicy) && isPendingAdd) { + children = ; } else { children = ; } } else if (isDynamicExternalWorkflowSubmitFailedAction(action)) { const errorMessage = getOriginalMessage(action)?.message ?? translate('iou.error.genericCreateFailureMessage'); children = ; + } else if (isDynamicExternalWorkflowApproveFailedAction(action)) { + const errorMessage = getOriginalMessage(action)?.message ?? translate('iou.error.genericCreateFailureMessage'); + children = ; } else if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.IOU) && getOriginalMessage(action)?.type === CONST.IOU.REPORT_ACTION_TYPE.PAY) { const wasAutoPaid = getOriginalMessage(action)?.automaticAction ?? false; const paymentType = getOriginalMessage(action)?.paymentType; diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts index 0aa90bfa4321..f4e4681ae6be 100644 --- a/src/types/onyx/OriginalMessage.ts +++ b/src/types/onyx/OriginalMessage.ts @@ -1162,6 +1162,7 @@ type OriginalMessageMap = { [CONST.REPORT.ACTIONS.TYPE.INTEGRATION_SYNC_FAILED]: OriginalMessageIntegrationSyncFailed; [CONST.REPORT.ACTIONS.TYPE.DELETED_TRANSACTION]: OriginalMessageDeletedTransaction; [CONST.REPORT.ACTIONS.TYPE.DEW_SUBMIT_FAILED]: OriginalMessageDEWFailed; + [CONST.REPORT.ACTIONS.TYPE.DEW_APPROVE_FAILED]: OriginalMessageDEWFailed; [CONST.REPORT.ACTIONS.TYPE.CONCIERGE_CATEGORY_OPTIONS]: OriginalMessageConciergeCategoryOptions; [CONST.REPORT.ACTIONS.TYPE.CONCIERGE_DESCRIPTION_OPTIONS]: OriginalMessageConciergeDescriptionOptions; [CONST.REPORT.ACTIONS.TYPE.CONCIERGE_AUTO_MAP_MCC_GROUPS]: OriginalMessageConciergeAutoMapMccGroups; diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts index 83722cc40aa0..10761905c5c3 100644 --- a/tests/unit/ReportActionsUtilsTest.ts +++ b/tests/unit/ReportActionsUtilsTest.ts @@ -1956,6 +1956,303 @@ describe('ReportActionsUtils', () => { }); }); + describe('isDynamicExternalWorkflowApproveFailedAction', () => { + it('should return true for DEW_APPROVE_FAILED action type', () => { + // Given a report action with DEW_APPROVE_FAILED action type + const action: ReportAction = { + ...createRandomReportAction(0), + actionName: CONST.REPORT.ACTIONS.TYPE.DEW_APPROVE_FAILED, + created: '2025-11-21', + reportActionID: '1', + originalMessage: { + message: 'This report cannot be approved because of compliance issues.', + automaticAction: false, + }, + message: [], + previousMessage: [], + }; + + // When checking if the action is a DEW approve failed action + const result = ReportActionsUtils.isDynamicExternalWorkflowApproveFailedAction(action); + + // Then it should return true because the action type is DEW_APPROVE_FAILED + expect(result).toBe(true); + }); + + it('should return false for non-DEW_APPROVE_FAILED action type', () => { + // Given a report action with APPROVED action type (not DEW_APPROVE_FAILED) + const action: ReportAction = { + ...createRandomReportAction(0), + actionName: CONST.REPORT.ACTIONS.TYPE.APPROVED, + created: '2025-11-21', + reportActionID: '1', + originalMessage: { + expenseReportID: '1', + amount: 1, + currency: CONST.CURRENCY.USD, + }, + message: [], + previousMessage: [], + }; + + // When checking if the action is a DEW approve failed action + const result = ReportActionsUtils.isDynamicExternalWorkflowApproveFailedAction(action); + + // Then it should return false because the action type is not DEW_APPROVE_FAILED + expect(result).toBe(false); + }); + + it('should return false for null action', () => { + // Given a null action + + // When checking if the action is a DEW approve failed action + const result = ReportActionsUtils.isDynamicExternalWorkflowApproveFailedAction(null); + + // Then it should return false because the action is null + expect(result).toBe(false); + }); + }); + + describe('getMostRecentActiveDEWApproveFailedAction', () => { + it('should return the DEW action when DEW_APPROVE_FAILED exists and no approval action exists', () => { + // Given report actions containing only a DEW_APPROVE_FAILED action + const actionId1 = '1'; + const reportActions: ReportActions = { + [actionId1]: { + ...createRandomReportAction(0), + actionName: CONST.REPORT.ACTIONS.TYPE.DEW_APPROVE_FAILED, + created: '2025-11-21 10:00:00', + reportActionID: actionId1, + originalMessage: { + message: 'DEW approve failed', + }, + message: [], + previousMessage: [], + } as ReportAction, + }; + + // When getting the most recent active DEW approve failed action + const result = ReportActionsUtils.getMostRecentActiveDEWApproveFailedAction(reportActions); + + // Then it should return the DEW action because there's no subsequent approval action + expect(result).toBeDefined(); + expect(result?.reportActionID).toBe(actionId1); + }); + + it('should return the DEW action when DEW_APPROVE_FAILED is more recent than APPROVED', () => { + // Given report actions where DEW_APPROVE_FAILED occurred after APPROVED + const actionId1 = '1'; + const actionId2 = '2'; + const reportActions: ReportActions = { + [actionId1]: { + ...createRandomReportAction(0), + actionName: CONST.REPORT.ACTIONS.TYPE.APPROVED, + created: '2025-11-21 09:00:00', + reportActionID: actionId1, + originalMessage: { + expenseReportID: actionId1, + amount: 1, + currency: CONST.CURRENCY.USD, + }, + message: [], + previousMessage: [], + } as ReportAction, + [actionId2]: { + ...createRandomReportAction(0), + actionName: CONST.REPORT.ACTIONS.TYPE.DEW_APPROVE_FAILED, + created: '2025-11-21 10:00:00', + reportActionID: actionId2, + originalMessage: { + message: 'DEW approve failed', + }, + message: [], + previousMessage: [], + } as ReportAction, + }; + + // When getting the most recent active DEW approve failed action + const result = ReportActionsUtils.getMostRecentActiveDEWApproveFailedAction(reportActions); + + // Then it should return the DEW action because it's more recent than the APPROVED action + expect(result).toBeDefined(); + expect(result?.reportActionID).toBe(actionId2); + }); + + it('should return undefined when APPROVED is more recent than DEW_APPROVE_FAILED', () => { + // Given report actions where APPROVED occurred after DEW_APPROVE_FAILED + const actionId1 = '1'; + const actionId2 = '2'; + const reportActions: ReportActions = { + [actionId1]: { + ...createRandomReportAction(0), + actionName: CONST.REPORT.ACTIONS.TYPE.DEW_APPROVE_FAILED, + created: '2025-11-21 09:00:00', + reportActionID: actionId1, + originalMessage: { + message: 'DEW approve failed', + }, + message: [], + previousMessage: [], + } as ReportAction, + [actionId2]: { + ...createRandomReportAction(0), + actionName: CONST.REPORT.ACTIONS.TYPE.APPROVED, + created: '2025-11-21 10:00:00', + reportActionID: actionId2, + originalMessage: { + expenseReportID: actionId2, + amount: 1, + currency: CONST.CURRENCY.USD, + }, + message: [], + previousMessage: [], + } as ReportAction, + }; + + // When getting the most recent active DEW approve failed action + const result = ReportActionsUtils.getMostRecentActiveDEWApproveFailedAction(reportActions); + + // Then it should return undefined because a successful APPROVED action supersedes the DEW failure + expect(result).toBeUndefined(); + }); + + it('should return undefined when FORWARDED is more recent than DEW_APPROVE_FAILED', () => { + // Given report actions where FORWARDED occurred after DEW_APPROVE_FAILED + const actionId1 = '1'; + const actionId2 = '2'; + const reportActions: ReportActions = { + [actionId1]: { + ...createRandomReportAction(0), + actionName: CONST.REPORT.ACTIONS.TYPE.DEW_APPROVE_FAILED, + created: '2025-11-21 09:00:00', + reportActionID: actionId1, + originalMessage: { + message: 'DEW approve failed', + }, + message: [], + previousMessage: [], + } as ReportAction, + [actionId2]: { + ...createRandomReportAction(0), + actionName: CONST.REPORT.ACTIONS.TYPE.FORWARDED, + created: '2025-11-21 10:00:00', + reportActionID: actionId2, + originalMessage: { + expenseReportID: actionId2, + amount: 1, + currency: CONST.CURRENCY.USD, + }, + message: [], + previousMessage: [], + } as ReportAction, + }; + + // When getting the most recent active DEW approve failed action + const result = ReportActionsUtils.getMostRecentActiveDEWApproveFailedAction(reportActions); + + // Then it should return undefined because a successful FORWARDED action supersedes the DEW failure + expect(result).toBeUndefined(); + }); + + it('should return undefined when no DEW_APPROVE_FAILED action exists', () => { + // Given report actions containing only an APPROVED action (no DEW failures) + const actionId1 = '1'; + const reportActions: ReportActions = { + [actionId1]: { + ...createRandomReportAction(0), + actionName: CONST.REPORT.ACTIONS.TYPE.APPROVED, + created: '2025-11-21 10:00:00', + reportActionID: actionId1, + originalMessage: { + expenseReportID: actionId1, + amount: 1, + currency: CONST.CURRENCY.USD, + }, + message: [], + previousMessage: [], + } as ReportAction, + }; + + // When getting the most recent active DEW approve failed action + const result = ReportActionsUtils.getMostRecentActiveDEWApproveFailedAction(reportActions); + + // Then it should return undefined because there are no DEW failures + expect(result).toBeUndefined(); + }); + + it('should return undefined for empty report actions', () => { + // Given an empty report actions object + + // When getting the most recent active DEW approve failed action + const result = ReportActionsUtils.getMostRecentActiveDEWApproveFailedAction({}); + + // Then it should return undefined because there are no actions + expect(result).toBeUndefined(); + }); + }); + + describe('hasPendingDEWApprove', () => { + it('should return true when pendingExpenseAction is APPROVE and isDEWPolicy is true', () => { + // Given reportMetadata with pendingExpenseAction APPROVE and isDEWPolicy is true + const reportMetadata = { + pendingExpenseAction: CONST.EXPENSE_PENDING_ACTION.APPROVE, + }; + + // When checking if there's a pending DEW approve + const result = ReportActionsUtils.hasPendingDEWApprove(reportMetadata, true); + + // Then it should return true + expect(result).toBe(true); + }); + + it('should return false when pendingExpenseAction is APPROVE but isDEWPolicy is false', () => { + // Given reportMetadata with pendingExpenseAction APPROVE but isDEWPolicy is false + const reportMetadata = { + pendingExpenseAction: CONST.EXPENSE_PENDING_ACTION.APPROVE, + }; + + // When checking if there's a pending DEW approve with isDEWPolicy false + const result = ReportActionsUtils.hasPendingDEWApprove(reportMetadata, false); + + // Then it should return false because the policy is not DEW + expect(result).toBe(false); + }); + + it('should return false when pendingExpenseAction is not APPROVE', () => { + // Given reportMetadata with pendingExpenseAction SUBMIT (not APPROVE) + const reportMetadata = { + pendingExpenseAction: CONST.EXPENSE_PENDING_ACTION.SUBMIT, + }; + + // When checking if there's a pending DEW approve + const result = ReportActionsUtils.hasPendingDEWApprove(reportMetadata, true); + + // Then it should return false because pendingExpenseAction is SUBMIT, not APPROVE + expect(result).toBe(false); + }); + + it('should return false when pendingExpenseAction is undefined', () => { + // Given reportMetadata without pendingExpenseAction + const reportMetadata = {}; + + // When checking if there's a pending DEW approve + const result = ReportActionsUtils.hasPendingDEWApprove(reportMetadata, true); + + // Then it should return false + expect(result).toBe(false); + }); + + it('should return false when reportMetadata is undefined', () => { + // Given undefined reportMetadata + + // When checking if there's a pending DEW approve + const result = ReportActionsUtils.hasPendingDEWApprove(undefined, true); + + // Then it should return false + expect(result).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 From 5af837d25a93907fc86774d3ebcd38dbafdd84a9 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Fri, 9 Jan 2026 00:02:04 +0100 Subject: [PATCH 02/35] fixing prettier --- src/libs/TransactionPreviewUtils.ts | 2 +- src/pages/Search/SearchPage.tsx | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/libs/TransactionPreviewUtils.ts b/src/libs/TransactionPreviewUtils.ts index f1b591bc1be0..4f16e726f633 100644 --- a/src/libs/TransactionPreviewUtils.ts +++ b/src/libs/TransactionPreviewUtils.ts @@ -289,7 +289,7 @@ function getTransactionPreviewTextAndTranslationPaths({ const dewErrorMessage = originalMessage?.message; RBRMessage = dewErrorMessage ? {text: dewErrorMessage} : {translationPath: 'iou.error.other'}; } - + const dewApproveFailedAction = getMostRecentActiveDEWApproveFailedAction(reportActions); if (dewApproveFailedAction && isDynamicExternalWorkflowApproveFailedAction(dewApproveFailedAction)) { const originalMessage = getOriginalMessage(dewApproveFailedAction); diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 30a5eadd6599..4140e53b5bd9 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -555,9 +555,7 @@ function SearchPage({route}: SearchPageProps) { const selectedPolicyIDList = selectedReports.length ? selectedReports.map((report) => report.policyID) : Object.values(selectedTransactions).map((transaction) => transaction.policyID); - const dewPolicy = selectedPolicyIDList - .map((policyID) => policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]) - .find((policy) => hasDynamicExternalWorkflow(policy)); + const dewPolicy = selectedPolicyIDList.map((policyID) => policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]).find((policy) => hasDynamicExternalWorkflow(policy)); if (dewPolicy && !isDEWBetaEnabled) { setIsDEWModalVisible(true); From 9224ada7002d532188d28f6035b0892560df3ea3 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Fri, 9 Jan 2026 00:13:47 +0100 Subject: [PATCH 03/35] performance improvment for the get dew policy --- src/pages/Search/SearchPage.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 4140e53b5bd9..3742bc7e934b 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -555,8 +555,11 @@ function SearchPage({route}: SearchPageProps) { const selectedPolicyIDList = selectedReports.length ? selectedReports.map((report) => report.policyID) : Object.values(selectedTransactions).map((transaction) => transaction.policyID); - const dewPolicy = selectedPolicyIDList.map((policyID) => policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]).find((policy) => hasDynamicExternalWorkflow(policy)); - + const dewPolicyID = selectedPolicyIDList.find((policyID) => { + const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; + return hasDynamicExternalWorkflow(policy); + }); + const dewPolicy = dewPolicyID ? policies?.[`${ONYXKEYS.COLLECTION.POLICY}${dewPolicyID}`] : undefined; if (dewPolicy && !isDEWBetaEnabled) { setIsDEWModalVisible(true); return; From aa15ca3a43f1fe30897ccab66b395f26d002c407 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Mon, 12 Jan 2026 13:07:08 +0100 Subject: [PATCH 04/35] Hide Approve action when DEW approval is pending --- src/components/MoneyReportHeader.tsx | 6 +-- src/hooks/useTodos.ts | 8 +-- src/libs/ReportActionsUtils.ts | 2 +- src/libs/ReportPrimaryActionUtils.ts | 19 +++++-- src/libs/ReportSecondaryActionUtils.ts | 17 +++++- src/libs/actions/IOU/index.ts | 13 ++++- tests/unit/ReportPrimaryActionUtilsTest.ts | 37 +++++++++++++- tests/unit/ReportSecondaryActionUtilsTest.ts | 54 ++++++++++++++++++++ 8 files changed, 142 insertions(+), 14 deletions(-) diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 2832413c2bea..146a821695eb 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -324,6 +324,7 @@ function MoneyReportHeader({ const {isBetaEnabled} = usePermissions(); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); const isDEWBetaEnabled = isBetaEnabled(CONST.BETAS.NEW_DOT_DEW); + const isDEWPolicy = isDEWBetaEnabled && hasDynamicExternalWorkflow(policy); const hasViolations = hasViolationsReportUtils(moneyRequestReport?.reportID, allTransactionViolations, accountID, email ?? ''); const [exportModalStatus, setExportModalStatus] = useState(null); @@ -459,8 +460,8 @@ function MoneyReportHeader({ const shouldShowPayButton = isPaidAnimationRunning || canIOUBePaid || onlyShowPayElsewhere; const shouldShowApproveButton = useMemo( - () => (canApproveIOU(moneyRequestReport, policy, transactions) && !hasOnlyPendingTransactions) || isApprovedAnimationRunning, - [moneyRequestReport, policy, transactions, hasOnlyPendingTransactions, isApprovedAnimationRunning], + () => (canApproveIOU(moneyRequestReport, policy, transactions, reportMetadata) && !hasOnlyPendingTransactions) || isApprovedAnimationRunning, + [moneyRequestReport, policy, transactions, hasOnlyPendingTransactions, isApprovedAnimationRunning, reportMetadata], ); const shouldDisableApproveButton = shouldShowApproveButton && !isAllowedToApproveExpenseReport(moneyRequestReport); @@ -482,7 +483,6 @@ function MoneyReportHeader({ let optimisticNextStep = isBlockSubmitDueToPreventSelfApproval ? buildOptimisticNextStepForPreventSelfApprovalsEnabled() : nextStep; // Check for DEW submit/approve failed or pending - show appropriate next step - const isDEWPolicy = isDEWBetaEnabled && hasDynamicExternalWorkflow(policy); if (isDEWPolicy && (moneyRequestReport?.statusNum === CONST.REPORT.STATUS_NUM.OPEN || moneyRequestReport?.statusNum === CONST.REPORT.STATUS_NUM.SUBMITTED)) { const reportActionsObject = reportActions.reduce((acc, action) => { if (action.reportActionID) { diff --git a/src/hooks/useTodos.ts b/src/hooks/useTodos.ts index cc5859a3c6d4..adc36433296f 100644 --- a/src/hooks/useTodos.ts +++ b/src/hooks/useTodos.ts @@ -12,6 +12,7 @@ export default function useTodos() { const [allReportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, {canBeMissing: false}); const [allTransactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, {canBeMissing: false}); const [allReportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS, {canBeMissing: false}); + const [allReportMetadata] = useOnyx(ONYXKEYS.COLLECTION.REPORT_METADATA, {canBeMissing: false}); const {email = '', accountID} = useCurrentUserPersonalDetails(); return useMemo(() => { @@ -43,11 +44,12 @@ export default function useTodos() { const reportNameValuePair = allReportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.chatReportID}`]; const reportActions = Object.values(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`] ?? []); const reportTransactions = transactionsByReportID[report.reportID] ?? []; + const reportMetadata = allReportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${report.reportID}`]; - if (isSubmitAction(report, reportTransactions, policy, reportNameValuePair)) { + if (isSubmitAction(report, reportTransactions, policy, reportNameValuePair, undefined, email, accountID, reportMetadata)) { reportsToSubmit.push(report); } - if (isApproveAction(report, reportTransactions, policy)) { + if (isApproveAction(report, reportTransactions, policy, reportMetadata)) { reportsToApprove.push(report); } if (isPrimaryPayAction(report, accountID, email, policy, reportNameValuePair)) { @@ -59,5 +61,5 @@ export default function useTodos() { } return {reportsToSubmit, reportsToApprove, reportsToPay, reportsToExport}; - }, [allReports, allTransactions, allPolicies, allReportNameValuePairs, allReportActions, accountID, email]); + }, [allReports, allTransactions, allPolicies, allReportNameValuePairs, allReportActions, allReportMetadata, accountID, email]); } diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 51b6c9bef9c2..2bd703523c22 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -335,7 +335,7 @@ function getMostRecentActiveDEWApproveFailedAction(reportActions: OnyxEntry isApprovedAction(action) || isForwardedAction(action)) .reduce((latest, current) => { diff --git a/src/libs/ReportPrimaryActionUtils.ts b/src/libs/ReportPrimaryActionUtils.ts index 137fa6a232d6..15141fc6feff 100644 --- a/src/libs/ReportPrimaryActionUtils.ts +++ b/src/libs/ReportPrimaryActionUtils.ts @@ -15,7 +15,15 @@ import { isPolicyAdmin as isPolicyAdminPolicyUtils, isPreferredExporter, } from './PolicyUtils'; -import {getAllReportActions, getOneTransactionThreadReportID, getOriginalMessage, getReportAction, hasPendingDEWSubmit, isMoneyRequestAction} from './ReportActionsUtils'; +import { + getAllReportActions, + getOneTransactionThreadReportID, + getOriginalMessage, + getReportAction, + hasPendingDEWApprove, + hasPendingDEWSubmit, + isMoneyRequestAction, +} from './ReportActionsUtils'; import { canAddTransaction as canAddTransactionUtil, canHoldUnholdReportAction, @@ -128,13 +136,18 @@ function isSubmitAction( return isExpenseReport && isReportSubmitter && isOpenReport && reportTransactions.length !== 0 && transactionAreComplete; } -function isApproveAction(report: Report, reportTransactions: Transaction[], policy?: Policy) { +function isApproveAction(report: Report, reportTransactions: Transaction[], policy?: Policy, reportMetadata?: OnyxEntry) { const isAnyReceiptBeingScanned = reportTransactions?.some((transaction) => isScanning(transaction)); if (isAnyReceiptBeingScanned) { return false; } + const isDEWPolicy = hasDynamicExternalWorkflow(policy); + if (hasPendingDEWApprove(reportMetadata, isDEWPolicy)) { + return false; + } + const currentUserAccountID = getCurrentUserAccountID(); const managerID = report?.managerID ?? CONST.DEFAULT_NUMBER_ID; const isCurrentUserManager = managerID === currentUserAccountID; @@ -446,7 +459,7 @@ function getReportPrimaryAction(params: GetReportPrimaryActionParams): ValueOf, policy?: Policy): boolean { +function isApproveAction( + currentUserLogin: string, + report: Report, + reportTransactions: Transaction[], + violations: OnyxCollection, + policy?: Policy, + reportMetadata?: OnyxEntry, +): boolean { const isAnyReceiptBeingScanned = reportTransactions?.some((transaction) => isReceiptBeingScanned(transaction)); if (isAnyReceiptBeingScanned) { return false; } + const isDEWPolicy = hasDynamicExternalWorkflow(policy); + if (hasPendingDEWApprove(reportMetadata, isDEWPolicy)) { + return false; + } + const currentUserAccountID = getCurrentUserAccountID(); const managerID = report?.managerID ?? CONST.DEFAULT_NUMBER_ID; const isCurrentUserManager = managerID === currentUserAccountID; @@ -837,7 +850,7 @@ function getSecondaryReportActions({ options.push(CONST.REPORT.SECONDARY_ACTIONS.SUBMIT); } - if (isApproveAction(currentUserEmail, report, reportTransactions, violations, policy)) { + if (isApproveAction(currentUserEmail, report, reportTransactions, violations, policy, reportMetadata)) { options.push(CONST.REPORT.SECONDARY_ACTIONS.APPROVE); } diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index d5b258329e91..748ee07fc14e 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -106,6 +106,7 @@ import { getReportActionMessage, getReportActionText, getTrackExpenseActionableWhisper, + hasPendingDEWApprove, isActionableTrackExpense, isCreatedAction, isDeletedAction, @@ -9864,7 +9865,12 @@ function getPayMoneyRequestParams({ }; } -function canApproveIOU(iouReport: OnyxTypes.OnyxInputOrEntry, policy: OnyxTypes.OnyxInputOrEntry, iouTransactions?: OnyxTypes.Transaction[]) { +function canApproveIOU( + iouReport: OnyxTypes.OnyxInputOrEntry, + policy: OnyxTypes.OnyxInputOrEntry, + iouTransactions?: OnyxTypes.Transaction[], + reportMetadata?: OnyxEntry, +) { // Only expense reports can be approved if (!isExpenseReport(iouReport) || !(policy && isPaidGroupPolicy(policy))) { return false; @@ -9875,6 +9881,11 @@ function canApproveIOU(iouReport: OnyxTypes.OnyxInputOrEntry, return false; } + const isDEWPolicy = hasDynamicExternalWorkflow(policy); + if (hasPendingDEWApprove(reportMetadata, isDEWPolicy)) { + return false; + } + const managerID = iouReport?.managerID ?? CONST.DEFAULT_NUMBER_ID; const isCurrentUserManager = managerID === userAccountID; const isOpenExpenseReport = isOpenExpenseReportReportUtils(iouReport); diff --git a/tests/unit/ReportPrimaryActionUtilsTest.ts b/tests/unit/ReportPrimaryActionUtilsTest.ts index 9040bc666a3f..f80a9bd03b59 100644 --- a/tests/unit/ReportPrimaryActionUtilsTest.ts +++ b/tests/unit/ReportPrimaryActionUtilsTest.ts @@ -5,7 +5,14 @@ import useReportIsArchived from '@hooks/useReportIsArchived'; import {getValidConnectedIntegration} from '@libs/PolicyUtils'; // eslint-disable-next-line no-restricted-syntax import type * as PolicyUtils from '@libs/PolicyUtils'; -import {getReportPrimaryAction, getTransactionThreadPrimaryAction, isMarkAsResolvedAction, isPrimaryMarkAsResolvedAction, isReviewDuplicatesAction} from '@libs/ReportPrimaryActionUtils'; +import { + getReportPrimaryAction, + getTransactionThreadPrimaryAction, + isApproveAction, + isMarkAsResolvedAction, + isPrimaryMarkAsResolvedAction, + isReviewDuplicatesAction, +} from '@libs/ReportPrimaryActionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, Report, ReportAction, Transaction, TransactionViolation} from '@src/types/onyx'; @@ -285,6 +292,34 @@ describe('getPrimaryAction', () => { ).toBe(''); }); + it('should return false from isApproveAction when DEW approval is pending', async () => { + const report = { + reportID: REPORT_ID, + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + managerID: CURRENT_USER_ACCOUNT_ID, + } as unknown as Report; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); + const policy = { + approver: CURRENT_USER_EMAIL, + approvalMode: CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL, + } as unknown as Policy; + const transaction = { + reportID: `${REPORT_ID}`, + amount: 10, + merchant: 'Merchant', + date: '2025-01-01', + } as unknown as Transaction; + + // Without DEW pending, isApproveAction should return true + expect(isApproveAction(report, [transaction], policy, undefined)).toBe(true); + + // With DEW pending, isApproveAction should return false + expect(isApproveAction(report, [transaction], policy, {pendingExpenseAction: CONST.EXPENSE_PENDING_ACTION.APPROVE})).toBe(false); + }); + it('should return PAY for submitted invoice report if paid as personal', async () => { const report = { reportID: REPORT_ID, diff --git a/tests/unit/ReportSecondaryActionUtilsTest.ts b/tests/unit/ReportSecondaryActionUtilsTest.ts index 0bc1dd2ba3ef..b79318245855 100644 --- a/tests/unit/ReportSecondaryActionUtilsTest.ts +++ b/tests/unit/ReportSecondaryActionUtilsTest.ts @@ -483,6 +483,60 @@ describe('getSecondaryAction', () => { expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.APPROVE)).toBe(false); }); + it('does not include APPROVE option when DEW approval is pending', async () => { + const TRANSACTION_ID = 'TRANSACTION_ID_DEW'; + const report = { + reportID: REPORT_ID, + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: EMPLOYEE_ACCOUNT_ID, + managerID: EMPLOYEE_ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + } as unknown as Report; + const policy = { + approver: EMPLOYEE_EMAIL, + approvalMode: CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL, + } as unknown as Policy; + const transaction = { + transactionID: TRANSACTION_ID, + } as unknown as Transaction; + const violation = { + name: CONST.VIOLATIONS.DUPLICATED_TRANSACTION, + } as TransactionViolation; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${TRANSACTION_ID}`, transaction); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); + + const violations = {[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${TRANSACTION_ID}`]: [violation]}; + + // Without DEW pending, APPROVE should be included (report has duplicates) + const resultWithoutPending = getSecondaryReportActions({ + currentUserEmail: EMPLOYEE_EMAIL, + currentUserAccountID: EMPLOYEE_ACCOUNT_ID, + report, + chatReport, + reportTransactions: [transaction], + originalTransaction: {} as Transaction, + violations, + policy, + }); + expect(resultWithoutPending.includes(CONST.REPORT.SECONDARY_ACTIONS.APPROVE)).toBe(true); + + // With DEW pending, APPROVE should NOT be included + const resultWithPending = getSecondaryReportActions({ + currentUserEmail: EMPLOYEE_EMAIL, + currentUserAccountID: EMPLOYEE_ACCOUNT_ID, + report, + chatReport, + reportTransactions: [transaction], + originalTransaction: {} as Transaction, + violations, + policy, + reportMetadata: {pendingExpenseAction: CONST.EXPENSE_PENDING_ACTION.APPROVE}, + }); + expect(resultWithPending.includes(CONST.REPORT.SECONDARY_ACTIONS.APPROVE)).toBe(false); + }); + it('includes APPROVE option for report with RTER violations when it is submitted', () => { const report = { reportID: REPORT_ID, From 9f587cb10cf557fa1bc13018c48b90b959267711 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Mon, 12 Jan 2026 15:54:32 +0100 Subject: [PATCH 05/35] fixing ts --- tests/unit/ReportSecondaryActionUtilsTest.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/unit/ReportSecondaryActionUtilsTest.ts b/tests/unit/ReportSecondaryActionUtilsTest.ts index 111b6a3ad5b5..313ffcc1d908 100644 --- a/tests/unit/ReportSecondaryActionUtilsTest.ts +++ b/tests/unit/ReportSecondaryActionUtilsTest.ts @@ -531,6 +531,7 @@ describe('getSecondaryAction', () => { reportTransactions: [transaction], originalTransaction: {} as Transaction, violations, + bankAccountList: {}, policy, }); expect(resultWithoutPending.includes(CONST.REPORT.SECONDARY_ACTIONS.APPROVE)).toBe(true); @@ -544,6 +545,7 @@ describe('getSecondaryAction', () => { reportTransactions: [transaction], originalTransaction: {} as Transaction, violations, + bankAccountList: {}, policy, reportMetadata: {pendingExpenseAction: CONST.EXPENSE_PENDING_ACTION.APPROVE}, }); From 4930e428d741b2a81c6878bdb66def8f758e2c99 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Tue, 20 Jan 2026 19:56:25 +0100 Subject: [PATCH 06/35] fixing tests --- tests/unit/ReportPrimaryActionUtilsTest.ts | 4 ++-- tests/unit/ReportSecondaryActionUtilsTest.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/unit/ReportPrimaryActionUtilsTest.ts b/tests/unit/ReportPrimaryActionUtilsTest.ts index f4f9cc9473b9..a1cb5c9f737e 100644 --- a/tests/unit/ReportPrimaryActionUtilsTest.ts +++ b/tests/unit/ReportPrimaryActionUtilsTest.ts @@ -321,10 +321,10 @@ describe('getPrimaryAction', () => { } as unknown as Transaction; // Without DEW pending, isApproveAction should return true - expect(isApproveAction(report, [transaction], policy, undefined)).toBe(true); + expect(isApproveAction(report, [transaction], CURRENT_USER_ACCOUNT_ID, policy)).toBe(true); // With DEW pending, isApproveAction should return false - expect(isApproveAction(report, [transaction], policy, {pendingExpenseAction: CONST.EXPENSE_PENDING_ACTION.APPROVE})).toBe(false); + expect(isApproveAction(report, [transaction], CURRENT_USER_ACCOUNT_ID, policy, {pendingExpenseAction: CONST.EXPENSE_PENDING_ACTION.APPROVE})).toBe(false); }); it('should return PAY for submitted invoice report if paid as personal', async () => { diff --git a/tests/unit/ReportSecondaryActionUtilsTest.ts b/tests/unit/ReportSecondaryActionUtilsTest.ts index 98c33a0e28a3..d124305960d3 100644 --- a/tests/unit/ReportSecondaryActionUtilsTest.ts +++ b/tests/unit/ReportSecondaryActionUtilsTest.ts @@ -524,7 +524,7 @@ describe('getSecondaryAction', () => { // Without DEW pending, APPROVE should be included (report has duplicates) const resultWithoutPending = getSecondaryReportActions({ - currentUserEmail: EMPLOYEE_EMAIL, + currentUserLogin: EMPLOYEE_EMAIL, currentUserAccountID: EMPLOYEE_ACCOUNT_ID, report, chatReport, @@ -538,7 +538,7 @@ describe('getSecondaryAction', () => { // With DEW pending, APPROVE should NOT be included const resultWithPending = getSecondaryReportActions({ - currentUserEmail: EMPLOYEE_EMAIL, + currentUserLogin: EMPLOYEE_EMAIL, currentUserAccountID: EMPLOYEE_ACCOUNT_ID, report, chatReport, From 72cf658495fa9761b4e88b7e83f2f3e13e4628dd Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Tue, 20 Jan 2026 20:26:45 +0100 Subject: [PATCH 07/35] fix: address review comments for DEW approve action --- src/libs/actions/IOU/index.ts | 28 ++++++------ src/libs/actions/Search.ts | 6 +-- src/pages/Search/SearchPage.tsx | 2 +- tests/unit/ReportPrimaryActionUtilsTest.ts | 32 +++++++++++-- tests/unit/ReportSecondaryActionUtilsTest.ts | 47 +++++++++++++++++--- 5 files changed, 86 insertions(+), 29 deletions(-) diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index e6f5c01124b6..25bc224ccdac 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -10581,33 +10581,33 @@ function approveMoneyRequest( } const failureData: OnyxUpdate[] = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, - value: { - statusNum: expenseReport.statusNum, - stateNum: expenseReport.stateNum, - ...(isDEWPolicy - ? {} - : { + ...(isDEWPolicy + ? [] + : [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}` as const, + value: { + statusNum: expenseReport.statusNum, + stateNum: expenseReport.stateNum, nextStep: expenseReport.nextStep ?? null, pendingFields: { partial: null, nextStep: null, }, - }), - }, - }, + }, + }, + ]), { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.chatReportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.chatReportID}` as const, value: { hasOutstandingChildRequest: chatReport?.hasOutstandingChildRequest, }, }, { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport.reportID}`, + key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport.reportID}` as const, value: expenseReportCurrentNextStepDeprecated ?? null, }, ]; diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 5d4c478581d7..eb57d5e3006a 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -130,7 +130,7 @@ function handleActionButtonPress({ onDEWModalOpen?.(); return; } - approveMoneyRequestOnSearch(hash, item.reportID ? [item.reportID] : [], snapshotPolicy, currentSearchKey); + approveMoneyRequestOnSearch(hash, item.reportID ? [item.reportID] : [], hasDynamicExternalWorkflow(snapshotPolicy), currentSearchKey); return; case CONST.SEARCH.ACTION_TYPES.SUBMIT: { if (hasDynamicExternalWorkflow(snapshotPolicy) && !isDEWBetaEnabled) { @@ -533,9 +533,7 @@ function submitMoneyRequestOnSearch(hash: number, reportList: Report[], policy: API.write(WRITE_COMMANDS.SUBMIT_REPORT, parameters, {optimisticData, successData, failureData}); } -function approveMoneyRequestOnSearch(hash: number, reportIDList: string[], policy?: Policy, currentSearchKey?: SearchKey) { - const isDEWPolicy = hasDynamicExternalWorkflow(policy); - +function approveMoneyRequestOnSearch(hash: number, reportIDList: string[], isDEWPolicy?: boolean, currentSearchKey?: SearchKey) { const optimisticData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE_COLLECTION, diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index edbfc0dca32e..82cb1501728b 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -408,7 +408,7 @@ function SearchPage({route}: SearchPageProps) { approveMoneyRequestOnSearch( hash, reportIDList.filter((reportID) => reportID !== undefined), - dewPolicy, + !!dewPolicy, ); // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.runAfterInteractions(() => { diff --git a/tests/unit/ReportPrimaryActionUtilsTest.ts b/tests/unit/ReportPrimaryActionUtilsTest.ts index a1cb5c9f737e..df95ce5d5ca2 100644 --- a/tests/unit/ReportPrimaryActionUtilsTest.ts +++ b/tests/unit/ReportPrimaryActionUtilsTest.ts @@ -299,7 +299,8 @@ describe('getPrimaryAction', () => { ).toBe(''); }); - it('should return false from isApproveAction when DEW approval is pending', async () => { + it('should return true from isApproveAction for DEW policy report without pending approval', async () => { + // Given a submitted expense report on a DEW policy without any pending approval action const report = { reportID: REPORT_ID, type: CONST.REPORT.TYPE.EXPENSE, @@ -320,10 +321,35 @@ describe('getPrimaryAction', () => { date: '2025-01-01', } as unknown as Transaction; - // Without DEW pending, isApproveAction should return true + // When checking if approve action is available + // Then it should return true because DEW approval is not in progress expect(isApproveAction(report, [transaction], CURRENT_USER_ACCOUNT_ID, policy)).toBe(true); + }); + + it('should return false from isApproveAction for DEW policy report with pending approval', async () => { + // Given a submitted expense report on a DEW policy with a pending approval action + const report = { + reportID: REPORT_ID, + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + managerID: CURRENT_USER_ACCOUNT_ID, + } as unknown as Report; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); + const policy = { + approver: CURRENT_USER_EMAIL, + approvalMode: CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL, + } as unknown as Policy; + const transaction = { + reportID: `${REPORT_ID}`, + amount: 10, + merchant: 'Merchant', + date: '2025-01-01', + } as unknown as Transaction; - // With DEW pending, isApproveAction should return false + // When checking if approve action is available while DEW approval is pending + // Then it should return false because DEW is already processing an approval expect(isApproveAction(report, [transaction], CURRENT_USER_ACCOUNT_ID, policy, {pendingExpenseAction: CONST.EXPENSE_PENDING_ACTION.APPROVE})).toBe(false); }); diff --git a/tests/unit/ReportSecondaryActionUtilsTest.ts b/tests/unit/ReportSecondaryActionUtilsTest.ts index d124305960d3..b4d0e5725afa 100644 --- a/tests/unit/ReportSecondaryActionUtilsTest.ts +++ b/tests/unit/ReportSecondaryActionUtilsTest.ts @@ -496,7 +496,8 @@ describe('getSecondaryAction', () => { expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.APPROVE)).toBe(false); }); - it('does not include APPROVE option when DEW approval is pending', async () => { + it('includes APPROVE option for DEW policy report without pending approval', async () => { + // Given a submitted expense report on a DEW policy without any pending approval action const TRANSACTION_ID = 'TRANSACTION_ID_DEW'; const report = { reportID: REPORT_ID, @@ -522,8 +523,8 @@ describe('getSecondaryAction', () => { const violations = {[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${TRANSACTION_ID}`]: [violation]}; - // Without DEW pending, APPROVE should be included (report has duplicates) - const resultWithoutPending = getSecondaryReportActions({ + // When getting secondary report actions + const result = getSecondaryReportActions({ currentUserLogin: EMPLOYEE_EMAIL, currentUserAccountID: EMPLOYEE_ACCOUNT_ID, report, @@ -534,10 +535,40 @@ describe('getSecondaryAction', () => { bankAccountList: {}, policy, }); - expect(resultWithoutPending.includes(CONST.REPORT.SECONDARY_ACTIONS.APPROVE)).toBe(true); - // With DEW pending, APPROVE should NOT be included - const resultWithPending = getSecondaryReportActions({ + // Then APPROVE should be included because DEW approval is not in progress + expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.APPROVE)).toBe(true); + }); + + it('does not include APPROVE option for DEW policy report with pending approval', async () => { + // Given a submitted expense report on a DEW policy with a pending approval action + const TRANSACTION_ID = 'TRANSACTION_ID_DEW_PENDING'; + const report = { + reportID: REPORT_ID, + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: EMPLOYEE_ACCOUNT_ID, + managerID: EMPLOYEE_ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + } as unknown as Report; + const policy = { + approver: EMPLOYEE_EMAIL, + approvalMode: CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL, + } as unknown as Policy; + const transaction = { + transactionID: TRANSACTION_ID, + } as unknown as Transaction; + const violation = { + name: CONST.VIOLATIONS.DUPLICATED_TRANSACTION, + } as TransactionViolation; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${TRANSACTION_ID}`, transaction); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); + + const violations = {[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${TRANSACTION_ID}`]: [violation]}; + + // When getting secondary report actions while DEW approval is pending + const result = getSecondaryReportActions({ currentUserLogin: EMPLOYEE_EMAIL, currentUserAccountID: EMPLOYEE_ACCOUNT_ID, report, @@ -549,7 +580,9 @@ describe('getSecondaryAction', () => { policy, reportMetadata: {pendingExpenseAction: CONST.EXPENSE_PENDING_ACTION.APPROVE}, }); - expect(resultWithPending.includes(CONST.REPORT.SECONDARY_ACTIONS.APPROVE)).toBe(false); + + // Then APPROVE should not be included because DEW is already processing an approval + expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.APPROVE)).toBe(false); }); it('includes APPROVE option for report with RTER violations when it is submitted', () => { From 1fade11d15a57cf553072d453780dda13a22b46a Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Tue, 20 Jan 2026 22:33:15 +0100 Subject: [PATCH 08/35] fix: make reportMetadata required param for canApproveIOU and isApproveAction --- src/components/MoneyReportHeader.tsx | 4 +- .../MoneyRequestReportNavigation.tsx | 2 + src/components/Search/index.tsx | 6 ++- .../Search/TransactionGroupListItem.tsx | 3 ++ src/hooks/useTodos.ts | 4 +- src/libs/ReportPrimaryActionUtils.ts | 8 +-- src/libs/ReportUtils.ts | 6 ++- src/libs/SearchUIUtils.ts | 20 ++++++-- src/libs/actions/IOU/index.ts | 10 ++-- tests/actions/IOUTest.ts | 32 ++++++------ tests/unit/ReportPrimaryActionUtilsTest.ts | 4 +- tests/unit/Search/SearchUIUtilsTest.ts | 51 +++++++++++-------- 12 files changed, 93 insertions(+), 57 deletions(-) diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 325ef0bc0836..5f610ecf2945 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -463,8 +463,8 @@ function MoneyReportHeader({ const shouldShowPayButton = isPaidAnimationRunning || canIOUBePaid || onlyShowPayElsewhere; const shouldShowApproveButton = useMemo( - () => (canApproveIOU(moneyRequestReport, policy, transactions, reportMetadata) && !hasOnlyPendingTransactions) || isApprovedAnimationRunning, - [moneyRequestReport, policy, transactions, hasOnlyPendingTransactions, isApprovedAnimationRunning, reportMetadata], + () => (canApproveIOU(moneyRequestReport, policy, reportMetadata, transactions) && !hasOnlyPendingTransactions) || isApprovedAnimationRunning, + [moneyRequestReport, policy, reportMetadata, transactions, hasOnlyPendingTransactions, isApprovedAnimationRunning], ); const shouldDisableApproveButton = shouldShowApproveButton && !isAllowedToApproveExpenseReport(moneyRequestReport); diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportNavigation.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportNavigation.tsx index 9a9d656b1c38..95a9504182c1 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportNavigation.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportNavigation.tsx @@ -36,6 +36,7 @@ function MoneyRequestReportNavigation({reportID, shouldDisplayNarrowVersion}: Mo const [cardFeeds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER, {canBeMissing: true}); const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST, {canBeMissing: true}); + const [allReportMetadata] = useOnyx(ONYXKEYS.COLLECTION.REPORT_METADATA, {canBeMissing: true}); const archivedReportsIdSet = useArchivedReportsIdSet(); @@ -56,6 +57,7 @@ function MoneyRequestReportNavigation({reportID, shouldDisplayNarrowVersion}: Mo archivedReportsIDList: archivedReportsIdSet, isActionLoadingSet, cardFeeds, + allReportMetadata, }); results = getSortedSections(type, status ?? '', searchData, localeCompare, translate, sortBy, sortOrder, groupBy).map((value) => value.reportID); } diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index f7348a64ddbe..4568aa21dcaf 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -246,6 +246,7 @@ function Search({ const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {canBeMissing: true}); const {accountID, email, login} = useCurrentUserPersonalDetails(); const [isActionLoadingSet = new Set()] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}`, {canBeMissing: true, selector: isActionLoadingSetSelector}); + const [allReportMetadata] = useOnyx(ONYXKEYS.COLLECTION.REPORT_METADATA, {canBeMissing: true}); const [visibleColumns] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM, {canBeMissing: true, selector: columnsSelector}); const [customCardNames] = useOnyx(ONYXKEYS.NVP_EXPENSIFY_COMPANY_CARDS_CUSTOM_NAMES, {canBeMissing: true}); @@ -398,6 +399,7 @@ function Search({ isActionLoadingSet, cardFeeds, allTransactionViolations: violations, + allReportMetadata, }); return [filteredData1, filteredData1.length, allLength]; }, [ @@ -418,6 +420,7 @@ function Search({ policies, bankAccountList, violations, + allReportMetadata, ]); // For group-by views, each grouped item has a transactionsQueryJSON with a hash pointing to a separate snapshot @@ -453,12 +456,13 @@ function Search({ translate, formatPhoneNumber, isActionLoadingSet, + allReportMetadata, }); return {...item, transactions: transactions1 as TransactionListItemType[]}; }); return enriched; - }, [validGroupBy, isExpenseReportType, baseFilteredData, groupByTransactionSnapshots, accountID, email, translate, formatPhoneNumber, isActionLoadingSet, bankAccountList]); + }, [validGroupBy, isExpenseReportType, baseFilteredData, groupByTransactionSnapshots, accountID, email, translate, formatPhoneNumber, isActionLoadingSet, bankAccountList, allReportMetadata]); const hasLoadedAllTransactions = useMemo(() => { if (!validGroupBy) { diff --git a/src/components/SelectionListWithSections/Search/TransactionGroupListItem.tsx b/src/components/SelectionListWithSections/Search/TransactionGroupListItem.tsx index bf5d45394495..e1f660d45a07 100644 --- a/src/components/SelectionListWithSections/Search/TransactionGroupListItem.tsx +++ b/src/components/SelectionListWithSections/Search/TransactionGroupListItem.tsx @@ -99,6 +99,7 @@ function TransactionGroupListItem({ const [transactionsVisibleLimit, setTransactionsVisibleLimit] = useState(CONST.TRANSACTION.RESULTS_PAGE_SIZE as number); const [isExpanded, setIsExpanded] = useState(false); const [isActionLoadingSet = new Set()] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}`, {canBeMissing: true, selector: isActionLoadingSetSelector}); + const [allReportMetadata] = useOnyx(ONYXKEYS.COLLECTION.REPORT_METADATA, {canBeMissing: true}); const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST, {canBeMissing: true}); const transactions = useMemo(() => { @@ -117,6 +118,7 @@ function TransactionGroupListItem({ formatPhoneNumber, bankAccountList, isActionLoadingSet, + allReportMetadata, }) as [TransactionListItemType[], number]; return sectionData.map((transactionItem) => ({ ...transactionItem, @@ -133,6 +135,7 @@ function TransactionGroupListItem({ currentUserDetails.email, isActionLoadingSet, bankAccountList, + allReportMetadata, ]); const selectedItemsLength = useMemo(() => { diff --git a/src/hooks/useTodos.ts b/src/hooks/useTodos.ts index 6a041eb82f19..e916d25fd7dd 100644 --- a/src/hooks/useTodos.ts +++ b/src/hooks/useTodos.ts @@ -47,10 +47,10 @@ export default function useTodos() { const reportTransactions = transactionsByReportID[report.reportID] ?? []; const reportMetadata = allReportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${report.reportID}`]; - if (isSubmitAction(report, reportTransactions, policy, reportNameValuePair, undefined, login, currentUserAccountID, reportMetadata)) { + if (isSubmitAction(report, reportTransactions, reportMetadata, policy, reportNameValuePair, undefined, login, currentUserAccountID)) { reportsToSubmit.push(report); } - if (isApproveAction(report, reportTransactions, currentUserAccountID, policy, reportMetadata)) { + if (isApproveAction(report, reportTransactions, currentUserAccountID, reportMetadata, policy)) { reportsToApprove.push(report); } if (isPrimaryPayAction(report, currentUserAccountID, login, bankAccountList, policy, reportNameValuePair)) { diff --git a/src/libs/ReportPrimaryActionUtils.ts b/src/libs/ReportPrimaryActionUtils.ts index 1d82d6ae97f3..b01e84ca42ca 100644 --- a/src/libs/ReportPrimaryActionUtils.ts +++ b/src/libs/ReportPrimaryActionUtils.ts @@ -91,12 +91,12 @@ function isAddExpenseAction(report: Report, reportTransactions: Transaction[], i function isSubmitAction( report: Report, reportTransactions: Transaction[], + reportMetadata: OnyxEntry, policy?: Policy, reportNameValuePairs?: ReportNameValuePairs, violations?: OnyxCollection, currentUserEmail?: string, currentUserAccountID?: number, - reportMetadata?: OnyxEntry, ) { if (isArchivedReport(reportNameValuePairs)) { return false; @@ -136,7 +136,7 @@ function isSubmitAction( return isExpenseReport && isReportSubmitter && isOpenReport && reportTransactions.length !== 0 && transactionAreComplete; } -function isApproveAction(report: Report, reportTransactions: Transaction[], currentUserAccountID: number, policy?: Policy, reportMetadata?: OnyxEntry) { +function isApproveAction(report: Report, reportTransactions: Transaction[], currentUserAccountID: number, reportMetadata: OnyxEntry, policy?: Policy) { const isAnyReceiptBeingScanned = reportTransactions?.some((transaction) => isScanning(transaction)); if (isAnyReceiptBeingScanned) { @@ -460,7 +460,7 @@ function getReportPrimaryAction(params: GetReportPrimaryActionParams): ValueOf violation.name === CONST.VIOLATIONS.AUTO_REPORTED_REJECTED_EXPENSE); }); + const reportMetadata = allReportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${iouReportID}`]; const canSubmit = !hasAutoRejectedTransactionsForManager && canSubmitReport(iouReport, policy, transactions, undefined, false, currentUserEmailParam); - return canIOUBePaid(iouReport, chatReport, policy, bankAccountList, transactions) || canApproveIOU(iouReport, policy, transactions) || canSubmit; + return canIOUBePaid(iouReport, chatReport, policy, bankAccountList, transactions) || canApproveIOU(iouReport, policy, reportMetadata, transactions) || canSubmit; }); } @@ -4218,7 +4219,8 @@ function getReasonAndReportActionThatRequiresAttention( }; } - const iouReportActionToApproveOrPay = getIOUReportActionToApproveOrPay(optionOrReport, undefined); + const optionReportMetadata = allReportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${optionOrReport.reportID}`]; + const iouReportActionToApproveOrPay = getIOUReportActionToApproveOrPay(optionOrReport, undefined, optionReportMetadata); const iouReportID = getIOUReportIDFromReportActionPreview(iouReportActionToApproveOrPay); const transactions = getReportTransactions(iouReportID); const hasOnlyPendingTransactions = transactions.length > 0 && transactions.every((t) => isExpensifyCardTransaction(t) && isPending(t)); diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index cd00f8181f60..9120d0b4385b 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -157,6 +157,7 @@ type GetReportSectionsParams = { allTransactionViolations: OnyxCollection; bankAccountList: OnyxEntry; reportActions?: Record; + allReportMetadata: OnyxCollection; }; const transactionColumnNamesToSortingProperty: TransactionSorting = { @@ -367,6 +368,7 @@ type GetSectionsParams = { isActionLoadingSet?: ReadonlySet; cardFeeds?: OnyxCollection; allTransactionViolations?: OnyxCollection; + allReportMetadata: OnyxCollection; }; /** @@ -1257,6 +1259,7 @@ function getTransactionsSections( isActionLoadingSet: ReadonlySet | undefined, bankAccountList: OnyxEntry, reportActions: Record = {}, + allReportMetadata: OnyxCollection, ): [TransactionListItemType[], number] { const shouldShowMerchant = getShouldShowMerchant(data); const {shouldShowYearCreated, shouldShowYearSubmitted, shouldShowYearApproved, shouldShowYearPosted, shouldShowYearExported} = shouldShowYear(data); @@ -1321,7 +1324,8 @@ function getTransactionsSections( report, ); const actions = reportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionItem.reportID}`] ?? []; - const allActions = getActions(data, allViolations, key, currentSearch, currentUserEmail, bankAccountList, actions); + const reportMetadata = allReportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${transactionItem.reportID}`] ?? {}; + const allActions = getActions(data, allViolations, key, currentSearch, currentUserEmail, bankAccountList, actions, reportMetadata); const transactionSection: TransactionListItemType = { ...transactionItem, keyForList: transactionItem.transactionID, @@ -1423,6 +1427,7 @@ function getActions( currentUserLogin: string, bankAccountList: OnyxEntry, reportActions: OnyxTypes.ReportAction[] = [], + reportMetadata: OnyxEntry, ): SearchTransactionAction[] { const isTransaction = isTransactionEntry(key); const report = getReportFromKey(data, key); @@ -1508,7 +1513,7 @@ function getActions( // We're not supporting approve partial amount on search page now if ( - canApproveIOU(report, policy, allReportTransactions) && + canApproveIOU(report, policy, reportMetadata, allReportTransactions) && isAllowedToApproveExpenseReport && !hasOnlyPendingCardOrScanningTransactions && !hasHeldExpenses(report.reportID, allReportTransactions) @@ -1717,6 +1722,7 @@ function getReportSections({ allTransactionViolations, bankAccountList, reportActions = {}, + allReportMetadata, }: GetReportSectionsParams): [TransactionGroupListItemType[], number] { const shouldShowMerchant = getShouldShowMerchant(data); @@ -1786,7 +1792,8 @@ function getReportSections({ if (shouldShow) { const reportPendingAction = reportItem?.pendingAction ?? reportItem?.pendingFields?.preview; const shouldShowBlankTo = !reportItem || isOpenExpenseReport(reportItem); - const allActions = getActions(data, allViolations, key, currentSearch, currentUserEmail, bankAccountList, actions); + const reportMetadata = allReportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportItem.reportID}`] ?? {}; + const allActions = getActions(data, allViolations, key, currentSearch, currentUserEmail, bankAccountList, actions, reportMetadata); const fromDetails = data.personalDetailsList?.[reportItem.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID] ?? @@ -1870,7 +1877,8 @@ function getReportSections({ report, ); - const allActions = getActions(data, allViolations, key, currentSearch, currentUserEmail, bankAccountList, actions); + const transactionReportMetadata = allReportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${transactionItem.reportID}`] ?? {}; + const allActions = getActions(data, allViolations, key, currentSearch, currentUserEmail, bankAccountList, actions, transactionReportMetadata); const transaction = { ...transactionItem, action: allActions.at(0) ?? CONST.SEARCH.ACTION_TYPES.VIEW, @@ -2095,6 +2103,7 @@ function getSections({ isActionLoadingSet, cardFeeds, allTransactionViolations, + allReportMetadata, }: GetSectionsParams) { if (type === CONST.SEARCH.DATA_TYPES.CHAT) { return getReportActionsSections(data); @@ -2116,6 +2125,7 @@ function getSections({ allTransactionViolations, bankAccountList, reportActions, + allReportMetadata, }); } @@ -2132,7 +2142,7 @@ function getSections({ } } - return getTransactionsSections(data, currentSearch, currentAccountID, currentUserEmail, formatPhoneNumber, isActionLoadingSet, bankAccountList, reportActions); + return getTransactionsSections(data, currentSearch, currentAccountID, currentUserEmail, formatPhoneNumber, isActionLoadingSet, bankAccountList, reportActions, allReportMetadata); } /** diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index 25bc224ccdac..80431829add1 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -10232,8 +10232,8 @@ function getPayMoneyRequestParams({ function canApproveIOU( iouReport: OnyxTypes.OnyxInputOrEntry, policy: OnyxTypes.OnyxInputOrEntry, + reportMetadata: OnyxEntry, iouTransactions?: OnyxTypes.Transaction[], - reportMetadata?: OnyxEntry, ) { // Only expense reports can be approved if (!isExpenseReport(iouReport) || !(policy && isPaidGroupPolicy(policy))) { @@ -10379,7 +10379,11 @@ function canSubmitReport( ); } -function getIOUReportActionToApproveOrPay(chatReport: OnyxEntry, updatedIouReport: OnyxEntry): OnyxEntry { +function getIOUReportActionToApproveOrPay( + chatReport: OnyxEntry, + updatedIouReport: OnyxEntry, + reportMetadata: OnyxEntry, +): OnyxEntry { const chatReportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`] ?? {}; return Object.values(chatReportActions).find((action) => { @@ -10391,7 +10395,7 @@ function getIOUReportActionToApproveOrPay(chatReport: OnyxEntry { await waitForBatchedUpdates(); - expect(canApproveIOU(fakeReport, fakePolicy)).toBeFalsy(); - // Then should return false when passing transactions directly as the third parameter instead of relying on Onyx data + expect(canApproveIOU(fakeReport, fakePolicy, {})).toBeFalsy(); + // Then should return false when passing transactions directly as the fourth parameter instead of relying on Onyx data const {result} = renderHook(() => useReportWithTransactionsAndViolations(reportID), {wrapper: OnyxListItemProvider}); await waitForBatchedUpdatesWithAct(); - expect(canApproveIOU(result.current.at(0) as Report, fakePolicy, result.current.at(1) as Transaction[])).toBeFalsy(); + expect(canApproveIOU(result.current.at(0) as Report, fakePolicy, {}, result.current.at(1) as Transaction[])).toBeFalsy(); }); it('should return false if we have only scanning transactions', async () => { const policyID = '2'; @@ -7527,11 +7527,11 @@ describe('actions/IOU', () => { await waitForBatchedUpdates(); - expect(canApproveIOU(fakeReport, fakePolicy)).toBeFalsy(); - // Then should return false when passing transactions directly as the third parameter instead of relying on Onyx data + expect(canApproveIOU(fakeReport, fakePolicy, {})).toBeFalsy(); + // Then should return false when passing transactions directly as the fourth parameter instead of relying on Onyx data const {result} = renderHook(() => useReportWithTransactionsAndViolations(reportID), {wrapper: OnyxListItemProvider}); await waitForBatchedUpdatesWithAct(); - expect(canApproveIOU(result.current.at(0) as Report, fakePolicy, result.current.at(1) as Transaction[])).toBeFalsy(); + expect(canApproveIOU(result.current.at(0) as Report, fakePolicy, {}, result.current.at(1) as Transaction[])).toBeFalsy(); }); it('should return false if all transactions are pending card or scanning transaction', async () => { const policyID = '2'; @@ -7574,11 +7574,11 @@ describe('actions/IOU', () => { await waitForBatchedUpdates(); - expect(canApproveIOU(fakeReport, fakePolicy)).toBeFalsy(); - // Then should return false when passing transactions directly as the third parameter instead of relying on Onyx data + expect(canApproveIOU(fakeReport, fakePolicy, {})).toBeFalsy(); + // Then should return false when passing transactions directly as the fourth parameter instead of relying on Onyx data const {result} = renderHook(() => useReportWithTransactionsAndViolations(reportID), {wrapper: OnyxListItemProvider}); await waitForBatchedUpdatesWithAct(); - expect(canApproveIOU(result.current.at(0) as Report, fakePolicy, result.current.at(1) as Transaction[])).toBeFalsy(); + expect(canApproveIOU(result.current.at(0) as Report, fakePolicy, {}, result.current.at(1) as Transaction[])).toBeFalsy(); }); it('should return true if at least one transaction is not pending card or scanning transaction', async () => { const policyID = '2'; @@ -7627,11 +7627,11 @@ describe('actions/IOU', () => { await waitForBatchedUpdates(); - expect(canApproveIOU(fakeReport, fakePolicy)).toBeTruthy(); - // Then should return true when passing transactions directly as the third parameter instead of relying on Onyx data + expect(canApproveIOU(fakeReport, fakePolicy, {})).toBeTruthy(); + // Then should return true when passing transactions directly as the fourth parameter instead of relying on Onyx data const {result} = renderHook(() => useReportWithTransactionsAndViolations(reportID), {wrapper: OnyxListItemProvider}); await waitForBatchedUpdatesWithAct(); - expect(canApproveIOU(result.current.at(0) as Report, fakePolicy, result.current.at(1) as Transaction[])).toBeTruthy(); + expect(canApproveIOU(result.current.at(0) as Report, fakePolicy, {}, result.current.at(1) as Transaction[])).toBeTruthy(); }); it('should return false if the report is closed', async () => { @@ -7660,9 +7660,9 @@ describe('actions/IOU', () => { }); await waitForBatchedUpdates(); // Then, canApproveIOU should return false since the report is closed - expect(canApproveIOU(fakeReport, fakePolicy)).toBeFalsy(); - // Then should return false when passing transactions directly as the third parameter instead of relying on Onyx data - expect(canApproveIOU(fakeReport, fakePolicy, [fakeTransaction])).toBeFalsy(); + expect(canApproveIOU(fakeReport, fakePolicy, {})).toBeFalsy(); + // Then should return false when passing transactions directly as the fourth parameter instead of relying on Onyx data + expect(canApproveIOU(fakeReport, fakePolicy, {}, [fakeTransaction])).toBeFalsy(); }); }); @@ -9981,7 +9981,7 @@ describe('actions/IOU', () => { await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${fakeReport.reportID}`, MOCK_REPORT_ACTIONS); - expect(getIOUReportActionToApproveOrPay(fakeReport, undefined)).toMatchObject(MOCK_REPORT_ACTIONS[reportID]); + expect(getIOUReportActionToApproveOrPay(fakeReport, undefined, {})).toMatchObject(MOCK_REPORT_ACTIONS[reportID]); }); }); diff --git a/tests/unit/ReportPrimaryActionUtilsTest.ts b/tests/unit/ReportPrimaryActionUtilsTest.ts index df95ce5d5ca2..3c3114d5a6fe 100644 --- a/tests/unit/ReportPrimaryActionUtilsTest.ts +++ b/tests/unit/ReportPrimaryActionUtilsTest.ts @@ -323,7 +323,7 @@ describe('getPrimaryAction', () => { // When checking if approve action is available // Then it should return true because DEW approval is not in progress - expect(isApproveAction(report, [transaction], CURRENT_USER_ACCOUNT_ID, policy)).toBe(true); + expect(isApproveAction(report, [transaction], CURRENT_USER_ACCOUNT_ID, {}, policy)).toBe(true); }); it('should return false from isApproveAction for DEW policy report with pending approval', async () => { @@ -350,7 +350,7 @@ describe('getPrimaryAction', () => { // When checking if approve action is available while DEW approval is pending // Then it should return false because DEW is already processing an approval - expect(isApproveAction(report, [transaction], CURRENT_USER_ACCOUNT_ID, policy, {pendingExpenseAction: CONST.EXPENSE_PENDING_ACTION.APPROVE})).toBe(false); + expect(isApproveAction(report, [transaction], CURRENT_USER_ACCOUNT_ID, {pendingExpenseAction: CONST.EXPENSE_PENDING_ACTION.APPROVE}, policy)).toBe(false); }); it('should return PAY for submitted invoice report if paid as personal', async () => { diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index 8b8f79a025b8..33974fbdf21e 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -1651,15 +1651,15 @@ describe('SearchUIUtils', () => { }); describe('Test getAction', () => { test('Should return `View` action for an invalid key', () => { - const action = SearchUIUtils.getActions(searchResults.data, {}, 'invalid_key', CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}).at(0); + const action = SearchUIUtils.getActions(searchResults.data, {}, 'invalid_key', CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, [], {}).at(0); expect(action).toStrictEqual(CONST.SEARCH.ACTION_TYPES.VIEW); }); test('Should return `Submit` action for transaction on policy with delayed submission and no violations', () => { - let action = SearchUIUtils.getActions(searchResults.data, {}, `report_${reportID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}).at(0); + let action = SearchUIUtils.getActions(searchResults.data, {}, `report_${reportID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, [], {}).at(0); expect(action).toStrictEqual(CONST.SEARCH.ACTION_TYPES.SUBMIT); - action = SearchUIUtils.getActions(searchResults.data, {}, `transactions_${transactionID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}).at(0); + action = SearchUIUtils.getActions(searchResults.data, {}, `transactions_${transactionID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, [], {}).at(0); expect(action).toStrictEqual(CONST.SEARCH.ACTION_TYPES.SUBMIT); }); @@ -1682,10 +1682,10 @@ describe('SearchUIUtils', () => { managerID: adminAccountID, }, }; - expect(SearchUIUtils.getActions(localSearchResults, allViolations, `report_${reportID2}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}).at(0)).toStrictEqual( + expect(SearchUIUtils.getActions(localSearchResults, allViolations, `report_${reportID2}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, [], {}).at(0)).toStrictEqual( CONST.SEARCH.ACTION_TYPES.VIEW, ); - expect(SearchUIUtils.getActions(localSearchResults, allViolations, `transactions_${transactionID2}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}).at(0)).toStrictEqual( + expect(SearchUIUtils.getActions(localSearchResults, allViolations, `transactions_${transactionID2}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, [], {}).at(0)).toStrictEqual( CONST.SEARCH.ACTION_TYPES.VIEW, ); }); @@ -1705,7 +1705,7 @@ describe('SearchUIUtils', () => { }, }; - const action = SearchUIUtils.getActions(localSearchResults, {}, paidReportID, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}).at(0); + const action = SearchUIUtils.getActions(localSearchResults, {}, paidReportID, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, [], {}).at(0); expect(action).toStrictEqual(CONST.SEARCH.ACTION_TYPES.PAID); }); @@ -1729,7 +1729,7 @@ describe('SearchUIUtils', () => { }, }; - const action = SearchUIUtils.getActions(localSearchResults, {}, paidReportID, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}).at(0); + const action = SearchUIUtils.getActions(localSearchResults, {}, paidReportID, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, [], {}).at(0); expect(action).toStrictEqual(CONST.SEARCH.ACTION_TYPES.PAID); }); @@ -1745,7 +1745,7 @@ describe('SearchUIUtils', () => { }, }; - const action = SearchUIUtils.getActions(localSearchResults, {}, `report_${closedReportID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}).at(0); + const action = SearchUIUtils.getActions(localSearchResults, {}, `report_${closedReportID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, [], {}).at(0); expect(action).toStrictEqual(CONST.SEARCH.ACTION_TYPES.PAY); }); @@ -1763,13 +1763,13 @@ describe('SearchUIUtils', () => { }, }; - const action = SearchUIUtils.getActions(localSearchResults, {}, `report_${closedReportID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}).at(0); + const action = SearchUIUtils.getActions(localSearchResults, {}, `report_${closedReportID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, [], {}).at(0); expect(action).toStrictEqual(CONST.SEARCH.ACTION_TYPES.DONE); }); test('Should return `View` action for non-money request reports', () => { - const action = SearchUIUtils.getActions(searchResults.data, {}, `report_${reportID4}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}).at(0); + const action = SearchUIUtils.getActions(searchResults.data, {}, `report_${reportID4}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, [], {}).at(0); expect(action).toStrictEqual(CONST.SEARCH.ACTION_TYPES.VIEW); }); @@ -1783,7 +1783,7 @@ describe('SearchUIUtils', () => { reportID: 'non_existent_report', }, }; - const action = SearchUIUtils.getActions(localSearchResults, {}, `transactions_${orphanedTransactionID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}).at(0); + const action = SearchUIUtils.getActions(localSearchResults, {}, `transactions_${orphanedTransactionID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, [], {}).at(0); expect(action).toStrictEqual(CONST.SEARCH.ACTION_TYPES.VIEW); }); test('Should return `View` action for a transaction in a multi-transaction report', () => { @@ -1801,14 +1801,14 @@ describe('SearchUIUtils', () => { reportID: multiTransactionReportID, }, }; - const action = SearchUIUtils.getActions(localSearchResults, {}, `transactions_${multiTransactionID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}).at(0); + const action = SearchUIUtils.getActions(localSearchResults, {}, `transactions_${multiTransactionID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, [], {}).at(0); expect(action).toStrictEqual(CONST.SEARCH.ACTION_TYPES.VIEW); }); test('Should return `Pay` action for an IOU report ready to be paid', async () => { Onyx.merge(ONYXKEYS.SESSION, {accountID: adminAccountID}); await waitForBatchedUpdates(); const iouReportKey = `report_${reportID3}`; - const action = SearchUIUtils.getActions(searchResults.data, {}, iouReportKey, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}).at(0); + const action = SearchUIUtils.getActions(searchResults.data, {}, iouReportKey, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, [], {}).at(0); expect(action).toEqual(CONST.SEARCH.ACTION_TYPES.PAY); }); @@ -1843,7 +1843,7 @@ describe('SearchUIUtils', () => { }, }; - const actions = SearchUIUtils.getActions(localSearchResults, {}, `report_${exportReportID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, adminEmail, {}); + const actions = SearchUIUtils.getActions(localSearchResults, {}, `report_${exportReportID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, adminEmail, {}, [], {}); expect(actions).toContain(CONST.SEARCH.ACTION_TYPES.EXPORT_TO_ACCOUNTING); }); @@ -1881,7 +1881,7 @@ describe('SearchUIUtils', () => { }, ] as OnyxTypes.ReportAction[]; - const action = SearchUIUtils.getActions(localSearchResults, {}, `transactions_${dewTransactionID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, dewReportActions).at(0); + const action = SearchUIUtils.getActions(localSearchResults, {}, `transactions_${dewTransactionID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, dewReportActions, {}).at(0); expect(action).toStrictEqual(CONST.SEARCH.ACTION_TYPES.SUBMIT); }); @@ -1918,7 +1918,7 @@ describe('SearchUIUtils', () => { }, ] as OnyxTypes.ReportAction[]; - const action = SearchUIUtils.getActions(localSearchResults, {}, `transactions_${dewTransactionID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, dewReportActions).at(0); + const action = SearchUIUtils.getActions(localSearchResults, {}, `transactions_${dewTransactionID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, dewReportActions, {}).at(0); expect(action).not.toStrictEqual(CONST.SEARCH.ACTION_TYPES.VIEW); }); @@ -1957,7 +1957,7 @@ describe('SearchUIUtils', () => { }, ] as OnyxTypes.ReportAction[]; - const action = SearchUIUtils.getActions(localSearchResults, {}, `transactions_${nonDewTransactionID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, nonDewReportActions).at(0); + const action = SearchUIUtils.getActions(localSearchResults, {}, `transactions_${nonDewTransactionID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, nonDewReportActions, {}).at(0); expect(action).not.toStrictEqual(CONST.SEARCH.ACTION_TYPES.VIEW); }); }); @@ -2006,6 +2006,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + allReportMetadata: {}, }); expect(filteredReportActions).toStrictEqual(reportActionListItems); expect(allReportActionsLength).toBe(6); @@ -2021,6 +2022,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + allReportMetadata: {}, })[0], ).toEqual(transactionsListItems); }); @@ -2047,6 +2049,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + allReportMetadata: {}, })[0] as TransactionListItemType[]; const distanceTransaction = result.find((item) => item.transactionID === distanceTransactionID); @@ -2080,6 +2083,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + allReportMetadata: {}, })[0] as TransactionGroupListItemType[]; const reportGroup = result.find((group) => group.transactions?.some((transaction) => transaction.transactionID === distanceTransactionID)); @@ -2103,6 +2107,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + allReportMetadata: {}, })[0], ).toStrictEqual(transactionReportGroupListItems); }); @@ -2144,6 +2149,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + allReportMetadata: {}, })[0]; const resultReportFirst = SearchUIUtils.getSections({ type: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT, @@ -2153,6 +2159,7 @@ describe('SearchUIUtils', () => { translate: translateLocal, formatPhoneNumber, bankAccountList: {}, + allReportMetadata: {}, })[0]; expect(resultTransactionFirst).toBeDefined(); @@ -2176,6 +2183,7 @@ describe('SearchUIUtils', () => { formatPhoneNumber, bankAccountList: {}, groupBy: CONST.SEARCH.GROUP_BY.FROM, + allReportMetadata: {}, })[0], ).toStrictEqual(transactionMemberGroupListItems); }); @@ -2191,6 +2199,7 @@ describe('SearchUIUtils', () => { formatPhoneNumber, bankAccountList: {}, groupBy: CONST.SEARCH.GROUP_BY.CARD, + allReportMetadata: {}, })[0], ).toStrictEqual(transactionCardGroupListItems); }); @@ -2206,6 +2215,7 @@ describe('SearchUIUtils', () => { formatPhoneNumber, bankAccountList: {}, groupBy: CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID, + allReportMetadata: {}, })[0], ).toStrictEqual(transactionWithdrawalIDGroupListItems); }); @@ -2230,6 +2240,7 @@ describe('SearchUIUtils', () => { formatPhoneNumber, bankAccountList: {}, groupBy: CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID, + allReportMetadata: {}, }) as [TransactionWithdrawalIDGroupListItemType[], number]; expect(result).toHaveLength(0); @@ -2750,10 +2761,10 @@ describe('SearchUIUtils', () => { Onyx.merge(ONYXKEYS.SESSION, {accountID: overlimitApproverAccountID}); searchResults.data[`policy_${policyID}`].role = CONST.POLICY.ROLE.USER; return waitForBatchedUpdates().then(() => { - let action = SearchUIUtils.getActions(searchResults.data, allViolations, `report_${reportID2}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}).at(0); + let action = SearchUIUtils.getActions(searchResults.data, allViolations, `report_${reportID2}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, [], {}).at(0); expect(action).toEqual(CONST.SEARCH.ACTION_TYPES.VIEW); - action = SearchUIUtils.getActions(searchResults.data, allViolations, `transactions_${transactionID2}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}).at(0); + action = SearchUIUtils.getActions(searchResults.data, allViolations, `transactions_${transactionID2}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, [], {}).at(0); expect(action).toEqual(CONST.SEARCH.ACTION_TYPES.VIEW); }); }); @@ -2866,7 +2877,7 @@ describe('SearchUIUtils', () => { }, }; return waitForBatchedUpdates().then(() => { - const action = SearchUIUtils.getActions(result.data, allViolations, 'report_6523565988285061', CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}).at(0); + const action = SearchUIUtils.getActions(result.data, allViolations, 'report_6523565988285061', CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, [], {}).at(0); expect(action).toEqual(CONST.SEARCH.ACTION_TYPES.APPROVE); }); }); From c2967a9e71d430083ea8a48e9ee5f1bf114f86a8 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Tue, 20 Jan 2026 22:54:31 +0100 Subject: [PATCH 09/35] fix tests and doing minor code refactoring --- src/components/Search/index.tsx | 14 +++++- src/libs/ReportActionsUtils.ts | 64 +++++++++++--------------- src/libs/SearchUIUtils.ts | 12 ++--- tests/unit/Search/SearchUIUtilsTest.ts | 40 ++++++++-------- 4 files changed, 67 insertions(+), 63 deletions(-) diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 4568aa21dcaf..160c7c8ddc6e 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -462,7 +462,19 @@ function Search({ }); return enriched; - }, [validGroupBy, isExpenseReportType, baseFilteredData, groupByTransactionSnapshots, accountID, email, translate, formatPhoneNumber, isActionLoadingSet, bankAccountList, allReportMetadata]); + }, [ + validGroupBy, + isExpenseReportType, + baseFilteredData, + groupByTransactionSnapshots, + accountID, + email, + translate, + formatPhoneNumber, + isActionLoadingSet, + bankAccountList, + allReportMetadata, + ]); const hasLoadedAllTransactions = useMemo(() => { if (!validGroupBy) { diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 61ddf82a4f12..897575da305d 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -279,30 +279,26 @@ function isDynamicExternalWorkflowSubmitFailedAction(reportAction: OnyxInputOrEn function getMostRecentActiveDEWSubmitFailedAction(reportActions: OnyxEntry | ReportAction[]): ReportAction | undefined { const actionsArray = Array.isArray(reportActions) ? reportActions : Object.values(reportActions ?? {}); - // Find the most recent DEW_SUBMIT_FAILED action - const mostRecentDewSubmitFailedAction = actionsArray - .filter((action): action is ReportAction => isDynamicExternalWorkflowSubmitFailedAction(action)) - .reduce((latest, current) => { - if (!latest || (current.created && latest.created && current.created > latest.created)) { - return current; + // Find the most recent DEW_SUBMIT_FAILED and SUBMITTED actions + let mostRecentDewSubmitFailedAction: ReportAction | undefined; + let mostRecentSubmittedAction: ReportAction | undefined; + + for (const action of actionsArray) { + if (isDynamicExternalWorkflowSubmitFailedAction(action)) { + if (!mostRecentDewSubmitFailedAction || (action.created && mostRecentDewSubmitFailedAction.created && action.created > mostRecentDewSubmitFailedAction.created)) { + mostRecentDewSubmitFailedAction = action; } - return latest; - }, undefined); + } else if (isSubmittedAction(action)) { + if (!mostRecentSubmittedAction || (action.created && mostRecentSubmittedAction.created && action.created > mostRecentSubmittedAction.created)) { + mostRecentSubmittedAction = action; + } + } + } if (!mostRecentDewSubmitFailedAction) { return undefined; } - // Find the most recent SUBMITTED action - const mostRecentSubmittedAction = actionsArray - .filter((action): action is ReportAction => isSubmittedAction(action)) - .reduce((latest, current) => { - if (!latest || (current.created && latest.created && current.created > latest.created)) { - return current; - } - return latest; - }, undefined); - // Return the DEW action if there's no SUBMITTED action, or if DEW_SUBMIT_FAILED is more recent if (!mostRecentSubmittedAction || mostRecentDewSubmitFailedAction.created > mostRecentSubmittedAction.created) { return mostRecentDewSubmitFailedAction; @@ -329,30 +325,26 @@ function isDynamicExternalWorkflowApproveFailedAction(reportAction: OnyxInputOrE function getMostRecentActiveDEWApproveFailedAction(reportActions: OnyxEntry | ReportAction[]): ReportAction | undefined { const actionsArray = Array.isArray(reportActions) ? reportActions : Object.values(reportActions ?? {}); - // Find the most recent DEW_APPROVE_FAILED action - const mostRecentDewApproveFailedAction = actionsArray - .filter((action): action is ReportAction => isDynamicExternalWorkflowApproveFailedAction(action)) - .reduce((latest, current) => { - if (!latest || (current.created && latest.created && current.created > latest.created)) { - return current; + // Find the most recent DEW_APPROVE_FAILED and APPROVED/FORWARDED actions + let mostRecentDewApproveFailedAction: ReportAction | undefined; + let mostRecentApprovalAction: ReportAction | undefined; + + for (const action of actionsArray) { + if (isDynamicExternalWorkflowApproveFailedAction(action)) { + if (!mostRecentDewApproveFailedAction || (action.created && mostRecentDewApproveFailedAction.created && action.created > mostRecentDewApproveFailedAction.created)) { + mostRecentDewApproveFailedAction = action; } - return latest; - }, undefined); + } else if (isApprovedAction(action) || isForwardedAction(action)) { + if (!mostRecentApprovalAction || (action.created && mostRecentApprovalAction.created && action.created > mostRecentApprovalAction.created)) { + mostRecentApprovalAction = action; + } + } + } if (!mostRecentDewApproveFailedAction) { return undefined; } - // Find the most recent APPROVED or FORWARDED action - const mostRecentApprovalAction = actionsArray - .filter((action): action is ReportAction => isApprovedAction(action) || isForwardedAction(action)) - .reduce((latest, current) => { - if (!latest || (current.created && latest.created && current.created > latest.created)) { - return current; - } - return latest; - }, undefined); - // Return the DEW action if there's no approval action, or if DEW_APPROVE_FAILED is more recent if (!mostRecentApprovalAction || mostRecentDewApproveFailedAction.created > mostRecentApprovalAction.created) { return mostRecentDewApproveFailedAction; diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 9120d0b4385b..e032870c2ade 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -1258,8 +1258,8 @@ function getTransactionsSections( formatPhoneNumber: LocaleContextProps['formatPhoneNumber'], isActionLoadingSet: ReadonlySet | undefined, bankAccountList: OnyxEntry, - reportActions: Record = {}, allReportMetadata: OnyxCollection, + reportActions: Record = {}, ): [TransactionListItemType[], number] { const shouldShowMerchant = getShouldShowMerchant(data); const {shouldShowYearCreated, shouldShowYearSubmitted, shouldShowYearApproved, shouldShowYearPosted, shouldShowYearExported} = shouldShowYear(data); @@ -1325,7 +1325,7 @@ function getTransactionsSections( ); const actions = reportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionItem.reportID}`] ?? []; const reportMetadata = allReportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${transactionItem.reportID}`] ?? {}; - const allActions = getActions(data, allViolations, key, currentSearch, currentUserEmail, bankAccountList, actions, reportMetadata); + const allActions = getActions(data, allViolations, key, currentSearch, currentUserEmail, bankAccountList, reportMetadata, actions); const transactionSection: TransactionListItemType = { ...transactionItem, keyForList: transactionItem.transactionID, @@ -1426,8 +1426,8 @@ function getActions( currentSearch: SearchKey, currentUserLogin: string, bankAccountList: OnyxEntry, - reportActions: OnyxTypes.ReportAction[] = [], reportMetadata: OnyxEntry, + reportActions: OnyxTypes.ReportAction[] = [], ): SearchTransactionAction[] { const isTransaction = isTransactionEntry(key); const report = getReportFromKey(data, key); @@ -1793,7 +1793,7 @@ function getReportSections({ const reportPendingAction = reportItem?.pendingAction ?? reportItem?.pendingFields?.preview; const shouldShowBlankTo = !reportItem || isOpenExpenseReport(reportItem); const reportMetadata = allReportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportItem.reportID}`] ?? {}; - const allActions = getActions(data, allViolations, key, currentSearch, currentUserEmail, bankAccountList, actions, reportMetadata); + const allActions = getActions(data, allViolations, key, currentSearch, currentUserEmail, bankAccountList, reportMetadata, actions); const fromDetails = data.personalDetailsList?.[reportItem.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID] ?? @@ -1878,7 +1878,7 @@ function getReportSections({ ); const transactionReportMetadata = allReportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${transactionItem.reportID}`] ?? {}; - const allActions = getActions(data, allViolations, key, currentSearch, currentUserEmail, bankAccountList, actions, transactionReportMetadata); + const allActions = getActions(data, allViolations, key, currentSearch, currentUserEmail, bankAccountList, transactionReportMetadata, actions); const transaction = { ...transactionItem, action: allActions.at(0) ?? CONST.SEARCH.ACTION_TYPES.VIEW, @@ -2142,7 +2142,7 @@ function getSections({ } } - return getTransactionsSections(data, currentSearch, currentAccountID, currentUserEmail, formatPhoneNumber, isActionLoadingSet, bankAccountList, reportActions, allReportMetadata); + return getTransactionsSections(data, currentSearch, currentAccountID, currentUserEmail, formatPhoneNumber, isActionLoadingSet, bankAccountList, allReportMetadata, reportActions); } /** diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index 33974fbdf21e..957312eb01be 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -1651,15 +1651,15 @@ describe('SearchUIUtils', () => { }); describe('Test getAction', () => { test('Should return `View` action for an invalid key', () => { - const action = SearchUIUtils.getActions(searchResults.data, {}, 'invalid_key', CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, [], {}).at(0); + const action = SearchUIUtils.getActions(searchResults.data, {}, 'invalid_key', CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, {}, []).at(0); expect(action).toStrictEqual(CONST.SEARCH.ACTION_TYPES.VIEW); }); test('Should return `Submit` action for transaction on policy with delayed submission and no violations', () => { - let action = SearchUIUtils.getActions(searchResults.data, {}, `report_${reportID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, [], {}).at(0); + let action = SearchUIUtils.getActions(searchResults.data, {}, `report_${reportID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, {}, []).at(0); expect(action).toStrictEqual(CONST.SEARCH.ACTION_TYPES.SUBMIT); - action = SearchUIUtils.getActions(searchResults.data, {}, `transactions_${transactionID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, [], {}).at(0); + action = SearchUIUtils.getActions(searchResults.data, {}, `transactions_${transactionID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, {}, []).at(0); expect(action).toStrictEqual(CONST.SEARCH.ACTION_TYPES.SUBMIT); }); @@ -1682,10 +1682,10 @@ describe('SearchUIUtils', () => { managerID: adminAccountID, }, }; - expect(SearchUIUtils.getActions(localSearchResults, allViolations, `report_${reportID2}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, [], {}).at(0)).toStrictEqual( + expect(SearchUIUtils.getActions(localSearchResults, allViolations, `report_${reportID2}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, {}, []).at(0)).toStrictEqual( CONST.SEARCH.ACTION_TYPES.VIEW, ); - expect(SearchUIUtils.getActions(localSearchResults, allViolations, `transactions_${transactionID2}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, [], {}).at(0)).toStrictEqual( + expect(SearchUIUtils.getActions(localSearchResults, allViolations, `transactions_${transactionID2}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, {}, []).at(0)).toStrictEqual( CONST.SEARCH.ACTION_TYPES.VIEW, ); }); @@ -1705,7 +1705,7 @@ describe('SearchUIUtils', () => { }, }; - const action = SearchUIUtils.getActions(localSearchResults, {}, paidReportID, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, [], {}).at(0); + const action = SearchUIUtils.getActions(localSearchResults, {}, paidReportID, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, {}, []).at(0); expect(action).toStrictEqual(CONST.SEARCH.ACTION_TYPES.PAID); }); @@ -1729,7 +1729,7 @@ describe('SearchUIUtils', () => { }, }; - const action = SearchUIUtils.getActions(localSearchResults, {}, paidReportID, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, [], {}).at(0); + const action = SearchUIUtils.getActions(localSearchResults, {}, paidReportID, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, {}, []).at(0); expect(action).toStrictEqual(CONST.SEARCH.ACTION_TYPES.PAID); }); @@ -1745,7 +1745,7 @@ describe('SearchUIUtils', () => { }, }; - const action = SearchUIUtils.getActions(localSearchResults, {}, `report_${closedReportID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, [], {}).at(0); + const action = SearchUIUtils.getActions(localSearchResults, {}, `report_${closedReportID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, {}, []).at(0); expect(action).toStrictEqual(CONST.SEARCH.ACTION_TYPES.PAY); }); @@ -1763,13 +1763,13 @@ describe('SearchUIUtils', () => { }, }; - const action = SearchUIUtils.getActions(localSearchResults, {}, `report_${closedReportID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, [], {}).at(0); + const action = SearchUIUtils.getActions(localSearchResults, {}, `report_${closedReportID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, {}, []).at(0); expect(action).toStrictEqual(CONST.SEARCH.ACTION_TYPES.DONE); }); test('Should return `View` action for non-money request reports', () => { - const action = SearchUIUtils.getActions(searchResults.data, {}, `report_${reportID4}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, [], {}).at(0); + const action = SearchUIUtils.getActions(searchResults.data, {}, `report_${reportID4}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, {}, []).at(0); expect(action).toStrictEqual(CONST.SEARCH.ACTION_TYPES.VIEW); }); @@ -1783,7 +1783,7 @@ describe('SearchUIUtils', () => { reportID: 'non_existent_report', }, }; - const action = SearchUIUtils.getActions(localSearchResults, {}, `transactions_${orphanedTransactionID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, [], {}).at(0); + const action = SearchUIUtils.getActions(localSearchResults, {}, `transactions_${orphanedTransactionID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, {}, []).at(0); expect(action).toStrictEqual(CONST.SEARCH.ACTION_TYPES.VIEW); }); test('Should return `View` action for a transaction in a multi-transaction report', () => { @@ -1801,14 +1801,14 @@ describe('SearchUIUtils', () => { reportID: multiTransactionReportID, }, }; - const action = SearchUIUtils.getActions(localSearchResults, {}, `transactions_${multiTransactionID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, [], {}).at(0); + const action = SearchUIUtils.getActions(localSearchResults, {}, `transactions_${multiTransactionID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, {}, []).at(0); expect(action).toStrictEqual(CONST.SEARCH.ACTION_TYPES.VIEW); }); test('Should return `Pay` action for an IOU report ready to be paid', async () => { Onyx.merge(ONYXKEYS.SESSION, {accountID: adminAccountID}); await waitForBatchedUpdates(); const iouReportKey = `report_${reportID3}`; - const action = SearchUIUtils.getActions(searchResults.data, {}, iouReportKey, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, [], {}).at(0); + const action = SearchUIUtils.getActions(searchResults.data, {}, iouReportKey, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, {}, []).at(0); expect(action).toEqual(CONST.SEARCH.ACTION_TYPES.PAY); }); @@ -1843,7 +1843,7 @@ describe('SearchUIUtils', () => { }, }; - const actions = SearchUIUtils.getActions(localSearchResults, {}, `report_${exportReportID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, adminEmail, {}, [], {}); + const actions = SearchUIUtils.getActions(localSearchResults, {}, `report_${exportReportID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, adminEmail, {}, {}, []); expect(actions).toContain(CONST.SEARCH.ACTION_TYPES.EXPORT_TO_ACCOUNTING); }); @@ -1881,7 +1881,7 @@ describe('SearchUIUtils', () => { }, ] as OnyxTypes.ReportAction[]; - const action = SearchUIUtils.getActions(localSearchResults, {}, `transactions_${dewTransactionID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, dewReportActions, {}).at(0); + const action = SearchUIUtils.getActions(localSearchResults, {}, `transactions_${dewTransactionID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, {}, dewReportActions).at(0); expect(action).toStrictEqual(CONST.SEARCH.ACTION_TYPES.SUBMIT); }); @@ -1918,7 +1918,7 @@ describe('SearchUIUtils', () => { }, ] as OnyxTypes.ReportAction[]; - const action = SearchUIUtils.getActions(localSearchResults, {}, `transactions_${dewTransactionID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, dewReportActions, {}).at(0); + const action = SearchUIUtils.getActions(localSearchResults, {}, `transactions_${dewTransactionID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, {}, dewReportActions).at(0); expect(action).not.toStrictEqual(CONST.SEARCH.ACTION_TYPES.VIEW); }); @@ -1957,7 +1957,7 @@ describe('SearchUIUtils', () => { }, ] as OnyxTypes.ReportAction[]; - const action = SearchUIUtils.getActions(localSearchResults, {}, `transactions_${nonDewTransactionID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, nonDewReportActions, {}).at(0); + const action = SearchUIUtils.getActions(localSearchResults, {}, `transactions_${nonDewTransactionID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, {}, nonDewReportActions).at(0); expect(action).not.toStrictEqual(CONST.SEARCH.ACTION_TYPES.VIEW); }); }); @@ -2761,10 +2761,10 @@ describe('SearchUIUtils', () => { Onyx.merge(ONYXKEYS.SESSION, {accountID: overlimitApproverAccountID}); searchResults.data[`policy_${policyID}`].role = CONST.POLICY.ROLE.USER; return waitForBatchedUpdates().then(() => { - let action = SearchUIUtils.getActions(searchResults.data, allViolations, `report_${reportID2}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, [], {}).at(0); + let action = SearchUIUtils.getActions(searchResults.data, allViolations, `report_${reportID2}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, {}, []).at(0); expect(action).toEqual(CONST.SEARCH.ACTION_TYPES.VIEW); - action = SearchUIUtils.getActions(searchResults.data, allViolations, `transactions_${transactionID2}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, [], {}).at(0); + action = SearchUIUtils.getActions(searchResults.data, allViolations, `transactions_${transactionID2}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, {}, []).at(0); expect(action).toEqual(CONST.SEARCH.ACTION_TYPES.VIEW); }); }); @@ -2877,7 +2877,7 @@ describe('SearchUIUtils', () => { }, }; return waitForBatchedUpdates().then(() => { - const action = SearchUIUtils.getActions(result.data, allViolations, 'report_6523565988285061', CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, [], {}).at(0); + const action = SearchUIUtils.getActions(result.data, allViolations, 'report_6523565988285061', CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', {}, {}, []).at(0); expect(action).toEqual(CONST.SEARCH.ACTION_TYPES.APPROVE); }); }); From c9b9926e66666cfdf7d1d4d0f32bc10dacfb87c8 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Wed, 21 Jan 2026 16:37:42 +0100 Subject: [PATCH 10/35] making reportMetadata required in isApproveAction --- src/libs/ReportSecondaryActionUtils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libs/ReportSecondaryActionUtils.ts b/src/libs/ReportSecondaryActionUtils.ts index bd82043002bb..f43e25b76fda 100644 --- a/src/libs/ReportSecondaryActionUtils.ts +++ b/src/libs/ReportSecondaryActionUtils.ts @@ -262,8 +262,8 @@ function isApproveAction( report: Report, reportTransactions: Transaction[], violations: OnyxCollection, + reportMetadata: OnyxEntry, policy?: Policy, - reportMetadata?: OnyxEntry, ): boolean { const isAnyReceiptBeingScanned = reportTransactions?.some((transaction) => isReceiptBeingScanned(transaction)); @@ -867,7 +867,7 @@ function getSecondaryReportActions({ options.push(CONST.REPORT.SECONDARY_ACTIONS.SUBMIT); } - if (isApproveAction(currentUserLogin, currentUserAccountID, report, reportTransactions, violations, policy, reportMetadata)) { + if (isApproveAction(currentUserLogin, currentUserAccountID, report, reportTransactions, violations, reportMetadata, policy)) { options.push(CONST.REPORT.SECONDARY_ACTIONS.APPROVE); } From e1ccc56b64b334dc9ca5ac023697dbd721f27569 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Wed, 21 Jan 2026 16:52:58 +0100 Subject: [PATCH 11/35] renaming buildOptimisticNextStepForDynamicExternalWorkflowError --- src/components/MoneyReportHeader.tsx | 4 ++-- src/libs/NextStepUtils.ts | 4 ++-- tests/unit/NextStepUtilsTest.ts | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 5f610ecf2945..21cc66ea720f 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -50,7 +50,7 @@ import type {ReportsSplitNavigatorParamList, RightModalNavigatorParamList} from import { buildOptimisticNextStepForDEWOffline, buildOptimisticNextStepForDynamicExternalWorkflowApproveError, - buildOptimisticNextStepForDynamicExternalWorkflowError, + buildOptimisticNextStepForDynamicExternalWorkflowSubmitError, buildOptimisticNextStepForPreventSelfApprovalsEnabled, buildOptimisticNextStepForStrictPolicyRuleViolations, } from '@libs/NextStepUtils'; @@ -497,7 +497,7 @@ function MoneyReportHeader({ if (moneyRequestReport?.statusNum === CONST.REPORT.STATUS_NUM.OPEN) { if (errors?.dewSubmitFailed) { - optimisticNextStep = buildOptimisticNextStepForDynamicExternalWorkflowError(theme.danger); + optimisticNextStep = buildOptimisticNextStepForDynamicExternalWorkflowSubmitError(theme.danger); } else if (isOffline && hasPendingDEWSubmit(reportMetadata, isDEWPolicy)) { optimisticNextStep = buildOptimisticNextStepForDEWOffline(); } diff --git a/src/libs/NextStepUtils.ts b/src/libs/NextStepUtils.ts index ae50cf15e147..d216798d23cb 100644 --- a/src/libs/NextStepUtils.ts +++ b/src/libs/NextStepUtils.ts @@ -332,7 +332,7 @@ function buildOptimisticNextStepForStrictPolicyRuleViolations() { return optimisticNextStep; } -function buildOptimisticNextStepForDynamicExternalWorkflowError(iconFill?: string) { +function buildOptimisticNextStepForDynamicExternalWorkflowSubmitError(iconFill?: string) { const optimisticNextStep: ReportNextStepDeprecated = { type: 'alert', icon: CONST.NEXT_STEP.ICONS.DOT_INDICATOR, @@ -749,7 +749,7 @@ export { parseMessage, buildOptimisticNextStepForPreventSelfApprovalsEnabled, buildOptimisticNextStepForStrictPolicyRuleViolations, - buildOptimisticNextStepForDynamicExternalWorkflowError, + buildOptimisticNextStepForDynamicExternalWorkflowSubmitError, buildOptimisticNextStepForDynamicExternalWorkflowApproveError, buildOptimisticNextStepForDEWOffline, // eslint-disable-next-line @typescript-eslint/no-deprecated diff --git a/tests/unit/NextStepUtilsTest.ts b/tests/unit/NextStepUtilsTest.ts index 1a7f19c0d006..3117e701eff8 100644 --- a/tests/unit/NextStepUtilsTest.ts +++ b/tests/unit/NextStepUtilsTest.ts @@ -1,6 +1,6 @@ import Onyx from 'react-native-onyx'; // eslint-disable-next-line @typescript-eslint/no-deprecated -import {buildNextStepNew, buildOptimisticNextStepForDynamicExternalWorkflowError, buildOptimisticNextStepForStrictPolicyRuleViolations} from '@libs/NextStepUtils'; +import {buildNextStepNew, buildOptimisticNextStepForDynamicExternalWorkflowSubmitError, buildOptimisticNextStepForStrictPolicyRuleViolations} from '@libs/NextStepUtils'; import {buildOptimisticEmptyReport, buildOptimisticExpenseReport} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -1047,12 +1047,12 @@ describe('libs/NextStepUtils', () => { }); }); - describe('buildOptimisticNextStepForDynamicExternalWorkflowError', () => { + describe('buildOptimisticNextStepForDynamicExternalWorkflowSubmitError', () => { test('should return alert next step with error message when DEW submit fails', () => { // Given a scenario where Dynamic External Workflow submission has failed - // When buildOptimisticNextStepForDynamicExternalWorkflowError is called - const result = buildOptimisticNextStepForDynamicExternalWorkflowError(); + // When buildOptimisticNextStepForDynamicExternalWorkflowSubmitError is called + const result = buildOptimisticNextStepForDynamicExternalWorkflowSubmitError(); // Then it should return an alert-type next step with the appropriate error message and dot indicator icon expect(result).toEqual({ From 449c599e5fa69996126e43793f0f5b068634c50b Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Wed, 21 Jan 2026 19:15:29 +0100 Subject: [PATCH 12/35] fixing ts --- tests/unit/ReportSecondaryActionUtilsTest.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/unit/ReportSecondaryActionUtilsTest.ts b/tests/unit/ReportSecondaryActionUtilsTest.ts index 2cecc2694b93..f732c8669801 100644 --- a/tests/unit/ReportSecondaryActionUtilsTest.ts +++ b/tests/unit/ReportSecondaryActionUtilsTest.ts @@ -546,6 +546,7 @@ describe('getSecondaryAction', () => { violations, bankAccountList: {}, policy, + allBetas: [CONST.BETAS.ALL], }); // Then APPROVE should be included because DEW approval is not in progress @@ -590,6 +591,7 @@ describe('getSecondaryAction', () => { violations, bankAccountList: {}, policy, + allBetas: [CONST.BETAS.ALL], reportMetadata: {pendingExpenseAction: CONST.EXPENSE_PENDING_ACTION.APPROVE}, }); From 66561c45fcb1cc03fe11ff1dc97fd41f8e6dd2da Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Thu, 22 Jan 2026 07:48:51 +0100 Subject: [PATCH 13/35] hide unapprove button for dew --- src/libs/ReportSecondaryActionUtils.ts | 5 ++ tests/unit/ReportSecondaryActionUtilsTest.ts | 67 ++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/src/libs/ReportSecondaryActionUtils.ts b/src/libs/ReportSecondaryActionUtils.ts index c3edb0d42e2c..4f68de417da5 100644 --- a/src/libs/ReportSecondaryActionUtils.ts +++ b/src/libs/ReportSecondaryActionUtils.ts @@ -338,6 +338,11 @@ function isUnapproveAction(currentUserLogin: string, currentUserAccountID: numbe return false; } + const isDEWPolicy = hasDynamicExternalWorkflow(policy); + if (isDEWPolicy && !isAdmin) { + return false; + } + if (report.statusNum === CONST.REPORT.STATUS_NUM.APPROVED) { return isManager || isAdmin; } diff --git a/tests/unit/ReportSecondaryActionUtilsTest.ts b/tests/unit/ReportSecondaryActionUtilsTest.ts index f732c8669801..35b87f4c4356 100644 --- a/tests/unit/ReportSecondaryActionUtilsTest.ts +++ b/tests/unit/ReportSecondaryActionUtilsTest.ts @@ -991,6 +991,73 @@ describe('getSecondaryAction', () => { expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.UNAPPROVE)).toBe(false); }); + it('does not include UNAPPROVE option for non-admin on DEW policy', () => { + // Given an approved expense report on a DEW policy where the current user is the manager but not an admin + const report = { + reportID: REPORT_ID, + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: EMPLOYEE_ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.APPROVED, + managerID: EMPLOYEE_ACCOUNT_ID, + } as unknown as Report; + const policy = { + approver: EMPLOYEE_EMAIL, + approvalMode: CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL, + } as unknown as Policy; + + // When getting secondary report actions + const result = getSecondaryReportActions({ + currentUserLogin: EMPLOYEE_EMAIL, + currentUserAccountID: EMPLOYEE_ACCOUNT_ID, + report, + chatReport, + reportTransactions: [], + originalTransaction: {} as Transaction, + violations: {}, + bankAccountList: {}, + policy, + allBetas: [CONST.BETAS.ALL], + }); + + // Then UNAPPROVE should not be included because DEW policies restrict unapprove to admins only + expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.UNAPPROVE)).toBe(false); + }); + + it('includes UNAPPROVE option for admin on DEW policy', () => { + // Given an approved expense report on a DEW policy where the current user is an admin + const report = { + reportID: REPORT_ID, + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: EMPLOYEE_ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.APPROVED, + managerID: MANAGER_ACCOUNT_ID, + } as unknown as Report; + const policy = { + approver: APPROVER_EMAIL, + role: CONST.POLICY.ROLE.ADMIN, + approvalMode: CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL, + } as unknown as Policy; + + // When getting secondary report actions + const result = getSecondaryReportActions({ + currentUserLogin: EMPLOYEE_EMAIL, + currentUserAccountID: EMPLOYEE_ACCOUNT_ID, + report, + chatReport, + reportTransactions: [], + originalTransaction: {} as Transaction, + violations: {}, + bankAccountList: {}, + policy, + allBetas: [CONST.BETAS.ALL], + }); + + // Then UNAPPROVE should be included because admins can unapprove on DEW policies + expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.UNAPPROVE)).toBe(true); + }); + it('includes CANCEL_PAYMENT option for report paid elsewhere', () => { const report = { reportID: REPORT_ID, From 7c61c163fd842bd1b9d0264f4d1cfa00b58199d8 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Thu, 22 Jan 2026 08:19:56 +0100 Subject: [PATCH 14/35] fix nextstep when report retracted and submitted again --- src/libs/ReportActionsUtils.ts | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 897575da305d..bc2e72b5ed00 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -322,21 +322,27 @@ function isDynamicExternalWorkflowApproveFailedAction(reportAction: OnyxInputOrE return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.DEW_APPROVE_FAILED); } +/** + * Actions that clear a DEW_APPROVE_FAILED error (approval succeeded or report was retracted/reopened). + */ +function isActionThatSupersedesDEWApproveFailure(action: ReportAction): boolean { + return isApprovedAction(action) || isForwardedAction(action) || isRetractedAction(action) || isReopenedAction(action); +} + function getMostRecentActiveDEWApproveFailedAction(reportActions: OnyxEntry | ReportAction[]): ReportAction | undefined { const actionsArray = Array.isArray(reportActions) ? reportActions : Object.values(reportActions ?? {}); - // Find the most recent DEW_APPROVE_FAILED and APPROVED/FORWARDED actions let mostRecentDewApproveFailedAction: ReportAction | undefined; - let mostRecentApprovalAction: ReportAction | undefined; + let mostRecentSupersedingAction: ReportAction | undefined; for (const action of actionsArray) { if (isDynamicExternalWorkflowApproveFailedAction(action)) { if (!mostRecentDewApproveFailedAction || (action.created && mostRecentDewApproveFailedAction.created && action.created > mostRecentDewApproveFailedAction.created)) { mostRecentDewApproveFailedAction = action; } - } else if (isApprovedAction(action) || isForwardedAction(action)) { - if (!mostRecentApprovalAction || (action.created && mostRecentApprovalAction.created && action.created > mostRecentApprovalAction.created)) { - mostRecentApprovalAction = action; + } else if (isActionThatSupersedesDEWApproveFailure(action)) { + if (!mostRecentSupersedingAction || (action.created && mostRecentSupersedingAction.created && action.created > mostRecentSupersedingAction.created)) { + mostRecentSupersedingAction = action; } } } @@ -345,8 +351,8 @@ function getMostRecentActiveDEWApproveFailedAction(reportActions: OnyxEntry mostRecentApprovalAction.created) { + // Return the DEW action if there's no superseding action, or if DEW_APPROVE_FAILED is more recent + if (!mostRecentSupersedingAction || mostRecentDewApproveFailedAction.created > mostRecentSupersedingAction.created) { return mostRecentDewApproveFailedAction; } From 427f9979d54f3e8401b4387bc778b7dac0159d67 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Thu, 22 Jan 2026 22:23:33 +0100 Subject: [PATCH 15/35] code refactoring --- src/CONST/index.ts | 1 + src/libs/ReportUtils.ts | 21 +++--- src/libs/TransactionPreviewUtils.ts | 20 +----- src/libs/actions/IOU/index.ts | 37 +++++----- src/libs/actions/Search.ts | 10 +-- src/pages/Search/SearchPage.tsx | 6 +- tests/unit/IOUUtilsTest.ts | 107 +++++++++++++++++++++++++++- 7 files changed, 145 insertions(+), 57 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 8633945fe3a8..2a79890b3321 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -7642,6 +7642,7 @@ const CONST = { HAS_CHILD_REPORT_AWAITING_ACTION: 'hasChildReportAwaitingAction', HAS_MISSING_INVOICE_BANK_ACCOUNT: 'hasMissingInvoiceBankAccount', HAS_UNRESOLVED_CARD_FRAUD_ALERT: 'hasUnresolvedCardFraudAlert', + HAS_DEW_APPROVE_FAILED: 'hasDEWApproveFailed', }, CARD_FRAUD_ALERT_RESOLUTION: { diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 2de58ebf814b..dde77d29697d 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -4218,6 +4218,18 @@ function getReasonAndReportActionThatRequiresAttention( }; } + // Check for DEW approve failures on SUBMITTED status reports (GBR) + if (optionOrReport.statusNum === CONST.REPORT.STATUS_NUM.SUBMITTED) { + const reportActionsArray = Object.values(reportActions ?? {}); + const mostRecentActiveDEWApproveAction = getMostRecentActiveDEWApproveFailedAction(reportActionsArray); + if (mostRecentActiveDEWApproveAction) { + return { + reason: CONST.REQUIRES_ATTENTION_REASONS.HAS_DEW_APPROVE_FAILED, + reportAction: mostRecentActiveDEWApproveAction, + }; + } + } + const optionReportMetadata = allReportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${optionOrReport.reportID}`]; const iouReportActionToApproveOrPay = getIOUReportActionToApproveOrPay(optionOrReport, undefined, optionReportMetadata); const iouReportID = getIOUReportIDFromReportActionPreview(iouReportActionToApproveOrPay); @@ -9365,15 +9377,6 @@ function getAllReportActionsErrorsAndReportActionThatRequiresAttention( } } - // Check for DEW approve failures on SUBMITTED status reports (GBR) - if (!isReportArchived && report?.statusNum === CONST.REPORT.STATUS_NUM.SUBMITTED) { - const mostRecentActiveDEWApproveAction = getMostRecentActiveDEWApproveFailedAction(reportActionsArray); - if (mostRecentActiveDEWApproveAction) { - reportActionErrors.dewApproveFailed = getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericCreateFailureMessage'); - reportAction = mostRecentActiveDEWApproveAction; - } - } - return { errors: reportActionErrors, reportAction, diff --git a/src/libs/TransactionPreviewUtils.ts b/src/libs/TransactionPreviewUtils.ts index b890f1875251..295b6ef0c238 100644 --- a/src/libs/TransactionPreviewUtils.ts +++ b/src/libs/TransactionPreviewUtils.ts @@ -10,15 +10,7 @@ import {isCategoryMissing} from './CategoryUtils'; import {convertToDisplayString} from './CurrencyUtils'; import DateUtils from './DateUtils'; import {getPolicy, hasDynamicExternalWorkflow} from './PolicyUtils'; -import { - getMostRecentActiveDEWApproveFailedAction, - getMostRecentActiveDEWSubmitFailedAction, - getOriginalMessage, - isDynamicExternalWorkflowApproveFailedAction, - isDynamicExternalWorkflowSubmitFailedAction, - isMessageDeleted, - isMoneyRequestAction, -} from './ReportActionsUtils'; +import {getMostRecentActiveDEWSubmitFailedAction, getOriginalMessage, isDynamicExternalWorkflowSubmitFailedAction, isMessageDeleted, isMoneyRequestAction} from './ReportActionsUtils'; import { hasActionWithErrorsForTransaction, hasReceiptError, @@ -282,13 +274,6 @@ function getTransactionPreviewTextAndTranslationPaths({ const dewErrorMessage = originalMessage?.message; RBRMessage = dewErrorMessage ? {text: dewErrorMessage} : {translationPath: 'iou.error.other'}; } - - const dewApproveFailedAction = getMostRecentActiveDEWApproveFailedAction(reportActions); - if (dewApproveFailedAction && isDynamicExternalWorkflowApproveFailedAction(dewApproveFailedAction)) { - const originalMessage = getOriginalMessage(dewApproveFailedAction); - const dewErrorMessage = originalMessage?.message; - RBRMessage = dewErrorMessage ? {text: dewErrorMessage} : {translationPath: 'iou.error.other'}; - } } let previewHeaderText: TranslationPathOrText[] = [showCashOrCard]; @@ -432,8 +417,7 @@ function createTransactionPreviewConditionals({ const hasErrorOrOnHold = hasFieldErrors || (!isFullySettled && !isFullyApproved && isTransactionOnHold); const hasReportViolationsOrActionErrors = (isReportOwner(iouReport) && hasReportViolations(iouReport?.reportID)) || hasActionWithErrorsForTransaction(iouReport?.reportID, transaction); const isDEWSubmitFailed = hasDynamicExternalWorkflow(policy) && !!getMostRecentActiveDEWSubmitFailedAction(reportActions); - const isDEWApproveFailed = hasDynamicExternalWorkflow(policy) && !!getMostRecentActiveDEWApproveFailedAction(reportActions); - const shouldShowRBR = hasAnyViolations || hasErrorOrOnHold || hasReportViolationsOrActionErrors || hasReceiptError(transaction) || isDEWSubmitFailed || isDEWApproveFailed; + const shouldShowRBR = hasAnyViolations || hasErrorOrOnHold || hasReportViolationsOrActionErrors || hasReceiptError(transaction) || isDEWSubmitFailed; // When there are no settled transactions in duplicates, show the "Keep this one" button const shouldShowKeepButton = areThereDuplicates; diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index 4c5d16992725..ce5d494fb294 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -9684,37 +9684,36 @@ function approveMoneyRequest( } const failureData: OnyxUpdate[] = [ - ...(isDEWPolicy - ? [] - : [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}` as const, - value: { - statusNum: expenseReport.statusNum, - stateNum: expenseReport.stateNum, - nextStep: expenseReport.nextStep ?? null, - pendingFields: { - partial: null, - nextStep: null, - }, - }, - }, - ]), { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.chatReportID}` as const, + key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.chatReportID}`, value: { hasOutstandingChildRequest: chatReport?.hasOutstandingChildRequest, }, }, { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport.reportID}` as const, + key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport.reportID}`, value: expenseReportCurrentNextStepDeprecated ?? null, }, ]; + if (!isDEWPolicy) { + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, + value: { + statusNum: expenseReport.statusNum, + stateNum: expenseReport.stateNum, + nextStep: expenseReport.nextStep ?? null, + pendingFields: { + partial: null, + nextStep: null, + }, + }, + }); + } + if (shouldAddOptimisticApproveAction) { if (isDEWPolicy) { failureData.push({ diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index eb57d5e3006a..36f80555bb4d 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -130,7 +130,7 @@ function handleActionButtonPress({ onDEWModalOpen?.(); return; } - approveMoneyRequestOnSearch(hash, item.reportID ? [item.reportID] : [], hasDynamicExternalWorkflow(snapshotPolicy), currentSearchKey); + approveMoneyRequestOnSearch(hash, item.reportID ? [item.reportID] : [], currentSearchKey); return; case CONST.SEARCH.ACTION_TYPES.SUBMIT: { if (hasDynamicExternalWorkflow(snapshotPolicy) && !isDEWBetaEnabled) { @@ -533,7 +533,7 @@ function submitMoneyRequestOnSearch(hash: number, reportList: Report[], policy: API.write(WRITE_COMMANDS.SUBMIT_REPORT, parameters, {optimisticData, successData, failureData}); } -function approveMoneyRequestOnSearch(hash: number, reportIDList: string[], isDEWPolicy?: boolean, currentSearchKey?: SearchKey) { +function approveMoneyRequestOnSearch(hash: number, reportIDList: string[], currentSearchKey?: SearchKey) { const optimisticData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE_COLLECTION, @@ -543,7 +543,7 @@ function approveMoneyRequestOnSearch(hash: number, reportIDList: string[], isDEW `${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`, { isActionLoading: true, - ...(isDEWPolicy ? {pendingExpenseAction: CONST.EXPENSE_PENDING_ACTION.APPROVE} : {}), + pendingExpenseAction: CONST.EXPENSE_PENDING_ACTION.APPROVE, }, ]), ), @@ -559,7 +559,7 @@ function approveMoneyRequestOnSearch(hash: number, reportIDList: string[], isDEW `${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`, { isActionLoading: false, - ...(isDEWPolicy ? {pendingExpenseAction: null} : {}), + pendingExpenseAction: null, }, ]), ), @@ -587,7 +587,7 @@ function approveMoneyRequestOnSearch(hash: number, reportIDList: string[], isDEW `${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`, { isActionLoading: false, - ...(isDEWPolicy ? {pendingExpenseAction: null} : {}), + pendingExpenseAction: null, }, ]), ), diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 929e7ec44192..61df2302045e 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -383,13 +383,12 @@ function SearchPage({route}: SearchPageProps) { const selectedPolicyIDList = selectedReports.length ? selectedReports.map((report) => report.policyID) : Object.values(selectedTransactions).map((transaction) => transaction.policyID); - const dewPolicyID = selectedPolicyIDList.find((policyID) => { + const hasDEWPolicy = selectedPolicyIDList.some((policyID) => { const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; return hasDynamicExternalWorkflow(policy); }); - const dewPolicy = dewPolicyID ? policies?.[`${ONYXKEYS.COLLECTION.POLICY}${dewPolicyID}`] : undefined; - if (dewPolicy && !isDEWBetaEnabled) { + if (hasDEWPolicy && !isDEWBetaEnabled) { const result = await showConfirmModal({ title: translate('customApprovalWorkflow.title'), prompt: translate('customApprovalWorkflow.description'), @@ -409,7 +408,6 @@ function SearchPage({route}: SearchPageProps) { approveMoneyRequestOnSearch( hash, reportIDList.filter((reportID) => reportID !== undefined), - !!dewPolicy, ); // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.runAfterInteractions(() => { diff --git a/tests/unit/IOUUtilsTest.ts b/tests/unit/IOUUtilsTest.ts index ae9dd3586967..b10e380a7735 100644 --- a/tests/unit/IOUUtilsTest.ts +++ b/tests/unit/IOUUtilsTest.ts @@ -4,7 +4,7 @@ import type {OnyxCollection} from 'react-native-onyx'; import useReportIsArchived from '@hooks/useReportIsArchived'; import DateUtils from '@libs/DateUtils'; import Navigation from '@libs/Navigation/Navigation'; -import {canSubmitReport} from '@userActions/IOU'; +import {canApproveIOU, canSubmitReport} from '@userActions/IOU'; import CONST from '@src/CONST'; import * as IOUUtils from '@src/libs/IOUUtils'; import * as ReportUtils from '@src/libs/ReportUtils'; @@ -12,7 +12,7 @@ import * as TransactionUtils from '@src/libs/TransactionUtils'; import {hasAnyTransactionWithoutRTERViolation} from '@src/libs/TransactionUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {Policy, Report, Transaction, TransactionViolations} from '@src/types/onyx'; +import type {Policy, Report, ReportMetadata, Transaction, TransactionViolations} from '@src/types/onyx'; import type {TransactionCollectionDataSet} from '@src/types/onyx/Transaction'; import createRandomPolicy from '../utils/collections/policies'; import {createRandomReport} from '../utils/collections/reports'; @@ -686,3 +686,106 @@ describe('navigateToConfirmationPage', () => { expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(CONST.IOU.ACTION.CREATE, CONST.IOU.TYPE.TRACK, transactionID, reportID, undefined)); }); }); + +describe('canApproveIOU', () => { + const REPORT_ID = '1'; + const CURRENT_USER_EMAIL = 'test@email.com'; + + beforeEach(async () => { + await Onyx.init({ + keys: ONYXKEYS, + }); + await Onyx.merge(ONYXKEYS.SESSION, {accountID: currentUserAccountID, email: CURRENT_USER_EMAIL}); + }); + + afterEach(async () => { + await Onyx.clear(); + }); + + it('should return true for DEW policy report without pending approval', async () => { + // Given a submitted expense report on a DEW policy without any pending approval action + const report = { + reportID: REPORT_ID, + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: currentUserAccountID, + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + managerID: currentUserAccountID, + } as unknown as Report; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); + + const policy = { + type: CONST.POLICY.TYPE.TEAM, + approver: CURRENT_USER_EMAIL, + approvalMode: CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL, + } as unknown as Policy; + + const reportMetadata: ReportMetadata = {}; + + const transaction = { + reportID: `${REPORT_ID}`, + transactionID: '123', + amount: 10, + merchant: 'Merchant', + created: '2025-01-01', + } as unknown as Transaction; + + // When checking if approve action is available + // Then it should return true because DEW approval is not in progress + expect(canApproveIOU(report, policy, reportMetadata, [transaction])).toBe(true); + }); + + it('should return false for DEW policy report with pending approval', async () => { + // Given a submitted expense report on a DEW policy with a pending approval action + const report = { + reportID: REPORT_ID, + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: currentUserAccountID, + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + managerID: currentUserAccountID, + } as unknown as Report; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); + + const policy = { + type: CONST.POLICY.TYPE.TEAM, + approver: CURRENT_USER_EMAIL, + approvalMode: CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL, + } as unknown as Policy; + + const reportMetadata: ReportMetadata = { + pendingExpenseAction: CONST.EXPENSE_PENDING_ACTION.APPROVE, + }; + + const transaction = { + reportID: `${REPORT_ID}`, + transactionID: '123', + amount: 10, + merchant: 'Merchant', + created: '2025-01-01', + } as unknown as Transaction; + + // When checking if approve action is available while DEW approval is pending + // Then it should return false because DEW is already processing an approval + expect(canApproveIOU(report, policy, reportMetadata, [transaction])).toBe(false); + }); + + it('should return false for non-expense report', async () => { + // Given a non-expense report + const report = { + reportID: REPORT_ID, + type: CONST.REPORT.TYPE.CHAT, + ownerAccountID: currentUserAccountID, + } as unknown as Report; + + const policy = { + type: CONST.POLICY.TYPE.TEAM, + approver: CURRENT_USER_EMAIL, + } as unknown as Policy; + + const reportMetadata: ReportMetadata = {}; + + // Then canApproveIOU should return false + expect(canApproveIOU(report, policy, reportMetadata)).toBe(false); + }); +}); From 9b44e5785a746f72eec3be75cb31ae46b4555657 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Thu, 22 Jan 2026 22:29:52 +0100 Subject: [PATCH 16/35] minor refactor: removing comments --- src/libs/ReportUtils.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 97e9eb42100d..30ca1cc629da 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -4219,7 +4219,6 @@ function getReasonAndReportActionThatRequiresAttention( }; } - // Check for DEW approve failures on SUBMITTED status reports (GBR) if (optionOrReport.statusNum === CONST.REPORT.STATUS_NUM.SUBMITTED) { const reportActionsArray = Object.values(reportActions ?? {}); const mostRecentActiveDEWApproveAction = getMostRecentActiveDEWApproveFailedAction(reportActionsArray); From abbf420ffe26fb7b3ea7f2cab51ffc2200883bf7 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Thu, 22 Jan 2026 23:04:43 +0100 Subject: [PATCH 17/35] fixing ts --- src/languages/de.ts | 1 + src/languages/en.ts | 1 + 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/pl.ts | 1 + src/languages/pt-BR.ts | 1 + src/languages/zh-hans.ts | 1 + 10 files changed, 10 insertions(+) diff --git a/src/languages/de.ts b/src/languages/de.ts index 4ae515322b19..f59178d58ef5 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -7807,6 +7807,7 @@ Fordere Spesendetails wie Belege und Beschreibungen an, lege Limits und Standard hasChildReportAwaitingAction: 'Untergeordneter Bericht wartet auf Aktion', hasMissingInvoiceBankAccount: 'Fehlendes Rechnungs-Bankkonto', hasUnresolvedCardFraudAlert: 'Hat ungelöste Kreditkartenbetrugswarnung', + hasDEWApproveFailed: 'DEW-Genehmigung fehlgeschlagen', }, reasonRBR: { hasErrors: 'Enthält Fehler in den Berichtsdaten oder den Berichtsvorgangsdatens', diff --git a/src/languages/en.ts b/src/languages/en.ts index a09b657db240..8766975ff95a 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -7745,6 +7745,7 @@ const translations = { hasChildReportAwaitingAction: 'Has child report awaiting action', hasMissingInvoiceBankAccount: 'Has missing invoice bank account', hasUnresolvedCardFraudAlert: 'Has unresolved card fraud alert', + hasDEWApproveFailed: 'Has DEW approve failed', }, reasonRBR: { hasErrors: 'Has errors in report or report actions data', diff --git a/src/languages/es.ts b/src/languages/es.ts index 2554af1ed520..01faa75e13ea 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -7903,6 +7903,7 @@ ${amount} para ${merchant} - ${date}`, hasChildReportAwaitingAction: 'Informe secundario pendiente de acción', hasMissingInvoiceBankAccount: 'Falta la cuenta bancaria de la factura', hasUnresolvedCardFraudAlert: 'Tiene una alerta de fraude de tarjeta sin resolver', + hasDEWApproveFailed: 'La aprobación DEW ha fallado', }, reasonRBR: { hasErrors: 'Tiene errores en los datos o las acciones del informe', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 9e013a9f4925..860500fa0ce7 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -7817,6 +7817,7 @@ Exigez des informations de dépense comme les reçus et les descriptions, défin hasChildReportAwaitingAction: 'A un rapport enfant en attente d’action', hasMissingInvoiceBankAccount: 'N’a pas de compte bancaire de facture', hasUnresolvedCardFraudAlert: 'A une alerte de fraude de carte non résolue', + hasDEWApproveFailed: 'L’approbation DEW a échoué', }, reasonRBR: { hasErrors: 'Contient des erreurs dans les données du rapport ou des actions de rapport', diff --git a/src/languages/it.ts b/src/languages/it.ts index 242cc88aeb91..bafdaec3c84e 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -7797,6 +7797,7 @@ Richiedi dettagli di spesa come ricevute e descrizioni, imposta limiti e valori hasChildReportAwaitingAction: 'Il report figlio è in attesa di azione', hasMissingInvoiceBankAccount: 'Non ha un conto bancario per la fattura', hasUnresolvedCardFraudAlert: 'Ha un avviso di frode sulla carta non risolto', + hasDEWApproveFailed: 'Approvazione DEW fallita', }, reasonRBR: { hasErrors: 'Presenta errori nei dati del report o nelle azioni del report', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 1b63c3d3101b..86894d60dcf6 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -7735,6 +7735,7 @@ ${reportName} hasChildReportAwaitingAction: '対応待ちの子レポートがあります', hasMissingInvoiceBankAccount: '請求書の銀行口座が未設定です', hasUnresolvedCardFraudAlert: '未解決のカード不正利用アラートがあります', + hasDEWApproveFailed: 'DEW承認に失敗しました', }, reasonRBR: { hasErrors: 'レポートまたはレポートアクションのデータにエラーがあります', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 251c0d696313..e946516cdf10 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -7777,6 +7777,7 @@ Vraag verplichte uitgavedetails zoals bonnetjes en beschrijvingen, stel limieten hasChildReportAwaitingAction: 'Heeft kinderreportage in afwachting van actie', hasMissingInvoiceBankAccount: 'Heeft ontbrekende bankrekening voor factuur', hasUnresolvedCardFraudAlert: 'Heeft onopgeloste kaartfraudewaarschuwing', + hasDEWApproveFailed: 'DEW-goedkeuring mislukt', }, reasonRBR: { hasErrors: 'Bevat fouten in rapport- of rapportactiedata', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 9260a8726ca3..2d96baba2131 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -7765,6 +7765,7 @@ Wymagaj szczegółów wydatków, takich jak paragony i opisy, ustawiaj limity i hasChildReportAwaitingAction: 'Ma raport podrzędny oczekujący na działanie', hasMissingInvoiceBankAccount: 'Brakujący rachunek bankowy faktury', hasUnresolvedCardFraudAlert: 'Ma nierozwiązane powiadomienie o oszustwie związanym z kartą', + hasDEWApproveFailed: 'Zatwierdzenie DEW nie powiodło się', }, reasonRBR: { hasErrors: 'Zawiera błędy w danych raportu lub danych działań na raporcie', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index d08d4ecf3803..fc1c5ac6d244 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -7767,6 +7767,7 @@ Exija detalhes de despesas como recibos e descrições, defina limites e padrõe hasChildReportAwaitingAction: 'Tem relatório filho aguardando ação', hasMissingInvoiceBankAccount: 'Está com conta bancária de fatura ausente', hasUnresolvedCardFraudAlert: 'Tem alerta de fraude de cartão não resolvido', + hasDEWApproveFailed: 'Aprovação DEW falhou', }, reasonRBR: { hasErrors: 'Tem erros nos dados do relatório ou nas ações do relatório', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 1861f49efe85..300bea05a2bc 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -7591,6 +7591,7 @@ ${reportName} hasChildReportAwaitingAction: '有子报表等待处理', hasMissingInvoiceBankAccount: '缺少发票银行账户', hasUnresolvedCardFraudAlert: '有未解决的银行卡欺诈警报', + hasDEWApproveFailed: 'DEW审批失败', }, reasonRBR: { hasErrors: '报表或报表操作数据中存在错误', From e85a9b8cbc5290ead9bdace659c28ba88eb5e1f3 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Thu, 22 Jan 2026 23:56:18 +0100 Subject: [PATCH 18/35] fixing ts --- tests/unit/ReportSecondaryActionUtilsTest.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/unit/ReportSecondaryActionUtilsTest.ts b/tests/unit/ReportSecondaryActionUtilsTest.ts index b03d310bf73c..6e4b1e7a2d8c 100644 --- a/tests/unit/ReportSecondaryActionUtilsTest.ts +++ b/tests/unit/ReportSecondaryActionUtilsTest.ts @@ -534,7 +534,6 @@ describe('getSecondaryAction', () => { violations, bankAccountList: {}, policy, - allBetas: [CONST.BETAS.ALL], }); // Then APPROVE should be included because DEW approval is not in progress @@ -579,7 +578,6 @@ describe('getSecondaryAction', () => { violations, bankAccountList: {}, policy, - allBetas: [CONST.BETAS.ALL], reportMetadata: {pendingExpenseAction: CONST.EXPENSE_PENDING_ACTION.APPROVE}, }); @@ -993,7 +991,6 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, - allBetas: [CONST.BETAS.ALL], }); // Then UNAPPROVE should not be included because DEW policies restrict unapprove to admins only @@ -1027,7 +1024,6 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, - allBetas: [CONST.BETAS.ALL], }); // Then UNAPPROVE should be included because admins can unapprove on DEW policies From 660703614fb0821b518d02917acf90214a41fc03 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Fri, 23 Jan 2026 00:18:01 +0100 Subject: [PATCH 19/35] fix tests --- tests/unit/IOUUtilsTest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/IOUUtilsTest.ts b/tests/unit/IOUUtilsTest.ts index b10e380a7735..d7071c5283fe 100644 --- a/tests/unit/IOUUtilsTest.ts +++ b/tests/unit/IOUUtilsTest.ts @@ -692,7 +692,7 @@ describe('canApproveIOU', () => { const CURRENT_USER_EMAIL = 'test@email.com'; beforeEach(async () => { - await Onyx.init({ + Onyx.init({ keys: ONYXKEYS, }); await Onyx.merge(ONYXKEYS.SESSION, {accountID: currentUserAccountID, email: CURRENT_USER_EMAIL}); From 0dd9641aac4a0535b90ed7b6e93a3465f2aca571 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Fri, 23 Jan 2026 09:11:33 +0100 Subject: [PATCH 20/35] fix approval header --- src/components/MoneyReportHeader.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 62051a048ea3..ee5b3b1b67f8 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -70,6 +70,7 @@ import { getNextApproverAccountID, getNonHeldAndFullAmount, getPolicyExpenseChat, + getReasonAndReportActionThatRequiresAttention, getTransactionsWithReceipts, hasHeldExpenses as hasHeldExpensesReportUtils, hasOnlyHeldExpenses as hasOnlyHeldExpensesReportUtils, @@ -502,7 +503,9 @@ function MoneyReportHeader({ optimisticNextStep = buildOptimisticNextStepForDEWOffline(); } } else if (moneyRequestReport?.statusNum === CONST.REPORT.STATUS_NUM.SUBMITTED) { - if (errors?.dewApproveFailed) { + const gbrResult = getReasonAndReportActionThatRequiresAttention(moneyRequestReport, undefined, isArchivedReport); + const hasDEWApproveFailed = gbrResult?.reason === CONST.REQUIRES_ATTENTION_REASONS.HAS_DEW_APPROVE_FAILED; + if (hasDEWApproveFailed) { optimisticNextStep = buildOptimisticNextStepForDynamicExternalWorkflowApproveError(theme.danger); } else if (isOffline && hasPendingDEWApprove(reportMetadata, isDEWPolicy)) { optimisticNextStep = buildOptimisticNextStepForDEWOffline(); From b5004c841ff6d8dffb51c4a26a7fc1e6c54e51a2 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Fri, 23 Jan 2026 11:58:52 +0100 Subject: [PATCH 21/35] Hide approve button in report preview when DEW approval is pending --- .../MoneyRequestReportPreviewContent.tsx | 2 + src/libs/ReportPreviewActionUtils.ts | 16 ++- tests/actions/ReportPreviewActionUtilsTest.ts | 111 ++++++++++++++++++ 3 files changed, 125 insertions(+), 4 deletions(-) diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx index d36c1dad5985..d58ce7096089 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx @@ -546,6 +546,7 @@ function MoneyRequestReportPreviewContent({ isSubmittingAnimationRunning, isDEWSubmitPending, violationsData: transactionViolations, + reportMetadata: iouReportMetadata, }); }, [ bankAccountList, @@ -562,6 +563,7 @@ function MoneyRequestReportPreviewContent({ isSubmittingAnimationRunning, transactionViolations, isDEWSubmitPending, + iouReportMetadata, ]); const addExpenseDropdownOptions = useMemo( diff --git a/src/libs/ReportPreviewActionUtils.ts b/src/libs/ReportPreviewActionUtils.ts index a2d56162c8f1..4bd4cb10d945 100644 --- a/src/libs/ReportPreviewActionUtils.ts +++ b/src/libs/ReportPreviewActionUtils.ts @@ -1,8 +1,9 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import CONST from '@src/CONST'; -import type {BankAccountList, Policy, Report, Transaction, TransactionViolation} from '@src/types/onyx'; -import {arePaymentsEnabled, getSubmitToAccountID, getValidConnectedIntegration, hasIntegrationAutoSync, isPreferredExporter} from './PolicyUtils'; +import type {BankAccountList, Policy, Report, ReportMetadata, Transaction, TransactionViolation} from '@src/types/onyx'; +import {arePaymentsEnabled, getSubmitToAccountID, getValidConnectedIntegration, hasDynamicExternalWorkflow, hasIntegrationAutoSync, isPreferredExporter} from './PolicyUtils'; +import {hasPendingDEWApprove} from './ReportActionsUtils'; import {isAddExpenseAction} from './ReportPrimaryActionUtils'; import { getMoneyRequestSpendBreakdown, @@ -59,7 +60,7 @@ function canSubmit( return isExpense && (isSubmitter || isManager || isAdmin) && isOpen && !isAnyReceiptBeingScanned && !!transactions && transactions.length > 0; } -function canApprove(report: Report, currentUserAccountID: number, policy?: Policy, transactions?: Transaction[]) { +function canApprove(report: Report, currentUserAccountID: number, reportMetadata: OnyxEntry, policy?: Policy, transactions?: Transaction[]) { const isExpense = isExpenseReport(report); const isProcessing = isProcessingReport(report); const isApprovalEnabled = policy?.approvalMode && policy.approvalMode !== CONST.POLICY.APPROVAL_MODE.OPTIONAL; @@ -76,6 +77,11 @@ function canApprove(report: Report, currentUserAccountID: number, policy?: Polic return false; } + const isDEWPolicy = hasDynamicExternalWorkflow(policy); + if (hasPendingDEWApprove(reportMetadata, isDEWPolicy)) { + return false; + } + const isPreventSelfApprovalEnabled = policy?.preventSelfApproval; const isReportSubmitter = isCurrentUserSubmitter(report); @@ -187,6 +193,7 @@ function getReportPreviewAction({ isSubmittingAnimationRunning, isDEWSubmitPending, violationsData, + reportMetadata, }: { isReportArchived: boolean; currentUserAccountID: number; @@ -201,6 +208,7 @@ function getReportPreviewAction({ isSubmittingAnimationRunning?: boolean; isDEWSubmitPending?: boolean; violationsData?: OnyxCollection; + reportMetadata: OnyxEntry; }): ValueOf { if (!report) { return CONST.REPORT.REPORT_PREVIEW_ACTIONS.VIEW; @@ -226,7 +234,7 @@ function getReportPreviewAction({ if (canSubmit(report, isReportArchived, currentUserAccountID, currentUserLogin, violationsData, policy, transactions)) { return CONST.REPORT.REPORT_PREVIEW_ACTIONS.SUBMIT; } - if (canApprove(report, currentUserAccountID, policy, transactions)) { + if (canApprove(report, currentUserAccountID, reportMetadata, policy, transactions)) { return CONST.REPORT.REPORT_PREVIEW_ACTIONS.APPROVE; } if (canPay(report, isReportArchived, currentUserAccountID, currentUserLogin, bankAccountList, policy, invoiceReceiverPolicy)) { diff --git a/tests/actions/ReportPreviewActionUtilsTest.ts b/tests/actions/ReportPreviewActionUtilsTest.ts index d8567e12702c..4c1b2621ecf7 100644 --- a/tests/actions/ReportPreviewActionUtilsTest.ts +++ b/tests/actions/ReportPreviewActionUtilsTest.ts @@ -81,6 +81,7 @@ describe('getReportPreviewAction', () => { policy, transactions: [], bankAccountList: {}, + reportMetadata: undefined, }), ).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.ADD_EXPENSE); }); @@ -123,6 +124,7 @@ describe('getReportPreviewAction', () => { policy, transactions: [transaction], bankAccountList: {}, + reportMetadata: undefined, }), ).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.SUBMIT); }); @@ -166,6 +168,7 @@ describe('getReportPreviewAction', () => { policy, transactions: [transaction], bankAccountList: {}, + reportMetadata: undefined, }), ).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.SUBMIT); }); @@ -209,6 +212,7 @@ describe('getReportPreviewAction', () => { policy, transactions: [transaction], bankAccountList: {}, + reportMetadata: undefined, }), ).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.VIEW); }); @@ -267,6 +271,7 @@ describe('getReportPreviewAction', () => { isSubmittingAnimationRunning: undefined, isDEWSubmitPending: undefined, violationsData: violations, + reportMetadata: undefined, }), ).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.VIEW); }); @@ -308,6 +313,7 @@ describe('getReportPreviewAction', () => { policy, transactions: [transaction], bankAccountList: {}, + reportMetadata: undefined, }), ).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.APPROVE); }); @@ -346,6 +352,7 @@ describe('getReportPreviewAction', () => { policy, transactions: [transaction], bankAccountList: {}, + reportMetadata: undefined, }), ).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.VIEW); }); @@ -385,6 +392,7 @@ describe('getReportPreviewAction', () => { policy, transactions: [transaction], bankAccountList: {}, + reportMetadata: undefined, }), ).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.VIEW); }); @@ -426,6 +434,7 @@ describe('getReportPreviewAction', () => { policy, transactions: [transaction], bankAccountList: {}, + reportMetadata: undefined, }), ).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.APPROVE); }); @@ -461,6 +470,7 @@ describe('getReportPreviewAction', () => { policy, transactions: [transaction], bankAccountList: {}, + reportMetadata: undefined, }), ).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.PAY); }); @@ -497,6 +507,7 @@ describe('getReportPreviewAction', () => { policy, transactions: [transaction], bankAccountList: {}, + reportMetadata: undefined, }), ).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.VIEW); }); @@ -537,6 +548,7 @@ describe('getReportPreviewAction', () => { transactions: [transaction], invoiceReceiverPolicy, bankAccountList: {}, + reportMetadata: undefined, }), ).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.PAY); }); @@ -593,6 +605,7 @@ describe('getReportPreviewAction', () => { transactions: [transaction], bankAccountList: {}, invoiceReceiverPolicy, + reportMetadata: undefined, }), ).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.VIEW); }); @@ -637,6 +650,7 @@ describe('getReportPreviewAction', () => { transactions: [transaction], bankAccountList: {}, invoiceReceiverPolicy, + reportMetadata: undefined, }), ).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.PAY); }); @@ -676,6 +690,7 @@ describe('getReportPreviewAction', () => { transactions: [transaction], bankAccountList: {}, invoiceReceiverPolicy: undefined, + reportMetadata: undefined, }), ).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.VIEW); }); @@ -709,6 +724,7 @@ describe('getReportPreviewAction', () => { policy, transactions: [transaction], bankAccountList: {}, + reportMetadata: undefined, }), ).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.EXPORT_TO_ACCOUNTING); }); @@ -749,6 +765,7 @@ describe('getReportPreviewAction', () => { isApprovedAnimationRunning: false, isSubmittingAnimationRunning: false, isDEWSubmitPending: true, + reportMetadata: undefined, }); // Then it should return VIEW because DEW submission is pending offline @@ -794,6 +811,7 @@ describe('getReportPreviewAction', () => { isApprovedAnimationRunning: false, isSubmittingAnimationRunning: false, isDEWSubmitPending: false, + reportMetadata: undefined, }); // Then it should allow SUBMIT because failed submissions can be retried (not VIEW) @@ -839,10 +857,103 @@ describe('getReportPreviewAction', () => { isApprovedAnimationRunning: false, isSubmittingAnimationRunning: false, isDEWSubmitPending: false, + reportMetadata: undefined, }); // Then it should not return VIEW because DEW submit did not fail and regular logic applies expect(result).not.toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.VIEW); }); }); + + describe('DEW (Dynamic External Workflow) approve pending', () => { + it('should return VIEW action when DEW approve is pending and report is SUBMITTED', async () => { + // Given a submitted expense report with a DEW policy where approve is pending + const report: Report = { + ...createRandomReport(REPORT_ID, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + managerID: CURRENT_USER_ACCOUNT_ID, + isWaitingOnBankAccount: false, + }; + + const policy = createRandomPolicy(0); + policy.type = CONST.POLICY.TYPE.CORPORATE; + policy.approvalMode = CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL; + policy.preventSelfApproval = false; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); + const transaction = { + reportID: `${REPORT_ID}`, + amount: 100, + merchant: 'Test Merchant', + created: '2025-01-01', + } as unknown as Transaction; + + const {result: isReportArchived} = renderHook(() => useReportIsArchived(report?.parentReportID)); + await waitForBatchedUpdatesWithAct(); + + // When getReportPreviewAction is called with reportMetadata indicating DEW approve is pending + const result = getReportPreviewAction({ + isReportArchived: isReportArchived.current, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + currentUserLogin: CURRENT_USER_EMAIL, + report, + policy, + transactions: [transaction], + bankAccountList: {}, + reportMetadata: { + pendingExpenseAction: CONST.EXPENSE_PENDING_ACTION.APPROVE, + }, + }); + + // Then it should return VIEW because DEW approval is pending offline + expect(result).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.VIEW); + }); + + it('should return APPROVE action when DEW approve is not pending and report is SUBMITTED', async () => { + // Given a submitted expense report with a DEW policy where approve is not pending + const report: Report = { + ...createRandomReport(REPORT_ID, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + managerID: CURRENT_USER_ACCOUNT_ID, + isWaitingOnBankAccount: false, + }; + + const policy = createRandomPolicy(0); + policy.type = CONST.POLICY.TYPE.CORPORATE; + policy.approvalMode = CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL; + policy.preventSelfApproval = false; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); + const transaction = { + reportID: `${REPORT_ID}`, + amount: 100, + merchant: 'Test Merchant', + created: '2025-01-01', + } as unknown as Transaction; + + const {result: isReportArchived} = renderHook(() => useReportIsArchived(report?.parentReportID)); + await waitForBatchedUpdatesWithAct(); + + // When getReportPreviewAction is called with reportMetadata without pending approve action + const result = getReportPreviewAction({ + isReportArchived: isReportArchived.current, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + currentUserLogin: CURRENT_USER_EMAIL, + report, + policy, + transactions: [transaction], + bankAccountList: {}, + reportMetadata: undefined, + }); + + // Then it should return APPROVE because DEW approval is not pending + expect(result).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.APPROVE); + }); + }); }); From de51fc3155267347bab6077a77e4a46df9a5908b Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Fri, 23 Jan 2026 20:07:17 +0100 Subject: [PATCH 22/35] code optimization for the nextstep inside the MoneyReportHeader --- src/components/MoneyReportHeader.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index ee5b3b1b67f8..4b60e4d31bfa 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -488,15 +488,15 @@ function MoneyReportHeader({ // Check for DEW submit/approve failed or pending - show appropriate next step if (isDEWPolicy && (moneyRequestReport?.statusNum === CONST.REPORT.STATUS_NUM.OPEN || moneyRequestReport?.statusNum === CONST.REPORT.STATUS_NUM.SUBMITTED)) { - const reportActionsObject = reportActions.reduce((acc, action) => { - if (action.reportActionID) { - acc[action.reportActionID] = action; - } - return acc; - }, {}); - const {errors} = getAllReportActionsErrorsAndReportActionThatRequiresAttention(moneyRequestReport, reportActionsObject); - if (moneyRequestReport?.statusNum === CONST.REPORT.STATUS_NUM.OPEN) { + const reportActionsObject = reportActions.reduce((acc, action) => { + if (action.reportActionID) { + acc[action.reportActionID] = action; + } + return acc; + }, {}); + const {errors} = getAllReportActionsErrorsAndReportActionThatRequiresAttention(moneyRequestReport, reportActionsObject); + if (errors?.dewSubmitFailed) { optimisticNextStep = buildOptimisticNextStepForDynamicExternalWorkflowSubmitError(theme.danger); } else if (isOffline && hasPendingDEWSubmit(reportMetadata, isDEWPolicy)) { From 97f70243b185838ac59698e277eaf3eb0022ef54 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Fri, 23 Jan 2026 20:27:36 +0100 Subject: [PATCH 23/35] fix prettier --- src/libs/SearchUIUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 7a8bd42d4c51..0cc33c3de462 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -369,7 +369,7 @@ type GetSectionsParams = { isActionLoadingSet?: ReadonlySet; cardFeeds?: OnyxCollection; allTransactionViolations?: OnyxCollection; -allReportMetadata: OnyxCollection; + allReportMetadata: OnyxCollection; }; /** From b59668317fb19d27b412d2ac57c115d2d249797b Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Sat, 24 Jan 2026 20:01:35 +0100 Subject: [PATCH 24/35] fix auto approval error next-step --- src/components/MoneyReportHeader.tsx | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index bf551af862e9..d1de797eba19 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -57,7 +57,15 @@ import { import type {KYCFlowEvent, TriggerKYCFlow} from '@libs/PaymentUtils'; import {selectPaymentType} from '@libs/PaymentUtils'; import {getConnectedIntegration, getValidConnectedIntegration, hasDynamicExternalWorkflow} from '@libs/PolicyUtils'; -import {getIOUActionForReportID, getOriginalMessage, getReportAction, hasPendingDEWApprove, hasPendingDEWSubmit, isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import { + getIOUActionForReportID, + getMostRecentActiveDEWApproveFailedAction, + getOriginalMessage, + getReportAction, + hasPendingDEWApprove, + hasPendingDEWSubmit, + isMoneyRequestAction, +} from '@libs/ReportActionsUtils'; import {getAllExpensesToHoldIfApplicable, getReportPrimaryAction, isMarkAsResolvedAction} from '@libs/ReportPrimaryActionUtils'; import {getSecondaryExportReportActions, getSecondaryReportActions} from '@libs/ReportSecondaryActionUtils'; import { @@ -510,7 +518,11 @@ function MoneyReportHeader({ const gbrResult = getReasonAndReportActionThatRequiresAttention(moneyRequestReport, undefined, isArchivedReport); const hasDEWApproveFailed = gbrResult?.reason === CONST.REQUIRES_ATTENTION_REASONS.HAS_DEW_APPROVE_FAILED; if (hasDEWApproveFailed) { - optimisticNextStep = buildOptimisticNextStepForDynamicExternalWorkflowApproveError(theme.danger); + const dewApproveFailedAction = getMostRecentActiveDEWApproveFailedAction(reportActions); + const {automaticAction} = getOriginalMessage(dewApproveFailedAction) ?? {}; + if (!automaticAction) { + optimisticNextStep = buildOptimisticNextStepForDynamicExternalWorkflowApproveError(theme.danger); + } } else if (isOffline && hasPendingDEWApprove(reportMetadata, isDEWPolicy)) { optimisticNextStep = buildOptimisticNextStepForDEWOffline(); } From 097a755a5c63e2bf1a8fb86c74c3338fb75e0901 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Tue, 27 Jan 2026 05:32:35 +0100 Subject: [PATCH 25/35] fix: show DEW auto-approval error only to the current approver --- src/components/MoneyReportHeader.tsx | 3 ++- src/libs/ReportActionsUtils.ts | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index d1de797eba19..eb7a172e6129 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -520,7 +520,8 @@ function MoneyReportHeader({ if (hasDEWApproveFailed) { const dewApproveFailedAction = getMostRecentActiveDEWApproveFailedAction(reportActions); const {automaticAction} = getOriginalMessage(dewApproveFailedAction) ?? {}; - if (!automaticAction) { + const isCurrentUserTheApprover = moneyRequestReport?.managerID === accountID; + if (!automaticAction || isCurrentUserTheApprover) { optimisticNextStep = buildOptimisticNextStepForDynamicExternalWorkflowApproveError(theme.danger); } } else if (isOffline && hasPendingDEWApprove(reportMetadata, isDEWPolicy)) { diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index bc2e72b5ed00..09139a266244 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -329,10 +329,12 @@ function isActionThatSupersedesDEWApproveFailure(action: ReportAction): boolean return isApprovedAction(action) || isForwardedAction(action) || isRetractedAction(action) || isReopenedAction(action); } -function getMostRecentActiveDEWApproveFailedAction(reportActions: OnyxEntry | ReportAction[]): ReportAction | undefined { +function getMostRecentActiveDEWApproveFailedAction( + reportActions: OnyxEntry | ReportAction[], +): ReportAction | undefined { const actionsArray = Array.isArray(reportActions) ? reportActions : Object.values(reportActions ?? {}); - let mostRecentDewApproveFailedAction: ReportAction | undefined; + let mostRecentDewApproveFailedAction: ReportAction | undefined; let mostRecentSupersedingAction: ReportAction | undefined; for (const action of actionsArray) { From 76c7be0b2ab84b6aa2f80e97e5fb8b2a47bfc1e3 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Tue, 27 Jan 2026 20:14:20 +0100 Subject: [PATCH 26/35] fix ts errors --- src/libs/ReportActionsUtils.ts | 4 +--- src/libs/ReportUtils.ts | 14 +++++++------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 09139a266244..1a398c5e9b80 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -329,9 +329,7 @@ function isActionThatSupersedesDEWApproveFailure(action: ReportAction): boolean return isApprovedAction(action) || isForwardedAction(action) || isRetractedAction(action) || isReopenedAction(action); } -function getMostRecentActiveDEWApproveFailedAction( - reportActions: OnyxEntry | ReportAction[], -): ReportAction | undefined { +function getMostRecentActiveDEWApproveFailedAction(reportActions: OnyxEntry | ReportAction[]): ReportAction | undefined { const actionsArray = Array.isArray(reportActions) ? reportActions : Object.values(reportActions ?? {}); let mostRecentDewApproveFailedAction: ReportAction | undefined; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 62ce61c08489..255dd999647a 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -4212,13 +4212,6 @@ function getReasonAndReportActionThatRequiresAttention( }; } - if (isWaitingForAssigneeToCompleteAction(optionOrReport, parentReportAction)) { - return { - reason: CONST.REQUIRES_ATTENTION_REASONS.IS_WAITING_FOR_ASSIGNEE_TO_COMPLETE_ACTION, - reportAction: Object.values(reportActions).find((action) => action.childType === CONST.REPORT.TYPE.TASK), - }; - } - if (optionOrReport.statusNum === CONST.REPORT.STATUS_NUM.SUBMITTED) { const reportActionsArray = Object.values(reportActions ?? {}); const mostRecentActiveDEWApproveAction = getMostRecentActiveDEWApproveFailedAction(reportActionsArray); @@ -4230,6 +4223,13 @@ function getReasonAndReportActionThatRequiresAttention( } } + if (isWaitingForAssigneeToCompleteAction(optionOrReport, parentReportAction)) { + return { + reason: CONST.REQUIRES_ATTENTION_REASONS.IS_WAITING_FOR_ASSIGNEE_TO_COMPLETE_ACTION, + reportAction: Object.values(reportActions).find((action) => action.childType === CONST.REPORT.TYPE.TASK), + }; + } + const optionReportMetadata = allReportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${optionOrReport.reportID}`]; const iouReportActionToApproveOrPay = getIOUReportActionToApproveOrPay(optionOrReport, undefined, optionReportMetadata); const iouReportID = getIOUReportIDFromReportActionPreview(iouReportActionToApproveOrPay); From bbda4c81335acd12446f2f1c1d0629dc9b522c8b Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Tue, 27 Jan 2026 20:25:26 +0100 Subject: [PATCH 27/35] fixing ts --- tests/unit/Search/SearchUIUtilsTest.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index 0f8866e4a7f9..746597370f54 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -2336,6 +2336,7 @@ describe('SearchUIUtils', () => { formatPhoneNumber, bankAccountList: {}, groupBy: CONST.SEARCH.GROUP_BY.CATEGORY, + allReportMetadata: {}, })[0], ).toStrictEqual(transactionCategoryGroupListItems); }); @@ -2366,6 +2367,7 @@ describe('SearchUIUtils', () => { formatPhoneNumber, bankAccountList: {}, groupBy: CONST.SEARCH.GROUP_BY.CATEGORY, + allReportMetadata: {}, }) as [TransactionCategoryGroupListItemType[], number]; expect(result).toHaveLength(2); @@ -2416,6 +2418,7 @@ describe('SearchUIUtils', () => { formatPhoneNumber, bankAccountList: {}, groupBy: CONST.SEARCH.GROUP_BY.CATEGORY, + allReportMetadata: {}, queryJSON: { type: CONST.SEARCH.DATA_TYPES.EXPENSE, status: '', @@ -2474,6 +2477,7 @@ describe('SearchUIUtils', () => { formatPhoneNumber, bankAccountList: {}, groupBy: CONST.SEARCH.GROUP_BY.CATEGORY, + allReportMetadata: {}, }) as [TransactionCategoryGroupListItemType[], number]; expect(result).toHaveLength(3); @@ -2514,6 +2518,7 @@ describe('SearchUIUtils', () => { formatPhoneNumber, bankAccountList: {}, groupBy: CONST.SEARCH.GROUP_BY.CATEGORY, + allReportMetadata: {}, }) as [TransactionCategoryGroupListItemType[], number]; expect(result).toHaveLength(3); @@ -2543,6 +2548,7 @@ describe('SearchUIUtils', () => { formatPhoneNumber, bankAccountList: {}, groupBy: CONST.SEARCH.GROUP_BY.CATEGORY, + allReportMetadata: {}, }) as [TransactionCategoryGroupListItemType[], number]; expect(result).toHaveLength(1); @@ -2577,6 +2583,7 @@ describe('SearchUIUtils', () => { formatPhoneNumber, bankAccountList: {}, groupBy: CONST.SEARCH.GROUP_BY.CATEGORY, + allReportMetadata: {}, }) as [TransactionCategoryGroupListItemType[], number]; expect(result).toHaveLength(2); From 8fb0cad38fa828281f2cdb4fd39988de4d9acbb6 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Tue, 27 Jan 2026 20:48:29 +0100 Subject: [PATCH 28/35] fixing max lines eslint error --- src/libs/ReportActionsUtils.ts | 32 ++++---------------------------- 1 file changed, 4 insertions(+), 28 deletions(-) diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 4c08bdb674ae..f9e3cdb7a94a 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -285,11 +285,8 @@ function isDynamicExternalWorkflowSubmitFailedAction(reportAction: OnyxInputOrEn function getMostRecentActiveDEWSubmitFailedAction(reportActions: OnyxEntry | ReportAction[]): ReportAction | undefined { const actionsArray = Array.isArray(reportActions) ? reportActions : Object.values(reportActions ?? {}); - - // Find the most recent DEW_SUBMIT_FAILED and SUBMITTED actions let mostRecentDewSubmitFailedAction: ReportAction | undefined; let mostRecentSubmittedAction: ReportAction | undefined; - for (const action of actionsArray) { if (isDynamicExternalWorkflowSubmitFailedAction(action)) { if (!mostRecentDewSubmitFailedAction || (action.created && mostRecentDewSubmitFailedAction.created && action.created > mostRecentDewSubmitFailedAction.created)) { @@ -301,47 +298,33 @@ function getMostRecentActiveDEWSubmitFailedAction(reportActions: OnyxEntry mostRecentSubmittedAction.created) { return mostRecentDewSubmitFailedAction; } - return undefined; } -/** - * Checks if there's a pending DEW submission in progress. - * Uses reportMetadata.pendingExpenseAction which is set during submit and cleared on success/failure. - */ +/** Checks if there's a pending DEW submission in progress. */ function hasPendingDEWSubmit(reportMetadata: OnyxEntry, isDEWPolicy: boolean): boolean { - if (!isDEWPolicy) { - return false; - } - return reportMetadata?.pendingExpenseAction === CONST.EXPENSE_PENDING_ACTION.SUBMIT; + return isDEWPolicy && reportMetadata?.pendingExpenseAction === CONST.EXPENSE_PENDING_ACTION.SUBMIT; } function isDynamicExternalWorkflowApproveFailedAction(reportAction: OnyxInputOrEntry): reportAction is ReportAction { return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.DEW_APPROVE_FAILED); } -/** - * Actions that clear a DEW_APPROVE_FAILED error (approval succeeded or report was retracted/reopened). - */ +/** Actions that clear a DEW_APPROVE_FAILED error (approval succeeded or report was retracted/reopened). */ function isActionThatSupersedesDEWApproveFailure(action: ReportAction): boolean { return isApprovedAction(action) || isForwardedAction(action) || isRetractedAction(action) || isReopenedAction(action); } function getMostRecentActiveDEWApproveFailedAction(reportActions: OnyxEntry | ReportAction[]): ReportAction | undefined { const actionsArray = Array.isArray(reportActions) ? reportActions : Object.values(reportActions ?? {}); - let mostRecentDewApproveFailedAction: ReportAction | undefined; let mostRecentSupersedingAction: ReportAction | undefined; - for (const action of actionsArray) { if (isDynamicExternalWorkflowApproveFailedAction(action)) { if (!mostRecentDewApproveFailedAction || (action.created && mostRecentDewApproveFailedAction.created && action.created > mostRecentDewApproveFailedAction.created)) { @@ -353,24 +336,17 @@ function getMostRecentActiveDEWApproveFailedAction(reportActions: OnyxEntry mostRecentSupersedingAction.created) { return mostRecentDewApproveFailedAction; } - return undefined; } function hasPendingDEWApprove(reportMetadata: OnyxEntry, isDEWPolicy: boolean): boolean { - if (!isDEWPolicy) { - return false; - } - return reportMetadata?.pendingExpenseAction === CONST.EXPENSE_PENDING_ACTION.APPROVE; + return isDEWPolicy && reportMetadata?.pendingExpenseAction === CONST.EXPENSE_PENDING_ACTION.APPROVE; } function isDynamicExternalWorkflowForwardedAction(reportAction: OnyxInputOrEntry): reportAction is ReportAction { From 2e6389257a79585313fc983b6e2a8e161eda088f Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Tue, 27 Jan 2026 23:35:56 +0100 Subject: [PATCH 29/35] fixing ts --- tests/unit/Search/SearchUIUtilsTest.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index 5dd835364024..2fcc94d87292 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -2462,6 +2462,7 @@ describe('SearchUIUtils', () => { formatPhoneNumber, bankAccountList: {}, groupBy: CONST.SEARCH.GROUP_BY.MONTH, + allReportMetadata: {}, })[0], ).toStrictEqual(transactionMonthGroupListItems); }); @@ -2494,6 +2495,7 @@ describe('SearchUIUtils', () => { formatPhoneNumber, bankAccountList: {}, groupBy: CONST.SEARCH.GROUP_BY.MONTH, + allReportMetadata: {}, }) as [TransactionMonthGroupListItemType[], number]; expect(result).toHaveLength(2); @@ -2511,6 +2513,7 @@ describe('SearchUIUtils', () => { formatPhoneNumber, bankAccountList: {}, groupBy: CONST.SEARCH.GROUP_BY.MONTH, + allReportMetadata: {}, }) as [TransactionMonthGroupListItemType[], number]; expect(result).toHaveLength(2); From a9b205c73aae84a8b9b44f0e9f600897edd3f595 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Wed, 28 Jan 2026 19:45:02 +0100 Subject: [PATCH 30/35] fix: show DEW approval error next step only to current approver --- src/components/MoneyReportHeader.tsx | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index e2ebf297eab6..64083a0f9060 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -60,7 +60,6 @@ import {selectPaymentType} from '@libs/PaymentUtils'; import {getConnectedIntegration, getValidConnectedIntegration, hasDynamicExternalWorkflow} from '@libs/PolicyUtils'; import { getIOUActionForReportID, - getMostRecentActiveDEWApproveFailedAction, getOriginalMessage, getReportAction, hasPendingDEWApprove, @@ -519,13 +518,9 @@ function MoneyReportHeader({ } else if (moneyRequestReport?.statusNum === CONST.REPORT.STATUS_NUM.SUBMITTED) { const gbrResult = getReasonAndReportActionThatRequiresAttention(moneyRequestReport, undefined, isArchivedReport); const hasDEWApproveFailed = gbrResult?.reason === CONST.REQUIRES_ATTENTION_REASONS.HAS_DEW_APPROVE_FAILED; - if (hasDEWApproveFailed) { - const dewApproveFailedAction = getMostRecentActiveDEWApproveFailedAction(reportActions); - const {automaticAction} = getOriginalMessage(dewApproveFailedAction) ?? {}; - const isCurrentUserTheApprover = moneyRequestReport?.managerID === accountID; - if (!automaticAction || isCurrentUserTheApprover) { - optimisticNextStep = buildOptimisticNextStepForDynamicExternalWorkflowApproveError(theme.danger); - } + const isCurrentUserTheApprover = moneyRequestReport?.managerID === accountID; + if (hasDEWApproveFailed && isCurrentUserTheApprover) { + optimisticNextStep = buildOptimisticNextStepForDynamicExternalWorkflowApproveError(theme.danger); } else if (isOffline && hasPendingDEWApprove(reportMetadata, isDEWPolicy)) { optimisticNextStep = buildOptimisticNextStepForDEWOffline(); } From 2721a2482800c676b0b7cf6ae0204b83d2df0479 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Wed, 28 Jan 2026 19:56:40 +0100 Subject: [PATCH 31/35] fixing prettier --- src/components/MoneyReportHeader.tsx | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index fc11429e1ef1..846ee690692b 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -58,14 +58,7 @@ import { import type {KYCFlowEvent, TriggerKYCFlow} from '@libs/PaymentUtils'; import {selectPaymentType} from '@libs/PaymentUtils'; import {getConnectedIntegration, getValidConnectedIntegration, hasDynamicExternalWorkflow} from '@libs/PolicyUtils'; -import { - getIOUActionForReportID, - getOriginalMessage, - getReportAction, - hasPendingDEWApprove, - hasPendingDEWSubmit, - isMoneyRequestAction, -} from '@libs/ReportActionsUtils'; +import {getIOUActionForReportID, getOriginalMessage, getReportAction, hasPendingDEWApprove, hasPendingDEWSubmit, isMoneyRequestAction} from '@libs/ReportActionsUtils'; import {getAllExpensesToHoldIfApplicable, getReportPrimaryAction, isMarkAsResolvedAction} from '@libs/ReportPrimaryActionUtils'; import {getSecondaryExportReportActions, getSecondaryReportActions} from '@libs/ReportSecondaryActionUtils'; import { From 04a2b70cff3a49dc586fdff91ef9c0b349afb0fe Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Wed, 28 Jan 2026 20:05:15 +0100 Subject: [PATCH 32/35] fixing ts --- src/libs/SearchUIUtils.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index c822c84bf980..b7b785eb33c2 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -2020,7 +2020,10 @@ function getReportSections({ const transactionReportMetadata = allReportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${transactionItem.reportID}`] ?? {}; const allActions = getActions(data, allViolations, key, currentSearch, currentUserEmail, currentAccountID, bankAccountList, transactionReportMetadata, actions); - const transaction = { + const transaction: TransactionListItemType = { + ...transactionItem, + action: allActions.at(0) ?? CONST.SEARCH.ACTION_TYPES.VIEW, + allActions, report, reportAction, holdReportAction: holdReportActionsByTransactionID.get(transactionItem.transactionID), From 74811f24b15b1efda7efe4711ffb8fb04f86d21b Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Wed, 28 Jan 2026 20:18:47 +0100 Subject: [PATCH 33/35] minor change --- src/libs/SearchUIUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index b7b785eb33c2..1676a4103912 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -2020,7 +2020,7 @@ function getReportSections({ const transactionReportMetadata = allReportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${transactionItem.reportID}`] ?? {}; const allActions = getActions(data, allViolations, key, currentSearch, currentUserEmail, currentAccountID, bankAccountList, transactionReportMetadata, actions); - const transaction: TransactionListItemType = { + const transaction = { ...transactionItem, action: allActions.at(0) ?? CONST.SEARCH.ACTION_TYPES.VIEW, allActions, From 4047a6a0ae307a910f6c77e9b0835dc879cb2fe9 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Thu, 29 Jan 2026 13:53:48 +0100 Subject: [PATCH 34/35] Minor refactor in getReasonAndReportActionThatRequiresAttention --- src/libs/ReportUtils.ts | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 9cea2fe694a0..0adfac7fba47 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -4181,6 +4181,17 @@ function getReasonAndReportActionThatRequiresAttention( const reportActions = getAllReportActions(optionOrReport.reportID); + if (optionOrReport.statusNum === CONST.REPORT.STATUS_NUM.SUBMITTED) { + const reportActionsArray = Object.values(reportActions ?? {}); + const mostRecentActiveDEWApproveAction = getMostRecentActiveDEWApproveFailedAction(reportActionsArray); + if (mostRecentActiveDEWApproveAction) { + return { + reason: CONST.REQUIRES_ATTENTION_REASONS.HAS_DEW_APPROVE_FAILED, + reportAction: mostRecentActiveDEWApproveAction, + }; + } + } + if (hasUnresolvedCardFraudAlert(optionOrReport)) { return { reason: CONST.REQUIRES_ATTENTION_REASONS.HAS_UNRESOLVED_CARD_FRAUD_ALERT, @@ -4205,17 +4216,6 @@ function getReasonAndReportActionThatRequiresAttention( }; } - if (optionOrReport.statusNum === CONST.REPORT.STATUS_NUM.SUBMITTED) { - const reportActionsArray = Object.values(reportActions ?? {}); - const mostRecentActiveDEWApproveAction = getMostRecentActiveDEWApproveFailedAction(reportActionsArray); - if (mostRecentActiveDEWApproveAction) { - return { - reason: CONST.REQUIRES_ATTENTION_REASONS.HAS_DEW_APPROVE_FAILED, - reportAction: mostRecentActiveDEWApproveAction, - }; - } - } - if (isWaitingForAssigneeToCompleteAction(optionOrReport, parentReportAction)) { return { reason: CONST.REQUIRES_ATTENTION_REASONS.IS_WAITING_FOR_ASSIGNEE_TO_COMPLETE_ACTION, From 231b5740eea5b837099395c0815847f17768b67d Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Thu, 29 Jan 2026 13:59:40 +0100 Subject: [PATCH 35/35] fixing ts --- tests/unit/Search/SearchUIUtilsTest.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index d352eff9d296..feacafe17cf1 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -2807,6 +2807,7 @@ describe('SearchUIUtils', () => { formatPhoneNumber, bankAccountList: {}, groupBy: CONST.SEARCH.GROUP_BY.WEEK, + allReportMetadata: {}, })[0], ).toStrictEqual(transactionWeekGroupListItems); }); @@ -2837,6 +2838,7 @@ describe('SearchUIUtils', () => { formatPhoneNumber, bankAccountList: {}, groupBy: CONST.SEARCH.GROUP_BY.WEEK, + allReportMetadata: {}, }) as [TransactionWeekGroupListItemType[], number]; expect(result).toHaveLength(2); @@ -3486,6 +3488,7 @@ describe('SearchUIUtils', () => { formatPhoneNumber, bankAccountList: {}, groupBy: CONST.SEARCH.GROUP_BY.TAG, + allReportMetadata: {}, }) as [TransactionTagGroupListItemType[], number]; // formattedTag should have unescaped colons for display