From d4e2954fdeb1dd6399ab065545a8bc9d5bcbb482 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Fri, 21 Nov 2025 00:13:04 +0100 Subject: [PATCH 01/76] Update SubmitReport API command --- src/CONST/index.ts | 3 ++ src/components/MoneyReportHeader.tsx | 16 +++++++ src/languages/de.ts | 2 + src/languages/en.ts | 2 + src/languages/fr.ts | 2 + src/languages/it.ts | 2 + src/languages/ja.ts | 2 + src/languages/nl.ts | 2 + src/languages/pl.ts | 2 + src/languages/pt-BR.ts | 2 + src/languages/zh-hans.ts | 2 + src/libs/ReportActionsUtils.ts | 10 ++++ src/libs/ReportUtils.ts | 19 ++++++++ src/libs/actions/IOU.ts | 47 +++++++++++-------- .../home/report/PureReportActionItem.tsx | 8 +++- 15 files changed, 100 insertions(+), 21 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 40b2fa1e6dca5..e66fd2bef3418 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1202,6 +1202,8 @@ const CONST = { CREATED: 'CREATED', DELETED_ACCOUNT: 'DELETEDACCOUNT', // Deprecated OldDot Action DELETED_TRANSACTION: 'DELETEDTRANSACTION', + DEW_APPROVE_FAILED: 'DEW_APPROVE_FAILED', + DEW_SUBMIT_FAILED: 'DEW_SUBMIT_FAILED', DISMISSED_VIOLATION: 'DISMISSEDVIOLATION', DONATION: 'DONATION', // Deprecated OldDot Action EXPENSIFY_CARD_SYSTEM_MESSAGE: 'EXPENSIFYCARDSYSTEMMESSAGE', @@ -7222,6 +7224,7 @@ const CONST = { HAS_CHILD_REPORT_AWAITING_ACTION: 'hasChildReportAwaitingAction', HAS_MISSING_INVOICE_BANK_ACCOUNT: 'hasMissingInvoiceBankAccount', HAS_UNRESOLVED_CARD_FRAUD_ALERT: 'hasUnresolvedCardFraudAlert', + HAS_DEW_ERROR: 'hasDEWError', }, CARD_FRAUD_ALERT_RESOLUTION: { diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 5fd7d5693c54a..8fb0a67820abd 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -52,6 +52,7 @@ import {getAllExpensesToHoldIfApplicable, getReportPrimaryAction, isMarkAsResolv import {getSecondaryExportReportActions, getSecondaryReportActions} from '@libs/ReportSecondaryActionUtils'; import { changeMoneyRequestHoldStatus, + getAllReportActionsErrorsAndReportActionThatRequiresAttention, getAddExpenseDropdownOptions, getIntegrationExportIcon, getIntegrationNameFromExportMessage as getIntegrationNameFromExportMessageUtils, @@ -558,6 +559,21 @@ function MoneyReportHeader({ return {icon: getStatusIcon(expensifyIcons.Hourglass), description: translate('iou.reject.rejectedStatus')}; } + // Check for DEW errors first (highest priority) + if (hasDynamicExternalWorkflow(policy) && moneyRequestReport?.statusNum === CONST.REPORT.STATUS_NUM.OPEN) { + // Convert array to object format for getAllReportActionsErrorsAndReportActionThatRequiresAttention + const reportActionsObject = reportActions.reduce((acc, action) => { + if (action.reportActionID) { + acc[action.reportActionID] = action; + } + return acc; + }, {}); + const {errors} = getAllReportActionsErrorsAndReportActionThatRequiresAttention(moneyRequestReport, reportActionsObject); + if (errors?.dewSubmitFailed) { + return {icon: getStatusIcon(expensifyIcons.Exclamation), description: translate('iou.dynamicExternalWorkflowCannotSubmit')}; + } + } + if (isPayAtEndExpense) { if (!isArchivedReport) { return {icon: getStatusIcon(expensifyIcons.Hourglass), description: translate('iou.bookingPendingDescription')}; diff --git a/src/languages/de.ts b/src/languages/de.ts index ba5bb7de0368e..38beede37b926 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1294,6 +1294,8 @@ const translations: TranslationDeepObject = { expenseAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `${formattedAmount}${comment ? `für ${comment}` : ''}`, submitted: ({memo}: SubmittedWithMemoParams) => `eingereicht${memo ? `, sagte ${memo}` : ''}`, automaticallySubmitted: `über verzögerte Einreichungen eingereicht`, + queuedToSubmitViaDEW: 'in die Warteschlange gestellt zur Einreichung über benutzerdefinierten Genehmigungsworkflow', + dynamicExternalWorkflowCannotSubmit: 'Dieser Bericht kann nicht eingereicht werden. Bitte überprüfen Sie die Kommentare zur Behebung.', trackedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `tracking ${formattedAmount}${comment ? `für ${comment}` : ''}`, splitAmount: ({amount}: SplitAmountParams) => `teilen ${amount}`, didSplitAmount: ({formattedAmount, comment}: DidSplitAmountMessageParams) => `split ${formattedAmount}${comment ? `für ${comment}` : ''}`, diff --git a/src/languages/en.ts b/src/languages/en.ts index 5250878059d85..f2052ac647f64 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1276,6 +1276,8 @@ const translations = { expenseAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `${formattedAmount}${comment ? ` for ${comment}` : ''}`, submitted: ({memo}: SubmittedWithMemoParams) => `submitted${memo ? `, saying ${memo}` : ''}`, automaticallySubmitted: `submitted via delay submissions`, + queuedToSubmitViaDEW: 'queued to submit via custom approval workflow', + dynamicExternalWorkflowCannotSubmit: 'This report can\'t be submitted. Please review the comments to resolve.', 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/fr.ts b/src/languages/fr.ts index 55e82f4a98b48..9917b51dc23a8 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1298,6 +1298,8 @@ const translations: TranslationDeepObject = { expenseAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `${formattedAmount}${comment ? `pour ${comment}` : ''}`, submitted: ({memo}: SubmittedWithMemoParams) => `soumis${memo ? `, en disant ${memo}` : ''}`, automaticallySubmitted: `soumis via soumissions différées`, + queuedToSubmitViaDEW: 'en file d\'attente pour être soumis via le workflow d\'approbation personnalisé', + dynamicExternalWorkflowCannotSubmit: 'Ce rapport ne peut pas être soumis. Veuillez consulter les commentaires pour résoudre.', trackedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `suivi ${formattedAmount}${comment ? `pour ${comment}` : ''}`, splitAmount: ({amount}: SplitAmountParams) => `diviser ${amount}`, didSplitAmount: ({formattedAmount, comment}: DidSplitAmountMessageParams) => `split ${formattedAmount}${comment ? `pour ${comment}` : ''}`, diff --git a/src/languages/it.ts b/src/languages/it.ts index a64a820dd859c..b97f6ccee29fb 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1292,6 +1292,8 @@ const translations: TranslationDeepObject = { expenseAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `${formattedAmount}${comment ? `per ${comment}` : ''}`, submitted: ({memo}: SubmittedWithMemoParams) => `inviato${memo ? `, dicendo: ${memo}` : ''}`, automaticallySubmitted: `inviato tramite invio ritardato`, + queuedToSubmitViaDEW: 'in coda per l\'invio tramite flusso di approvazione personalizzato', + dynamicExternalWorkflowCannotSubmit: 'Questo rapporto non può essere inviato. Si prega di rivedere i commenti per risolvere.', trackedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `tracking ${formattedAmount}${comment ? `per ${comment}` : ''}`, splitAmount: ({amount}: SplitAmountParams) => `dividi ${amount}`, didSplitAmount: ({formattedAmount, comment}: DidSplitAmountMessageParams) => `split ${formattedAmount}${comment ? `per ${comment}` : ''}`, diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 3bdab330d66ad..1cf10b3b4efb9 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1294,6 +1294,8 @@ const translations: TranslationDeepObject = { expenseAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `${formattedAmount}${comment ? `${comment} のために` : ''}`, submitted: ({memo}: SubmittedWithMemoParams) => `提出済み${memo ? `、次のように言って ${memo}` : ''}`, automaticallySubmitted: `送信の遅延を通じて送信されました`, + queuedToSubmitViaDEW: 'カスタム承認ワークフローを介して送信待ちキューに入れられました', + dynamicExternalWorkflowCannotSubmit: 'このレポートは送信できません。解決するにはコメントを確認してください。', trackedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `tracking ${formattedAmount}${comment ? `${comment} のために` : ''}`, splitAmount: ({amount}: SplitAmountParams) => `${amount} を分割`, didSplitAmount: ({formattedAmount, comment}: DidSplitAmountMessageParams) => `split ${formattedAmount}${comment ? `${comment} のために` : ''}`, diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 70b2a792575cf..a1b0cc9576383 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1292,6 +1292,8 @@ const translations: TranslationDeepObject = { expenseAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `${formattedAmount}${comment ? `voor ${comment}` : ''}`, submitted: ({memo}: SubmittedWithMemoParams) => `ingediend${memo ? `, zegt ${memo}` : ''}`, automaticallySubmitted: `ingediend via vertraging indieningen`, + queuedToSubmitViaDEW: 'in wachtrij geplaatst om in te dienen via aangepaste goedkeuringswerkstroom', + dynamicExternalWorkflowCannotSubmit: 'Dit rapport kan niet worden ingediend. Bekijk de opmerkingen om op te lossen.', trackedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `volgt ${formattedAmount}${comment ? `voor ${comment}` : ''}`, splitAmount: ({amount}: SplitAmountParams) => `splitsen ${amount}`, didSplitAmount: ({formattedAmount, comment}: DidSplitAmountMessageParams) => `split ${formattedAmount}${comment ? `voor ${comment}` : ''}`, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index d83afac841ff9..2877a55aadf20 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1291,6 +1291,8 @@ const translations: TranslationDeepObject = { expenseAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `${formattedAmount}${comment ? `dla ${comment}` : ''}`, submitted: ({memo}: SubmittedWithMemoParams) => `przesłano${memo ? `, mówiąc ${memo}` : ''}`, automaticallySubmitted: `przesłane za pomocą opóźnij zgłoszenia`, + queuedToSubmitViaDEW: 'umieszczone w kolejce do przesłania przez niestandardowy przepływ zatwierdzania', + dynamicExternalWorkflowCannotSubmit: 'Ten raport nie może zostać przesłany. Proszę sprawdzić komentarze, aby rozwiązać problem.', 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 a0d0fc9c03c5c..ed95fefe221f6 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1290,6 +1290,8 @@ const translations: TranslationDeepObject = { expenseAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `${formattedAmount}${comment ? `para ${comment}` : ''}`, submitted: ({memo}: SubmittedWithMemoParams) => `enviado${memo ? `, dizendo ${memo}` : ''}`, automaticallySubmitted: `enviado via adiar envios`, + queuedToSubmitViaDEW: 'enfileirado para envio via fluxo de aprovação personalizado', + dynamicExternalWorkflowCannotSubmit: 'Este relatório não pode ser enviado. Por favor, revise os comentários para resolver.', trackedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `rastreamento ${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 6b43bc37319d7..165b49453ca2a 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1274,6 +1274,8 @@ const translations: TranslationDeepObject = { expenseAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `${formattedAmount}${comment ? `对于${comment}` : ''}`, submitted: ({memo}: SubmittedWithMemoParams) => `已提交${memo ? `, 备注 ${memo}` : ''}`, automaticallySubmitted: `通过延迟提交提交`, + queuedToSubmitViaDEW: '已排队等待通过自定义审批工作流提交', + dynamicExternalWorkflowCannotSubmit: '无法提交此报告。请查看评论以解决问题。', trackedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `跟踪 ${formattedAmount}${comment ? `对于${comment}` : ''}`, splitAmount: ({amount}: SplitAmountParams) => `拆分 ${amount}`, didSplitAmount: ({formattedAmount, comment}: DidSplitAmountMessageParams) => `split ${formattedAmount}${comment ? `对于${comment}` : ''}`, diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index d9d78da742481..c4155f2c992db 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -242,6 +242,14 @@ function isForwardedAction(reportAction: OnyxInputOrEntry): report return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.FORWARDED); } +function isDynamicExternalWorkflowSubmitFailedAction(reportAction: OnyxInputOrEntry): reportAction is ReportAction { + return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.DEW_SUBMIT_FAILED); +} + +function isDynamicExternalWorkflowApproveFailedAction(reportAction: OnyxInputOrEntry): reportAction is ReportAction { + return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.DEW_APPROVE_FAILED); +} + function isModifiedExpenseAction(reportAction: OnyxInputOrEntry): reportAction is ReportAction { return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE); } @@ -3437,6 +3445,8 @@ export { isApprovedAction, isUnapprovedAction, isForwardedAction, + isDynamicExternalWorkflowSubmitFailedAction, + isDynamicExternalWorkflowApproveFailedAction, isWhisperActionTargetedToOthers, isTagModificationAction, isIOUActionMatchingTransactionList, diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index cd675e90ba855..00e839ef1d9fe 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -219,6 +219,8 @@ import { isCurrentActionUnread, isDeletedAction, isDeletedParentAction, + isDynamicExternalWorkflowApproveFailedAction, + isDynamicExternalWorkflowSubmitFailedAction, isExportIntegrationAction, isIntegrationMessageAction, isMarkAsClosedAction, @@ -9011,6 +9013,23 @@ function getAllReportActionsErrorsAndReportActionThatRequiresAttention( reportAction = getReportActionWithSmartscanError(reportActionsArray); } + // Check for DEW submit errors on OPEN reports + if (!isReportArchived && report?.statusNum === CONST.REPORT.STATUS_NUM.OPEN) { + const dewSubmitFailedAction = reportActionsArray.find((action) => isDynamicExternalWorkflowSubmitFailedAction(action)); + if (dewSubmitFailedAction) { + // Check if there's a more recent SUBMITTED action + const submittedAction = reportActionsArray.find((action) => isSubmittedAction(action)); + const shouldShowDEWError = !submittedAction || (submittedAction && dewSubmitFailedAction.created > submittedAction.created); + + if (shouldShowDEWError) { + reportActionErrors.dewSubmitFailed = getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericDEWSubmitFailureMessage'); + if (!reportAction) { + reportAction = dewSubmitFailedAction; + } + } + } + } + return { errors: reportActionErrors, reportAction, diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index fe916c13ae887..76e171426fd1e 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -90,6 +90,7 @@ import { getPolicy, getSubmitToAccountID, hasDependentTags, + hasDynamicExternalWorkflow, hasOnlyPersonalPolicies, isControlPolicy, isDelayedSubmissionEnabled, @@ -10987,29 +10988,35 @@ function submitReport( const isSubmitAndClosePolicy = isSubmitAndClose(policy); const adminAccountID = policy?.role === CONST.POLICY.ROLE.ADMIN ? currentUserAccountIDParam : undefined; const optimisticSubmittedReportAction = buildOptimisticSubmittedReportAction(expenseReport?.total ?? 0, expenseReport.currency ?? '', expenseReport.reportID, adminAccountID); + const isDEWPolicy = hasDynamicExternalWorkflow(policy); // buildOptimisticNextStep is used in parallel + // Skip optimistic next step for DEW policies since we can't predict the next step // eslint-disable-next-line @typescript-eslint/no-deprecated - const optimisticNextStepDeprecated = buildNextStepNew({ - report: expenseReport, - predictedNextStatus: isSubmitAndClosePolicy ? CONST.REPORT.STATUS_NUM.CLOSED : CONST.REPORT.STATUS_NUM.SUBMITTED, - policy, - currentUserAccountIDParam, - currentUserEmailParam, - hasViolations, - isASAPSubmitBetaEnabled, - isUnapprove: true, - }); - const optimisticNextStep = buildOptimisticNextStep({ - report: expenseReport, - predictedNextStatus: isSubmitAndClosePolicy ? CONST.REPORT.STATUS_NUM.CLOSED : CONST.REPORT.STATUS_NUM.SUBMITTED, - policy, - currentUserAccountIDParam, - currentUserEmailParam, - hasViolations, - isASAPSubmitBetaEnabled, - isUnapprove: true, - }); + const optimisticNextStepDeprecated = isDEWPolicy + ? null + : buildNextStepNew({ + report: expenseReport, + predictedNextStatus: isSubmitAndClosePolicy ? CONST.REPORT.STATUS_NUM.CLOSED : CONST.REPORT.STATUS_NUM.SUBMITTED, + policy, + currentUserAccountIDParam, + currentUserEmailParam, + hasViolations, + isASAPSubmitBetaEnabled, + isUnapprove: true, + }); + const optimisticNextStep = isDEWPolicy + ? null + : buildOptimisticNextStep({ + report: expenseReport, + predictedNextStatus: isSubmitAndClosePolicy ? CONST.REPORT.STATUS_NUM.CLOSED : CONST.REPORT.STATUS_NUM.SUBMITTED, + policy, + currentUserAccountIDParam, + currentUserEmailParam, + hasViolations, + isASAPSubmitBetaEnabled, + isUnapprove: true, + }); const approvalChain = getApprovalChain(policy, expenseReport); const managerID = getAccountIDsByLogins(approvalChain).at(0); diff --git a/src/pages/home/report/PureReportActionItem.tsx b/src/pages/home/report/PureReportActionItem.tsx index 26f1de18ce522..5d4a526eafd76 100644 --- a/src/pages/home/report/PureReportActionItem.tsx +++ b/src/pages/home/report/PureReportActionItem.tsx @@ -58,7 +58,7 @@ import {isReportMessageAttachment} from '@libs/isReportMessageAttachment'; import Navigation from '@libs/Navigation/Navigation'; import Permissions from '@libs/Permissions'; import {getDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; -import {getCleanedTagName, getPersonalPolicy, isPolicyAdmin, isPolicyOwner} from '@libs/PolicyUtils'; +import {getCleanedTagName, getPersonalPolicy, hasDynamicExternalWorkflow, isPolicyAdmin, isPolicyOwner} from '@libs/PolicyUtils'; import { extractLinksFromMessageHtml, getActionableCardFraudAlertMessage, @@ -1139,12 +1139,18 @@ function PureReportActionItem({ children = ; } else if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.SUBMITTED) || isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.SUBMITTED_AND_CLOSED) || isMarkAsClosedAction(action)) { const wasSubmittedViaHarvesting = !isMarkAsClosedAction(action) ? (getOriginalMessage(action)?.harvesting ?? false) : false; + const isPendingAdd = action.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD; + const isDEWPolicy = hasDynamicExternalWorkflow(policy); + if (wasSubmittedViaHarvesting) { children = ( ${translate('iou.automaticallySubmitted')}`} /> ); + } else if (isPendingAdd && isDEWPolicy) { + // Show queued message for DEW policies when offline + children = ; } else { children = ; } From 8b510f3ae96a89ae2624e74bd1922b2d1e267d08 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Fri, 21 Nov 2025 00:13:28 +0100 Subject: [PATCH 02/76] minor edit --- src/components/MoneyReportHeader.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 8fb0a67820abd..5be5f9cf18554 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -52,8 +52,8 @@ import {getAllExpensesToHoldIfApplicable, getReportPrimaryAction, isMarkAsResolv import {getSecondaryExportReportActions, getSecondaryReportActions} from '@libs/ReportSecondaryActionUtils'; import { changeMoneyRequestHoldStatus, - getAllReportActionsErrorsAndReportActionThatRequiresAttention, getAddExpenseDropdownOptions, + getAllReportActionsErrorsAndReportActionThatRequiresAttention, getIntegrationExportIcon, getIntegrationNameFromExportMessage as getIntegrationNameFromExportMessageUtils, getNextApproverAccountID, From 33394483ef91bbd419a67f8f56ec904f434e8485 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Fri, 21 Nov 2025 00:18:20 +0100 Subject: [PATCH 03/76] minor fixes --- src/languages/en.ts | 2 +- src/languages/fr.ts | 2 +- src/languages/it.ts | 2 +- src/libs/ReportUtils.ts | 2 +- src/pages/home/report/PureReportActionItem.tsx | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/languages/en.ts b/src/languages/en.ts index f2052ac647f64..1db84d73620fd 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1277,7 +1277,7 @@ const translations = { submitted: ({memo}: SubmittedWithMemoParams) => `submitted${memo ? `, saying ${memo}` : ''}`, automaticallySubmitted: `submitted via delay submissions`, queuedToSubmitViaDEW: 'queued to submit via custom approval workflow', - dynamicExternalWorkflowCannotSubmit: 'This report can\'t be submitted. Please review the comments to resolve.', + dynamicExternalWorkflowCannotSubmit: "This report can't be submitted. Please review the comments to resolve.", 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/fr.ts b/src/languages/fr.ts index 9917b51dc23a8..d5908564a6906 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1298,7 +1298,7 @@ const translations: TranslationDeepObject = { expenseAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `${formattedAmount}${comment ? `pour ${comment}` : ''}`, submitted: ({memo}: SubmittedWithMemoParams) => `soumis${memo ? `, en disant ${memo}` : ''}`, automaticallySubmitted: `soumis via soumissions différées`, - queuedToSubmitViaDEW: 'en file d\'attente pour être soumis via le workflow d\'approbation personnalisé', + queuedToSubmitViaDEW: "en file d'attente pour être soumis via le workflow d'approbation personnalisé", dynamicExternalWorkflowCannotSubmit: 'Ce rapport ne peut pas être soumis. Veuillez consulter les commentaires pour résoudre.', trackedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `suivi ${formattedAmount}${comment ? `pour ${comment}` : ''}`, splitAmount: ({amount}: SplitAmountParams) => `diviser ${amount}`, diff --git a/src/languages/it.ts b/src/languages/it.ts index b97f6ccee29fb..e0a5f7c8579a6 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1292,7 +1292,7 @@ const translations: TranslationDeepObject = { expenseAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `${formattedAmount}${comment ? `per ${comment}` : ''}`, submitted: ({memo}: SubmittedWithMemoParams) => `inviato${memo ? `, dicendo: ${memo}` : ''}`, automaticallySubmitted: `inviato tramite invio ritardato`, - queuedToSubmitViaDEW: 'in coda per l\'invio tramite flusso di approvazione personalizzato', + queuedToSubmitViaDEW: "in coda per l'invio tramite flusso di approvazione personalizzato", dynamicExternalWorkflowCannotSubmit: 'Questo rapporto non può essere inviato. Si prega di rivedere i commenti per risolvere.', trackedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `tracking ${formattedAmount}${comment ? `per ${comment}` : ''}`, splitAmount: ({amount}: SplitAmountParams) => `dividi ${amount}`, diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 00e839ef1d9fe..ba9aadfad4787 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -9020,7 +9020,7 @@ function getAllReportActionsErrorsAndReportActionThatRequiresAttention( // Check if there's a more recent SUBMITTED action const submittedAction = reportActionsArray.find((action) => isSubmittedAction(action)); const shouldShowDEWError = !submittedAction || (submittedAction && dewSubmitFailedAction.created > submittedAction.created); - + if (shouldShowDEWError) { reportActionErrors.dewSubmitFailed = getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericDEWSubmitFailureMessage'); if (!reportAction) { diff --git a/src/pages/home/report/PureReportActionItem.tsx b/src/pages/home/report/PureReportActionItem.tsx index 5d4a526eafd76..194da4b21568c 100644 --- a/src/pages/home/report/PureReportActionItem.tsx +++ b/src/pages/home/report/PureReportActionItem.tsx @@ -1141,7 +1141,7 @@ function PureReportActionItem({ const wasSubmittedViaHarvesting = !isMarkAsClosedAction(action) ? (getOriginalMessage(action)?.harvesting ?? false) : false; const isPendingAdd = action.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD; const isDEWPolicy = hasDynamicExternalWorkflow(policy); - + if (wasSubmittedViaHarvesting) { children = ( From e7648c0b223a91a77d3c98e1b3b15b59d4cb801f Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Fri, 21 Nov 2025 00:56:48 +0100 Subject: [PATCH 04/76] Add support for DEW submit with beta flag --- src/CONST/index.ts | 1 + src/components/MoneyReportHeader.tsx | 11 +++++------ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index a02ed50d2369d..8835343897282 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -709,6 +709,7 @@ const CONST = { NO_OPTIMISTIC_TRANSACTION_THREADS: 'noOptimisticTransactionThreads', UBER_FOR_BUSINESS: 'uberForBusiness', CUSTOM_REPORT_NAMES: 'newExpensifyCustomReportNames', + NEW_DOT_DEW: 'newDotDEW', }, BUTTON_STATES: { DEFAULT: 'default', diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 5be5f9cf18554..7b7b8af85afe4 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -291,6 +291,7 @@ function MoneyReportHeader({ const [allTransactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, {canBeMissing: true}); const {isBetaEnabled} = usePermissions(); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); + const isDEWSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.NEW_DOT_DEW); const hasViolations = hasViolationsReportUtils(moneyRequestReport?.reportID, allTransactionViolations); const [exportModalStatus, setExportModalStatus] = useState(null); @@ -559,9 +560,7 @@ function MoneyReportHeader({ return {icon: getStatusIcon(expensifyIcons.Hourglass), description: translate('iou.reject.rejectedStatus')}; } - // Check for DEW errors first (highest priority) - if (hasDynamicExternalWorkflow(policy) && moneyRequestReport?.statusNum === CONST.REPORT.STATUS_NUM.OPEN) { - // Convert array to object format for getAllReportActionsErrorsAndReportActionThatRequiresAttention + if (isDEWSubmitBetaEnabled && hasDynamicExternalWorkflow(policy) && moneyRequestReport?.statusNum === CONST.REPORT.STATUS_NUM.OPEN) { const reportActionsObject = reportActions.reduce((acc, action) => { if (action.reportActionID) { acc[action.reportActionID] = action; @@ -570,7 +569,7 @@ function MoneyReportHeader({ }, {}); const {errors} = getAllReportActionsErrorsAndReportActionThatRequiresAttention(moneyRequestReport, reportActionsObject); if (errors?.dewSubmitFailed) { - return {icon: getStatusIcon(expensifyIcons.Exclamation), description: translate('iou.dynamicExternalWorkflowCannotSubmit')}; + return {icon: getStatusIcon(expensifyIcons.Flag), description: translate('iou.dynamicExternalWorkflowCannotSubmit')}; } } @@ -790,7 +789,7 @@ function MoneyReportHeader({ if (!moneyRequestReport || shouldBlockSubmit) { return; } - if (hasDynamicExternalWorkflow(policy)) { + if (hasDynamicExternalWorkflow(policy) && !isDEWSubmitBetaEnabled) { showDWEModal(); return; } @@ -1006,7 +1005,7 @@ function MoneyReportHeader({ if (!moneyRequestReport) { return; } - if (hasDynamicExternalWorkflow(policy)) { + if (hasDynamicExternalWorkflow(policy) && !isDEWSubmitBetaEnabled) { showDWEModal(); return; } From 2dbf516b0d4c0f3a059c88024b8a61ac1b4891e3 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Fri, 21 Nov 2025 01:22:58 +0100 Subject: [PATCH 05/76] keep the optimisitc value not changed on submit --- .../MoneyRequestReportPreviewContent.tsx | 5 +- src/components/Search/index.tsx | 8 +- src/libs/Permissions.ts | 2 +- src/libs/actions/IOU.ts | 80 ++++++++++++------- src/pages/Search/SearchPage.tsx | 5 +- 5 files changed, 64 insertions(+), 36 deletions(-) diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx index 26830e8db39bd..47c3e5eb59542 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx @@ -166,6 +166,7 @@ function MoneyRequestReportPreviewContent({ const {isBetaEnabled} = usePermissions(); const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, {canBeMissing: true}); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); + const isDEWSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.NEW_DOT_DEW); const hasViolations = hasViolationsReportUtils(iouReport?.reportID, transactionViolations); const getCanIOUBePaid = useCallback( @@ -257,7 +258,7 @@ function MoneyRequestReportPreviewContent({ ); const confirmApproval = () => { - if (hasDynamicExternalWorkflow(policy)) { + if (hasDynamicExternalWorkflow(policy) && !isDEWSubmitBetaEnabled) { setIsDEWModalVisible(true); return; } @@ -527,7 +528,7 @@ function MoneyRequestReportPreviewContent({ success={isWaitingForSubmissionFromCurrentUser} text={translate('common.submit')} onPress={() => { - if (hasDynamicExternalWorkflow(policy)) { + if (hasDynamicExternalWorkflow(policy) && !isDEWSubmitBetaEnabled) { setIsDEWModalVisible(true); return; } diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index d5d83a8015c65..203755e9ef4b3 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -18,6 +18,7 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; import usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchHighlightAndScroll from '@hooks/useSearchHighlightAndScroll'; @@ -226,14 +227,19 @@ function Search({ const {shouldUseNarrowLayout} = useResponsiveLayout(); const styles = useThemeStyles(); const [isDEWModalVisible, setIsDEWModalVisible] = useState(false); + const {isBetaEnabled} = usePermissions(); + const isDEWSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.NEW_DOT_DEW); const handleDEWModalOpen = useCallback(() => { + if (isDEWSubmitBetaEnabled) { + return; + } if (onDEWModalOpen) { onDEWModalOpen(); } else { setIsDEWModalVisible(true); } - }, [onDEWModalOpen]); + }, [onDEWModalOpen, isDEWSubmitBetaEnabled]); // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout for enabling the selection mode on small screens only // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth, isLargeScreenWidth} = useResponsiveLayout(); diff --git a/src/libs/Permissions.ts b/src/libs/Permissions.ts index 7d0e831f31007..8a699bcede294 100644 --- a/src/libs/Permissions.ts +++ b/src/libs/Permissions.ts @@ -5,7 +5,7 @@ import type BetaConfiguration from '@src/types/onyx/BetaConfiguration'; // eslint-disable-next-line rulesdir/no-beta-handler function canUseAllBetas(betas: OnyxEntry): boolean { - return !!betas?.includes(CONST.BETAS.ALL); + return true; } /** diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 64b419ac4c839..d1576cd2f11b1 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -11056,7 +11056,6 @@ function submitReport( const isDEWPolicy = hasDynamicExternalWorkflow(policy); // buildOptimisticNextStep is used in parallel - // Skip optimistic next step for DEW policies since we can't predict the next step // eslint-disable-next-line @typescript-eslint/no-deprecated const optimisticNextStepDeprecated = isDEWPolicy ? null @@ -11107,10 +11106,14 @@ function submitReport( lastMessageHtml: getReportActionHtml(optimisticSubmittedReportAction), stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, - nextStep: optimisticNextStep, - pendingFields: { - nextStep: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, - }, + ...(isDEWPolicy ? {} : {nextStep: optimisticNextStep}), + ...(isDEWPolicy + ? {} + : { + pendingFields: { + nextStep: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }), }, } : { @@ -11120,19 +11123,25 @@ function submitReport( ...expenseReport, stateNum: CONST.REPORT.STATE_NUM.APPROVED, statusNum: CONST.REPORT.STATUS_NUM.CLOSED, - nextStep: optimisticNextStep, - pendingFields: { - nextStep: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, - }, + ...(isDEWPolicy ? {} : {nextStep: optimisticNextStep}), + ...(isDEWPolicy + ? {} + : { + pendingFields: { + nextStep: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }), }, }, ]; - optimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport.reportID}`, - value: optimisticNextStepDeprecated, - }); + if (!isDEWPolicy) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport.reportID}`, + value: optimisticNextStepDeprecated, + }); + } if (parentReport?.reportID) { optimisticData.push({ @@ -11148,15 +11157,17 @@ function submitReport( } const successData: OnyxUpdate[] = []; - successData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, - value: { - pendingFields: { - nextStep: null, + if (!isDEWPolicy) { + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, + value: { + pendingFields: { + nextStep: null, + }, }, - }, - }); + }); + } successData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, @@ -11174,17 +11185,16 @@ function submitReport( value: { statusNum: CONST.REPORT.STATUS_NUM.OPEN, stateNum: CONST.REPORT.STATE_NUM.OPEN, - nextStep: expenseReport.nextStep ?? null, - pendingFields: { - nextStep: null, - }, + ...(isDEWPolicy ? {} : {nextStep: expenseReport.nextStep ?? null}), + ...(isDEWPolicy + ? {} + : { + pendingFields: { + nextStep: null, + }, + }), }, }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport.reportID}`, - value: currentNextStepDeprecated, - }, ]; failureData.push({ onyxMethod: Onyx.METHOD.MERGE, @@ -11196,6 +11206,14 @@ function submitReport( }, }); + if (!isDEWPolicy) { + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport.reportID}`, + value: currentNextStepDeprecated, + }); + } + if (parentReport?.reportID) { failureData.push({ onyxMethod: Onyx.METHOD.MERGE, diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 4df55d8b9efe3..1340ce3666cc3 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -28,6 +28,7 @@ import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; import usePersonalPolicy from '@hooks/usePersonalPolicy'; import usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -117,6 +118,8 @@ function SearchPage({route}: SearchPageProps) { const [isExportWithTemplateModalVisible, setIsExportWithTemplateModalVisible] = useState(false); const [searchRequestResponseStatusCode, setSearchRequestResponseStatusCode] = useState(null); const [isDEWModalVisible, setIsDEWModalVisible] = useState(false); + const {isBetaEnabled} = usePermissions(); + const isDEWSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.NEW_DOT_DEW); const queryJSON = useMemo(() => buildSearchQueryJSON(route.params.q), [route.params.q]); const {saveScrollOffset} = useContext(ScrollOffsetContext); const activeAdminPolicies = getActiveAdminWorkspaces(policies, currentUserPersonalDetails?.accountID.toString()).sort((a, b) => localeCompare(a.name || '', b.name || '')); @@ -428,7 +431,7 @@ function SearchPage({route}: SearchPageProps) { return hasDynamicExternalWorkflow(policy); }); - if (hasDEWPolicy) { + if (hasDEWPolicy && !isDEWSubmitBetaEnabled) { setIsDEWModalVisible(true); return; } From 99cbd173087d4d834fde34f83b3c7b197d5fe496 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Fri, 21 Nov 2025 01:26:33 +0100 Subject: [PATCH 06/76] cleaning unneeded comments --- src/libs/ReportUtils.ts | 2 -- src/pages/home/report/PureReportActionItem.tsx | 1 - 2 files changed, 3 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index d77e90c83c0b9..b68fb80a69b9b 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -9021,11 +9021,9 @@ function getAllReportActionsErrorsAndReportActionThatRequiresAttention( reportAction = getReportActionWithSmartscanError(reportActionsArray); } - // Check for DEW submit errors on OPEN reports if (!isReportArchived && report?.statusNum === CONST.REPORT.STATUS_NUM.OPEN) { const dewSubmitFailedAction = reportActionsArray.find((action) => isDynamicExternalWorkflowSubmitFailedAction(action)); if (dewSubmitFailedAction) { - // Check if there's a more recent SUBMITTED action const submittedAction = reportActionsArray.find((action) => isSubmittedAction(action)); const shouldShowDEWError = !submittedAction || (submittedAction && dewSubmitFailedAction.created > submittedAction.created); diff --git a/src/pages/home/report/PureReportActionItem.tsx b/src/pages/home/report/PureReportActionItem.tsx index 194da4b21568c..745a75b64d26d 100644 --- a/src/pages/home/report/PureReportActionItem.tsx +++ b/src/pages/home/report/PureReportActionItem.tsx @@ -1149,7 +1149,6 @@ function PureReportActionItem({ ); } else if (isPendingAdd && isDEWPolicy) { - // Show queued message for DEW policies when offline children = ; } else { children = ; From 1efb0169344e2bc63e9d60ffc2bc1e8c1ede8e0f Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Fri, 21 Nov 2025 01:50:21 +0100 Subject: [PATCH 07/76] reverting permissions change --- src/libs/Permissions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/Permissions.ts b/src/libs/Permissions.ts index 8a699bcede294..7d0e831f31007 100644 --- a/src/libs/Permissions.ts +++ b/src/libs/Permissions.ts @@ -5,7 +5,7 @@ import type BetaConfiguration from '@src/types/onyx/BetaConfiguration'; // eslint-disable-next-line rulesdir/no-beta-handler function canUseAllBetas(betas: OnyxEntry): boolean { - return true; + return !!betas?.includes(CONST.BETAS.ALL); } /** From e7c2d7123c50f1eb277141cd1a7bd9cebede9111 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Fri, 21 Nov 2025 02:20:25 +0100 Subject: [PATCH 08/76] adding test cases --- 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/libs/ReportUtils.ts | 1 + tests/ui/PureReportActionItemTest.tsx | 111 +++++++++++++++- tests/unit/ReportActionsUtilsTest.ts | 77 +++++++++++ tests/unit/ReportUtilsTest.ts | 176 ++++++++++++++++++++++++++ 13 files changed, 373 insertions(+), 1 deletion(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index 69c482f193f64..5e65c49e0fceb 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1370,6 +1370,7 @@ const translations: TranslationDeepObject = { genericDeleteFailureMessage: 'Unerwarteter Fehler beim Löschen dieser Ausgabe. Bitte versuchen Sie es später erneut.', genericEditFailureMessage: 'Unerwarteter Fehler beim Bearbeiten dieser Ausgabe. Bitte versuchen Sie es später erneut.', genericSmartscanFailureMessage: 'Transaktion fehlt Felder', + genericDEWSubmitFailureMessage: 'Fehler beim dynamischen externen Workflow-Einreichung', duplicateWaypointsErrorMessage: 'Bitte entfernen Sie doppelte Wegpunkte', atLeastTwoDifferentWaypoints: 'Bitte geben Sie mindestens zwei verschiedene Adressen ein.', splitExpenseMultipleParticipantsErrorMessage: diff --git a/src/languages/en.ts b/src/languages/en.ts index bc01c8405c1df..a96cfb7580506 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1350,6 +1350,7 @@ const translations = { genericDeleteFailureMessage: 'Unexpected error deleting this expense. Please try again later.', genericEditFailureMessage: 'Unexpected error editing this expense. Please try again later.', genericSmartscanFailureMessage: 'Transaction is missing fields', + genericDEWSubmitFailureMessage: 'Dynamic External Workflow submission failed', duplicateWaypointsErrorMessage: 'Please remove duplicate waypoints', atLeastTwoDifferentWaypoints: 'Please enter at least two different addresses', splitExpenseMultipleParticipantsErrorMessage: 'An expense cannot be split between a workspace and other members. Please update your selection.', diff --git a/src/languages/es.ts b/src/languages/es.ts index 148560e4093eb..98b83cfdc76b9 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1010,6 +1010,7 @@ const translations: TranslationDeepObject = { genericDeleteFailureMessage: 'Error inesperado al eliminar este gasto. Por favor, inténtalo más tarde.', genericEditFailureMessage: 'Error inesperado al editar este gasto. Por favor, inténtalo más tarde.', genericSmartscanFailureMessage: 'La transacción tiene campos vacíos', + genericDEWSubmitFailureMessage: 'Error al enviar el flujo de trabajo externo dinámico', duplicateWaypointsErrorMessage: 'Por favor, elimina los puntos de ruta duplicados', atLeastTwoDifferentWaypoints: 'Por favor, introduce al menos dos direcciones diferentes', splitExpenseMultipleParticipantsErrorMessage: 'Solo puedes dividir un gasto entre un único espacio de trabajo o con miembros individuales. Por favor, actualiza tu selección.', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 1862d21fcdd67..f98ca54f648cf 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1373,6 +1373,7 @@ const translations: TranslationDeepObject = { genericDeleteFailureMessage: 'Erreur inattendue lors de la suppression de cette dépense. Veuillez réessayer plus tard.', genericEditFailureMessage: 'Erreur inattendue lors de la modification de cette dépense. Veuillez réessayer plus tard.', genericSmartscanFailureMessage: 'La transaction comporte des champs manquants', + genericDEWSubmitFailureMessage: 'Échec de la soumission du flux de travail externe dynamique', duplicateWaypointsErrorMessage: 'Veuillez supprimer les points de passage en double', atLeastTwoDifferentWaypoints: 'Veuillez entrer au moins deux adresses différentes.', splitExpenseMultipleParticipantsErrorMessage: "Une dépense ne peut pas être partagée entre un espace de travail et d'autres membres. Veuillez mettre à jour votre sélection.", diff --git a/src/languages/it.ts b/src/languages/it.ts index 31c696279030c..d3f0919870178 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1367,6 +1367,7 @@ const translations: TranslationDeepObject = { genericDeleteFailureMessage: "Errore imprevisto nell'eliminazione di questa spesa. Per favore riprova più tardi.", genericEditFailureMessage: 'Errore imprevisto durante la modifica di questa spesa. Per favore riprova più tardi.', genericSmartscanFailureMessage: 'La transazione manca di campi', + genericDEWSubmitFailureMessage: 'Invio del flusso di lavoro esterno dinamico non riuscito', duplicateWaypointsErrorMessage: 'Si prega di rimuovere i punti di passaggio duplicati', atLeastTwoDifferentWaypoints: 'Inserisci almeno due indirizzi diversi per favore.', splitExpenseMultipleParticipantsErrorMessage: 'Una spesa non può essere suddivisa tra un workspace e altri membri. Si prega di aggiornare la selezione.', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 60aa8c571f4cb..074afe37a58bb 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1368,6 +1368,7 @@ const translations: TranslationDeepObject = { genericDeleteFailureMessage: 'この経費を削除中に予期しないエラーが発生しました。後でもう一度お試しください。', genericEditFailureMessage: 'この経費の編集中に予期しないエラーが発生しました。後でもう一度お試しください。', genericSmartscanFailureMessage: 'トランザクションにフィールドが欠けています', + genericDEWSubmitFailureMessage: '動的外部ワークフローの送信に失敗しました', duplicateWaypointsErrorMessage: '重複するウェイポイントを削除してください', atLeastTwoDifferentWaypoints: '少なくとも2つの異なる住所を入力してください。', splitExpenseMultipleParticipantsErrorMessage: '経費はワークスペースと他のメンバーの間で分割することはできません。選択を更新してください。', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index c1ca718a531de..03e2c9ca15c1f 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1367,6 +1367,7 @@ const translations: TranslationDeepObject = { genericDeleteFailureMessage: 'Onverwachte fout bij het verwijderen van deze uitgave. Probeer het later opnieuw.', genericEditFailureMessage: 'Onverwachte fout bij het bewerken van deze uitgave. Probeer het later opnieuw.', genericSmartscanFailureMessage: 'Transactie mist velden', + genericDEWSubmitFailureMessage: 'Dynamische externe workflow indienen mislukt', duplicateWaypointsErrorMessage: 'Verwijder dubbele waypoints alstublieft.', atLeastTwoDifferentWaypoints: 'Voer alstublieft ten minste twee verschillende adressen in.', splitExpenseMultipleParticipantsErrorMessage: 'Een uitgave kan niet worden gesplitst tussen een werkruimte en andere leden. Werk uw selectie bij.', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 3de28f15a23fd..9ef59029399df 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1365,6 +1365,7 @@ const translations: TranslationDeepObject = { genericDeleteFailureMessage: 'Nieoczekiwany błąd podczas usuwania tego wydatku. Proszę spróbować ponownie później.', genericEditFailureMessage: 'Nieoczekiwany błąd podczas edytowania tego wydatku. Proszę spróbować ponownie później.', genericSmartscanFailureMessage: 'Transakcja ma brakujące pola', + genericDEWSubmitFailureMessage: 'Przesyłanie dynamicznego zewnętrznego przepływu pracy nie powiodło się', duplicateWaypointsErrorMessage: 'Proszę usunąć zduplikowane punkty trasy', atLeastTwoDifferentWaypoints: 'Proszę wprowadzić co najmniej dwa różne adresy', splitExpenseMultipleParticipantsErrorMessage: 'Wydatek nie może być podzielony między przestrzeń roboczą a innych członków. Proszę zaktualizować swój wybór.', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 08ddd9d30177d..1e2871481687d 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1364,6 +1364,7 @@ const translations: TranslationDeepObject = { genericDeleteFailureMessage: 'Erro inesperado ao excluir esta despesa. Por favor, tente novamente mais tarde.', genericEditFailureMessage: 'Erro inesperado ao editar esta despesa. Por favor, tente novamente mais tarde.', genericSmartscanFailureMessage: 'A transação está com campos faltando', + genericDEWSubmitFailureMessage: 'Falha no envio do fluxo de trabalho externo dinâmico', duplicateWaypointsErrorMessage: 'Por favor, remova os pontos de passagem duplicados.', atLeastTwoDifferentWaypoints: 'Por favor, insira pelo menos dois endereços diferentes.', splitExpenseMultipleParticipantsErrorMessage: 'Uma despesa não pode ser dividida entre um espaço de trabalho e outros membros. Por favor, atualize sua seleção.', diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index b68fb80a69b9b..03ca99e5b12b3 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -241,6 +241,7 @@ import { isRoomChangeLogAction, isSentMoneyReportAction, isSplitBillAction as isSplitBillReportAction, + isSubmittedAction, isTagModificationAction, isThreadParentMessage, isTrackExpenseAction, diff --git a/tests/ui/PureReportActionItemTest.tsx b/tests/ui/PureReportActionItemTest.tsx index e8dba044995ed..5a586f73f0d7d 100644 --- a/tests/ui/PureReportActionItemTest.tsx +++ b/tests/ui/PureReportActionItemTest.tsx @@ -15,7 +15,7 @@ import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import * as ReportActionUtils from '@src/libs/ReportActionsUtils'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {ReportAction} from '@src/types/onyx'; +import type {Policy, ReportAction} from '@src/types/onyx'; import type {OriginalMessage} from '@src/types/onyx/ReportAction'; import type ReportActionName from '@src/types/onyx/ReportActionName'; import {translateLocal} from '../utils/TestHelper'; @@ -207,4 +207,113 @@ describe('PureReportActionItem', () => { expect(screen.getByText(translateLocal('iou.submitted', {}))).toBeOnTheScreen(); }); }); + + describe('DEW (Dynamic External Workflow) actions', () => { + it('SUBMITTED action with pendingAction when policy has DEW enabled', async () => { + const action = createReportAction(CONST.REPORT.ACTIONS.TYPE.SUBMITTED, {harvesting: false}); + action.pendingAction = CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD; + + const dewPolicy = { + id: 'testPolicy', + name: 'Test DEW Policy', + type: CONST.POLICY.TYPE.TEAM, + role: CONST.POLICY.ROLE.ADMIN, + owner: 'owner@test.com', + outputCurrency: CONST.CURRENCY.USD, + isPolicyExpenseChatEnabled: true, + approvalMode: CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL, + } as const; + + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}testPolicy`, dewPolicy); + }); + await waitForBatchedUpdatesWithAct(); + + render( + + + + + + + + + , + ); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByText(actorEmail)).toBeOnTheScreen(); + expect(screen.getByText(translateLocal('iou.queuedToSubmitViaDEW'))).toBeOnTheScreen(); + }); + + it('SUBMITTED action with pendingAction when policy does NOT have DEW enabled', async () => { + const action = createReportAction(CONST.REPORT.ACTIONS.TYPE.SUBMITTED, {harvesting: false}); + action.pendingAction = CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD; + + const basicPolicy = { + id: 'testPolicy', + name: 'Test Basic Policy', + type: CONST.POLICY.TYPE.TEAM, + role: CONST.POLICY.ROLE.ADMIN, + owner: 'owner@test.com', + outputCurrency: CONST.CURRENCY.USD, + isPolicyExpenseChatEnabled: true, + approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC, + } as const; + + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}testPolicy`, basicPolicy); + }); + await waitForBatchedUpdatesWithAct(); + + render( + + + + + + + + + , + ); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByText(actorEmail)).toBeOnTheScreen(); + expect(screen.getByText(translateLocal('iou.submitted', {}))).toBeOnTheScreen(); + expect(screen.queryByText(translateLocal('iou.queuedToSubmitViaDEW'))).not.toBeOnTheScreen(); + }); + }); }); diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts index 3b97883ef3fdf..ff0c0f69ef61c 100644 --- a/tests/unit/ReportActionsUtilsTest.ts +++ b/tests/unit/ReportActionsUtilsTest.ts @@ -1501,4 +1501,81 @@ describe('ReportActionsUtils', () => { expect(ReportActionsUtils.isDeletedAction(action)).toBe(false); }); }); + + describe('isDynamicExternalWorkflowSubmitFailedAction', () => { + it('should return true for DEW_SUBMIT_FAILED action', () => { + const action: ReportAction = { + ...createRandomReportAction(0), + actionName: CONST.REPORT.ACTIONS.TYPE.DEW_SUBMIT_FAILED, + created: '2025-11-21', + reportActionID: '1', + originalMessage: { + message: 'This report contains an Airfare expense that is missing the Flight Destination tag.', + automaticAction: true, + }, + message: [], + previousMessage: [], + }; + expect(ReportActionsUtils.isDynamicExternalWorkflowSubmitFailedAction(action)).toBe(true); + }); + + it('should return false for non-DEW_SUBMIT_FAILED action', () => { + const action: ReportAction = { + ...createRandomReportAction(0), + actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, + created: '2025-11-21', + reportActionID: '1', + originalMessage: { + amount: 10000, + currency: 'USD', + }, + message: [], + previousMessage: [], + }; + expect(ReportActionsUtils.isDynamicExternalWorkflowSubmitFailedAction(action)).toBe(false); + }); + + it('should return false for null action', () => { + expect(ReportActionsUtils.isDynamicExternalWorkflowSubmitFailedAction(null)).toBe(false); + }); + }); + + describe('isDynamicExternalWorkflowApproveFailedAction', () => { + it('should return true for DEW_APPROVE_FAILED action', () => { + const action: ReportAction = { + ...createRandomReportAction(0), + actionName: CONST.REPORT.ACTIONS.TYPE.DEW_APPROVE_FAILED, + created: '2025-11-21', + reportActionID: '1', + originalMessage: { + message: 'Only one Cost Center can be selected per report.', + automaticAction: false, + }, + message: [], + previousMessage: [], + }; + expect(ReportActionsUtils.isDynamicExternalWorkflowApproveFailedAction(action)).toBe(true); + }); + + it('should return false for non-DEW_APPROVE_FAILED action', () => { + const action: ReportAction = { + ...createRandomReportAction(0), + actionName: CONST.REPORT.ACTIONS.TYPE.APPROVED, + created: '2025-11-21', + reportActionID: '1', + originalMessage: { + amount: 10000, + currency: 'USD', + expenseReportID: '123', + }, + message: [], + previousMessage: [], + }; + expect(ReportActionsUtils.isDynamicExternalWorkflowApproveFailedAction(action)).toBe(false); + }); + + it('should return false for null action', () => { + expect(ReportActionsUtils.isDynamicExternalWorkflowApproveFailedAction(null)).toBe(false); + }); + }); }); diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 3d4a79d9e5ea2..f51f0d3daa133 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -9493,4 +9493,180 @@ describe('ReportUtils', () => { await Onyx.clear(); }); + + describe('getAllReportActionsErrorsAndReportActionThatRequiresAttention for DEW', () => { + it('should return error for DEW_SUBMIT_FAILED action on OPEN report', () => { + const report = { + reportID: '1', + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + }; + + const dewSubmitFailedAction = { + ...createRandomReportAction(1), + reportActionID: '1', + actionName: CONST.REPORT.ACTIONS.TYPE.DEW_SUBMIT_FAILED, + created: '2025-11-21 12:00:00', + shouldShow: true, + message: [ + { + type: CONST.REPORT.MESSAGE.TYPE.TEXT, + text: 'DEW submit failed', + }, + ], + originalMessage: { + message: 'This report contains an Airfare expense that is missing the Flight Destination tag.', + }, + }; + + const reportActions = { + '1': dewSubmitFailedAction, + }; + + const {errors, reportAction} = getAllReportActionsErrorsAndReportActionThatRequiresAttention(report, reportActions); + + expect(errors?.dewSubmitFailed).toBeDefined(); + expect(reportAction).toEqual(dewSubmitFailedAction); + }); + + it('should NOT return error for DEW_SUBMIT_FAILED if there is a more recent SUBMITTED action', () => { + const report = { + reportID: '1', + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + }; + + const dewSubmitFailedAction = { + reportActionID: '1', + actionName: CONST.REPORT.ACTIONS.TYPE.DEW_SUBMIT_FAILED, + created: '2025-11-21 12:00:00', + shouldShow: true, + originalMessage: { + message: 'Error message', + }, + }; + + const submittedAction = { + reportActionID: '2', + actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, + created: '2025-11-21 13:00:00', + shouldShow: true, + originalMessage: {}, + }; + + const reportActions = { + '1': dewSubmitFailedAction, + '2': submittedAction, + }; + + const {errors, reportAction} = getAllReportActionsErrorsAndReportActionThatRequiresAttention(report, reportActions); + + expect(errors?.dewSubmitFailed).toBeUndefined(); + }); + + it('should return error for DEW_SUBMIT_FAILED if it is more recent than SUBMITTED action', () => { + const report = { + reportID: '1', + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + }; + + const submittedAction = { + ...createRandomReportAction(1), + reportActionID: '1', + actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, + created: '2025-11-21 12:00:00', + shouldShow: true, + message: [ + { + type: CONST.REPORT.MESSAGE.TYPE.TEXT, + text: 'submitted', + }, + ], + originalMessage: { + amount: 10000, + currency: 'USD', + }, + }; + + const dewSubmitFailedAction = { + ...createRandomReportAction(2), + reportActionID: '2', + actionName: CONST.REPORT.ACTIONS.TYPE.DEW_SUBMIT_FAILED, + created: '2025-11-21 13:00:00', + shouldShow: true, + message: [ + { + type: CONST.REPORT.MESSAGE.TYPE.TEXT, + text: 'DEW submit failed', + }, + ], + originalMessage: { + message: 'Error message', + }, + }; + + const reportActions = { + '1': submittedAction, + '2': dewSubmitFailedAction, + }; + + const {errors, reportAction} = getAllReportActionsErrorsAndReportActionThatRequiresAttention(report, reportActions); + + expect(errors?.dewSubmitFailed).toBeDefined(); + expect(reportAction).toEqual(dewSubmitFailedAction); + }); + + it('should NOT return error for DEW_SUBMIT_FAILED on non-OPEN report', () => { + const report = { + reportID: '1', + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + }; + + const dewSubmitFailedAction = { + reportActionID: '1', + actionName: CONST.REPORT.ACTIONS.TYPE.DEW_SUBMIT_FAILED, + created: '2025-11-21 12:00:00', + shouldShow: true, + originalMessage: { + message: 'Error message', + }, + }; + + const reportActions = { + '1': dewSubmitFailedAction, + }; + + const {errors} = getAllReportActionsErrorsAndReportActionThatRequiresAttention(report, reportActions); + + expect(errors?.dewSubmitFailed).toBeUndefined(); + }); + + it('should NOT return error for DEW_SUBMIT_FAILED on archived report', () => { + const report = { + reportID: '1', + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + }; + + const dewSubmitFailedAction = { + reportActionID: '1', + actionName: CONST.REPORT.ACTIONS.TYPE.DEW_SUBMIT_FAILED, + created: '2025-11-21 12:00:00', + shouldShow: true, + originalMessage: { + message: 'Error message', + }, + }; + + const reportActions = { + '1': dewSubmitFailedAction, + }; + + const {errors} = getAllReportActionsErrorsAndReportActionThatRequiresAttention(report, reportActions, true); + + expect(errors?.dewSubmitFailed).toBeUndefined(); + }); + }); }); From f9adf2140b574195e8d66f10e916c86d87ed9bac Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Fri, 21 Nov 2025 02:34:32 +0100 Subject: [PATCH 09/76] fixing missing translations --- src/languages/es.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/languages/es.ts b/src/languages/es.ts index 98b83cfdc76b9..02bace43bb535 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -937,6 +937,8 @@ const translations: TranslationDeepObject = { expenseAmount: ({formattedAmount, comment}) => `${formattedAmount}${comment ? ` para ${comment}` : ''}`, 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', + dynamicExternalWorkflowCannotSubmit: 'Este informe no se puede enviar. Revise los comentarios para resolver.', trackedAmount: ({formattedAmount, comment}) => `realizó un seguimiento de ${formattedAmount}${comment ? ` para ${comment}` : ''}`, splitAmount: ({amount}) => `dividir ${amount}`, didSplitAmount: ({formattedAmount, comment}) => `dividió ${formattedAmount}${comment ? ` para ${comment}` : ''}`, From cdaf4adc7a503d9112b538d3bff34d0e48dcb6e7 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Fri, 21 Nov 2025 02:44:37 +0100 Subject: [PATCH 10/76] covering missing translations --- src/languages/zh-hans.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index a3e29093de667..b3307bae30fb2 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1346,6 +1346,7 @@ const translations: TranslationDeepObject = { genericDeleteFailureMessage: '删除此费用时出现意外错误。请稍后再试。', genericEditFailureMessage: '编辑此费用时发生意外错误。请稍后再试。', genericSmartscanFailureMessage: '交易缺少字段', + genericDEWSubmitFailureMessage: '动态外部工作流提交失败', duplicateWaypointsErrorMessage: '请删除重复的航点', atLeastTwoDifferentWaypoints: '请输入至少两个不同的地址', splitExpenseMultipleParticipantsErrorMessage: '无法在工作区和其他成员之间拆分费用。请更新您的选择。', From a65eb6a471b029e18cf2786ba38d2b5b9d979356 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Fri, 21 Nov 2025 02:54:40 +0100 Subject: [PATCH 11/76] covering more tests --- 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/ReportUtils.ts | 13 ++++++- tests/unit/ReportUtilsTest.ts | 72 +++++++++++++++++++++++++++++++++++ 12 files changed, 93 insertions(+), 2 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index 5e65c49e0fceb..9e4d133ae9b23 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -7415,6 +7415,7 @@ ${ hasChildReportAwaitingAction: 'Hat einen untergeordneten Bericht, der auf eine Aktion wartet', hasMissingInvoiceBankAccount: 'Fehlendes Rechnungsbankkonto', hasUnresolvedCardFraudAlert: 'Hat eine ungelöste Karten-Fraud-Warnung', + hasDEWError: 'Hat DEW-Fehler', }, reasonRBR: { hasErrors: 'Hat Fehler in den Berichtsdaten oder Berichtsaktionen', diff --git a/src/languages/en.ts b/src/languages/en.ts index a96cfb7580506..5d4f640fa405c 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -7463,6 +7463,7 @@ const translations = { hasChildReportAwaitingAction: 'Has child report awaiting action', hasMissingInvoiceBankAccount: 'Has missing invoice bank account', hasUnresolvedCardFraudAlert: 'Has unresolved card fraud alert', + hasDEWError: 'Has DEW error', }, reasonRBR: { hasErrors: 'Has errors in report or report actions data', diff --git a/src/languages/es.ts b/src/languages/es.ts index 02bace43bb535..45a0a7f8d8519 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -7579,6 +7579,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', + hasDEWError: 'Tiene error de DEW', }, 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 f98ca54f648cf..19618b4804f70 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -7420,6 +7420,7 @@ ${ hasChildReportAwaitingAction: 'Le rapport enfant attend une action', hasMissingInvoiceBankAccount: 'Il manque le compte bancaire de la facture', hasUnresolvedCardFraudAlert: 'A une alerte de fraude de carte non résolue', + hasDEWError: 'A une erreur DEW', }, reasonRBR: { hasErrors: 'Contient des erreurs dans les données du rapport ou des actions du rapport', diff --git a/src/languages/it.ts b/src/languages/it.ts index d3f0919870178..991342d694a1e 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -7398,6 +7398,7 @@ ${ hasChildReportAwaitingAction: 'Ha un rapporto figlio in attesa di azione', hasMissingInvoiceBankAccount: 'Manca il conto bancario della fattura', hasUnresolvedCardFraudAlert: 'Ha una alerta di fraude di carta non risolta', + hasDEWError: 'Ha errore DEW', }, reasonRBR: { hasErrors: 'Ha errori nei dati del report o delle azioni del report', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 074afe37a58bb..054394ea010f6 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -7330,6 +7330,7 @@ ${ hasChildReportAwaitingAction: '子レポートがアクション待ちです。', hasMissingInvoiceBankAccount: '請求書の銀行口座がありません', hasUnresolvedCardFraudAlert: '未解決のカード詐欺警告があります', + hasDEWError: 'DEWエラーがあります', }, reasonRBR: { hasErrors: 'レポートまたはレポートアクションデータにエラーがあります', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 03e2c9ca15c1f..0ffe92fd8474e 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -7378,6 +7378,7 @@ ${ hasChildReportAwaitingAction: 'Heeft kindrapport wachtend op actie', hasMissingInvoiceBankAccount: 'Heeft een ontbrekende factuur bankrekening', hasUnresolvedCardFraudAlert: 'Heeft een onopgeloste kaartfraude waarschuwing', + hasDEWError: 'Heeft DEW-fout', }, reasonRBR: { hasErrors: 'Heeft fouten in rapport of rapportacties gegevens', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 9ef59029399df..9118516e25b3f 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -7366,6 +7366,7 @@ ${ hasChildReportAwaitingAction: 'Raport podrzędny oczekuje na działanie', hasMissingInvoiceBankAccount: 'Brakuje konta bankowego na fakturze', hasUnresolvedCardFraudAlert: 'Ma nierozwiązaną alertę fraudy karty', + hasDEWError: 'Ma błąd DEW', }, reasonRBR: { hasErrors: 'Ma błędy w danych raportu lub działaniach raportu', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 1e2871481687d..49cb894437d34 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -7376,6 +7376,7 @@ ${ hasChildReportAwaitingAction: 'Tem um relatório infantil aguardando ação', hasMissingInvoiceBankAccount: 'Falta a conta bancária da fatura', hasUnresolvedCardFraudAlert: 'Tem uma alerta de fraude de cartão não resolvida', + hasDEWError: 'Tem erro de DEW', }, 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 b3307bae30fb2..4781583b8c9de 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -7217,6 +7217,7 @@ ${ hasChildReportAwaitingAction: '有子报告等待处理', hasMissingInvoiceBankAccount: '缺少发票银行账户', hasUnresolvedCardFraudAlert: '有未解决的卡片欺诈警告', + hasDEWError: '有DEW错误', }, reasonRBR: { hasErrors: '报告或报告操作数据中有错误', diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 03ca99e5b12b3..26626d1036559 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -9025,8 +9025,17 @@ function getAllReportActionsErrorsAndReportActionThatRequiresAttention( if (!isReportArchived && report?.statusNum === CONST.REPORT.STATUS_NUM.OPEN) { const dewSubmitFailedAction = reportActionsArray.find((action) => isDynamicExternalWorkflowSubmitFailedAction(action)); if (dewSubmitFailedAction) { - const submittedAction = reportActionsArray.find((action) => isSubmittedAction(action)); - const shouldShowDEWError = !submittedAction || (submittedAction && dewSubmitFailedAction.created > submittedAction.created); + // find the most recent SUBMITTED action + const mostRecentSubmittedAction = reportActionsArray + .filter((action) => isSubmittedAction(action)) + .reduce((latest, current) => { + if (!latest || (current.created && latest.created && current.created > latest.created)) { + return current; + } + return latest; + }, undefined); + + const shouldShowDEWError = !mostRecentSubmittedAction || (mostRecentSubmittedAction.created && dewSubmitFailedAction.created > mostRecentSubmittedAction.created); if (shouldShowDEWError) { reportActionErrors.dewSubmitFailed = getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericDEWSubmitFailureMessage'); diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index f51f0d3daa133..94b74fad75615 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -9668,5 +9668,77 @@ describe('ReportUtils', () => { expect(errors?.dewSubmitFailed).toBeUndefined(); }); + + it('should clear DEW error when a more recent SUBMITTED action exists after the failure (multiple submits)', () => { + const report = { + reportID: '1', + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + }; + + const firstSubmittedAction = { + ...createRandomReportAction(1), + reportActionID: '1', + actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, + created: '2025-11-21 10:00:00', + shouldShow: true, + message: [ + { + type: CONST.REPORT.MESSAGE.TYPE.TEXT, + text: 'first submit', + }, + ], + originalMessage: { + amount: 10000, + currency: 'USD', + }, + }; + + const dewSubmitFailedAction = { + ...createRandomReportAction(2), + reportActionID: '2', + actionName: CONST.REPORT.ACTIONS.TYPE.DEW_SUBMIT_FAILED, + created: '2025-11-21 10:05:00', + shouldShow: true, + message: [ + { + type: CONST.REPORT.MESSAGE.TYPE.TEXT, + text: 'DEW submit failed', + }, + ], + originalMessage: { + message: 'This report contains an Airfare expense that is missing the Flight Destination tag.', + }, + }; + + const secondSubmittedAction = { + ...createRandomReportAction(3), + reportActionID: '3', + actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, + created: '2025-11-21 10:10:00', + shouldShow: true, + message: [ + { + type: CONST.REPORT.MESSAGE.TYPE.TEXT, + text: 'second submit', + }, + ], + originalMessage: { + amount: 10000, + currency: 'USD', + }, + }; + + const reportActions = { + '1': firstSubmittedAction, + '2': dewSubmitFailedAction, + '3': secondSubmittedAction, + }; + + const {errors, reportAction} = getAllReportActionsErrorsAndReportActionThatRequiresAttention(report, reportActions); + + expect(errors?.dewSubmitFailed).toBeUndefined(); + expect(reportAction).not.toEqual(dewSubmitFailedAction); + }); }); }); From 23ee824519623568b3e074cb3d289da158ebdea7 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Fri, 21 Nov 2025 04:35:49 +0100 Subject: [PATCH 12/76] fixing ts --- src/types/onyx/OriginalMessage.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts index 7485e78b3e9ad..d5e78888d10f0 100644 --- a/src/types/onyx/OriginalMessage.ts +++ b/src/types/onyx/OriginalMessage.ts @@ -941,6 +941,17 @@ type OriginalMessageIntegrationSyncFailed = { errorMessage: string; }; +/** + * Original message for DEW_SUBMIT_FAILED and DEW_APPROVE_FAILED actions + */ +type OriginalMessageDEWFailed = { + /** The error message */ + message: string; + + /** Whether the action was automatic */ + automaticAction?: boolean; +}; + /** * Model of CARD_ISSUED, CARD_MISSING_ADDRESS, CARD_ISSUED_VIRTUAL actions */ @@ -1088,6 +1099,8 @@ type OriginalMessageMap = { [CONST.REPORT.ACTIONS.TYPE.CARD_ASSIGNED]: OriginalMessageCard; [CONST.REPORT.ACTIONS.TYPE.INTEGRATION_SYNC_FAILED]: OriginalMessageIntegrationSyncFailed; [CONST.REPORT.ACTIONS.TYPE.DELETED_TRANSACTION]: OriginalMessageDeletedTransaction; + [CONST.REPORT.ACTIONS.TYPE.DEW_APPROVE_FAILED]: OriginalMessageDEWFailed; + [CONST.REPORT.ACTIONS.TYPE.DEW_SUBMIT_FAILED]: OriginalMessageDEWFailed; [CONST.REPORT.ACTIONS.TYPE.CONCIERGE_CATEGORY_OPTIONS]: OriginalMessageConciergeCategoryOptions; [CONST.REPORT.ACTIONS.TYPE.CONCIERGE_AUTO_MAP_MCC_GROUPS]: OriginalMessageConciergeAutoMapMccGroups; [CONST.REPORT.ACTIONS.TYPE.RETRACTED]: never; From 8ba5fcff95557875f81ae9a5a230068d8bfff38f Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Fri, 21 Nov 2025 05:18:23 +0100 Subject: [PATCH 13/76] fixing eslint --- src/libs/actions/IOU.ts | 4 ++-- src/pages/Search/SearchPage.tsx | 1 + src/pages/home/report/PureReportActionItem.tsx | 6 +++--- tests/unit/ReportUtilsTest.ts | 6 +++--- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 6d43aea7ced54..7f5d0d56e064a 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -11062,10 +11062,10 @@ function submitReport( const isDEWPolicy = hasDynamicExternalWorkflow(policy); // buildOptimisticNextStep is used in parallel - // eslint-disable-next-line @typescript-eslint/no-deprecated const optimisticNextStepDeprecated = isDEWPolicy ? null - : buildNextStepNew({ + : // eslint-disable-next-line @typescript-eslint/no-deprecated + buildNextStepNew({ report: expenseReport, predictedNextStatus: isSubmitAndClosePolicy ? CONST.REPORT.STATUS_NUM.CLOSED : CONST.REPORT.STATUS_NUM.SUBMITTED, policy, diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 1340ce3666cc3..9cf4b4149a7f8 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -622,6 +622,7 @@ function SearchPage({route}: SearchPageProps) { lastPaymentMethods, theme.icon, styles.colorMuted, + isDEWSubmitBetaEnabled, styles.fontWeightNormal, styles.textWrap, beginExportWithTemplate, diff --git a/src/pages/home/report/PureReportActionItem.tsx b/src/pages/home/report/PureReportActionItem.tsx index 745a75b64d26d..3701c8c3e9533 100644 --- a/src/pages/home/report/PureReportActionItem.tsx +++ b/src/pages/home/report/PureReportActionItem.tsx @@ -14,7 +14,6 @@ import DisplayNames from '@components/DisplayNames'; import Hoverable from '@components/Hoverable'; import MentionReportContext from '@components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/MentionReportContext'; import Icon from '@components/Icon'; -import {Eye} from '@components/Icon/Expensicons'; import InlineSystemMessage from '@components/InlineSystemMessage'; import KYCWall from '@components/KYCWall'; import {KYCWallContext} from '@components/KYCWall/KYCWallContext'; @@ -40,6 +39,7 @@ import Text from '@components/Text'; import TextLink from '@components/TextLink'; import UnreadActionIndicator from '@components/UnreadActionIndicator'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import usePreferredPolicy from '@hooks/usePreferredPolicy'; import usePrevious from '@hooks/usePrevious'; @@ -470,6 +470,7 @@ function PureReportActionItem({ const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['Eye'] as const); const [isContextMenuActive, setIsContextMenuActive] = useState(() => isActiveReportAction(action.reportActionID)); const [isEmojiPickerActive, setIsEmojiPickerActive] = useState(); const [isPaymentMethodPopoverActive, setIsPaymentMethodPopoverActive] = useState(); @@ -947,7 +948,6 @@ function PureReportActionItem({ translate, resolveActionableReportMentionWhisper, isReportArchived, - formatPhoneNumber, isOriginalReportArchived, resolveActionableMentionWhisper, introSelected, @@ -1758,7 +1758,7 @@ function PureReportActionItem({ diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 94b74fad75615..e52f37b614be0 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -1663,7 +1663,7 @@ describe('ReportUtils', () => { }, }; - const transaction: SearchTransaction = { + const transaction: Transaction = { transactionID: 'txn1', reportID: '2', amount: 1000, @@ -1671,7 +1671,7 @@ describe('ReportUtils', () => { merchant: 'Test Merchant', created: testDate, modifiedMerchant: 'Test Merchant', - } as SearchTransaction; + } as Transaction; const reportName = getSearchReportName({ report: baseExpenseReport, @@ -9559,7 +9559,7 @@ describe('ReportUtils', () => { '2': submittedAction, }; - const {errors, reportAction} = getAllReportActionsErrorsAndReportActionThatRequiresAttention(report, reportActions); + const {errors} = getAllReportActionsErrorsAndReportActionThatRequiresAttention(report, reportActions); expect(errors?.dewSubmitFailed).toBeUndefined(); }); From 96338b94a6d0f41f2e8ffdf0a92408d08d53801e Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Fri, 21 Nov 2025 05:22:39 +0100 Subject: [PATCH 14/76] minor edit --- tests/unit/ReportUtilsTest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index e52f37b614be0..82eeb1d41acee 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -1671,7 +1671,7 @@ describe('ReportUtils', () => { merchant: 'Test Merchant', created: testDate, modifiedMerchant: 'Test Merchant', - } as Transaction; + }; const reportName = getSearchReportName({ report: baseExpenseReport, From 2bf0e96255e51d902d4820cbbac44c9abbcf9e1f Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Fri, 21 Nov 2025 12:58:54 +0100 Subject: [PATCH 15/76] fixing tests --- tests/unit/ReportUtilsTest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 82eeb1d41acee..e91259e0b1a87 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -131,7 +131,7 @@ import type {ErrorFields, Errors, OnyxValueWithOfflineFeedback} from '@src/types import type {JoinWorkspaceResolution} from '@src/types/onyx/OriginalMessage'; import type {ACHAccount} from '@src/types/onyx/Policy'; import type {Participant, Participants} from '@src/types/onyx/Report'; -import type {SearchReport, SearchTransaction} from '@src/types/onyx/SearchResults'; +import type {SearchReport} from '@src/types/onyx/SearchResults'; import {toCollectionDataSet} from '@src/types/utils/CollectionDataSet'; import {actionR14932 as mockIOUAction} from '../../__mocks__/reportData/actions'; import {chatReportR14932 as mockedChatReport, iouReportR14932 as mockIOUReport} from '../../__mocks__/reportData/reports'; From 62a09533184863a267d882001bcffb4f3f7680b0 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Fri, 21 Nov 2025 13:05:00 +0100 Subject: [PATCH 16/76] minor edit --- src/libs/ReportUtils.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index b0d817b0ec815..6c06b11ad3430 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -219,7 +219,6 @@ import { isCurrentActionUnread, isDeletedAction, isDeletedParentAction, - isDynamicExternalWorkflowApproveFailedAction, isDynamicExternalWorkflowSubmitFailedAction, isExportIntegrationAction, isIntegrationMessageAction, From 1ae07fffc0314eb71bb1f4f1794f4ffe98e388f8 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Fri, 21 Nov 2025 13:38:32 +0100 Subject: [PATCH 17/76] covering iou tests --- tests/actions/IOUTest.ts | 313 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 313 insertions(+) diff --git a/tests/actions/IOUTest.ts b/tests/actions/IOUTest.ts index 380f6fcfc9eb4..a1e43dd5f070d 100644 --- a/tests/actions/IOUTest.ts +++ b/tests/actions/IOUTest.ts @@ -5465,6 +5465,319 @@ describe('actions/IOU', () => { }), ); }); + + it('correctly submits a report with Dynamic External Workflow policy without setting nextStep', () => { + const amount = 10000; + const comment = '💸💸💸💸'; + const merchant = 'NASDAQ'; + let expenseReport: OnyxEntry; + let chatReport: OnyxEntry; + let policy: OnyxEntry; + let policyID: string; + + return waitForBatchedUpdates() + .then(() => { + policyID = generatePolicyID(); + createWorkspace({ + policyOwnerEmail: CARLOS_EMAIL, + makeMeAdmin: true, + policyName: 'Test Workspace with Dynamic External Workflow', + policyID, + }); + return waitForBatchedUpdates(); + }) + .then(() => { + setWorkspaceApprovalMode(policyID, CARLOS_EMAIL, CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL); + return waitForBatchedUpdates(); + }) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.POLICY, + waitForCollectionCallback: true, + callback: (allPolicies) => { + Onyx.disconnect(connection); + policy = Object.values(allPolicies ?? {}).find((p): p is OnyxEntry => p?.id === policyID); + expect(policy).toBeTruthy(); + expect(policy?.approvalMode).toBe(CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL); + resolve(); + }, + }); + }), + ) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + Onyx.disconnect(connection); + chatReport = Object.values(allReports ?? {}).find( + (report) => report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT && report.policyID === policyID, + ); + resolve(); + }, + }); + }), + ) + .then(() => { + if (chatReport) { + requestMoney({ + report: chatReport, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID, isPolicyExpenseChat: true, reportID: chatReport.reportID}, + }, + transactionParams: { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant, + comment, + reimbursable: true, + }, + shouldGenerateTransactionThreadReport: true, + isASAPSubmitBetaEnabled: false, + }); + } + return waitForBatchedUpdates(); + }) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + Onyx.disconnect(connection); + expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.EXPENSE && report?.policyID === policyID); + Onyx.merge(`report_${expenseReport?.reportID}`, { + statusNum: 0, + stateNum: 0, + }); + resolve(); + }, + }); + }), + ) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + Onyx.disconnect(connection); + expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.EXPENSE && report?.policyID === policyID); + + expect(expenseReport?.stateNum).toBe(0); + expect(expenseReport?.statusNum).toBe(0); + resolve(); + }, + }); + }), + ) + .then(() => { + if (expenseReport) { + submitReport(expenseReport, policy, CARLOS_ACCOUNT_ID, CARLOS_EMAIL, true, true); + } + return waitForBatchedUpdates(); + }) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + Onyx.disconnect(connection); + expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.EXPENSE && report?.policyID === policyID); + + expect(expenseReport?.stateNum).toBe(1); + expect(expenseReport?.statusNum).toBe(1); + expect(expenseReport?.nextStep).toBeUndefined(); + expect(expenseReport?.pendingFields?.nextStep).toBeUndefined(); + + resolve(); + }, + }); + }), + ) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport?.reportID}`, + callback: (nextStep) => { + Onyx.disconnect(connection); + expect(nextStep).toBeUndefined(); + resolve(); + }, + }); + }), + ); + }); + + it('correctly submits a report with Dynamic External Workflow policy in Submit and Close mode without setting nextStep', () => { + const amount = 10000; + const comment = '💸💸💸💸'; + const merchant = 'NASDAQ'; + let expenseReport: OnyxEntry; + let chatReport: OnyxEntry; + let policy: OnyxEntry; + let policyID: string; + + return waitForBatchedUpdates() + .then(() => { + policyID = generatePolicyID(); + createWorkspace({ + policyOwnerEmail: CARLOS_EMAIL, + makeMeAdmin: true, + policyName: 'Test Workspace with Dynamic External Workflow and Submit and Close', + policyID, + engagementChoice: CONST.ONBOARDING_CHOICES.CHAT_SPLIT, + }); + return waitForBatchedUpdates(); + }) + .then(() => { + setWorkspaceApprovalMode(policyID, CARLOS_EMAIL, CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL); + return waitForBatchedUpdates(); + }) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.POLICY, + waitForCollectionCallback: true, + callback: (allPolicies) => { + Onyx.disconnect(connection); + policy = Object.values(allPolicies ?? {}).find((p): p is OnyxEntry => p?.id === policyID); + expect(policy).toBeTruthy(); + expect(policy?.approvalMode).toBe(CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL); + resolve(); + }, + }); + }), + ) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + Onyx.disconnect(connection); + chatReport = Object.values(allReports ?? {}).find( + (report) => report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT && report.policyID === policyID, + ); + resolve(); + }, + }); + }), + ) + .then(() => { + if (chatReport) { + requestMoney({ + report: chatReport, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID, isPolicyExpenseChat: true, reportID: chatReport.reportID}, + }, + transactionParams: { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant, + comment, + reimbursable: true, + }, + shouldGenerateTransactionThreadReport: true, + isASAPSubmitBetaEnabled: false, + }); + } + return waitForBatchedUpdates(); + }) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + Onyx.disconnect(connection); + expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.EXPENSE && report?.policyID === policyID); + Onyx.merge(`report_${expenseReport?.reportID}`, { + statusNum: 0, + stateNum: 0, + }); + resolve(); + }, + }); + }), + ) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + Onyx.disconnect(connection); + expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.EXPENSE && report?.policyID === policyID); + + expect(expenseReport?.stateNum).toBe(0); + expect(expenseReport?.statusNum).toBe(0); + resolve(); + }, + }); + }), + ) + .then(() => { + if (expenseReport) { + submitReport(expenseReport, policy, CARLOS_ACCOUNT_ID, CARLOS_EMAIL, true, true); + } + return waitForBatchedUpdates(); + }) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + Onyx.disconnect(connection); + expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.EXPENSE && report?.policyID === policyID); + + expect(expenseReport?.stateNum).toBe(2); + expect(expenseReport?.statusNum).toBe(2); + expect(expenseReport?.nextStep).toBeUndefined(); + expect(expenseReport?.pendingFields?.nextStep).toBeUndefined(); + + resolve(); + }, + }); + }), + ) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport?.reportID}`, + callback: (nextStep) => { + Onyx.disconnect(connection); + expect(nextStep).toBeUndefined(); + resolve(); + }, + }); + }), + ); + }); }); describe('resolveDuplicate', () => { From b2f5a3475e95f0509c363ffbbc67a7f42848feed Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Fri, 21 Nov 2025 13:58:13 +0100 Subject: [PATCH 18/76] fixing tests --- tests/actions/IOUTest.ts | 170 --------------------------------------- 1 file changed, 170 deletions(-) diff --git a/tests/actions/IOUTest.ts b/tests/actions/IOUTest.ts index a1e43dd5f070d..e2b2fc33ffdeb 100644 --- a/tests/actions/IOUTest.ts +++ b/tests/actions/IOUTest.ts @@ -5602,176 +5602,6 @@ describe('actions/IOU', () => { expect(expenseReport?.nextStep).toBeUndefined(); expect(expenseReport?.pendingFields?.nextStep).toBeUndefined(); - resolve(); - }, - }); - }), - ) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport?.reportID}`, - callback: (nextStep) => { - Onyx.disconnect(connection); - expect(nextStep).toBeUndefined(); - resolve(); - }, - }); - }), - ); - }); - - it('correctly submits a report with Dynamic External Workflow policy in Submit and Close mode without setting nextStep', () => { - const amount = 10000; - const comment = '💸💸💸💸'; - const merchant = 'NASDAQ'; - let expenseReport: OnyxEntry; - let chatReport: OnyxEntry; - let policy: OnyxEntry; - let policyID: string; - - return waitForBatchedUpdates() - .then(() => { - policyID = generatePolicyID(); - createWorkspace({ - policyOwnerEmail: CARLOS_EMAIL, - makeMeAdmin: true, - policyName: 'Test Workspace with Dynamic External Workflow and Submit and Close', - policyID, - engagementChoice: CONST.ONBOARDING_CHOICES.CHAT_SPLIT, - }); - return waitForBatchedUpdates(); - }) - .then(() => { - setWorkspaceApprovalMode(policyID, CARLOS_EMAIL, CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL); - return waitForBatchedUpdates(); - }) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.POLICY, - waitForCollectionCallback: true, - callback: (allPolicies) => { - Onyx.disconnect(connection); - policy = Object.values(allPolicies ?? {}).find((p): p is OnyxEntry => p?.id === policyID); - expect(policy).toBeTruthy(); - expect(policy?.approvalMode).toBe(CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL); - resolve(); - }, - }); - }), - ) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - Onyx.disconnect(connection); - chatReport = Object.values(allReports ?? {}).find( - (report) => report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT && report.policyID === policyID, - ); - resolve(); - }, - }); - }), - ) - .then(() => { - if (chatReport) { - requestMoney({ - report: chatReport, - participantParams: { - payeeEmail: RORY_EMAIL, - payeeAccountID: RORY_ACCOUNT_ID, - participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID, isPolicyExpenseChat: true, reportID: chatReport.reportID}, - }, - transactionParams: { - amount, - attendees: [], - currency: CONST.CURRENCY.USD, - created: '', - merchant, - comment, - reimbursable: true, - }, - shouldGenerateTransactionThreadReport: true, - isASAPSubmitBetaEnabled: false, - }); - } - return waitForBatchedUpdates(); - }) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - Onyx.disconnect(connection); - expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.EXPENSE && report?.policyID === policyID); - Onyx.merge(`report_${expenseReport?.reportID}`, { - statusNum: 0, - stateNum: 0, - }); - resolve(); - }, - }); - }), - ) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - Onyx.disconnect(connection); - expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.EXPENSE && report?.policyID === policyID); - - expect(expenseReport?.stateNum).toBe(0); - expect(expenseReport?.statusNum).toBe(0); - resolve(); - }, - }); - }), - ) - .then(() => { - if (expenseReport) { - submitReport(expenseReport, policy, CARLOS_ACCOUNT_ID, CARLOS_EMAIL, true, true); - } - return waitForBatchedUpdates(); - }) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - Onyx.disconnect(connection); - expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.EXPENSE && report?.policyID === policyID); - - expect(expenseReport?.stateNum).toBe(2); - expect(expenseReport?.statusNum).toBe(2); - expect(expenseReport?.nextStep).toBeUndefined(); - expect(expenseReport?.pendingFields?.nextStep).toBeUndefined(); - - resolve(); - }, - }); - }), - ) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport?.reportID}`, - callback: (nextStep) => { - Onyx.disconnect(connection); - expect(nextStep).toBeUndefined(); resolve(); }, }); From 9f4e55514119adeff0a0b2976f021f4eee21e2ad Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Fri, 21 Nov 2025 14:48:04 +0100 Subject: [PATCH 19/76] fixing es translation --- src/languages/es.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/languages/es.ts b/src/languages/es.ts index b61ad1c6aed01..4f44f6f9da5ba 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -938,7 +938,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', - dynamicExternalWorkflowCannotSubmit: 'Este informe no se puede enviar. Revise los comentarios para resolver.', + dynamicExternalWorkflowCannotSubmit: 'Este informe no se puede enviar. Revise los comentarios para solucionarlo.', trackedAmount: ({formattedAmount, comment}) => `realizó un seguimiento de ${formattedAmount}${comment ? ` para ${comment}` : ''}`, splitAmount: ({amount}) => `dividir ${amount}`, didSplitAmount: ({formattedAmount, comment}) => `dividió ${formattedAmount}${comment ? ` para ${comment}` : ''}`, From 5201219b8c146f6a829ad6ac7517ebf366523f02 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Mon, 24 Nov 2025 20:43:54 +0100 Subject: [PATCH 20/76] add isDEWBetaEnabled check in all other places that use the block DEW submit modal --- src/components/Search/SearchList/index.tsx | 6 ++++++ src/components/Search/index.tsx | 1 + .../Search/ExpenseReportListItem.tsx | 4 +++- .../Search/ReportListItemHeader.tsx | 5 +++++ .../Search/TransactionGroupListItem.tsx | 2 ++ .../Search/TransactionListItem.tsx | 16 +++++++++++++++- .../SelectionListWithSections/types.ts | 7 +++++++ src/libs/actions/Search.ts | 3 ++- 8 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/components/Search/SearchList/index.tsx b/src/components/Search/SearchList/index.tsx index 92b345d4ff044..7a31b7ab8fc51 100644 --- a/src/components/Search/SearchList/index.tsx +++ b/src/components/Search/SearchList/index.tsx @@ -118,6 +118,9 @@ type SearchListProps = Pick, 'onScroll' | 'conten /** Callback to fire when DEW modal should be opened */ onDEWModalOpen?: () => void; + /** Whether the DEW beta flag is enabled */ + isDEWBetaEnabled?: boolean; + /** Reference to the outer element */ ref?: ForwardedRef; }; @@ -169,6 +172,7 @@ function SearchList({ newTransactions = [], violations, onDEWModalOpen, + isDEWBetaEnabled, ref, }: SearchListProps) { const styles = useThemeStyles(); @@ -327,6 +331,7 @@ function SearchList({ groupBy={groupBy} searchType={type} onDEWModalOpen={onDEWModalOpen} + isDEWBetaEnabled={isDEWBetaEnabled} userWalletTierName={userWalletTierName} isUserValidated={isUserValidated} personalDetails={personalDetails} @@ -368,6 +373,7 @@ function SearchList({ areAllOptionalColumnsHidden, violations, onDEWModalOpen, + isDEWBetaEnabled, ], ); diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 32c680d788e9c..ed0d3aac7d72f 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -976,6 +976,7 @@ function Search({ shouldPreventLongPressRow={isChat || isTask} isFocused={isFocused} onDEWModalOpen={handleDEWModalOpen} + isDEWBetaEnabled={isDEWSubmitBetaEnabled} SearchTableHeader={ !shouldShowTableHeader ? undefined : ( diff --git a/src/components/SelectionListWithSections/Search/ExpenseReportListItem.tsx b/src/components/SelectionListWithSections/Search/ExpenseReportListItem.tsx index 8e5886d9ccdc1..1333ca37be6b7 100644 --- a/src/components/SelectionListWithSections/Search/ExpenseReportListItem.tsx +++ b/src/components/SelectionListWithSections/Search/ExpenseReportListItem.tsx @@ -27,6 +27,7 @@ function ExpenseReportListItem({ shouldSyncFocus, onCheckboxPress, onDEWModalOpen, + isDEWBetaEnabled, }: ExpenseReportListItemProps) { const reportItem = item as unknown as ExpenseReportListItemType; const styles = useThemeStyles(); @@ -64,8 +65,9 @@ function ExpenseReportListItem({ lastPaymentMethod, currentSearchKey, onDEWModalOpen, + isDEWBetaEnabled, ); - }, [currentSearchHash, reportItem, onSelectRow, snapshotReport, snapshotPolicy, lastPaymentMethod, currentSearchKey, onDEWModalOpen]); + }, [currentSearchHash, reportItem, onSelectRow, snapshotReport, snapshotPolicy, lastPaymentMethod, currentSearchKey, onDEWModalOpen, isDEWBetaEnabled]); const handleCheckboxPress = useCallback(() => { onCheckboxPress?.(reportItem as unknown as TItem); diff --git a/src/components/SelectionListWithSections/Search/ReportListItemHeader.tsx b/src/components/SelectionListWithSections/Search/ReportListItemHeader.tsx index 2307830ec3a90..d36c7a15c5dd3 100644 --- a/src/components/SelectionListWithSections/Search/ReportListItemHeader.tsx +++ b/src/components/SelectionListWithSections/Search/ReportListItemHeader.tsx @@ -59,6 +59,9 @@ type ReportListItemHeaderProps = { /** Callback to fire when DEW modal should be opened */ onDEWModalOpen?: () => void; + + /** Whether the DEW beta flag is enabled */ + isDEWBetaEnabled?: boolean; }; type FirstRowReportHeaderProps = { @@ -208,6 +211,7 @@ function ReportListItemHeader({ isExpanded, isHovered, onDEWModalOpen, + isDEWBetaEnabled, }: ReportListItemHeaderProps) { const StyleUtils = useStyleUtils(); const styles = useThemeStyles(); @@ -239,6 +243,7 @@ function ReportListItemHeader({ lastPaymentMethod, currentSearchKey, onDEWModalOpen, + isDEWBetaEnabled, ); }; return !isLargeScreenWidth ? ( diff --git a/src/components/SelectionListWithSections/Search/TransactionGroupListItem.tsx b/src/components/SelectionListWithSections/Search/TransactionGroupListItem.tsx index 318f8764bf190..12541093c7393 100644 --- a/src/components/SelectionListWithSections/Search/TransactionGroupListItem.tsx +++ b/src/components/SelectionListWithSections/Search/TransactionGroupListItem.tsx @@ -63,6 +63,7 @@ function TransactionGroupListItem({ newTransactionID, violations, onDEWModalOpen, + isDEWBetaEnabled, }: TransactionGroupListItemProps) { const groupItem = item as unknown as TransactionGroupListItemType; const theme = useTheme(); @@ -267,6 +268,7 @@ function TransactionGroupListItem({ isIndeterminate={isIndeterminate} isHovered={hovered} onDEWModalOpen={onDEWModalOpen} + isDEWBetaEnabled={isDEWBetaEnabled} onDownArrowClick={onExpandIconPress} isExpanded={isExpanded} /> diff --git a/src/components/SelectionListWithSections/Search/TransactionListItem.tsx b/src/components/SelectionListWithSections/Search/TransactionListItem.tsx index 159c83b73522f..14e26258a78e6 100644 --- a/src/components/SelectionListWithSections/Search/TransactionListItem.tsx +++ b/src/components/SelectionListWithSections/Search/TransactionListItem.tsx @@ -46,6 +46,7 @@ function TransactionListItem({ areAllOptionalColumnsHidden, violations, onDEWModalOpen, + isDEWBetaEnabled, }: TransactionListItemProps) { const transactionItem = item as unknown as TransactionListItemType; const styles = useThemeStyles(); @@ -122,8 +123,21 @@ function TransactionListItem({ lastPaymentMethod, currentSearchKey, onDEWModalOpen, + isDEWBetaEnabled, ); - }, [currentSearchHash, transactionItem, transactionPreviewData, snapshotReport, snapshotPolicy, lastPaymentMethod, currentSearchKey, onSelectRow, item, onDEWModalOpen]); + }, [ + currentSearchHash, + transactionItem, + transactionPreviewData, + snapshotReport, + snapshotPolicy, + lastPaymentMethod, + currentSearchKey, + onSelectRow, + item, + onDEWModalOpen, + isDEWBetaEnabled, + ]); const handleCheckboxPress = useCallback(() => { onCheckboxPress?.(item); diff --git a/src/components/SelectionListWithSections/types.ts b/src/components/SelectionListWithSections/types.ts index 9d373044b83bb..0aa2bedae7be7 100644 --- a/src/components/SelectionListWithSections/types.ts +++ b/src/components/SelectionListWithSections/types.ts @@ -552,6 +552,8 @@ type TransactionListItemProps = ListItemProps & { violations?: Record | undefined; /** Callback to fire when DEW modal should be opened */ onDEWModalOpen?: () => void; + /** Whether the DEW beta flag is enabled */ + isDEWBetaEnabled?: boolean; }; type TaskListItemProps = ListItemProps & { @@ -571,6 +573,9 @@ type ExpenseReportListItemProps = ListItemProps & /** Callback to fire when DEW modal should be opened */ onDEWModalOpen?: () => void; + + /** Whether the DEW beta flag is enabled */ + isDEWBetaEnabled?: boolean; }; type TransactionGroupListItemProps = ListItemProps & { @@ -584,6 +589,8 @@ type TransactionGroupListItemProps = ListItemProps | undefined; /** Callback to fire when DEW modal should be opened */ onDEWModalOpen?: () => void; + /** Whether the DEW beta flag is enabled */ + isDEWBetaEnabled?: boolean; }; type TransactionGroupListExpandedProps = Pick< diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 99c66ed98754c..364f9aae1af1d 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -77,6 +77,7 @@ function handleActionButtonPress( lastPaymentMethod: OnyxEntry, currentSearchKey?: SearchKey, onDEWModalOpen?: () => void, + isDEWBetaEnabled?: boolean, ) { // The transactionIDList is needed to handle actions taken on `status:""` where transactions on single expense reports can be approved/paid. // We need the transactionID to display the loading indicator for that list item's action. @@ -101,7 +102,7 @@ function handleActionButtonPress( approveMoneyRequestOnSearch(hash, [item.reportID], currentSearchKey); return; case CONST.SEARCH.ACTION_TYPES.SUBMIT: { - if (hasDynamicExternalWorkflow(snapshotPolicy)) { + if (hasDynamicExternalWorkflow(snapshotPolicy) && !isDEWBetaEnabled) { onDEWModalOpen?.(); return; } From 0ed1497529f979efc2c8537a666181fcfc436ceb Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Mon, 24 Nov 2025 20:49:35 +0100 Subject: [PATCH 21/76] cleaning handleDEWModalOpen --- src/components/Search/index.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index ed0d3aac7d72f..244d2bc73257a 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -231,15 +231,12 @@ function Search({ const isDEWSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.NEW_DOT_DEW); const handleDEWModalOpen = useCallback(() => { - if (isDEWSubmitBetaEnabled) { - return; - } if (onDEWModalOpen) { onDEWModalOpen(); } else { setIsDEWModalVisible(true); } - }, [onDEWModalOpen, isDEWSubmitBetaEnabled]); + }, [onDEWModalOpen]); // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout for enabling the selection mode on small screens only // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth, isLargeScreenWidth} = useResponsiveLayout(); From 8a3c570e386397b7768b9baf9d1a523a4ab89fd5 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Mon, 24 Nov 2025 20:52:02 +0100 Subject: [PATCH 22/76] minor: renaming isDEWBetaEnabled flag --- src/components/MoneyReportHeader.tsx | 8 ++++---- .../MoneyRequestReportPreviewContent.tsx | 6 +++--- src/components/Search/index.tsx | 4 ++-- src/pages/Search/SearchPage.tsx | 6 +++--- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index a16ad546045ef..27bc3ccc5f863 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -292,7 +292,7 @@ function MoneyReportHeader({ const [allTransactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, {canBeMissing: true}); const {isBetaEnabled} = usePermissions(); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); - const isDEWSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.NEW_DOT_DEW); + const isDEWBetaEnabled = isBetaEnabled(CONST.BETAS.NEW_DOT_DEW); const hasViolations = hasViolationsReportUtils(moneyRequestReport?.reportID, allTransactionViolations); const [exportModalStatus, setExportModalStatus] = useState(null); @@ -561,7 +561,7 @@ function MoneyReportHeader({ return {icon: getStatusIcon(expensifyIcons.Hourglass), description: translate('iou.reject.rejectedStatus')}; } - if (isDEWSubmitBetaEnabled && hasDynamicExternalWorkflow(policy) && moneyRequestReport?.statusNum === CONST.REPORT.STATUS_NUM.OPEN) { + if (isDEWBetaEnabled && hasDynamicExternalWorkflow(policy) && moneyRequestReport?.statusNum === CONST.REPORT.STATUS_NUM.OPEN) { const reportActionsObject = reportActions.reduce((acc, action) => { if (action.reportActionID) { acc[action.reportActionID] = action; @@ -790,7 +790,7 @@ function MoneyReportHeader({ if (!moneyRequestReport || shouldBlockSubmit) { return; } - if (hasDynamicExternalWorkflow(policy) && !isDEWSubmitBetaEnabled) { + if (hasDynamicExternalWorkflow(policy) && !isDEWBetaEnabled) { showDWEModal(); return; } @@ -1006,7 +1006,7 @@ function MoneyReportHeader({ if (!moneyRequestReport) { return; } - if (hasDynamicExternalWorkflow(policy) && !isDEWSubmitBetaEnabled) { + if (hasDynamicExternalWorkflow(policy) && !isDEWBetaEnabled) { showDWEModal(); return; } diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx index 47c3e5eb59542..6af6447817b9c 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx @@ -166,7 +166,7 @@ function MoneyRequestReportPreviewContent({ const {isBetaEnabled} = usePermissions(); const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, {canBeMissing: true}); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); - const isDEWSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.NEW_DOT_DEW); + const isDEWBetaEnabled = isBetaEnabled(CONST.BETAS.NEW_DOT_DEW); const hasViolations = hasViolationsReportUtils(iouReport?.reportID, transactionViolations); const getCanIOUBePaid = useCallback( @@ -258,7 +258,7 @@ function MoneyRequestReportPreviewContent({ ); const confirmApproval = () => { - if (hasDynamicExternalWorkflow(policy) && !isDEWSubmitBetaEnabled) { + if (hasDynamicExternalWorkflow(policy) && !isDEWBetaEnabled) { setIsDEWModalVisible(true); return; } @@ -528,7 +528,7 @@ function MoneyRequestReportPreviewContent({ success={isWaitingForSubmissionFromCurrentUser} text={translate('common.submit')} onPress={() => { - if (hasDynamicExternalWorkflow(policy) && !isDEWSubmitBetaEnabled) { + if (hasDynamicExternalWorkflow(policy) && !isDEWBetaEnabled) { setIsDEWModalVisible(true); return; } diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 244d2bc73257a..dbf30d9feb113 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -228,7 +228,7 @@ function Search({ const styles = useThemeStyles(); const [isDEWModalVisible, setIsDEWModalVisible] = useState(false); const {isBetaEnabled} = usePermissions(); - const isDEWSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.NEW_DOT_DEW); + const isDEWBetaEnabled = isBetaEnabled(CONST.BETAS.NEW_DOT_DEW); const handleDEWModalOpen = useCallback(() => { if (onDEWModalOpen) { @@ -973,7 +973,7 @@ function Search({ shouldPreventLongPressRow={isChat || isTask} isFocused={isFocused} onDEWModalOpen={handleDEWModalOpen} - isDEWBetaEnabled={isDEWSubmitBetaEnabled} + isDEWBetaEnabled={isDEWBetaEnabled} SearchTableHeader={ !shouldShowTableHeader ? undefined : ( diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 9cf4b4149a7f8..d395bdabd21eb 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -119,7 +119,7 @@ function SearchPage({route}: SearchPageProps) { const [searchRequestResponseStatusCode, setSearchRequestResponseStatusCode] = useState(null); const [isDEWModalVisible, setIsDEWModalVisible] = useState(false); const {isBetaEnabled} = usePermissions(); - const isDEWSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.NEW_DOT_DEW); + const isDEWBetaEnabled = isBetaEnabled(CONST.BETAS.NEW_DOT_DEW); const queryJSON = useMemo(() => buildSearchQueryJSON(route.params.q), [route.params.q]); const {saveScrollOffset} = useContext(ScrollOffsetContext); const activeAdminPolicies = getActiveAdminWorkspaces(policies, currentUserPersonalDetails?.accountID.toString()).sort((a, b) => localeCompare(a.name || '', b.name || '')); @@ -431,7 +431,7 @@ function SearchPage({route}: SearchPageProps) { return hasDynamicExternalWorkflow(policy); }); - if (hasDEWPolicy && !isDEWSubmitBetaEnabled) { + if (hasDEWPolicy && !isDEWBetaEnabled) { setIsDEWModalVisible(true); return; } @@ -622,7 +622,7 @@ function SearchPage({route}: SearchPageProps) { lastPaymentMethods, theme.icon, styles.colorMuted, - isDEWSubmitBetaEnabled, + isDEWBetaEnabled, styles.fontWeightNormal, styles.textWrap, beginExportWithTemplate, From dd6afc20605cf6e66a3fa453daf7f21446a715bf Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Tue, 25 Nov 2025 08:23:38 +0100 Subject: [PATCH 23/76] cleanup --- src/CONST/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 4c4c38fd5550d..d9444b90a9c3f 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1202,7 +1202,6 @@ const CONST = { CREATED: 'CREATED', DELETED_ACCOUNT: 'DELETEDACCOUNT', // Deprecated OldDot Action DELETED_TRANSACTION: 'DELETEDTRANSACTION', - DEW_APPROVE_FAILED: 'DEW_APPROVE_FAILED', DEW_SUBMIT_FAILED: 'DEW_SUBMIT_FAILED', DISMISSED_VIOLATION: 'DISMISSEDVIOLATION', DONATION: 'DONATION', // Deprecated OldDot Action From a0f2dfaeda3f2858f83bbdceb718358d6f6a365f Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Tue, 25 Nov 2025 09:17:11 +0100 Subject: [PATCH 24/76] cleaning the test --- tests/unit/ReportActionsUtilsTest.ts | 39 ---------------------------- 1 file changed, 39 deletions(-) diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts index ff0c0f69ef61c..a7548160e6b94 100644 --- a/tests/unit/ReportActionsUtilsTest.ts +++ b/tests/unit/ReportActionsUtilsTest.ts @@ -1539,43 +1539,4 @@ describe('ReportActionsUtils', () => { expect(ReportActionsUtils.isDynamicExternalWorkflowSubmitFailedAction(null)).toBe(false); }); }); - - describe('isDynamicExternalWorkflowApproveFailedAction', () => { - it('should return true for DEW_APPROVE_FAILED action', () => { - const action: ReportAction = { - ...createRandomReportAction(0), - actionName: CONST.REPORT.ACTIONS.TYPE.DEW_APPROVE_FAILED, - created: '2025-11-21', - reportActionID: '1', - originalMessage: { - message: 'Only one Cost Center can be selected per report.', - automaticAction: false, - }, - message: [], - previousMessage: [], - }; - expect(ReportActionsUtils.isDynamicExternalWorkflowApproveFailedAction(action)).toBe(true); - }); - - it('should return false for non-DEW_APPROVE_FAILED action', () => { - const action: ReportAction = { - ...createRandomReportAction(0), - actionName: CONST.REPORT.ACTIONS.TYPE.APPROVED, - created: '2025-11-21', - reportActionID: '1', - originalMessage: { - amount: 10000, - currency: 'USD', - expenseReportID: '123', - }, - message: [], - previousMessage: [], - }; - expect(ReportActionsUtils.isDynamicExternalWorkflowApproveFailedAction(action)).toBe(false); - }); - - it('should return false for null action', () => { - expect(ReportActionsUtils.isDynamicExternalWorkflowApproveFailedAction(null)).toBe(false); - }); - }); }); From e3ddfde5fc76cbfafb857b2e83529dc437322c69 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Tue, 25 Nov 2025 09:20:57 +0100 Subject: [PATCH 25/76] removning approve related types --- src/libs/ReportActionsUtils.ts | 5 ----- src/types/onyx/OriginalMessage.ts | 1 - 2 files changed, 6 deletions(-) diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 98e05aa9d6074..03be2d9e2d529 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -246,10 +246,6 @@ function isDynamicExternalWorkflowSubmitFailedAction(reportAction: OnyxInputOrEn return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.DEW_SUBMIT_FAILED); } -function isDynamicExternalWorkflowApproveFailedAction(reportAction: OnyxInputOrEntry): reportAction is ReportAction { - return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.DEW_APPROVE_FAILED); -} - function isModifiedExpenseAction(reportAction: OnyxInputOrEntry): reportAction is ReportAction { return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE); } @@ -3468,7 +3464,6 @@ export { isUnapprovedAction, isForwardedAction, isDynamicExternalWorkflowSubmitFailedAction, - isDynamicExternalWorkflowApproveFailedAction, isWhisperActionTargetedToOthers, isTagModificationAction, isIOUActionMatchingTransactionList, diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts index 11e775386a818..f4e3c204ddc5d 100644 --- a/src/types/onyx/OriginalMessage.ts +++ b/src/types/onyx/OriginalMessage.ts @@ -1114,7 +1114,6 @@ type OriginalMessageMap = { [CONST.REPORT.ACTIONS.TYPE.CARD_ASSIGNED]: OriginalMessageCard; [CONST.REPORT.ACTIONS.TYPE.INTEGRATION_SYNC_FAILED]: OriginalMessageIntegrationSyncFailed; [CONST.REPORT.ACTIONS.TYPE.DELETED_TRANSACTION]: OriginalMessageDeletedTransaction; - [CONST.REPORT.ACTIONS.TYPE.DEW_APPROVE_FAILED]: OriginalMessageDEWFailed; [CONST.REPORT.ACTIONS.TYPE.DEW_SUBMIT_FAILED]: OriginalMessageDEWFailed; [CONST.REPORT.ACTIONS.TYPE.CONCIERGE_CATEGORY_OPTIONS]: OriginalMessageConciergeCategoryOptions; [CONST.REPORT.ACTIONS.TYPE.CONCIERGE_DESCRIPTION_OPTIONS]: OriginalMessageConciergeDescriptionOptions; From d59926217707ea8f82037fd5928efa259ed23970 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Wed, 26 Nov 2025 22:58:36 +0100 Subject: [PATCH 26/76] refactor finding the most recent DEW_SUBMIT_FAILED action --- src/libs/ReportUtils.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 7a15219d8f4c7..53ebd4cfea1d9 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -9094,8 +9094,17 @@ function getAllReportActionsErrorsAndReportActionThatRequiresAttention( } if (!isReportArchived && report?.statusNum === CONST.REPORT.STATUS_NUM.OPEN) { - const dewSubmitFailedAction = reportActionsArray.find((action) => isDynamicExternalWorkflowSubmitFailedAction(action)); - if (dewSubmitFailedAction) { + // Find the most recent DEW_SUBMIT_FAILED action + const mostRecentDewSubmitFailedAction = reportActionsArray + .filter((action) => isDynamicExternalWorkflowSubmitFailedAction(action)) + .reduce((latest, current) => { + if (!latest || (current.created && latest.created && current.created > latest.created)) { + return current; + } + return latest; + }, undefined); + + if (mostRecentDewSubmitFailedAction) { // find the most recent SUBMITTED action const mostRecentSubmittedAction = reportActionsArray .filter((action) => isSubmittedAction(action)) @@ -9106,12 +9115,12 @@ function getAllReportActionsErrorsAndReportActionThatRequiresAttention( return latest; }, undefined); - const shouldShowDEWError = !mostRecentSubmittedAction || (mostRecentSubmittedAction.created && dewSubmitFailedAction.created > mostRecentSubmittedAction.created); + const shouldShowDEWError = !mostRecentSubmittedAction || (mostRecentSubmittedAction.created && mostRecentDewSubmitFailedAction.created > mostRecentSubmittedAction.created); if (shouldShowDEWError) { reportActionErrors.dewSubmitFailed = getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericDEWSubmitFailureMessage'); if (!reportAction) { - reportAction = dewSubmitFailedAction; + reportAction = mostRecentDewSubmitFailedAction; } } } From 23d5efa508715f98f2437609581701bae4fec7ea Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Wed, 26 Nov 2025 23:26:35 +0100 Subject: [PATCH 27/76] fixing LHN queuedToSubmitViaDEW msg --- src/libs/OptionsListUtils/index.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index b2af81d0a4157..83d2804a6c685 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -30,6 +30,7 @@ import { getCountOfEnabledTagsOfList, getCountOfRequiredTagLists, getSubmitToAccountID, + hasDynamicExternalWorkflow, isCurrentUserMemberOfAnyPolicy, } from '@libs/PolicyUtils'; import { @@ -711,9 +712,15 @@ function getLastMessageTextForReport({ isMarkAsClosedAction(lastReportAction) ) { const wasSubmittedViaHarvesting = !isMarkAsClosedAction(lastReportAction) ? (getOriginalMessage(lastReportAction)?.harvesting ?? false) : false; + const isPendingAdd = lastReportAction.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD; + const isDEWPolicy = hasDynamicExternalWorkflow(policy); + if (wasSubmittedViaHarvesting) { // eslint-disable-next-line @typescript-eslint/no-deprecated lastMessageTextFromReport = Parser.htmlToText(translateLocal('iou.automaticallySubmitted')); + } else if (isPendingAdd && isDEWPolicy) { + // eslint-disable-next-line @typescript-eslint/no-deprecated + lastMessageTextFromReport = translateLocal('iou.queuedToSubmitViaDEW'); } else { // eslint-disable-next-line @typescript-eslint/no-deprecated lastMessageTextFromReport = translateLocal('iou.submitted', {memo: getOriginalMessage(lastReportAction)?.message}); From f7da162f30598126d06e1584a4cb3628ae750ccb Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Wed, 26 Nov 2025 23:27:20 +0100 Subject: [PATCH 28/76] fixing ts errors --- tests/actions/IOUTest.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/actions/IOUTest.ts b/tests/actions/IOUTest.ts index d3dfb33fb5a7f..19c41e9182f33 100644 --- a/tests/actions/IOUTest.ts +++ b/tests/actions/IOUTest.ts @@ -5533,6 +5533,9 @@ describe('actions/IOU', () => { }, shouldGenerateTransactionThreadReport: true, isASAPSubmitBetaEnabled: false, + currentUserAccountIDParam: RORY_ACCOUNT_ID, + currentUserEmailParam: RORY_EMAIL, + transactionViolations: {}, }); } return waitForBatchedUpdates(); From 305d520c6c1f26991d9339a17a67f99309e2e682 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Tue, 2 Dec 2025 13:52:16 +0100 Subject: [PATCH 29/76] changing action name to DEWSUBMITFAILED --- src/CONST/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 0f37b88c7915b..e4e6995b4f5d5 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1208,7 +1208,7 @@ const CONST = { CREATED: 'CREATED', DELETED_ACCOUNT: 'DELETEDACCOUNT', // Deprecated OldDot Action DELETED_TRANSACTION: 'DELETEDTRANSACTION', - DEW_SUBMIT_FAILED: 'DEW_SUBMIT_FAILED', + DEW_SUBMIT_FAILED: 'DEWSUBMITFAILED', DISMISSED_VIOLATION: 'DISMISSEDVIOLATION', DONATION: 'DONATION', // Deprecated OldDot Action EXPENSIFY_CARD_SYSTEM_MESSAGE: 'EXPENSIFYCARDSYSTEMMESSAGE', From ad7066a74a37a10092cb5875257af3e10ba890be Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Tue, 2 Dec 2025 21:00:04 +0100 Subject: [PATCH 30/76] fixing submit failed actions --- src/components/MoneyReportHeader.tsx | 13 ++++- .../MoneyRequestReportPreviewContent.tsx | 7 +++ src/libs/OptionsListUtils/index.ts | 3 ++ src/libs/ReportPreviewActionUtils.ts | 6 +++ src/libs/ReportUtils.ts | 52 ++++++++++--------- src/libs/SearchUIUtils.ts | 7 +++ .../home/report/PureReportActionItem.tsx | 4 ++ 7 files changed, 66 insertions(+), 26 deletions(-) diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 8bb01e636d432..5298f4999335e 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -228,6 +228,7 @@ function MoneyReportHeader({ 'Export', 'Document', 'Feed', + 'DotIndicator', ] as const); const [lastDistanceExpenseType] = useOnyx(ONYXKEYS.NVP_LAST_DISTANCE_EXPENSE_TYPE, {canBeMissing: true}); const {translate} = useLocalize(); @@ -577,7 +578,17 @@ function MoneyReportHeader({ }, {}); const {errors} = getAllReportActionsErrorsAndReportActionThatRequiresAttention(moneyRequestReport, reportActionsObject); if (errors?.dewSubmitFailed) { - return {icon: getStatusIcon(expensifyIcons.Flag), description: translate('iou.dynamicExternalWorkflowCannotSubmit')}; + return { + icon: ( + + ), + description: {translate('iou.dynamicExternalWorkflowCannotSubmit')}, + }; } } diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx index 15d774dbdec78..9934e5bab1551 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx @@ -44,6 +44,7 @@ import {getTotalAmountForIOUReportPreviewButton} from '@libs/MoneyRequestReportU import Navigation from '@libs/Navigation/Navigation'; import Performance from '@libs/Performance'; import {getConnectedIntegration, hasDynamicExternalWorkflow} from '@libs/PolicyUtils'; +import {isDynamicExternalWorkflowSubmitFailedAction} from '@libs/ReportActionsUtils'; import {getReportPreviewAction} from '@libs/ReportPreviewActionUtils'; import { areAllRequestsBeingSmartScanned as areAllRequestsBeingSmartScannedReportUtils, @@ -491,6 +492,10 @@ function MoneyRequestReportPreviewContent({ Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(iouReportID, undefined, undefined, Navigation.getActiveRoute())); }, [iouReportID]); + const hasDEWSubmitFailed = useMemo(() => { + return Object.values(reportActions ?? {}).some(isDynamicExternalWorkflowSubmitFailedAction); + }, [reportActions]); + const reportPreviewAction = useMemo(() => { return getReportPreviewAction( violations, @@ -505,6 +510,7 @@ function MoneyRequestReportPreviewContent({ isApprovedAnimationRunning, isSubmittingAnimationRunning, areStrictPolicyRulesEnabled, + hasDEWSubmitFailed, ); }, [ isPaidAnimationRunning, @@ -520,6 +526,7 @@ function MoneyRequestReportPreviewContent({ areStrictPolicyRulesEnabled, currentUserDetails.email, currentUserDetails.accountID, + hasDEWSubmitFailed, ]); const addExpenseDropdownOptions = useMemo( diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index fb13c50cd8e13..69fddfe5a130a 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -66,6 +66,7 @@ import { isCreatedTaskReportAction, isDeletedAction, isDeletedParentAction, + isDynamicExternalWorkflowSubmitFailedAction, isInviteOrRemovedAction, isMarkAsClosedAction, isModifiedExpenseAction, @@ -734,6 +735,8 @@ function getLastMessageTextForReport({ // eslint-disable-next-line @typescript-eslint/no-deprecated lastMessageTextFromReport = translateLocal('iou.approvedMessage'); } + } else if (isDynamicExternalWorkflowSubmitFailedAction(lastReportAction)) { + lastMessageTextFromReport = getOriginalMessage(lastReportAction)?.message ?? ''; } else if (isUnapprovedAction(lastReportAction)) { // eslint-disable-next-line @typescript-eslint/no-deprecated lastMessageTextFromReport = translateLocal('iou.unapproved'); diff --git a/src/libs/ReportPreviewActionUtils.ts b/src/libs/ReportPreviewActionUtils.ts index 3268e18fe6a80..f3bd4825e2be8 100644 --- a/src/libs/ReportPreviewActionUtils.ts +++ b/src/libs/ReportPreviewActionUtils.ts @@ -246,6 +246,7 @@ function getReportPreviewAction( isApprovedAnimationRunning?: boolean, isSubmittingAnimationRunning?: boolean, areStrictPolicyRulesEnabled?: boolean, + hasDEWSubmitFailed?: boolean, ): ValueOf { if (!report) { return CONST.REPORT.REPORT_PREVIEW_ACTIONS.VIEW; @@ -264,6 +265,11 @@ function getReportPreviewAction( return CONST.REPORT.REPORT_PREVIEW_ACTIONS.ADD_EXPENSE; } + // If DEW submit failed and report is still open, show REVIEW + if (hasDEWSubmitFailed && isOpenReport(report)) { + return CONST.REPORT.REPORT_PREVIEW_ACTIONS.REVIEW; + } + // When strict policy rules are enabled and there are violations, show REVIEW button instead of SUBMIT const shouldBlockSubmit = shouldBlockSubmitDueToStrictPolicyRules( report.reportID, diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 5e12ffcab28c7..446c3f6569717 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -11770,13 +11770,13 @@ function prepareOnboardingOnyxData( }, ); - optimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${targetChatReportID}`, - value: { - [textCommentAction.reportActionID]: textCommentAction as ReportAction, - }, - }); + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${targetChatReportID}`, + value: { + [textCommentAction.reportActionID]: textCommentAction as ReportAction, + }, + }); if (!wasInvited) { optimisticData.push({ @@ -11788,13 +11788,13 @@ function prepareOnboardingOnyxData( const successData: OnyxUpdate[] = [...tasksForSuccessData]; - successData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${targetChatReportID}`, - value: { - [textCommentAction.reportActionID]: {pendingAction: null, isOptimisticAction: null}, - }, - }); + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${targetChatReportID}`, + value: { + [textCommentAction.reportActionID]: {pendingAction: null, isOptimisticAction: null}, + }, + }); let failureReport: Partial = { lastMessageText: '', @@ -11833,15 +11833,15 @@ function prepareOnboardingOnyxData( }, ); - failureData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${targetChatReportID}`, - value: { - [textCommentAction.reportActionID]: { - errors: getMicroSecondOnyxErrorWithTranslationKey('report.genericAddCommentFailureMessage'), - } as ReportAction, - }, - }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${targetChatReportID}`, + value: { + [textCommentAction.reportActionID]: { + errors: getMicroSecondOnyxErrorWithTranslationKey('report.genericAddCommentFailureMessage'), + } as ReportAction, + }, + }); if (!wasInvited) { failureData.push({ @@ -11895,7 +11895,7 @@ function prepareOnboardingOnyxData( // If we post tasks in the #admins room and introSelected?.choice does not exist, it means that a guide is assigned and all messages except tasks are handled by the backend const guidedSetupData: GuidedSetupData = []; - guidedSetupData.push({type: 'message', ...textMessage}); + guidedSetupData.push({type: 'message', ...textMessage}); let selfDMParameters: SelfDMParameters = {}; if (engagementChoice === CONST.ONBOARDING_CHOICES.PERSONAL_SPEND || engagementChoice === CONST.ONBOARDING_CHOICES.TRACK_WORKSPACE) { @@ -12634,7 +12634,9 @@ function selectFilteredReportActions( return Object.fromEntries( Object.entries(reportActions).map(([reportId, actionsGroup]) => { const actions = Object.values(actionsGroup ?? {}); - const filteredActions = actions.filter((action): action is ReportAction => isExportIntegrationAction(action) || isIntegrationMessageAction(action)); + const filteredActions = actions.filter( + (action): action is ReportAction => isExportIntegrationAction(action) || isIntegrationMessageAction(action) || isDynamicExternalWorkflowSubmitFailedAction(action), + ); return [reportId, filteredActions]; }), ); diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 7e26a8f07eb6a..0ff82199d0717 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -80,6 +80,7 @@ import { getOriginalMessage, isCreatedAction, isDeletedAction, + isDynamicExternalWorkflowSubmitFailedAction, isHoldAction, isMoneyRequestAction, isResolvedActionableWhisper, @@ -1263,6 +1264,12 @@ function getActions( return [CONST.SEARCH.ACTION_TYPES.REVIEW]; } + // Check for DEW submit failed - if the report has a DEW_SUBMIT_FAILED action and is still OPEN, show Review + const hasDEWSubmitFailed = report.statusNum === CONST.REPORT.STATUS_NUM.OPEN && reportActions.some(isDynamicExternalWorkflowSubmitFailedAction); + if (hasDEWSubmitFailed) { + return [CONST.SEARCH.ACTION_TYPES.REVIEW]; + } + // We don't need to run the logic if this is not a transaction or iou/expense report, so let's shortcut the logic for performance reasons if (!isMoneyRequestReport(report) && !isInvoiceReport(report)) { return [CONST.SEARCH.ACTION_TYPES.VIEW]; diff --git a/src/pages/home/report/PureReportActionItem.tsx b/src/pages/home/report/PureReportActionItem.tsx index c85f9793bd75f..0a38a3ab6087a 100644 --- a/src/pages/home/report/PureReportActionItem.tsx +++ b/src/pages/home/report/PureReportActionItem.tsx @@ -123,6 +123,7 @@ import { isCreatedTaskReportAction, isDeletedAction, isDeletedParentAction as isDeletedParentActionUtils, + isDynamicExternalWorkflowSubmitFailedAction, isIOURequestReportAction, isMarkAsClosedAction, isMessageDeleted, @@ -1202,6 +1203,9 @@ function PureReportActionItem({ } else { children = ; } + } else if (isDynamicExternalWorkflowSubmitFailedAction(action)) { + const errorMessage = getOriginalMessage(action)?.message ?? translate('iou.error.genericDEWSubmitFailureMessage'); + 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; From 351b28e9d491e71da7f0745d0c4cf0b1bca641ed Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Tue, 2 Dec 2025 21:04:04 +0100 Subject: [PATCH 31/76] minor edit --- src/libs/ReportUtils.ts | 48 ++++++++++++++++++++--------------------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 446c3f6569717..12cd4cb678c16 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -11770,13 +11770,13 @@ function prepareOnboardingOnyxData( }, ); - optimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${targetChatReportID}`, - value: { - [textCommentAction.reportActionID]: textCommentAction as ReportAction, - }, - }); + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${targetChatReportID}`, + value: { + [textCommentAction.reportActionID]: textCommentAction as ReportAction, + }, + }); if (!wasInvited) { optimisticData.push({ @@ -11788,13 +11788,13 @@ function prepareOnboardingOnyxData( const successData: OnyxUpdate[] = [...tasksForSuccessData]; - successData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${targetChatReportID}`, - value: { - [textCommentAction.reportActionID]: {pendingAction: null, isOptimisticAction: null}, - }, - }); + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${targetChatReportID}`, + value: { + [textCommentAction.reportActionID]: {pendingAction: null, isOptimisticAction: null}, + }, + }); let failureReport: Partial = { lastMessageText: '', @@ -11833,15 +11833,15 @@ function prepareOnboardingOnyxData( }, ); - failureData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${targetChatReportID}`, - value: { - [textCommentAction.reportActionID]: { - errors: getMicroSecondOnyxErrorWithTranslationKey('report.genericAddCommentFailureMessage'), - } as ReportAction, - }, - }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${targetChatReportID}`, + value: { + [textCommentAction.reportActionID]: { + errors: getMicroSecondOnyxErrorWithTranslationKey('report.genericAddCommentFailureMessage'), + } as ReportAction, + }, + }); if (!wasInvited) { failureData.push({ @@ -11895,7 +11895,7 @@ function prepareOnboardingOnyxData( // If we post tasks in the #admins room and introSelected?.choice does not exist, it means that a guide is assigned and all messages except tasks are handled by the backend const guidedSetupData: GuidedSetupData = []; - guidedSetupData.push({type: 'message', ...textMessage}); + guidedSetupData.push({type: 'message', ...textMessage}); let selfDMParameters: SelfDMParameters = {}; if (engagementChoice === CONST.ONBOARDING_CHOICES.PERSONAL_SPEND || engagementChoice === CONST.ONBOARDING_CHOICES.TRACK_WORKSPACE) { From c847cc32c2446be11a495b525d88c185a57a5a3b Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Tue, 2 Dec 2025 21:44:23 +0100 Subject: [PATCH 32/76] adding generic fallback error to lastMessageTextFromReport --- src/libs/OptionsListUtils/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 69fddfe5a130a..0a60946b1b91b 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -736,7 +736,7 @@ function getLastMessageTextForReport({ lastMessageTextFromReport = translateLocal('iou.approvedMessage'); } } else if (isDynamicExternalWorkflowSubmitFailedAction(lastReportAction)) { - lastMessageTextFromReport = getOriginalMessage(lastReportAction)?.message ?? ''; + lastMessageTextFromReport = getOriginalMessage(lastReportAction)?.message ?? translateLocal('iou.error.genericDEWSubmitFailureMessage'); } else if (isUnapprovedAction(lastReportAction)) { // eslint-disable-next-line @typescript-eslint/no-deprecated lastMessageTextFromReport = translateLocal('iou.unapproved'); From 417834abfefc81e1642dee195293a50c95144d0f Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Tue, 2 Dec 2025 21:52:43 +0100 Subject: [PATCH 33/76] adding more test cases --- tests/actions/ReportPreviewActionUtilsTest.ts | 130 ++++++++++++++++++ tests/unit/Search/SearchUIUtilsTest.ts | 78 +++++++++++ 2 files changed, 208 insertions(+) diff --git a/tests/actions/ReportPreviewActionUtilsTest.ts b/tests/actions/ReportPreviewActionUtilsTest.ts index 881586ac8093d..a9fdc94e1151d 100644 --- a/tests/actions/ReportPreviewActionUtilsTest.ts +++ b/tests/actions/ReportPreviewActionUtilsTest.ts @@ -792,4 +792,134 @@ describe('getReportPreviewAction', () => { CONST.REPORT.REPORT_PREVIEW_ACTIONS.VIEW, ); }); + + describe('DEW (Dynamic External Workflow) submit failed', () => { + it('should return REVIEW when hasDEWSubmitFailed is true and report is OPEN', async () => { + const report: Report = { + ...createRandomReport(REPORT_ID, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + isWaitingOnBankAccount: false, + }; + + const policy = createRandomPolicy(0); + policy.type = CONST.POLICY.TYPE.CORPORATE; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); + const transaction = { + reportID: `${REPORT_ID}`, + } as unknown as Transaction; + + const {result: isReportArchived} = renderHook(() => useReportIsArchived(report?.parentReportID)); + await waitForBatchedUpdatesWithAct(); + + expect( + getReportPreviewAction( + VIOLATIONS, + isReportArchived.current, + CURRENT_USER_EMAIL, + CURRENT_USER_ACCOUNT_ID, + report, + policy, + [transaction], + undefined, + false, + false, + false, + false, + true, + ), + ).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.REVIEW); + }); + + it('should NOT return REVIEW when hasDEWSubmitFailed is true but report is not OPEN', async () => { + 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.approver = CURRENT_USER_EMAIL; + policy.approvalMode = CONST.POLICY.APPROVAL_MODE.BASIC; + policy.preventSelfApproval = false; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); + const transaction = { + reportID: `${REPORT_ID}`, + } as unknown as Transaction; + + const {result: isReportArchived} = renderHook(() => useReportIsArchived(report?.parentReportID)); + await waitForBatchedUpdatesWithAct(); + + expect( + getReportPreviewAction( + VIOLATIONS, + isReportArchived.current, + CURRENT_USER_EMAIL, + CURRENT_USER_ACCOUNT_ID, + report, + policy, + [transaction], + undefined, + false, + false, + false, + false, + true, + ), + ).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.APPROVE); + }); + + it('should NOT return REVIEW when hasDEWSubmitFailed is false', async () => { + const report: Report = { + ...createRandomReport(REPORT_ID, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + isWaitingOnBankAccount: false, + }; + + const policy = createRandomPolicy(0); + policy.type = CONST.POLICY.TYPE.CORPORATE; + policy.autoReportingFrequency = CONST.POLICY.AUTO_REPORTING_FREQUENCIES.IMMEDIATE; + if (policy.harvesting) { + policy.harvesting.enabled = false; + } + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); + const transaction = { + reportID: `${REPORT_ID}`, + } as unknown as Transaction; + + const {result: isReportArchived} = renderHook(() => useReportIsArchived(report?.parentReportID)); + await waitForBatchedUpdatesWithAct(); + + expect( + getReportPreviewAction( + VIOLATIONS, + isReportArchived.current, + CURRENT_USER_EMAIL, + CURRENT_USER_ACCOUNT_ID, + report, + policy, + [transaction], + undefined, + false, + false, + false, + false, + false, + ), + ).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.SUBMIT); + }); + }); }); diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index 3a50a51fe736b..e67a7412d724a 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -1712,6 +1712,84 @@ describe('SearchUIUtils', () => { const action = SearchUIUtils.getActions(searchResults.data, {}, iouReportKey, CONST.SEARCH.SEARCH_KEYS.EXPENSES, adminAccountID, '').at(0); expect(action).toEqual(CONST.SEARCH.ACTION_TYPES.PAY); }); + + test('Should return `Review` action when report has DEW_SUBMIT_FAILED action and is still OPEN', async () => { + const dewReportID = '999'; + const dewTransactionID = '9999'; + const dewReportActionID = '99999'; + + const localSearchResults = { + ...searchResults.data, + [`report_${dewReportID}`]: { + ...searchResults.data[`report_${reportID}`], + reportID: dewReportID, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + type: CONST.REPORT.TYPE.EXPENSE, + }, + [`transactions_${dewTransactionID}`]: { + ...searchResults.data[`transactions_${transactionID}`], + transactionID: dewTransactionID, + reportID: dewReportID, + }, + }; + + const dewReportActions = [ + { + reportActionID: dewReportActionID, + actionName: CONST.REPORT.ACTIONS.TYPE.DEW_SUBMIT_FAILED, + reportID: dewReportID, + created: '2025-01-01 00:00:00', + originalMessage: { + message: 'DEW submit failed', + }, + }, + ] as OnyxTypes.ReportAction[]; + + const action = SearchUIUtils.getActions(localSearchResults, {}, `transactions_${dewTransactionID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, adminAccountID, '', dewReportActions).at( + 0, + ); + expect(action).toStrictEqual(CONST.SEARCH.ACTION_TYPES.REVIEW); + }); + + test('Should NOT return `Review` action when report has DEW_SUBMIT_FAILED action but is not OPEN', async () => { + const dewReportID = '888'; + const dewTransactionID = '8888'; + const dewReportActionID = '88888'; + + const localSearchResults = { + ...searchResults.data, + [`report_${dewReportID}`]: { + ...searchResults.data[`report_${reportID}`], + reportID: dewReportID, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + type: CONST.REPORT.TYPE.EXPENSE, + }, + [`transactions_${dewTransactionID}`]: { + ...searchResults.data[`transactions_${transactionID}`], + transactionID: dewTransactionID, + reportID: dewReportID, + }, + }; + + const dewReportActions = [ + { + reportActionID: dewReportActionID, + actionName: CONST.REPORT.ACTIONS.TYPE.DEW_SUBMIT_FAILED, + reportID: dewReportID, + created: '2025-01-01 00:00:00', + originalMessage: { + message: 'DEW submit failed', + }, + }, + ] as OnyxTypes.ReportAction[]; + + const action = SearchUIUtils.getActions(localSearchResults, {}, `transactions_${dewTransactionID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, adminAccountID, '', dewReportActions).at( + 0, + ); + expect(action).not.toStrictEqual(CONST.SEARCH.ACTION_TYPES.REVIEW); + }); }); describe('Test getListItem', () => { From aed96bd7c7ae61612adfd23bf4e3a2310434b99f Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Tue, 2 Dec 2025 22:35:02 +0100 Subject: [PATCH 34/76] Fixing tests --- src/libs/OptionsListUtils/index.ts | 1 + tests/actions/ReportPreviewActionUtilsTest.ts | 2 +- tests/ui/ReportListItemHeaderTest.tsx | 54 +++++++++++- tests/unit/OptionsListUtilsTest.tsx | 84 +++++++++++++++++++ 4 files changed, 139 insertions(+), 2 deletions(-) diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index a56a0aa9d1269..b4c199ef7f99c 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -738,6 +738,7 @@ function getLastMessageTextForReport({ lastMessageTextFromReport = translateLocal('iou.approvedMessage'); } } else if (isDynamicExternalWorkflowSubmitFailedAction(lastReportAction)) { + // eslint-disable-next-line @typescript-eslint/no-deprecated lastMessageTextFromReport = getOriginalMessage(lastReportAction)?.message ?? translateLocal('iou.error.genericDEWSubmitFailureMessage'); } else if (isUnapprovedAction(lastReportAction)) { // eslint-disable-next-line @typescript-eslint/no-deprecated diff --git a/tests/actions/ReportPreviewActionUtilsTest.ts b/tests/actions/ReportPreviewActionUtilsTest.ts index a9fdc94e1151d..1c966aad7529f 100644 --- a/tests/actions/ReportPreviewActionUtilsTest.ts +++ b/tests/actions/ReportPreviewActionUtilsTest.ts @@ -919,7 +919,7 @@ describe('getReportPreviewAction', () => { false, false, ), - ).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.SUBMIT); + ).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.VIEW); }); }); }); diff --git a/tests/ui/ReportListItemHeaderTest.tsx b/tests/ui/ReportListItemHeaderTest.tsx index d77eb05b47644..b8d376eba32fe 100644 --- a/tests/ui/ReportListItemHeaderTest.tsx +++ b/tests/ui/ReportListItemHeaderTest.tsx @@ -92,7 +92,13 @@ const createReportListItem = ( }); // Helper function to wrap component with context -const renderReportListItemHeader = (reportItem: TransactionReportGroupListItemType) => { +const renderReportListItemHeader = ( + reportItem: TransactionReportGroupListItemType, + options?: { + onDEWModalOpen?: () => void; + isDEWBetaEnabled?: boolean; + }, +) => { return render( {/* @ts-expect-error - Disable TypeScript errors to simplify the test */} @@ -103,6 +109,8 @@ const renderReportListItemHeader = (reportItem: TransactionReportGroupListItemTy onCheckboxPress={jest.fn()} isDisabled={false} canSelectMultiple={false} + onDEWModalOpen={options?.onDEWModalOpen} + isDEWBetaEnabled={options?.isDEWBetaEnabled} /> , @@ -216,4 +224,48 @@ describe('ReportListItemHeader', () => { }); }); }); + + describe('DEW (Dynamic External Workflow)', () => { + it('should accept onDEWModalOpen callback for SUBMIT action', async () => { + const mockOnDEWModalOpen = jest.fn(); + const reportItem = createReportListItem(CONST.REPORT.TYPE.EXPENSE, 'john', 'jane', { + action: 'submit', + }); + renderReportListItemHeader(reportItem, {onDEWModalOpen: mockOnDEWModalOpen}); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByText('John Doe')).toBeOnTheScreen(); + }); + + it('should accept onDEWModalOpen callback for APPROVE action', async () => { + const mockOnDEWModalOpen = jest.fn(); + const reportItem = createReportListItem(CONST.REPORT.TYPE.EXPENSE, 'john', 'jane', { + action: 'approve', + }); + renderReportListItemHeader(reportItem, {onDEWModalOpen: mockOnDEWModalOpen}); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByText('John Doe')).toBeOnTheScreen(); + }); + + it('should accept isDEWBetaEnabled prop', async () => { + const reportItem = createReportListItem(CONST.REPORT.TYPE.EXPENSE, 'john', 'jane', { + action: 'submit', + }); + renderReportListItemHeader(reportItem, {isDEWBetaEnabled: true}); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByText('John Doe')).toBeOnTheScreen(); + }); + + it('should render without DEW props', async () => { + const reportItem = createReportListItem(CONST.REPORT.TYPE.EXPENSE, 'john', 'jane', { + action: 'submit', + }); + renderReportListItemHeader(reportItem); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByText('John Doe')).toBeOnTheScreen(); + }); + }); }); diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index 953b957fcafa5..0652b20157864 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -2786,5 +2786,89 @@ describe('OptionsListUtils', () => { const lastMessage = getLastMessageTextForReport({report, lastActorDetails: null, isReportArchived: false}); expect(lastMessage).toBe(Parser.htmlToText(getMovedActionMessage(movedAction, report))); }); + describe('DEW (Dynamic External Workflow)', () => { + beforeEach(() => Onyx.clear()); + + it('should show queued message for SUBMITTED action with DEW policy and pending add', async () => { + const reportID = 'dewReport1'; + const report: Report = { + reportID, + reportName: 'Test Report', + type: CONST.REPORT.TYPE.EXPENSE, + policyID: 'dewPolicy1', + }; + const policy: Policy = { + id: 'dewPolicy1', + name: 'Test Policy', + type: CONST.POLICY.TYPE.CORPORATE, + approvalMode: CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL, + } as Policy; + const submittedAction: ReportAction = { + reportActionID: '1', + actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, + created: '2024-01-01 00:00:00', + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + message: [{type: 'COMMENT', text: 'submitted'}], + originalMessage: {}, + }; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, report); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { + [submittedAction.reportActionID]: submittedAction, + }); + const lastMessage = getLastMessageTextForReport({report, lastActorDetails: null, isReportArchived: false, policy}); + expect(lastMessage).toBe(translate(CONST.LOCALES.EN, 'iou.queuedToSubmitViaDEW')); + }); + + it('should show custom error message for DEW_SUBMIT_FAILED action', async () => { + const reportID = 'dewReport2'; + const report: Report = { + reportID, + reportName: 'Test Report', + type: CONST.REPORT.TYPE.EXPENSE, + }; + const customErrorMessage = 'This report contains an expense missing required fields.'; + const dewSubmitFailedAction: ReportAction = { + reportActionID: '1', + actionName: CONST.REPORT.ACTIONS.TYPE.DEW_SUBMIT_FAILED, + created: '2024-01-01 00:00:00', + message: [{type: 'COMMENT', text: customErrorMessage}], + originalMessage: { + message: customErrorMessage, + }, + }; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, report); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { + [dewSubmitFailedAction.reportActionID]: dewSubmitFailedAction, + }); + const lastMessage = getLastMessageTextForReport({report, lastActorDetails: null, isReportArchived: false}); + expect(lastMessage).toBe(customErrorMessage); + }); + + it('should show fallback message for DEW_SUBMIT_FAILED action without message', async () => { + const reportID = 'dewReport3'; + const report: Report = { + reportID, + reportName: 'Test Report', + type: CONST.REPORT.TYPE.EXPENSE, + }; + const dewSubmitFailedAction: ReportAction = { + reportActionID: '1', + actionName: CONST.REPORT.ACTIONS.TYPE.DEW_SUBMIT_FAILED, + created: '2024-01-01 00:00:00', + message: [{type: 'COMMENT', text: ''}], + originalMessage: {}, + }; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, report); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { + [dewSubmitFailedAction.reportActionID]: dewSubmitFailedAction, + }); + const lastMessage = getLastMessageTextForReport({report, lastActorDetails: null, isReportArchived: false}); + expect(lastMessage).toBe(translate(CONST.LOCALES.EN, 'iou.error.genericDEWSubmitFailureMessage')); + }); + }); }); }); From 3d805ca157ae2d64983ca8380bb8301f23f42322 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Wed, 3 Dec 2025 09:57:37 +0100 Subject: [PATCH 35/76] remvoe the unneeded hasDewError const --- src/CONST/index.ts | 1 - 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 - 11 files changed, 11 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 86557ca171e6c..0d95424d0d5d8 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -7279,7 +7279,6 @@ const CONST = { HAS_CHILD_REPORT_AWAITING_ACTION: 'hasChildReportAwaitingAction', HAS_MISSING_INVOICE_BANK_ACCOUNT: 'hasMissingInvoiceBankAccount', HAS_UNRESOLVED_CARD_FRAUD_ALERT: 'hasUnresolvedCardFraudAlert', - HAS_DEW_ERROR: 'hasDEWError', }, CARD_FRAUD_ALERT_RESOLUTION: { diff --git a/src/languages/de.ts b/src/languages/de.ts index 81fda416563a7..5fc9cc82af087 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -7492,7 +7492,6 @@ ${ hasChildReportAwaitingAction: 'Hat einen untergeordneten Bericht, der auf eine Aktion wartet', hasMissingInvoiceBankAccount: 'Fehlendes Rechnungsbankkonto', hasUnresolvedCardFraudAlert: 'Hat eine ungelöste Karten-Fraud-Warnung', - hasDEWError: 'Hat DEW-Fehler', }, reasonRBR: { hasErrors: 'Hat Fehler in den Berichtsdaten oder Berichtsaktionen', diff --git a/src/languages/en.ts b/src/languages/en.ts index c78395662d0d4..8be133081f7cd 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -7557,7 +7557,6 @@ const translations = { hasChildReportAwaitingAction: 'Has child report awaiting action', hasMissingInvoiceBankAccount: 'Has missing invoice bank account', hasUnresolvedCardFraudAlert: 'Has unresolved card fraud alert', - hasDEWError: 'Has DEW error', }, reasonRBR: { hasErrors: 'Has errors in report or report actions data', diff --git a/src/languages/es.ts b/src/languages/es.ts index 17912eb7cb414..e806c0dfb704e 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -7668,7 +7668,6 @@ ${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', - hasDEWError: 'Tiene error de DEW', }, 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 7b55680a12a6f..5e63a3992f2f2 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -7497,7 +7497,6 @@ ${ hasChildReportAwaitingAction: 'Le rapport enfant attend une action', hasMissingInvoiceBankAccount: 'Il manque le compte bancaire de la facture', hasUnresolvedCardFraudAlert: 'A une alerte de fraude de carte non résolue', - hasDEWError: 'A une erreur DEW', }, reasonRBR: { hasErrors: 'Contient des erreurs dans les données du rapport ou des actions du rapport', diff --git a/src/languages/it.ts b/src/languages/it.ts index bc5860110425f..86456047cbab8 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -7477,7 +7477,6 @@ ${ hasChildReportAwaitingAction: 'Ha un rapporto figlio in attesa di azione', hasMissingInvoiceBankAccount: 'Manca il conto bancario della fattura', hasUnresolvedCardFraudAlert: 'Ha una alerta di fraude di carta non risolta', - hasDEWError: 'Ha errore DEW', }, reasonRBR: { hasErrors: 'Ha errori nei dati del report o delle azioni del report', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 3accd4cf8b094..52943fa29e455 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -7404,7 +7404,6 @@ ${ hasChildReportAwaitingAction: '子レポートがアクション待ちです。', hasMissingInvoiceBankAccount: '請求書の銀行口座がありません', hasUnresolvedCardFraudAlert: '未解決のカード詐欺警告があります', - hasDEWError: 'DEWエラーがあります', }, reasonRBR: { hasErrors: 'レポートまたはレポートアクションデータにエラーがあります', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 8421c17dad89a..5d2bcfe783c5e 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -7452,7 +7452,6 @@ ${ hasChildReportAwaitingAction: 'Heeft kindrapport wachtend op actie', hasMissingInvoiceBankAccount: 'Heeft een ontbrekende factuur bankrekening', hasUnresolvedCardFraudAlert: 'Heeft een onopgeloste kaartfraude waarschuwing', - hasDEWError: 'Heeft DEW-fout', }, reasonRBR: { hasErrors: 'Heeft fouten in rapport of rapportacties gegevens', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 50b3e42aec089..07bf292476f0c 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -7443,7 +7443,6 @@ ${ hasChildReportAwaitingAction: 'Raport podrzędny oczekuje na działanie', hasMissingInvoiceBankAccount: 'Brakuje konta bankowego na fakturze', hasUnresolvedCardFraudAlert: 'Ma nierozwiązaną alertę fraudy karty', - hasDEWError: 'Ma błąd DEW', }, reasonRBR: { hasErrors: 'Ma błędy w danych raportu lub działaniach raportu', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 868990ef16b84..7627dff13fe5b 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -7453,7 +7453,6 @@ ${ hasChildReportAwaitingAction: 'Tem um relatório infantil aguardando ação', hasMissingInvoiceBankAccount: 'Falta a conta bancária da fatura', hasUnresolvedCardFraudAlert: 'Tem uma alerta de fraude de cartão não resolvida', - hasDEWError: 'Tem erro de DEW', }, 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 574d568b534f2..3bf4286bb444d 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -7292,7 +7292,6 @@ ${ hasChildReportAwaitingAction: '有子报告等待处理', hasMissingInvoiceBankAccount: '缺少发票银行账户', hasUnresolvedCardFraudAlert: '有未解决的卡片欺诈警告', - hasDEWError: '有DEW错误', }, reasonRBR: { hasErrors: '报告或报告操作数据中有错误', From 2cc03cc9b8362935b757cf81f4c339c03992b173 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Wed, 3 Dec 2025 11:15:25 +0100 Subject: [PATCH 36/76] making err as next step instead of next step status --- src/CONST/index.ts | 1 + src/components/MoneyReportHeader.tsx | 39 +++++++------------ src/components/MoneyReportHeaderStatusBar.tsx | 1 + 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 | 15 +++++++ tests/unit/NextStepUtilsTest.ts | 18 ++++++++- 15 files changed, 49 insertions(+), 35 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 0d95424d0d5d8..844ad1fa1c50f 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1555,6 +1555,7 @@ const CONST = { HOURGLASS: 'hourglass', CHECKMARK: 'checkmark', STOPWATCH: 'stopwatch', + DOT_INDICATOR: 'dotIndicator', }, ETA_KEY: { SHORTLY: 'shortly', diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index d75b5cc081469..dee182881ef1c 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -43,7 +43,7 @@ import {getThreadReportIDsForTransactions, getTotalAmountForIOUReportPreviewButt import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import type {ReportsSplitNavigatorParamList, SearchFullscreenNavigatorParamList, SearchReportParamList} from '@libs/Navigation/types'; -import {buildOptimisticNextStepForPreventSelfApprovalsEnabled, buildOptimisticNextStepForStrictPolicyRuleViolations} from '@libs/NextStepUtils'; +import {buildOptimisticNextStepForDynamicExternalWorkflowError, buildOptimisticNextStepForPreventSelfApprovalsEnabled, buildOptimisticNextStepForStrictPolicyRuleViolations} from '@libs/NextStepUtils'; import type {KYCFlowEvent, TriggerKYCFlow} from '@libs/PaymentUtils'; import {selectPaymentType} from '@libs/PaymentUtils'; import {getConnectedIntegration, getValidConnectedIntegration, hasDynamicExternalWorkflow} from '@libs/PolicyUtils'; @@ -443,6 +443,20 @@ function MoneyReportHeader({ const isSubmitterSameAsNextApprover = isReportOwner(moneyRequestReport) && nextApproverAccountID === moneyRequestReport?.ownerAccountID; let optimisticNextStep = isSubmitterSameAsNextApprover && policy?.preventSelfApproval ? buildOptimisticNextStepForPreventSelfApprovalsEnabled() : nextStep; + // Check for DEW submit failed - if the report has a DEW_SUBMIT_FAILED action, show the custom next step + if (isDEWBetaEnabled && hasDynamicExternalWorkflow(policy) && 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 = buildOptimisticNextStepForDynamicExternalWorkflowError(); + } + } + if (shouldBlockSubmit && isReportOwner(moneyRequestReport) && isOpenExpenseReport(moneyRequestReport)) { optimisticNextStep = buildOptimisticNextStepForStrictPolicyRuleViolations(); } @@ -569,29 +583,6 @@ function MoneyReportHeader({ return {icon: getStatusIcon(expensifyIcons.Hourglass), description: translate('iou.reject.rejectedStatus')}; } - if (isDEWBetaEnabled && hasDynamicExternalWorkflow(policy) && 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) { - return { - icon: ( - - ), - description: {translate('iou.dynamicExternalWorkflowCannotSubmit')}, - }; - } - } - if (isPayAtEndExpense) { if (!isArchivedReport) { return {icon: getStatusIcon(expensifyIcons.Hourglass), description: translate('iou.bookingPendingDescription')}; diff --git a/src/components/MoneyReportHeaderStatusBar.tsx b/src/components/MoneyReportHeaderStatusBar.tsx index 86c887c096cda..65a547f8e4790 100644 --- a/src/components/MoneyReportHeaderStatusBar.tsx +++ b/src/components/MoneyReportHeaderStatusBar.tsx @@ -24,6 +24,7 @@ const iconMap: IconMap = { [CONST.NEXT_STEP.ICONS.HOURGLASS]: Expensicons.Hourglass, [CONST.NEXT_STEP.ICONS.CHECKMARK]: Expensicons.Checkmark, [CONST.NEXT_STEP.ICONS.STOPWATCH]: Expensicons.Stopwatch, + [CONST.NEXT_STEP.ICONS.DOT_INDICATOR]: Expensicons.DotIndicator, }; function MoneyReportHeaderStatusBar({nextStep}: MoneyReportHeaderStatusBarProps) { diff --git a/src/languages/de.ts b/src/languages/de.ts index 5fc9cc82af087..e96f6ca098455 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1301,7 +1301,6 @@ const translations: TranslationDeepObject = { submitted: ({memo}: SubmittedWithMemoParams) => `eingereicht${memo ? `, sagte ${memo}` : ''}`, automaticallySubmitted: `über verzögerte Einreichungen eingereicht`, queuedToSubmitViaDEW: 'in die Warteschlange gestellt zur Einreichung über benutzerdefinierten Genehmigungsworkflow', - dynamicExternalWorkflowCannotSubmit: 'Dieser Bericht kann nicht eingereicht werden. Bitte überprüfen Sie die Kommentare zur Behebung.', trackedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `tracking ${formattedAmount}${comment ? `für ${comment}` : ''}`, splitAmount: ({amount}: SplitAmountParams) => `teilen ${amount}`, didSplitAmount: ({formattedAmount, comment}: DidSplitAmountMessageParams) => `split ${formattedAmount}${comment ? `für ${comment}` : ''}`, diff --git a/src/languages/en.ts b/src/languages/en.ts index 8be133081f7cd..e7e4b972f5165 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1283,7 +1283,6 @@ const translations = { submitted: ({memo}: SubmittedWithMemoParams) => `submitted${memo ? `, saying ${memo}` : ''}`, automaticallySubmitted: `submitted via delay submissions`, queuedToSubmitViaDEW: 'queued to submit via custom approval workflow', - dynamicExternalWorkflowCannotSubmit: "This report can't be submitted. Please review the comments to resolve.", 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 e806c0dfb704e..efc716c311879 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -943,7 +943,6 @@ 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', - dynamicExternalWorkflowCannotSubmit: 'Este informe no se puede enviar. Revise los comentarios para solucionarlo.', 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 5e63a3992f2f2..1e216b747f85b 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1305,7 +1305,6 @@ const translations: TranslationDeepObject = { submitted: ({memo}: SubmittedWithMemoParams) => `soumis${memo ? `, en disant ${memo}` : ''}`, automaticallySubmitted: `soumis via soumissions différées`, queuedToSubmitViaDEW: "en file d'attente pour être soumis via le workflow d'approbation personnalisé", - dynamicExternalWorkflowCannotSubmit: 'Ce rapport ne peut pas être soumis. Veuillez consulter les commentaires pour résoudre.', trackedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `suivi ${formattedAmount}${comment ? `pour ${comment}` : ''}`, splitAmount: ({amount}: SplitAmountParams) => `diviser ${amount}`, didSplitAmount: ({formattedAmount, comment}: DidSplitAmountMessageParams) => `split ${formattedAmount}${comment ? `pour ${comment}` : ''}`, diff --git a/src/languages/it.ts b/src/languages/it.ts index 86456047cbab8..cae71ccf065f7 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1299,7 +1299,6 @@ 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", - dynamicExternalWorkflowCannotSubmit: 'Questo rapporto non può essere inviato. Si prega di rivedere i commenti per risolvere.', trackedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `tracking ${formattedAmount}${comment ? `per ${comment}` : ''}`, splitAmount: ({amount}: SplitAmountParams) => `dividi ${amount}`, didSplitAmount: ({formattedAmount, comment}: DidSplitAmountMessageParams) => `split ${formattedAmount}${comment ? `per ${comment}` : ''}`, diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 52943fa29e455..acf26979aa185 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1301,7 +1301,6 @@ const translations: TranslationDeepObject = { submitted: ({memo}: SubmittedWithMemoParams) => `提出済み${memo ? `、次のように言って ${memo}` : ''}`, automaticallySubmitted: `送信の遅延を通じて送信されました`, queuedToSubmitViaDEW: 'カスタム承認ワークフローを介して送信待ちキューに入れられました', - dynamicExternalWorkflowCannotSubmit: 'このレポートは送信できません。解決するにはコメントを確認してください。', trackedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `tracking ${formattedAmount}${comment ? `${comment} のために` : ''}`, splitAmount: ({amount}: SplitAmountParams) => `${amount} を分割`, didSplitAmount: ({formattedAmount, comment}: DidSplitAmountMessageParams) => `split ${formattedAmount}${comment ? `${comment} のために` : ''}`, diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 5d2bcfe783c5e..68b32e0214485 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1299,7 +1299,6 @@ const translations: TranslationDeepObject = { submitted: ({memo}: SubmittedWithMemoParams) => `ingediend${memo ? `, zegt ${memo}` : ''}`, automaticallySubmitted: `ingediend via vertraging indieningen`, queuedToSubmitViaDEW: 'in wachtrij geplaatst om in te dienen via aangepaste goedkeuringswerkstroom', - dynamicExternalWorkflowCannotSubmit: 'Dit rapport kan niet worden ingediend. Bekijk de opmerkingen om op te lossen.', trackedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `volgt ${formattedAmount}${comment ? `voor ${comment}` : ''}`, splitAmount: ({amount}: SplitAmountParams) => `splitsen ${amount}`, didSplitAmount: ({formattedAmount, comment}: DidSplitAmountMessageParams) => `split ${formattedAmount}${comment ? `voor ${comment}` : ''}`, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 07bf292476f0c..2548bbcf61868 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1298,7 +1298,6 @@ const translations: TranslationDeepObject = { submitted: ({memo}: SubmittedWithMemoParams) => `przesłano${memo ? `, mówiąc ${memo}` : ''}`, automaticallySubmitted: `przesłane za pomocą opóźnij zgłoszenia`, queuedToSubmitViaDEW: 'umieszczone w kolejce do przesłania przez niestandardowy przepływ zatwierdzania', - dynamicExternalWorkflowCannotSubmit: 'Ten raport nie może zostać przesłany. Proszę sprawdzić komentarze, aby rozwiązać problem.', 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 7627dff13fe5b..6ce90eb4b6523 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1297,7 +1297,6 @@ const translations: TranslationDeepObject = { submitted: ({memo}: SubmittedWithMemoParams) => `enviado${memo ? `, dizendo ${memo}` : ''}`, automaticallySubmitted: `enviado via adiar envios`, queuedToSubmitViaDEW: 'enfileirado para envio via fluxo de aprovação personalizado', - dynamicExternalWorkflowCannotSubmit: 'Este relatório não pode ser enviado. Por favor, revise os comentários para resolver.', trackedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `rastreamento ${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 3bf4286bb444d..a5ce3a1e08ae9 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1281,7 +1281,6 @@ const translations: TranslationDeepObject = { submitted: ({memo}: SubmittedWithMemoParams) => `已提交${memo ? `, 备注 ${memo}` : ''}`, automaticallySubmitted: `通过延迟提交提交`, queuedToSubmitViaDEW: '已排队等待通过自定义审批工作流提交', - dynamicExternalWorkflowCannotSubmit: '无法提交此报告。请查看评论以解决问题。', trackedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `跟踪 ${formattedAmount}${comment ? `对于${comment}` : ''}`, splitAmount: ({amount}: SplitAmountParams) => `拆分 ${amount}`, didSplitAmount: ({formattedAmount, comment}: DidSplitAmountMessageParams) => `split ${formattedAmount}${comment ? `对于${comment}` : ''}`, diff --git a/src/libs/NextStepUtils.ts b/src/libs/NextStepUtils.ts index 150ffa0253a1b..58a683323f0f2 100644 --- a/src/libs/NextStepUtils.ts +++ b/src/libs/NextStepUtils.ts @@ -325,6 +325,20 @@ function buildOptimisticNextStepForStrictPolicyRuleViolations() { return optimisticNextStep; } +function buildOptimisticNextStepForDynamicExternalWorkflowError() { + const optimisticNextStep: ReportNextStepDeprecated = { + type: 'alert', + icon: CONST.NEXT_STEP.ICONS.DOT_INDICATOR, + message: [ + { + text: "This report can't be submitted. Please review the comments to resolve.", + }, + ], + }; + + return optimisticNextStep; +} + /** * Generates an optimistic nextStep based on a current report status and other properties. * Need to rename this function and remove the buildNextStep function above after migrating to this function @@ -707,6 +721,7 @@ export { parseMessage, buildOptimisticNextStepForPreventSelfApprovalsEnabled, buildOptimisticNextStepForStrictPolicyRuleViolations, + buildOptimisticNextStepForDynamicExternalWorkflowError, // eslint-disable-next-line @typescript-eslint/no-deprecated buildNextStepNew, }; diff --git a/tests/unit/NextStepUtilsTest.ts b/tests/unit/NextStepUtilsTest.ts index 31fd3176089e4..2b18b044637fd 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, buildOptimisticNextStepForStrictPolicyRuleViolations} from '@libs/NextStepUtils'; +import {buildNextStepNew, buildOptimisticNextStepForDynamicExternalWorkflowError, buildOptimisticNextStepForStrictPolicyRuleViolations} from '@libs/NextStepUtils'; import {buildOptimisticEmptyReport, buildOptimisticExpenseReport} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -1046,4 +1046,20 @@ describe('libs/NextStepUtils', () => { }); }); }); + + describe('buildOptimisticNextStepForDynamicExternalWorkflowError', () => { + test('returns correct next step message for DEW submit failed', () => { + const result = buildOptimisticNextStepForDynamicExternalWorkflowError(); + + expect(result).toEqual({ + type: 'alert', + icon: CONST.NEXT_STEP.ICONS.DOT_INDICATOR, + message: [ + { + text: "This report can't be submitted. Please review the comments to resolve.", + }, + ], + }); + }); + }); }); From a416d620b9425a1e9ba8653f268e8c84ea96d5ca Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Wed, 3 Dec 2025 18:34:14 +0100 Subject: [PATCH 37/76] making next steps style as red --- src/components/MoneyReportHeaderStatusBar.tsx | 2 +- src/libs/NextStepUtils.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/MoneyReportHeaderStatusBar.tsx b/src/components/MoneyReportHeaderStatusBar.tsx index 65a547f8e4790..7007ce669b355 100644 --- a/src/components/MoneyReportHeaderStatusBar.tsx +++ b/src/components/MoneyReportHeaderStatusBar.tsx @@ -44,7 +44,7 @@ function MoneyReportHeaderStatusBar({nextStep}: MoneyReportHeaderStatusBarProps) src={(nextStep?.icon && iconMap?.[nextStep.icon]) ?? Expensicons.Hourglass} height={variables.iconSizeSmall} width={variables.iconSizeSmall} - fill={theme.icon} + fill={nextStep?.type === 'alert' ? theme.danger : theme.icon} /> diff --git a/src/libs/NextStepUtils.ts b/src/libs/NextStepUtils.ts index 58a683323f0f2..dc17bd36f7074 100644 --- a/src/libs/NextStepUtils.ts +++ b/src/libs/NextStepUtils.ts @@ -332,6 +332,7 @@ function buildOptimisticNextStepForDynamicExternalWorkflowError() { message: [ { text: "This report can't be submitted. Please review the comments to resolve.", + type: 'alert-text', }, ], }; From eba8d9c17001c39ce6df50b47f76c1770b084a0a Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Wed, 3 Dec 2025 18:40:38 +0100 Subject: [PATCH 38/76] minor edit --- .../MoneyRequestReportPreviewContent.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx index dfa07af736574..0b0a9aa967af0 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx @@ -44,7 +44,6 @@ import Navigation from '@libs/Navigation/Navigation'; import Performance from '@libs/Performance'; import {getConnectedIntegration, hasDynamicExternalWorkflow} from '@libs/PolicyUtils'; import {isDynamicExternalWorkflowSubmitFailedAction} from '@libs/ReportActionsUtils'; -import {getReportPreviewAction} from '@libs/ReportPreviewActionUtils'; import {getInvoicePayerName} from '@libs/ReportNameUtils'; import getReportPreviewAction from '@libs/ReportPreviewActionUtils'; import { From dd7731ff162a4a96a7f064015be0b1e3442e6073 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Wed, 3 Dec 2025 19:11:22 +0100 Subject: [PATCH 39/76] Replace review with view inside the preview actions --- src/libs/ReportPreviewActionUtils.ts | 4 +- src/libs/SearchUIUtils.ts | 4 +- tests/actions/ReportPreviewActionUtilsTest.ts | 69 ++++--------------- tests/unit/Search/SearchUIUtilsTest.ts | 16 ++--- 4 files changed, 25 insertions(+), 68 deletions(-) diff --git a/src/libs/ReportPreviewActionUtils.ts b/src/libs/ReportPreviewActionUtils.ts index b70e7ef16a547..f5208d0521c88 100644 --- a/src/libs/ReportPreviewActionUtils.ts +++ b/src/libs/ReportPreviewActionUtils.ts @@ -178,9 +178,9 @@ function getReportPreviewAction( return CONST.REPORT.REPORT_PREVIEW_ACTIONS.ADD_EXPENSE; } - // If DEW submit failed and report is still open, show REVIEW + // If DEW submit failed and report is still open, show VIEW (user needs to review errors in report comments) if (hasDEWSubmitFailed && isOpenReport(report)) { - return CONST.REPORT.REPORT_PREVIEW_ACTIONS.REVIEW; + return CONST.REPORT.REPORT_PREVIEW_ACTIONS.VIEW; } if (canSubmit(report, isReportArchived, currentUserAccountID, policy, transactions)) { diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 7a5c881c63c59..8d1ee2f2ac141 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -1239,10 +1239,10 @@ function getActions( return [CONST.SEARCH.ACTION_TYPES.VIEW]; } - // Check for DEW submit failed - if the report has a DEW_SUBMIT_FAILED action and is still OPEN, show Review + // Check for DEW submit failed - if the report has a DEW_SUBMIT_FAILED action and is still OPEN, show View const hasDEWSubmitFailed = report.statusNum === CONST.REPORT.STATUS_NUM.OPEN && reportActions.some(isDynamicExternalWorkflowSubmitFailedAction); if (hasDEWSubmitFailed) { - return [CONST.SEARCH.ACTION_TYPES.REVIEW]; + return [CONST.SEARCH.ACTION_TYPES.VIEW]; } // We don't need to run the logic if this is not a transaction or iou/expense report, so let's shortcut the logic for performance reasons diff --git a/tests/actions/ReportPreviewActionUtilsTest.ts b/tests/actions/ReportPreviewActionUtilsTest.ts index 124c1add70729..0a5392822841e 100644 --- a/tests/actions/ReportPreviewActionUtilsTest.ts +++ b/tests/actions/ReportPreviewActionUtilsTest.ts @@ -491,7 +491,7 @@ describe('getReportPreviewAction', () => { }); describe('DEW (Dynamic External Workflow) submit failed', () => { - it('should return REVIEW when hasDEWSubmitFailed is true and report is OPEN', async () => { + it('should return VIEW when hasDEWSubmitFailed is true and report is OPEN', async () => { const report: Report = { ...createRandomReport(REPORT_ID, undefined), type: CONST.REPORT.TYPE.EXPENSE, @@ -512,26 +512,13 @@ describe('getReportPreviewAction', () => { const {result: isReportArchived} = renderHook(() => useReportIsArchived(report?.parentReportID)); await waitForBatchedUpdatesWithAct(); - expect( - getReportPreviewAction( - VIOLATIONS, - isReportArchived.current, - CURRENT_USER_EMAIL, - CURRENT_USER_ACCOUNT_ID, - report, - policy, - [transaction], - undefined, - false, - false, - false, - false, - true, - ), - ).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.REVIEW); + // hasDEWSubmitFailed = true (last parameter) + expect(getReportPreviewAction(isReportArchived.current, CURRENT_USER_ACCOUNT_ID, report, policy, [transaction], undefined, false, false, false, true)).toBe( + CONST.REPORT.REPORT_PREVIEW_ACTIONS.VIEW, + ); }); - it('should NOT return REVIEW when hasDEWSubmitFailed is true but report is not OPEN', async () => { + it('should NOT return VIEW when hasDEWSubmitFailed is true but report is not OPEN', async () => { const report: Report = { ...createRandomReport(REPORT_ID, undefined), type: CONST.REPORT.TYPE.EXPENSE, @@ -556,26 +543,13 @@ describe('getReportPreviewAction', () => { const {result: isReportArchived} = renderHook(() => useReportIsArchived(report?.parentReportID)); await waitForBatchedUpdatesWithAct(); - expect( - getReportPreviewAction( - VIOLATIONS, - isReportArchived.current, - CURRENT_USER_EMAIL, - CURRENT_USER_ACCOUNT_ID, - report, - policy, - [transaction], - undefined, - false, - false, - false, - false, - true, - ), - ).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.APPROVE); + // hasDEWSubmitFailed = true, but report is SUBMITTED, so DEW check doesn't apply + expect(getReportPreviewAction(isReportArchived.current, CURRENT_USER_ACCOUNT_ID, report, policy, [transaction], undefined, false, false, false, true)).toBe( + CONST.REPORT.REPORT_PREVIEW_ACTIONS.APPROVE, + ); }); - it('should NOT return REVIEW when hasDEWSubmitFailed is false', async () => { + it('should NOT return VIEW due to DEW when hasDEWSubmitFailed is false', async () => { const report: Report = { ...createRandomReport(REPORT_ID, undefined), type: CONST.REPORT.TYPE.EXPENSE, @@ -600,23 +574,10 @@ describe('getReportPreviewAction', () => { const {result: isReportArchived} = renderHook(() => useReportIsArchived(report?.parentReportID)); await waitForBatchedUpdatesWithAct(); - expect( - getReportPreviewAction( - VIOLATIONS, - isReportArchived.current, - CURRENT_USER_EMAIL, - CURRENT_USER_ACCOUNT_ID, - report, - policy, - [transaction], - undefined, - false, - false, - false, - false, - false, - ), - ).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.VIEW); + // hasDEWSubmitFailed = false (last parameter), regular logic applies + expect(getReportPreviewAction(isReportArchived.current, CURRENT_USER_ACCOUNT_ID, report, policy, [transaction], undefined, false, false, false, false)).not.toBe( + CONST.REPORT.REPORT_PREVIEW_ACTIONS.VIEW, + ); }); }); }); diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index 6df9982e59c05..cdc747b8ad2c5 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -1629,7 +1629,7 @@ describe('SearchUIUtils', () => { expect(action).toEqual(CONST.SEARCH.ACTION_TYPES.PAY); }); - test('Should return `Review` action when report has DEW_SUBMIT_FAILED action and is still OPEN', async () => { + test('Should return `View` action when report has DEW_SUBMIT_FAILED action and is still OPEN', async () => { const dewReportID = '999'; const dewTransactionID = '9999'; const dewReportActionID = '99999'; @@ -1662,13 +1662,11 @@ describe('SearchUIUtils', () => { }, ] as OnyxTypes.ReportAction[]; - const action = SearchUIUtils.getActions(localSearchResults, {}, `transactions_${dewTransactionID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, adminAccountID, '', dewReportActions).at( - 0, - ); - expect(action).toStrictEqual(CONST.SEARCH.ACTION_TYPES.REVIEW); + const action = SearchUIUtils.getActions(localSearchResults, {}, `transactions_${dewTransactionID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', dewReportActions).at(0); + expect(action).toStrictEqual(CONST.SEARCH.ACTION_TYPES.VIEW); }); - test('Should NOT return `Review` action when report has DEW_SUBMIT_FAILED action but is not OPEN', async () => { + test('Should NOT return `View` action when report has DEW_SUBMIT_FAILED action but is not OPEN', async () => { const dewReportID = '888'; const dewTransactionID = '8888'; const dewReportActionID = '88888'; @@ -1701,10 +1699,8 @@ describe('SearchUIUtils', () => { }, ] as OnyxTypes.ReportAction[]; - const action = SearchUIUtils.getActions(localSearchResults, {}, `transactions_${dewTransactionID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, adminAccountID, '', dewReportActions).at( - 0, - ); - expect(action).not.toStrictEqual(CONST.SEARCH.ACTION_TYPES.REVIEW); + const action = SearchUIUtils.getActions(localSearchResults, {}, `transactions_${dewTransactionID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', dewReportActions).at(0); + expect(action).not.toStrictEqual(CONST.SEARCH.ACTION_TYPES.VIEW); }); }); From 346845354ba8a73ac9f31bcc2f344ac8705af108 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Wed, 3 Dec 2025 19:36:47 +0100 Subject: [PATCH 40/76] fixing tests --- tests/unit/NextStepUtilsTest.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/NextStepUtilsTest.ts b/tests/unit/NextStepUtilsTest.ts index 2b18b044637fd..6140ff3ec3433 100644 --- a/tests/unit/NextStepUtilsTest.ts +++ b/tests/unit/NextStepUtilsTest.ts @@ -1057,6 +1057,7 @@ describe('libs/NextStepUtils', () => { message: [ { text: "This report can't be submitted. Please review the comments to resolve.", + type: 'alert-text', }, ], }); From 8f6467f0d4370aecaffb7b9044371f9a89a24aa7 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Wed, 3 Dec 2025 20:14:20 +0100 Subject: [PATCH 41/76] code refactoring --- .../MoneyRequestReportPreviewContent.tsx | 7 +- src/libs/ReportActionsUtils.ts | 36 +++ src/libs/ReportUtils.ts | 35 +-- src/libs/SearchUIUtils.ts | 13 +- src/libs/actions/IOU.ts | 6 +- tests/unit/ReportActionsUtilsTest.ts | 215 +++++++++++++++++- 6 files changed, 267 insertions(+), 45 deletions(-) diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx index 0b0a9aa967af0..ddfd2d2627ded 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx @@ -43,7 +43,7 @@ import {getTotalAmountForIOUReportPreviewButton} from '@libs/MoneyRequestReportU import Navigation from '@libs/Navigation/Navigation'; import Performance from '@libs/Performance'; import {getConnectedIntegration, hasDynamicExternalWorkflow} from '@libs/PolicyUtils'; -import {isDynamicExternalWorkflowSubmitFailedAction} from '@libs/ReportActionsUtils'; +import {getMostRecentActiveDEWSubmitFailedAction} from '@libs/ReportActionsUtils'; import {getInvoicePayerName} from '@libs/ReportNameUtils'; import getReportPreviewAction from '@libs/ReportPreviewActionUtils'; import { @@ -490,10 +490,7 @@ function MoneyRequestReportPreviewContent({ Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(iouReportID, undefined, undefined, Navigation.getActiveRoute())); }, [iouReportID]); - const hasDEWSubmitFailed = useMemo(() => { - return Object.values(reportActions ?? {}).some(isDynamicExternalWorkflowSubmitFailedAction); - }, [reportActions]); - + const hasDEWSubmitFailed = useMemo(() => !!getMostRecentActiveDEWSubmitFailedAction(reportActions), [reportActions]); const reportPreviewAction = useMemo(() => { return getReportPreviewAction( isIouReportArchived || isChatReportArchived, diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 1525f975d22d8..2ecb160d517f6 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -239,6 +239,41 @@ function isDynamicExternalWorkflowSubmitFailedAction(reportAction: OnyxInputOrEn return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.DEW_SUBMIT_FAILED); } +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; + } + return latest; + }, undefined); + + 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; + } + + return undefined; +} + function isModifiedExpenseAction(reportAction: OnyxInputOrEntry): reportAction is ReportAction { return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE); } @@ -3487,6 +3522,7 @@ export { isUnapprovedAction, isForwardedAction, isDynamicExternalWorkflowSubmitFailedAction, + getMostRecentActiveDEWSubmitFailedAction, isWhisperActionTargetedToOthers, isTagModificationAction, isIOUActionMatchingTransactionList, diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 9ddddfd0d1301..4b500ed612888 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -183,6 +183,7 @@ import { getLastVisibleMessage as getLastVisibleMessageActionUtils, getLastVisibleMessage as getLastVisibleMessageReportActionsUtils, getMessageOfOldDotReportAction, + getMostRecentActiveDEWSubmitFailedAction, getNumberOfMoneyRequests, getOneTransactionThreadReportID, getOriginalMessage, @@ -243,7 +244,6 @@ import { isRoomChangeLogAction, isSentMoneyReportAction, isSplitBillAction as isSplitBillReportAction, - isSubmittedAction, isTagModificationAction, isThreadParentMessage, isTrackExpenseAction, @@ -9302,35 +9302,10 @@ function getAllReportActionsErrorsAndReportActionThatRequiresAttention( } if (!isReportArchived && report?.statusNum === CONST.REPORT.STATUS_NUM.OPEN) { - // Find the most recent DEW_SUBMIT_FAILED action - const mostRecentDewSubmitFailedAction = reportActionsArray - .filter((action) => isDynamicExternalWorkflowSubmitFailedAction(action)) - .reduce((latest, current) => { - if (!latest || (current.created && latest.created && current.created > latest.created)) { - return current; - } - return latest; - }, undefined); - - if (mostRecentDewSubmitFailedAction) { - // find the most recent SUBMITTED action - const mostRecentSubmittedAction = reportActionsArray - .filter((action) => isSubmittedAction(action)) - .reduce((latest, current) => { - if (!latest || (current.created && latest.created && current.created > latest.created)) { - return current; - } - return latest; - }, undefined); - - const shouldShowDEWError = !mostRecentSubmittedAction || (mostRecentSubmittedAction.created && mostRecentDewSubmitFailedAction.created > mostRecentSubmittedAction.created); - - if (shouldShowDEWError) { - reportActionErrors.dewSubmitFailed = getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericDEWSubmitFailureMessage'); - if (!reportAction) { - reportAction = mostRecentDewSubmitFailedAction; - } - } + const mostRecentActiveDEWAction = getMostRecentActiveDEWSubmitFailedAction(reportActionsArray); + if (mostRecentActiveDEWAction) { + reportActionErrors.dewSubmitFailed = getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericDEWSubmitFailureMessage'); + reportAction = mostRecentActiveDEWAction; } } diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 8d1ee2f2ac141..4514feb7cdf49 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -78,10 +78,10 @@ import {getDisplayNameOrDefault} from './PersonalDetailsUtils'; import {arePaymentsEnabled, canSendInvoice, getGroupPaidPoliciesWithExpenseChatEnabled, getPolicy, isPaidGroupPolicy, isPolicyPayer} from './PolicyUtils'; import { getIOUActionForReportID, + getMostRecentActiveDEWSubmitFailedAction, getOriginalMessage, isCreatedAction, isDeletedAction, - isDynamicExternalWorkflowSubmitFailedAction, isHoldAction, isMoneyRequestAction, isResolvedActionableWhisper, @@ -1051,6 +1051,7 @@ function getTransactionsSections( currentUserEmail: string, formatPhoneNumber: LocaleContextProps['formatPhoneNumber'], isActionLoadingSet: ReadonlySet | undefined, + reportActions: Record = {}, ): [TransactionListItemType[], number] { const shouldShowMerchant = getShouldShowMerchant(data); const doesDataContainAPastYearTransaction = shouldShowYear(data); @@ -1111,7 +1112,8 @@ function getTransactionsSections( formatPhoneNumber, report, ); - const allActions = getActions(data, allViolations, key, currentSearch, currentUserEmail); + const actions = reportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionItem.reportID}`] ?? []; + const allActions = getActions(data, allViolations, key, currentSearch, currentUserEmail, actions); const transactionSection: TransactionListItemType = { ...transactionItem, keyForList: transactionItem.transactionID, @@ -1239,9 +1241,8 @@ function getActions( return [CONST.SEARCH.ACTION_TYPES.VIEW]; } - // Check for DEW submit failed - if the report has a DEW_SUBMIT_FAILED action and is still OPEN, show View - const hasDEWSubmitFailed = report.statusNum === CONST.REPORT.STATUS_NUM.OPEN && reportActions.some(isDynamicExternalWorkflowSubmitFailedAction); - if (hasDEWSubmitFailed) { + // Check for DEW submit failed - if the report has a DEW_SUBMIT_FAILED action (more recent than last SUBMITTED) and is still OPEN, show View + if (report.statusNum === CONST.REPORT.STATUS_NUM.OPEN && !!getMostRecentActiveDEWSubmitFailedAction(reportActions)) { return [CONST.SEARCH.ACTION_TYPES.VIEW]; } @@ -1840,7 +1841,7 @@ function getSections({ } } - return getTransactionsSections(data, currentSearch, currentAccountID, currentUserEmail, formatPhoneNumber, isActionLoadingSet); + return getTransactionsSections(data, currentSearch, currentAccountID, currentUserEmail, formatPhoneNumber, isActionLoadingSet, reportActions); } /** diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index fe83d6d324cd8..eab6eb406b040 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -11451,10 +11451,10 @@ function submitReport( lastMessageHtml: getReportActionHtml(optimisticSubmittedReportAction), stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, - ...(isDEWPolicy ? {} : {nextStep: optimisticNextStep}), ...(isDEWPolicy ? {} : { + nextStep: optimisticNextStep, pendingFields: { nextStep: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, }, @@ -11468,10 +11468,10 @@ function submitReport( ...expenseReport, stateNum: CONST.REPORT.STATE_NUM.APPROVED, statusNum: CONST.REPORT.STATUS_NUM.CLOSED, - ...(isDEWPolicy ? {} : {nextStep: optimisticNextStep}), ...(isDEWPolicy ? {} : { + nextStep: optimisticNextStep, pendingFields: { nextStep: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, }, @@ -11530,10 +11530,10 @@ function submitReport( value: { statusNum: CONST.REPORT.STATUS_NUM.OPEN, stateNum: CONST.REPORT.STATE_NUM.OPEN, - ...(isDEWPolicy ? {} : {nextStep: expenseReport.nextStep ?? null}), ...(isDEWPolicy ? {} : { + nextStep: expenseReport.nextStep ?? null, pendingFields: { nextStep: null, }, diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts index 8454126984864..57246c35f4279 100644 --- a/tests/unit/ReportActionsUtilsTest.ts +++ b/tests/unit/ReportActionsUtilsTest.ts @@ -9,7 +9,7 @@ import CONST from '../../src/CONST'; import * as ReportActionsUtils from '../../src/libs/ReportActionsUtils'; import {getCardIssuedMessage, getOneTransactionThreadReportID, getOriginalMessage, getSendMoneyFlowAction, isIOUActionMatchingTransactionList} from '../../src/libs/ReportActionsUtils'; import ONYXKEYS from '../../src/ONYXKEYS'; -import type {Card, OriginalMessageIOU, Report, ReportAction} from '../../src/types/onyx'; +import type {Card, OriginalMessageIOU, Report, ReportAction, ReportActions} from '../../src/types/onyx'; import createRandomReportAction from '../utils/collections/reportActions'; import {createRandomReport} from '../utils/collections/reports'; import * as LHNTestUtils from '../utils/LHNTestUtils'; @@ -1514,4 +1514,217 @@ describe('ReportActionsUtils', () => { expect(ReportActionsUtils.isDynamicExternalWorkflowSubmitFailedAction(null)).toBe(false); }); }); + + describe('getMostRecentActiveDEWSubmitFailedAction', () => { + it('should return the DEW action when DEW_SUBMIT_FAILED exists and no SUBMITTED action exists', () => { + const reportActions: ReportActions = { + '1': { + ...createRandomReportAction(0), + actionName: CONST.REPORT.ACTIONS.TYPE.DEW_SUBMIT_FAILED, + created: '2025-11-21 10:00:00', + reportActionID: '1', + originalMessage: { + message: 'DEW submit failed', + }, + message: [], + previousMessage: [], + } as ReportAction, + }; + const result = ReportActionsUtils.getMostRecentActiveDEWSubmitFailedAction(reportActions); + expect(result).toBeDefined(); + expect(result?.reportActionID).toBe('1'); + }); + + it('should return the DEW action when DEW_SUBMIT_FAILED is more recent than SUBMITTED', () => { + const reportActions: ReportActions = { + '1': { + ...createRandomReportAction(0), + actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, + created: '2025-11-21 09:00:00', + reportActionID: '1', + originalMessage: { + amount: 10000, + currency: 'USD', + }, + message: [], + previousMessage: [], + } as ReportAction, + '2': { + ...createRandomReportAction(0), + actionName: CONST.REPORT.ACTIONS.TYPE.DEW_SUBMIT_FAILED, + created: '2025-11-21 10:00:00', + reportActionID: '2', + originalMessage: { + message: 'DEW submit failed', + }, + message: [], + previousMessage: [], + } as ReportAction, + }; + const result = ReportActionsUtils.getMostRecentActiveDEWSubmitFailedAction(reportActions); + expect(result).toBeDefined(); + expect(result?.reportActionID).toBe('2'); + }); + + it('should return undefined when SUBMITTED is more recent than DEW_SUBMIT_FAILED', () => { + const reportActions: ReportActions = { + '1': { + ...createRandomReportAction(0), + actionName: CONST.REPORT.ACTIONS.TYPE.DEW_SUBMIT_FAILED, + created: '2025-11-21 09:00:00', + reportActionID: '1', + originalMessage: { + message: 'DEW submit failed', + }, + message: [], + previousMessage: [], + } as ReportAction, + '2': { + ...createRandomReportAction(0), + actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, + created: '2025-11-21 10:00:00', + reportActionID: '2', + originalMessage: { + amount: 10000, + currency: 'USD', + }, + message: [], + previousMessage: [], + } as ReportAction, + }; + expect(ReportActionsUtils.getMostRecentActiveDEWSubmitFailedAction(reportActions)).toBeUndefined(); + }); + + it('should return undefined when no DEW_SUBMIT_FAILED action exists', () => { + const reportActions: ReportActions = { + '1': { + ...createRandomReportAction(0), + actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, + created: '2025-11-21 10:00:00', + reportActionID: '1', + originalMessage: { + amount: 10000, + currency: 'USD', + }, + message: [], + previousMessage: [], + } as ReportAction, + }; + expect(ReportActionsUtils.getMostRecentActiveDEWSubmitFailedAction(reportActions)).toBeUndefined(); + }); + + it('should return undefined for empty report actions', () => { + expect(ReportActionsUtils.getMostRecentActiveDEWSubmitFailedAction({})).toBeUndefined(); + }); + + it('should work with array input', () => { + const reportActionsArray: ReportAction[] = [ + { + ...createRandomReportAction(0), + actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, + created: '2025-11-21 09:00:00', + reportActionID: '1', + originalMessage: { + amount: 10000, + currency: 'USD', + }, + message: [], + previousMessage: [], + } as ReportAction, + { + ...createRandomReportAction(0), + actionName: CONST.REPORT.ACTIONS.TYPE.DEW_SUBMIT_FAILED, + created: '2025-11-21 10:00:00', + reportActionID: '2', + originalMessage: { + message: 'DEW submit failed', + }, + message: [], + previousMessage: [], + } as ReportAction, + ]; + const result = ReportActionsUtils.getMostRecentActiveDEWSubmitFailedAction(reportActionsArray); + expect(result).toBeDefined(); + expect(result?.reportActionID).toBe('2'); + }); + + it('should return the most recent DEW action when multiple DEW_SUBMIT_FAILED and SUBMITTED actions exist', () => { + const reportActions: ReportActions = { + '1': { + ...createRandomReportAction(0), + actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, + created: '2025-11-21 08:00:00', + reportActionID: '1', + originalMessage: {amount: 10000, currency: 'USD'}, + message: [], + previousMessage: [], + } as ReportAction, + '2': { + ...createRandomReportAction(0), + actionName: CONST.REPORT.ACTIONS.TYPE.DEW_SUBMIT_FAILED, + created: '2025-11-21 09:00:00', + reportActionID: '2', + originalMessage: {message: 'First DEW failure'}, + message: [], + previousMessage: [], + } as ReportAction, + '3': { + ...createRandomReportAction(0), + actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, + created: '2025-11-21 10:00:00', + reportActionID: '3', + originalMessage: {amount: 10000, currency: 'USD'}, + message: [], + previousMessage: [], + } as ReportAction, + '4': { + ...createRandomReportAction(0), + actionName: CONST.REPORT.ACTIONS.TYPE.DEW_SUBMIT_FAILED, + created: '2025-11-21 11:00:00', + reportActionID: '4', + originalMessage: {message: 'Second DEW failure'}, + message: [], + previousMessage: [], + } as ReportAction, + }; + // Most recent DEW_SUBMIT_FAILED (11:00) is after most recent SUBMITTED (10:00) + const result = ReportActionsUtils.getMostRecentActiveDEWSubmitFailedAction(reportActions); + expect(result).toBeDefined(); + expect(result?.reportActionID).toBe('4'); + }); + + it('should return undefined when most recent SUBMITTED is after all DEW_SUBMIT_FAILED actions', () => { + const reportActions: ReportActions = { + '1': { + ...createRandomReportAction(0), + actionName: CONST.REPORT.ACTIONS.TYPE.DEW_SUBMIT_FAILED, + created: '2025-11-21 08:00:00', + reportActionID: '1', + originalMessage: {message: 'First DEW failure'}, + message: [], + previousMessage: [], + } as ReportAction, + '2': { + ...createRandomReportAction(0), + actionName: CONST.REPORT.ACTIONS.TYPE.DEW_SUBMIT_FAILED, + created: '2025-11-21 09:00:00', + reportActionID: '2', + originalMessage: {message: 'Second DEW failure'}, + message: [], + previousMessage: [], + } as ReportAction, + '3': { + ...createRandomReportAction(0), + actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, + created: '2025-11-21 10:00:00', + reportActionID: '3', + originalMessage: {amount: 10000, currency: 'USD'}, + message: [], + previousMessage: [], + } as ReportAction, + }; + // Most recent SUBMITTED (10:00) is after all DEW_SUBMIT_FAILED actions + expect(ReportActionsUtils.getMostRecentActiveDEWSubmitFailedAction(reportActions)).toBeUndefined(); + }); + }); }); From b654fa7304ecd3cde0867dbff58d7195dd2a6c46 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Wed, 3 Dec 2025 20:45:57 +0100 Subject: [PATCH 42/76] fixing eslint --- tests/unit/ReportActionsUtilsTest.ts | 39 ++++++++++++++++++---------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts index 57246c35f4279..f563e28cf1d41 100644 --- a/tests/unit/ReportActionsUtilsTest.ts +++ b/tests/unit/ReportActionsUtilsTest.ts @@ -1517,8 +1517,9 @@ describe('ReportActionsUtils', () => { describe('getMostRecentActiveDEWSubmitFailedAction', () => { it('should return the DEW action when DEW_SUBMIT_FAILED exists and no SUBMITTED action exists', () => { + const actionId1 = '1'; const reportActions: ReportActions = { - '1': { + [actionId1]: { ...createRandomReportAction(0), actionName: CONST.REPORT.ACTIONS.TYPE.DEW_SUBMIT_FAILED, created: '2025-11-21 10:00:00', @@ -1536,8 +1537,10 @@ describe('ReportActionsUtils', () => { }); it('should return the DEW action when DEW_SUBMIT_FAILED is more recent than SUBMITTED', () => { + const actionId1 = '1'; + const actionId2 = '2'; const reportActions: ReportActions = { - '1': { + [actionId1]: { ...createRandomReportAction(0), actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, created: '2025-11-21 09:00:00', @@ -1549,7 +1552,7 @@ describe('ReportActionsUtils', () => { message: [], previousMessage: [], } as ReportAction, - '2': { + [actionId2]: { ...createRandomReportAction(0), actionName: CONST.REPORT.ACTIONS.TYPE.DEW_SUBMIT_FAILED, created: '2025-11-21 10:00:00', @@ -1567,8 +1570,10 @@ describe('ReportActionsUtils', () => { }); it('should return undefined when SUBMITTED is more recent than DEW_SUBMIT_FAILED', () => { + const actionId1 = '1'; + const actionId2 = '2'; const reportActions: ReportActions = { - '1': { + [actionId1]: { ...createRandomReportAction(0), actionName: CONST.REPORT.ACTIONS.TYPE.DEW_SUBMIT_FAILED, created: '2025-11-21 09:00:00', @@ -1579,7 +1584,7 @@ describe('ReportActionsUtils', () => { message: [], previousMessage: [], } as ReportAction, - '2': { + [actionId2]: { ...createRandomReportAction(0), actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, created: '2025-11-21 10:00:00', @@ -1596,8 +1601,9 @@ describe('ReportActionsUtils', () => { }); it('should return undefined when no DEW_SUBMIT_FAILED action exists', () => { + const actionId1 = '1'; const reportActions: ReportActions = { - '1': { + [actionId1]: { ...createRandomReportAction(0), actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, created: '2025-11-21 10:00:00', @@ -1649,8 +1655,12 @@ describe('ReportActionsUtils', () => { }); it('should return the most recent DEW action when multiple DEW_SUBMIT_FAILED and SUBMITTED actions exist', () => { + const actionId1 = '1'; + const actionId2 = '2'; + const actionId3 = '3'; + const actionId4 = '4'; const reportActions: ReportActions = { - '1': { + [actionId1]: { ...createRandomReportAction(0), actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, created: '2025-11-21 08:00:00', @@ -1659,7 +1669,7 @@ describe('ReportActionsUtils', () => { message: [], previousMessage: [], } as ReportAction, - '2': { + [actionId2]: { ...createRandomReportAction(0), actionName: CONST.REPORT.ACTIONS.TYPE.DEW_SUBMIT_FAILED, created: '2025-11-21 09:00:00', @@ -1668,7 +1678,7 @@ describe('ReportActionsUtils', () => { message: [], previousMessage: [], } as ReportAction, - '3': { + [actionId3]: { ...createRandomReportAction(0), actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, created: '2025-11-21 10:00:00', @@ -1677,7 +1687,7 @@ describe('ReportActionsUtils', () => { message: [], previousMessage: [], } as ReportAction, - '4': { + [actionId4]: { ...createRandomReportAction(0), actionName: CONST.REPORT.ACTIONS.TYPE.DEW_SUBMIT_FAILED, created: '2025-11-21 11:00:00', @@ -1694,8 +1704,11 @@ describe('ReportActionsUtils', () => { }); it('should return undefined when most recent SUBMITTED is after all DEW_SUBMIT_FAILED actions', () => { + const actionId1 = '1'; + const actionId2 = '2'; + const actionId3 = '3'; const reportActions: ReportActions = { - '1': { + [actionId1]: { ...createRandomReportAction(0), actionName: CONST.REPORT.ACTIONS.TYPE.DEW_SUBMIT_FAILED, created: '2025-11-21 08:00:00', @@ -1704,7 +1717,7 @@ describe('ReportActionsUtils', () => { message: [], previousMessage: [], } as ReportAction, - '2': { + [actionId2]: { ...createRandomReportAction(0), actionName: CONST.REPORT.ACTIONS.TYPE.DEW_SUBMIT_FAILED, created: '2025-11-21 09:00:00', @@ -1713,7 +1726,7 @@ describe('ReportActionsUtils', () => { message: [], previousMessage: [], } as ReportAction, - '3': { + [actionId3]: { ...createRandomReportAction(0), actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, created: '2025-11-21 10:00:00', From 35edd0c3f1c2bca981e5d7273e7c46ae64ff0a97 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Wed, 3 Dec 2025 21:48:08 +0100 Subject: [PATCH 43/76] showing view in offline mode and hide submit when report is submitted --- .../MoneyRequestReportPreviewContent.tsx | 6 +- src/libs/ReportActionsUtils.ts | 10 +++ src/libs/ReportPreviewActionUtils.ts | 5 +- src/libs/ReportPrimaryActionUtils.ts | 12 ++- src/libs/ReportUtils.ts | 7 +- src/libs/SearchUIUtils.ts | 8 +- src/libs/actions/IOU.ts | 22 +++-- tests/actions/ReportPreviewActionUtilsTest.ts | 27 ++++++ tests/unit/ReportActionsUtilsTest.ts | 53 ++++++++++++ tests/unit/Search/SearchUIUtilsTest.ts | 85 +++++++++++++++++++ 10 files changed, 217 insertions(+), 18 deletions(-) diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx index ddfd2d2627ded..41754ad608af1 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx @@ -43,7 +43,7 @@ import {getTotalAmountForIOUReportPreviewButton} from '@libs/MoneyRequestReportU import Navigation from '@libs/Navigation/Navigation'; import Performance from '@libs/Performance'; import {getConnectedIntegration, hasDynamicExternalWorkflow} from '@libs/PolicyUtils'; -import {getMostRecentActiveDEWSubmitFailedAction} from '@libs/ReportActionsUtils'; +import {getMostRecentActiveDEWSubmitFailedAction, hasPendingSubmittedAction} from '@libs/ReportActionsUtils'; import {getInvoicePayerName} from '@libs/ReportNameUtils'; import getReportPreviewAction from '@libs/ReportPreviewActionUtils'; import { @@ -491,6 +491,8 @@ function MoneyRequestReportPreviewContent({ }, [iouReportID]); const hasDEWSubmitFailed = useMemo(() => !!getMostRecentActiveDEWSubmitFailedAction(reportActions), [reportActions]); + const isDEWPolicy = hasDynamicExternalWorkflow(policy); + const hasPendingDEWSubmit = useMemo(() => isDEWPolicy && hasPendingSubmittedAction(reportActions), [isDEWPolicy, reportActions]); const reportPreviewAction = useMemo(() => { return getReportPreviewAction( isIouReportArchived || isChatReportArchived, @@ -503,6 +505,7 @@ function MoneyRequestReportPreviewContent({ isApprovedAnimationRunning, isSubmittingAnimationRunning, hasDEWSubmitFailed, + hasPendingDEWSubmit, ); }, [ isPaidAnimationRunning, @@ -516,6 +519,7 @@ function MoneyRequestReportPreviewContent({ isChatReportArchived, currentUserDetails.accountID, hasDEWSubmitFailed, + hasPendingDEWSubmit, ]); const addExpenseDropdownOptions = useMemo( diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 2ecb160d517f6..c48a512574a39 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -274,6 +274,15 @@ function getMostRecentActiveDEWSubmitFailedAction(reportActions: OnyxEntry | ReportAction[]): boolean { + const actionsArray = Array.isArray(reportActions) ? reportActions : Object.values(reportActions ?? {}); + return actionsArray.some((action): action is ReportAction => isSubmittedAction(action) && action.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); +} + function isModifiedExpenseAction(reportAction: OnyxInputOrEntry): reportAction is ReportAction { return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE); } @@ -3523,6 +3532,7 @@ export { isForwardedAction, isDynamicExternalWorkflowSubmitFailedAction, getMostRecentActiveDEWSubmitFailedAction, + hasPendingSubmittedAction, isWhisperActionTargetedToOthers, isTagModificationAction, isIOUActionMatchingTransactionList, diff --git a/src/libs/ReportPreviewActionUtils.ts b/src/libs/ReportPreviewActionUtils.ts index f5208d0521c88..f565be769db7e 100644 --- a/src/libs/ReportPreviewActionUtils.ts +++ b/src/libs/ReportPreviewActionUtils.ts @@ -160,6 +160,7 @@ function getReportPreviewAction( isApprovedAnimationRunning?: boolean, isSubmittingAnimationRunning?: boolean, hasDEWSubmitFailed?: boolean, + hasPendingDEWSubmit?: boolean, ): ValueOf { if (!report) { return CONST.REPORT.REPORT_PREVIEW_ACTIONS.VIEW; @@ -178,8 +179,8 @@ function getReportPreviewAction( return CONST.REPORT.REPORT_PREVIEW_ACTIONS.ADD_EXPENSE; } - // If DEW submit failed and report is still open, show VIEW (user needs to review errors in report comments) - if (hasDEWSubmitFailed && isOpenReport(report)) { + // If DEW submit failed or there's a pending DEW submission, show VIEW + if ((hasDEWSubmitFailed || hasPendingDEWSubmit) && isOpenReport(report)) { return CONST.REPORT.REPORT_PREVIEW_ACTIONS.VIEW; } diff --git a/src/libs/ReportPrimaryActionUtils.ts b/src/libs/ReportPrimaryActionUtils.ts index ffb42b9b40852..a772596fb5ce2 100644 --- a/src/libs/ReportPrimaryActionUtils.ts +++ b/src/libs/ReportPrimaryActionUtils.ts @@ -8,11 +8,12 @@ import { arePaymentsEnabled as arePaymentsEnabledUtils, getSubmitToAccountID, getValidConnectedIntegration, + hasDynamicExternalWorkflow, hasIntegrationAutoSync, isPolicyAdmin as isPolicyAdminPolicyUtils, isPreferredExporter, } from './PolicyUtils'; -import {getAllReportActions, getOneTransactionThreadReportID, getOriginalMessage, getReportAction, isMoneyRequestAction} from './ReportActionsUtils'; +import {getAllReportActions, getOneTransactionThreadReportID, getOriginalMessage, getReportAction, hasPendingSubmittedAction, isMoneyRequestAction} from './ReportActionsUtils'; import { canAddTransaction as canAddTransactionUtil, canHoldUnholdReportAction, @@ -76,11 +77,16 @@ function isAddExpenseAction(report: Report, reportTransactions: Transaction[], i return isExpenseReport && canAddTransaction && reportTransactions.length === 0; } -function isSubmitAction(report: Report, reportTransactions: Transaction[], policy?: Policy, reportNameValuePairs?: ReportNameValuePairs) { +function isSubmitAction(report: Report, reportTransactions: Transaction[], policy?: Policy, reportNameValuePairs?: ReportNameValuePairs, reportActions?: ReportAction[]) { if (isArchivedReport(reportNameValuePairs)) { return false; } + // Don't show submit button if there's already a pending SUBMITTED action on DEW policy (DEW submission in progress) + if (hasDynamicExternalWorkflow(policy) && hasPendingSubmittedAction(reportActions ?? [])) { + return false; + } + const isExpenseReport = isExpenseReportUtils(report); const isReportSubmitter = isCurrentUserSubmitter(report); const isOpenReport = isOpenReportUtils(report); @@ -410,7 +416,7 @@ function getReportPrimaryAction(params: GetReportPrimaryActionParams): ValueOf { const actions = Object.values(actionsGroup ?? {}); const filteredActions = actions.filter( - (action): action is ReportAction => isExportIntegrationAction(action) || isIntegrationMessageAction(action) || isDynamicExternalWorkflowSubmitFailedAction(action), + (action): action is ReportAction => + isExportIntegrationAction(action) || + isIntegrationMessageAction(action) || + isDynamicExternalWorkflowSubmitFailedAction(action) || + (isSubmittedAction(action) && action.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD), ); return [reportId, filteredActions]; }), diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 4514feb7cdf49..9983a99ad7152 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -75,11 +75,12 @@ import {translateLocal} from './Localize'; import Navigation from './Navigation/Navigation'; import Parser from './Parser'; import {getDisplayNameOrDefault} from './PersonalDetailsUtils'; -import {arePaymentsEnabled, canSendInvoice, getGroupPaidPoliciesWithExpenseChatEnabled, getPolicy, isPaidGroupPolicy, isPolicyPayer} from './PolicyUtils'; +import {arePaymentsEnabled, canSendInvoice, getGroupPaidPoliciesWithExpenseChatEnabled, getPolicy, hasDynamicExternalWorkflow, isPaidGroupPolicy, isPolicyPayer} from './PolicyUtils'; import { getIOUActionForReportID, getMostRecentActiveDEWSubmitFailedAction, getOriginalMessage, + hasPendingSubmittedAction, isCreatedAction, isDeletedAction, isHoldAction, @@ -1241,8 +1242,9 @@ function getActions( return [CONST.SEARCH.ACTION_TYPES.VIEW]; } - // Check for DEW submit failed - if the report has a DEW_SUBMIT_FAILED action (more recent than last SUBMITTED) and is still OPEN, show View - if (report.statusNum === CONST.REPORT.STATUS_NUM.OPEN && !!getMostRecentActiveDEWSubmitFailedAction(reportActions)) { + // Check for DEW submit failed or pending DEW submission - show View instead of Submit + const isDEWPolicy = hasDynamicExternalWorkflow(policy); + if (report.statusNum === CONST.REPORT.STATUS_NUM.OPEN && (!!getMostRecentActiveDEWSubmitFailedAction(reportActions) || (isDEWPolicy && hasPendingSubmittedAction(reportActions)))) { return [CONST.SEARCH.ACTION_TYPES.VIEW]; } diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index eab6eb406b040..78076e048ba29 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -11446,14 +11446,19 @@ function submitReport( key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, value: { ...expenseReport, - managerID, - lastMessageText: getReportActionText(optimisticSubmittedReportAction), - lastMessageHtml: getReportActionHtml(optimisticSubmittedReportAction), - stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, - statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + // For DEW policies, don't optimistically update managerID, stateNum, or statusNum + // because DEW determines the actual workflow on the backend ...(isDEWPolicy - ? {} + ? { + lastMessageText: getReportActionText(optimisticSubmittedReportAction), + lastMessageHtml: getReportActionHtml(optimisticSubmittedReportAction), + } : { + managerID, + lastMessageText: getReportActionText(optimisticSubmittedReportAction), + lastMessageHtml: getReportActionHtml(optimisticSubmittedReportAction), + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, nextStep: optimisticNextStep, pendingFields: { nextStep: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, @@ -11466,11 +11471,12 @@ function submitReport( key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, value: { ...expenseReport, - stateNum: CONST.REPORT.STATE_NUM.APPROVED, - statusNum: CONST.REPORT.STATUS_NUM.CLOSED, + // For DEW policies, don't optimistically update stateNum or statusNum ...(isDEWPolicy ? {} : { + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.CLOSED, nextStep: optimisticNextStep, pendingFields: { nextStep: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, diff --git a/tests/actions/ReportPreviewActionUtilsTest.ts b/tests/actions/ReportPreviewActionUtilsTest.ts index 0a5392822841e..c93689547cdd6 100644 --- a/tests/actions/ReportPreviewActionUtilsTest.ts +++ b/tests/actions/ReportPreviewActionUtilsTest.ts @@ -579,5 +579,32 @@ describe('getReportPreviewAction', () => { CONST.REPORT.REPORT_PREVIEW_ACTIONS.VIEW, ); }); + + it('should return VIEW when hasPendingDEWSubmit is true and report is OPEN', async () => { + const report: Report = { + ...createRandomReport(REPORT_ID, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + isWaitingOnBankAccount: false, + }; + + const policy = createRandomPolicy(0); + policy.type = CONST.POLICY.TYPE.CORPORATE; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); + const transaction = { + reportID: `${REPORT_ID}`, + } as unknown as Transaction; + + const {result: isReportArchived} = renderHook(() => useReportIsArchived(report?.parentReportID)); + await waitForBatchedUpdatesWithAct(); + + // hasDEWSubmitFailed = false, hasPendingDEWSubmit = true + expect(getReportPreviewAction(isReportArchived.current, CURRENT_USER_ACCOUNT_ID, report, policy, [transaction], undefined, false, false, false, false, true)).toBe( + CONST.REPORT.REPORT_PREVIEW_ACTIONS.VIEW, + ); + }); }); }); diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts index f563e28cf1d41..50868cc44d28d 100644 --- a/tests/unit/ReportActionsUtilsTest.ts +++ b/tests/unit/ReportActionsUtilsTest.ts @@ -1740,4 +1740,57 @@ describe('ReportActionsUtils', () => { expect(ReportActionsUtils.getMostRecentActiveDEWSubmitFailedAction(reportActions)).toBeUndefined(); }); }); + + describe('hasPendingSubmittedAction', () => { + it('should return true when there is a pending SUBMITTED action', () => { + const actionId1 = '1'; + const reportActions: ReportActions = { + [actionId1]: { + ...createRandomReportAction(0), + actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, + reportActionID: '1', + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + originalMessage: {amount: 10000, currency: 'USD'}, + message: [], + previousMessage: [], + } as ReportAction, + }; + expect(ReportActionsUtils.hasPendingSubmittedAction(reportActions)).toBe(true); + }); + + it('should return false when SUBMITTED action is not pending', () => { + const actionId1 = '1'; + const reportActions: ReportActions = { + [actionId1]: { + ...createRandomReportAction(0), + actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, + reportActionID: '1', + pendingAction: undefined, + originalMessage: {amount: 10000, currency: 'USD'}, + message: [], + previousMessage: [], + } as ReportAction, + }; + expect(ReportActionsUtils.hasPendingSubmittedAction(reportActions)).toBe(false); + }); + + it('should return false for empty report actions', () => { + expect(ReportActionsUtils.hasPendingSubmittedAction({})).toBe(false); + }); + + it('should return false when there are no SUBMITTED actions', () => { + const actionId1 = '1'; + const reportActions: ReportActions = { + [actionId1]: { + ...createRandomReportAction(0), + actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, + reportActionID: '1', + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + message: [], + previousMessage: [], + } as ReportAction, + }; + expect(ReportActionsUtils.hasPendingSubmittedAction(reportActions)).toBe(false); + }); + }); }); diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index cdc747b8ad2c5..dc334bd36cd91 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -1702,6 +1702,91 @@ describe('SearchUIUtils', () => { const action = SearchUIUtils.getActions(localSearchResults, {}, `transactions_${dewTransactionID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', dewReportActions).at(0); expect(action).not.toStrictEqual(CONST.SEARCH.ACTION_TYPES.VIEW); }); + + test('Should return `View` action when report has pending SUBMITTED action on DEW policy and is OPEN', async () => { + const dewReportID = '777'; + const dewTransactionID = '7777'; + const dewReportActionID = '77777'; + const dewPolicyID = 'dewPolicy777'; + + const localSearchResults = { + ...searchResults.data, + [`policy_${dewPolicyID}`]: { + ...searchResults.data[`policy_${policyID}`], + id: dewPolicyID, + approvalMode: CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL, + }, + [`report_${dewReportID}`]: { + ...searchResults.data[`report_${reportID}`], + reportID: dewReportID, + policyID: dewPolicyID, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + type: CONST.REPORT.TYPE.EXPENSE, + }, + [`transactions_${dewTransactionID}`]: { + ...searchResults.data[`transactions_${transactionID}`], + transactionID: dewTransactionID, + reportID: dewReportID, + }, + }; + + const dewReportActions = [ + { + reportActionID: dewReportActionID, + actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, + reportID: dewReportID, + created: '2025-01-01 00:00:00', + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + originalMessage: { + amount: 10000, + currency: 'USD', + }, + }, + ] as OnyxTypes.ReportAction[]; + + const action = SearchUIUtils.getActions(localSearchResults, {}, `transactions_${dewTransactionID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', dewReportActions).at(0); + expect(action).toStrictEqual(CONST.SEARCH.ACTION_TYPES.VIEW); + }); + + test('Should NOT return `View` action when report has pending SUBMITTED action on non-DEW policy', async () => { + const nonDewReportID = '666'; + const nonDewTransactionID = '6666'; + const nonDewReportActionID = '66666'; + + const localSearchResults = { + ...searchResults.data, + [`report_${nonDewReportID}`]: { + ...searchResults.data[`report_${reportID}`], + reportID: nonDewReportID, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + type: CONST.REPORT.TYPE.EXPENSE, + }, + [`transactions_${nonDewTransactionID}`]: { + ...searchResults.data[`transactions_${transactionID}`], + transactionID: nonDewTransactionID, + reportID: nonDewReportID, + }, + }; + + const nonDewReportActions = [ + { + reportActionID: nonDewReportActionID, + actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, + reportID: nonDewReportID, + created: '2025-01-01 00:00:00', + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + originalMessage: { + amount: 10000, + currency: 'USD', + }, + }, + ] as OnyxTypes.ReportAction[]; + + const action = SearchUIUtils.getActions(localSearchResults, {}, `transactions_${nonDewTransactionID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', nonDewReportActions).at(0); + expect(action).not.toStrictEqual(CONST.SEARCH.ACTION_TYPES.VIEW); + }); }); describe('Test getListItem', () => { From fee83d9123f19e42b23b51cf2fc61d8484ae6c47 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Wed, 3 Dec 2025 22:19:07 +0100 Subject: [PATCH 44/76] fixing tests --- src/libs/ReportPreviewActionUtils.ts | 1 + tests/actions/IOUTest.ts | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/libs/ReportPreviewActionUtils.ts b/src/libs/ReportPreviewActionUtils.ts index f565be769db7e..145db87b0d7bc 100644 --- a/src/libs/ReportPreviewActionUtils.ts +++ b/src/libs/ReportPreviewActionUtils.ts @@ -149,6 +149,7 @@ function canExport(report: Report, policy?: Policy) { return isApproved || isReimbursed || isClosed; } +// eslint-disable-next-line @typescript-eslint/max-params function getReportPreviewAction( isReportArchived: boolean, currentUserAccountID: number, diff --git a/tests/actions/IOUTest.ts b/tests/actions/IOUTest.ts index c1977b3a70c4e..ec306f73391d7 100644 --- a/tests/actions/IOUTest.ts +++ b/tests/actions/IOUTest.ts @@ -5715,8 +5715,10 @@ describe('actions/IOU', () => { Onyx.disconnect(connection); expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.EXPENSE && report?.policyID === policyID); - expect(expenseReport?.stateNum).toBe(1); - expect(expenseReport?.statusNum).toBe(1); + // For DEW policies, stateNum and statusNum should remain OPEN (0) because + // we don't optimistically update them - DEW determines the actual workflow on the backend + expect(expenseReport?.stateNum).toBe(CONST.REPORT.STATE_NUM.OPEN); + expect(expenseReport?.statusNum).toBe(CONST.REPORT.STATUS_NUM.OPEN); expect(expenseReport?.nextStep).toBeUndefined(); expect(expenseReport?.pendingFields?.nextStep).toBeUndefined(); From 189838eea31a9d9288a56b0ca4326e65ebbe5aec Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Wed, 3 Dec 2025 22:30:06 +0100 Subject: [PATCH 45/76] minor cleanup --- tests/actions/IOUTest.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/actions/IOUTest.ts b/tests/actions/IOUTest.ts index ec306f73391d7..f31263e81f822 100644 --- a/tests/actions/IOUTest.ts +++ b/tests/actions/IOUTest.ts @@ -5715,8 +5715,6 @@ describe('actions/IOU', () => { Onyx.disconnect(connection); expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.EXPENSE && report?.policyID === policyID); - // For DEW policies, stateNum and statusNum should remain OPEN (0) because - // we don't optimistically update them - DEW determines the actual workflow on the backend expect(expenseReport?.stateNum).toBe(CONST.REPORT.STATE_NUM.OPEN); expect(expenseReport?.statusNum).toBe(CONST.REPORT.STATUS_NUM.OPEN); expect(expenseReport?.nextStep).toBeUndefined(); From 401242162d1465eeb09449fcd6af31f952f11598 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Thu, 4 Dec 2025 15:54:43 +0100 Subject: [PATCH 46/76] Refactoring test cases --- tests/actions/IOUTest.ts | 21 +-- tests/actions/ReportPreviewActionUtilsTest.ts | 52 +++--- tests/ui/PureReportActionItemTest.tsx | 10 +- tests/ui/ReportListItemHeaderTest.tsx | 54 +----- tests/unit/NextStepUtilsTest.ts | 6 +- tests/unit/ReportActionsUtilsTest.ts | 158 ++++++++++++++---- tests/unit/ReportUtilsTest.ts | 22 +-- 7 files changed, 182 insertions(+), 141 deletions(-) diff --git a/tests/actions/IOUTest.ts b/tests/actions/IOUTest.ts index f31263e81f822..c4dac7df92537 100644 --- a/tests/actions/IOUTest.ts +++ b/tests/actions/IOUTest.ts @@ -5581,26 +5581,21 @@ describe('actions/IOU', () => { ); }); - it('correctly submits a report with Dynamic External Workflow policy without setting nextStep', () => { + it('should not set stateNum, statusNum, or nextStep optimistically when submitting with Dynamic External Workflow policy', () => { const amount = 10000; const comment = '💸💸💸💸'; const merchant = 'NASDAQ'; let expenseReport: OnyxEntry; let chatReport: OnyxEntry; let policy: OnyxEntry; - let policyID: string; - + const policyID = generatePolicyID(); + createWorkspace({ + policyOwnerEmail: CARLOS_EMAIL, + makeMeAdmin: true, + policyName: 'Test Workspace with Dynamic External Workflow', + policyID, + }); return waitForBatchedUpdates() - .then(() => { - policyID = generatePolicyID(); - createWorkspace({ - policyOwnerEmail: CARLOS_EMAIL, - makeMeAdmin: true, - policyName: 'Test Workspace with Dynamic External Workflow', - policyID, - }); - return waitForBatchedUpdates(); - }) .then(() => { setWorkspaceApprovalMode(policyID, CARLOS_EMAIL, CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL); return waitForBatchedUpdates(); diff --git a/tests/actions/ReportPreviewActionUtilsTest.ts b/tests/actions/ReportPreviewActionUtilsTest.ts index c93689547cdd6..0e5d80656f8ff 100644 --- a/tests/actions/ReportPreviewActionUtilsTest.ts +++ b/tests/actions/ReportPreviewActionUtilsTest.ts @@ -491,7 +491,8 @@ describe('getReportPreviewAction', () => { }); describe('DEW (Dynamic External Workflow) submit failed', () => { - it('should return VIEW when hasDEWSubmitFailed is true and report is OPEN', async () => { + it('should return VIEW action when DEW submit has failed and report is OPEN', async () => { + // Given an open expense report with a corporate policy where DEW submit has failed const report: Report = { ...createRandomReport(REPORT_ID, undefined), type: CONST.REPORT.TYPE.EXPENSE, @@ -510,15 +511,16 @@ describe('getReportPreviewAction', () => { } as unknown as Transaction; const {result: isReportArchived} = renderHook(() => useReportIsArchived(report?.parentReportID)); - await waitForBatchedUpdatesWithAct(); - // hasDEWSubmitFailed = true (last parameter) - expect(getReportPreviewAction(isReportArchived.current, CURRENT_USER_ACCOUNT_ID, report, policy, [transaction], undefined, false, false, false, true)).toBe( - CONST.REPORT.REPORT_PREVIEW_ACTIONS.VIEW, - ); + // When getReportPreviewAction is called with hasDEWSubmitFailed = true + const result = getReportPreviewAction(isReportArchived.current, CURRENT_USER_ACCOUNT_ID, report, policy, [transaction], undefined, false, false, false, true); + + // Then it should return VIEW because DEW submission failed and the report cannot be submitted + expect(result).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.VIEW); }); - it('should NOT return VIEW when hasDEWSubmitFailed is true but report is not OPEN', async () => { + it('should return APPROVE action when DEW submit has failed but report is already SUBMITTED', async () => { + // Given a submitted expense report where DEW submit has failed const report: Report = { ...createRandomReport(REPORT_ID, undefined), type: CONST.REPORT.TYPE.EXPENSE, @@ -541,15 +543,16 @@ describe('getReportPreviewAction', () => { } as unknown as Transaction; const {result: isReportArchived} = renderHook(() => useReportIsArchived(report?.parentReportID)); - await waitForBatchedUpdatesWithAct(); - // hasDEWSubmitFailed = true, but report is SUBMITTED, so DEW check doesn't apply - expect(getReportPreviewAction(isReportArchived.current, CURRENT_USER_ACCOUNT_ID, report, policy, [transaction], undefined, false, false, false, true)).toBe( - CONST.REPORT.REPORT_PREVIEW_ACTIONS.APPROVE, - ); + // When getReportPreviewAction is called with hasDEWSubmitFailed = true but report is already submitted + const result = getReportPreviewAction(isReportArchived.current, CURRENT_USER_ACCOUNT_ID, report, policy, [transaction], undefined, false, false, false, true); + + // Then it should return APPROVE because the DEW check only applies to OPEN reports + expect(result).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.APPROVE); }); - it('should NOT return VIEW due to DEW when hasDEWSubmitFailed is false', async () => { + it('should return SUBMIT action when DEW submit has not failed and report is OPEN', async () => { + // Given an open expense report where DEW submit has not failed const report: Report = { ...createRandomReport(REPORT_ID, undefined), type: CONST.REPORT.TYPE.EXPENSE, @@ -572,15 +575,16 @@ describe('getReportPreviewAction', () => { } as unknown as Transaction; const {result: isReportArchived} = renderHook(() => useReportIsArchived(report?.parentReportID)); - await waitForBatchedUpdatesWithAct(); - // hasDEWSubmitFailed = false (last parameter), regular logic applies - expect(getReportPreviewAction(isReportArchived.current, CURRENT_USER_ACCOUNT_ID, report, policy, [transaction], undefined, false, false, false, false)).not.toBe( - CONST.REPORT.REPORT_PREVIEW_ACTIONS.VIEW, - ); + // When getReportPreviewAction is called with hasDEWSubmitFailed = false + const result = getReportPreviewAction(isReportArchived.current, CURRENT_USER_ACCOUNT_ID, report, policy, [transaction], undefined, false, false, false, false); + + // 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); }); - it('should return VIEW when hasPendingDEWSubmit is true and report is OPEN', async () => { + it('should return VIEW action when DEW submit is pending and report is OPEN', async () => { + // Given an open expense report where DEW submit is pending const report: Report = { ...createRandomReport(REPORT_ID, undefined), type: CONST.REPORT.TYPE.EXPENSE, @@ -599,12 +603,12 @@ describe('getReportPreviewAction', () => { } as unknown as Transaction; const {result: isReportArchived} = renderHook(() => useReportIsArchived(report?.parentReportID)); - await waitForBatchedUpdatesWithAct(); - // hasDEWSubmitFailed = false, hasPendingDEWSubmit = true - expect(getReportPreviewAction(isReportArchived.current, CURRENT_USER_ACCOUNT_ID, report, policy, [transaction], undefined, false, false, false, false, true)).toBe( - CONST.REPORT.REPORT_PREVIEW_ACTIONS.VIEW, - ); + // When getReportPreviewAction is called with hasPendingDEWSubmit = true + const result = getReportPreviewAction(isReportArchived.current, CURRENT_USER_ACCOUNT_ID, report, policy, [transaction], undefined, false, false, false, false, true); + + // Then it should return VIEW because DEW submission is in progress + expect(result).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.VIEW); }); }); }); diff --git a/tests/ui/PureReportActionItemTest.tsx b/tests/ui/PureReportActionItemTest.tsx index bd7031eedea46..2a001955efe00 100644 --- a/tests/ui/PureReportActionItemTest.tsx +++ b/tests/ui/PureReportActionItemTest.tsx @@ -225,7 +225,8 @@ describe('PureReportActionItem', () => { }); describe('DEW (Dynamic External Workflow) actions', () => { - it('SUBMITTED action with pendingAction when policy has DEW enabled', async () => { + it('should display DEW queued message for pending SUBMITTED action when policy has DEW enabled', async () => { + // Given a SUBMITTED action with pendingAction on a policy with DEW (Dynamic External Workflow) enabled const action = createReportAction(CONST.REPORT.ACTIONS.TYPE.SUBMITTED, {harvesting: false}); action.pendingAction = CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD; @@ -245,6 +246,7 @@ describe('PureReportActionItem', () => { }); await waitForBatchedUpdatesWithAct(); + // When the PureReportActionItem is rendered with the pending SUBMITTED action render( @@ -274,11 +276,13 @@ describe('PureReportActionItem', () => { ); await waitForBatchedUpdatesWithAct(); + // Then it should display the DEW queued message because submission is pending via external workflow expect(screen.getByText(actorEmail)).toBeOnTheScreen(); expect(screen.getByText(translateLocal('iou.queuedToSubmitViaDEW'))).toBeOnTheScreen(); }); - it('SUBMITTED action with pendingAction when policy does NOT have DEW enabled', async () => { + it('should display standard submitted message for pending SUBMITTED action when policy does not have DEW enabled', async () => { + // Given a SUBMITTED action with pendingAction on a policy with basic approval mode (no DEW) const action = createReportAction(CONST.REPORT.ACTIONS.TYPE.SUBMITTED, {harvesting: false}); action.pendingAction = CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD; @@ -298,6 +302,7 @@ describe('PureReportActionItem', () => { }); await waitForBatchedUpdatesWithAct(); + // When the PureReportActionItem is rendered with the pending SUBMITTED action render( @@ -327,6 +332,7 @@ describe('PureReportActionItem', () => { ); await waitForBatchedUpdatesWithAct(); + // Then it should display the standard submitted message and not the DEW queued message expect(screen.getByText(actorEmail)).toBeOnTheScreen(); expect(screen.getByText(translateLocal('iou.submitted', {}))).toBeOnTheScreen(); expect(screen.queryByText(translateLocal('iou.queuedToSubmitViaDEW'))).not.toBeOnTheScreen(); diff --git a/tests/ui/ReportListItemHeaderTest.tsx b/tests/ui/ReportListItemHeaderTest.tsx index b8d376eba32fe..d77eb05b47644 100644 --- a/tests/ui/ReportListItemHeaderTest.tsx +++ b/tests/ui/ReportListItemHeaderTest.tsx @@ -92,13 +92,7 @@ const createReportListItem = ( }); // Helper function to wrap component with context -const renderReportListItemHeader = ( - reportItem: TransactionReportGroupListItemType, - options?: { - onDEWModalOpen?: () => void; - isDEWBetaEnabled?: boolean; - }, -) => { +const renderReportListItemHeader = (reportItem: TransactionReportGroupListItemType) => { return render( {/* @ts-expect-error - Disable TypeScript errors to simplify the test */} @@ -109,8 +103,6 @@ const renderReportListItemHeader = ( onCheckboxPress={jest.fn()} isDisabled={false} canSelectMultiple={false} - onDEWModalOpen={options?.onDEWModalOpen} - isDEWBetaEnabled={options?.isDEWBetaEnabled} /> , @@ -224,48 +216,4 @@ describe('ReportListItemHeader', () => { }); }); }); - - describe('DEW (Dynamic External Workflow)', () => { - it('should accept onDEWModalOpen callback for SUBMIT action', async () => { - const mockOnDEWModalOpen = jest.fn(); - const reportItem = createReportListItem(CONST.REPORT.TYPE.EXPENSE, 'john', 'jane', { - action: 'submit', - }); - renderReportListItemHeader(reportItem, {onDEWModalOpen: mockOnDEWModalOpen}); - await waitForBatchedUpdatesWithAct(); - - expect(screen.getByText('John Doe')).toBeOnTheScreen(); - }); - - it('should accept onDEWModalOpen callback for APPROVE action', async () => { - const mockOnDEWModalOpen = jest.fn(); - const reportItem = createReportListItem(CONST.REPORT.TYPE.EXPENSE, 'john', 'jane', { - action: 'approve', - }); - renderReportListItemHeader(reportItem, {onDEWModalOpen: mockOnDEWModalOpen}); - await waitForBatchedUpdatesWithAct(); - - expect(screen.getByText('John Doe')).toBeOnTheScreen(); - }); - - it('should accept isDEWBetaEnabled prop', async () => { - const reportItem = createReportListItem(CONST.REPORT.TYPE.EXPENSE, 'john', 'jane', { - action: 'submit', - }); - renderReportListItemHeader(reportItem, {isDEWBetaEnabled: true}); - await waitForBatchedUpdatesWithAct(); - - expect(screen.getByText('John Doe')).toBeOnTheScreen(); - }); - - it('should render without DEW props', async () => { - const reportItem = createReportListItem(CONST.REPORT.TYPE.EXPENSE, 'john', 'jane', { - action: 'submit', - }); - renderReportListItemHeader(reportItem); - await waitForBatchedUpdatesWithAct(); - - expect(screen.getByText('John Doe')).toBeOnTheScreen(); - }); - }); }); diff --git a/tests/unit/NextStepUtilsTest.ts b/tests/unit/NextStepUtilsTest.ts index 6140ff3ec3433..af2b4c5d192b2 100644 --- a/tests/unit/NextStepUtilsTest.ts +++ b/tests/unit/NextStepUtilsTest.ts @@ -1048,9 +1048,13 @@ describe('libs/NextStepUtils', () => { }); describe('buildOptimisticNextStepForDynamicExternalWorkflowError', () => { - test('returns correct next step message for DEW submit failed', () => { + 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(); + // Then it should return an alert-type next step with the appropriate error message and dot indicator icon expect(result).toEqual({ type: 'alert', icon: CONST.NEXT_STEP.ICONS.DOT_INDICATOR, diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts index 50868cc44d28d..1532b39c37fe4 100644 --- a/tests/unit/ReportActionsUtilsTest.ts +++ b/tests/unit/ReportActionsUtilsTest.ts @@ -1478,7 +1478,8 @@ describe('ReportActionsUtils', () => { }); describe('isDynamicExternalWorkflowSubmitFailedAction', () => { - it('should return true for DEW_SUBMIT_FAILED action', () => { + it('should return true for DEW_SUBMIT_FAILED action type', () => { + // Given a report action with DEW_SUBMIT_FAILED action type const action: ReportAction = { ...createRandomReportAction(0), actionName: CONST.REPORT.ACTIONS.TYPE.DEW_SUBMIT_FAILED, @@ -1491,10 +1492,16 @@ describe('ReportActionsUtils', () => { message: [], previousMessage: [], }; - expect(ReportActionsUtils.isDynamicExternalWorkflowSubmitFailedAction(action)).toBe(true); + + // When checking if the action is a DEW submit failed action + const result = ReportActionsUtils.isDynamicExternalWorkflowSubmitFailedAction(action); + + // Then it should return true because the action type is DEW_SUBMIT_FAILED + expect(result).toBe(true); }); - it('should return false for non-DEW_SUBMIT_FAILED action', () => { + it('should return false for non-DEW_SUBMIT_FAILED action type', () => { + // Given a report action with SUBMITTED action type (not DEW_SUBMIT_FAILED) const action: ReportAction = { ...createRandomReportAction(0), actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, @@ -1507,23 +1514,35 @@ describe('ReportActionsUtils', () => { message: [], previousMessage: [], }; - expect(ReportActionsUtils.isDynamicExternalWorkflowSubmitFailedAction(action)).toBe(false); + + // When checking if the action is a DEW submit failed action + const result = ReportActionsUtils.isDynamicExternalWorkflowSubmitFailedAction(action); + + // Then it should return false because the action type is not DEW_SUBMIT_FAILED + expect(result).toBe(false); }); it('should return false for null action', () => { - expect(ReportActionsUtils.isDynamicExternalWorkflowSubmitFailedAction(null)).toBe(false); + // Given a null action + + // When checking if the action is a DEW submit failed action + const result = ReportActionsUtils.isDynamicExternalWorkflowSubmitFailedAction(null); + + // Then it should return false because the action is null + expect(result).toBe(false); }); }); describe('getMostRecentActiveDEWSubmitFailedAction', () => { it('should return the DEW action when DEW_SUBMIT_FAILED exists and no SUBMITTED action exists', () => { + // Given report actions containing only a DEW_SUBMIT_FAILED action const actionId1 = '1'; const reportActions: ReportActions = { [actionId1]: { ...createRandomReportAction(0), actionName: CONST.REPORT.ACTIONS.TYPE.DEW_SUBMIT_FAILED, created: '2025-11-21 10:00:00', - reportActionID: '1', + reportActionID: actionId1, originalMessage: { message: 'DEW submit failed', }, @@ -1531,12 +1550,17 @@ describe('ReportActionsUtils', () => { previousMessage: [], } as ReportAction, }; + + // When getting the most recent active DEW submit failed action const result = ReportActionsUtils.getMostRecentActiveDEWSubmitFailedAction(reportActions); + + // Then it should return the DEW action because there's no subsequent SUBMITTED action expect(result).toBeDefined(); - expect(result?.reportActionID).toBe('1'); + expect(result?.reportActionID).toBe(actionId1); }); it('should return the DEW action when DEW_SUBMIT_FAILED is more recent than SUBMITTED', () => { + // Given report actions where DEW_SUBMIT_FAILED occurred after SUBMITTED const actionId1 = '1'; const actionId2 = '2'; const reportActions: ReportActions = { @@ -1544,7 +1568,7 @@ describe('ReportActionsUtils', () => { ...createRandomReportAction(0), actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, created: '2025-11-21 09:00:00', - reportActionID: '1', + reportActionID: actionId1, originalMessage: { amount: 10000, currency: 'USD', @@ -1556,7 +1580,7 @@ describe('ReportActionsUtils', () => { ...createRandomReportAction(0), actionName: CONST.REPORT.ACTIONS.TYPE.DEW_SUBMIT_FAILED, created: '2025-11-21 10:00:00', - reportActionID: '2', + reportActionID: actionId2, originalMessage: { message: 'DEW submit failed', }, @@ -1564,12 +1588,17 @@ describe('ReportActionsUtils', () => { previousMessage: [], } as ReportAction, }; + + // When getting the most recent active DEW submit failed action const result = ReportActionsUtils.getMostRecentActiveDEWSubmitFailedAction(reportActions); + + // Then it should return the DEW action because it's more recent than the SUBMITTED action expect(result).toBeDefined(); - expect(result?.reportActionID).toBe('2'); + expect(result?.reportActionID).toBe(actionId2); }); it('should return undefined when SUBMITTED is more recent than DEW_SUBMIT_FAILED', () => { + // Given report actions where SUBMITTED occurred after DEW_SUBMIT_FAILED const actionId1 = '1'; const actionId2 = '2'; const reportActions: ReportActions = { @@ -1577,7 +1606,7 @@ describe('ReportActionsUtils', () => { ...createRandomReportAction(0), actionName: CONST.REPORT.ACTIONS.TYPE.DEW_SUBMIT_FAILED, created: '2025-11-21 09:00:00', - reportActionID: '1', + reportActionID: actionId1, originalMessage: { message: 'DEW submit failed', }, @@ -1588,7 +1617,7 @@ describe('ReportActionsUtils', () => { ...createRandomReportAction(0), actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, created: '2025-11-21 10:00:00', - reportActionID: '2', + reportActionID: actionId2, originalMessage: { amount: 10000, currency: 'USD', @@ -1597,17 +1626,23 @@ describe('ReportActionsUtils', () => { previousMessage: [], } as ReportAction, }; - expect(ReportActionsUtils.getMostRecentActiveDEWSubmitFailedAction(reportActions)).toBeUndefined(); + + // When getting the most recent active DEW submit failed action + const result = ReportActionsUtils.getMostRecentActiveDEWSubmitFailedAction(reportActions); + + // Then it should return undefined because a successful SUBMITTED action supersedes the DEW failure + expect(result).toBeUndefined(); }); it('should return undefined when no DEW_SUBMIT_FAILED action exists', () => { + // Given report actions containing only a SUBMITTED action (no DEW failures) const actionId1 = '1'; const reportActions: ReportActions = { [actionId1]: { ...createRandomReportAction(0), actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, created: '2025-11-21 10:00:00', - reportActionID: '1', + reportActionID: actionId1, originalMessage: { amount: 10000, currency: 'USD', @@ -1616,14 +1651,26 @@ describe('ReportActionsUtils', () => { previousMessage: [], } as ReportAction, }; - expect(ReportActionsUtils.getMostRecentActiveDEWSubmitFailedAction(reportActions)).toBeUndefined(); + + // When getting the most recent active DEW submit failed action + const result = ReportActionsUtils.getMostRecentActiveDEWSubmitFailedAction(reportActions); + + // Then it should return undefined because there are no DEW failures + expect(result).toBeUndefined(); }); it('should return undefined for empty report actions', () => { - expect(ReportActionsUtils.getMostRecentActiveDEWSubmitFailedAction({})).toBeUndefined(); + // Given an empty report actions object + + // When getting the most recent active DEW submit failed action + const result = ReportActionsUtils.getMostRecentActiveDEWSubmitFailedAction({}); + + // Then it should return undefined because there are no actions + expect(result).toBeUndefined(); }); - it('should work with array input', () => { + it('should handle array input and return the DEW action when it is most recent', () => { + // Given an array of report actions where DEW_SUBMIT_FAILED is more recent const reportActionsArray: ReportAction[] = [ { ...createRandomReportAction(0), @@ -1649,12 +1696,17 @@ describe('ReportActionsUtils', () => { previousMessage: [], } as ReportAction, ]; + + // When getting the most recent active DEW submit failed action const result = ReportActionsUtils.getMostRecentActiveDEWSubmitFailedAction(reportActionsArray); + + // Then it should return the DEW action because it's the most recent expect(result).toBeDefined(); expect(result?.reportActionID).toBe('2'); }); - it('should return the most recent DEW action when multiple DEW_SUBMIT_FAILED and SUBMITTED actions exist', () => { + it('should return the most recent DEW action when multiple DEW failures and submissions exist', () => { + // Given report actions with multiple DEW failures and submissions, where the latest DEW failure is most recent const actionId1 = '1'; const actionId2 = '2'; const actionId3 = '3'; @@ -1664,7 +1716,7 @@ describe('ReportActionsUtils', () => { ...createRandomReportAction(0), actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, created: '2025-11-21 08:00:00', - reportActionID: '1', + reportActionID: actionId1, originalMessage: {amount: 10000, currency: 'USD'}, message: [], previousMessage: [], @@ -1673,7 +1725,7 @@ describe('ReportActionsUtils', () => { ...createRandomReportAction(0), actionName: CONST.REPORT.ACTIONS.TYPE.DEW_SUBMIT_FAILED, created: '2025-11-21 09:00:00', - reportActionID: '2', + reportActionID: actionId2, originalMessage: {message: 'First DEW failure'}, message: [], previousMessage: [], @@ -1682,7 +1734,7 @@ describe('ReportActionsUtils', () => { ...createRandomReportAction(0), actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, created: '2025-11-21 10:00:00', - reportActionID: '3', + reportActionID: actionId3, originalMessage: {amount: 10000, currency: 'USD'}, message: [], previousMessage: [], @@ -1691,19 +1743,23 @@ describe('ReportActionsUtils', () => { ...createRandomReportAction(0), actionName: CONST.REPORT.ACTIONS.TYPE.DEW_SUBMIT_FAILED, created: '2025-11-21 11:00:00', - reportActionID: '4', + reportActionID: actionId4, originalMessage: {message: 'Second DEW failure'}, message: [], previousMessage: [], } as ReportAction, }; - // Most recent DEW_SUBMIT_FAILED (11:00) is after most recent SUBMITTED (10:00) + + // When getting the most recent active DEW submit failed action const result = ReportActionsUtils.getMostRecentActiveDEWSubmitFailedAction(reportActions); + + // Then it should return the most recent DEW action (11:00) because it's after the most recent SUBMITTED (10:00) expect(result).toBeDefined(); - expect(result?.reportActionID).toBe('4'); + expect(result?.reportActionID).toBe(actionId4); }); - it('should return undefined when most recent SUBMITTED is after all DEW_SUBMIT_FAILED actions', () => { + it('should return undefined when most recent SUBMITTED is after all DEW failures', () => { + // Given report actions where SUBMITTED is more recent than all DEW failures const actionId1 = '1'; const actionId2 = '2'; const actionId3 = '3'; @@ -1712,7 +1768,7 @@ describe('ReportActionsUtils', () => { ...createRandomReportAction(0), actionName: CONST.REPORT.ACTIONS.TYPE.DEW_SUBMIT_FAILED, created: '2025-11-21 08:00:00', - reportActionID: '1', + reportActionID: actionId1, originalMessage: {message: 'First DEW failure'}, message: [], previousMessage: [], @@ -1721,7 +1777,7 @@ describe('ReportActionsUtils', () => { ...createRandomReportAction(0), actionName: CONST.REPORT.ACTIONS.TYPE.DEW_SUBMIT_FAILED, created: '2025-11-21 09:00:00', - reportActionID: '2', + reportActionID: actionId2, originalMessage: {message: 'Second DEW failure'}, message: [], previousMessage: [], @@ -1730,67 +1786,95 @@ describe('ReportActionsUtils', () => { ...createRandomReportAction(0), actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, created: '2025-11-21 10:00:00', - reportActionID: '3', + reportActionID: actionId3, originalMessage: {amount: 10000, currency: 'USD'}, message: [], previousMessage: [], } as ReportAction, }; - // Most recent SUBMITTED (10:00) is after all DEW_SUBMIT_FAILED actions - expect(ReportActionsUtils.getMostRecentActiveDEWSubmitFailedAction(reportActions)).toBeUndefined(); + + // When getting the most recent active DEW submit failed action + const result = ReportActionsUtils.getMostRecentActiveDEWSubmitFailedAction(reportActions); + + // Then it should return undefined because the successful submission supersedes all prior DEW failures + expect(result).toBeUndefined(); }); }); describe('hasPendingSubmittedAction', () => { it('should return true when there is a pending SUBMITTED action', () => { + // Given report actions containing a SUBMITTED action with pendingAction = ADD const actionId1 = '1'; const reportActions: ReportActions = { [actionId1]: { ...createRandomReportAction(0), actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, - reportActionID: '1', + reportActionID: actionId1, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, originalMessage: {amount: 10000, currency: 'USD'}, message: [], previousMessage: [], } as ReportAction, }; - expect(ReportActionsUtils.hasPendingSubmittedAction(reportActions)).toBe(true); + + // When checking if there is a pending submitted action + const result = ReportActionsUtils.hasPendingSubmittedAction(reportActions); + + // Then it should return true because the SUBMITTED action is pending + expect(result).toBe(true); }); it('should return false when SUBMITTED action is not pending', () => { + // Given report actions containing a SUBMITTED action without pendingAction const actionId1 = '1'; const reportActions: ReportActions = { [actionId1]: { ...createRandomReportAction(0), actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, - reportActionID: '1', + reportActionID: actionId1, pendingAction: undefined, originalMessage: {amount: 10000, currency: 'USD'}, message: [], previousMessage: [], } as ReportAction, }; - expect(ReportActionsUtils.hasPendingSubmittedAction(reportActions)).toBe(false); + + // When checking if there is a pending submitted action + const result = ReportActionsUtils.hasPendingSubmittedAction(reportActions); + + // Then it should return false because the SUBMITTED action is not pending + expect(result).toBe(false); }); it('should return false for empty report actions', () => { - expect(ReportActionsUtils.hasPendingSubmittedAction({})).toBe(false); + // Given an empty report actions object + + // When checking if there is a pending submitted action + const result = ReportActionsUtils.hasPendingSubmittedAction({}); + + // Then it should return false because there are no actions + expect(result).toBe(false); }); it('should return false when there are no SUBMITTED actions', () => { + // Given report actions containing only a CREATED action (no SUBMITTED) const actionId1 = '1'; const reportActions: ReportActions = { [actionId1]: { ...createRandomReportAction(0), actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, - reportActionID: '1', + reportActionID: actionId1, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, message: [], previousMessage: [], } as ReportAction, }; - expect(ReportActionsUtils.hasPendingSubmittedAction(reportActions)).toBe(false); + + // When checking if there is a pending submitted action + const result = ReportActionsUtils.hasPendingSubmittedAction(reportActions); + + // Then it should return false because there are no SUBMITTED actions + expect(result).toBe(false); }); }); }); diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 04a45546a23b3..5bcd8268b0ecd 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -10140,7 +10140,7 @@ describe('ReportUtils', () => { }; const reportActions = { - '1': dewSubmitFailedAction, + [dewSubmitFailedAction.reportActionID]: dewSubmitFailedAction, }; const {errors, reportAction} = getAllReportActionsErrorsAndReportActionThatRequiresAttention(report, reportActions); @@ -10175,8 +10175,8 @@ describe('ReportUtils', () => { }; const reportActions = { - '1': dewSubmitFailedAction, - '2': submittedAction, + [dewSubmitFailedAction.reportActionID]: dewSubmitFailedAction, + [submittedAction.reportActionID]: submittedAction, }; const {errors} = getAllReportActionsErrorsAndReportActionThatRequiresAttention(report, reportActions); @@ -10227,8 +10227,8 @@ describe('ReportUtils', () => { }; const reportActions = { - '1': submittedAction, - '2': dewSubmitFailedAction, + [submittedAction.reportActionID]: submittedAction, + [dewSubmitFailedAction.reportActionID]: dewSubmitFailedAction, }; const {errors, reportAction} = getAllReportActionsErrorsAndReportActionThatRequiresAttention(report, reportActions); @@ -10255,7 +10255,7 @@ describe('ReportUtils', () => { }; const reportActions = { - '1': dewSubmitFailedAction, + [dewSubmitFailedAction.reportActionID]: dewSubmitFailedAction, }; const {errors} = getAllReportActionsErrorsAndReportActionThatRequiresAttention(report, reportActions); @@ -10281,7 +10281,7 @@ describe('ReportUtils', () => { }; const reportActions = { - '1': dewSubmitFailedAction, + [dewSubmitFailedAction.reportActionID]: dewSubmitFailedAction, }; const {errors} = getAllReportActionsErrorsAndReportActionThatRequiresAttention(report, reportActions, true); @@ -10289,7 +10289,7 @@ describe('ReportUtils', () => { expect(errors?.dewSubmitFailed).toBeUndefined(); }); - it('should clear DEW error when a more recent SUBMITTED action exists after the failure (multiple submits)', () => { + it('should NOT return DEW error when a more recent SUBMITTED action exists after the failure (multiple submits)', () => { const report = { reportID: '1', statusNum: CONST.REPORT.STATUS_NUM.OPEN, @@ -10350,9 +10350,9 @@ describe('ReportUtils', () => { }; const reportActions = { - '1': firstSubmittedAction, - '2': dewSubmitFailedAction, - '3': secondSubmittedAction, + [firstSubmittedAction.reportActionID]: firstSubmittedAction, + [dewSubmitFailedAction.reportActionID]: dewSubmitFailedAction, + [secondSubmittedAction.reportActionID]: secondSubmittedAction, }; const {errors, reportAction} = getAllReportActionsErrorsAndReportActionThatRequiresAttention(report, reportActions); From 15d27b1af10ae121a025d47c79ce48e78633f2a9 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Wed, 10 Dec 2025 00:45:34 +0100 Subject: [PATCH 47/76] refactoring --- Mobile-Expensify | 2 +- src/components/MoneyReportHeader.tsx | 10 ++- src/components/MoneyReportHeaderStatusBar.tsx | 2 +- .../MoneyRequestReportPreviewContent.tsx | 11 +-- src/libs/NextStepUtils.ts | 18 +++- src/libs/ReportActionsUtils.ts | 25 +++++- src/libs/ReportPreviewActionUtils.ts | 6 +- src/libs/ReportPrimaryActionUtils.ts | 12 +-- src/libs/ReportUtils.ts | 5 +- src/libs/SearchUIUtils.ts | 19 +++-- src/libs/actions/IOU.ts | 9 +- src/types/onyx/ReportNextStepDeprecated.ts | 3 + tests/actions/ReportPreviewActionUtilsTest.ts | 16 ++-- tests/unit/ReportActionsUtilsTest.ts | 83 ++++++++++++++----- 14 files changed, 147 insertions(+), 74 deletions(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index eba7d422b52ec..ca397a9f30123 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit eba7d422b52ecc06ce60fe7a13caae7a7d45a27b +Subproject commit ca397a9f301230b182d49721fcf4fccdb899821a diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index df908636e8433..e2c2c57d0c763 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -44,6 +44,7 @@ import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import type {ReportsSplitNavigatorParamList, SearchFullscreenNavigatorParamList, SearchReportParamList} from '@libs/Navigation/types'; import { + buildOptimisticNextStepForDEWOfflineSubmission, buildOptimisticNextStepForDynamicExternalWorkflowError, buildOptimisticNextStepForPreventSelfApprovalsEnabled, buildOptimisticNextStepForStrictPolicyRuleViolations, @@ -51,7 +52,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, isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import {getIOUActionForReportID, getOriginalMessage, getReportAction, hasPendingDEWSubmit, isMoneyRequestAction} from '@libs/ReportActionsUtils'; import {getAllExpensesToHoldIfApplicable, getReportPrimaryAction, isMarkAsResolvedAction} from '@libs/ReportPrimaryActionUtils'; import {getSecondaryExportReportActions, getSecondaryReportActions} from '@libs/ReportSecondaryActionUtils'; import { @@ -232,7 +233,6 @@ function MoneyReportHeader({ 'Export', 'Document', 'Feed', - 'DotIndicator', ] as const); const [lastDistanceExpenseType] = useOnyx(ONYXKEYS.NVP_LAST_DISTANCE_EXPENSE_TYPE, {canBeMissing: true}); const {translate} = useLocalize(); @@ -447,7 +447,7 @@ function MoneyReportHeader({ const isSubmitterSameAsNextApprover = isReportOwner(moneyRequestReport) && nextApproverAccountID === moneyRequestReport?.ownerAccountID; let optimisticNextStep = isSubmitterSameAsNextApprover && policy?.preventSelfApproval ? buildOptimisticNextStepForPreventSelfApprovalsEnabled() : nextStep; - // Check for DEW submit failed - if the report has a DEW_SUBMIT_FAILED action, show the custom next step + // Check for DEW submit failed or pending - show appropriate next step if (isDEWBetaEnabled && hasDynamicExternalWorkflow(policy) && moneyRequestReport?.statusNum === CONST.REPORT.STATUS_NUM.OPEN) { const reportActionsObject = reportActions.reduce((acc, action) => { if (action.reportActionID) { @@ -457,7 +457,9 @@ function MoneyReportHeader({ }, {}); const {errors} = getAllReportActionsErrorsAndReportActionThatRequiresAttention(moneyRequestReport, reportActionsObject); if (errors?.dewSubmitFailed) { - optimisticNextStep = buildOptimisticNextStepForDynamicExternalWorkflowError(); + optimisticNextStep = buildOptimisticNextStepForDynamicExternalWorkflowError(theme.danger); + } else if (hasPendingDEWSubmit(reportActions)) { + optimisticNextStep = buildOptimisticNextStepForDEWOfflineSubmission(); } } diff --git a/src/components/MoneyReportHeaderStatusBar.tsx b/src/components/MoneyReportHeaderStatusBar.tsx index 7007ce669b355..6e74f62505522 100644 --- a/src/components/MoneyReportHeaderStatusBar.tsx +++ b/src/components/MoneyReportHeaderStatusBar.tsx @@ -44,7 +44,7 @@ function MoneyReportHeaderStatusBar({nextStep}: MoneyReportHeaderStatusBarProps) src={(nextStep?.icon && iconMap?.[nextStep.icon]) ?? Expensicons.Hourglass} height={variables.iconSizeSmall} width={variables.iconSizeSmall} - fill={nextStep?.type === 'alert' ? theme.danger : theme.icon} + fill={nextStep?.iconFill ?? theme.icon} /> diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx index f728c562e9006..61c5df4d19595 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx @@ -43,7 +43,7 @@ import {getTotalAmountForIOUReportPreviewButton} from '@libs/MoneyRequestReportU import Navigation from '@libs/Navigation/Navigation'; import Performance from '@libs/Performance'; import {getConnectedIntegration, hasDynamicExternalWorkflow} from '@libs/PolicyUtils'; -import {getMostRecentActiveDEWSubmitFailedAction, hasPendingSubmittedAction} from '@libs/ReportActionsUtils'; +import {hasDEWSubmitPendingOrFailed} from '@libs/ReportActionsUtils'; import {getInvoicePayerName} from '@libs/ReportNameUtils'; import getReportPreviewAction from '@libs/ReportPreviewActionUtils'; import { @@ -490,9 +490,8 @@ function MoneyRequestReportPreviewContent({ Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(iouReportID, undefined, undefined, Navigation.getActiveRoute())); }, [iouReportID]); - const hasDEWSubmitFailed = useMemo(() => !!getMostRecentActiveDEWSubmitFailedAction(reportActions), [reportActions]); const isDEWPolicy = hasDynamicExternalWorkflow(policy); - const hasPendingDEWSubmit = useMemo(() => isDEWPolicy && hasPendingSubmittedAction(reportActions), [isDEWPolicy, reportActions]); + const isDEWSubmitPendingOrFailed = useMemo(() => hasDEWSubmitPendingOrFailed(reportActions, isDEWPolicy), [reportActions, isDEWPolicy]); const reportPreviewAction = useMemo(() => { return getReportPreviewAction( isIouReportArchived || isChatReportArchived, @@ -504,8 +503,7 @@ function MoneyRequestReportPreviewContent({ isPaidAnimationRunning, isApprovedAnimationRunning, isSubmittingAnimationRunning, - hasDEWSubmitFailed, - hasPendingDEWSubmit, + isDEWSubmitPendingOrFailed, ); }, [ isPaidAnimationRunning, @@ -518,8 +516,7 @@ function MoneyRequestReportPreviewContent({ invoiceReceiverPolicy, isChatReportArchived, currentUserDetails.accountID, - hasDEWSubmitFailed, - hasPendingDEWSubmit, + isDEWSubmitPendingOrFailed, ]); const addExpenseDropdownOptions = useMemo( diff --git a/src/libs/NextStepUtils.ts b/src/libs/NextStepUtils.ts index dc17bd36f7074..3f7f27d599d1c 100644 --- a/src/libs/NextStepUtils.ts +++ b/src/libs/NextStepUtils.ts @@ -325,10 +325,11 @@ function buildOptimisticNextStepForStrictPolicyRuleViolations() { return optimisticNextStep; } -function buildOptimisticNextStepForDynamicExternalWorkflowError() { +function buildOptimisticNextStepForDynamicExternalWorkflowError(iconFill?: string) { const optimisticNextStep: ReportNextStepDeprecated = { type: 'alert', icon: CONST.NEXT_STEP.ICONS.DOT_INDICATOR, + iconFill, message: [ { text: "This report can't be submitted. Please review the comments to resolve.", @@ -340,6 +341,20 @@ function buildOptimisticNextStepForDynamicExternalWorkflowError() { return optimisticNextStep; } +function buildOptimisticNextStepForDEWOfflineSubmission() { + const optimisticNextStep: ReportNextStepDeprecated = { + type: 'neutral', + icon: CONST.NEXT_STEP.ICONS.HOURGLASS, + message: [ + { + text: 'Waiting for you to come back online to determine next steps.', + }, + ], + }; + + return optimisticNextStep; +} + /** * Generates an optimistic nextStep based on a current report status and other properties. * Need to rename this function and remove the buildNextStep function above after migrating to this function @@ -723,6 +738,7 @@ export { buildOptimisticNextStepForPreventSelfApprovalsEnabled, buildOptimisticNextStepForStrictPolicyRuleViolations, buildOptimisticNextStepForDynamicExternalWorkflowError, + buildOptimisticNextStepForDEWOfflineSubmission, // eslint-disable-next-line @typescript-eslint/no-deprecated buildNextStepNew, }; diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index c48a512574a39..e84f437cd5527 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -275,14 +275,30 @@ function getMostRecentActiveDEWSubmitFailedAction(reportActions: OnyxEntry | ReportAction[]): boolean { +function hasPendingDEWSubmit(reportActions: OnyxEntry | ReportAction[]): boolean { const actionsArray = Array.isArray(reportActions) ? reportActions : Object.values(reportActions ?? {}); return actionsArray.some((action): action is ReportAction => isSubmittedAction(action) && action.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); } +/** + * Checks if there's a DEW submit that has failed or is currently pending. + * This is used to determine if we should show the VIEW action instead of SUBMIT. + */ +function hasDEWSubmitPendingOrFailed(reportActions: OnyxEntry | ReportAction[], isDEWPolicy: boolean): boolean { + if (!isDEWPolicy) { + return false; + } + + if (getMostRecentActiveDEWSubmitFailedAction(reportActions)) { + return true; + } + + return hasPendingDEWSubmit(reportActions); +} + function isModifiedExpenseAction(reportAction: OnyxInputOrEntry): reportAction is ReportAction { return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE); } @@ -3532,7 +3548,8 @@ export { isForwardedAction, isDynamicExternalWorkflowSubmitFailedAction, getMostRecentActiveDEWSubmitFailedAction, - hasPendingSubmittedAction, + hasPendingDEWSubmit, + hasDEWSubmitPendingOrFailed, isWhisperActionTargetedToOthers, isTagModificationAction, isIOUActionMatchingTransactionList, diff --git a/src/libs/ReportPreviewActionUtils.ts b/src/libs/ReportPreviewActionUtils.ts index 145db87b0d7bc..be3a8a0fde1d7 100644 --- a/src/libs/ReportPreviewActionUtils.ts +++ b/src/libs/ReportPreviewActionUtils.ts @@ -149,7 +149,6 @@ function canExport(report: Report, policy?: Policy) { return isApproved || isReimbursed || isClosed; } -// eslint-disable-next-line @typescript-eslint/max-params function getReportPreviewAction( isReportArchived: boolean, currentUserAccountID: number, @@ -160,8 +159,7 @@ function getReportPreviewAction( isPaidAnimationRunning?: boolean, isApprovedAnimationRunning?: boolean, isSubmittingAnimationRunning?: boolean, - hasDEWSubmitFailed?: boolean, - hasPendingDEWSubmit?: boolean, + hasDEWSubmitPendingOrFailed?: boolean, ): ValueOf { if (!report) { return CONST.REPORT.REPORT_PREVIEW_ACTIONS.VIEW; @@ -181,7 +179,7 @@ function getReportPreviewAction( } // If DEW submit failed or there's a pending DEW submission, show VIEW - if ((hasDEWSubmitFailed || hasPendingDEWSubmit) && isOpenReport(report)) { + if (hasDEWSubmitPendingOrFailed && isOpenReport(report)) { return CONST.REPORT.REPORT_PREVIEW_ACTIONS.VIEW; } diff --git a/src/libs/ReportPrimaryActionUtils.ts b/src/libs/ReportPrimaryActionUtils.ts index 3ff993ff68741..05f7ce45a0597 100644 --- a/src/libs/ReportPrimaryActionUtils.ts +++ b/src/libs/ReportPrimaryActionUtils.ts @@ -13,7 +13,7 @@ import { isPolicyAdmin as isPolicyAdminPolicyUtils, isPreferredExporter, } from './PolicyUtils'; -import {getAllReportActions, getOneTransactionThreadReportID, getOriginalMessage, getReportAction, hasPendingSubmittedAction, isMoneyRequestAction} from './ReportActionsUtils'; +import {getAllReportActions, getOneTransactionThreadReportID, getOriginalMessage, getReportAction, hasDEWSubmitPendingOrFailed, isMoneyRequestAction} from './ReportActionsUtils'; import { canAddTransaction as canAddTransactionUtil, canHoldUnholdReportAction, @@ -82,14 +82,14 @@ function isSubmitAction(report: Report, reportTransactions: Transaction[], polic return false; } - // Don't show submit button if there's already a pending SUBMITTED action on DEW policy (DEW submission in progress) - if (hasDynamicExternalWorkflow(policy) && hasPendingSubmittedAction(reportActions ?? [])) { - return false; - } - const isExpenseReport = isExpenseReportUtils(report); const isReportSubmitter = isCurrentUserSubmitter(report); const isOpenReport = isOpenReportUtils(report); + + // Don't show submit button if DEW submit has failed or is pending + if (hasDEWSubmitPendingOrFailed(reportActions ?? [], hasDynamicExternalWorkflow(policy))) { + return false; + } const transactionAreComplete = reportTransactions.every((transaction) => transaction.amount !== 0 || transaction.modifiedAmount !== 0); if (reportTransactions.length > 0 && reportTransactions.every((transaction) => isPending(transaction))) { diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 4a5f59033df48..5e1f13e22e27d 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -12667,10 +12667,7 @@ function selectFilteredReportActions( const actions = Object.values(actionsGroup ?? {}); const filteredActions = actions.filter( (action): action is ReportAction => - isExportIntegrationAction(action) || - isIntegrationMessageAction(action) || - isDynamicExternalWorkflowSubmitFailedAction(action) || - (isSubmittedAction(action) && action.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD), + isExportIntegrationAction(action) || isIntegrationMessageAction(action) || isDynamicExternalWorkflowSubmitFailedAction(action) || isSubmittedAction(action), ); return [reportId, filteredActions]; }), diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 9983a99ad7152..671dfe9e73878 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -78,9 +78,8 @@ import {getDisplayNameOrDefault} from './PersonalDetailsUtils'; import {arePaymentsEnabled, canSendInvoice, getGroupPaidPoliciesWithExpenseChatEnabled, getPolicy, hasDynamicExternalWorkflow, isPaidGroupPolicy, isPolicyPayer} from './PolicyUtils'; import { getIOUActionForReportID, - getMostRecentActiveDEWSubmitFailedAction, getOriginalMessage, - hasPendingSubmittedAction, + hasDEWSubmitPendingOrFailed, isCreatedAction, isDeletedAction, isHoldAction, @@ -109,6 +108,7 @@ import { isOneTransactionReport, isOpenExpenseReport, isOpenReport, + isReportApproved, isSettled, } from './ReportUtils'; import {buildCannedSearchQuery, buildQueryStringFromFilterFormValues, buildSearchQueryJSON, buildSearchQueryString, getCurrentSearchQueryJSON} from './SearchQueryUtils'; @@ -1242,9 +1242,8 @@ function getActions( return [CONST.SEARCH.ACTION_TYPES.VIEW]; } - // Check for DEW submit failed or pending DEW submission - show View instead of Submit - const isDEWPolicy = hasDynamicExternalWorkflow(policy); - if (report.statusNum === CONST.REPORT.STATUS_NUM.OPEN && (!!getMostRecentActiveDEWSubmitFailedAction(reportActions) || (isDEWPolicy && hasPendingSubmittedAction(reportActions)))) { + // Check for DEW submit failed or pending DEW submission - show View + if (hasDEWSubmitPendingOrFailed(reportActions, hasDynamicExternalWorkflow(policy))) { return [CONST.SEARCH.ACTION_TYPES.VIEW]; } @@ -1280,8 +1279,14 @@ function getActions( : undefined; const chatReport = getChatReport(data, report); - const canBePaid = canIOUBePaid(report, chatReport, policy, allReportTransactions, false, chatReportRNVP, invoiceReceiverPolicy); - const shouldOnlyShowElsewhere = !canBePaid && canIOUBePaid(report, chatReport, policy, allReportTransactions, true, chatReportRNVP, invoiceReceiverPolicy); + + // For DEW policies, don't show PAY if the report is not approved yet + // DEW reports need to go through external approval workflow before payment + const isDEWPolicy = hasDynamicExternalWorkflow(policy); + const shouldSkipPayForDEW = isDEWPolicy && !isReportApproved({report}) && !isClosedReport(report); + + const canBePaid = shouldSkipPayForDEW ? false : canIOUBePaid(report, chatReport, policy, allReportTransactions, false, chatReportRNVP, invoiceReceiverPolicy); + const shouldOnlyShowElsewhere = shouldSkipPayForDEW ? false : !canBePaid && canIOUBePaid(report, chatReport, policy, allReportTransactions, true, chatReportRNVP, invoiceReceiverPolicy); // We're not supporting pay partial amount on search page now. if ((canBePaid || shouldOnlyShowElsewhere) && !hasHeldExpenses(report.reportID, allReportTransactions)) { diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 78076e048ba29..d35abd00dd50f 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -11446,17 +11446,14 @@ function submitReport( key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, value: { ...expenseReport, + lastMessageText: getReportActionText(optimisticSubmittedReportAction), + lastMessageHtml: getReportActionHtml(optimisticSubmittedReportAction), // For DEW policies, don't optimistically update managerID, stateNum, or statusNum // because DEW determines the actual workflow on the backend ...(isDEWPolicy - ? { - lastMessageText: getReportActionText(optimisticSubmittedReportAction), - lastMessageHtml: getReportActionHtml(optimisticSubmittedReportAction), - } + ? {} : { managerID, - lastMessageText: getReportActionText(optimisticSubmittedReportAction), - lastMessageHtml: getReportActionHtml(optimisticSubmittedReportAction), stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, nextStep: optimisticNextStep, diff --git a/src/types/onyx/ReportNextStepDeprecated.ts b/src/types/onyx/ReportNextStepDeprecated.ts index 690ce0329863b..088d155fe2061 100644 --- a/src/types/onyx/ReportNextStepDeprecated.ts +++ b/src/types/onyx/ReportNextStepDeprecated.ts @@ -61,6 +61,9 @@ type ReportNextStepDeprecated = { /** The icon for the next step */ icon: ValueOf; + /** Optional custom fill color for the icon */ + iconFill?: string; + /** Whether the user should take some sort of action in order to unblock the report */ requiresUserAction?: boolean; diff --git a/tests/actions/ReportPreviewActionUtilsTest.ts b/tests/actions/ReportPreviewActionUtilsTest.ts index 0e5d80656f8ff..f667bbfc7c93e 100644 --- a/tests/actions/ReportPreviewActionUtilsTest.ts +++ b/tests/actions/ReportPreviewActionUtilsTest.ts @@ -512,14 +512,14 @@ describe('getReportPreviewAction', () => { const {result: isReportArchived} = renderHook(() => useReportIsArchived(report?.parentReportID)); - // When getReportPreviewAction is called with hasDEWSubmitFailed = true + // When getReportPreviewAction is called with hasDEWSubmitPendingOrFailed = true const result = getReportPreviewAction(isReportArchived.current, CURRENT_USER_ACCOUNT_ID, report, policy, [transaction], undefined, false, false, false, true); // Then it should return VIEW because DEW submission failed and the report cannot be submitted expect(result).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.VIEW); }); - it('should return APPROVE action when DEW submit has failed but report is already SUBMITTED', async () => { + it('should return VIEW action when DEW submit has failed even if report is already SUBMITTED', async () => { // Given a submitted expense report where DEW submit has failed const report: Report = { ...createRandomReport(REPORT_ID, undefined), @@ -544,11 +544,11 @@ describe('getReportPreviewAction', () => { const {result: isReportArchived} = renderHook(() => useReportIsArchived(report?.parentReportID)); - // When getReportPreviewAction is called with hasDEWSubmitFailed = true but report is already submitted + // When getReportPreviewAction is called with hasDEWSubmitPendingOrFailed = true const result = getReportPreviewAction(isReportArchived.current, CURRENT_USER_ACCOUNT_ID, report, policy, [transaction], undefined, false, false, false, true); - // Then it should return APPROVE because the DEW check only applies to OPEN reports - expect(result).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.APPROVE); + // Then it should return VIEW because DEW submission is pending/failed regardless of report status + expect(result).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.VIEW); }); it('should return SUBMIT action when DEW submit has not failed and report is OPEN', async () => { @@ -576,7 +576,7 @@ describe('getReportPreviewAction', () => { const {result: isReportArchived} = renderHook(() => useReportIsArchived(report?.parentReportID)); - // When getReportPreviewAction is called with hasDEWSubmitFailed = false + // When getReportPreviewAction is called with hasDEWSubmitPendingOrFailed = false const result = getReportPreviewAction(isReportArchived.current, CURRENT_USER_ACCOUNT_ID, report, policy, [transaction], undefined, false, false, false, false); // Then it should not return VIEW because DEW submit did not fail and regular logic applies @@ -604,8 +604,8 @@ describe('getReportPreviewAction', () => { const {result: isReportArchived} = renderHook(() => useReportIsArchived(report?.parentReportID)); - // When getReportPreviewAction is called with hasPendingDEWSubmit = true - const result = getReportPreviewAction(isReportArchived.current, CURRENT_USER_ACCOUNT_ID, report, policy, [transaction], undefined, false, false, false, false, true); + // When getReportPreviewAction is called with hasDEWSubmitPendingOrFailed = true (for pending) + const result = getReportPreviewAction(isReportArchived.current, CURRENT_USER_ACCOUNT_ID, report, policy, [transaction], undefined, false, false, false, true); // Then it should return VIEW because DEW submission is in progress expect(result).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.VIEW); diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts index 1532b39c37fe4..084d9b00b970f 100644 --- a/tests/unit/ReportActionsUtilsTest.ts +++ b/tests/unit/ReportActionsUtilsTest.ts @@ -1801,8 +1801,48 @@ describe('ReportActionsUtils', () => { }); }); - describe('hasPendingSubmittedAction', () => { - it('should return true when there is a pending SUBMITTED action', () => { + describe('hasDEWSubmitPendingOrFailed', () => { + it('should return true when there is a DEW submit failed action on DEW policy', () => { + // Given report actions containing a DEW_SUBMIT_FAILED action + const actionId1 = '1'; + const reportActions: ReportActions = { + [actionId1]: { + ...createRandomReportAction(0), + actionName: CONST.REPORT.ACTIONS.TYPE.DEW_SUBMIT_FAILED, + reportActionID: actionId1, + message: [], + previousMessage: [], + } as ReportAction, + }; + + // When checking if DEW submit is pending or failed with isDEWPolicy = true + const result = ReportActionsUtils.hasDEWSubmitPendingOrFailed(reportActions, true); + + // Then it should return true because there's a DEW submit failed action + expect(result).toBe(true); + }); + + it('should return false when not a DEW policy even with DEW submit failed action', () => { + // Given report actions containing a DEW_SUBMIT_FAILED action + const actionId1 = '1'; + const reportActions: ReportActions = { + [actionId1]: { + ...createRandomReportAction(0), + actionName: CONST.REPORT.ACTIONS.TYPE.DEW_SUBMIT_FAILED, + reportActionID: actionId1, + message: [], + previousMessage: [], + } as ReportAction, + }; + + // When checking if DEW submit is pending or failed with isDEWPolicy = false + const result = ReportActionsUtils.hasDEWSubmitPendingOrFailed(reportActions, false); + + // Then it should return false because it's not a DEW policy + expect(result).toBe(false); + }); + + it('should return true when there is a pending SUBMITTED action on DEW policy', () => { // Given report actions containing a SUBMITTED action with pendingAction = ADD const actionId1 = '1'; const reportActions: ReportActions = { @@ -1817,63 +1857,64 @@ describe('ReportActionsUtils', () => { } as ReportAction, }; - // When checking if there is a pending submitted action - const result = ReportActionsUtils.hasPendingSubmittedAction(reportActions); + // When checking if DEW submit is pending or failed with isDEWPolicy = true + const result = ReportActionsUtils.hasDEWSubmitPendingOrFailed(reportActions, true); - // Then it should return true because the SUBMITTED action is pending + // Then it should return true because there's a pending submission on DEW policy expect(result).toBe(true); }); - it('should return false when SUBMITTED action is not pending', () => { - // Given report actions containing a SUBMITTED action without pendingAction + it('should return false when there is a pending SUBMITTED action but NOT a DEW policy', () => { + // Given report actions containing a SUBMITTED action with pendingAction = ADD const actionId1 = '1'; const reportActions: ReportActions = { [actionId1]: { ...createRandomReportAction(0), actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, reportActionID: actionId1, - pendingAction: undefined, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, originalMessage: {amount: 10000, currency: 'USD'}, message: [], previousMessage: [], } as ReportAction, }; - // When checking if there is a pending submitted action - const result = ReportActionsUtils.hasPendingSubmittedAction(reportActions); + // When checking if DEW submit is pending or failed with isDEWPolicy = false + const result = ReportActionsUtils.hasDEWSubmitPendingOrFailed(reportActions, false); - // Then it should return false because the SUBMITTED action is not pending + // Then it should return false because it's not a DEW policy expect(result).toBe(false); }); it('should return false for empty report actions', () => { // Given an empty report actions object - // When checking if there is a pending submitted action - const result = ReportActionsUtils.hasPendingSubmittedAction({}); + // When checking if DEW submit is pending or failed + const result = ReportActionsUtils.hasDEWSubmitPendingOrFailed({}, true); // Then it should return false because there are no actions expect(result).toBe(false); }); - it('should return false when there are no SUBMITTED actions', () => { - // Given report actions containing only a CREATED action (no SUBMITTED) + it('should return false when SUBMITTED action is not pending on DEW policy', () => { + // Given report actions containing a SUBMITTED action without pendingAction const actionId1 = '1'; const reportActions: ReportActions = { [actionId1]: { ...createRandomReportAction(0), - actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, + actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, reportActionID: actionId1, - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + pendingAction: undefined, + originalMessage: {amount: 10000, currency: 'USD'}, message: [], previousMessage: [], - } as ReportAction, + } as ReportAction, }; - // When checking if there is a pending submitted action - const result = ReportActionsUtils.hasPendingSubmittedAction(reportActions); + // When checking if DEW submit is pending or failed with isDEWPolicy = true + const result = ReportActionsUtils.hasDEWSubmitPendingOrFailed(reportActions, true); - // Then it should return false because there are no SUBMITTED actions + // Then it should return false because the SUBMITTED action is not pending expect(result).toBe(false); }); }); From b34a3573a837f46c5f3b167fba30e6b9f04b3875 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Wed, 10 Dec 2025 01:15:51 +0100 Subject: [PATCH 48/76] Reset Mobile-Expensify to match main --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index ca397a9f30123..83624a7d93247 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit ca397a9f301230b182d49721fcf4fccdb899821a +Subproject commit 83624a7d93247e96d0f7bec2e159ec942d0a2b5b From 0a42a2971ff982c59b68e8f6f09097f1a59f3c72 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Mon, 15 Dec 2025 22:28:09 +0100 Subject: [PATCH 49/76] fix ts errors --- src/components/MoneyReportHeaderStatusBar.tsx | 3 ++- src/languages/fr.ts | 1 + src/languages/ja.ts | 3 ++- src/languages/nl.ts | 1 + src/languages/pl.ts | 2 ++ tests/actions/IOUTest.ts | 2 +- tests/actions/ReportPreviewActionUtilsTest.ts | 2 +- 7 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/components/MoneyReportHeaderStatusBar.tsx b/src/components/MoneyReportHeaderStatusBar.tsx index 41e4da4177325..0a5188e38d740 100644 --- a/src/components/MoneyReportHeaderStatusBar.tsx +++ b/src/components/MoneyReportHeaderStatusBar.tsx @@ -24,12 +24,13 @@ type IconMap = Record; function MoneyReportHeaderStatusBar({nextStep}: MoneyReportHeaderStatusBarProps) { const styles = useThemeStyles(); const theme = useTheme(); - const icons = useMemoizedLazyExpensifyIcons(['Hourglass', 'Checkmark', 'Stopwatch'] as const); + const icons = useMemoizedLazyExpensifyIcons(['Hourglass', 'Checkmark', 'Stopwatch', 'DotIndicator'] as const); const iconMap: IconMap = useMemo( () => ({ [CONST.NEXT_STEP.ICONS.HOURGLASS]: icons.Hourglass, [CONST.NEXT_STEP.ICONS.CHECKMARK]: icons.Checkmark, [CONST.NEXT_STEP.ICONS.STOPWATCH]: icons.Stopwatch, + [CONST.NEXT_STEP.ICONS.DOT_INDICATOR]: icons.DotIndicator, }), [icons], ); diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 222f2b3635398..6d8e6a44b6033 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1318,6 +1318,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é", + trackedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `suivi de ${formattedAmount}${comment ? `pour ${comment}` : ''}`, splitAmount: ({amount}: SplitAmountParams) => `diviser ${amount}`, didSplitAmount: ({formattedAmount, comment}: DidSplitAmountMessageParams) => `Diviser ${formattedAmount}${comment ? `pour ${comment}` : ''}`, yourSplit: ({amount}: UserSplitParams) => `Votre part de ${amount}`, diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 7e845ef3a1cbd..a1a0145412cd6 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1316,6 +1316,7 @@ const translations: TranslationDeepObject = { submitted: ({memo}: SubmittedWithMemoParams) => `送信済み${memo ? `、メモ「${memo}」と述べています` : ''}`, automaticallySubmitted: `提出を遅らせるを通じて送信されました`, queuedToSubmitViaDEW: 'カスタム承認ワークフローを介して送信待ちキューに入れられました', + trackedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `${formattedAmount}${comment ? `${comment} 用` : ''} を追跡中`, splitAmount: ({amount}: SplitAmountParams) => `${amount} を分割`, didSplitAmount: ({formattedAmount, comment}: DidSplitAmountMessageParams) => `分割 ${formattedAmount}${comment ? `${comment} 用` : ''}`, yourSplit: ({amount}: UserSplitParams) => `あなたの分担額 ${amount}`, @@ -1396,7 +1397,7 @@ const translations: TranslationDeepObject = { atLeastTwoDifferentWaypoints: '少なくとも 2 つの異なる住所を入力してください', splitExpenseMultipleParticipantsErrorMessage: '経費はワークスペースと他のメンバーで分割できません。選択内容を更新してください。', invalidMerchant: '有効な加盟店名を入力してください', - + atLeastOneAttendee: '少なくとも 1 人の参加者を選択する必要があります', invalidQuantity: '有効な数量を入力してください', quantityGreaterThanZero: '数量は0より大きくなければなりません', invalidSubrateLength: '少なくとも 1 つのサブレートが必要です', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 6202bff2588b8..454f646ca0c15 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1314,6 +1314,7 @@ const translations: TranslationDeepObject = { queuedToSubmitViaDEW: 'in wachtrij geplaatst om in te dienen 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}` : ''}`, yourSplit: ({amount}: UserSplitParams) => `Jouw deel ${amount}`, payerOwesAmount: ({payer, amount, comment}: PayerOwesAmountParams) => `${payer} is ${amount}${comment ? `voor ${comment}` : ''} verschuldigd`, payerOwes: ({payer}: PayerOwesParams) => `${payer} is verschuldigd:`, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 7f11e5ef11c0a..d336e564defd5 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1311,6 +1311,7 @@ const translations: TranslationDeepObject = { expenseAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `${formattedAmount}${comment ? `dla ${comment}` : ''}`, 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', trackedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `śledzenie ${formattedAmount}${comment ? `dla ${comment}` : ''}`, splitAmount: ({amount}: SplitAmountParams) => `podziel ${amount}`, didSplitAmount: ({formattedAmount, comment}: DidSplitAmountMessageParams) => `podziel ${formattedAmount}${comment ? `dla ${comment}` : ''}`, @@ -1393,6 +1394,7 @@ const translations: TranslationDeepObject = { splitExpenseMultipleParticipantsErrorMessage: 'Wydatek nie może zostać podzielony między przestrzeń roboczą a innych członków. Proszę zaktualizować swój wybór.', invalidMerchant: 'Wprowadź poprawnego sprzedawcę', atLeastOneAttendee: 'Co najmniej jeden uczestnik musi zostać wybrany', + invalidQuantity: 'Wprowadź prawidłową ilość', quantityGreaterThanZero: 'Ilość musi być większa niż zero', invalidSubrateLength: 'Musi istnieć co najmniej jedna podstawka', invalidRate: 'Stawka nie jest prawidłowa dla tego przestrzeni roboczej. Wybierz dostępną stawkę z tej przestrzeni roboczej.', diff --git a/tests/actions/IOUTest.ts b/tests/actions/IOUTest.ts index c1d321690f906..c6e6ab8a45243 100644 --- a/tests/actions/IOUTest.ts +++ b/tests/actions/IOUTest.ts @@ -5864,7 +5864,7 @@ describe('actions/IOU', () => { ) .then(() => { if (expenseReport) { - submitReport(expenseReport, policy, CARLOS_ACCOUNT_ID, CARLOS_EMAIL, true, true); + submitReport(expenseReport, policy, CARLOS_ACCOUNT_ID, CARLOS_EMAIL, true, true, undefined); } return waitForBatchedUpdates(); }) diff --git a/tests/actions/ReportPreviewActionUtilsTest.ts b/tests/actions/ReportPreviewActionUtilsTest.ts index f51442ac1dc0e..0de47be1b774d 100644 --- a/tests/actions/ReportPreviewActionUtilsTest.ts +++ b/tests/actions/ReportPreviewActionUtilsTest.ts @@ -206,7 +206,7 @@ describe('getReportPreviewAction', () => { await waitForBatchedUpdatesWithAct(); expect( - getReportPreviewAction(isReportArchived.current, CURRENT_USER_ACCOUNT_ID, report, policy, [transaction], undefined, undefined, undefined, undefined, { + getReportPreviewAction(isReportArchived.current, CURRENT_USER_ACCOUNT_ID, report, policy, [transaction], undefined, undefined, undefined, undefined, undefined, { currentUserEmail: CURRENT_USER_EMAIL, violations, }), From ace86f781d1525c2ce3c9b553385e70909ad7cff Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Mon, 15 Dec 2025 22:38:03 +0100 Subject: [PATCH 50/76] fixing failing tests --- tests/unit/Search/SearchUIUtilsTest.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index ffaa254530de5..acc60a1bde149 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -1609,7 +1609,7 @@ describe('SearchUIUtils', () => { expect(action).toEqual(CONST.SEARCH.ACTION_TYPES.PAY); }); - test('Should return `View` action when report has DEW_SUBMIT_FAILED action and is still OPEN', async () => { + test('Should return `Submit` action when report has DEW_SUBMIT_FAILED action and is still OPEN', async () => { const dewReportID = '999'; const dewTransactionID = '9999'; const dewReportActionID = '99999'; @@ -1643,7 +1643,7 @@ describe('SearchUIUtils', () => { ] as OnyxTypes.ReportAction[]; const action = SearchUIUtils.getActions(localSearchResults, {}, `transactions_${dewTransactionID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', dewReportActions).at(0); - expect(action).toStrictEqual(CONST.SEARCH.ACTION_TYPES.VIEW); + expect(action).toStrictEqual(CONST.SEARCH.ACTION_TYPES.SUBMIT); }); test('Should NOT return `View` action when report has DEW_SUBMIT_FAILED action but is not OPEN', async () => { From 409422dccee84434c7bd3e5352dc2c71e87a3f66 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Tue, 16 Dec 2025 01:55:30 +0100 Subject: [PATCH 51/76] code refactor to enable submit on preview even though there are errors --- src/components/MoneyReportHeader.tsx | 2 +- .../MoneyRequestReportPreviewContent.tsx | 8 +- .../TransactionPreviewContent.tsx | 3 +- src/libs/ReportActionsUtils.ts | 20 +-- src/libs/ReportPreviewActionUtils.ts | 5 +- src/libs/ReportPrimaryActionUtils.ts | 5 +- src/libs/ReportSecondaryActionUtils.ts | 7 +- src/libs/SearchUIUtils.ts | 5 +- src/libs/TransactionPreviewUtils.ts | 18 ++- src/libs/actions/IOU.ts | 1 + tests/actions/ReportPreviewActionUtilsTest.ts | 64 +++------ tests/unit/ReportActionsUtilsTest.ts | 126 ++++++++++-------- 12 files changed, 130 insertions(+), 134 deletions(-) diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index f778ba635c127..cff6c5ff2f57d 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -483,7 +483,7 @@ function MoneyReportHeader({ const {errors} = getAllReportActionsErrorsAndReportActionThatRequiresAttention(moneyRequestReport, reportActionsObject); if (errors?.dewSubmitFailed) { optimisticNextStep = buildOptimisticNextStepForDynamicExternalWorkflowError(theme.danger); - } else if (hasPendingDEWSubmit(reportActions)) { + } else if (hasPendingDEWSubmit(reportActions, hasDynamicExternalWorkflow(policy))) { optimisticNextStep = buildOptimisticNextStepForDEWOfflineSubmission(); } } diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx index 1e32530a47867..5c07cdee73556 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx @@ -43,7 +43,7 @@ import {getTotalAmountForIOUReportPreviewButton} from '@libs/MoneyRequestReportU import Navigation from '@libs/Navigation/Navigation'; import Performance from '@libs/Performance'; import {getConnectedIntegration, hasDynamicExternalWorkflow} from '@libs/PolicyUtils'; -import {hasDEWSubmitPendingOrFailed} from '@libs/ReportActionsUtils'; +import {hasPendingDEWSubmit} from '@libs/ReportActionsUtils'; import {getInvoicePayerName} from '@libs/ReportNameUtils'; import getReportPreviewAction from '@libs/ReportPreviewActionUtils'; import { @@ -493,7 +493,7 @@ function MoneyRequestReportPreviewContent({ }, [iouReportID]); const isDEWPolicy = hasDynamicExternalWorkflow(policy); - const isDEWSubmitPendingOrFailed = useMemo(() => hasDEWSubmitPendingOrFailed(reportActions, isDEWPolicy), [reportActions, isDEWPolicy]); + const isDEWSubmitPending = useMemo(() => hasPendingDEWSubmit(reportActions, isDEWPolicy), [reportActions, isDEWPolicy]); const reportPreviewAction = useMemo(() => { return getReportPreviewAction( isIouReportArchived || isChatReportArchived, @@ -505,7 +505,7 @@ function MoneyRequestReportPreviewContent({ isPaidAnimationRunning, isApprovedAnimationRunning, isSubmittingAnimationRunning, - isDEWSubmitPendingOrFailed, + isDEWSubmitPending, {currentUserEmail: currentUserDetails.email ?? '', violations: transactionViolations}, ); }, [ @@ -521,7 +521,7 @@ function MoneyRequestReportPreviewContent({ currentUserDetails.accountID, currentUserDetails.email, transactionViolations, - isDEWSubmitPendingOrFailed, + isDEWSubmitPending, ]); const addExpenseDropdownOptions = useMemo( diff --git a/src/components/ReportActionItem/TransactionPreview/TransactionPreviewContent.tsx b/src/components/ReportActionItem/TransactionPreview/TransactionPreviewContent.tsx index c0f644e0bfae1..8f35e86f68df7 100644 --- a/src/components/ReportActionItem/TransactionPreview/TransactionPreviewContent.tsx +++ b/src/components/ReportActionItem/TransactionPreview/TransactionPreviewContent.tsx @@ -104,8 +104,9 @@ function TransactionPreviewContent({ isReportAPolicyExpenseChat, currentUserEmail: currentUserDetails.email ?? '', currentUserAccountID: currentUserDetails.accountID, + reportActions, }), - [areThereDuplicates, transactionPreviewCommonArguments, isReportAPolicyExpenseChat, currentUserDetails.email, currentUserDetails.accountID], + [areThereDuplicates, transactionPreviewCommonArguments, isReportAPolicyExpenseChat, currentUserDetails.email, currentUserDetails.accountID, reportActions], ); const {shouldShowRBR, shouldShowMerchant, shouldShowSplitShare, shouldShowTag, shouldShowCategory, shouldShowSkeleton, shouldShowDescription} = conditionals; diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 348a9c8e1c074..c29427956fc4f 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -279,25 +279,12 @@ function getMostRecentActiveDEWSubmitFailedAction(reportActions: OnyxEntry | ReportAction[]): boolean { - const actionsArray = Array.isArray(reportActions) ? reportActions : Object.values(reportActions ?? {}); - return actionsArray.some((action): action is ReportAction => isSubmittedAction(action) && action.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); -} - -/** - * Checks if there's a DEW submit that has failed or is currently pending. - * This is used to determine if we should show the VIEW action instead of SUBMIT. - */ -function hasDEWSubmitPendingOrFailed(reportActions: OnyxEntry | ReportAction[], isDEWPolicy: boolean): boolean { +function hasPendingDEWSubmit(reportActions: OnyxEntry | ReportAction[], isDEWPolicy: boolean): boolean { if (!isDEWPolicy) { return false; } - - if (getMostRecentActiveDEWSubmitFailedAction(reportActions)) { - return true; - } - - return hasPendingDEWSubmit(reportActions); + const actionsArray = Array.isArray(reportActions) ? reportActions : Object.values(reportActions ?? {}); + return actionsArray.some((action): action is ReportAction => isSubmittedAction(action) && action.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); } function isModifiedExpenseAction(reportAction: OnyxInputOrEntry): reportAction is ReportAction { @@ -3712,7 +3699,6 @@ export { isDynamicExternalWorkflowSubmitFailedAction, getMostRecentActiveDEWSubmitFailedAction, hasPendingDEWSubmit, - hasDEWSubmitPendingOrFailed, isWhisperActionTargetedToOthers, isTagModificationAction, isIOUActionMatchingTransactionList, diff --git a/src/libs/ReportPreviewActionUtils.ts b/src/libs/ReportPreviewActionUtils.ts index 8ecb851ab4067..78f799cbe4412 100644 --- a/src/libs/ReportPreviewActionUtils.ts +++ b/src/libs/ReportPreviewActionUtils.ts @@ -172,7 +172,7 @@ function getReportPreviewAction( isPaidAnimationRunning?: boolean, isApprovedAnimationRunning?: boolean, isSubmittingAnimationRunning?: boolean, - hasDEWSubmitPendingOrFailed?: boolean, + isDEWSubmitPending?: boolean, violationsData?: {currentUserEmail?: string; violations?: OnyxCollection}, ): ValueOf { if (!report) { @@ -192,8 +192,7 @@ function getReportPreviewAction( return CONST.REPORT.REPORT_PREVIEW_ACTIONS.ADD_EXPENSE; } - // If DEW submit failed or there's a pending DEW submission, show VIEW - if (hasDEWSubmitPendingOrFailed && isOpenReport(report)) { + if (isDEWSubmitPending && isOpenReport(report)) { return CONST.REPORT.REPORT_PREVIEW_ACTIONS.VIEW; } diff --git a/src/libs/ReportPrimaryActionUtils.ts b/src/libs/ReportPrimaryActionUtils.ts index b92358c76cf24..450a867bfb593 100644 --- a/src/libs/ReportPrimaryActionUtils.ts +++ b/src/libs/ReportPrimaryActionUtils.ts @@ -13,7 +13,7 @@ import { isPolicyAdmin as isPolicyAdminPolicyUtils, isPreferredExporter, } from './PolicyUtils'; -import {getAllReportActions, getOneTransactionThreadReportID, getOriginalMessage, getReportAction, hasDEWSubmitPendingOrFailed, isMoneyRequestAction} from './ReportActionsUtils'; +import {getAllReportActions, getOneTransactionThreadReportID, getOriginalMessage, getReportAction, hasPendingDEWSubmit, isMoneyRequestAction} from './ReportActionsUtils'; import { canAddTransaction as canAddTransactionUtil, canHoldUnholdReportAction, @@ -96,8 +96,7 @@ function isSubmitAction( const isReportSubmitter = isCurrentUserSubmitter(report); const isOpenReport = isOpenReportUtils(report); - // Don't show submit button if DEW submit has failed or is pending - if (hasDEWSubmitPendingOrFailed(reportActions ?? [], hasDynamicExternalWorkflow(policy))) { + if (hasPendingDEWSubmit(reportActions ?? [], hasDynamicExternalWorkflow(policy))) { return false; } const transactionAreComplete = reportTransactions.every((transaction) => transaction.amount !== 0 || transaction.modifiedAmount !== 0); diff --git a/src/libs/ReportSecondaryActionUtils.ts b/src/libs/ReportSecondaryActionUtils.ts index d8b3ff80303e9..1c7a207e38efb 100644 --- a/src/libs/ReportSecondaryActionUtils.ts +++ b/src/libs/ReportSecondaryActionUtils.ts @@ -12,6 +12,7 @@ import { getCorrectedAutoReportingFrequency, getSubmitToAccountID, getValidConnectedIntegration, + hasDynamicExternalWorkflow, hasIntegrationAutoSync, isInstantSubmitEnabled, isPolicyAdmin, @@ -19,7 +20,7 @@ import { isPreferredExporter, isSubmitAndClose, } from './PolicyUtils'; -import {getIOUActionForReportID, getIOUActionForTransactionID, getOneTransactionThreadReportID, getReportAction, isPayAction} from './ReportActionsUtils'; +import {getIOUActionForReportID, getIOUActionForTransactionID, getOneTransactionThreadReportID, getReportAction, hasPendingDEWSubmit, isPayAction} from './ReportActionsUtils'; import {getReportPrimaryAction, isPrimaryPayAction} from './ReportPrimaryActionUtils'; import { canAddTransaction, @@ -149,6 +150,10 @@ function isSubmitAction( return false; } + if (hasPendingDEWSubmit(reportActions ?? [], hasDynamicExternalWorkflow(policy))) { + return false; + } + const transactionAreComplete = reportTransactions.every((transaction) => transaction.amount !== 0 || transaction.modifiedAmount !== 0); if (!transactionAreComplete) { diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 9edf555a6c1ef..b8f0879b7ee6c 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -77,7 +77,7 @@ import {arePaymentsEnabled, canSendInvoice, getGroupPaidPoliciesWithExpenseChatE import { getIOUActionForReportID, getOriginalMessage, - hasDEWSubmitPendingOrFailed, + hasPendingDEWSubmit, isCreatedAction, isDeletedAction, isHoldAction, @@ -1238,8 +1238,7 @@ function getActions( return [CONST.SEARCH.ACTION_TYPES.VIEW]; } - // Check for DEW submit failed or pending DEW submission - show View - if (hasDEWSubmitPendingOrFailed(reportActions, hasDynamicExternalWorkflow(policy))) { + if (hasPendingDEWSubmit(reportActions, hasDynamicExternalWorkflow(policy))) { return [CONST.SEARCH.ACTION_TYPES.VIEW]; } diff --git a/src/libs/TransactionPreviewUtils.ts b/src/libs/TransactionPreviewUtils.ts index 78473e0babb88..744221ca8dd17 100644 --- a/src/libs/TransactionPreviewUtils.ts +++ b/src/libs/TransactionPreviewUtils.ts @@ -10,8 +10,8 @@ import {abandonReviewDuplicateTransactions, setReviewDuplicatesKey} from './acti import {isCategoryMissing} from './CategoryUtils'; import {convertToDisplayString} from './CurrencyUtils'; import DateUtils from './DateUtils'; -import {getPolicy} from './PolicyUtils'; -import {getOriginalMessage, isMessageDeleted, isMoneyRequestAction} from './ReportActionsUtils'; +import {getPolicy, hasDynamicExternalWorkflow} from './PolicyUtils'; +import {getMostRecentActiveDEWSubmitFailedAction, getOriginalMessage, isDynamicExternalWorkflowSubmitFailedAction, isMessageDeleted, isMoneyRequestAction} from './ReportActionsUtils'; import { hasActionWithErrorsForTransaction, hasReceiptError, @@ -273,6 +273,15 @@ function getTransactionPreviewTextAndTranslationPaths({ RBRMessage = actionsWithErrors.length > 1 ? {translationPath: 'violations.reviewRequired'} : {text: actionsWithErrors.at(0)}; } + if (RBRMessage === undefined && hasDynamicExternalWorkflow(policy)) { + const dewFailedAction = getMostRecentActiveDEWSubmitFailedAction(reportActions); + if (dewFailedAction && isDynamicExternalWorkflowSubmitFailedAction(dewFailedAction)) { + const originalMessage = getOriginalMessage(dewFailedAction); + const dewErrorMessage = originalMessage?.message; + RBRMessage = dewErrorMessage ? {text: dewErrorMessage} : {translationPath: 'iou.error.other'}; + } + } + let previewHeaderText: TranslationPathOrText[] = [showCashOrCard]; if (isDistanceRequest(transaction)) { @@ -356,6 +365,7 @@ function createTransactionPreviewConditionals({ areThereDuplicates, currentUserEmail, currentUserAccountID, + reportActions, }: { iouReport: OnyxInputValue | undefined; transaction: OnyxEntry | undefined; @@ -367,6 +377,7 @@ function createTransactionPreviewConditionals({ areThereDuplicates: boolean; currentUserEmail: string; currentUserAccountID: number; + reportActions?: OnyxTypes.ReportActions; }) { const {amount: requestAmount, comment: requestComment, merchant, tag, category} = transactionDetails; @@ -409,7 +420,8 @@ function createTransactionPreviewConditionals({ )); const hasErrorOrOnHold = hasFieldErrors || (!isFullySettled && !isFullyApproved && isTransactionOnHold); const hasReportViolationsOrActionErrors = (isReportOwner(iouReport) && hasReportViolations(iouReport?.reportID)) || hasActionWithErrorsForTransaction(iouReport?.reportID, transaction); - const shouldShowRBR = hasAnyViolations || hasErrorOrOnHold || hasReportViolationsOrActionErrors || hasReceiptError(transaction); + const isDEWSubmitFailed = hasDynamicExternalWorkflow(policy) && !!getMostRecentActiveDEWSubmitFailedAction(reportActions); + 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.ts b/src/libs/actions/IOU.ts index f50a7670c49c8..851182888707e 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -11650,6 +11650,7 @@ function submitReport( key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, value: { [optimisticSubmittedReportAction.reportActionID]: { + pendingAction: null, errors: getMicroSecondOnyxErrorWithTranslationKey('iou.error.other'), }, }, diff --git a/tests/actions/ReportPreviewActionUtilsTest.ts b/tests/actions/ReportPreviewActionUtilsTest.ts index 0de47be1b774d..5498f22a8ca58 100644 --- a/tests/actions/ReportPreviewActionUtilsTest.ts +++ b/tests/actions/ReportPreviewActionUtilsTest.ts @@ -537,9 +537,9 @@ describe('getReportPreviewAction', () => { expect(getReportPreviewAction(isReportArchived.current, CURRENT_USER_ACCOUNT_ID, report, policy, [transaction])).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.EXPORT_TO_ACCOUNTING); }); - describe('DEW (Dynamic External Workflow) submit failed', () => { - it('should return VIEW action when DEW submit has failed and report is OPEN', async () => { - // Given an open expense report with a corporate policy where DEW submit has failed + describe('DEW (Dynamic External Workflow) submit pending', () => { + it('should return VIEW action when DEW submit is pending (offline) and report is OPEN', async () => { + // Given an open expense report with a corporate policy where DEW submit is pending (offline) const report: Report = { ...createRandomReport(REPORT_ID, undefined), type: CONST.REPORT.TYPE.EXPENSE, @@ -559,30 +559,30 @@ describe('getReportPreviewAction', () => { const {result: isReportArchived} = renderHook(() => useReportIsArchived(report?.parentReportID)); - // When getReportPreviewAction is called with hasDEWSubmitPendingOrFailed = true + // When getReportPreviewAction is called with isDEWSubmitPending = true const result = getReportPreviewAction(isReportArchived.current, CURRENT_USER_ACCOUNT_ID, report, policy, [transaction], undefined, false, false, false, true); - // Then it should return VIEW because DEW submission failed and the report cannot be submitted + // Then it should return VIEW because DEW submission is pending offline expect(result).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.VIEW); }); - it('should return VIEW action when DEW submit has failed even if report is already SUBMITTED', async () => { - // Given a submitted expense report where DEW submit has failed + it('should return SUBMIT action when DEW submit has failed (not pending) and report is OPEN', async () => { + // Given an open expense report where DEW submit has failed (returned from backend, not pending offline) 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, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, isWaitingOnBankAccount: false, }; const policy = createRandomPolicy(0); policy.type = CONST.POLICY.TYPE.CORPORATE; - policy.approver = CURRENT_USER_EMAIL; - policy.approvalMode = CONST.POLICY.APPROVAL_MODE.BASIC; - policy.preventSelfApproval = false; + policy.autoReportingFrequency = CONST.POLICY.AUTO_REPORTING_FREQUENCIES.IMMEDIATE; + if (policy.harvesting) { + policy.harvesting.enabled = false; + } await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); const transaction = { @@ -591,11 +591,11 @@ describe('getReportPreviewAction', () => { const {result: isReportArchived} = renderHook(() => useReportIsArchived(report?.parentReportID)); - // When getReportPreviewAction is called with hasDEWSubmitPendingOrFailed = true - const result = getReportPreviewAction(isReportArchived.current, CURRENT_USER_ACCOUNT_ID, report, policy, [transaction], undefined, false, false, false, true); + // When getReportPreviewAction is called with isDEWSubmitPending = false (failed, not pending) + const result = getReportPreviewAction(isReportArchived.current, CURRENT_USER_ACCOUNT_ID, report, policy, [transaction], undefined, false, false, false, false); - // Then it should return VIEW because DEW submission is pending/failed regardless of report status - expect(result).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.VIEW); + // Then it should allow SUBMIT because failed submissions can be retried (not VIEW) + expect(result).not.toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.VIEW); }); it('should return SUBMIT action when DEW submit has not failed and report is OPEN', async () => { @@ -623,39 +623,11 @@ describe('getReportPreviewAction', () => { const {result: isReportArchived} = renderHook(() => useReportIsArchived(report?.parentReportID)); - // When getReportPreviewAction is called with hasDEWSubmitPendingOrFailed = false + // When getReportPreviewAction is called with isDEWSubmitPending = false const result = getReportPreviewAction(isReportArchived.current, CURRENT_USER_ACCOUNT_ID, report, policy, [transaction], undefined, false, false, false, false); // 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); }); - - it('should return VIEW action when DEW submit is pending and report is OPEN', async () => { - // Given an open expense report where DEW submit is pending - const report: Report = { - ...createRandomReport(REPORT_ID, undefined), - type: CONST.REPORT.TYPE.EXPENSE, - ownerAccountID: CURRENT_USER_ACCOUNT_ID, - stateNum: CONST.REPORT.STATE_NUM.OPEN, - statusNum: CONST.REPORT.STATUS_NUM.OPEN, - isWaitingOnBankAccount: false, - }; - - const policy = createRandomPolicy(0); - policy.type = CONST.POLICY.TYPE.CORPORATE; - - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); - const transaction = { - reportID: `${REPORT_ID}`, - } as unknown as Transaction; - - const {result: isReportArchived} = renderHook(() => useReportIsArchived(report?.parentReportID)); - - // When getReportPreviewAction is called with hasDEWSubmitPendingOrFailed = true (for pending) - const result = getReportPreviewAction(isReportArchived.current, CURRENT_USER_ACCOUNT_ID, report, policy, [transaction], undefined, false, false, false, true); - - // Then it should return VIEW because DEW submission is in progress - expect(result).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.VIEW); - }); }); }); diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts index f4e2669bcb4c2..d0c074a384a99 100644 --- a/tests/unit/ReportActionsUtilsTest.ts +++ b/tests/unit/ReportActionsUtilsTest.ts @@ -1878,120 +1878,142 @@ describe('ReportActionsUtils', () => { }); }); - describe('hasDEWSubmitPendingOrFailed', () => { - it('should return true when there is a DEW submit failed action on DEW policy', () => { - // Given report actions containing a DEW_SUBMIT_FAILED action + describe('hasPendingDEWSubmit', () => { + it('should return true when there is a pending SUBMITTED action and isDEWPolicy is true', () => { + // Given a SUBMITTED action with pendingAction ADD and isDEWPolicy is true const actionId1 = '1'; const reportActions: ReportActions = { [actionId1]: { ...createRandomReportAction(0), - actionName: CONST.REPORT.ACTIONS.TYPE.DEW_SUBMIT_FAILED, + actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, + created: '2025-11-21 10:00:00', reportActionID: actionId1, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + originalMessage: { + amount: 10000, + currency: 'USD', + }, message: [], previousMessage: [], - } as ReportAction, + } as ReportAction, }; - // When checking if DEW submit is pending or failed with isDEWPolicy = true - const result = ReportActionsUtils.hasDEWSubmitPendingOrFailed(reportActions, true); + // When checking if there's a pending DEW submit + const result = ReportActionsUtils.hasPendingDEWSubmit(reportActions, true); - // Then it should return true because there's a DEW submit failed action + // Then it should return true because there's a pending SUBMITTED action and the policy is DEW expect(result).toBe(true); }); - it('should return false when not a DEW policy even with DEW submit failed action', () => { - // Given report actions containing a DEW_SUBMIT_FAILED action - const actionId1 = '1'; - const reportActions: ReportActions = { - [actionId1]: { - ...createRandomReportAction(0), - actionName: CONST.REPORT.ACTIONS.TYPE.DEW_SUBMIT_FAILED, - reportActionID: actionId1, - message: [], - previousMessage: [], - } as ReportAction, - }; - - // When checking if DEW submit is pending or failed with isDEWPolicy = false - const result = ReportActionsUtils.hasDEWSubmitPendingOrFailed(reportActions, false); - - // Then it should return false because it's not a DEW policy - expect(result).toBe(false); - }); - - it('should return true when there is a pending SUBMITTED action on DEW policy', () => { - // Given report actions containing a SUBMITTED action with pendingAction = ADD + it('should return false when there is a pending SUBMITTED action but isDEWPolicy is false', () => { + // Given a SUBMITTED action with pendingAction ADD but isDEWPolicy is false const actionId1 = '1'; const reportActions: ReportActions = { [actionId1]: { ...createRandomReportAction(0), actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, + created: '2025-11-21 10:00:00', reportActionID: actionId1, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, - originalMessage: {amount: 10000, currency: 'USD'}, + originalMessage: { + amount: 10000, + currency: 'USD', + }, message: [], previousMessage: [], } as ReportAction, }; - // When checking if DEW submit is pending or failed with isDEWPolicy = true - const result = ReportActionsUtils.hasDEWSubmitPendingOrFailed(reportActions, true); + // When checking if there's a pending DEW submit with isDEWPolicy false + const result = ReportActionsUtils.hasPendingDEWSubmit(reportActions, false); - // Then it should return true because there's a pending submission on DEW policy - expect(result).toBe(true); + // Then it should return false because the policy is not DEW + expect(result).toBe(false); }); - it('should return false when there is a pending SUBMITTED action but NOT a DEW policy', () => { - // Given report actions containing a SUBMITTED action with pendingAction = ADD + it('should return false when there is no pending SUBMITTED action', () => { + // Given a SUBMITTED action without pendingAction ADD const actionId1 = '1'; const reportActions: ReportActions = { [actionId1]: { ...createRandomReportAction(0), actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, + created: '2025-11-21 10:00:00', reportActionID: actionId1, - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, - originalMessage: {amount: 10000, currency: 'USD'}, + originalMessage: { + amount: 10000, + currency: 'USD', + }, message: [], previousMessage: [], } as ReportAction, }; - // When checking if DEW submit is pending or failed with isDEWPolicy = false - const result = ReportActionsUtils.hasDEWSubmitPendingOrFailed(reportActions, false); + // When checking if there's a pending DEW submit + const result = ReportActionsUtils.hasPendingDEWSubmit(reportActions, true); - // Then it should return false because it's not a DEW policy + // Then it should return false because the SUBMITTED action doesn't have pendingAction ADD expect(result).toBe(false); }); it('should return false for empty report actions', () => { - // Given an empty report actions object + // Given empty report actions and isDEWPolicy is true - // When checking if DEW submit is pending or failed - const result = ReportActionsUtils.hasDEWSubmitPendingOrFailed({}, true); + // When checking if there's a pending DEW submit + const result = ReportActionsUtils.hasPendingDEWSubmit({}, true); // Then it should return false because there are no actions expect(result).toBe(false); }); - it('should return false when SUBMITTED action is not pending on DEW policy', () => { - // Given report actions containing a SUBMITTED action without pendingAction + it('should handle array input and return true when there is a pending SUBMITTED action', () => { + // Given an array of report actions with a pending SUBMITTED action + const reportActionsArray: ReportAction[] = [ + { + ...createRandomReportAction(0), + actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, + created: '2025-11-21 10:00:00', + reportActionID: '1', + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + originalMessage: { + amount: 10000, + currency: 'USD', + }, + message: [], + previousMessage: [], + } as ReportAction, + ]; + + // When checking if there's a pending DEW submit + const result = ReportActionsUtils.hasPendingDEWSubmit(reportActionsArray, true); + + // Then it should return true because there's a pending SUBMITTED action + expect(result).toBe(true); + }); + + it('should return false when SUBMITTED action has UPDATE pendingAction instead of ADD', () => { + // Given a SUBMITTED action with pendingAction UPDATE (not ADD) const actionId1 = '1'; const reportActions: ReportActions = { [actionId1]: { ...createRandomReportAction(0), actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, + created: '2025-11-21 10:00:00', reportActionID: actionId1, - pendingAction: undefined, - originalMessage: {amount: 10000, currency: 'USD'}, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + originalMessage: { + amount: 10000, + currency: 'USD', + }, message: [], previousMessage: [], } as ReportAction, }; - // When checking if DEW submit is pending or failed with isDEWPolicy = true - const result = ReportActionsUtils.hasDEWSubmitPendingOrFailed(reportActions, true); + // When checking if there's a pending DEW submit + const result = ReportActionsUtils.hasPendingDEWSubmit(reportActions, true); - // Then it should return false because the SUBMITTED action is not pending + // Then it should return false because pendingAction is UPDATE, not ADD expect(result).toBe(false); }); }); From 735e1455c9d5d230d1801fe7c5b69083d2602bbc Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Tue, 16 Dec 2025 02:10:11 +0100 Subject: [PATCH 52/76] fixing eslint --- src/libs/ReportPreviewActionUtils.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/libs/ReportPreviewActionUtils.ts b/src/libs/ReportPreviewActionUtils.ts index 78f799cbe4412..cecee5fac534f 100644 --- a/src/libs/ReportPreviewActionUtils.ts +++ b/src/libs/ReportPreviewActionUtils.ts @@ -162,6 +162,7 @@ function canExport(report: Report, policy?: Policy) { return isApproved || isReimbursed || isClosed; } +// eslint-disable-next-line @typescript-eslint/max-params function getReportPreviewAction( isReportArchived: boolean, currentUserAccountID: number, From 379b9c849ee2f5ec6e0655d6130000e211518733 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Tue, 16 Dec 2025 02:21:47 +0100 Subject: [PATCH 53/76] fixing eslint --- src/libs/actions/IOU.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 851182888707e..c42a6bc7c20d9 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -91,7 +91,6 @@ import { getSubmitToAccountID, hasDependentTags, hasDynamicExternalWorkflow, - hasOnlyPersonalPolicies, isControlPolicy, isDelayedSubmissionEnabled, isPaidGroupPolicy, From d4718b8a0c76df5c3cc2d98bde09864f5f927176 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Wed, 17 Dec 2025 21:16:37 +0100 Subject: [PATCH 54/76] fix: track DEW pending submit via reportMetadata and only show offline messages when offline --- src/CONST/index.ts | 4 + src/components/MoneyReportHeader.tsx | 7 +- .../MoneyRequestReportPreviewContent.tsx | 3 +- src/libs/ReportActionsUtils.ts | 11 +- src/libs/ReportPrimaryActionUtils.ts | 10 +- src/libs/ReportSecondaryActionUtils.ts | 24 +++- src/libs/SearchUIUtils.ts | 17 +-- src/libs/actions/IOU.ts | 31 ++++- .../home/report/PureReportActionItem.tsx | 15 +- src/pages/home/report/ReportActionItem.tsx | 5 + src/types/onyx/ReportMetadata.ts | 5 + tests/unit/ReportActionsUtilsTest.ts | 130 ++++-------------- 12 files changed, 125 insertions(+), 137 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index ddec0f25af431..b8fd65171df88 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -3956,6 +3956,10 @@ const CONST = { DELETE: 'delete', UPDATE: 'update', }, + EXPENSE_PENDING_ACTION: { + SUBMIT: 'SUBMIT', + APPROVE: 'APPROVE', + }, BRICK_ROAD_INDICATOR_STATUS: { ERROR: 'error', INFO: 'info', diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index d31d282e8b933..4e71edc94e846 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -249,6 +249,7 @@ function MoneyReportHeader({ 'ReceiptMultiple', ] as const); const [lastDistanceExpenseType] = useOnyx(ONYXKEYS.NVP_LAST_DISTANCE_EXPENSE_TYPE, {canBeMissing: true}); + const [reportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${moneyRequestReport?.reportID}`, {canBeMissing: true}); const {translate} = useLocalize(); const exportTemplates = useMemo( @@ -483,7 +484,7 @@ function MoneyReportHeader({ const {errors} = getAllReportActionsErrorsAndReportActionThatRequiresAttention(moneyRequestReport, reportActionsObject); if (errors?.dewSubmitFailed) { optimisticNextStep = buildOptimisticNextStepForDynamicExternalWorkflowError(theme.danger); - } else if (hasPendingDEWSubmit(reportActions, hasDynamicExternalWorkflow(policy))) { + } else if (isOffline && hasPendingDEWSubmit(reportMetadata, hasDynamicExternalWorkflow(policy))) { optimisticNextStep = buildOptimisticNextStepForDEWOfflineSubmission(); } } @@ -731,6 +732,7 @@ function MoneyReportHeader({ policy, reportNameValuePairs, reportActions, + reportMetadata, isChatReportArchived, invoiceReceiverPolicy, isPaidAnimationRunning, @@ -748,6 +750,7 @@ function MoneyReportHeader({ policy, reportNameValuePairs, reportActions, + reportMetadata, isChatReportArchived, invoiceReceiverPolicy, currentUserLogin, @@ -1025,6 +1028,7 @@ function MoneyReportHeader({ policy, reportNameValuePairs, reportActions, + reportMetadata, policies, isChatReportArchived, }); @@ -1039,6 +1043,7 @@ function MoneyReportHeader({ policy, reportNameValuePairs, reportActions, + reportMetadata, policies, isChatReportArchived, ]); diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx index d57559d9a011b..3b0f947fdbbcf 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx @@ -124,6 +124,7 @@ function MoneyRequestReportPreviewContent({ forwardedFSClass, }: MoneyRequestReportPreviewContentProps) { const [chatReportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${chatReportID}`, {canBeMissing: true, allowStaleData: true}); + const [iouReportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${iouReportID}`, {canBeMissing: true}); const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID, {canBeMissing: true}); const [iouReportNextStep] = useOnyx(`${ONYXKEYS.COLLECTION.NEXT_STEP}${iouReportID}`, {canBeMissing: true}); const activePolicy = usePolicy(activePolicyID); @@ -493,7 +494,7 @@ function MoneyRequestReportPreviewContent({ }, [iouReportID]); const isDEWPolicy = hasDynamicExternalWorkflow(policy); - const isDEWSubmitPending = useMemo(() => hasPendingDEWSubmit(reportActions, isDEWPolicy), [reportActions, isDEWPolicy]); + const isDEWSubmitPending = hasPendingDEWSubmit(iouReportMetadata, isDEWPolicy); const reportPreviewAction = useMemo(() => { return getReportPreviewAction( isIouReportArchived || isChatReportArchived, diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index c29427956fc4f..3cf7247bd2f3d 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -12,7 +12,7 @@ import IntlStore from '@src/languages/IntlStore'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {Card, OnyxInputOrEntry, OriginalMessageIOU, Policy, PrivatePersonalDetails} from '@src/types/onyx'; +import type {Card, OnyxInputOrEntry, OriginalMessageIOU, Policy, PrivatePersonalDetails, ReportMetadata} from '@src/types/onyx'; import type {JoinWorkspaceResolution, OriginalMessageChangeLog, OriginalMessageExportIntegration} from '@src/types/onyx/OriginalMessage'; import type {PolicyReportFieldType} from '@src/types/onyx/Policy'; import type Report from '@src/types/onyx/Report'; @@ -276,15 +276,14 @@ function getMostRecentActiveDEWSubmitFailedAction(reportActions: OnyxEntry | ReportAction[], isDEWPolicy: boolean): boolean { +function hasPendingDEWSubmit(reportMetadata: OnyxEntry, isDEWPolicy: boolean): boolean { if (!isDEWPolicy) { return false; } - const actionsArray = Array.isArray(reportActions) ? reportActions : Object.values(reportActions ?? {}); - return actionsArray.some((action): action is ReportAction => isSubmittedAction(action) && action.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + return reportMetadata?.pendingExpenseAction === CONST.EXPENSE_PENDING_ACTION.SUBMIT; } function isModifiedExpenseAction(reportAction: OnyxInputOrEntry): reportAction is ReportAction { diff --git a/src/libs/ReportPrimaryActionUtils.ts b/src/libs/ReportPrimaryActionUtils.ts index 450a867bfb593..5739c03ced4f6 100644 --- a/src/libs/ReportPrimaryActionUtils.ts +++ b/src/libs/ReportPrimaryActionUtils.ts @@ -1,7 +1,7 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import CONST from '@src/CONST'; -import type {Policy, Report, ReportAction, ReportNameValuePairs, Transaction, TransactionViolation} from '@src/types/onyx'; +import type {Policy, Report, ReportAction, ReportMetadata, ReportNameValuePairs, Transaction, TransactionViolation} from '@src/types/onyx'; import {isApprover as isApproverUtils} from './actions/Policy/Member'; import {getCurrentUserAccountID} from './actions/Report'; import { @@ -60,6 +60,7 @@ type GetReportPrimaryActionParams = { policy?: Policy; reportNameValuePairs?: ReportNameValuePairs; reportActions?: ReportAction[]; + reportMetadata?: OnyxEntry; isChatReportArchived: boolean; invoiceReceiverPolicy?: Policy; isPaidAnimationRunning?: boolean; @@ -86,7 +87,7 @@ function isSubmitAction( violations?: OnyxCollection, currentUserEmail?: string, currentUserAccountID?: number, - reportActions?: ReportAction[], + reportMetadata?: OnyxEntry, ) { if (isArchivedReport(reportNameValuePairs)) { return false; @@ -96,7 +97,7 @@ function isSubmitAction( const isReportSubmitter = isCurrentUserSubmitter(report); const isOpenReport = isOpenReportUtils(report); - if (hasPendingDEWSubmit(reportActions ?? [], hasDynamicExternalWorkflow(policy))) { + if (hasPendingDEWSubmit(reportMetadata, hasDynamicExternalWorkflow(policy))) { return false; } const transactionAreComplete = reportTransactions.every((transaction) => transaction.amount !== 0 || transaction.modifiedAmount !== 0); @@ -390,6 +391,7 @@ function getReportPrimaryAction(params: GetReportPrimaryActionParams): ValueOf, isChatReportArchived = false, primaryAction?: ValueOf | '', violations?: OnyxCollection, @@ -150,7 +151,7 @@ function isSubmitAction( return false; } - if (hasPendingDEWSubmit(reportActions ?? [], hasDynamicExternalWorkflow(policy))) { + if (hasPendingDEWSubmit(reportMetadata, hasDynamicExternalWorkflow(policy))) { return false; } @@ -688,6 +689,7 @@ function getSecondaryReportActions({ policy, reportNameValuePairs, reportActions, + reportMetadata, policies, isChatReportArchived = false, }: { @@ -701,6 +703,7 @@ function getSecondaryReportActions({ policy?: Policy; reportNameValuePairs?: ReportNameValuePairs; reportActions?: ReportAction[]; + reportMetadata?: OnyxEntry; policies?: OnyxCollection; canUseNewDotSplits?: boolean; isChatReportArchived?: boolean; @@ -725,10 +728,25 @@ function getSecondaryReportActions({ policy, reportNameValuePairs, reportActions, + reportMetadata, isChatReportArchived, }); - if (isSubmitAction(report, reportTransactions, policy, reportNameValuePairs, reportActions, isChatReportArchived, primaryAction, violations, currentUserEmail, currentUserAccountID)) { + if ( + isSubmitAction( + report, + reportTransactions, + policy, + reportNameValuePairs, + reportActions, + reportMetadata, + isChatReportArchived, + primaryAction, + violations, + currentUserEmail, + currentUserAccountID, + ) + ) { options.push(CONST.REPORT.SECONDARY_ACTIONS.SUBMIT); } diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index c7880e84cd97f..a0f95f29c3fce 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -75,11 +75,10 @@ import {translateLocal} from './Localize'; import Navigation from './Navigation/Navigation'; import Parser from './Parser'; import {getDisplayNameOrDefault} from './PersonalDetailsUtils'; -import {arePaymentsEnabled, canSendInvoice, getGroupPaidPoliciesWithExpenseChatEnabled, getPolicy, hasDynamicExternalWorkflow, isPaidGroupPolicy, isPolicyPayer} from './PolicyUtils'; +import {arePaymentsEnabled, canSendInvoice, getGroupPaidPoliciesWithExpenseChatEnabled, getPolicy, isPaidGroupPolicy, isPolicyPayer} from './PolicyUtils'; import { getIOUActionForReportID, getOriginalMessage, - hasPendingDEWSubmit, isCreatedAction, isDeletedAction, isHoldAction, @@ -109,7 +108,6 @@ import { isOneTransactionReport, isOpenExpenseReport, isOpenReport, - isReportApproved, isSettled, } from './ReportUtils'; import {buildCannedSearchQuery, buildQueryStringFromFilterFormValues, buildSearchQueryJSON, buildSearchQueryString, getCurrentSearchQueryJSON} from './SearchQueryUtils'; @@ -1240,10 +1238,6 @@ function getActions( return [CONST.SEARCH.ACTION_TYPES.VIEW]; } - if (hasPendingDEWSubmit(reportActions, hasDynamicExternalWorkflow(policy))) { - return [CONST.SEARCH.ACTION_TYPES.VIEW]; - } - // We don't need to run the logic if this is not a transaction or iou/expense report, so let's shortcut the logic for performance reasons if (!isMoneyRequestReport(report) && !isInvoiceReport(report)) { return [CONST.SEARCH.ACTION_TYPES.VIEW]; @@ -1277,13 +1271,8 @@ function getActions( const chatReport = getChatReport(data, report); - // For DEW policies, don't show PAY if the report is not approved yet - // DEW reports need to go through external approval workflow before payment - const isDEWPolicy = hasDynamicExternalWorkflow(policy); - const shouldSkipPayForDEW = isDEWPolicy && !isReportApproved({report}) && !isClosedReport(report); - - const canBePaid = shouldSkipPayForDEW ? false : canIOUBePaid(report, chatReport, policy, allReportTransactions, false, chatReportRNVP, invoiceReceiverPolicy); - const shouldOnlyShowElsewhere = shouldSkipPayForDEW ? false : !canBePaid && canIOUBePaid(report, chatReport, policy, allReportTransactions, true, chatReportRNVP, invoiceReceiverPolicy); + const canBePaid = canIOUBePaid(report, chatReport, policy, allReportTransactions, false, chatReportRNVP, invoiceReceiverPolicy); + const shouldOnlyShowElsewhere = !canBePaid && canIOUBePaid(report, chatReport, policy, allReportTransactions, true, chatReportRNVP, invoiceReceiverPolicy); // We're not supporting pay partial amount on search page now. if ((canBePaid || shouldOnlyShowElsewhere) && !hasHeldExpenses(report.reportID, allReportTransactions)) { diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index f5af75628089f..d94b162e8d0b0 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -11599,6 +11599,16 @@ function submitReport( }); } + if (isDEWPolicy) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${expenseReport.reportID}`, + value: { + pendingExpenseAction: CONST.EXPENSE_PENDING_ACTION.SUBMIT, + }, + }); + } + const successData: OnyxUpdate[] = []; if (!isDEWPolicy) { successData.push({ @@ -11621,6 +11631,16 @@ function submitReport( }, }); + 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, @@ -11644,12 +11664,21 @@ function submitReport( key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, value: { [optimisticSubmittedReportAction.reportActionID]: { - pendingAction: null, errors: getMicroSecondOnyxErrorWithTranslationKey('iou.error.other'), }, }, }); + if (isDEWPolicy) { + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${expenseReport.reportID}`, + value: { + pendingExpenseAction: null, + }, + }); + } + if (!isDEWPolicy) { failureData.push({ onyxMethod: Onyx.METHOD.MERGE, diff --git a/src/pages/home/report/PureReportActionItem.tsx b/src/pages/home/report/PureReportActionItem.tsx index a7d7c87c56e73..890e2a0b626d3 100644 --- a/src/pages/home/report/PureReportActionItem.tsx +++ b/src/pages/home/report/PureReportActionItem.tsx @@ -114,6 +114,7 @@ import { getWorkspaceTagUpdateMessage, getWorkspaceTaxUpdateMessage, getWorkspaceUpdateFieldMessage, + hasPendingDEWSubmit, isActionableAddPaymentCard, isActionableCardFraudAlert, isActionableJoinRequest, @@ -418,6 +419,12 @@ type PureReportActionItemProps = { /** Report name value pairs originalID */ reportNameValuePairsOriginalID?: string; + + /** Report metadata for the report */ + reportMetadata?: OnyxEntry; + + /** Whether the network is offline */ + isOffline?: boolean; }; // This is equivalent to returning a negative boolean in normal functions, but we can keep the element return type @@ -488,6 +495,8 @@ function PureReportActionItem({ bankAccountList, reportNameValuePairsOrigin, reportNameValuePairsOriginalID, + reportMetadata, + isOffline, }: PureReportActionItemProps) { const actionSheetAwareScrollViewContext = useContext(ActionSheetAwareScrollView.ActionSheetAwareScrollViewContext); const {translate, formatPhoneNumber, localeCompare, formatTravelDate, getLocalDateFromDatetime} = useLocalize(); @@ -1198,7 +1207,6 @@ function PureReportActionItem({ ); } else if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.SUBMITTED) || isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.SUBMITTED_AND_CLOSED) || isMarkAsClosedAction(action)) { const wasSubmittedViaHarvesting = !isMarkAsClosedAction(action) ? (getOriginalMessage(action)?.harvesting ?? false) : false; - const isPendingAdd = action.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD; const isDEWPolicy = hasDynamicExternalWorkflow(policy); if (wasSubmittedViaHarvesting) { @@ -1207,7 +1215,7 @@ function PureReportActionItem({ ${translate('iou.automaticallySubmitted')}`} /> ); - } else if (isPendingAdd && isDEWPolicy) { + } else if (isOffline && hasPendingDEWSubmit(reportMetadata, isDEWPolicy)) { children = ; } else { children = ; @@ -1973,6 +1981,7 @@ export default memo(PureReportActionItem, (prevProps, nextProps) => { prevProps.shouldHighlight === nextProps.shouldHighlight && deepEqual(prevProps.bankAccountList, nextProps.bankAccountList) && prevProps.reportNameValuePairsOrigin === nextProps.reportNameValuePairsOrigin && - prevProps.reportNameValuePairsOriginalID === nextProps.reportNameValuePairsOriginalID + prevProps.reportNameValuePairsOriginalID === nextProps.reportNameValuePairsOriginalID && + prevProps.reportMetadata?.pendingExpenseAction === nextProps.reportMetadata?.pendingExpenseAction ); }); diff --git a/src/pages/home/report/ReportActionItem.tsx b/src/pages/home/report/ReportActionItem.tsx index 3d1ab6006c390..1f2532bc5d489 100644 --- a/src/pages/home/report/ReportActionItem.tsx +++ b/src/pages/home/report/ReportActionItem.tsx @@ -2,6 +2,7 @@ import {accountIDSelector} from '@selectors/Session'; import React from 'react'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {useBlockedFromConcierge} from '@components/OnyxListItemProvider'; +import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useOriginalReportID from '@hooks/useOriginalReportID'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; @@ -94,8 +95,10 @@ function ReportActionItem({ const originalReportID = useOriginalReportID(reportID, action); const originalReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${originalReportID}`]; const isOriginalReportArchived = useReportIsArchived(originalReportID); + const {isOffline} = useNetwork(); const [currentUserAccountID] = useOnyx(ONYXKEYS.SESSION, {canBeMissing: false, selector: accountIDSelector}); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {canBeMissing: true}); + const [reportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`, {canBeMissing: true}); const iouReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${getIOUReportIDFromReportActionPreview(action)}`]; const movedFromReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${getMovedReportID(action, CONST.REPORT.MOVE_TYPE.FROM)}`]; const movedToReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${getMovedReportID(action, CONST.REPORT.MOVE_TYPE.TO)}`]; @@ -164,6 +167,8 @@ function ReportActionItem({ userBillingFundID={userBillingFundID} isTryNewDotNVPDismissed={isTryNewDotNVPDismissed} bankAccountList={bankAccountList} + reportMetadata={reportMetadata} + isOffline={isOffline} /> ); } diff --git a/src/types/onyx/ReportMetadata.ts b/src/types/onyx/ReportMetadata.ts index a006bd90c5b22..bbd6e2193319b 100644 --- a/src/types/onyx/ReportMetadata.ts +++ b/src/types/onyx/ReportMetadata.ts @@ -1,3 +1,5 @@ +import type {ValueOf} from 'type-fest'; +import type CONST from '@src/CONST'; import type * as OnyxCommon from './OnyxCommon'; /** The pending member of report */ @@ -49,6 +51,9 @@ type ReportMetadata = { /** Whether the report has violations or errors */ errors?: OnyxCommon.Errors; + + /** Pending expense action for DEW policies (e.g., SUBMIT or APPROVE in progress) */ + pendingExpenseAction?: ValueOf; }; export default ReportMetadata; diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts index d0c074a384a99..b2f1290e2b15b 100644 --- a/tests/unit/ReportActionsUtilsTest.ts +++ b/tests/unit/ReportActionsUtilsTest.ts @@ -1879,141 +1879,63 @@ describe('ReportActionsUtils', () => { }); describe('hasPendingDEWSubmit', () => { - it('should return true when there is a pending SUBMITTED action and isDEWPolicy is true', () => { - // Given a SUBMITTED action with pendingAction ADD and isDEWPolicy is true - const actionId1 = '1'; - const reportActions: ReportActions = { - [actionId1]: { - ...createRandomReportAction(0), - actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, - created: '2025-11-21 10:00:00', - reportActionID: actionId1, - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, - originalMessage: { - amount: 10000, - currency: 'USD', - }, - message: [], - previousMessage: [], - } as ReportAction, + it('should return true when pendingExpenseAction is SUBMIT and isDEWPolicy is true', () => { + // Given reportMetadata with pendingExpenseAction SUBMIT and isDEWPolicy is true + const reportMetadata = { + pendingExpenseAction: CONST.EXPENSE_PENDING_ACTION.SUBMIT, }; // When checking if there's a pending DEW submit - const result = ReportActionsUtils.hasPendingDEWSubmit(reportActions, true); + const result = ReportActionsUtils.hasPendingDEWSubmit(reportMetadata, true); - // Then it should return true because there's a pending SUBMITTED action and the policy is DEW + // Then it should return true expect(result).toBe(true); }); - it('should return false when there is a pending SUBMITTED action but isDEWPolicy is false', () => { - // Given a SUBMITTED action with pendingAction ADD but isDEWPolicy is false - const actionId1 = '1'; - const reportActions: ReportActions = { - [actionId1]: { - ...createRandomReportAction(0), - actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, - created: '2025-11-21 10:00:00', - reportActionID: actionId1, - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, - originalMessage: { - amount: 10000, - currency: 'USD', - }, - message: [], - previousMessage: [], - } as ReportAction, + it('should return false when pendingExpenseAction is SUBMIT but isDEWPolicy is false', () => { + // Given reportMetadata with pendingExpenseAction SUBMIT but isDEWPolicy is false + const reportMetadata = { + pendingExpenseAction: CONST.EXPENSE_PENDING_ACTION.SUBMIT, }; // When checking if there's a pending DEW submit with isDEWPolicy false - const result = ReportActionsUtils.hasPendingDEWSubmit(reportActions, false); + const result = ReportActionsUtils.hasPendingDEWSubmit(reportMetadata, false); // Then it should return false because the policy is not DEW expect(result).toBe(false); }); - it('should return false when there is no pending SUBMITTED action', () => { - // Given a SUBMITTED action without pendingAction ADD - const actionId1 = '1'; - const reportActions: ReportActions = { - [actionId1]: { - ...createRandomReportAction(0), - actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, - created: '2025-11-21 10:00:00', - reportActionID: actionId1, - originalMessage: { - amount: 10000, - currency: 'USD', - }, - message: [], - previousMessage: [], - } as ReportAction, + it('should return false when pendingExpenseAction is not SUBMIT', () => { + // Given reportMetadata with pendingExpenseAction APPROVE (not SUBMIT) + const reportMetadata = { + pendingExpenseAction: CONST.EXPENSE_PENDING_ACTION.APPROVE, }; // When checking if there's a pending DEW submit - const result = ReportActionsUtils.hasPendingDEWSubmit(reportActions, true); + const result = ReportActionsUtils.hasPendingDEWSubmit(reportMetadata, true); - // Then it should return false because the SUBMITTED action doesn't have pendingAction ADD + // Then it should return false because pendingExpenseAction is APPROVE, not SUBMIT expect(result).toBe(false); }); - it('should return false for empty report actions', () => { - // Given empty report actions and isDEWPolicy is true + it('should return false when pendingExpenseAction is undefined', () => { + // Given reportMetadata without pendingExpenseAction + const reportMetadata = {}; // When checking if there's a pending DEW submit - const result = ReportActionsUtils.hasPendingDEWSubmit({}, true); + const result = ReportActionsUtils.hasPendingDEWSubmit(reportMetadata, true); - // Then it should return false because there are no actions + // Then it should return false expect(result).toBe(false); }); - it('should handle array input and return true when there is a pending SUBMITTED action', () => { - // Given an array of report actions with a pending SUBMITTED action - const reportActionsArray: ReportAction[] = [ - { - ...createRandomReportAction(0), - actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, - created: '2025-11-21 10:00:00', - reportActionID: '1', - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, - originalMessage: { - amount: 10000, - currency: 'USD', - }, - message: [], - previousMessage: [], - } as ReportAction, - ]; - - // When checking if there's a pending DEW submit - const result = ReportActionsUtils.hasPendingDEWSubmit(reportActionsArray, true); - - // Then it should return true because there's a pending SUBMITTED action - expect(result).toBe(true); - }); - - it('should return false when SUBMITTED action has UPDATE pendingAction instead of ADD', () => { - // Given a SUBMITTED action with pendingAction UPDATE (not ADD) - const actionId1 = '1'; - const reportActions: ReportActions = { - [actionId1]: { - ...createRandomReportAction(0), - actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, - created: '2025-11-21 10:00:00', - reportActionID: actionId1, - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, - originalMessage: { - amount: 10000, - currency: 'USD', - }, - message: [], - previousMessage: [], - } as ReportAction, - }; + it('should return false when reportMetadata is undefined', () => { + // Given undefined reportMetadata // When checking if there's a pending DEW submit - const result = ReportActionsUtils.hasPendingDEWSubmit(reportActions, true); + const result = ReportActionsUtils.hasPendingDEWSubmit(undefined, true); - // Then it should return false because pendingAction is UPDATE, not ADD + // Then it should return false expect(result).toBe(false); }); }); From c41a50c7cbc6cb2d03cb0718a24427e0e4b8cac1 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Wed, 17 Dec 2025 21:39:45 +0100 Subject: [PATCH 55/76] fix: make LHN preview consistent with report view for DEW pending state --- src/components/LHNOptionsList/LHNOptionsList.tsx | 4 ++++ src/libs/OptionsListUtils/index.ts | 9 +++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/components/LHNOptionsList/LHNOptionsList.tsx b/src/components/LHNOptionsList/LHNOptionsList.tsx index 5ac8b957b8550..712c89e925a17 100644 --- a/src/components/LHNOptionsList/LHNOptionsList.tsx +++ b/src/components/LHNOptionsList/LHNOptionsList.tsx @@ -61,6 +61,7 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {canBeMissing: false}); const [reportAttributes] = useOnyx(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, {selector: reportsSelector, canBeMissing: true}); const [reportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, {canBeMissing: true}); + const [reportMetadataCollection] = useOnyx(ONYXKEYS.COLLECTION.REPORT_METADATA, {canBeMissing: true}); const [reportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS, {canBeMissing: false}); const [policy] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {canBeMissing: false}); const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {canBeMissing: true}); @@ -225,6 +226,7 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio } const movedFromReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${getMovedReportID(lastReportAction, CONST.REPORT.MOVE_TYPE.FROM)}`]; const movedToReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${getMovedReportID(lastReportAction, CONST.REPORT.MOVE_TYPE.TO)}`]; + const itemReportMetadata = reportMetadataCollection?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`]; const lastMessageTextFromReport = getLastMessageTextForReport({ report: item, lastActorDetails, @@ -233,6 +235,8 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio policy: itemPolicy, isReportArchived: !!itemReportNameValuePairs?.private_isArchived, policyForMovingExpensesID, + reportMetadata: itemReportMetadata, + isOffline, }); const shouldShowRBRorGBRTooltip = firstReportIDWithGBRorRBR === reportID; diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 80792a270e877..252dff331e8dc 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -57,6 +57,7 @@ import { getSortedReportActions, getTravelUpdateMessage, getUpdateRoomDescriptionMessage, + hasPendingDEWSubmit, isActionableAddPaymentCard, isActionableJoinRequest, isActionableMentionWhisper, @@ -159,6 +160,7 @@ import type { ReportAction, ReportActions, ReportAttributesDerivedValue, + ReportMetadata, ReportNameValuePairs, } from '@src/types/onyx'; import type {Attendee, Participant} from '@src/types/onyx/IOU'; @@ -603,6 +605,8 @@ function getLastMessageTextForReport({ policy, isReportArchived = false, policyForMovingExpensesID, + reportMetadata, + isOffline, }: { report: OnyxEntry; lastActorDetails: Partial | null; @@ -611,6 +615,8 @@ function getLastMessageTextForReport({ policy?: OnyxEntry; isReportArchived?: boolean; policyForMovingExpensesID?: string; + reportMetadata?: OnyxEntry; + isOffline?: boolean; }): string { const reportID = report?.reportID; const lastReportAction = reportID ? lastVisibleReportActions[reportID] : undefined; @@ -710,13 +716,12 @@ function getLastMessageTextForReport({ isMarkAsClosedAction(lastReportAction) ) { const wasSubmittedViaHarvesting = !isMarkAsClosedAction(lastReportAction) ? (getOriginalMessage(lastReportAction)?.harvesting ?? false) : false; - const isPendingAdd = lastReportAction.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD; const isDEWPolicy = hasDynamicExternalWorkflow(policy); if (wasSubmittedViaHarvesting) { // eslint-disable-next-line @typescript-eslint/no-deprecated lastMessageTextFromReport = Parser.htmlToText(translateLocal('iou.automaticallySubmitted')); - } else if (isPendingAdd && isDEWPolicy) { + } else if (isOffline && hasPendingDEWSubmit(reportMetadata, isDEWPolicy)) { // eslint-disable-next-line @typescript-eslint/no-deprecated lastMessageTextFromReport = translateLocal('iou.queuedToSubmitViaDEW'); } else { From 5927c421296e8a5088083d8ea5ad2ee08737579d Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Wed, 17 Dec 2025 21:45:26 +0100 Subject: [PATCH 56/76] fixing react complience err --- .../TransactionPreviewContent.tsx | 14 ++++---- .../Search/ReportListItemHeader.tsx | 32 +++++++------------ 2 files changed, 19 insertions(+), 27 deletions(-) diff --git a/src/components/ReportActionItem/TransactionPreview/TransactionPreviewContent.tsx b/src/components/ReportActionItem/TransactionPreview/TransactionPreviewContent.tsx index 6ab26f60c0c5b..a417b3917126d 100644 --- a/src/components/ReportActionItem/TransactionPreview/TransactionPreviewContent.tsx +++ b/src/components/ReportActionItem/TransactionPreview/TransactionPreviewContent.tsx @@ -84,6 +84,8 @@ function TransactionPreviewContent({ const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(report?.reportID)}`, {canBeMissing: true}); const isChatReportArchived = useReportIsArchived(chatReport?.reportID); const currentUserDetails = useCurrentUserPersonalDetails(); + const currentUserEmail = currentUserDetails.email ?? ''; + const currentUserAccountID = currentUserDetails.accountID; const transactionPreviewCommonArguments = useMemo( () => ({ iouReport: report, @@ -102,11 +104,11 @@ function TransactionPreviewContent({ ...transactionPreviewCommonArguments, areThereDuplicates, isReportAPolicyExpenseChat, - currentUserEmail: currentUserDetails.email ?? '', - currentUserAccountID: currentUserDetails.accountID, + currentUserEmail, + currentUserAccountID, reportActions, }), - [areThereDuplicates, transactionPreviewCommonArguments, isReportAPolicyExpenseChat, currentUserDetails.email, currentUserDetails.accountID, reportActions], + [areThereDuplicates, transactionPreviewCommonArguments, isReportAPolicyExpenseChat, currentUserEmail, currentUserAccountID, reportActions], ); const {shouldShowRBR, shouldShowMerchant, shouldShowSplitShare, shouldShowTag, shouldShowCategory, shouldShowSkeleton, shouldShowDescription} = conditionals; @@ -124,11 +126,11 @@ function TransactionPreviewContent({ shouldShowRBR, violationMessage, reportActions, - currentUserEmail: currentUserDetails.email ?? '', - currentUserAccountID: currentUserDetails.accountID, + currentUserEmail, + currentUserAccountID, originalTransaction, }), - [transactionPreviewCommonArguments, shouldShowRBR, violationMessage, reportActions, currentUserDetails.email, currentUserDetails.accountID, originalTransaction], + [transactionPreviewCommonArguments, shouldShowRBR, violationMessage, reportActions, currentUserEmail, currentUserAccountID, originalTransaction], ); const getTranslatedText = (item: TranslationPathOrText) => (item.translationPath ? translate(item.translationPath) : (item.text ?? '')); diff --git a/src/components/SelectionListWithSections/Search/ReportListItemHeader.tsx b/src/components/SelectionListWithSections/Search/ReportListItemHeader.tsx index 78002c35d288f..a43d5a1cd92ae 100644 --- a/src/components/SelectionListWithSections/Search/ReportListItemHeader.tsx +++ b/src/components/SelectionListWithSections/Search/ReportListItemHeader.tsx @@ -1,4 +1,4 @@ -import React, {useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; import type {ColorValue} from 'react-native'; import Checkbox from '@components/Checkbox'; @@ -113,21 +113,15 @@ function HeaderFirstRow({ const theme = useTheme(); const [isActionLoading] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportItem.reportID}`, {canBeMissing: true, selector: isActionLoadingSelector}); - const {total, currency} = useMemo(() => { - let reportTotal = reportItem.total ?? 0; - - if (reportTotal) { - if (reportItem.type === CONST.REPORT.TYPE.IOU) { - reportTotal = Math.abs(reportTotal ?? 0); - } else { - reportTotal *= reportItem.type === CONST.REPORT.TYPE.EXPENSE || reportItem.type === CONST.REPORT.TYPE.INVOICE ? -1 : 1; - } + let total = reportItem.total ?? 0; + if (total) { + if (reportItem.type === CONST.REPORT.TYPE.IOU) { + total = Math.abs(total); + } else { + total *= reportItem.type === CONST.REPORT.TYPE.EXPENSE || reportItem.type === CONST.REPORT.TYPE.INVOICE ? -1 : 1; } - - const reportCurrency = reportItem.currency ?? CONST.CURRENCY.USD; - - return {total: reportTotal, currency: reportCurrency}; - }, [reportItem.type, reportItem.total, reportItem.currency]); + } + const currency = reportItem.currency ?? CONST.CURRENCY.USD; return ( @@ -221,12 +215,8 @@ function ReportListItemHeader({ const thereIsFromAndTo = !!reportItem?.from && !!reportItem?.to; const showUserInfo = (reportItem.type === CONST.REPORT.TYPE.IOU && thereIsFromAndTo) || (reportItem.type === CONST.REPORT.TYPE.EXPENSE && !!reportItem?.from); const [snapshot] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${currentSearchHash}`, {canBeMissing: true}); - const snapshotReport = useMemo(() => { - return (snapshot?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${reportItem.reportID}`] ?? {}) as Report; - }, [snapshot, reportItem.reportID]); - const snapshotPolicy = useMemo(() => { - return (snapshot?.data?.[`${ONYXKEYS.COLLECTION.POLICY}${reportItem.policyID}`] ?? {}) as Policy; - }, [snapshot, reportItem.policyID]); + const snapshotReport = (snapshot?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${reportItem.reportID}`] ?? {}) as Report; + const snapshotPolicy = (snapshot?.data?.[`${ONYXKEYS.COLLECTION.POLICY}${reportItem.policyID}`] ?? {}) as Policy; const avatarBorderColor = StyleUtils.getItemBackgroundColorStyle(!!reportItem.isSelected, !!isFocused || !!isHovered, !!isDisabled, theme.activeComponentBG, theme.hoverComponentBG)?.backgroundColor ?? theme.highlightBG; From 0ceabc014e027b09b3f93894eb51cc5eaa618668 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Wed, 17 Dec 2025 21:52:03 +0100 Subject: [PATCH 57/76] fixng test --- tests/unit/OptionsListUtilsTest.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index f1cd19c223fbf..af7c4e0beab69 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -2832,7 +2832,7 @@ describe('OptionsListUtils', () => { describe('DEW (Dynamic External Workflow)', () => { beforeEach(() => Onyx.clear()); - it('should show queued message for SUBMITTED action with DEW policy and pending add', async () => { + it('should show queued message for SUBMITTED action with DEW policy when offline and pending submit', async () => { const reportID = 'dewReport1'; const report: Report = { reportID, @@ -2854,13 +2854,16 @@ describe('OptionsListUtils', () => { message: [{type: 'COMMENT', text: 'submitted'}], originalMessage: {}, }; + const reportMetadata = { + pendingExpenseAction: CONST.EXPENSE_PENDING_ACTION.SUBMIT, + }; await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, report); await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { [submittedAction.reportActionID]: submittedAction, }); - const lastMessage = getLastMessageTextForReport({report, lastActorDetails: null, isReportArchived: false, policy}); + const lastMessage = getLastMessageTextForReport({report, lastActorDetails: null, isReportArchived: false, policy, reportMetadata, isOffline: true}); expect(lastMessage).toBe(translate(CONST.LOCALES.EN, 'iou.queuedToSubmitViaDEW')); }); From 2b15c350754fa7fed600e623296a1e764d79c952 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Wed, 17 Dec 2025 22:23:12 +0100 Subject: [PATCH 58/76] fixing tests --- src/libs/ReportSecondaryActionUtils.ts | 1 + src/libs/actions/IOU.ts | 4 +-- tests/actions/IOUTest.ts | 4 ++- tests/ui/PureReportActionItemTest.tsx | 12 +++++-- tests/unit/Search/SearchUIUtilsTest.ts | 46 -------------------------- 5 files changed, 15 insertions(+), 52 deletions(-) diff --git a/src/libs/ReportSecondaryActionUtils.ts b/src/libs/ReportSecondaryActionUtils.ts index a984678094458..bc8639bbec2b2 100644 --- a/src/libs/ReportSecondaryActionUtils.ts +++ b/src/libs/ReportSecondaryActionUtils.ts @@ -134,6 +134,7 @@ function isSplitAction(report: OnyxEntry, reportTransactions: Array { let expenseReport: OnyxEntry; let chatReport: OnyxEntry; let policy: OnyxEntry; + let nextStepBeforeSubmit: Report['nextStep']; const policyID = generatePolicyID(); createWorkspace({ policyOwnerEmail: CARLOS_EMAIL, @@ -5856,6 +5857,7 @@ describe('actions/IOU', () => { expect(expenseReport?.stateNum).toBe(0); expect(expenseReport?.statusNum).toBe(0); + nextStepBeforeSubmit = expenseReport?.nextStep; resolve(); }, }); @@ -5879,7 +5881,7 @@ describe('actions/IOU', () => { expect(expenseReport?.stateNum).toBe(CONST.REPORT.STATE_NUM.OPEN); expect(expenseReport?.statusNum).toBe(CONST.REPORT.STATUS_NUM.OPEN); - expect(expenseReport?.nextStep).toBeUndefined(); + expect(expenseReport?.nextStep).toEqual(nextStepBeforeSubmit); expect(expenseReport?.pendingFields?.nextStep).toBeUndefined(); resolve(); diff --git a/tests/ui/PureReportActionItemTest.tsx b/tests/ui/PureReportActionItemTest.tsx index 8d0d0e85d2977..fdd084ffde8d7 100644 --- a/tests/ui/PureReportActionItemTest.tsx +++ b/tests/ui/PureReportActionItemTest.tsx @@ -221,7 +221,7 @@ describe('PureReportActionItem', () => { }); describe('DEW (Dynamic External Workflow) actions', () => { - it('should display DEW queued message for pending SUBMITTED action when policy has DEW enabled', async () => { + it('should display DEW queued message for pending SUBMITTED action when policy has DEW enabled and offline', async () => { // Given a SUBMITTED action with pendingAction on a policy with DEW (Dynamic External Workflow) enabled const action = createReportAction(CONST.REPORT.ACTIONS.TYPE.SUBMITTED, {harvesting: false}); action.pendingAction = CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD; @@ -237,12 +237,16 @@ describe('PureReportActionItem', () => { approvalMode: CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL, } as const; + const reportMetadata = { + pendingExpenseAction: CONST.EXPENSE_PENDING_ACTION.SUBMIT, + }; + await act(async () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}testPolicy`, dewPolicy); }); await waitForBatchedUpdatesWithAct(); - // When the PureReportActionItem is rendered with the pending SUBMITTED action + // When the PureReportActionItem is rendered with the pending SUBMITTED action while offline render( @@ -264,6 +268,8 @@ describe('PureReportActionItem', () => { taskReport={undefined} linkedReport={undefined} iouReportOfLinkedReport={undefined} + reportMetadata={reportMetadata} + isOffline /> @@ -272,7 +278,7 @@ describe('PureReportActionItem', () => { ); await waitForBatchedUpdatesWithAct(); - // Then it should display the DEW queued message because submission is pending via external workflow + // Then it should display the DEW queued message because submission is pending via external workflow while offline expect(screen.getByText(actorEmail)).toBeOnTheScreen(); expect(screen.getByText(translateLocal('iou.queuedToSubmitViaDEW'))).toBeOnTheScreen(); }); diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index c133968bef51b..20ee0d977e832 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -1860,52 +1860,6 @@ describe('SearchUIUtils', () => { expect(action).not.toStrictEqual(CONST.SEARCH.ACTION_TYPES.VIEW); }); - test('Should return `View` action when report has pending SUBMITTED action on DEW policy and is OPEN', async () => { - const dewReportID = '777'; - const dewTransactionID = '7777'; - const dewReportActionID = '77777'; - const dewPolicyID = 'dewPolicy777'; - - const localSearchResults = { - ...searchResults.data, - [`policy_${dewPolicyID}`]: { - ...searchResults.data[`policy_${policyID}`], - id: dewPolicyID, - approvalMode: CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL, - }, - [`report_${dewReportID}`]: { - ...searchResults.data[`report_${reportID}`], - reportID: dewReportID, - policyID: dewPolicyID, - statusNum: CONST.REPORT.STATUS_NUM.OPEN, - stateNum: CONST.REPORT.STATE_NUM.OPEN, - type: CONST.REPORT.TYPE.EXPENSE, - }, - [`transactions_${dewTransactionID}`]: { - ...searchResults.data[`transactions_${transactionID}`], - transactionID: dewTransactionID, - reportID: dewReportID, - }, - }; - - const dewReportActions = [ - { - reportActionID: dewReportActionID, - actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, - reportID: dewReportID, - created: '2025-01-01 00:00:00', - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, - originalMessage: { - amount: 10000, - currency: 'USD', - }, - }, - ] as OnyxTypes.ReportAction[]; - - const action = SearchUIUtils.getActions(localSearchResults, {}, `transactions_${dewTransactionID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '', dewReportActions).at(0); - expect(action).toStrictEqual(CONST.SEARCH.ACTION_TYPES.VIEW); - }); - test('Should NOT return `View` action when report has pending SUBMITTED action on non-DEW policy', async () => { const nonDewReportID = '666'; const nonDewTransactionID = '6666'; From 9bd20376f8860ec202bb1979192ed7e601a229a8 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Thu, 18 Dec 2025 16:01:51 +0100 Subject: [PATCH 59/76] code refactoring --- 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/OptionsListUtils/index.ts | 8 +- src/libs/ReportUtils.ts | 2 +- src/libs/SearchUIUtils.ts | 1 - .../home/report/PureReportActionItem.tsx | 12 +- tests/actions/ReportPreviewActionUtilsTest.ts | 4 +- tests/ui/components/LHNOptionsListTest.tsx | 111 +++++++++++ tests/unit/OptionsListUtilsTest.tsx | 188 +++++++++--------- 17 files changed, 220 insertions(+), 116 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index 8e8822cc302ed..52d0aba780a41 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1376,7 +1376,6 @@ const translations: TranslationDeepObject = { receiptFailureMessageShort: 'Beim Hochladen Ihres Belegs ist ein Fehler aufgetreten.', genericDeleteFailureMessage: 'Unerwarteter Fehler beim Löschen dieser Ausgabe. Bitte versuche es später erneut.', genericEditFailureMessage: 'Unerwarteter Fehler beim Bearbeiten dieser Ausgabe. Bitte versuche es später erneut.', - genericDEWSubmitFailureMessage: 'Fehler beim dynamischen externen Workflow-Einreichung', genericSmartscanFailureMessage: 'Transaktion hat fehlende Felder', duplicateWaypointsErrorMessage: 'Bitte entfernen Sie doppelte Wegpunkte', atLeastTwoDifferentWaypoints: 'Bitte gib mindestens zwei verschiedene Adressen ein', diff --git a/src/languages/en.ts b/src/languages/en.ts index 1da1dc7943c72..df6063c89ecfc 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1349,7 +1349,6 @@ const translations = { genericDeleteFailureMessage: 'Unexpected error deleting this expense. Please try again later.', genericEditFailureMessage: 'Unexpected error editing this expense. Please try again later.', genericSmartscanFailureMessage: 'Transaction is missing fields', - genericDEWSubmitFailureMessage: 'Dynamic External Workflow submission failed', duplicateWaypointsErrorMessage: 'Please remove duplicate waypoints', atLeastTwoDifferentWaypoints: 'Please enter at least two different addresses', splitExpenseMultipleParticipantsErrorMessage: 'An expense cannot be split between a workspace and other members. Please update your selection.', diff --git a/src/languages/es.ts b/src/languages/es.ts index c8368111b56ef..04ce574c3f58c 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1025,7 +1025,6 @@ const translations: TranslationDeepObject = { genericDeleteFailureMessage: 'Error inesperado al eliminar este gasto. Por favor, inténtalo más tarde.', genericEditFailureMessage: 'Error inesperado al editar este gasto. Por favor, inténtalo más tarde.', genericSmartscanFailureMessage: 'La transacción tiene campos vacíos', - genericDEWSubmitFailureMessage: 'Error al enviar el flujo de trabajo externo dinámico', duplicateWaypointsErrorMessage: 'Por favor, elimina los puntos de ruta duplicados', atLeastTwoDifferentWaypoints: 'Por favor, introduce al menos dos direcciones diferentes', splitExpenseMultipleParticipantsErrorMessage: 'Solo puedes dividir un gasto entre un único espacio de trabajo o con miembros individuales. Por favor, actualiza tu selección.', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 4966b831dd1c3..1f84939f939c6 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1379,7 +1379,6 @@ const translations: TranslationDeepObject = { receiptFailureMessageShort: 'Une erreur s’est produite lors du téléchargement de votre reçu.', genericDeleteFailureMessage: 'Erreur inattendue lors de la suppression de cette dépense. Veuillez réessayer plus tard.', genericEditFailureMessage: 'Erreur inattendue lors de la modification de cette dépense. Veuillez réessayer plus tard.', - genericDEWSubmitFailureMessage: 'Échec de la soumission du flux de travail externe dynamique', genericSmartscanFailureMessage: 'Des champs manquent dans la transaction', duplicateWaypointsErrorMessage: 'Veuillez supprimer les points de passage en double', atLeastTwoDifferentWaypoints: 'Veuillez saisir au moins deux adresses différentes', diff --git a/src/languages/it.ts b/src/languages/it.ts index 617bcb2d01b4b..306972cdd6c99 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1373,7 +1373,6 @@ const translations: TranslationDeepObject = { genericDeleteFailureMessage: "Errore imprevisto durante l'eliminazione di questa spesa. Riprova più tardi.", genericEditFailureMessage: 'Errore imprevisto durante la modifica di questa spesa. Riprova più tardi.', genericSmartscanFailureMessage: 'Alla transazione mancano dei campi', - genericDEWSubmitFailureMessage: 'Invio del flusso di lavoro esterno dinamico non riuscito', duplicateWaypointsErrorMessage: 'Rimuovi i waypoint duplicati', atLeastTwoDifferentWaypoints: 'Per favore inserisci almeno due indirizzi diversi', splitExpenseMultipleParticipantsErrorMessage: 'Una spesa non può essere suddivisa tra uno spazio di lavoro e altri membri. Aggiorna la tua selezione.', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index c689426c8a6c9..b566824f043ac 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1374,7 +1374,6 @@ const translations: TranslationDeepObject = { genericDeleteFailureMessage: 'この経費の削除中に予期しないエラーが発生しました。後でもう一度お試しください。', genericEditFailureMessage: 'この経費の編集中に予期しないエラーが発生しました。後でもう一度お試しください。', genericSmartscanFailureMessage: '取引に不足している項目があります', - genericDEWSubmitFailureMessage: '動的外部ワークフローの送信に失敗しました', duplicateWaypointsErrorMessage: '重複する経路ポイントを削除してください', atLeastTwoDifferentWaypoints: '少なくとも 2 つの異なる住所を入力してください', splitExpenseMultipleParticipantsErrorMessage: '経費はワークスペースと他のメンバーで分割できません。選択内容を更新してください。', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index f1bf6e4733227..8d2aa38c34c31 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1372,7 +1372,6 @@ const translations: TranslationDeepObject = { genericDeleteFailureMessage: 'Onverwachte fout bij het verwijderen van deze uitgave. Probeer het later opnieuw.', genericEditFailureMessage: 'Onverwachte fout bij het bewerken van deze uitgave. Probeer het later opnieuw.', genericSmartscanFailureMessage: 'Transactie mist velden', - genericDEWSubmitFailureMessage: 'Dynamische externe workflow indienen mislukt', duplicateWaypointsErrorMessage: 'Verwijder dubbele tussenpunten alstublieft', atLeastTwoDifferentWaypoints: 'Voer ten minste twee verschillende adressen in', splitExpenseMultipleParticipantsErrorMessage: 'Een uitgave kan niet worden gesplitst tussen een werkruimte en andere leden. Werk uw selectie alstublieft bij.', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 438dcafea4906..3f4d194f89edb 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1370,7 +1370,6 @@ const translations: TranslationDeepObject = { genericDeleteFailureMessage: 'Nieoczekiwany błąd podczas usuwania tego wydatku. Spróbuj ponownie później.', genericEditFailureMessage: 'Nieoczekiwany błąd podczas edytowania tego wydatku. Spróbuj ponownie później.', genericSmartscanFailureMessage: 'Transakcji brakuje pól', - genericDEWSubmitFailureMessage: 'Przesyłanie dynamicznego zewnętrznego przepływu pracy nie powiodło się', duplicateWaypointsErrorMessage: 'Usuń zduplikowane punkty pośrednie', atLeastTwoDifferentWaypoints: 'Wprowadź co najmniej dwa różne adresy', splitExpenseMultipleParticipantsErrorMessage: 'Wydatek nie może zostać podzielony między przestrzeń roboczą a innych członków. Proszę zaktualizować swój wybór.', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 082ececf1b566..c0ae125548167 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1369,7 +1369,6 @@ const translations: TranslationDeepObject = { receiptFailureMessageShort: 'Ocorreu um erro ao fazer upload do seu recibo.', genericDeleteFailureMessage: 'Erro inesperado ao excluir esta despesa. Tente novamente mais tarde.', genericEditFailureMessage: 'Erro inesperado ao editar esta despesa. Tente novamente mais tarde.', - genericDEWSubmitFailureMessage: 'Falha no envio do fluxo de trabalho externo dinâmico', genericSmartscanFailureMessage: 'A transação está com campos ausentes', duplicateWaypointsErrorMessage: 'Remova pontos de passagem duplicados', atLeastTwoDifferentWaypoints: 'Insira pelo menos dois endereços diferentes', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 34030c4617881..e88a32bb13d79 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1350,7 +1350,6 @@ const translations: TranslationDeepObject = { genericDeleteFailureMessage: '删除此报销时发生意外错误。请稍后重试。', genericEditFailureMessage: '编辑此报销时出现意外错误。请稍后重试。', genericSmartscanFailureMessage: '交易缺少字段', - genericDEWSubmitFailureMessage: '动态外部工作流提交失败', duplicateWaypointsErrorMessage: '请移除重复的航路点', atLeastTwoDifferentWaypoints: '请输入至少两个不同的地址', splitExpenseMultipleParticipantsErrorMessage: '一笔报销不能在一个工作区和其他成员之间拆分。请更新你的选择。', diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 252dff331e8dc..37f95d058a7bb 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -717,13 +717,15 @@ function getLastMessageTextForReport({ ) { const wasSubmittedViaHarvesting = !isMarkAsClosedAction(lastReportAction) ? (getOriginalMessage(lastReportAction)?.harvesting ?? false) : false; const isDEWPolicy = hasDynamicExternalWorkflow(policy); + const isPendingAdd = lastReportAction.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD; if (wasSubmittedViaHarvesting) { // eslint-disable-next-line @typescript-eslint/no-deprecated lastMessageTextFromReport = Parser.htmlToText(translateLocal('iou.automaticallySubmitted')); - } else if (isOffline && hasPendingDEWSubmit(reportMetadata, isDEWPolicy)) { + } else if (hasPendingDEWSubmit(reportMetadata, isDEWPolicy) && isPendingAdd) { + // When DEW submit is pending: show "queued" message if offline, otherwise show nothing // eslint-disable-next-line @typescript-eslint/no-deprecated - lastMessageTextFromReport = translateLocal('iou.queuedToSubmitViaDEW'); + lastMessageTextFromReport = isOffline ? translateLocal('iou.queuedToSubmitViaDEW') : ''; } else { // eslint-disable-next-line @typescript-eslint/no-deprecated lastMessageTextFromReport = translateLocal('iou.submitted', {memo: getOriginalMessage(lastReportAction)?.message}); @@ -739,7 +741,7 @@ function getLastMessageTextForReport({ } } else if (isDynamicExternalWorkflowSubmitFailedAction(lastReportAction)) { // eslint-disable-next-line @typescript-eslint/no-deprecated - lastMessageTextFromReport = getOriginalMessage(lastReportAction)?.message ?? translateLocal('iou.error.genericDEWSubmitFailureMessage'); + lastMessageTextFromReport = getOriginalMessage(lastReportAction)?.message ?? translateLocal('iou.error.genericCreateFailureMessage'); } else if (isUnapprovedAction(lastReportAction)) { // eslint-disable-next-line @typescript-eslint/no-deprecated lastMessageTextFromReport = translateLocal('iou.unapproved'); diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 9be6d73d24b9d..f9e19a040a2d3 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -9403,7 +9403,7 @@ function getAllReportActionsErrorsAndReportActionThatRequiresAttention( if (!isReportArchived && report?.statusNum === CONST.REPORT.STATUS_NUM.OPEN) { const mostRecentActiveDEWAction = getMostRecentActiveDEWSubmitFailedAction(reportActionsArray); if (mostRecentActiveDEWAction) { - reportActionErrors.dewSubmitFailed = getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericDEWSubmitFailureMessage'); + reportActionErrors.dewSubmitFailed = getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericCreateFailureMessage'); reportAction = mostRecentActiveDEWAction; } } diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index c70d30565d238..a72412d8ce89b 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -1358,7 +1358,6 @@ function getActions( : undefined; const chatReport = getChatReport(data, report); - const canBePaid = canIOUBePaid(report, chatReport, policy, allReportTransactions, false, chatReportRNVP, invoiceReceiverPolicy); const shouldOnlyShowElsewhere = !canBePaid && canIOUBePaid(report, chatReport, policy, allReportTransactions, true, chatReportRNVP, invoiceReceiverPolicy); diff --git a/src/pages/home/report/PureReportActionItem.tsx b/src/pages/home/report/PureReportActionItem.tsx index 890e2a0b626d3..3be3bb218a204 100644 --- a/src/pages/home/report/PureReportActionItem.tsx +++ b/src/pages/home/report/PureReportActionItem.tsx @@ -1209,14 +1209,17 @@ function PureReportActionItem({ const wasSubmittedViaHarvesting = !isMarkAsClosedAction(action) ? (getOriginalMessage(action)?.harvesting ?? false) : false; const isDEWPolicy = hasDynamicExternalWorkflow(policy); + const isPendingAdd = action.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD; + if (wasSubmittedViaHarvesting) { children = ( ${translate('iou.automaticallySubmitted')}`} /> ); - } else if (isOffline && hasPendingDEWSubmit(reportMetadata, isDEWPolicy)) { - children = ; + } else if (hasPendingDEWSubmit(reportMetadata, isDEWPolicy) && isPendingAdd) { + // When DEW submit is pending: show "queued" message if offline, otherwise show nothing + children = isOffline ? : emptyHTML; } else { children = ; } @@ -1232,7 +1235,7 @@ function PureReportActionItem({ children = ; } } else if (isDynamicExternalWorkflowSubmitFailedAction(action)) { - const errorMessage = getOriginalMessage(action)?.message ?? translate('iou.error.genericDEWSubmitFailureMessage'); + 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; @@ -1982,6 +1985,7 @@ export default memo(PureReportActionItem, (prevProps, nextProps) => { deepEqual(prevProps.bankAccountList, nextProps.bankAccountList) && prevProps.reportNameValuePairsOrigin === nextProps.reportNameValuePairsOrigin && prevProps.reportNameValuePairsOriginalID === nextProps.reportNameValuePairsOriginalID && - prevProps.reportMetadata?.pendingExpenseAction === nextProps.reportMetadata?.pendingExpenseAction + prevProps.reportMetadata?.pendingExpenseAction === nextProps.reportMetadata?.pendingExpenseAction && + prevProps.isOffline === nextProps.isOffline ); }); diff --git a/tests/actions/ReportPreviewActionUtilsTest.ts b/tests/actions/ReportPreviewActionUtilsTest.ts index 5498f22a8ca58..09b302b544505 100644 --- a/tests/actions/ReportPreviewActionUtilsTest.ts +++ b/tests/actions/ReportPreviewActionUtilsTest.ts @@ -538,8 +538,8 @@ describe('getReportPreviewAction', () => { }); describe('DEW (Dynamic External Workflow) submit pending', () => { - it('should return VIEW action when DEW submit is pending (offline) and report is OPEN', async () => { - // Given an open expense report with a corporate policy where DEW submit is pending (offline) + it('should return VIEW action when DEW submit is pending and report is OPEN', async () => { + // Given an open expense report with a corporate policy where DEW submit is pending const report: Report = { ...createRandomReport(REPORT_ID, undefined), type: CONST.REPORT.TYPE.EXPENSE, diff --git a/tests/ui/components/LHNOptionsListTest.tsx b/tests/ui/components/LHNOptionsListTest.tsx index d8fe85f03d4cd..8f20bc6840b90 100644 --- a/tests/ui/components/LHNOptionsListTest.tsx +++ b/tests/ui/components/LHNOptionsListTest.tsx @@ -11,6 +11,7 @@ import OnyxListItemProvider from '@components/OnyxListItemProvider'; import {showContextMenu} from '@pages/home/report/ContextMenu/ReportActionContextMenu'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy, Report, ReportAction} from '@src/types/onyx'; import {getFakeReport} from '../../utils/LHNTestUtils'; // Mock the context menu @@ -175,4 +176,114 @@ describe('LHNOptionsList', () => { ); }); }); + + describe('DEW (Dynamic External Workflow) pending submit message', () => { + it('shows queued message when offline with pending DEW submit', async () => { + // Given a DEW policy and a report with pending submit + const policyID = 'dewTestPolicy'; + const reportID = 'dewTestReport'; + const policy: Policy = { + id: policyID, + name: 'DEW Test Policy', + type: CONST.POLICY.TYPE.CORPORATE, + approvalMode: CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL, + } as Policy; + const report: Report = { + reportID, + reportName: 'DEW Test Report', + type: CONST.REPORT.TYPE.EXPENSE, + policyID, + participants: {1: {notificationPreference: 'always'}, 2: {notificationPreference: 'always'}}, + }; + const submittedAction: ReportAction = { + reportActionID: '1', + actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, + created: '2024-01-01 00:00:00', + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + message: [{type: 'COMMENT', text: 'submitted'}], + originalMessage: {}, + }; + + // Given the screen is focused and network is offline + mockUseIsFocused.mockReturnValue(true); + + await act(async () => { + await Onyx.merge(ONYXKEYS.NETWORK, {isOffline: true}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, policy); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, report); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { + [submittedAction.reportActionID]: submittedAction, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`, { + pendingExpenseAction: CONST.EXPENSE_PENDING_ACTION.SUBMIT, + }); + }); + + // When the LHNOptionsList is rendered with the DEW report + render(getLHNOptionsListElement({data: [report]})); + + // Then wait for the report to be displayed + const reportItem = await waitFor(() => getReportItem(reportID)); + expect(reportItem).toBeTruthy(); + + // Then the queued message should be displayed + await waitFor(() => { + expect(screen.getByText('queued to submit via custom approval workflow')).toBeTruthy(); + }); + }); + + it('does not show queued message when online with pending DEW submit', async () => { + // Given a DEW policy and a report with pending submit + const policyID = 'dewTestPolicyOnline'; + const reportID = 'dewTestReportOnline'; + const policy: Policy = { + id: policyID, + name: 'DEW Test Policy', + type: CONST.POLICY.TYPE.CORPORATE, + approvalMode: CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL, + } as Policy; + const report: Report = { + reportID, + reportName: 'DEW Test Report', + type: CONST.REPORT.TYPE.EXPENSE, + policyID, + participants: {1: {notificationPreference: 'always'}, 2: {notificationPreference: 'always'}}, + }; + const submittedAction: ReportAction = { + reportActionID: '1', + actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, + created: '2024-01-01 00:00:00', + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + message: [{type: 'COMMENT', text: 'submitted'}], + originalMessage: {}, + }; + + // Given the screen is focused and network is online + mockUseIsFocused.mockReturnValue(true); + + await act(async () => { + await Onyx.merge(ONYXKEYS.NETWORK, {isOffline: false}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, policy); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, report); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { + [submittedAction.reportActionID]: submittedAction, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`, { + pendingExpenseAction: CONST.EXPENSE_PENDING_ACTION.SUBMIT, + }); + }); + + // When the LHNOptionsList is rendered with the DEW report + render(getLHNOptionsListElement({data: [report]})); + + // Then wait for the report to be displayed + const reportItem = await waitFor(() => getReportItem(reportID)); + expect(reportItem).toBeTruthy(); + + // Then the queued message should NOT be displayed when online + await waitFor(() => { + expect(screen.queryByText('queued to submit via custom approval workflow')).toBeNull(); + }); + }); + }); }); diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index af7c4e0beab69..4803c32e4eff8 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -2785,7 +2785,6 @@ describe('OptionsListUtils', () => { const lastMessage = getLastMessageTextForReport({report, lastActorDetails: null, isReportArchived: false}); expect(lastMessage).toBe(Parser.htmlToText(getMovedActionMessage(movedAction, report))); }); - it('should return last visible message text when last action is hidden (e.g. whisper)', async () => { const report: Report = { ...createRandomReport(0, undefined), @@ -2808,113 +2807,112 @@ describe('OptionsListUtils', () => { }); expect(result).toBe(expectedVisibleText); }); - - describe('getPersonalDetailSearchTerms', () => { - it('should include display name', () => { - const displayName = 'test'; - const searchTerms = getPersonalDetailSearchTerms({displayName}); - expect(searchTerms.includes(displayName)).toBe(true); - const searchTerms2 = getPersonalDetailSearchTerms({participantsList: [{displayName, accountID: 123}]}); - expect(searchTerms2.includes(displayName)).toBe(true); - }); + }); + describe('getPersonalDetailSearchTerms', () => { + it('should include display name', () => { + const displayName = 'test'; + const searchTerms = getPersonalDetailSearchTerms({displayName}); + expect(searchTerms.includes(displayName)).toBe(true); + const searchTerms2 = getPersonalDetailSearchTerms({participantsList: [{displayName, accountID: 123}]}); + expect(searchTerms2.includes(displayName)).toBe(true); }); + }); - describe('getCurrentUserSearchTerms', () => { - it('should include display name', () => { - const displayName = 'test'; - const searchTerms = getCurrentUserSearchTerms({displayName}); - expect(searchTerms.includes(displayName)).toBe(true); - const searchTerms2 = getCurrentUserSearchTerms({text: displayName}); - expect(searchTerms2.includes(displayName)).toBe(true); - }); + describe('getCurrentUserSearchTerms', () => { + it('should include display name', () => { + const displayName = 'test'; + const searchTerms = getCurrentUserSearchTerms({displayName}); + expect(searchTerms.includes(displayName)).toBe(true); + const searchTerms2 = getCurrentUserSearchTerms({text: displayName}); + expect(searchTerms2.includes(displayName)).toBe(true); }); + }); - describe('DEW (Dynamic External Workflow)', () => { - beforeEach(() => Onyx.clear()); + describe('DEW (Dynamic External Workflow)', () => { + beforeEach(() => Onyx.clear()); - it('should show queued message for SUBMITTED action with DEW policy when offline and pending submit', async () => { - const reportID = 'dewReport1'; - const report: Report = { - reportID, - reportName: 'Test Report', - type: CONST.REPORT.TYPE.EXPENSE, - policyID: 'dewPolicy1', - }; - const policy: Policy = { - id: 'dewPolicy1', - name: 'Test Policy', - type: CONST.POLICY.TYPE.CORPORATE, - approvalMode: CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL, - } as Policy; - const submittedAction: ReportAction = { - reportActionID: '1', - actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, - created: '2024-01-01 00:00:00', - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, - message: [{type: 'COMMENT', text: 'submitted'}], - originalMessage: {}, - }; - const reportMetadata = { - pendingExpenseAction: CONST.EXPENSE_PENDING_ACTION.SUBMIT, - }; + it('should show queued message for SUBMITTED action with DEW policy when offline and pending submit', async () => { + const reportID = 'dewReport1'; + const report: Report = { + reportID, + reportName: 'Test Report', + type: CONST.REPORT.TYPE.EXPENSE, + policyID: 'dewPolicy1', + }; + const policy: Policy = { + id: 'dewPolicy1', + name: 'Test Policy', + type: CONST.POLICY.TYPE.CORPORATE, + approvalMode: CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL, + } as Policy; + const submittedAction: ReportAction = { + reportActionID: '1', + actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, + created: '2024-01-01 00:00:00', + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + message: [{type: 'COMMENT', text: 'submitted'}], + originalMessage: {}, + }; + const reportMetadata = { + pendingExpenseAction: CONST.EXPENSE_PENDING_ACTION.SUBMIT, + }; - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, report); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { - [submittedAction.reportActionID]: submittedAction, - }); - const lastMessage = getLastMessageTextForReport({report, lastActorDetails: null, isReportArchived: false, policy, reportMetadata, isOffline: true}); - expect(lastMessage).toBe(translate(CONST.LOCALES.EN, 'iou.queuedToSubmitViaDEW')); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, report); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { + [submittedAction.reportActionID]: submittedAction, }); + const lastMessage = getLastMessageTextForReport({report, lastActorDetails: null, isReportArchived: false, policy, reportMetadata, isOffline: true}); + expect(lastMessage).toBe(translate(CONST.LOCALES.EN, 'iou.queuedToSubmitViaDEW')); + }); - it('should show custom error message for DEW_SUBMIT_FAILED action', async () => { - const reportID = 'dewReport2'; - const report: Report = { - reportID, - reportName: 'Test Report', - type: CONST.REPORT.TYPE.EXPENSE, - }; - const customErrorMessage = 'This report contains an expense missing required fields.'; - const dewSubmitFailedAction: ReportAction = { - reportActionID: '1', - actionName: CONST.REPORT.ACTIONS.TYPE.DEW_SUBMIT_FAILED, - created: '2024-01-01 00:00:00', - message: [{type: 'COMMENT', text: customErrorMessage}], - originalMessage: { - message: customErrorMessage, - }, - }; + it('should show custom error message for DEW_SUBMIT_FAILED action', async () => { + const reportID = 'dewReport2'; + const report: Report = { + reportID, + reportName: 'Test Report', + type: CONST.REPORT.TYPE.EXPENSE, + }; + const customErrorMessage = 'This report contains an expense missing required fields.'; + const dewSubmitFailedAction: ReportAction = { + reportActionID: '1', + actionName: CONST.REPORT.ACTIONS.TYPE.DEW_SUBMIT_FAILED, + created: '2024-01-01 00:00:00', + message: [{type: 'COMMENT', text: customErrorMessage}], + originalMessage: { + message: customErrorMessage, + }, + }; - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, report); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { - [dewSubmitFailedAction.reportActionID]: dewSubmitFailedAction, - }); - const lastMessage = getLastMessageTextForReport({report, lastActorDetails: null, isReportArchived: false}); - expect(lastMessage).toBe(customErrorMessage); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, report); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { + [dewSubmitFailedAction.reportActionID]: dewSubmitFailedAction, }); + const lastMessage = getLastMessageTextForReport({report, lastActorDetails: null, isReportArchived: false}); + expect(lastMessage).toBe(customErrorMessage); + }); - it('should show fallback message for DEW_SUBMIT_FAILED action without message', async () => { - const reportID = 'dewReport3'; - const report: Report = { - reportID, - reportName: 'Test Report', - type: CONST.REPORT.TYPE.EXPENSE, - }; - const dewSubmitFailedAction: ReportAction = { - reportActionID: '1', - actionName: CONST.REPORT.ACTIONS.TYPE.DEW_SUBMIT_FAILED, - created: '2024-01-01 00:00:00', - message: [{type: 'COMMENT', text: ''}], - originalMessage: {}, - }; + it('should show fallback message for DEW_SUBMIT_FAILED action without message', async () => { + const reportID = 'dewReport3'; + const report: Report = { + reportID, + reportName: 'Test Report', + type: CONST.REPORT.TYPE.EXPENSE, + }; + const dewSubmitFailedAction: ReportAction = { + reportActionID: '1', + actionName: CONST.REPORT.ACTIONS.TYPE.DEW_SUBMIT_FAILED, + created: '2024-01-01 00:00:00', + message: [{type: 'COMMENT', text: ''}], + originalMessage: {}, + }; - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, report); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { - [dewSubmitFailedAction.reportActionID]: dewSubmitFailedAction, - }); - const lastMessage = getLastMessageTextForReport({report, lastActorDetails: null, isReportArchived: false}); - expect(lastMessage).toBe(translate(CONST.LOCALES.EN, 'iou.error.genericDEWSubmitFailureMessage')); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, report); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { + [dewSubmitFailedAction.reportActionID]: dewSubmitFailedAction, }); + const lastMessage = getLastMessageTextForReport({report, lastActorDetails: null, isReportArchived: false}); + expect(lastMessage).toBe(translate(CONST.LOCALES.EN, 'iou.error.genericCreateFailureMessage')); }); }); }); From f681f8df13867008036312d137104ba7595d5f1a Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Fri, 19 Dec 2025 09:15:13 +0100 Subject: [PATCH 60/76] fixing ts errors --- tests/ui/components/LHNOptionsListTest.tsx | 13 +- tests/unit/OptionsListUtilsTest.tsx | 176 ++++++++++----------- 2 files changed, 97 insertions(+), 92 deletions(-) diff --git a/tests/ui/components/LHNOptionsListTest.tsx b/tests/ui/components/LHNOptionsListTest.tsx index 8f20bc6840b90..c85aa1f278b73 100644 --- a/tests/ui/components/LHNOptionsListTest.tsx +++ b/tests/ui/components/LHNOptionsListTest.tsx @@ -182,6 +182,8 @@ describe('LHNOptionsList', () => { // Given a DEW policy and a report with pending submit const policyID = 'dewTestPolicy'; const reportID = 'dewTestReport'; + const accountID1 = 1; + const accountID2 = 2; const policy: Policy = { id: policyID, name: 'DEW Test Policy', @@ -193,7 +195,7 @@ describe('LHNOptionsList', () => { reportName: 'DEW Test Report', type: CONST.REPORT.TYPE.EXPENSE, policyID, - participants: {1: {notificationPreference: 'always'}, 2: {notificationPreference: 'always'}}, + participants: {[accountID1]: {notificationPreference: 'always'}, [accountID2]: {notificationPreference: 'always'}}, }; const submittedAction: ReportAction = { reportActionID: '1', @@ -232,10 +234,12 @@ describe('LHNOptionsList', () => { }); }); - it('does not show queued message when online with pending DEW submit', async () => { + it('shows submitted message when online with pending DEW submit', async () => { // Given a DEW policy and a report with pending submit const policyID = 'dewTestPolicyOnline'; const reportID = 'dewTestReportOnline'; + const accountID1 = 1; + const accountID2 = 2; const policy: Policy = { id: policyID, name: 'DEW Test Policy', @@ -247,7 +251,7 @@ describe('LHNOptionsList', () => { reportName: 'DEW Test Report', type: CONST.REPORT.TYPE.EXPENSE, policyID, - participants: {1: {notificationPreference: 'always'}, 2: {notificationPreference: 'always'}}, + participants: {[accountID1]: {notificationPreference: 'always'}, [accountID2]: {notificationPreference: 'always'}}, }; const submittedAction: ReportAction = { reportActionID: '1', @@ -280,9 +284,10 @@ describe('LHNOptionsList', () => { const reportItem = await waitFor(() => getReportItem(reportID)); expect(reportItem).toBeTruthy(); - // Then the queued message should NOT be displayed when online + // Then the queued message should NOT be displayed when online, instead show "submitted" await waitFor(() => { expect(screen.queryByText('queued to submit via custom approval workflow')).toBeNull(); + expect(screen.getByText('submitted')).toBeTruthy(); }); }); }); diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index 4803c32e4eff8..1cf9c46b0b0af 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -2807,6 +2807,94 @@ describe('OptionsListUtils', () => { }); expect(result).toBe(expectedVisibleText); }); + + describe('DEW (Dynamic External Workflow)', () => { + beforeEach(() => Onyx.clear()); + + it('should show queued message for SUBMITTED action with DEW policy when offline and pending submit', async () => { + const reportID = 'dewReport1'; + const report: Report = { + reportID, + reportName: 'Test Report', + type: CONST.REPORT.TYPE.EXPENSE, + policyID: 'dewPolicy1', + }; + const policy: Policy = { + id: 'dewPolicy1', + name: 'Test Policy', + type: CONST.POLICY.TYPE.CORPORATE, + approvalMode: CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL, + } as Policy; + const submittedAction: ReportAction = { + reportActionID: '1', + actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, + created: '2024-01-01 00:00:00', + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + message: [{type: 'COMMENT', text: 'submitted'}], + originalMessage: {}, + }; + const reportMetadata = { + pendingExpenseAction: CONST.EXPENSE_PENDING_ACTION.SUBMIT, + }; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, report); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { + [submittedAction.reportActionID]: submittedAction, + }); + const lastMessage = getLastMessageTextForReport({report, lastActorDetails: null, isReportArchived: false, policy, reportMetadata, isOffline: true}); + expect(lastMessage).toBe(translate(CONST.LOCALES.EN, 'iou.queuedToSubmitViaDEW')); + }); + + it('should show custom error message for DEW_SUBMIT_FAILED action', async () => { + const reportID = 'dewReport2'; + const report: Report = { + reportID, + reportName: 'Test Report', + type: CONST.REPORT.TYPE.EXPENSE, + }; + const customErrorMessage = 'This report contains an expense missing required fields.'; + const dewSubmitFailedAction: ReportAction = { + reportActionID: '1', + actionName: CONST.REPORT.ACTIONS.TYPE.DEW_SUBMIT_FAILED, + created: '2024-01-01 00:00:00', + message: [{type: 'COMMENT', text: customErrorMessage}], + originalMessage: { + message: customErrorMessage, + }, + }; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, report); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { + [dewSubmitFailedAction.reportActionID]: dewSubmitFailedAction, + }); + const lastMessage = getLastMessageTextForReport({report, lastActorDetails: null, isReportArchived: false}); + expect(lastMessage).toBe(customErrorMessage); + }); + + it('should show fallback message for DEW_SUBMIT_FAILED action without message', async () => { + const reportID = 'dewReport3'; + const report: Report = { + reportID, + reportName: 'Test Report', + type: CONST.REPORT.TYPE.EXPENSE, + }; + const dewSubmitFailedAction: ReportAction = { + reportActionID: '1', + actionName: CONST.REPORT.ACTIONS.TYPE.DEW_SUBMIT_FAILED, + created: '2024-01-01 00:00:00', + message: [{type: 'COMMENT', text: ''}], + originalMessage: {}, + }; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, report); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { + [dewSubmitFailedAction.reportActionID]: dewSubmitFailedAction, + }); + const lastMessage = getLastMessageTextForReport({report, lastActorDetails: null, isReportArchived: false}); + expect(lastMessage).toBe(translate(CONST.LOCALES.EN, 'iou.error.genericCreateFailureMessage')); + }); + }); }); describe('getPersonalDetailSearchTerms', () => { it('should include display name', () => { @@ -2827,92 +2915,4 @@ describe('OptionsListUtils', () => { expect(searchTerms2.includes(displayName)).toBe(true); }); }); - - describe('DEW (Dynamic External Workflow)', () => { - beforeEach(() => Onyx.clear()); - - it('should show queued message for SUBMITTED action with DEW policy when offline and pending submit', async () => { - const reportID = 'dewReport1'; - const report: Report = { - reportID, - reportName: 'Test Report', - type: CONST.REPORT.TYPE.EXPENSE, - policyID: 'dewPolicy1', - }; - const policy: Policy = { - id: 'dewPolicy1', - name: 'Test Policy', - type: CONST.POLICY.TYPE.CORPORATE, - approvalMode: CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL, - } as Policy; - const submittedAction: ReportAction = { - reportActionID: '1', - actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, - created: '2024-01-01 00:00:00', - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, - message: [{type: 'COMMENT', text: 'submitted'}], - originalMessage: {}, - }; - const reportMetadata = { - pendingExpenseAction: CONST.EXPENSE_PENDING_ACTION.SUBMIT, - }; - - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, report); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { - [submittedAction.reportActionID]: submittedAction, - }); - const lastMessage = getLastMessageTextForReport({report, lastActorDetails: null, isReportArchived: false, policy, reportMetadata, isOffline: true}); - expect(lastMessage).toBe(translate(CONST.LOCALES.EN, 'iou.queuedToSubmitViaDEW')); - }); - - it('should show custom error message for DEW_SUBMIT_FAILED action', async () => { - const reportID = 'dewReport2'; - const report: Report = { - reportID, - reportName: 'Test Report', - type: CONST.REPORT.TYPE.EXPENSE, - }; - const customErrorMessage = 'This report contains an expense missing required fields.'; - const dewSubmitFailedAction: ReportAction = { - reportActionID: '1', - actionName: CONST.REPORT.ACTIONS.TYPE.DEW_SUBMIT_FAILED, - created: '2024-01-01 00:00:00', - message: [{type: 'COMMENT', text: customErrorMessage}], - originalMessage: { - message: customErrorMessage, - }, - }; - - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, report); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { - [dewSubmitFailedAction.reportActionID]: dewSubmitFailedAction, - }); - const lastMessage = getLastMessageTextForReport({report, lastActorDetails: null, isReportArchived: false}); - expect(lastMessage).toBe(customErrorMessage); - }); - - it('should show fallback message for DEW_SUBMIT_FAILED action without message', async () => { - const reportID = 'dewReport3'; - const report: Report = { - reportID, - reportName: 'Test Report', - type: CONST.REPORT.TYPE.EXPENSE, - }; - const dewSubmitFailedAction: ReportAction = { - reportActionID: '1', - actionName: CONST.REPORT.ACTIONS.TYPE.DEW_SUBMIT_FAILED, - created: '2024-01-01 00:00:00', - message: [{type: 'COMMENT', text: ''}], - originalMessage: {}, - }; - - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, report); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { - [dewSubmitFailedAction.reportActionID]: dewSubmitFailedAction, - }); - const lastMessage = getLastMessageTextForReport({report, lastActorDetails: null, isReportArchived: false}); - expect(lastMessage).toBe(translate(CONST.LOCALES.EN, 'iou.error.genericCreateFailureMessage')); - }); - }); }); From d785f83db2430d24da61db4f31ecea20b98ea17e Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Fri, 19 Dec 2025 09:50:38 +0100 Subject: [PATCH 61/76] delete the optimistic action on success because the backend provides its own SUBMITTED action with the DEW --- src/libs/OptionsListUtils/index.ts | 6 ++-- src/libs/actions/IOU.ts | 28 +++++++++++++------ .../home/report/PureReportActionItem.tsx | 6 ++-- 3 files changed, 26 insertions(+), 14 deletions(-) diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 6b3c0a7afd145..a3d1f2f7c667e 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -722,10 +722,10 @@ function getLastMessageTextForReport({ if (wasSubmittedViaHarvesting) { // eslint-disable-next-line @typescript-eslint/no-deprecated lastMessageTextFromReport = Parser.htmlToText(translateLocal('iou.automaticallySubmitted')); - } else if (hasPendingDEWSubmit(reportMetadata, isDEWPolicy) && isPendingAdd) { - // When DEW submit is pending: show "queued" message if offline, otherwise show nothing + } else if (hasPendingDEWSubmit(reportMetadata, isDEWPolicy) && isPendingAdd && isOffline) { + // When DEW submit is pending offline, show "queued" message. When online, fall through to show normal "submitted" message. // eslint-disable-next-line @typescript-eslint/no-deprecated - lastMessageTextFromReport = isOffline ? translateLocal('iou.queuedToSubmitViaDEW') : ''; + lastMessageTextFromReport = translateLocal('iou.queuedToSubmitViaDEW'); } else { // eslint-disable-next-line @typescript-eslint/no-deprecated lastMessageTextFromReport = translateLocal('iou.submitted', {memo: getOriginalMessage(lastReportAction)?.message}); diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 124808e82cf1f..a6a884aacb18b 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -11769,15 +11769,27 @@ function submitReport( }, }); } - successData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, - value: { - [optimisticSubmittedReportAction.reportActionID]: { - pendingAction: null, + if (isDEWPolicy) { + // For DEW policies, delete the optimistic action on success because the backend + // provides its own SUBMITTED action with the DEW workflow info + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, + value: { + [optimisticSubmittedReportAction.reportActionID]: null, }, - }, - }); + }); + } else { + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, + value: { + [optimisticSubmittedReportAction.reportActionID]: { + pendingAction: null, + }, + }, + }); + } if (isDEWPolicy) { successData.push({ diff --git a/src/pages/home/report/PureReportActionItem.tsx b/src/pages/home/report/PureReportActionItem.tsx index 3be3bb218a204..42169d00c8fe5 100644 --- a/src/pages/home/report/PureReportActionItem.tsx +++ b/src/pages/home/report/PureReportActionItem.tsx @@ -1217,9 +1217,9 @@ function PureReportActionItem({ ${translate('iou.automaticallySubmitted')}`} /> ); - } else if (hasPendingDEWSubmit(reportMetadata, isDEWPolicy) && isPendingAdd) { - // When DEW submit is pending: show "queued" message if offline, otherwise show nothing - children = isOffline ? : emptyHTML; + } else if (hasPendingDEWSubmit(reportMetadata, isDEWPolicy) && isPendingAdd && isOffline) { + // When DEW submit is pending offline, show "queued" message. When online, show submitted + children = ; } else { children = ; } From 13fb98c8e393c6dc90fdf6025da07e493d1cb996 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Fri, 19 Dec 2025 10:16:24 +0100 Subject: [PATCH 62/76] minor edit --- tests/unit/ReportUtilsTest.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index b7f3a0c5cc72a..eeb1ec8fe6fa2 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -10343,6 +10343,7 @@ describe('ReportUtils', () => { expect(result).toBe('payer owes $100'); }); }); + describe('getAvailableReportFields', () => { const fieldList1 = { expensify_field_id_LIST: { From 962da45ec73ae54c8630f27ca8dfc319121fa574 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Mon, 22 Dec 2025 10:26:55 +0100 Subject: [PATCH 63/76] remove expense failed action from optimitic values on success --- src/libs/actions/IOU.ts | 55 ++++++++++++++++++++--------------------- 1 file changed, 27 insertions(+), 28 deletions(-) diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 01023df880340..008f028b575e7 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -11769,27 +11769,15 @@ function submitReport( }, }); } - if (isDEWPolicy) { - // For DEW policies, delete the optimistic action on success because the backend - // provides its own SUBMITTED action with the DEW workflow info - successData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, - value: { - [optimisticSubmittedReportAction.reportActionID]: null, - }, - }); - } else { - successData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, - value: { - [optimisticSubmittedReportAction.reportActionID]: { - pendingAction: null, - }, + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, + value: { + [optimisticSubmittedReportAction.reportActionID]: { + pendingAction: null, }, - }); - } + }, + }); if (isDEWPolicy) { successData.push({ @@ -11819,15 +11807,26 @@ function submitReport( }, }, ]; - failureData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, - value: { - [optimisticSubmittedReportAction.reportActionID]: { - errors: getMicroSecondOnyxErrorWithTranslationKey('iou.error.other'), + if (isDEWPolicy) { + // delete the optimistic SUBMITTED action as The backend creates a DEW_SUBMIT_FAILED action instead. + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, + value: { + [optimisticSubmittedReportAction.reportActionID]: null, }, - }, - }); + }); + } else { + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, + value: { + [optimisticSubmittedReportAction.reportActionID]: { + errors: getMicroSecondOnyxErrorWithTranslationKey('iou.error.other'), + }, + }, + }); + } if (isDEWPolicy) { failureData.push({ From b8c31a3acf355c8caea002123c0725c032e9bb5d Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Tue, 30 Dec 2025 01:59:10 +0100 Subject: [PATCH 64/76] hide submited action when submiting while online --- src/libs/OptionsListUtils/index.ts | 6 +++--- src/pages/home/report/PureReportActionItem.tsx | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 5703b36e1bc19..93186107ff229 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -724,10 +724,10 @@ function getLastMessageTextForReport({ if (wasSubmittedViaHarvesting) { // eslint-disable-next-line @typescript-eslint/no-deprecated lastMessageTextFromReport = Parser.htmlToText(translateLocal('iou.automaticallySubmitted')); - } else if (hasPendingDEWSubmit(reportMetadata, isDEWPolicy) && isPendingAdd && isOffline) { - // When DEW submit is pending offline, show "queued" message. When online, fall through to show normal "submitted" message. + } else if (hasPendingDEWSubmit(reportMetadata, isDEWPolicy) && isPendingAdd) { + // When DEW submit is pending offline, show "queued" message. When online, show nothing // eslint-disable-next-line @typescript-eslint/no-deprecated - lastMessageTextFromReport = translateLocal('iou.queuedToSubmitViaDEW'); + lastMessageTextFromReport = isOffline ? translateLocal('iou.queuedToSubmitViaDEW') : ''; } else { // eslint-disable-next-line @typescript-eslint/no-deprecated lastMessageTextFromReport = translateLocal('iou.submitted', {memo: getOriginalMessage(lastReportAction)?.message}); diff --git a/src/pages/home/report/PureReportActionItem.tsx b/src/pages/home/report/PureReportActionItem.tsx index 1dd310f99e17d..1da0471a67dc3 100644 --- a/src/pages/home/report/PureReportActionItem.tsx +++ b/src/pages/home/report/PureReportActionItem.tsx @@ -1217,9 +1217,9 @@ function PureReportActionItem({ ${translate('iou.automaticallySubmitted')}`} /> ); - } else if (hasPendingDEWSubmit(reportMetadata, isDEWPolicy) && isPendingAdd && isOffline) { - // When DEW submit is pending offline, show "queued" message. When online, show submitted - children = ; + } else if (hasPendingDEWSubmit(reportMetadata, isDEWPolicy) && isPendingAdd) { + // When DEW submit is pending offline, show "queued" message. When online, show nothing + children = isOffline ? : emptyHTML; } else { children = ; } From c6ed4114c88ca460407af3e88e9081b4933c7f5e Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Thu, 1 Jan 2026 21:27:52 +0100 Subject: [PATCH 65/76] Skip optimistic submit action for online DEW submissions --- src/libs/OptionsListUtils/index.ts | 3 +- src/libs/actions/IOU.ts | 91 +++++++++++-------- .../home/report/PureReportActionItem.tsx | 3 +- 3 files changed, 55 insertions(+), 42 deletions(-) diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 93186107ff229..3f937efe5f0a4 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -725,9 +725,8 @@ function getLastMessageTextForReport({ // eslint-disable-next-line @typescript-eslint/no-deprecated lastMessageTextFromReport = Parser.htmlToText(translateLocal('iou.automaticallySubmitted')); } else if (hasPendingDEWSubmit(reportMetadata, isDEWPolicy) && isPendingAdd) { - // When DEW submit is pending offline, show "queued" message. When online, show nothing // eslint-disable-next-line @typescript-eslint/no-deprecated - lastMessageTextFromReport = isOffline ? translateLocal('iou.queuedToSubmitViaDEW') : ''; + lastMessageTextFromReport = translateLocal('iou.queuedToSubmitViaDEW'); } else { // eslint-disable-next-line @typescript-eslint/no-deprecated lastMessageTextFromReport = translateLocal('iou.submitted', {memo: getOriginalMessage(lastReportAction)?.message}); diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 9198ef595454e..7205e97efdc3e 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -70,6 +70,7 @@ import {validateAmount} from '@libs/MoneyRequestUtils'; import isReportOpenInRHP from '@libs/Navigation/helpers/isReportOpenInRHP'; import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; +import {isOffline} from '@libs/Network/NetworkStore'; // eslint-disable-next-line @typescript-eslint/no-deprecated import {buildNextStepNew, buildOptimisticNextStep} from '@libs/NextStepUtils'; import * as NumberUtils from '@libs/NumberUtils'; @@ -11677,6 +11678,8 @@ function submitReport( policy?.approvalMode, ); const isDEWPolicy = hasDynamicExternalWorkflow(policy); + // For DEW policies, only add optimistic submit action when offline + const shouldAddOptimisticSubmitAction = !isDEWPolicy || isOffline(); // buildOptimisticNextStep is used in parallel const optimisticNextStepDeprecated = isDEWPolicy @@ -11708,24 +11711,32 @@ function submitReport( const managerID = getAccountIDsByLogins(approvalChain).at(0); const optimisticData: OnyxUpdate[] = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, - value: { - [optimisticSubmittedReportAction.reportActionID]: { - ...(optimisticSubmittedReportAction as OnyxTypes.ReportAction), - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, - }, - }, - }, + ...(shouldAddOptimisticSubmitAction + ? [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, + value: { + [optimisticSubmittedReportAction.reportActionID]: { + ...(optimisticSubmittedReportAction as OnyxTypes.ReportAction), + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + }, + }, + ] + : []), !isSubmitAndClosePolicy ? { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, value: { ...expenseReport, - lastMessageText: getReportActionText(optimisticSubmittedReportAction), - lastMessageHtml: getReportActionHtml(optimisticSubmittedReportAction), + ...(shouldAddOptimisticSubmitAction + ? { + lastMessageText: getReportActionText(optimisticSubmittedReportAction), + lastMessageHtml: getReportActionHtml(optimisticSubmittedReportAction), + } + : {}), // For DEW policies, don't optimistically update managerID, stateNum, statusNum, or nextStep // because DEW determines the actual workflow on the backend ...(isDEWPolicy @@ -11804,15 +11815,17 @@ function submitReport( }, }); } - successData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, - value: { - [optimisticSubmittedReportAction.reportActionID]: { - pendingAction: null, + if (shouldAddOptimisticSubmitAction) { + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, + value: { + [optimisticSubmittedReportAction.reportActionID]: { + pendingAction: null, + }, }, - }, - }); + }); + } if (isDEWPolicy) { successData.push({ @@ -11842,25 +11855,27 @@ function submitReport( }, }, ]; - if (isDEWPolicy) { - // delete the optimistic SUBMITTED action as The backend creates a DEW_SUBMIT_FAILED action instead. - failureData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, - value: { - [optimisticSubmittedReportAction.reportActionID]: null, - }, - }); - } else { - failureData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, - value: { - [optimisticSubmittedReportAction.reportActionID]: { - errors: getMicroSecondOnyxErrorWithTranslationKey('iou.error.other'), + if (shouldAddOptimisticSubmitAction) { + if (isDEWPolicy) { + // delete the optimistic SUBMITTED action as The backend creates a DEW_SUBMIT_FAILED action instead. + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, + value: { + [optimisticSubmittedReportAction.reportActionID]: null, }, - }, - }); + }); + } else { + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, + value: { + [optimisticSubmittedReportAction.reportActionID]: { + errors: getMicroSecondOnyxErrorWithTranslationKey('iou.error.other'), + }, + }, + }); + } } if (isDEWPolicy) { diff --git a/src/pages/home/report/PureReportActionItem.tsx b/src/pages/home/report/PureReportActionItem.tsx index 1da0471a67dc3..921ac680e150a 100644 --- a/src/pages/home/report/PureReportActionItem.tsx +++ b/src/pages/home/report/PureReportActionItem.tsx @@ -1218,8 +1218,7 @@ function PureReportActionItem({ ); } else if (hasPendingDEWSubmit(reportMetadata, isDEWPolicy) && isPendingAdd) { - // When DEW submit is pending offline, show "queued" message. When online, show nothing - children = isOffline ? : emptyHTML; + children = ; } else { children = ; } From cafb2f2c306eac0ec8bd5d62ae64eb59648cb459 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Thu, 1 Jan 2026 22:49:18 +0100 Subject: [PATCH 66/76] fixing ts --- .../MoneyRequestReportPreviewContent.tsx | 2 +- src/libs/OptionsListUtils/index.ts | 2 -- src/libs/actions/IOU/index.ts | 13 +++++++------ src/pages/home/report/PureReportActionItem.tsx | 7 +------ src/pages/home/report/ReportActionItem.tsx | 3 --- tests/ui/PureReportActionItemTest.tsx | 1 - tests/unit/OptionsListUtilsTest.tsx | 3 ++- 7 files changed, 11 insertions(+), 20 deletions(-) diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx index 86d042b9d3e82..8fa1cd809ced5 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx @@ -534,7 +534,7 @@ function MoneyRequestReportPreviewContent({ isSubmittingAnimationRunning, isDEWSubmitPending, violationsData: transactionViolations, - ); + }); }, [ isIouReportArchived, isChatReportArchived, diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 916afed0f50f9..b1b0380cd2df3 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -602,7 +602,6 @@ function getLastMessageTextForReport({ isReportArchived = false, policyForMovingExpensesID, reportMetadata, - isOffline, }: { report: OnyxEntry; lastActorDetails: Partial | null; @@ -612,7 +611,6 @@ function getLastMessageTextForReport({ isReportArchived?: boolean; policyForMovingExpensesID?: string; reportMetadata?: OnyxEntry; - isOffline?: boolean; }): string { const reportID = report?.reportID; const lastReportAction = reportID ? lastVisibleReportActions[reportID] : undefined; diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index 65e8de769c57b..ddd08451259fa 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -11382,12 +11382,13 @@ function submitReport( const successData: Array> = []; if (!isDEWPolicy) { - successData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, - value: { - pendingFields: { - nextStep: null, + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, + value: { + pendingFields: { + nextStep: null, + }, }, }); } diff --git a/src/pages/home/report/PureReportActionItem.tsx b/src/pages/home/report/PureReportActionItem.tsx index f8499a609b01a..2c5adc15e7f56 100644 --- a/src/pages/home/report/PureReportActionItem.tsx +++ b/src/pages/home/report/PureReportActionItem.tsx @@ -423,9 +423,6 @@ type PureReportActionItemProps = { /** Report metadata for the report */ reportMetadata?: OnyxEntry; - - /** Whether the network is offline */ - isOffline?: boolean; }; // This is equivalent to returning a negative boolean in normal functions, but we can keep the element return type @@ -497,7 +494,6 @@ function PureReportActionItem({ reportNameValuePairsOrigin, reportNameValuePairsOriginalID, reportMetadata, - isOffline, }: PureReportActionItemProps) { const actionSheetAwareScrollViewContext = useContext(ActionSheetAwareScrollView.ActionSheetAwareScrollViewContext); const {translate, formatPhoneNumber, localeCompare, formatTravelDate, getLocalDateFromDatetime} = useLocalize(); @@ -1999,7 +1995,6 @@ export default memo(PureReportActionItem, (prevProps, nextProps) => { deepEqual(prevProps.bankAccountList, nextProps.bankAccountList) && prevProps.reportNameValuePairsOrigin === nextProps.reportNameValuePairsOrigin && prevProps.reportNameValuePairsOriginalID === nextProps.reportNameValuePairsOriginalID && - prevProps.reportMetadata?.pendingExpenseAction === nextProps.reportMetadata?.pendingExpenseAction && - prevProps.isOffline === nextProps.isOffline + prevProps.reportMetadata?.pendingExpenseAction === nextProps.reportMetadata?.pendingExpenseAction ); }); diff --git a/src/pages/home/report/ReportActionItem.tsx b/src/pages/home/report/ReportActionItem.tsx index 1f2532bc5d489..93aa6441f55c4 100644 --- a/src/pages/home/report/ReportActionItem.tsx +++ b/src/pages/home/report/ReportActionItem.tsx @@ -2,7 +2,6 @@ import {accountIDSelector} from '@selectors/Session'; import React from 'react'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {useBlockedFromConcierge} from '@components/OnyxListItemProvider'; -import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useOriginalReportID from '@hooks/useOriginalReportID'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; @@ -95,7 +94,6 @@ function ReportActionItem({ const originalReportID = useOriginalReportID(reportID, action); const originalReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${originalReportID}`]; const isOriginalReportArchived = useReportIsArchived(originalReportID); - const {isOffline} = useNetwork(); const [currentUserAccountID] = useOnyx(ONYXKEYS.SESSION, {canBeMissing: false, selector: accountIDSelector}); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {canBeMissing: true}); const [reportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`, {canBeMissing: true}); @@ -168,7 +166,6 @@ function ReportActionItem({ isTryNewDotNVPDismissed={isTryNewDotNVPDismissed} bankAccountList={bankAccountList} reportMetadata={reportMetadata} - isOffline={isOffline} /> ); } diff --git a/tests/ui/PureReportActionItemTest.tsx b/tests/ui/PureReportActionItemTest.tsx index fdd084ffde8d7..451191b79fbec 100644 --- a/tests/ui/PureReportActionItemTest.tsx +++ b/tests/ui/PureReportActionItemTest.tsx @@ -269,7 +269,6 @@ describe('PureReportActionItem', () => { linkedReport={undefined} iouReportOfLinkedReport={undefined} reportMetadata={reportMetadata} - isOffline /> diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index 8b0549f8c81c3..481f17e819fba 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -2888,7 +2888,7 @@ describe('OptionsListUtils', () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { [submittedAction.reportActionID]: submittedAction, }); - const lastMessage = getLastMessageTextForReport({report, lastActorDetails: null, isReportArchived: false, policy, reportMetadata, isOffline: true}); + const lastMessage = getLastMessageTextForReport({report, lastActorDetails: null, isReportArchived: false, policy, reportMetadata}); expect(lastMessage).toBe(translate(CONST.LOCALES.EN, 'iou.queuedToSubmitViaDEW')); }); @@ -2942,6 +2942,7 @@ describe('OptionsListUtils', () => { }); }); }); + describe('getPersonalDetailSearchTerms', () => { it('should include display name', () => { const displayName = 'test'; From 153b035a547753f6b7c7cad1261377d7d66fcdb9 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Thu, 1 Jan 2026 23:01:17 +0100 Subject: [PATCH 67/76] Refactor handleActionButtonPress to use object param --- .../Search/ExpenseReportListItem.tsx | 12 +++--- .../Search/ReportListItemHeader.tsx | 12 +++--- src/libs/actions/Search.ts | 40 +++++++++++++------ .../Search/handleActionButtonPressTest.ts | 26 ++++++++++-- 4 files changed, 61 insertions(+), 29 deletions(-) diff --git a/src/components/SelectionListWithSections/Search/ExpenseReportListItem.tsx b/src/components/SelectionListWithSections/Search/ExpenseReportListItem.tsx index cd2f5ac8019bb..fc751cd899231 100644 --- a/src/components/SelectionListWithSections/Search/ExpenseReportListItem.tsx +++ b/src/components/SelectionListWithSections/Search/ExpenseReportListItem.tsx @@ -65,10 +65,10 @@ function ExpenseReportListItem({ const {isDelegateAccessRestricted, showDelegateNoAccessModal} = useContext(DelegateNoAccessContext); const handleOnButtonPress = useCallback(() => { - handleActionButtonPress( - currentSearchHash, - reportItem, - () => onSelectRow(reportItem as unknown as TItem), + handleActionButtonPress({ + hash: currentSearchHash, + item: reportItem, + goToItem: () => onSelectRow(reportItem as unknown as TItem), snapshotReport, snapshotPolicy, lastPaymentMethod, @@ -76,8 +76,8 @@ function ExpenseReportListItem({ onDEWModalOpen, isDEWBetaEnabled, isDelegateAccessRestricted, - showDelegateNoAccessModal, - ); + onDelegateAccessRestricted: showDelegateNoAccessModal, + }); }, [ currentSearchHash, reportItem, diff --git a/src/components/SelectionListWithSections/Search/ReportListItemHeader.tsx b/src/components/SelectionListWithSections/Search/ReportListItemHeader.tsx index c7b6f35b0d9c6..0e07dc61f2c40 100644 --- a/src/components/SelectionListWithSections/Search/ReportListItemHeader.tsx +++ b/src/components/SelectionListWithSections/Search/ReportListItemHeader.tsx @@ -228,10 +228,10 @@ function ReportListItemHeader({ theme.highlightBG; const handleOnButtonPress = () => { - handleActionButtonPress( - currentSearchHash, - reportItem, - () => onSelectRow(reportItem as unknown as TItem), + handleActionButtonPress({ + hash: currentSearchHash, + item: reportItem, + goToItem: () => onSelectRow(reportItem as unknown as TItem), snapshotReport, snapshotPolicy, lastPaymentMethod, @@ -239,8 +239,8 @@ function ReportListItemHeader({ onDEWModalOpen, isDEWBetaEnabled, isDelegateAccessRestricted, - showDelegateNoAccessModal, - ); + onDelegateAccessRestricted: showDelegateNoAccessModal, + }); }; return !isLargeScreenWidth ? ( diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index d49d6f25e587d..7dc7ffc967433 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -72,19 +72,33 @@ type TransactionPreviewData = { hasTransactionThreadReport: boolean; }; -function handleActionButtonPress( - hash: number, - item: TransactionListItemType | TransactionReportGroupListItemType, - goToItem: () => void, - snapshotReport: Report, - snapshotPolicy: Policy, - lastPaymentMethod: OnyxEntry, - currentSearchKey?: SearchKey, - onDEWModalOpen?: () => void, - isDEWBetaEnabled?: boolean, - isDelegateAccessRestricted?: boolean, - onDelegateAccessRestricted?: () => void, -) { +type HandleActionButtonPressParams = { + hash: number; + item: TransactionListItemType | TransactionReportGroupListItemType; + goToItem: () => void; + snapshotReport: Report; + snapshotPolicy: Policy; + lastPaymentMethod: OnyxEntry; + currentSearchKey?: SearchKey; + onDEWModalOpen?: () => void; + isDEWBetaEnabled?: boolean; + isDelegateAccessRestricted?: boolean; + onDelegateAccessRestricted?: () => void; +}; + +function handleActionButtonPress({ + hash, + item, + goToItem, + snapshotReport, + snapshotPolicy, + lastPaymentMethod, + currentSearchKey, + onDEWModalOpen, + isDEWBetaEnabled, + isDelegateAccessRestricted, + onDelegateAccessRestricted, +}: HandleActionButtonPressParams) { // The transactionIDList is needed to handle actions taken on `status:""` where transactions on single expense reports can be approved/paid. // We need the transactionID to display the loading indicator for that list item's action. // eslint-disable-next-line @typescript-eslint/no-deprecated diff --git a/tests/unit/Search/handleActionButtonPressTest.ts b/tests/unit/Search/handleActionButtonPressTest.ts index 2c796e2b148ef..4ac4f9380fce1 100644 --- a/tests/unit/Search/handleActionButtonPressTest.ts +++ b/tests/unit/Search/handleActionButtonPressTest.ts @@ -307,15 +307,33 @@ describe('handleActionButtonPress', () => { test('Should navigate to item when report has one transaction on hold', () => { const goToItem = jest.fn(() => {}); - // @ts-expect-error: Allow partial record in snapshot update for testing - handleActionButtonPress(searchHash, mockReportItemWithHold, goToItem, snapshotReport, snapshotPolicy, mockLastPaymentMethod); + handleActionButtonPress({ + hash: searchHash, + // @ts-expect-error: Allow partial record in snapshot update for testing + item: mockReportItemWithHold, + goToItem, + // @ts-expect-error: Allow partial record in snapshot update for testing + snapshotReport, + // @ts-expect-error: Allow partial record in snapshot update for testing + snapshotPolicy, + lastPaymentMethod: mockLastPaymentMethod, + }); expect(goToItem).toHaveBeenCalledTimes(1); }); test('Should not navigate to item when the hold is removed', () => { const goToItem = jest.fn(() => {}); - // @ts-expect-error: Allow partial record in snapshot update for testing - handleActionButtonPress(searchHash, updatedMockReportItem, goToItem, snapshotReport, snapshotPolicy, mockLastPaymentMethod); + handleActionButtonPress({ + hash: searchHash, + // @ts-expect-error: Allow partial record in snapshot update for testing + item: updatedMockReportItem, + goToItem, + // @ts-expect-error: Allow partial record in snapshot update for testing + snapshotReport, + // @ts-expect-error: Allow partial record in snapshot update for testing + snapshotPolicy, + lastPaymentMethod: mockLastPaymentMethod, + }); expect(goToItem).toHaveBeenCalledTimes(0); }); }); From 031ff9840a30ae74749ad90a21cc8c4ef74b3085 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Thu, 1 Jan 2026 23:43:44 +0100 Subject: [PATCH 68/76] fixing eslint --- .../LHNOptionsList/LHNOptionsList.tsx | 1 - .../Search/TransactionListItem.tsx | 12 +- src/libs/actions/IOU/index.ts | 127 +++++++++--------- tests/actions/ReportPreviewActionUtilsTest.ts | 42 +++++- .../Search/handleActionButtonPressTest.ts | 20 +-- 5 files changed, 117 insertions(+), 85 deletions(-) diff --git a/src/components/LHNOptionsList/LHNOptionsList.tsx b/src/components/LHNOptionsList/LHNOptionsList.tsx index 96c8f4139a3db..4688166437ef9 100644 --- a/src/components/LHNOptionsList/LHNOptionsList.tsx +++ b/src/components/LHNOptionsList/LHNOptionsList.tsx @@ -236,7 +236,6 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio isReportArchived: !!itemReportNameValuePairs?.private_isArchived, policyForMovingExpensesID, reportMetadata: itemReportMetadata, - isOffline, }); const shouldShowRBRorGBRTooltip = firstReportIDWithGBRorRBR === reportID; diff --git a/src/components/SelectionListWithSections/Search/TransactionListItem.tsx b/src/components/SelectionListWithSections/Search/TransactionListItem.tsx index 3f5125868cc60..1cb2681c66987 100644 --- a/src/components/SelectionListWithSections/Search/TransactionListItem.tsx +++ b/src/components/SelectionListWithSections/Search/TransactionListItem.tsx @@ -129,10 +129,10 @@ function TransactionListItem({ const {isDelegateAccessRestricted, showDelegateNoAccessModal} = useContext(DelegateNoAccessContext); const handleActionButtonPress = useCallback(() => { - handleActionButtonPressUtil( - currentSearchHash, - transactionItem, - () => onSelectRow(item, transactionPreviewData), + handleActionButtonPressUtil({ + hash: currentSearchHash, + item: transactionItem, + goToItem: () => onSelectRow(item, transactionPreviewData), snapshotReport, snapshotPolicy, lastPaymentMethod, @@ -140,8 +140,8 @@ function TransactionListItem({ onDEWModalOpen, isDEWBetaEnabled, isDelegateAccessRestricted, - showDelegateNoAccessModal, - ); + onDelegateAccessRestricted: showDelegateNoAccessModal, + }); }, [ currentSearchHash, transactionItem, diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index ddd08451259fa..972f9c9f73411 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -11287,67 +11287,68 @@ function submitReport( const approvalChain = getApprovalChain(policy, expenseReport); const managerID = getAccountIDsByLogins(approvalChain).at(0); - const optimisticData: OnyxUpdate[] = [ - ...(shouldAddOptimisticSubmitAction - ? [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, - value: { - [optimisticSubmittedReportAction.reportActionID]: { - ...(optimisticSubmittedReportAction as OnyxTypes.ReportAction), - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + const optimisticData: OnyxUpdate[] = []; + + if (shouldAddOptimisticSubmitAction) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, + value: { + [optimisticSubmittedReportAction.reportActionID]: { + ...(optimisticSubmittedReportAction as OnyxTypes.ReportAction), + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + }, + }); + } + + if (!isSubmitAndClosePolicy) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, + value: { + ...expenseReport, + ...(shouldAddOptimisticSubmitAction + ? { + lastMessageText: getReportActionText(optimisticSubmittedReportAction), + lastMessageHtml: getReportActionHtml(optimisticSubmittedReportAction), + } + : {}), + // For DEW policies, don't optimistically update managerID, stateNum, statusNum, or nextStep + // because DEW determines the actual workflow on the backend + ...(isDEWPolicy + ? {} + : { + managerID, + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + nextStep: optimisticNextStep, + pendingFields: { + nextStep: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, }, - }, - }, - ] - : []), - !isSubmitAndClosePolicy - ? { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, - value: { - ...expenseReport, - ...(shouldAddOptimisticSubmitAction - ? { - lastMessageText: getReportActionText(optimisticSubmittedReportAction), - lastMessageHtml: getReportActionHtml(optimisticSubmittedReportAction), - } - : {}), - // For DEW policies, don't optimistically update managerID, stateNum, statusNum, or nextStep - // because DEW determines the actual workflow on the backend - ...(isDEWPolicy - ? {} - : { - managerID, - stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, - statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, - nextStep: optimisticNextStep, - pendingFields: { - nextStep: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, - }, - }), - }, - } - : { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, - value: { - ...expenseReport, - // For DEW policies, don't optimistically update stateNum, statusNum, or nextStep - ...(isDEWPolicy - ? {} - : { - stateNum: CONST.REPORT.STATE_NUM.APPROVED, - statusNum: CONST.REPORT.STATUS_NUM.CLOSED, - nextStep: optimisticNextStep, - pendingFields: { - nextStep: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, - }, - }), - }, - }, - ]; + }), + }, + }); + } else { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, + value: { + ...expenseReport, + // For DEW policies, don't optimistically update stateNum, statusNum, or nextStep + ...(isDEWPolicy + ? {} + : { + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.CLOSED, + nextStep: optimisticNextStep, + pendingFields: { + nextStep: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }), + }, + }); + } if (!isDEWPolicy) { optimisticData.push({ @@ -11380,7 +11381,7 @@ function submitReport( }); } - const successData: Array> = []; + const successData: Array> = []; if (!isDEWPolicy) { successData.push({ onyxMethod: Onyx.METHOD.MERGE, @@ -11414,7 +11415,9 @@ function submitReport( }); } - const failureData: Array> = [ + const failureData: Array< + OnyxUpdate + > = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, diff --git a/tests/actions/ReportPreviewActionUtilsTest.ts b/tests/actions/ReportPreviewActionUtilsTest.ts index 3dd3165575a11..fbc95b430c051 100644 --- a/tests/actions/ReportPreviewActionUtilsTest.ts +++ b/tests/actions/ReportPreviewActionUtilsTest.ts @@ -700,7 +700,19 @@ describe('getReportPreviewAction', () => { const {result: isReportArchived} = renderHook(() => useReportIsArchived(report?.parentReportID)); // When getReportPreviewAction is called with isDEWSubmitPending = true - const result = getReportPreviewAction(isReportArchived.current, CURRENT_USER_ACCOUNT_ID, report, policy, [transaction], undefined, false, false, false, true); + const result = getReportPreviewAction({ + isReportArchived: isReportArchived.current, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + currentUserEmail: '', + report, + policy, + transactions: [transaction], + invoiceReceiverPolicy: undefined, + isPaidAnimationRunning: false, + isApprovedAnimationRunning: false, + isSubmittingAnimationRunning: false, + isDEWSubmitPending: true, + }); // Then it should return VIEW because DEW submission is pending offline expect(result).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.VIEW); @@ -732,7 +744,19 @@ describe('getReportPreviewAction', () => { const {result: isReportArchived} = renderHook(() => useReportIsArchived(report?.parentReportID)); // When getReportPreviewAction is called with isDEWSubmitPending = false (failed, not pending) - const result = getReportPreviewAction(isReportArchived.current, CURRENT_USER_ACCOUNT_ID, report, policy, [transaction], undefined, false, false, false, false); + const result = getReportPreviewAction({ + isReportArchived: isReportArchived.current, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + currentUserEmail: '', + report, + policy, + transactions: [transaction], + invoiceReceiverPolicy: undefined, + isPaidAnimationRunning: false, + isApprovedAnimationRunning: false, + isSubmittingAnimationRunning: false, + isDEWSubmitPending: false, + }); // Then it should allow SUBMIT because failed submissions can be retried (not VIEW) expect(result).not.toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.VIEW); @@ -764,7 +788,19 @@ describe('getReportPreviewAction', () => { const {result: isReportArchived} = renderHook(() => useReportIsArchived(report?.parentReportID)); // When getReportPreviewAction is called with isDEWSubmitPending = false - const result = getReportPreviewAction(isReportArchived.current, CURRENT_USER_ACCOUNT_ID, report, policy, [transaction], undefined, false, false, false, false); + const result = getReportPreviewAction({ + isReportArchived: isReportArchived.current, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + currentUserEmail: '', + report, + policy, + transactions: [transaction], + invoiceReceiverPolicy: undefined, + isPaidAnimationRunning: false, + isApprovedAnimationRunning: false, + isSubmittingAnimationRunning: false, + isDEWSubmitPending: false, + }); // 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); diff --git a/tests/unit/Search/handleActionButtonPressTest.ts b/tests/unit/Search/handleActionButtonPressTest.ts index 4ac4f9380fce1..ae4637eac5d4d 100644 --- a/tests/unit/Search/handleActionButtonPressTest.ts +++ b/tests/unit/Search/handleActionButtonPressTest.ts @@ -4,7 +4,7 @@ import Onyx from 'react-native-onyx'; import type {TransactionReportGroupListItemType} from '@components/SelectionListWithSections/types'; import {handleActionButtonPress} from '@libs/actions/Search'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {LastPaymentMethod, SearchResults} from '@src/types/onyx'; +import type {LastPaymentMethod, Policy, Report, SearchResults} from '@src/types/onyx'; jest.mock('@src/components/ConfirmedRoute.tsx'); @@ -309,13 +309,10 @@ describe('handleActionButtonPress', () => { const goToItem = jest.fn(() => {}); handleActionButtonPress({ hash: searchHash, - // @ts-expect-error: Allow partial record in snapshot update for testing - item: mockReportItemWithHold, + item: mockReportItemWithHold as TransactionReportGroupListItemType, goToItem, - // @ts-expect-error: Allow partial record in snapshot update for testing - snapshotReport, - // @ts-expect-error: Allow partial record in snapshot update for testing - snapshotPolicy, + snapshotReport: snapshotReport as Report, + snapshotPolicy: snapshotPolicy as Policy, lastPaymentMethod: mockLastPaymentMethod, }); expect(goToItem).toHaveBeenCalledTimes(1); @@ -325,13 +322,10 @@ describe('handleActionButtonPress', () => { const goToItem = jest.fn(() => {}); handleActionButtonPress({ hash: searchHash, - // @ts-expect-error: Allow partial record in snapshot update for testing - item: updatedMockReportItem, + item: updatedMockReportItem as TransactionReportGroupListItemType, goToItem, - // @ts-expect-error: Allow partial record in snapshot update for testing - snapshotReport, - // @ts-expect-error: Allow partial record in snapshot update for testing - snapshotPolicy, + snapshotReport: snapshotReport as Report, + snapshotPolicy: snapshotPolicy as Policy, lastPaymentMethod: mockLastPaymentMethod, }); expect(goToItem).toHaveBeenCalledTimes(0); From aaa680dbc5edc3a3c0e4ea2927557783d2ce7978 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Thu, 1 Jan 2026 23:58:52 +0100 Subject: [PATCH 69/76] fixing lint --- tests/unit/Search/handleActionButtonPressTest.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/Search/handleActionButtonPressTest.ts b/tests/unit/Search/handleActionButtonPressTest.ts index ae4637eac5d4d..3f709aa8abf5f 100644 --- a/tests/unit/Search/handleActionButtonPressTest.ts +++ b/tests/unit/Search/handleActionButtonPressTest.ts @@ -309,7 +309,7 @@ describe('handleActionButtonPress', () => { const goToItem = jest.fn(() => {}); handleActionButtonPress({ hash: searchHash, - item: mockReportItemWithHold as TransactionReportGroupListItemType, + item: mockReportItemWithHold, goToItem, snapshotReport: snapshotReport as Report, snapshotPolicy: snapshotPolicy as Policy, @@ -322,7 +322,7 @@ describe('handleActionButtonPress', () => { const goToItem = jest.fn(() => {}); handleActionButtonPress({ hash: searchHash, - item: updatedMockReportItem as TransactionReportGroupListItemType, + item: updatedMockReportItem, goToItem, snapshotReport: snapshotReport as Report, snapshotPolicy: snapshotPolicy as Policy, From 91a47a93ba23babcc00d735e396b91063c6fd6d2 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Mon, 5 Jan 2026 23:02:38 +0100 Subject: [PATCH 70/76] fixing test --- tests/actions/IOUTest.ts | 1 + tests/ui/components/LHNOptionsListTest.tsx | 38 ++++++++-------------- 2 files changed, 15 insertions(+), 24 deletions(-) diff --git a/tests/actions/IOUTest.ts b/tests/actions/IOUTest.ts index 9d2d455277b17..853a6710c080d 100644 --- a/tests/actions/IOUTest.ts +++ b/tests/actions/IOUTest.ts @@ -6001,6 +6001,7 @@ describe('actions/IOU', () => { currentUserAccountIDParam: RORY_ACCOUNT_ID, currentUserEmailParam: RORY_EMAIL, transactionViolations: {}, + policyRecentlyUsedCurrencies: [], }); } return waitForBatchedUpdates(); diff --git a/tests/ui/components/LHNOptionsListTest.tsx b/tests/ui/components/LHNOptionsListTest.tsx index c85aa1f278b73..b1804e2a2f49d 100644 --- a/tests/ui/components/LHNOptionsListTest.tsx +++ b/tests/ui/components/LHNOptionsListTest.tsx @@ -179,7 +179,7 @@ describe('LHNOptionsList', () => { describe('DEW (Dynamic External Workflow) pending submit message', () => { it('shows queued message when offline with pending DEW submit', async () => { - // Given a DEW policy and a report with pending submit + // Given a report is submitted while offline on a DEW policy, which creates an optimistic SUBMITTED action const policyID = 'dewTestPolicy'; const reportID = 'dewTestReport'; const accountID1 = 1; @@ -205,10 +205,7 @@ describe('LHNOptionsList', () => { message: [{type: 'COMMENT', text: 'submitted'}], originalMessage: {}, }; - - // Given the screen is focused and network is offline mockUseIsFocused.mockReturnValue(true); - await act(async () => { await Onyx.merge(ONYXKEYS.NETWORK, {isOffline: true}); await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, policy); @@ -221,25 +218,24 @@ describe('LHNOptionsList', () => { }); }); - // When the LHNOptionsList is rendered with the DEW report + // When the LHNOptionsList is rendered render(getLHNOptionsListElement({data: [report]})); - // Then wait for the report to be displayed + // Then the queued message should be displayed because DEW submissions are processed async and the user needs feedback const reportItem = await waitFor(() => getReportItem(reportID)); expect(reportItem).toBeTruthy(); - - // Then the queued message should be displayed await waitFor(() => { expect(screen.getByText('queued to submit via custom approval workflow')).toBeTruthy(); }); }); - it('shows submitted message when online with pending DEW submit', async () => { - // Given a DEW policy and a report with pending submit + it('does not show queued message when user submits online with DEW policy', async () => { + // Given a report is submitted while online on a DEW policy, which does NOT create an optimistic SUBMITTED action const policyID = 'dewTestPolicyOnline'; const reportID = 'dewTestReportOnline'; const accountID1 = 1; const accountID2 = 2; + const expectedLastMessage = 'Expense for lunch meeting'; const policy: Policy = { id: policyID, name: 'DEW Test Policy', @@ -251,43 +247,37 @@ describe('LHNOptionsList', () => { reportName: 'DEW Test Report', type: CONST.REPORT.TYPE.EXPENSE, policyID, + lastMessageText: expectedLastMessage, participants: {[accountID1]: {notificationPreference: 'always'}, [accountID2]: {notificationPreference: 'always'}}, }; - const submittedAction: ReportAction = { + const commentAction: ReportAction = { reportActionID: '1', - actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, created: '2024-01-01 00:00:00', - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, - message: [{type: 'COMMENT', text: 'submitted'}], - originalMessage: {}, + message: [{type: 'COMMENT', text: expectedLastMessage, html: expectedLastMessage}], }; - - // Given the screen is focused and network is online mockUseIsFocused.mockReturnValue(true); - await act(async () => { await Onyx.merge(ONYXKEYS.NETWORK, {isOffline: false}); await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, policy); await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, report); await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { - [submittedAction.reportActionID]: submittedAction, + [commentAction.reportActionID]: commentAction, }); await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`, { pendingExpenseAction: CONST.EXPENSE_PENDING_ACTION.SUBMIT, }); }); - // When the LHNOptionsList is rendered with the DEW report + // When the LHNOptionsList is rendered render(getLHNOptionsListElement({data: [report]})); - // Then wait for the report to be displayed + // Then the queued message should NOT appear because the server processes DEW submissions immediately when online const reportItem = await waitFor(() => getReportItem(reportID)); expect(reportItem).toBeTruthy(); - - // Then the queued message should NOT be displayed when online, instead show "submitted" await waitFor(() => { expect(screen.queryByText('queued to submit via custom approval workflow')).toBeNull(); - expect(screen.getByText('submitted')).toBeTruthy(); + expect(screen.getByText(expectedLastMessage)).toBeTruthy(); }); }); }); From 6867379ca25232299576f4efdb573c739210f53f Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Mon, 5 Jan 2026 23:18:26 +0100 Subject: [PATCH 71/76] fix: `getPersonalPolicy` is deprecated inside Search.ts --- .../Search/ExpenseReportListItem.tsx | 3 ++ .../Search/ReportListItemHeader.tsx | 2 ++ src/libs/actions/Search.ts | 28 +++++++++++++------ src/pages/Search/SearchPage.tsx | 2 +- 4 files changed, 25 insertions(+), 10 deletions(-) diff --git a/src/components/SelectionListWithSections/Search/ExpenseReportListItem.tsx b/src/components/SelectionListWithSections/Search/ExpenseReportListItem.tsx index fc751cd899231..0242a3aad7074 100644 --- a/src/components/SelectionListWithSections/Search/ExpenseReportListItem.tsx +++ b/src/components/SelectionListWithSections/Search/ExpenseReportListItem.tsx @@ -43,6 +43,7 @@ function ExpenseReportListItem({ const {isLargeScreenWidth} = useResponsiveLayout(); const {currentSearchHash, currentSearchKey} = useSearchContext(); const [lastPaymentMethod] = useOnyx(ONYXKEYS.NVP_LAST_PAYMENT_METHOD, {canBeMissing: true}); + const [personalPolicyID] = useOnyx(ONYXKEYS.PERSONAL_POLICY_ID, {canBeMissing: true}); const [snapshot] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${currentSearchHash}`, {canBeMissing: true}); const [isActionLoading] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportItem.reportID}`, {canBeMissing: true, selector: isActionLoadingSelector}); const expensifyIcons = useMemoizedLazyExpensifyIcons(['DotIndicator']); @@ -77,6 +78,7 @@ function ExpenseReportListItem({ isDEWBetaEnabled, isDelegateAccessRestricted, onDelegateAccessRestricted: showDelegateNoAccessModal, + personalPolicyID, }); }, [ currentSearchHash, @@ -85,6 +87,7 @@ function ExpenseReportListItem({ snapshotReport, snapshotPolicy, lastPaymentMethod, + personalPolicyID, currentSearchKey, onDEWModalOpen, isDEWBetaEnabled, diff --git a/src/components/SelectionListWithSections/Search/ReportListItemHeader.tsx b/src/components/SelectionListWithSections/Search/ReportListItemHeader.tsx index 0e07dc61f2c40..c595c635dba00 100644 --- a/src/components/SelectionListWithSections/Search/ReportListItemHeader.tsx +++ b/src/components/SelectionListWithSections/Search/ReportListItemHeader.tsx @@ -213,6 +213,7 @@ function ReportListItemHeader({ const {currentSearchHash, currentSearchKey} = useSearchContext(); const {isLargeScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout(); const [lastPaymentMethod] = useOnyx(ONYXKEYS.NVP_LAST_PAYMENT_METHOD, {canBeMissing: true}); + const [personalPolicyID] = useOnyx(ONYXKEYS.PERSONAL_POLICY_ID, {canBeMissing: true}); const thereIsFromAndTo = !!reportItem?.from && !!reportItem?.to; const showUserInfo = (reportItem.type === CONST.REPORT.TYPE.IOU && thereIsFromAndTo) || (reportItem.type === CONST.REPORT.TYPE.EXPENSE && !!reportItem?.from); const [snapshot] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${currentSearchHash}`, {canBeMissing: true}); @@ -240,6 +241,7 @@ function ReportListItemHeader({ isDEWBetaEnabled, isDelegateAccessRestricted, onDelegateAccessRestricted: showDelegateNoAccessModal, + personalPolicyID, }); }; return !isLargeScreenWidth ? ( diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 11133fab0101b..be56a958d050a 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -21,7 +21,7 @@ import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; import enhanceParameters from '@libs/Network/enhanceParameters'; import {rand64} from '@libs/NumberUtils'; import {getActivePaymentType} from '@libs/PaymentUtils'; -import {getPersonalPolicy, getSubmitToAccountID, getValidConnectedIntegration, hasDynamicExternalWorkflow, isDelayedSubmissionEnabled} from '@libs/PolicyUtils'; +import {getSubmitToAccountID, getValidConnectedIntegration, hasDynamicExternalWorkflow, isDelayedSubmissionEnabled} from '@libs/PolicyUtils'; import {getIOUActionForTransactionID} from '@libs/ReportActionsUtils'; import type {OptimisticExportIntegrationAction} from '@libs/ReportUtils'; import { @@ -84,6 +84,7 @@ type HandleActionButtonPressParams = { isDEWBetaEnabled?: boolean; isDelegateAccessRestricted?: boolean; onDelegateAccessRestricted?: () => void; + personalPolicyID?: string; }; function handleActionButtonPress({ @@ -98,6 +99,7 @@ function handleActionButtonPress({ isDEWBetaEnabled, isDelegateAccessRestricted, onDelegateAccessRestricted, + personalPolicyID, }: HandleActionButtonPressParams) { // The transactionIDList is needed to handle actions taken on `status:""` where transactions on single expense reports can be approved/paid. // We need the transactionID to display the loading indicator for that list item's action. @@ -116,7 +118,7 @@ function handleActionButtonPress({ onDelegateAccessRestricted?.(); return; } - getPayActionCallback(hash, item, goToItem, snapshotReport, snapshotPolicy, lastPaymentMethod, currentSearchKey); + getPayActionCallback(hash, item, goToItem, snapshotReport, snapshotPolicy, lastPaymentMethod, currentSearchKey, personalPolicyID); return; case CONST.SEARCH.ACTION_TYPES.APPROVE: if (isDelegateAccessRestricted) { @@ -174,14 +176,13 @@ function getLastPolicyPaymentMethod( lastPaymentMethods: OnyxEntry, reportType: keyof LastPaymentMethodType = 'lastUsed', isIOUReport?: boolean, + personalPolicyID?: string, ): ValueOf | undefined { if (!policyID) { return undefined; } - const personalPolicy = getPersonalPolicy(); - - const lastPolicyPaymentMethod = lastPaymentMethods?.[policyID] ?? (isIOUReport && personalPolicy ? lastPaymentMethods?.[personalPolicy.id] : undefined); + const lastPolicyPaymentMethod = lastPaymentMethods?.[policyID] ?? (isIOUReport && personalPolicyID ? lastPaymentMethods?.[personalPolicyID] : undefined); const result = typeof lastPolicyPaymentMethod === 'string' ? lastPolicyPaymentMethod : (lastPolicyPaymentMethod?.[reportType] as PaymentInformation)?.name; return result as ValueOf | undefined; @@ -211,8 +212,9 @@ function getPayActionCallback( snapshotPolicy: Policy, lastPaymentMethod: OnyxEntry, currentSearchKey?: SearchKey, + personalPolicyID?: string, ) { - const lastPolicyPaymentMethod = getLastPolicyPaymentMethod(item.policyID, lastPaymentMethod, getReportType(item.reportID)); + const lastPolicyPaymentMethod = getLastPolicyPaymentMethod(item.policyID, lastPaymentMethod, getReportType(item.reportID), undefined, personalPolicyID); if (!lastPolicyPaymentMethod || !Object.values(CONST.IOU.PAYMENT_TYPE).includes(lastPolicyPaymentMethod)) { goToItem(); @@ -1033,14 +1035,22 @@ function shouldShowBulkOptionForRemainingTransactions(selectedTransactions: Sele /** * Checks if the current selected reports/transactions are eligible for bulk pay. */ -function getPayOption(selectedReports: SelectedReports[], selectedTransactions: SelectedTransactions, lastPaymentMethods: OnyxEntry, selectedReportIDs: string[]) { +function getPayOption( + selectedReports: SelectedReports[], + selectedTransactions: SelectedTransactions, + lastPaymentMethods: OnyxEntry, + selectedReportIDs: string[], + personalPolicyID?: string, +) { const transactionKeys = Object.keys(selectedTransactions ?? {}); const firstTransaction = selectedTransactions?.[transactionKeys.at(0) ?? '']; const firstReport = selectedReports.at(0); const hasLastPaymentMethod = selectedReports.length > 0 - ? selectedReports.every((report) => !!getLastPolicyPaymentMethod(report.policyID, lastPaymentMethods)) - : transactionKeys.every((transactionIDKey) => !!getLastPolicyPaymentMethod(selectedTransactions[transactionIDKey].policyID, lastPaymentMethods)); + ? selectedReports.every((report) => !!getLastPolicyPaymentMethod(report.policyID, lastPaymentMethods, 'lastUsed', undefined, personalPolicyID)) + : transactionKeys.every( + (transactionIDKey) => !!getLastPolicyPaymentMethod(selectedTransactions[transactionIDKey].policyID, lastPaymentMethods, 'lastUsed', undefined, personalPolicyID), + ); const shouldShowBulkPayOption = selectedReports.length > 0 diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 9d92f7eb951a8..08d1640f9ed28 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -626,7 +626,7 @@ function SearchPage({route}: SearchPageProps) { }, }); } - const {shouldEnableBulkPayOption, isFirstTimePayment} = getPayOption(selectedReports, selectedTransactions, lastPaymentMethods, selectedReportIDs); + const {shouldEnableBulkPayOption, isFirstTimePayment} = getPayOption(selectedReports, selectedTransactions, lastPaymentMethods, selectedReportIDs, personalPolicy?.id); const shouldShowPayOption = !isOffline && !isAnyTransactionOnHold && shouldEnableBulkPayOption; From 68dc280cfa7262242923df774d0d895fdcfd8488 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Tue, 6 Jan 2026 11:52:37 +0100 Subject: [PATCH 72/76] making personalPolicyID as required --- .../LHNOptionsList/LHNOptionsList.tsx | 1 + .../Search/TransactionListItem.tsx | 3 ++ src/libs/ReportSecondaryActionUtils.ts | 41 ++++++++++++------- src/libs/actions/Search.ts | 18 ++++---- src/pages/Search/SearchPage.tsx | 7 ++-- .../Search/handleActionButtonPressTest.ts | 2 + 6 files changed, 44 insertions(+), 28 deletions(-) diff --git a/src/components/LHNOptionsList/LHNOptionsList.tsx b/src/components/LHNOptionsList/LHNOptionsList.tsx index 4688166437ef9..ec38c19283ba1 100644 --- a/src/components/LHNOptionsList/LHNOptionsList.tsx +++ b/src/components/LHNOptionsList/LHNOptionsList.tsx @@ -301,6 +301,7 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio reports, reportNameValuePairs, reportActions, + reportMetadataCollection, isOffline, reportAttributes, policy, diff --git a/src/components/SelectionListWithSections/Search/TransactionListItem.tsx b/src/components/SelectionListWithSections/Search/TransactionListItem.tsx index baabe9edebb3f..ef398b51994a7 100644 --- a/src/components/SelectionListWithSections/Search/TransactionListItem.tsx +++ b/src/components/SelectionListWithSections/Search/TransactionListItem.tsx @@ -66,6 +66,7 @@ function TransactionListItem({ return (snapshot?.data?.[`${ONYXKEYS.COLLECTION.POLICY}${transactionItem.policyID}`] ?? {}) as Policy; }, [snapshot, transactionItem.policyID]); const [lastPaymentMethod] = useOnyx(`${ONYXKEYS.NVP_LAST_PAYMENT_METHOD}`, {canBeMissing: true}); + const [personalPolicyID] = useOnyx(ONYXKEYS.PERSONAL_POLICY_ID, {canBeMissing: true}); const [parentReport] = originalUseOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(transactionItem.reportID)}`, {canBeMissing: true}); const [transactionThreadReport] = originalUseOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionItem?.reportAction?.childReportID}`, {canBeMissing: true}); @@ -142,6 +143,7 @@ function TransactionListItem({ isDEWBetaEnabled, isDelegateAccessRestricted, onDelegateAccessRestricted: showDelegateNoAccessModal, + personalPolicyID, }); }, [ currentSearchHash, @@ -150,6 +152,7 @@ function TransactionListItem({ snapshotReport, snapshotPolicy, lastPaymentMethod, + personalPolicyID, currentSearchKey, onSelectRow, item, diff --git a/src/libs/ReportSecondaryActionUtils.ts b/src/libs/ReportSecondaryActionUtils.ts index f4c74dcfb13d2..b397963f1c2a5 100644 --- a/src/libs/ReportSecondaryActionUtils.ts +++ b/src/libs/ReportSecondaryActionUtils.ts @@ -143,20 +143,31 @@ function isSplitAction(report: OnyxEntry, reportTransactions: Array, +function isSubmitAction({ + report, + reportTransactions, + policy, + reportNameValuePairs, + reportActions, + reportMetadata, isChatReportArchived = false, - primaryAction?: ValueOf | '', - violations?: OnyxCollection, - currentUserEmail?: string, - currentUserAccountID?: number, -): boolean { + primaryAction, + violations, + currentUserEmail, + currentUserAccountID, +}: { + report: Report; + reportTransactions: Transaction[]; + policy?: Policy; + reportNameValuePairs?: ReportNameValuePairs; + reportActions?: ReportAction[]; + reportMetadata?: OnyxEntry; + isChatReportArchived?: boolean; + primaryAction?: ValueOf | ''; + violations?: OnyxCollection; + currentUserEmail?: string; + currentUserAccountID?: number; +}): boolean { if (isArchivedReport(reportNameValuePairs) || isChatReportArchived) { return false; } @@ -808,7 +819,7 @@ function getSecondaryReportActions({ }); if ( - isSubmitAction( + isSubmitAction({ report, reportTransactions, policy, @@ -820,7 +831,7 @@ function getSecondaryReportActions({ violations, currentUserEmail, currentUserAccountID, - ) + }) ) { options.push(CONST.REPORT.SECONDARY_ACTIONS.SUBMIT); } diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index be56a958d050a..0d102877c0631 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -84,7 +84,7 @@ type HandleActionButtonPressParams = { isDEWBetaEnabled?: boolean; isDelegateAccessRestricted?: boolean; onDelegateAccessRestricted?: () => void; - personalPolicyID?: string; + personalPolicyID: string | undefined; }; function handleActionButtonPress({ @@ -173,10 +173,10 @@ function getLastPolicyBankAccountID( function getLastPolicyPaymentMethod( policyID: string | undefined, + personalPolicyID: string | undefined, lastPaymentMethods: OnyxEntry, reportType: keyof LastPaymentMethodType = 'lastUsed', isIOUReport?: boolean, - personalPolicyID?: string, ): ValueOf | undefined { if (!policyID) { return undefined; @@ -211,10 +211,10 @@ function getPayActionCallback( snapshotReport: Report, snapshotPolicy: Policy, lastPaymentMethod: OnyxEntry, - currentSearchKey?: SearchKey, - personalPolicyID?: string, + currentSearchKey: SearchKey | undefined, + personalPolicyID: string | undefined, ) { - const lastPolicyPaymentMethod = getLastPolicyPaymentMethod(item.policyID, lastPaymentMethod, getReportType(item.reportID), undefined, personalPolicyID); + const lastPolicyPaymentMethod = getLastPolicyPaymentMethod(item.policyID, personalPolicyID, lastPaymentMethod, getReportType(item.reportID)); if (!lastPolicyPaymentMethod || !Object.values(CONST.IOU.PAYMENT_TYPE).includes(lastPolicyPaymentMethod)) { goToItem(); @@ -1040,17 +1040,15 @@ function getPayOption( selectedTransactions: SelectedTransactions, lastPaymentMethods: OnyxEntry, selectedReportIDs: string[], - personalPolicyID?: string, + personalPolicyID: string | undefined, ) { const transactionKeys = Object.keys(selectedTransactions ?? {}); const firstTransaction = selectedTransactions?.[transactionKeys.at(0) ?? '']; const firstReport = selectedReports.at(0); const hasLastPaymentMethod = selectedReports.length > 0 - ? selectedReports.every((report) => !!getLastPolicyPaymentMethod(report.policyID, lastPaymentMethods, 'lastUsed', undefined, personalPolicyID)) - : transactionKeys.every( - (transactionIDKey) => !!getLastPolicyPaymentMethod(selectedTransactions[transactionIDKey].policyID, lastPaymentMethods, 'lastUsed', undefined, personalPolicyID), - ); + ? selectedReports.every((report) => !!getLastPolicyPaymentMethod(report.policyID, personalPolicyID, lastPaymentMethods)) + : transactionKeys.every((transactionIDKey) => !!getLastPolicyPaymentMethod(selectedTransactions[transactionIDKey].policyID, personalPolicyID, lastPaymentMethods)); const shouldShowBulkPayOption = selectedReports.length > 0 diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 08d1640f9ed28..878602b8e5d82 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -288,7 +288,7 @@ function SearchPage({route}: SearchPageProps) { const isExpenseReport = isExpenseReportUtil(itemReportID); const isIOUReport = isIOUReportUtil(itemReportID); const reportType = getReportType(itemReportID); - const lastPolicyPaymentMethod = getLastPolicyPaymentMethod(itemPolicyID, lastPaymentMethods, reportType) ?? paymentMethod; + const lastPolicyPaymentMethod = getLastPolicyPaymentMethod(itemPolicyID, personalPolicy?.id, lastPaymentMethods, reportType, isIOUReport) ?? paymentMethod; if (!lastPolicyPaymentMethod) { Navigation.navigate( @@ -338,7 +338,7 @@ function SearchPage({route}: SearchPageProps) { return { reportID: report.reportID, amount: report.total, - paymentType: getLastPolicyPaymentMethod(report.policyID, lastPaymentMethods) ?? paymentMethod, + paymentType: getLastPolicyPaymentMethod(report.policyID, personalPolicy?.id, lastPaymentMethods, undefined, isIOUReportUtil(report.reportID)) ?? paymentMethod, ...(isInvoiceReport(report.reportID) ? getPayMoneyOnSearchInvoiceParams( report.policyID, @@ -352,7 +352,8 @@ function SearchPage({route}: SearchPageProps) { : Object.values(selectedTransactions).map((transaction) => ({ reportID: transaction.reportID, amount: transaction.amount, - paymentType: getLastPolicyPaymentMethod(transaction.policyID, lastPaymentMethods) ?? paymentMethod, + paymentType: + getLastPolicyPaymentMethod(transaction.policyID, personalPolicy?.id, lastPaymentMethods, undefined, isIOUReportUtil(transaction.reportID)) ?? paymentMethod, ...(isInvoiceReport(transaction.reportID) ? getPayMoneyOnSearchInvoiceParams( transaction.policyID, diff --git a/tests/unit/Search/handleActionButtonPressTest.ts b/tests/unit/Search/handleActionButtonPressTest.ts index 8df9686eeb078..8786fc8a41ef2 100644 --- a/tests/unit/Search/handleActionButtonPressTest.ts +++ b/tests/unit/Search/handleActionButtonPressTest.ts @@ -312,6 +312,7 @@ describe('handleActionButtonPress', () => { snapshotReport: snapshotReport as Report, snapshotPolicy: snapshotPolicy as Policy, lastPaymentMethod: mockLastPaymentMethod, + personalPolicyID: undefined, }); expect(goToItem).toHaveBeenCalledTimes(1); }); @@ -325,6 +326,7 @@ describe('handleActionButtonPress', () => { snapshotReport: snapshotReport as Report, snapshotPolicy: snapshotPolicy as Policy, lastPaymentMethod: mockLastPaymentMethod, + personalPolicyID: undefined, }); expect(goToItem).toHaveBeenCalledTimes(0); }); From 5c278a1214f16678d4c7d0c96d1b7d00a89a8cf0 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Tue, 6 Jan 2026 12:08:58 +0100 Subject: [PATCH 73/76] fixing ts --- src/components/SettlementButton/index.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/SettlementButton/index.tsx b/src/components/SettlementButton/index.tsx index 23947ad4688c6..3a3980662dad7 100644 --- a/src/components/SettlementButton/index.tsx +++ b/src/components/SettlementButton/index.tsx @@ -24,7 +24,7 @@ import {navigateToBankAccountRoute} from '@libs/actions/ReimbursementAccount'; import {getLastPolicyBankAccountID, getLastPolicyPaymentMethod} from '@libs/actions/Search'; import Navigation from '@libs/Navigation/Navigation'; import {formatPaymentMethods, getActivePaymentType} from '@libs/PaymentUtils'; -import {getActiveAdminWorkspaces, getPersonalPolicy, getPolicyEmployeeAccountIDs, isPaidGroupPolicy, isPolicyAdmin} from '@libs/PolicyUtils'; +import {getActiveAdminWorkspaces, getPolicyEmployeeAccountIDs, isPaidGroupPolicy, isPolicyAdmin} from '@libs/PolicyUtils'; import {hasRequestFromCurrentAccount} from '@libs/ReportActionsUtils'; import { doesReportBelongToWorkspace, @@ -113,14 +113,15 @@ function SettlementButton({ const hasActivatedWallet = ([CONST.WALLET.TIER_NAME.GOLD, CONST.WALLET.TIER_NAME.PLATINUM] as string[]).includes(userWallet?.tierName ?? ''); const paymentMethods = useSettlementButtonPaymentMethods(hasActivatedWallet, translate); const [lastPaymentMethods, lastPaymentMethodResult] = useOnyx(ONYXKEYS.NVP_LAST_PAYMENT_METHOD, {canBeMissing: true}); + const [personalPolicyID] = useOnyx(ONYXKEYS.PERSONAL_POLICY_ID, {canBeMissing: true}); const lastPaymentMethod = useMemo(() => { if (!iouReport?.type) { return; } - return getLastPolicyPaymentMethod(policyIDKey, lastPaymentMethods, iouReport?.type as keyof LastPaymentMethodType, isIOUReport(iouReport)); - }, [policyIDKey, iouReport, lastPaymentMethods]); + return getLastPolicyPaymentMethod(policyIDKey, personalPolicyID, lastPaymentMethods, iouReport?.type as keyof LastPaymentMethodType, isIOUReport(iouReport)); + }, [policyIDKey, iouReport, lastPaymentMethods, personalPolicyID]); const lastBankAccountID = getLastPolicyBankAccountID(policyIDKey, lastPaymentMethods, iouReport?.type as keyof LastPaymentMethodType); const [fundList] = useOnyx(ONYXKEYS.FUND_LIST, {canBeMissing: true}); @@ -131,8 +132,7 @@ function SettlementButton({ const activePolicy = usePolicy(activePolicyID); const activeAdminPolicies = getActiveAdminWorkspaces(policies, accountID.toString()).sort((a, b) => localeCompare(a.name || '', b.name || '')); const reportID = iouReport?.reportID; - // eslint-disable-next-line @typescript-eslint/no-deprecated - const personalPolicy = usePolicy(getPersonalPolicy()?.id); + const personalPolicy = usePolicy(personalPolicyID); const hasPreferredPaymentMethod = !!lastPaymentMethod; const isLoadingLastPaymentMethod = isLoadingOnyxValue(lastPaymentMethodResult); From f33388d1b958078ffc2fc04ef295418ea9b12d4c Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Tue, 6 Jan 2026 12:32:04 +0100 Subject: [PATCH 74/76] fixing ts --- tests/actions/IOUTest.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/actions/IOUTest.ts b/tests/actions/IOUTest.ts index 5ed720de412e5..4b13d81575f4f 100644 --- a/tests/actions/IOUTest.ts +++ b/tests/actions/IOUTest.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ + /* eslint-disable @typescript-eslint/no-unsafe-assignment */ import {renderHook, waitFor} from '@testing-library/react-native'; import {format} from 'date-fns'; @@ -6300,6 +6301,9 @@ describe('actions/IOU', () => { makeMeAdmin: true, policyName: 'Test Workspace with Dynamic External Workflow', policyID, + introSelected: undefined, + currentUserAccountIDParam: CARLOS_ACCOUNT_ID, + currentUserEmailParam: CARLOS_EMAIL, }); return waitForBatchedUpdates() .then(() => { From f0046ba489d4f1159da2ef7832a21c047e864b9f Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Tue, 6 Jan 2026 12:54:48 +0100 Subject: [PATCH 75/76] prettier fix --- tests/actions/IOUTest.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/actions/IOUTest.ts b/tests/actions/IOUTest.ts index 4b13d81575f4f..94273a8d19844 100644 --- a/tests/actions/IOUTest.ts +++ b/tests/actions/IOUTest.ts @@ -1,5 +1,4 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ - /* eslint-disable @typescript-eslint/no-unsafe-assignment */ import {renderHook, waitFor} from '@testing-library/react-native'; import {format} from 'date-fns'; From b4edff80613bafc9cac6a956d14c495b3d7e68fa Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Tue, 6 Jan 2026 14:57:03 +0100 Subject: [PATCH 76/76] refactor: remove usePersonalPolicy and use useOnyx directly in SearchPage for performance improvement --- src/pages/Search/SearchPage.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 878602b8e5d82..a405b7e1f6301 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -30,7 +30,6 @@ import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; -import usePersonalPolicy from '@hooks/usePersonalPolicy'; import usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; @@ -121,7 +120,8 @@ function SearchPage({route}: SearchPageProps) { const [newParentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${newReport?.parentReportID}`, {canBeMissing: true}); const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID, {canBeMissing: false}); const [activePolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`, {canBeMissing: true}); - const personalPolicy = usePersonalPolicy(); + const [personalPolicyID] = useOnyx(ONYXKEYS.PERSONAL_POLICY_ID, {canBeMissing: true}); + const [personalPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${personalPolicyID}`, {canBeMissing: true}); const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {canBeMissing: true}); const [integrationsExportTemplates] = useOnyx(ONYXKEYS.NVP_INTEGRATION_SERVER_EXPORT_TEMPLATES, {canBeMissing: true}); const [csvExportLayouts] = useOnyx(ONYXKEYS.NVP_CSV_EXPORT_LAYOUTS, {canBeMissing: true}); @@ -288,7 +288,7 @@ function SearchPage({route}: SearchPageProps) { const isExpenseReport = isExpenseReportUtil(itemReportID); const isIOUReport = isIOUReportUtil(itemReportID); const reportType = getReportType(itemReportID); - const lastPolicyPaymentMethod = getLastPolicyPaymentMethod(itemPolicyID, personalPolicy?.id, lastPaymentMethods, reportType, isIOUReport) ?? paymentMethod; + const lastPolicyPaymentMethod = getLastPolicyPaymentMethod(itemPolicyID, personalPolicyID, lastPaymentMethods, reportType, isIOUReport) ?? paymentMethod; if (!lastPolicyPaymentMethod) { Navigation.navigate( @@ -338,7 +338,7 @@ function SearchPage({route}: SearchPageProps) { return { reportID: report.reportID, amount: report.total, - paymentType: getLastPolicyPaymentMethod(report.policyID, personalPolicy?.id, lastPaymentMethods, undefined, isIOUReportUtil(report.reportID)) ?? paymentMethod, + paymentType: getLastPolicyPaymentMethod(report.policyID, personalPolicyID, lastPaymentMethods, undefined, isIOUReportUtil(report.reportID)) ?? paymentMethod, ...(isInvoiceReport(report.reportID) ? getPayMoneyOnSearchInvoiceParams( report.policyID, @@ -353,7 +353,7 @@ function SearchPage({route}: SearchPageProps) { reportID: transaction.reportID, amount: transaction.amount, paymentType: - getLastPolicyPaymentMethod(transaction.policyID, personalPolicy?.id, lastPaymentMethods, undefined, isIOUReportUtil(transaction.reportID)) ?? paymentMethod, + getLastPolicyPaymentMethod(transaction.policyID, personalPolicyID, lastPaymentMethods, undefined, isIOUReportUtil(transaction.reportID)) ?? paymentMethod, ...(isInvoiceReport(transaction.reportID) ? getPayMoneyOnSearchInvoiceParams( transaction.policyID, @@ -384,6 +384,7 @@ function SearchPage({route}: SearchPageProps) { policyIDsWithVBBA, isDelegateAccessRestricted, showDelegateNoAccessModal, + personalPolicyID, ], ); @@ -627,7 +628,7 @@ function SearchPage({route}: SearchPageProps) { }, }); } - const {shouldEnableBulkPayOption, isFirstTimePayment} = getPayOption(selectedReports, selectedTransactions, lastPaymentMethods, selectedReportIDs, personalPolicy?.id); + const {shouldEnableBulkPayOption, isFirstTimePayment} = getPayOption(selectedReports, selectedTransactions, lastPaymentMethods, selectedReportIDs, personalPolicyID); const shouldShowPayOption = !isOffline && !isAnyTransactionOnHold && shouldEnableBulkPayOption; @@ -875,6 +876,7 @@ function SearchPage({route}: SearchPageProps) { isDelegateAccessRestricted, showDelegateNoAccessModal, currentUserPersonalDetails?.accountID, + personalPolicyID, ]); const handleDeleteExpenses = () => {