diff --git a/src/CONST/index.ts b/src/CONST/index.ts index f989bf7b94610..9058d290d43ac 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -733,6 +733,7 @@ const CONST = { NO_OPTIMISTIC_TRANSACTION_THREADS: 'noOptimisticTransactionThreads', UBER_FOR_BUSINESS: 'uberForBusiness', CUSTOM_REPORT_NAMES: 'newExpensifyCustomReportNames', + NEW_DOT_DEW: 'newDotDEW', GPS_MILEAGE: 'gpsMileage', }, BUTTON_STATES: { @@ -1237,6 +1238,7 @@ const CONST = { CREATED: 'CREATED', DELETED_ACCOUNT: 'DELETEDACCOUNT', // Deprecated OldDot Action DELETED_TRANSACTION: 'DELETEDTRANSACTION', + DEW_SUBMIT_FAILED: 'DEWSUBMITFAILED', DISMISSED_VIOLATION: 'DISMISSEDVIOLATION', DONATION: 'DONATION', // Deprecated OldDot Action DYNAMIC_EXTERNAL_WORKFLOW_ROUTED: 'DYNAMICEXTERNALWORKFLOWROUTED', @@ -1568,6 +1570,7 @@ const CONST = { HOURGLASS: 'hourglass', CHECKMARK: 'checkmark', STOPWATCH: 'stopwatch', + DOT_INDICATOR: 'dotIndicator', }, ETA_KEY: { SHORTLY: 'shortly', @@ -3980,6 +3983,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/LHNOptionsList/LHNOptionsList.tsx b/src/components/LHNOptionsList/LHNOptionsList.tsx index 27c6ebf2daa55..ec38c19283ba1 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,7 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio policy: itemPolicy, isReportArchived: !!itemReportNameValuePairs?.private_isArchived, policyForMovingExpensesID, + reportMetadata: itemReportMetadata, }); const shouldShowRBRorGBRTooltip = firstReportIDWithGBRorRBR === reportID; @@ -298,6 +301,7 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio reports, reportNameValuePairs, reportActions, + reportMetadataCollection, isOffline, reportAttributes, policy, diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 6f4941ce60ecc..9196046957bff 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -45,17 +45,23 @@ import {getThreadReportIDsForTransactions, getTotalAmountForIOUReportPreviewButt import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import type {ReportsSplitNavigatorParamList, RightModalNavigatorParamList, SearchFullscreenNavigatorParamList} from '@libs/Navigation/types'; -import {buildOptimisticNextStepForPreventSelfApprovalsEnabled, buildOptimisticNextStepForStrictPolicyRuleViolations} from '@libs/NextStepUtils'; +import { + buildOptimisticNextStepForDEWOfflineSubmission, + 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'; -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 { changeMoneyRequestHoldStatus, generateReportID, getAddExpenseDropdownOptions, + getAllReportActionsErrorsAndReportActionThatRequiresAttention, getIntegrationExportIcon, getIntegrationNameFromExportMessage as getIntegrationNameFromExportMessageUtils, getNextApproverAccountID, @@ -243,6 +249,7 @@ function MoneyReportHeader({ 'ReceiptPlus', ] 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, localeCompare} = useLocalize(); const exportTemplates = useMemo( @@ -315,6 +322,7 @@ function MoneyReportHeader({ const [allTransactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, {canBeMissing: true}); const {isBetaEnabled} = usePermissions(); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); + const isDEWBetaEnabled = isBetaEnabled(CONST.BETAS.NEW_DOT_DEW); const hasViolations = hasViolationsReportUtils(moneyRequestReport?.reportID, allTransactionViolations, accountID, email ?? ''); const [exportModalStatus, setExportModalStatus] = useState(null); @@ -472,6 +480,22 @@ function MoneyReportHeader({ let optimisticNextStep = isBlockSubmitDueToPreventSelfApproval ? buildOptimisticNextStepForPreventSelfApprovalsEnabled() : nextStep; + // Check for DEW submit failed or pending - show appropriate next step + if (isDEWBetaEnabled && hasDynamicExternalWorkflow(policy) && moneyRequestReport?.statusNum === CONST.REPORT.STATUS_NUM.OPEN) { + 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(theme.danger); + } else if (isOffline && hasPendingDEWSubmit(reportMetadata, hasDynamicExternalWorkflow(policy))) { + optimisticNextStep = buildOptimisticNextStepForDEWOfflineSubmission(); + } + } + if (isBlockSubmitDueToStrictPolicyRules && isReportOwner(moneyRequestReport) && isOpenExpenseReport(moneyRequestReport)) { optimisticNextStep = buildOptimisticNextStepForStrictPolicyRuleViolations(); } @@ -744,6 +768,7 @@ function MoneyReportHeader({ policy, reportNameValuePairs, reportActions, + reportMetadata, isChatReportArchived, invoiceReceiverPolicy, isPaidAnimationRunning, @@ -761,6 +786,7 @@ function MoneyReportHeader({ policy, reportNameValuePairs, reportActions, + reportMetadata, isChatReportArchived, invoiceReceiverPolicy, currentUserLogin, @@ -891,7 +917,7 @@ function MoneyReportHeader({ if (!moneyRequestReport || shouldBlockSubmit) { return; } - if (hasDynamicExternalWorkflow(policy)) { + if (hasDynamicExternalWorkflow(policy) && !isDEWBetaEnabled) { showDWEModal(); return; } @@ -1052,6 +1078,7 @@ function MoneyReportHeader({ policy, reportNameValuePairs, reportActions, + reportMetadata, policies, isChatReportArchived, }); @@ -1066,6 +1093,7 @@ function MoneyReportHeader({ policy, reportNameValuePairs, reportActions, + reportMetadata, policies, isChatReportArchived, ]); @@ -1138,7 +1166,7 @@ function MoneyReportHeader({ if (!moneyRequestReport) { return; } - if (hasDynamicExternalWorkflow(policy)) { + if (hasDynamicExternalWorkflow(policy) && !isDEWBetaEnabled) { showDWEModal(); return; } diff --git a/src/components/MoneyReportHeaderStatusBar.tsx b/src/components/MoneyReportHeaderStatusBar.tsx index d57811d934736..a82ec6fba260f 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']); + const icons = useMemoizedLazyExpensifyIcons(['Hourglass', 'Checkmark', 'Stopwatch', 'DotIndicator']); 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], ); @@ -47,7 +48,7 @@ function MoneyReportHeaderStatusBar({nextStep}: MoneyReportHeaderStatusBarProps) src={(nextStep?.icon && iconMap?.[nextStep.icon]) ?? icons.Hourglass} height={variables.iconSizeSmall} width={variables.iconSizeSmall} - fill={theme.icon} + fill={nextStep?.iconFill ?? theme.icon} /> diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx index 422c836a17010..73ff38b44d69f 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 {hasPendingDEWSubmit} from '@libs/ReportActionsUtils'; import {getInvoicePayerName} from '@libs/ReportNameUtils'; import getReportPreviewAction from '@libs/ReportPreviewActionUtils'; import { @@ -125,6 +126,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); @@ -172,6 +174,7 @@ function MoneyRequestReportPreviewContent({ const {isBetaEnabled} = usePermissions(); const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, {canBeMissing: true}); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); + const isDEWBetaEnabled = isBetaEnabled(CONST.BETAS.NEW_DOT_DEW); const hasViolations = hasViolationsReportUtils(iouReport?.reportID, transactionViolations, currentUserAccountID, currentUserEmail); const getCanIOUBePaid = useCallback( @@ -286,7 +289,7 @@ function MoneyRequestReportPreviewContent({ ); const confirmApproval = () => { - if (hasDynamicExternalWorkflow(policy)) { + if (hasDynamicExternalWorkflow(policy) && !isDEWBetaEnabled) { setIsDEWModalVisible(true); return; } @@ -559,6 +562,8 @@ function MoneyRequestReportPreviewContent({ Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(iouReportID, undefined, undefined, Navigation.getActiveRoute())); }, [iouReportID]); + const isDEWPolicy = hasDynamicExternalWorkflow(policy); + const isDEWSubmitPending = hasPendingDEWSubmit(iouReportMetadata, isDEWPolicy); const reportPreviewAction = useMemo(() => { return getReportPreviewAction({ isReportArchived: isIouReportArchived || isChatReportArchived, @@ -571,6 +576,7 @@ function MoneyRequestReportPreviewContent({ isPaidAnimationRunning, isApprovedAnimationRunning, isSubmittingAnimationRunning, + isDEWSubmitPending, violationsData: transactionViolations, }); }, [ @@ -586,6 +592,7 @@ function MoneyRequestReportPreviewContent({ isApprovedAnimationRunning, isSubmittingAnimationRunning, transactionViolations, + isDEWSubmitPending, ]); const addExpenseDropdownOptions = useMemo( @@ -602,7 +609,7 @@ function MoneyRequestReportPreviewContent({ success={isWaitingForSubmissionFromCurrentUser} text={translate('common.submit')} onPress={() => { - if (hasDynamicExternalWorkflow(policy)) { + if (hasDynamicExternalWorkflow(policy) && !isDEWBetaEnabled) { setIsDEWModalVisible(true); return; } diff --git a/src/components/ReportActionItem/TransactionPreview/TransactionPreviewContent.tsx b/src/components/ReportActionItem/TransactionPreview/TransactionPreviewContent.tsx index 81e71bdefebda..03ac28b53a6ff 100644 --- a/src/components/ReportActionItem/TransactionPreview/TransactionPreviewContent.tsx +++ b/src/components/ReportActionItem/TransactionPreview/TransactionPreviewContent.tsx @@ -87,6 +87,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, @@ -105,10 +107,11 @@ function TransactionPreviewContent({ ...transactionPreviewCommonArguments, areThereDuplicates, isReportAPolicyExpenseChat, - currentUserEmail: currentUserDetails.email ?? '', - currentUserAccountID: currentUserDetails.accountID, + currentUserEmail, + currentUserAccountID, + reportActions, }), - [areThereDuplicates, transactionPreviewCommonArguments, isReportAPolicyExpenseChat, currentUserDetails.email, currentUserDetails.accountID], + [areThereDuplicates, transactionPreviewCommonArguments, isReportAPolicyExpenseChat, currentUserEmail, currentUserAccountID, reportActions], ); const {shouldShowRBR, shouldShowMerchant, shouldShowSplitShare, shouldShowTag, shouldShowCategory, shouldShowSkeleton, shouldShowDescription} = conditionals; @@ -126,11 +129,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/Search/SearchList/index.tsx b/src/components/Search/SearchList/index.tsx index 24e9c3e1df46a..ec02f483f0cca 100644 --- a/src/components/Search/SearchList/index.tsx +++ b/src/components/Search/SearchList/index.tsx @@ -126,6 +126,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; + /** Selected transactions for determining isSelected state */ selectedTransactions: SelectedTransactions; @@ -180,6 +183,7 @@ function SearchList({ violations, customCardNames, onDEWModalOpen, + isDEWBetaEnabled, selectedTransactions, ref, }: SearchListProps) { @@ -404,6 +408,7 @@ function SearchList({ groupBy={groupBy} searchType={type} onDEWModalOpen={onDEWModalOpen} + isDEWBetaEnabled={isDEWBetaEnabled} userWalletTierName={userWalletTierName} isUserValidated={isUserValidated} personalDetails={personalDetails} @@ -446,6 +451,7 @@ function SearchList({ isOffline, violations, onDEWModalOpen, + isDEWBetaEnabled, customCardNames, ], ); diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 88334a09df5c2..61a49e95f82c5 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'; @@ -205,6 +206,8 @@ function Search({ const {shouldUseNarrowLayout} = useResponsiveLayout(); const styles = useThemeStyles(); const [isDEWModalVisible, setIsDEWModalVisible] = useState(false); + const {isBetaEnabled} = usePermissions(); + const isDEWBetaEnabled = isBetaEnabled(CONST.BETAS.NEW_DOT_DEW); const handleDEWModalOpen = useCallback(() => { if (onDEWModalOpen) { @@ -1045,6 +1048,7 @@ function Search({ shouldPreventLongPressRow={isChat || isTask} isFocused={isFocused} onDEWModalOpen={handleDEWModalOpen} + isDEWBetaEnabled={isDEWBetaEnabled} SearchTableHeader={ !shouldShowTableHeader ? undefined : ( diff --git a/src/components/SelectionListWithSections/Search/ExpenseReportListItem.tsx b/src/components/SelectionListWithSections/Search/ExpenseReportListItem.tsx index 3f6da3d906452..0242a3aad7074 100644 --- a/src/components/SelectionListWithSections/Search/ExpenseReportListItem.tsx +++ b/src/components/SelectionListWithSections/Search/ExpenseReportListItem.tsx @@ -34,6 +34,7 @@ function ExpenseReportListItem({ shouldSyncFocus, onCheckboxPress, onDEWModalOpen, + isDEWBetaEnabled, }: ExpenseReportListItemProps) { const reportItem = item as unknown as ExpenseReportListItemType; const styles = useThemeStyles(); @@ -42,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']); @@ -64,18 +66,20 @@ 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, currentSearchKey, onDEWModalOpen, + isDEWBetaEnabled, isDelegateAccessRestricted, - showDelegateNoAccessModal, - ); + onDelegateAccessRestricted: showDelegateNoAccessModal, + personalPolicyID, + }); }, [ currentSearchHash, reportItem, @@ -83,8 +87,10 @@ function ExpenseReportListItem({ snapshotReport, snapshotPolicy, lastPaymentMethod, + personalPolicyID, currentSearchKey, onDEWModalOpen, + isDEWBetaEnabled, isDelegateAccessRestricted, showDelegateNoAccessModal, ]); diff --git a/src/components/SelectionListWithSections/Search/ReportListItemHeader.tsx b/src/components/SelectionListWithSections/Search/ReportListItemHeader.tsx index c323bf2fdf5dd..e013f656fe85b 100644 --- a/src/components/SelectionListWithSections/Search/ReportListItemHeader.tsx +++ b/src/components/SelectionListWithSections/Search/ReportListItemHeader.tsx @@ -1,6 +1,6 @@ import React, {useContext, useMemo} from 'react'; -import {View} from 'react-native'; import type {ColorValue} from 'react-native'; +import {View} from 'react-native'; import Checkbox from '@components/Checkbox'; import {DelegateNoAccessContext} from '@components/DelegateNoAccessModalProvider'; import Icon from '@components/Icon'; @@ -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 = { @@ -112,21 +115,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 ( @@ -209,6 +206,7 @@ function ReportListItemHeader({ isExpanded, isHovered, onDEWModalOpen, + isDEWBetaEnabled, }: ReportListItemHeaderProps) { const StyleUtils = useStyleUtils(); const styles = useThemeStyles(); @@ -216,6 +214,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}); @@ -231,18 +230,20 @@ 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, currentSearchKey, onDEWModalOpen, + isDEWBetaEnabled, isDelegateAccessRestricted, - showDelegateNoAccessModal, - ); + onDelegateAccessRestricted: showDelegateNoAccessModal, + personalPolicyID, + }); }; return !isLargeScreenWidth ? ( diff --git a/src/components/SelectionListWithSections/Search/TransactionGroupListItem.tsx b/src/components/SelectionListWithSections/Search/TransactionGroupListItem.tsx index 0bf7121eabf31..ed015ba4491de 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(); @@ -291,6 +292,7 @@ function TransactionGroupListItem({ isIndeterminate={isIndeterminate} isHovered={hovered} onDEWModalOpen={onDEWModalOpen} + isDEWBetaEnabled={isDEWBetaEnabled} onDownArrowClick={onExpandIconPress} isExpanded={isExpanded} /> @@ -311,12 +313,13 @@ function TransactionGroupListItem({ canSelectMultiple, isSelectAllChecked, isIndeterminate, - onExpandIconPress, + onDEWModalOpen, + isDEWBetaEnabled, + groupBy, isExpanded, + onExpandIconPress, isFocused, searchType, - groupBy, - onDEWModalOpen, onSelectRow, transactionPreviewData, ], diff --git a/src/components/SelectionListWithSections/Search/TransactionListItem.tsx b/src/components/SelectionListWithSections/Search/TransactionListItem.tsx index a11af3b109b8e..ef398b51994a7 100644 --- a/src/components/SelectionListWithSections/Search/TransactionListItem.tsx +++ b/src/components/SelectionListWithSections/Search/TransactionListItem.tsx @@ -47,6 +47,7 @@ function TransactionListItem({ violations, customCardNames, onDEWModalOpen, + isDEWBetaEnabled, }: TransactionListItemProps) { const transactionItem = item as unknown as TransactionListItemType; const styles = useThemeStyles(); @@ -65,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}); @@ -129,18 +131,20 @@ 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, currentSearchKey, onDEWModalOpen, + isDEWBetaEnabled, isDelegateAccessRestricted, - showDelegateNoAccessModal, - ); + onDelegateAccessRestricted: showDelegateNoAccessModal, + personalPolicyID, + }); }, [ currentSearchHash, transactionItem, @@ -148,10 +152,12 @@ function TransactionListItem({ snapshotReport, snapshotPolicy, lastPaymentMethod, + personalPolicyID, currentSearchKey, onSelectRow, item, onDEWModalOpen, + isDEWBetaEnabled, isDelegateAccessRestricted, showDelegateNoAccessModal, ]); diff --git a/src/components/SelectionListWithSections/types.ts b/src/components/SelectionListWithSections/types.ts index 75666dbae2ccb..da9f391d2117e 100644 --- a/src/components/SelectionListWithSections/types.ts +++ b/src/components/SelectionListWithSections/types.ts @@ -635,6 +635,8 @@ type TransactionListItemProps = ListItemProps & { customCardNames?: Record; /** Callback to fire when DEW modal should be opened */ onDEWModalOpen?: () => void; + /** Whether the DEW beta flag is enabled */ + isDEWBetaEnabled?: boolean; }; type TaskListItemProps = ListItemProps & { @@ -657,6 +659,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 & { @@ -669,6 +674,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/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); diff --git a/src/languages/de.ts b/src/languages/de.ts index b93c917ca09b6..3bf6eb8ebcb8c 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1263,6 +1263,7 @@ const translations: TranslationDeepObject = { expenseAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `${formattedAmount}${comment ? `für ${comment}` : ''}`, submitted: ({memo}: SubmittedWithMemoParams) => `Eingereicht${memo ? `, mit dem Hinweis ${memo}` : ''}`, automaticallySubmitted: `eingereicht über verspätete Einreichungen`, + queuedToSubmitViaDEW: 'in die Warteschlange gestellt zur Einreichung über benutzerdefinierten Genehmigungsworkflow', trackedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `Verfolgung von ${formattedAmount}${comment ? `für ${comment}` : ''}`, splitAmount: ({amount}: SplitAmountParams) => `Split ${amount}`, didSplitAmount: ({formattedAmount, comment}: DidSplitAmountMessageParams) => `aufteilen ${formattedAmount}${comment ? `für ${comment}` : ''}`, diff --git a/src/languages/en.ts b/src/languages/en.ts index 244ae208ff533..fc3b9e4cbd34a 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1241,6 +1241,7 @@ 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', 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 11e58e87a6ebc..3a370bfc8e2c0 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -947,6 +947,7 @@ 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', 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 aa218ac57f6e3..db35ff1712160 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1263,6 +1263,7 @@ const translations: TranslationDeepObject = { expenseAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `${formattedAmount}${comment ? `pour ${comment}` : ''}`, 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}` : ''}`, diff --git a/src/languages/it.ts b/src/languages/it.ts index 16b16ef893290..ca5530401bcae 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1258,6 +1258,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", trackedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `monitoraggio ${formattedAmount}${comment ? `per ${comment}` : ''}`, splitAmount: ({amount}: SplitAmountParams) => `dividi ${amount}`, didSplitAmount: ({formattedAmount, comment}: DidSplitAmountMessageParams) => `dividi ${formattedAmount}${comment ? `per ${comment}` : ''}`, diff --git a/src/languages/ja.ts b/src/languages/ja.ts index f5dbc3f0e7495..04ae3d174601f 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1260,6 +1260,7 @@ const translations: TranslationDeepObject = { expenseAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `${formattedAmount}${comment ? `${comment} 用` : ''}`, 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} 用` : ''}`, diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 33ce64f79ed87..06e93f0ab1454 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1258,6 +1258,7 @@ const translations: TranslationDeepObject = { expenseAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `${formattedAmount}${comment ? `voor ${comment}` : ''}`, submitted: ({memo}: SubmittedWithMemoParams) => `ingediend${memo ? `, met de melding ${memo}` : ''}`, automaticallySubmitted: `ingediend via indiening uitstellen`, + queuedToSubmitViaDEW: 'in wachtrij geplaatst om in te dienen via aangepaste goedkeuringswerkstroom', trackedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `bijhouden ${formattedAmount}${comment ? `voor ${comment}` : ''}`, splitAmount: ({amount}: SplitAmountParams) => `${amount} splits`, didSplitAmount: ({formattedAmount, comment}: DidSplitAmountMessageParams) => `splitsen ${formattedAmount}${comment ? `voor ${comment}` : ''}`, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index a80abc7ad5c7b..e55050b7811cf 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1257,6 +1257,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}` : ''}`, diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 78ef868f8262c..66f72484e6560 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1257,6 +1257,7 @@ const translations: TranslationDeepObject = { expenseAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `${formattedAmount}${comment ? `para ${comment}` : ''}`, submitted: ({memo}: SubmittedWithMemoParams) => `enviado${memo ? `, dizendo ${memo}` : ''}`, automaticallySubmitted: `enviado via atrasar envios`, + queuedToSubmitViaDEW: 'enfileirado para envio via fluxo de aprovação personalizado', trackedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `rastreamento de ${formattedAmount}${comment ? `para ${comment}` : ''}`, splitAmount: ({amount}: SplitAmountParams) => `dividir ${amount}`, didSplitAmount: ({formattedAmount, comment}: DidSplitAmountMessageParams) => `dividir ${formattedAmount}${comment ? `para ${comment}` : ''}`, diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index b47a5708eff4e..d7e82bb95e7b5 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1240,6 +1240,7 @@ const translations: TranslationDeepObject = { expenseAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `${formattedAmount}${comment ? `为 ${comment}` : ''}`, 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}` : ''}`, diff --git a/src/libs/NextStepUtils.ts b/src/libs/NextStepUtils.ts index eccad5b3b9d2f..7bbc345982335 100644 --- a/src/libs/NextStepUtils.ts +++ b/src/libs/NextStepUtils.ts @@ -332,6 +332,36 @@ function buildOptimisticNextStepForStrictPolicyRuleViolations() { return optimisticNextStep; } +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.", + type: 'alert-text', + }, + ], + }; + + 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 @@ -703,6 +733,8 @@ export { parseMessage, buildOptimisticNextStepForPreventSelfApprovalsEnabled, buildOptimisticNextStepForStrictPolicyRuleViolations, + buildOptimisticNextStepForDynamicExternalWorkflowError, + buildOptimisticNextStepForDEWOfflineSubmission, // eslint-disable-next-line @typescript-eslint/no-deprecated buildNextStepNew, }; diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 5ffeeae428bcb..9686f4de3917e 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 { @@ -54,6 +55,7 @@ import { getSortedReportActions, getTravelUpdateMessage, getUpdateRoomDescriptionMessage, + hasPendingDEWSubmit, isActionableAddPaymentCard, isActionableJoinRequest, isActionableMentionWhisper, @@ -62,6 +64,7 @@ import { isClosedAction, isCreatedTaskReportAction, isDeletedParentAction, + isDynamicExternalWorkflowSubmitFailedAction, isInviteOrRemovedAction, isMarkAsClosedAction, isModifiedExpenseAction, @@ -155,6 +158,7 @@ import type { ReportAction, ReportActions, ReportAttributesDerivedValue, + ReportMetadata, ReportNameValuePairs, } from '@src/types/onyx'; import type {Attendee, Participant} from '@src/types/onyx/IOU'; @@ -593,6 +597,7 @@ function getLastMessageTextForReport({ policy, isReportArchived = false, policyForMovingExpensesID, + reportMetadata, }: { report: OnyxEntry; lastActorDetails: Partial | null; @@ -601,6 +606,7 @@ function getLastMessageTextForReport({ policy?: OnyxEntry; isReportArchived?: boolean; policyForMovingExpensesID?: string; + reportMetadata?: OnyxEntry; }): string { const reportID = report?.reportID; const lastReportAction = reportID ? lastVisibleReportActions[reportID] : undefined; @@ -700,9 +706,15 @@ function getLastMessageTextForReport({ isMarkAsClosedAction(lastReportAction) ) { 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 (hasPendingDEWSubmit(reportMetadata, isDEWPolicy) && isPendingAdd) { + // 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}); @@ -716,6 +728,9 @@ function getLastMessageTextForReport({ // eslint-disable-next-line @typescript-eslint/no-deprecated lastMessageTextFromReport = translateLocal('iou.approvedMessage'); } + } else if (isDynamicExternalWorkflowSubmitFailedAction(lastReportAction)) { + // eslint-disable-next-line @typescript-eslint/no-deprecated + 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/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index e842f03311c23..ad9859dae4481 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -13,7 +13,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, PersonalDetails, Policy, PrivatePersonalDetails, ReportNameValuePairs} from '@src/types/onyx'; +import type {Card, OnyxInputOrEntry, OriginalMessageIOU, PersonalDetails, Policy, PrivatePersonalDetails, ReportMetadata, ReportNameValuePairs} from '@src/types/onyx'; import type {JoinWorkspaceResolution, OriginalMessageChangeLog, OriginalMessageExportIntegration, OriginalMessageUnreportedTransaction} from '@src/types/onyx/OriginalMessage'; import type {PolicyReportFieldType} from '@src/types/onyx/Policy'; import type Report from '@src/types/onyx/Report'; @@ -257,6 +257,56 @@ 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 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; +} + +/** + * Checks if there's a pending DEW submission in progress. + * Uses reportMetadata.pendingExpenseAction which is set during submit and cleared on success/failure. + */ +function hasPendingDEWSubmit(reportMetadata: OnyxEntry, isDEWPolicy: boolean): boolean { + if (!isDEWPolicy) { + return false; + } + return reportMetadata?.pendingExpenseAction === CONST.EXPENSE_PENDING_ACTION.SUBMIT; +} + function isDynamicExternalWorkflowForwardedAction(reportAction: OnyxInputOrEntry): reportAction is ReportAction { return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.FORWARDED) && getOriginalMessage(reportAction)?.workflow === CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL; } @@ -3585,6 +3635,9 @@ export { isDynamicExternalWorkflowForwardedAction, isUnapprovedAction, isForwardedAction, + isDynamicExternalWorkflowSubmitFailedAction, + getMostRecentActiveDEWSubmitFailedAction, + hasPendingDEWSubmit, isWhisperActionTargetedToOthers, isTagModificationAction, isIOUActionMatchingTransactionList, diff --git a/src/libs/ReportPreviewActionUtils.ts b/src/libs/ReportPreviewActionUtils.ts index fdd16876cf9bf..5199b2bcf7568 100644 --- a/src/libs/ReportPreviewActionUtils.ts +++ b/src/libs/ReportPreviewActionUtils.ts @@ -176,6 +176,7 @@ function getReportPreviewAction({ isPaidAnimationRunning, isApprovedAnimationRunning, isSubmittingAnimationRunning, + isDEWSubmitPending, violationsData, }: { isReportArchived: boolean; @@ -188,6 +189,7 @@ function getReportPreviewAction({ isPaidAnimationRunning?: boolean; isApprovedAnimationRunning?: boolean; isSubmittingAnimationRunning?: boolean; + isDEWSubmitPending?: boolean; violationsData?: OnyxCollection; }): ValueOf { if (!report) { @@ -207,6 +209,10 @@ function getReportPreviewAction({ return CONST.REPORT.REPORT_PREVIEW_ACTIONS.ADD_EXPENSE; } + if (isDEWSubmitPending && isOpenReport(report)) { + return CONST.REPORT.REPORT_PREVIEW_ACTIONS.VIEW; + } + if (canSubmit(report, isReportArchived, currentUserAccountID, currentUserEmail, violationsData, policy, transactions)) { return CONST.REPORT.REPORT_PREVIEW_ACTIONS.SUBMIT; } diff --git a/src/libs/ReportPrimaryActionUtils.ts b/src/libs/ReportPrimaryActionUtils.ts index 56f7bc55baa3a..f1bbd576cdbfe 100644 --- a/src/libs/ReportPrimaryActionUtils.ts +++ b/src/libs/ReportPrimaryActionUtils.ts @@ -2,18 +2,19 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -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 { 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, hasPendingDEWSubmit, isMoneyRequestAction} from './ReportActionsUtils'; import { canAddTransaction as canAddTransactionUtil, canHoldUnholdReportAction, @@ -59,6 +60,7 @@ type GetReportPrimaryActionParams = { policy?: Policy; reportNameValuePairs?: ReportNameValuePairs; reportActions?: ReportAction[]; + reportMetadata?: OnyxEntry; isChatReportArchived: boolean; invoiceReceiverPolicy?: Policy; isPaidAnimationRunning?: boolean; @@ -85,6 +87,7 @@ function isSubmitAction( violations?: OnyxCollection, currentUserEmail?: string, currentUserAccountID?: number, + reportMetadata?: OnyxEntry, ) { if (isArchivedReport(reportNameValuePairs)) { return false; @@ -93,6 +96,10 @@ function isSubmitAction( const isExpenseReport = isExpenseReportUtils(report); const isReportSubmitter = isCurrentUserSubmitter(report); const isOpenReport = isOpenReportUtils(report); + + if (hasPendingDEWSubmit(reportMetadata, hasDynamicExternalWorkflow(policy))) { + return false; + } const transactionAreComplete = reportTransactions.every((transaction) => transaction.amount !== 0 || transaction.modifiedAmount !== 0); if (reportTransactions.length > 0 && reportTransactions.every((transaction) => isPending(transaction))) { @@ -399,6 +406,7 @@ function getReportPrimaryAction(params: GetReportPrimaryActionParams): ValueOf, reportTransactions: Array | '', - 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; } + if (hasPendingDEWSubmit(reportMetadata, hasDynamicExternalWorkflow(policy))) { + return false; + } + const transactionAreComplete = reportTransactions.every((transaction) => transaction.amount !== 0 || transaction.modifiedAmount !== 0); if (!transactionAreComplete) { @@ -742,6 +768,7 @@ function getSecondaryReportActions({ policy, reportNameValuePairs, reportActions, + reportMetadata, policies, isChatReportArchived = false, }: { @@ -755,6 +782,7 @@ function getSecondaryReportActions({ policy?: Policy; reportNameValuePairs?: ReportNameValuePairs; reportActions?: ReportAction[]; + reportMetadata?: OnyxEntry; policies?: OnyxCollection; canUseNewDotSplits?: boolean; isChatReportArchived?: boolean; @@ -786,10 +814,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/ReportUtils.ts b/src/libs/ReportUtils.ts index 0fa92bf882da1..27dd0c207cc50 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -190,6 +190,7 @@ import { getLastVisibleMessage as getLastVisibleMessageActionUtils, getLastVisibleMessage as getLastVisibleMessageReportActionsUtils, getMessageOfOldDotReportAction, + getMostRecentActiveDEWSubmitFailedAction, getNumberOfMoneyRequests, getOneTransactionThreadReportID, getOriginalMessage, @@ -233,6 +234,7 @@ import { isCurrentActionUnread, isDeletedAction, isDeletedParentAction, + isDynamicExternalWorkflowSubmitFailedAction, isExportIntegrationAction, isIntegrationMessageAction, isMarkAsClosedAction, @@ -253,6 +255,7 @@ import { isRoomChangeLogAction, isSentMoneyReportAction, isSplitBillAction as isSplitBillReportAction, + isSubmittedAction, isTagModificationAction, isThreadParentMessage, isTrackExpenseAction, @@ -9224,6 +9227,14 @@ function getAllReportActionsErrorsAndReportActionThatRequiresAttention( reportAction = getReportActionWithSmartscanError(reportActionsArray); } + if (!isReportArchived && report?.statusNum === CONST.REPORT.STATUS_NUM.OPEN) { + const mostRecentActiveDEWAction = getMostRecentActiveDEWSubmitFailedAction(reportActionsArray); + if (mostRecentActiveDEWAction) { + reportActionErrors.dewSubmitFailed = getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericCreateFailureMessage'); + reportAction = mostRecentActiveDEWAction; + } + } + return { errors: reportActionErrors, reportAction, @@ -12540,7 +12551,10 @@ 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) || isSubmittedAction(action), + ); return [reportId, filteredActions]; }), ); diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 79f778e19f74e..30eccb18852e9 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -1216,6 +1216,7 @@ function getTransactionsSections( currentUserEmail: string, formatPhoneNumber: LocaleContextProps['formatPhoneNumber'], isActionLoadingSet: ReadonlySet | undefined, + reportActions: Record = {}, ): [TransactionListItemType[], number] { const shouldShowMerchant = getShouldShowMerchant(data); const {shouldShowYearCreated, shouldShowYearSubmitted, shouldShowYearApproved, shouldShowYearPosted, shouldShowYearExported} = shouldShowYear(data); @@ -1278,7 +1279,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, @@ -2064,7 +2066,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/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/index.ts b/src/libs/actions/IOU/index.ts index 286b4941b2721..e3f9734f8c9f4 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -68,6 +68,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'; @@ -87,6 +88,7 @@ import { getPolicy, getSubmitToAccountID, hasDependentTags, + hasDynamicExternalWorkflow, isControlPolicy, isDelayedSubmissionEnabled, isPaidGroupPolicy, @@ -10634,34 +10636,43 @@ function submitReport( adminAccountID, 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 - // 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 + : // eslint-disable-next-line @typescript-eslint/no-deprecated + 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); - const optimisticData: OnyxUpdate[] = [ - { + const optimisticData: OnyxUpdate[] = []; + + if (shouldAddOptimisticSubmitAction) { + optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, value: { @@ -10670,44 +10681,64 @@ function submitReport( pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, }, }, - }, - !isSubmitAndClosePolicy - ? { - onyxMethod: Onyx.METHOD.MERGE, - 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, - nextStep: optimisticNextStep, - pendingFields: { - nextStep: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, - }, - }, - } - : { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, - value: { - ...expenseReport, - stateNum: CONST.REPORT.STATE_NUM.APPROVED, - statusNum: CONST.REPORT.STATUS_NUM.CLOSED, - nextStep: optimisticNextStep, - 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 (!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, + }, + }), + }, + }); + } 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({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport.reportID}`, + value: optimisticNextStepDeprecated, + }); + } if (parentReport?.reportID) { optimisticData.push({ @@ -10722,54 +10753,110 @@ function submitReport( }); } - const successData: Array> = []; - 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}`, - value: { - [optimisticSubmittedReportAction.reportActionID]: { - pendingAction: null, + if (isDEWPolicy) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${expenseReport.reportID}`, + value: { + pendingExpenseAction: CONST.EXPENSE_PENDING_ACTION.SUBMIT, }, - }, - }); + }); + } - const failureData: Array> = [ - { + const successData: Array> = []; + if (!isDEWPolicy) { + successData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, value: { - statusNum: CONST.REPORT.STATUS_NUM.OPEN, - stateNum: CONST.REPORT.STATE_NUM.OPEN, - nextStep: expenseReport.nextStep ?? null, pendingFields: { nextStep: 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({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${expenseReport.reportID}`, + value: { + pendingExpenseAction: null, + }, + }); + } + + const failureData: Array< + OnyxUpdate + > = [ { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport.reportID}`, - value: expenseReportCurrentNextStepDeprecated ?? null, + key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, + value: { + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + ...(isDEWPolicy + ? {} + : { + nextStep: expenseReport.nextStep ?? null, + pendingFields: { + nextStep: null, + }, + }), + }, }, ]; - 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) { + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${expenseReport.reportID}`, + value: { + pendingExpenseAction: null, }, - }, - }); + }); + } + + if (!isDEWPolicy) { + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport.reportID}`, + value: expenseReportCurrentNextStepDeprecated ?? null, + }); + } if (parentReport?.reportID) { failureData.push({ diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 1ff6868c0b16e..b035ddb48284c 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 { @@ -71,18 +71,35 @@ type TransactionPreviewData = { hasTransactionThreadReport: boolean; }; -function handleActionButtonPress( - hash: number, - item: TransactionListItemType | TransactionReportGroupListItemType, - goToItem: () => void, - snapshotReport: Report, - snapshotPolicy: Policy, - lastPaymentMethod: OnyxEntry, - currentSearchKey?: SearchKey, - onDEWModalOpen?: () => void, - 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; + personalPolicyID: string | undefined; +}; + +function handleActionButtonPress({ + hash, + item, + goToItem, + snapshotReport, + snapshotPolicy, + lastPaymentMethod, + currentSearchKey, + onDEWModalOpen, + 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. const allReportTransactions = (isTransactionGroupListItemType(item) ? item.transactions : [item]) as Transaction[]; @@ -99,7 +116,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) { @@ -113,7 +130,7 @@ function handleActionButtonPress( approveMoneyRequestOnSearch(hash, item.reportID ? [item.reportID] : [], currentSearchKey); return; case CONST.SEARCH.ACTION_TYPES.SUBMIT: { - if (hasDynamicExternalWorkflow(snapshotPolicy)) { + if (hasDynamicExternalWorkflow(snapshotPolicy) && !isDEWBetaEnabled) { onDEWModalOpen?.(); return; } @@ -154,6 +171,7 @@ function getLastPolicyBankAccountID( function getLastPolicyPaymentMethod( policyID: string | undefined, + personalPolicyID: string | undefined, lastPaymentMethods: OnyxEntry, reportType: keyof LastPaymentMethodType = 'lastUsed', isIOUReport?: boolean, @@ -162,10 +180,7 @@ function getLastPolicyPaymentMethod( return undefined; } - // eslint-disable-next-line @typescript-eslint/no-deprecated - 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; @@ -194,9 +209,10 @@ function getPayActionCallback( snapshotReport: Report, snapshotPolicy: Policy, lastPaymentMethod: OnyxEntry, - currentSearchKey?: SearchKey, + currentSearchKey: SearchKey | undefined, + personalPolicyID: string | undefined, ) { - const lastPolicyPaymentMethod = getLastPolicyPaymentMethod(item.policyID, lastPaymentMethod, getReportType(item.reportID)); + const lastPolicyPaymentMethod = getLastPolicyPaymentMethod(item.policyID, personalPolicyID, lastPaymentMethod, getReportType(item.reportID)); if (!item.reportID) { return; @@ -1030,14 +1046,20 @@ 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 | 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)) - : transactionKeys.every((transactionIDKey) => !!getLastPolicyPaymentMethod(selectedTransactions[transactionIDKey].policyID, lastPaymentMethods)); + ? 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 8293969d49cee..69b0475351210 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -29,7 +29,7 @@ import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; -import usePersonalPolicy from '@hooks/usePersonalPolicy'; +import usePermissions from '@hooks/usePermissions'; import usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; @@ -120,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}); @@ -132,6 +133,8 @@ function SearchPage({route}: SearchPageProps) { const [isExportWithTemplateModalVisible, setIsExportWithTemplateModalVisible] = useState(false); const [searchRequestResponseStatusCode, setSearchRequestResponseStatusCode] = useState(null); const [isDEWModalVisible, setIsDEWModalVisible] = useState(false); + const {isBetaEnabled} = usePermissions(); + const isDEWBetaEnabled = isBetaEnabled(CONST.BETAS.NEW_DOT_DEW); const [isHoldEducationalModalVisible, setIsHoldEducationalModalVisible] = useState(false); const [rejectModalAction, setRejectModalAction] = useState ({ reportID: transaction.reportID, amount: transaction.amount, - paymentType: getLastPolicyPaymentMethod(transaction.policyID, lastPaymentMethods) ?? paymentMethod, + paymentType: + getLastPolicyPaymentMethod(transaction.policyID, personalPolicyID, lastPaymentMethods, undefined, isIOUReportUtil(transaction.reportID)) ?? paymentMethod, ...(isInvoiceReport(transaction.reportID) ? getPayMoneyOnSearchInvoiceParams( transaction.policyID, @@ -394,6 +398,7 @@ function SearchPage({route}: SearchPageProps) { policyIDsWithVBBA, isDelegateAccessRestricted, showDelegateNoAccessModal, + personalPolicyID, ], ); @@ -555,7 +560,7 @@ function SearchPage({route}: SearchPageProps) { return hasDynamicExternalWorkflow(policy); }); - if (hasDEWPolicy) { + if (hasDEWPolicy && !isDEWBetaEnabled) { setIsDEWModalVisible(true); return; } @@ -642,7 +647,7 @@ function SearchPage({route}: SearchPageProps) { }, }); } - const {shouldEnableBulkPayOption, isFirstTimePayment} = getPayOption(selectedReports, selectedTransactions, lastPaymentMethods, selectedReportIDs); + const {shouldEnableBulkPayOption, isFirstTimePayment} = getPayOption(selectedReports, selectedTransactions, lastPaymentMethods, selectedReportIDs, personalPolicyID); const shouldShowPayOption = !isOffline && !isAnyTransactionOnHold && shouldEnableBulkPayOption; @@ -867,6 +872,7 @@ function SearchPage({route}: SearchPageProps) { allReports, theme.icon, styles.colorMuted, + isDEWBetaEnabled, styles.fontWeightNormal, styles.textWrap, expensifyIcons.ArrowCollapse, @@ -889,6 +895,7 @@ function SearchPage({route}: SearchPageProps) { isDelegateAccessRestricted, showDelegateNoAccessModal, currentUserPersonalDetails?.accountID, + personalPolicyID, ]); const handleDeleteExpenses = () => { diff --git a/src/pages/home/report/PureReportActionItem.tsx b/src/pages/home/report/PureReportActionItem.tsx index f13fb59fe0f34..6bd27e0ae1040 100644 --- a/src/pages/home/report/PureReportActionItem.tsx +++ b/src/pages/home/report/PureReportActionItem.tsx @@ -60,7 +60,7 @@ import Navigation from '@libs/Navigation/Navigation'; import Parser from '@libs/Parser'; import Permissions from '@libs/Permissions'; import {getDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; -import {getCleanedTagName, getPersonalPolicy, isPolicyAdmin, isPolicyMember, isPolicyOwner} from '@libs/PolicyUtils'; +import {getCleanedTagName, getPersonalPolicy, hasDynamicExternalWorkflow, isPolicyAdmin, isPolicyMember, isPolicyOwner} from '@libs/PolicyUtils'; import { extractLinksFromMessageHtml, getActionableCardFraudAlertMessage, @@ -115,6 +115,7 @@ import { getWorkspaceTagUpdateMessage, getWorkspaceTaxUpdateMessage, getWorkspaceUpdateFieldMessage, + hasPendingDEWSubmit, isActionableAddPaymentCard, isActionableCardFraudAlert, isActionableJoinRequest, @@ -130,6 +131,7 @@ import { isCreatedTaskReportAction, isDeletedAction, isDeletedParentAction as isDeletedParentActionUtils, + isDynamicExternalWorkflowSubmitFailedAction, isIOURequestReportAction, isMarkAsClosedAction, isMessageDeleted, @@ -421,6 +423,9 @@ type PureReportActionItemProps = { /** Report name value pairs originalID */ reportNameValuePairsOriginalID?: string; + + /** Report metadata for the report */ + reportMetadata?: OnyxEntry; }; // This is equivalent to returning a negative boolean in normal functions, but we can keep the element return type @@ -492,6 +497,7 @@ function PureReportActionItem({ bankAccountList, reportNameValuePairsOrigin, reportNameValuePairsOriginalID, + reportMetadata, }: PureReportActionItemProps) { const actionSheetAwareScrollViewContext = useContext(ActionSheetAwareScrollView.ActionSheetAwareScrollViewContext); const {translate, formatPhoneNumber, localeCompare, formatTravelDate, getLocalDateFromDatetime} = useLocalize(); @@ -1205,12 +1211,18 @@ 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 isDEWPolicy = hasDynamicExternalWorkflow(policy); + + const isPendingAdd = action.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD; + if (wasSubmittedViaHarvesting) { children = ( ${translate('iou.automaticallySubmitted')}`} /> ); + } else if (hasPendingDEWSubmit(reportMetadata, isDEWPolicy) && isPendingAdd) { + children = ; } else { children = ; } @@ -1225,6 +1237,9 @@ function PureReportActionItem({ } else { children = ; } + } else if (isDynamicExternalWorkflowSubmitFailedAction(action)) { + const errorMessage = getOriginalMessage(action)?.message ?? translate('iou.error.genericCreateFailureMessage'); + children = ; } else if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.IOU) && getOriginalMessage(action)?.type === CONST.IOU.REPORT_ACTION_TYPE.PAY) { const wasAutoPaid = getOriginalMessage(action)?.automaticAction ?? false; const paymentType = getOriginalMessage(action)?.paymentType; @@ -1990,6 +2005,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 e050f367b817e..ab6833febcc0d 100644 --- a/src/pages/home/report/ReportActionItem.tsx +++ b/src/pages/home/report/ReportActionItem.tsx @@ -96,6 +96,7 @@ function ReportActionItem({ const isOriginalReportArchived = useReportIsArchived(originalReportID); 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)}`]; @@ -165,6 +166,7 @@ function ReportActionItem({ userBillingFundID={userBillingFundID} isTryNewDotNVPDismissed={isTryNewDotNVPDismissed} bankAccountList={bankAccountList} + reportMetadata={reportMetadata} /> ); } diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts index 9d998667f2c19..0aa90bfa43212 100644 --- a/src/types/onyx/OriginalMessage.ts +++ b/src/types/onyx/OriginalMessage.ts @@ -998,6 +998,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, and CARD_REPLACED_VIRTUAL actions */ @@ -1150,6 +1161,7 @@ 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_SUBMIT_FAILED]: OriginalMessageDEWFailed; [CONST.REPORT.ACTIONS.TYPE.CONCIERGE_CATEGORY_OPTIONS]: OriginalMessageConciergeCategoryOptions; [CONST.REPORT.ACTIONS.TYPE.CONCIERGE_DESCRIPTION_OPTIONS]: OriginalMessageConciergeDescriptionOptions; [CONST.REPORT.ACTIONS.TYPE.CONCIERGE_AUTO_MAP_MCC_GROUPS]: OriginalMessageConciergeAutoMapMccGroups; diff --git a/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/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/IOUTest.ts b/tests/actions/IOUTest.ts index e3c31104fcef9..307ba204dea47 100644 --- a/tests/actions/IOUTest.ts +++ b/tests/actions/IOUTest.ts @@ -6285,6 +6285,153 @@ describe('actions/IOU', () => { }), ); }); + + 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 nextStepBeforeSubmit: Report['nextStep']; + const policyID = generatePolicyID(); + createWorkspace({ + policyOwnerEmail: CARLOS_EMAIL, + makeMeAdmin: true, + policyName: 'Test Workspace with Dynamic External Workflow', + policyID, + introSelected: undefined, + currentUserAccountIDParam: CARLOS_ACCOUNT_ID, + currentUserEmailParam: CARLOS_EMAIL, + }); + 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, + currentUserAccountIDParam: RORY_ACCOUNT_ID, + currentUserEmailParam: RORY_EMAIL, + transactionViolations: {}, + policyRecentlyUsedCurrencies: [], + }); + } + 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); + nextStepBeforeSubmit = expenseReport?.nextStep; + resolve(); + }, + }); + }), + ) + .then(() => { + if (expenseReport) { + submitReport(expenseReport, policy, CARLOS_ACCOUNT_ID, CARLOS_EMAIL, true, true, undefined); + } + 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(CONST.REPORT.STATE_NUM.OPEN); + expect(expenseReport?.statusNum).toBe(CONST.REPORT.STATUS_NUM.OPEN); + expect(expenseReport?.nextStep).toEqual(nextStepBeforeSubmit); + expect(expenseReport?.pendingFields?.nextStep).toBeUndefined(); + + resolve(); + }, + }); + }), + ); + }); }); describe('resolveDuplicate', () => { diff --git a/tests/actions/ReportPreviewActionUtilsTest.ts b/tests/actions/ReportPreviewActionUtilsTest.ts index 491fc278c5c00..fbc95b430c051 100644 --- a/tests/actions/ReportPreviewActionUtilsTest.ts +++ b/tests/actions/ReportPreviewActionUtilsTest.ts @@ -246,6 +246,7 @@ describe('getReportPreviewAction', () => { isPaidAnimationRunning: undefined, isApprovedAnimationRunning: undefined, isSubmittingAnimationRunning: undefined, + isDEWSubmitPending: undefined, violationsData: violations, }), ).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.VIEW); @@ -675,4 +676,134 @@ describe('getReportPreviewAction', () => { }), ).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.EXPORT_TO_ACCOUNTING); }); + + describe('DEW (Dynamic External Workflow) submit pending', () => { + 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, + 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 isDEWSubmitPending = 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); + }); + + 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.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)); + + // When getReportPreviewAction is called with isDEWSubmitPending = false (failed, not pending) + 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); + }); + + 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, + 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)); + + // When getReportPreviewAction is called with isDEWSubmitPending = 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/ui/PureReportActionItemTest.tsx b/tests/ui/PureReportActionItemTest.tsx index 1712ee3621c5b..451191b79fbec 100644 --- a/tests/ui/PureReportActionItemTest.tsx +++ b/tests/ui/PureReportActionItemTest.tsx @@ -16,7 +16,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'; @@ -219,4 +219,124 @@ describe('PureReportActionItem', () => { expect(screen.getByText(Parser.htmlToText(translateLocal('workspaceActions.forcedCorporateUpgrade')))).toBeOnTheScreen(); }); }); + + describe('DEW (Dynamic External Workflow) actions', () => { + 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; + + 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; + + 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 while offline + render( + + + + + + + + + , + ); + await waitForBatchedUpdatesWithAct(); + + // 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(); + }); + + 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; + + 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(); + + // When the PureReportActionItem is rendered with the pending SUBMITTED action + render( + + + + + + + + + , + ); + 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/components/LHNOptionsListTest.tsx b/tests/ui/components/LHNOptionsListTest.tsx index d8fe85f03d4cd..b1804e2a2f49d 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,109 @@ describe('LHNOptionsList', () => { ); }); }); + + describe('DEW (Dynamic External Workflow) pending submit message', () => { + it('shows queued message when offline with pending DEW submit', async () => { + // 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; + const accountID2 = 2; + 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: {[accountID1]: {notificationPreference: 'always'}, [accountID2]: {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: {}, + }; + 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 + render(getLHNOptionsListElement({data: [report]})); + + // 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(); + await waitFor(() => { + expect(screen.getByText('queued to submit via custom approval workflow')).toBeTruthy(); + }); + }); + + 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', + 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, + lastMessageText: expectedLastMessage, + participants: {[accountID1]: {notificationPreference: 'always'}, [accountID2]: {notificationPreference: 'always'}}, + }; + const commentAction: ReportAction = { + reportActionID: '1', + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + created: '2024-01-01 00:00:00', + message: [{type: 'COMMENT', text: expectedLastMessage, html: expectedLastMessage}], + }; + 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}`, { + [commentAction.reportActionID]: commentAction, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`, { + pendingExpenseAction: CONST.EXPENSE_PENDING_ACTION.SUBMIT, + }); + }); + + // When the LHNOptionsList is rendered + render(getLHNOptionsListElement({data: [report]})); + + // 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(); + await waitFor(() => { + expect(screen.queryByText('queued to submit via custom approval workflow')).toBeNull(); + expect(screen.getByText(expectedLastMessage)).toBeTruthy(); + }); + }); + }); }); diff --git a/tests/unit/NextStepUtilsTest.ts b/tests/unit/NextStepUtilsTest.ts index 31fd3176089e4..af2b4c5d192b2 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,25 @@ describe('libs/NextStepUtils', () => { }); }); }); + + describe('buildOptimisticNextStepForDynamicExternalWorkflowError', () => { + 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, + message: [ + { + text: "This report can't be submitted. Please review the comments to resolve.", + type: 'alert-text', + }, + ], + }); + }); + }); }); diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index 0ef14bdc6718c..283a8a23b5890 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -2853,6 +2853,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}); + 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', () => { diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts index 5d2d88acc7658..8ed77eb629310 100644 --- a/tests/unit/ReportActionsUtilsTest.ts +++ b/tests/unit/ReportActionsUtilsTest.ts @@ -1570,6 +1570,392 @@ describe('ReportActionsUtils', () => { }); }); + describe('isDynamicExternalWorkflowSubmitFailedAction', () => { + 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, + 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: [], + }; + + // 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 type', () => { + // Given a report action with SUBMITTED action type (not DEW_SUBMIT_FAILED) + const action: ReportAction = { + ...createRandomReportAction(0), + actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, + created: '2025-11-21', + reportActionID: '1', + originalMessage: { + amount: 10000, + currency: 'USD', + }, + message: [], + previousMessage: [], + }; + + // 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', () => { + // 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: actionId1, + originalMessage: { + message: 'DEW submit failed', + }, + message: [], + 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(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 = { + [actionId1]: { + ...createRandomReportAction(0), + actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, + created: '2025-11-21 09:00:00', + reportActionID: actionId1, + originalMessage: { + amount: 10000, + currency: 'USD', + }, + message: [], + previousMessage: [], + } as ReportAction, + [actionId2]: { + ...createRandomReportAction(0), + actionName: CONST.REPORT.ACTIONS.TYPE.DEW_SUBMIT_FAILED, + created: '2025-11-21 10:00:00', + reportActionID: actionId2, + originalMessage: { + message: 'DEW submit failed', + }, + message: [], + 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(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 = { + [actionId1]: { + ...createRandomReportAction(0), + actionName: CONST.REPORT.ACTIONS.TYPE.DEW_SUBMIT_FAILED, + created: '2025-11-21 09:00:00', + reportActionID: actionId1, + originalMessage: { + message: 'DEW submit failed', + }, + message: [], + previousMessage: [], + } as ReportAction, + [actionId2]: { + ...createRandomReportAction(0), + actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, + created: '2025-11-21 10:00:00', + reportActionID: actionId2, + originalMessage: { + amount: 10000, + currency: 'USD', + }, + message: [], + previousMessage: [], + } as ReportAction, + }; + + // 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: actionId1, + originalMessage: { + amount: 10000, + currency: 'USD', + }, + message: [], + previousMessage: [], + } as ReportAction, + }; + + // 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', () => { + // 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 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), + 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, + ]; + + // 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 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'; + const actionId4 = '4'; + const reportActions: ReportActions = { + [actionId1]: { + ...createRandomReportAction(0), + actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, + created: '2025-11-21 08:00:00', + reportActionID: actionId1, + originalMessage: {amount: 10000, currency: 'USD'}, + message: [], + previousMessage: [], + } as ReportAction, + [actionId2]: { + ...createRandomReportAction(0), + actionName: CONST.REPORT.ACTIONS.TYPE.DEW_SUBMIT_FAILED, + created: '2025-11-21 09:00:00', + reportActionID: actionId2, + originalMessage: {message: 'First DEW failure'}, + message: [], + previousMessage: [], + } as ReportAction, + [actionId3]: { + ...createRandomReportAction(0), + actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, + created: '2025-11-21 10:00:00', + reportActionID: actionId3, + originalMessage: {amount: 10000, currency: 'USD'}, + message: [], + previousMessage: [], + } as ReportAction, + [actionId4]: { + ...createRandomReportAction(0), + actionName: CONST.REPORT.ACTIONS.TYPE.DEW_SUBMIT_FAILED, + created: '2025-11-21 11:00:00', + reportActionID: actionId4, + originalMessage: {message: 'Second DEW failure'}, + message: [], + previousMessage: [], + } as ReportAction, + }; + + // 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(actionId4); + }); + + 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'; + const reportActions: ReportActions = { + [actionId1]: { + ...createRandomReportAction(0), + actionName: CONST.REPORT.ACTIONS.TYPE.DEW_SUBMIT_FAILED, + created: '2025-11-21 08:00:00', + reportActionID: actionId1, + originalMessage: {message: 'First DEW failure'}, + message: [], + previousMessage: [], + } as ReportAction, + [actionId2]: { + ...createRandomReportAction(0), + actionName: CONST.REPORT.ACTIONS.TYPE.DEW_SUBMIT_FAILED, + created: '2025-11-21 09:00:00', + reportActionID: actionId2, + originalMessage: {message: 'Second DEW failure'}, + message: [], + previousMessage: [], + } as ReportAction, + [actionId3]: { + ...createRandomReportAction(0), + actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, + created: '2025-11-21 10:00:00', + reportActionID: actionId3, + originalMessage: {amount: 10000, currency: 'USD'}, + message: [], + previousMessage: [], + } as ReportAction, + }; + + // 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('hasPendingDEWSubmit', () => { + 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(reportMetadata, true); + + // Then it should return true + expect(result).toBe(true); + }); + + 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(reportMetadata, false); + + // Then it should return false because the policy is not DEW + expect(result).toBe(false); + }); + + it('should return false when pendingExpenseAction is not 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(reportMetadata, true); + + // Then it should return false because pendingExpenseAction is APPROVE, not SUBMIT + expect(result).toBe(false); + }); + + it('should return false when pendingExpenseAction is undefined', () => { + // Given reportMetadata without pendingExpenseAction + const reportMetadata = {}; + + // When checking if there's a pending DEW submit + const result = ReportActionsUtils.hasPendingDEWSubmit(reportMetadata, true); + + // Then it should return false + expect(result).toBe(false); + }); + + it('should return false when reportMetadata is undefined', () => { + // Given undefined reportMetadata + + // When checking if there's a pending DEW submit + const result = ReportActionsUtils.hasPendingDEWSubmit(undefined, true); + + // Then it should return false + expect(result).toBe(false); + }); + }); + describe('isDynamicExternalWorkflowSubmitAction', () => { it('should return true for SUBMITTED action if workflow is DYNAMICEXTERNAL', () => { // Given a report action with SUBMITTED action type and workflow is DYNAMICEXTERNAL diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 34025bb471a26..0c316a961f1d5 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -1827,7 +1827,7 @@ describe('ReportUtils', () => { merchant: 'Test Merchant', created: testDate, modifiedMerchant: 'Test Merchant', - } as Transaction; + }; const policies = {[`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`]: policy}; const transactions = {[`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`]: transaction}; @@ -10665,7 +10665,6 @@ describe('ReportUtils', () => { expect(shouldHideSingleReportField(reportField)).toBe(true); }); }); - describe('P2P Wallet Activation - GBR and Wallet Indicator', () => { const friendAccountID = 42; @@ -10952,4 +10951,252 @@ 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 = { + [dewSubmitFailedAction.reportActionID]: 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 = { + [dewSubmitFailedAction.reportActionID]: dewSubmitFailedAction, + [submittedAction.reportActionID]: submittedAction, + }; + + const {errors} = 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 = { + [submittedAction.reportActionID]: submittedAction, + [dewSubmitFailedAction.reportActionID]: 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 = { + [dewSubmitFailedAction.reportActionID]: 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 = { + [dewSubmitFailedAction.reportActionID]: dewSubmitFailedAction, + }; + + const {errors} = getAllReportActionsErrorsAndReportActionThatRequiresAttention(report, reportActions, true); + + expect(errors?.dewSubmitFailed).toBeUndefined(); + }); + + 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, + 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 = { + [firstSubmittedAction.reportActionID]: firstSubmittedAction, + [dewSubmitFailedAction.reportActionID]: dewSubmitFailedAction, + [secondSubmittedAction.reportActionID]: secondSubmittedAction, + }; + + const {errors, reportAction} = getAllReportActionsErrorsAndReportActionThatRequiresAttention(report, reportActions); + + expect(errors?.dewSubmitFailed).toBeUndefined(); + expect(reportAction).not.toEqual(dewSubmitFailedAction); + }); + }); }); diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index c33418eacf763..5f3ad8039f36d 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -1810,6 +1810,119 @@ describe('SearchUIUtils', () => { const action = SearchUIUtils.getActions(searchResults.data, {}, iouReportKey, CONST.SEARCH.SEARCH_KEYS.EXPENSES, '').at(0); expect(action).toEqual(CONST.SEARCH.ACTION_TYPES.PAY); }); + + 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'; + + 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, '', dewReportActions).at(0); + 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 () => { + 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, '', dewReportActions).at(0); + expect(action).not.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', () => { diff --git a/tests/unit/Search/handleActionButtonPressTest.ts b/tests/unit/Search/handleActionButtonPressTest.ts index b99cda35e2008..8786fc8a41ef2 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'); @@ -305,15 +305,29 @@ 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, + item: mockReportItemWithHold, + goToItem, + snapshotReport: snapshotReport as Report, + snapshotPolicy: snapshotPolicy as Policy, + lastPaymentMethod: mockLastPaymentMethod, + personalPolicyID: undefined, + }); 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, + item: updatedMockReportItem, + goToItem, + snapshotReport: snapshotReport as Report, + snapshotPolicy: snapshotPolicy as Policy, + lastPaymentMethod: mockLastPaymentMethod, + personalPolicyID: undefined, + }); expect(goToItem).toHaveBeenCalledTimes(0); }); });