diff --git a/src/components/MoneyReportHeaderPrimaryAction/useConfirmApproval.ts b/src/components/MoneyReportHeaderPrimaryAction/useConfirmApproval.ts index b5f033228922..c9ff3b735111 100644 --- a/src/components/MoneyReportHeaderPrimaryAction/useConfirmApproval.ts +++ b/src/components/MoneyReportHeaderPrimaryAction/useConfirmApproval.ts @@ -1,6 +1,7 @@ import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider'; import {useLockedAccountActions, useLockedAccountState} from '@components/LockedAccountModalProvider'; +import useConfirmApproveReportViolations from '@hooks/useConfirmApproveReportViolations'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDelegateAccountID from '@hooks/useDelegateAccountID'; @@ -13,15 +14,16 @@ import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViol import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {isSubmitPolicy} from '@libs/PolicyUtils'; import {hasHeldExpensesFromTransactions as hasHeldExpensesReportUtils, hasViolations as hasViolationsReportUtils} from '@libs/ReportUtils'; +import getReportApprovalWarningData from '@libs/Violations/getReportApprovalWarningData'; import {approveMoneyRequest} from '@userActions/IOU/ReportWorkflow'; import type AdditionalPayOnyxData from '@userActions/IOU/types/AdditionalPayOnyxData'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Policy, Report, Transaction} from '@src/types/onyx'; +import type {Policy, Report, Transaction, TransactionViolations} from '@src/types/onyx'; -import type {OnyxEntry} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {delegateEmailSelector} from '@selectors/Account'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; @@ -35,12 +37,14 @@ type UseConfirmApprovalOptions = { fallbackReport?: OnyxEntry; fallbackPolicy?: OnyxEntry; fallbackTransactions?: Transaction[]; + /** Search warnings used only when no live violation entry is available. */ + fallbackViolations?: OnyxCollection; }; function useConfirmApproval( reportID: string | undefined, startApprovedAnimation: () => void, - {getAdditionalOnyxData, fallbackReport, fallbackPolicy, fallbackTransactions}: UseConfirmApprovalOptions = {}, + {getAdditionalOnyxData, fallbackReport, fallbackPolicy, fallbackTransactions, fallbackViolations}: UseConfirmApprovalOptions = {}, ) { const {accountID, email} = useCurrentUserPersonalDetails(); const {getCurrencyDecimals} = useCurrencyListActions(); @@ -78,6 +82,13 @@ function useConfirmApproval( const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); const hasViolations = hasViolationsReportUtils(moneyRequestReport?.reportID, transactionViolations, accountID, email ?? '', undefined, transactions); const isAnyTransactionOnHold = hasHeldExpensesReportUtils(transactions); + const warningData = getReportApprovalWarningData(reportID, transactions, [], fallbackViolations, transactionViolations); + const confirmApproveReportViolations = useConfirmApproveReportViolations({ + report: moneyRequestReport, + policy, + transactions: warningData.transactions, + violationsCollection: warningData.violations, + }); const onApprove = (full: boolean) => { if (isDelegateAccessRestricted) { @@ -88,31 +99,33 @@ function useConfirmApproval( showLockedAccountModal(); return; } - if (!isSubmitPolicy(policy)) { - startApprovedAnimation(); - } - approveMoneyRequest({ - personalDetailsByLogins: getPersonalDetailsByLogin(), - getCurrencyDecimals, - expenseReport: moneyRequestReport, - expenseReportPolicy: policy, - rules, - currentUserAccountIDParam: accountID, - currentUserEmailParam: email ?? '', - hasViolations, - isASAPSubmitBetaEnabled, - userBillingGracePeriodEnds, - amountOwed, - ownerBillingGracePeriodEnd, - ownerLogin, - full, - onApproved: startApprovedAnimation, - delegateEmail, - delegateAccountID, - isTrackIntentUser, - allTransactionViolations: {...autoReportedRejectedExpenseViolations, ...transactionViolations}, - additionalOnyxData: getAdditionalOnyxData?.(), - }); + confirmApproveReportViolations(() => { + if (!isSubmitPolicy(policy)) { + startApprovedAnimation(); + } + approveMoneyRequest({ + personalDetailsByLogins: getPersonalDetailsByLogin(), + getCurrencyDecimals, + expenseReport: moneyRequestReport, + expenseReportPolicy: policy, + rules, + currentUserAccountIDParam: accountID, + currentUserEmailParam: email ?? '', + hasViolations, + isASAPSubmitBetaEnabled, + userBillingGracePeriodEnds, + amountOwed, + ownerBillingGracePeriodEnd, + ownerLogin, + full, + onApproved: startApprovedAnimation, + delegateEmail, + delegateAccountID, + isTrackIntentUser, + allTransactionViolations: {...autoReportedRejectedExpenseViolations, ...transactionViolations}, + additionalOnyxData: getAdditionalOnyxData?.(), + }); + }, full); }; // When there are held expenses the partial/full choice is surfaced up front via the settlement button's approve diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/useConfirmApproveReportAction.ts b/src/components/ReportActionItem/MoneyRequestReportPreview/useConfirmApproveReportAction.ts index 04df3a8e9450..b63fbb733c60 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/useConfirmApproveReportAction.ts +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/useConfirmApproveReportAction.ts @@ -6,6 +6,7 @@ import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider'; import {useLockedAccountActions, useLockedAccountState} from '@components/LockedAccountModalProvider'; +import useConfirmApproveReportViolations from '@hooks/useConfirmApproveReportViolations'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useOnyx from '@hooks/useOnyx'; @@ -38,6 +39,7 @@ function useConfirmApproveReportAction(actionButtonData: ReturnType { if (isDelegateAccessRestricted) { @@ -45,27 +47,29 @@ function useConfirmApproveReportAction(actionButtonData: ReturnType { + approveMoneyRequest({ + personalDetailsByLogins: getPersonalDetailsByLogin(), + getCurrencyDecimals, + expenseReport: iouReport, + expenseReportPolicy: policy, + rules, + currentUserAccountIDParam: currentUserDetails.accountID, + currentUserEmailParam: currentUserDetails.email ?? '', + hasViolations, + isASAPSubmitBetaEnabled: isBetaEnabled(CONST.BETAS.ASAP_SUBMIT), + userBillingGracePeriodEnds, + amountOwed, + ownerBillingGracePeriodEnd, + ownerLogin, + full, + onApproved: startApprovedAnimation, + delegateEmail, + delegateAccountID, + isTrackIntentUser, + allTransactionViolations, + }); + }, full); } }; } diff --git a/src/components/Search/SearchList/ListItem/ActionCell/ApproveActionCell.tsx b/src/components/Search/SearchList/ListItem/ActionCell/ApproveActionCell.tsx index e32ddfa89b98..626c4f002b8b 100644 --- a/src/components/Search/SearchList/ListItem/ActionCell/ApproveActionCell.tsx +++ b/src/components/Search/SearchList/ListItem/ActionCell/ApproveActionCell.tsx @@ -37,7 +37,7 @@ function ApproveActionCell({isLoading, reportID, policyID, hash, shouldDisablePo const currentUserDetails = useCurrentUserPersonalDetails(); const {isDelegateAccessRestricted} = useDelegateNoAccessState(); const {currentSearchKey} = useSearchQueryContext(); - const {currentSearchResults} = useSearchResultsContext(); + const {currentSearchResults, currentSearchTransactionsByReportID, currentSearchViolations} = useSearchResultsContext(); const snapshotReport = currentSearchResults?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`] as OnyxEntry; const snapshotPolicy = currentSearchResults?.data?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`] as OnyxEntry; @@ -52,7 +52,8 @@ function ApproveActionCell({isLoading, reportID, policyID, hash, shouldDisablePo getAdditionalOnyxData: hash === undefined ? undefined : () => getSearchApproveOnyxData(hash, reportID, currentSearchKey), fallbackReport: snapshotReport, fallbackPolicy: snapshotPolicy, - fallbackTransactions: snapshotTransactions, + fallbackTransactions: currentSearchTransactionsByReportID.get(reportID) ?? snapshotTransactions, + fallbackViolations: currentSearchViolations, }); const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); diff --git a/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx b/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx index 0071d21d385b..25c54a8f256a 100644 --- a/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx +++ b/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx @@ -14,6 +14,7 @@ import ListItemComposed from '@components/SelectionList/ListItemComposed'; import type {ListItem} from '@components/SelectionList/types'; import Text from '@components/Text'; +import useConfirmApproveReportViolations from '@hooks/useConfirmApproveReportViolations'; import useConfirmModal from '@hooks/useConfirmModal'; import useConfirmSubmitReportViolations from '@hooks/useConfirmSubmitReportViolations'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; @@ -40,6 +41,7 @@ import {isInvoiceReport, isOpenExpenseReport, isProcessingReport, isReportPendin import {hasVisibleViolations} from '@libs/SearchUIUtils'; import shouldBreakAccessibilityGrouping from '@libs/shouldBreakAccessibilityGrouping'; import {getAttendees, isViolationDismissed, shouldShowViolation, showHeldExpensesBlockModal, showPendingCardTransactionsBlockModal} from '@libs/TransactionUtils'; +import getReportApprovalWarningData from '@libs/Violations/getReportApprovalWarningData'; import variables from '@styles/variables'; @@ -121,7 +123,7 @@ function ExpenseReportListItemInner({ const {getCurrencyDecimals, convertToDisplayString} = useCurrencyListActions(); const {isLargeScreenWidth} = useResponsiveLayout(); const {currentSearchHash, currentSearchKey} = useSearchQueryContext(); - const {currentSearchResults} = useSearchResultsContext(); + const {currentSearchResults, currentSearchTransactionsByReportID, currentSearchViolations} = useSearchResultsContext(); const [isActionLoading] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${reportItem.reportID}`, { selector: isActionLoadingSelector, }); @@ -247,10 +249,18 @@ function ExpenseReportListItemInner({ // Scoped live violations for the report's snapshot transactions: before the report's transactions // hydrate into the live collection, rule/category changes still push violation updates that must // reflect on the badge (per-row selector, not the screen-level collection merge this slice removed). - const snapshotTransactionIDs = (reportItem.transactions ?? []).map((transaction) => transaction.transactionID); + const snapshotTransactions = currentSearchTransactionsByReportID.get(reportItem.reportID) ?? reportItem.transactions ?? []; + const snapshotTransactionIDs = [...new Set([...snapshotTransactions, ...liveReportTransactions].map((transaction) => transaction.transactionID))]; const [liveViolationsForSnapshotTransactions] = useOnyxWithoutSnapshots(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, { selector: transactionViolationsByIDsSelector(snapshotTransactionIDs), }); + const warningData = getReportApprovalWarningData(reportItem.reportID, snapshotTransactions, liveReportTransactions, currentSearchViolations, liveViolationsForSnapshotTransactions); + const confirmApproveReportViolations = useConfirmApproveReportViolations({ + report: reportForViolations, + policy: policyForViolations, + transactions: warningData.transactions, + violationsCollection: warningData.violations, + }); // Approving also recomputes the chat's hasOutstandingChildRequest, which checks sibling reports' transactions for auto-reported rejected expenses. const [autoReportedRejectedExpenseViolations] = useOnyxWithoutSnapshots(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, {selector: autoReportedRejectedExpenseViolationsSelector}); const {currentUserAccountID, currentUserLogin, isASAPSubmitBetaEnabled, isWiseReimbursementsBetaEnabled, activePolicy, chatReportPolicy, delegateEmail, delegateAccountID} = @@ -284,6 +294,7 @@ function ExpenseReportListItemInner({ onAccountLocked: showLockedAccountModal, personalPolicyID, confirmSubmitReportViolations, + confirmApproveReportViolations, ownerBillingGracePeriodEnd, openReportSubmitToPopover, onPendingCardTransactionsBlock: () => showPendingCardTransactionsBlockModal(showConfirmModal, translate, shouldShowMarkAsDoneCopy), diff --git a/src/components/Search/SearchList/ListItem/ReportListItemHeader.tsx b/src/components/Search/SearchList/ListItem/ReportListItemHeader.tsx index d1d2f745fe38..f95ea29a9c6a 100644 --- a/src/components/Search/SearchList/ListItem/ReportListItemHeader.tsx +++ b/src/components/Search/SearchList/ListItem/ReportListItemHeader.tsx @@ -14,6 +14,7 @@ import {useSearchQueryContext, useSearchResultsContext} from '@components/Search import {useRowSelection} from '@components/Search/SearchSelectionProvider'; import type {ListItem} from '@components/SelectionList/types'; +import useConfirmApproveReportViolations from '@hooks/useConfirmApproveReportViolations'; import useConfirmModal from '@hooks/useConfirmModal'; import useConfirmSubmitReportViolations from '@hooks/useConfirmSubmitReportViolations'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; @@ -28,11 +29,13 @@ import useSearchActionPress from '@hooks/useSearchActionPress'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import type {ModifiedMouseEvent} from '@libs/Navigation/helpers/openInternalRouteInNewTab'; import {shouldShowMarkAsDone} from '@libs/ReportUtils'; import {showHeldExpensesBlockModal, showPendingCardTransactionsBlockModal} from '@libs/TransactionUtils'; +import getReportApprovalWarningData from '@libs/Violations/getReportApprovalWarningData'; import {handleActionButtonPress} from '@userActions/Search'; @@ -266,7 +269,7 @@ function ReportListItemHeaderInner({ const styles = useThemeStyles(); const theme = useTheme(); const {currentSearchHash, currentSearchKey} = useSearchQueryContext(); - const {currentSearchResults: snapshot} = useSearchResultsContext(); + const {currentSearchResults: snapshot, currentSearchTransactionsByReportID, currentSearchViolations} = useSearchResultsContext(); const {isLargeScreenWidth} = useResponsiveLayout(); const thereIsFromAndTo = !!reportItem?.from && !!reportItem?.to; const showUserInfo = (reportItem.type === CONST.REPORT.TYPE.IOU && thereIsFromAndTo) || (reportItem.type === CONST.REPORT.TYPE.EXPENSE && !!reportItem?.from); @@ -298,8 +301,18 @@ function ReportListItemHeaderInner({ shouldShowMarkAsDoneCopy, }); - const reportTransactionIDs = (reportItem.transactions ?? []).map((transaction) => transaction.transactionID); + const {transactions: reportTransactions} = useTransactionsAndViolationsForReport(reportItem.reportID); + const liveReportTransactions = Object.values(reportTransactions); + const snapshotTransactions = currentSearchTransactionsByReportID.get(reportItem.reportID) ?? reportItem.transactions ?? []; + const reportTransactionIDs = [...new Set([...snapshotTransactions, ...liveReportTransactions].map((transaction) => transaction.transactionID))]; const [allViolations] = useOnyxWithoutSnapshots(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, {selector: transactionViolationsByIDsSelector(reportTransactionIDs)}); + const warningData = getReportApprovalWarningData(reportItem.reportID, snapshotTransactions, liveReportTransactions, currentSearchViolations, allViolations); + const confirmApproveReportViolations = useConfirmApproveReportViolations({ + report: parentReport ?? snapshotReport, + policy: parentPolicy ?? snapshotPolicy, + transactions: warningData.transactions, + violationsCollection: warningData.violations, + }); // Approving also recomputes the chat's hasOutstandingChildRequest, which checks sibling reports' transactions for auto-reported rejected expenses. const [autoReportedRejectedExpenseViolations] = useOnyxWithoutSnapshots(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, {selector: autoReportedRejectedExpenseViolationsSelector}); @@ -349,6 +362,7 @@ function ReportListItemHeaderInner({ personalPolicyID, ownerBillingGracePeriodEnd, confirmSubmitReportViolations, + confirmApproveReportViolations, openReportSubmitToPopover, onPendingCardTransactionsBlock: () => showPendingCardTransactionsBlockModal(showConfirmModal, translate, shouldShowMarkAsDoneCopy), onAllHeldExpensesBlock: () => showHeldExpensesBlockModal(showConfirmModal, translate, shouldShowMarkAsDoneCopy), diff --git a/src/hooks/useConfirmApproveReportViolations.ts b/src/hooks/useConfirmApproveReportViolations.ts new file mode 100644 index 000000000000..094923c1135b --- /dev/null +++ b/src/hooks/useConfirmApproveReportViolations.ts @@ -0,0 +1,43 @@ +/** Confirms locally available violations before approval, preserving offline behavior. */ +import confirmApproveViolationsThenProceed from '@libs/Violations/confirmApproveViolationsThenProceed'; +import getReportApproveViolationSummary from '@libs/Violations/getReportApproveViolationSummary'; + +import type {Policy, Report, Transaction, TransactionViolations} from '@src/types/onyx'; + +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; + +import useConfirmModal from './useConfirmModal'; +import {useCurrencyListActions} from './useCurrencyList'; +import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; +import useLocalize from './useLocalize'; +import useTransactionsAndViolationsForReport from './useTransactionsAndViolationsForReport'; + +type UseConfirmApproveReportViolationsParams = { + report: OnyxEntry; + policy: OnyxEntry; + transactions?: Array>; + violationsCollection?: OnyxCollection; +}; + +function useConfirmApproveReportViolations({report, policy, transactions: transactionsOverride, violationsCollection: violationsOverride}: UseConfirmApproveReportViolationsParams) { + const {showConfirmModal} = useConfirmModal(); + const {translate, dateFnsLocale} = useLocalize(); + const {convertToDisplayString} = useCurrencyListActions(); + const {accountID, email} = useCurrentUserPersonalDetails(); + const {transactions: ownedTransactions, violations: ownedViolations} = useTransactionsAndViolationsForReport(transactionsOverride ? undefined : report?.reportID); + + return (onProceed: () => void, full = true) => { + const summary = getReportApproveViolationSummary( + transactionsOverride ?? Object.values(ownedTransactions), + violationsOverride ?? ownedViolations, + report, + policy, + email ?? '', + accountID, + full, + ); + confirmApproveViolationsThenProceed({summary, showConfirmModal, translate, dateFnsLocale, convertToDisplayString, onProceed}); + }; +} + +export default useConfirmApproveReportViolations; diff --git a/src/hooks/useHoldMenuSubmit.ts b/src/hooks/useHoldMenuSubmit.ts index 3dbd84d26e9f..1d0d12e7b90a 100644 --- a/src/hooks/useHoldMenuSubmit.ts +++ b/src/hooks/useHoldMenuSubmit.ts @@ -18,6 +18,7 @@ import {delegateEmailSelector} from '@selectors/Account'; import {hasSeenTourSelector} from '@selectors/Onboarding'; import {loginSelector} from '@selectors/PersonalDetails'; +import useConfirmApproveReportViolations from './useConfirmApproveReportViolations'; import {useCurrencyListActions} from './useCurrencyList'; import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; import useDelegateAccountID from './useDelegateAccountID'; @@ -77,6 +78,12 @@ function useHoldMenuSubmit({moneyRequestReport, chatReport, requestType, payment const {showLockedAccountModal} = useLockedAccountActions(); const isApprove = requestType === CONST.IOU.REPORT_ACTION_TYPE.APPROVE; + const confirmApproveReportViolations = useConfirmApproveReportViolations({ + report: moneyRequestReport, + policy, + transactions: moneyRequestReportTransactions, + violationsCollection: transactionViolations, + }); const onSubmit = (full: boolean) => { if (isDelegateAccessRestricted) { @@ -98,27 +105,29 @@ function useHoldMenuSubmit({moneyRequestReport, chatReport, requestType, payment const currentChatReport = getReportOrDraftReport({reportID: chatReport?.reportID}) ?? chatReport; if (isApprove) { - approveMoneyRequest({ - personalDetailsByLogins: getPersonalDetailsByLogin(), - getCurrencyDecimals, - expenseReport: currentMoneyRequestReport, - rules, - currentUserAccountIDParam: currentUserDetails.accountID, - currentUserEmailParam: currentUserDetails.email ?? '', - hasViolations, - isASAPSubmitBetaEnabled, - userBillingGracePeriodEnds, - amountOwed, - ownerBillingGracePeriodEnd, - ownerLogin, - full, - onApproved: animationCallback, - expenseReportPolicy: policy, - delegateEmail, - delegateAccountID, - isTrackIntentUser, - allTransactionViolations: transactionViolations, - }); + confirmApproveReportViolations(() => { + approveMoneyRequest({ + personalDetailsByLogins: getPersonalDetailsByLogin(), + getCurrencyDecimals, + expenseReport: currentMoneyRequestReport, + rules, + currentUserAccountIDParam: currentUserDetails.accountID, + currentUserEmailParam: currentUserDetails.email ?? '', + hasViolations, + isASAPSubmitBetaEnabled, + userBillingGracePeriodEnds, + amountOwed, + ownerBillingGracePeriodEnd, + ownerLogin, + full, + onApproved: animationCallback, + expenseReportPolicy: policy, + delegateEmail, + delegateAccountID, + isTrackIntentUser, + allTransactionViolations: transactionViolations, + }); + }, full); } else if (currentChatReport && paymentType) { payWithWiseQuote({ bankAccountList, diff --git a/src/hooks/useLifecycleActions.tsx b/src/hooks/useLifecycleActions.tsx index e0f3a23f7ba2..5db26dc2a831 100644 --- a/src/hooks/useLifecycleActions.tsx +++ b/src/hooks/useLifecycleActions.tsx @@ -41,6 +41,7 @@ import React, {useEffect, useRef} from 'react'; import type {ActionHandledType} from './useHoldMenuSubmit'; +import useConfirmApproveReportViolations from './useConfirmApproveReportViolations'; import useConfirmModal from './useConfirmModal'; import useConfirmSubmitReportViolations from './useConfirmSubmitReportViolations'; import {useCurrencyListActions} from './useCurrencyList'; @@ -198,6 +199,8 @@ function useLifecycleActions({reportID, startApprovedAnimation, startAnimation, shouldShowMarkAsDoneCopy, }); + const confirmApproveReportViolations = useConfirmApproveReportViolations({report: moneyRequestReport, policy, transactions, violationsCollection: violations}); + const onApprove = (isFullApproval: boolean, skipAnimation = false) => { if (isDelegateAccessRestricted) { showDelegateNoAccessModal(); @@ -207,39 +210,41 @@ function useLifecycleActions({reportID, startApprovedAnimation, startAnimation, showLockedAccountModal(); return; } - if (!skipAnimation && !isSubmitPolicy(policy)) { - startApprovedAnimation(); - } - approveMoneyRequest({ - personalDetailsByLogins: getPersonalDetailsByLogin(), - getCurrencyDecimals, - expenseReport: moneyRequestReport, - currentUserAccountIDParam: accountID, - currentUserEmailParam: email ?? '', - hasViolations, - isASAPSubmitBetaEnabled, - full: isFullApproval, - expenseReportPolicy: policy, - userBillingGracePeriodEnds, - ownerBillingGracePeriodEnd, - ownerLogin: submitterLogin, - amountOwed, - onApproved: () => { - if (skipAnimation) { - return; - } + confirmApproveReportViolations(() => { + if (!skipAnimation && !isSubmitPolicy(policy)) { startApprovedAnimation(); - }, - delegateEmail, - delegateAccountID, - isTrackIntentUser, - rules, - allTransactionViolations, - }); - if (skipAnimation) { - clearSelectedTransactions(true); - onCleanup?.(); - } + } + approveMoneyRequest({ + personalDetailsByLogins: getPersonalDetailsByLogin(), + getCurrencyDecimals, + expenseReport: moneyRequestReport, + currentUserAccountIDParam: accountID, + currentUserEmailParam: email ?? '', + hasViolations, + isASAPSubmitBetaEnabled, + full: isFullApproval, + expenseReportPolicy: policy, + userBillingGracePeriodEnds, + ownerBillingGracePeriodEnd, + ownerLogin: submitterLogin, + amountOwed, + onApproved: () => { + if (skipAnimation) { + return; + } + startApprovedAnimation(); + }, + delegateEmail, + delegateAccountID, + isTrackIntentUser, + rules, + allTransactionViolations, + }); + if (skipAnimation) { + clearSelectedTransactions(true); + onCleanup?.(); + } + }, isFullApproval); }; const isIOUPayable = canIOUBePaid(moneyRequestReport, chatReport, policy, bankAccountList, currentUserPersonalDetails.login ?? '', accountID, undefined, false, isChatReportArchived); diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index 2db83dfbb771..65fecfc8c4d4 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -102,6 +102,7 @@ import { getSearchColumnTranslationKey, getSelectedGroupFilterEntry, getTransactionsByReportID, + getViolationsFromSearchData, getValidGroupBy, insertColumnBeforeTotalAmount, isGroupEntry, @@ -130,7 +131,10 @@ import { isPerDiemRequest, isScanning, } from '@libs/TransactionUtils'; +import confirmApproveViolationsThenProceed from '@libs/Violations/confirmApproveViolationsThenProceed'; import confirmSubmitViolationsThenProceed from '@libs/Violations/confirmSubmitViolationsThenProceed'; +import getReportApprovalWarningData from '@libs/Violations/getReportApprovalWarningData'; +import getReportApproveViolationSummary from '@libs/Violations/getReportApproveViolationSummary'; import {getReportSubmitViolationSummary, mergeReportSubmitViolationSummaries} from '@libs/Violations/getReportSubmitViolationSummary'; import variables from '@styles/variables'; @@ -1487,81 +1491,100 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { return; } - // "Select all" can cover more reports than are loaded, so hand the search query to the backend to approve every match. - if (areAllMatchingItemsSelected) { - if (!queryJSON) { - Log.info('[BulkApprove] Dropping bulk approve: no search query'); - return; - } - const allMatchingQuery = getAllMatchingReportQuery(queryJSON, excludedTransactions); - if (!allMatchingQuery) { - Log.info('[BulkApprove] Dropping bulk approve: report exclusion has no reportID'); - return; - } - queueBulkApproveReports(serializeQueryJSONForBackend(allMatchingQuery)); - playSound(SOUNDS.SUCCESS); - clearSelectedTransactions(); - return; - } - - const reportIDList = !selectedReports.length - ? Object.values(selectedTransactions).map((transaction) => transaction.reportID) - : (selectedReports?.filter((report) => !!report).map((report) => report.reportID) ?? []); + const reportIDList = !selectedReports.length ? Object.values(selectedTransactions).map((transaction) => transaction.reportID) : selectedReports.map((report) => report.reportID); const uniqueReportIDs = [...new Set(reportIDList.filter((reportID): reportID is string => reportID !== undefined))]; const searchData = searchResults?.data; - const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); - - let approvedReportCount = 0; - for (const reportID of uniqueReportIDs) { + const snapshotTransactionsByReportID = getTransactionsByReportID(searchData ?? {}); + const snapshotViolations = getViolationsFromSearchData(searchData ?? {}); + const perReportSummaries = uniqueReportIDs.map((reportID) => { const expenseReport = getReportFromSearchSnapshot(reportID, searchData, allReports); - if (!expenseReport) { - Log.info('[BulkApprove] Skipping report: expense report not found in the search snapshot or Onyx', false, {reportID}); - continue; - } + const reportPolicy = getPolicyFromSearchSnapshot(expenseReport?.policyID, searchData, policies); + const liveTransactions = Object.values(allReportsTransactionsAndViolations?.[reportID]?.transactions ?? {}); + const warningData = getReportApprovalWarningData(reportID, snapshotTransactionsByReportID.get(reportID), liveTransactions, snapshotViolations, allTransactionViolations); + return getReportApproveViolationSummary(warningData.transactions, warningData.violations, expenseReport, reportPolicy, email ?? '', accountID); + }); + const summary = mergeReportSubmitViolationSummaries(perReportSummaries); - const reportPolicy = getPolicyFromSearchSnapshot(expenseReport.policyID, searchData, policies); - const hasViolations = hasViolationsReportUtils(reportID, allTransactionViolations, accountID, email ?? ''); - const policyToUpgrade = reportPolicy; - const wouldNavigateToUpgrade = isSubmitPolicy(policyToUpgrade) && !!policyToUpgrade?.id; - const wouldNavigateToRestricted = - !!expenseReport.policyID && shouldRestrictUserBillableActions(reportPolicy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, accountID); - - approveMoneyRequest({ - personalDetailsByLogins: getPersonalDetailsByLogin(), - getCurrencyDecimals, - expenseReport, - expenseReportPolicy: reportPolicy, - rules, - currentUserAccountIDParam: accountID, - currentUserEmailParam: email ?? '', - hasViolations, - isASAPSubmitBetaEnabled, - userBillingGracePeriodEnds, - amountOwed, - ownerBillingGracePeriodEnd, - ownerLogin: getLoginByAccountID(expenseReport.ownerAccountID, personalDetails), - delegateEmail, - delegateAccountID, - full: true, - additionalOnyxData: getSearchApproveOnyxData(hash, reportID, currentSearchKey), - shouldPlaySuccessSound: false, - isTrackIntentUser, - allTransactionViolations, - }); + // As with submit, warnings use local violations; reports not loaded by Select all cannot contribute bullets. + confirmApproveViolationsThenProceed({ + summary, + showConfirmModal, + translate, + dateFnsLocale, + convertToDisplayString, + onProceed: () => { + // "Select all" can cover more reports than are loaded, so hand the search query to the backend to approve every match. + if (areAllMatchingItemsSelected) { + if (!queryJSON) { + Log.info('[BulkApprove] Dropping bulk approve: no search query'); + return; + } + const allMatchingQuery = getAllMatchingReportQuery(queryJSON, excludedTransactions); + if (!allMatchingQuery) { + Log.info('[BulkApprove] Dropping bulk approve: report exclusion has no reportID'); + return; + } + queueBulkApproveReports(serializeQueryJSONForBackend(allMatchingQuery)); + playSound(SOUNDS.SUCCESS); + clearSelectedTransactions(); + return; + } - if (!wouldNavigateToUpgrade && !wouldNavigateToRestricted) { - approvedReportCount += 1; - } - } + const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); - if (approvedReportCount > 0) { - playSound(SOUNDS.SUCCESS); - TransitionTracker.runAfterTransitions({ - callback: () => { - clearSelectedTransactions(); - }, - }); - } + let approvedReportCount = 0; + for (const reportID of uniqueReportIDs) { + const expenseReport = getReportFromSearchSnapshot(reportID, searchData, allReports); + if (!expenseReport) { + Log.info('[BulkApprove] Skipping report: expense report not found in the search snapshot or Onyx', false, {reportID}); + continue; + } + + const reportPolicy = getPolicyFromSearchSnapshot(expenseReport.policyID, searchData, policies); + const hasViolations = hasViolationsReportUtils(reportID, allTransactionViolations, accountID, email ?? ''); + const policyToUpgrade = reportPolicy; + const wouldNavigateToUpgrade = isSubmitPolicy(policyToUpgrade) && !!policyToUpgrade?.id; + const wouldNavigateToRestricted = + !!expenseReport.policyID && shouldRestrictUserBillableActions(reportPolicy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, accountID); + + approveMoneyRequest({ + personalDetailsByLogins: getPersonalDetailsByLogin(), + getCurrencyDecimals, + expenseReport, + expenseReportPolicy: reportPolicy, + rules, + currentUserAccountIDParam: accountID, + currentUserEmailParam: email ?? '', + hasViolations, + isASAPSubmitBetaEnabled, + userBillingGracePeriodEnds, + amountOwed, + ownerBillingGracePeriodEnd, + ownerLogin: getLoginByAccountID(expenseReport.ownerAccountID, personalDetails), + delegateEmail, + delegateAccountID, + full: true, + additionalOnyxData: getSearchApproveOnyxData(hash, reportID, currentSearchKey), + shouldPlaySuccessSound: false, + isTrackIntentUser, + allTransactionViolations, + }); + + if (!wouldNavigateToUpgrade && !wouldNavigateToRestricted) { + approvedReportCount += 1; + } + } + + if (approvedReportCount > 0) { + playSound(SOUNDS.SUCCESS); + TransitionTracker.runAfterTransitions({ + callback: () => { + clearSelectedTransactions(); + }, + }); + } + }, + }); }, [ isOffline, showOfflineModal, @@ -1594,6 +1617,11 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { delegateAccountID, getCurrencyDecimals, rules, + allReportsTransactionsAndViolations, + showConfirmModal, + translate, + dateFnsLocale, + convertToDisplayString, ]); const {expenseCount, uniqueReportCount} = useMemo(() => { diff --git a/src/languages/de.ts b/src/languages/de.ts index 4aba5ca993c3..ef0d1e34daf5 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -104,6 +104,7 @@ const translations: TranslationDeepObject = { saveChanges: 'Änderungen speichern', submit: 'Senden', submitAnyway: 'Trotzdem senden', + approveAnyway: 'Trotzdem genehmigen', markAsDone: 'Als erledigt markieren', markAsDoneAnyway: 'Trotzdem als erledigt markieren', submitted: 'Übermittelt', @@ -1538,6 +1539,10 @@ const translations: TranslationDeepObject = { pendingMatch: 'Ausstehende Zuordnung', pendingMatchWithCreditCardDescription: 'Beleg wartet auf Abgleich mit Kartenumsatz. Als Barzahlung markieren, um abzubrechen.', markAsCash: 'Als Bar markieren', + confirmApproveReportViolations: { + title: 'Trotzdem genehmigen?', + description: 'Dieser Bericht enthält Richtlinienverstöße. Sind Sie sicher, dass Sie ihn ohne Änderungen genehmigen möchten?', + }, confirmSubmitReportViolations: { title: 'Bericht einreichen?', description: 'Dieser Bericht enthält Richtlinienverstöße. Möchten Sie ihn wirklich ohne Änderungen einreichen?', diff --git a/src/languages/el.ts b/src/languages/el.ts index 4e6dd1da424f..c8e22fc773a2 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -108,6 +108,7 @@ const translations: TranslationDeepObject = { saveChanges: 'Αποθήκευση αλλαγών', submit: 'Υποβολή', submitAnyway: 'Υποβολή ούτως ή άλλως', + approveAnyway: 'Έγκριση ούτως ή άλλως', markAsDone: 'Σήμανση ως ολοκληρωμένο', markAsDoneAnyway: 'Σήμανση ως ολοκληρωμένο ούτως ή άλλως', submitted: 'Υποβλήθηκε', @@ -1584,6 +1585,10 @@ const translations: TranslationDeepObject = { pendingMatch: 'Εκκρεμής αντιστοίχιση', pendingMatchWithCreditCardDescription: 'Η απόδειξη εκκρεμεί για αντιστοίχιση με συναλλαγή κάρτας. Επισημάνετε την ως μετρητά για ακύρωση.', markAsCash: 'Σήμανση ως μετρητά', + confirmApproveReportViolations: { + title: 'Έγκριση ούτως ή άλλως;', + description: 'Αυτή η αναφορά έχει παραβιάσεις πολιτικής. Είστε βέβαιοι ότι θέλετε να την εγκρίνετε χωρίς να κάνετε αλλαγές;', + }, confirmSubmitReportViolations: { title: 'Υποβολή αναφοράς;', description: 'Αυτή η αναφορά έχει παραβιάσεις πολιτικής. Είστε σίγουροι ότι θέλετε να την υποβάλετε χωρίς αλλαγές;', diff --git a/src/languages/en.ts b/src/languages/en.ts index f9da335d8474..f10663415e30 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -164,6 +164,7 @@ const translations = { saveChanges: 'Save changes', submit: 'Submit', submitAnyway: 'Submit anyway', + approveAnyway: 'Approve anyway', markAsDone: 'Mark as done', markAsDoneAnyway: 'Mark as done anyway', // @context Status label meaning an item has already been sent or submitted (e.g., a form or report). Not the action “to submit.” @@ -1679,6 +1680,10 @@ const translations = { pendingMatch: 'Pending match', pendingMatchWithCreditCardDescription: 'Receipt pending match with card transaction. Mark as cash to cancel.', markAsCash: 'Mark as cash', + confirmApproveReportViolations: { + title: 'Approve anyway?', + description: 'This report has policy violations. Are you sure you want to approve it without making changes?', + }, confirmSubmitReportViolations: { title: 'Submit report?', description: 'This report has policy violations. Are you sure you want to submit it without making changes?', diff --git a/src/languages/es.ts b/src/languages/es.ts index 9cd95934893d..581abecc5765 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -97,6 +97,7 @@ const translations: TranslationDeepObject = { saveChanges: 'Guardar cambios', submit: 'Enviar', submitAnyway: 'Enviar de todos modos', + approveAnyway: 'Aprobar de todos modos', markAsDone: 'Marcar como listo', markAsDoneAnyway: 'Marcar como listo de todos modos', submitted: 'Enviado', @@ -1512,6 +1513,10 @@ const translations: TranslationDeepObject = { pendingMatchWithCreditCard: 'Recibo pendiente de adjuntar con la transacción de la tarjeta', pendingMatchWithCreditCardDescription: 'Recibo pendiente de adjuntar con la transacción de la tarjeta. Márcalo como efectivo para cancelar.', markAsCash: 'Marcar como efectivo', + confirmApproveReportViolations: { + title: '¿Aprobar de todas formas?', + description: 'Este informe tiene infracciones de la política. ¿Seguro que quieres aprobarlo sin hacer cambios?', + }, confirmSubmitReportViolations: { title: '¿Enviar informe?', description: 'Este informe tiene infracciones de política. ¿Seguro que quieres enviarlo sin hacer cambios?', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 248e45bb05d4..5cfee694ed71 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -104,6 +104,7 @@ const translations: TranslationDeepObject = { saveChanges: 'Enregistrer les modifications', submit: 'Soumettre', submitAnyway: 'Soumettre quand même', + approveAnyway: 'Approuver quand même', markAsDone: 'Marquer comme terminé', markAsDoneAnyway: 'Marquer comme terminé quand même', submitted: 'Soumis', @@ -1546,6 +1547,10 @@ const translations: TranslationDeepObject = { pendingMatch: 'Correspondance en attente', pendingMatchWithCreditCardDescription: 'Reçu en attente de rapprochement avec une transaction par carte. Marquez comme paiement en espèces pour annuler.', markAsCash: 'Marquer comme espèces', + confirmApproveReportViolations: { + title: 'Approuver quand même ?', + description: 'Cette note de frais comporte des violations de politique. Voulez-vous vraiment l’approuver sans y apporter de modifications ?', + }, confirmSubmitReportViolations: { title: 'Soumettre le rapport ?', description: 'Ce rapport contient des violations de politique. Voulez-vous vraiment le soumettre sans apporter de modifications ?', diff --git a/src/languages/it.ts b/src/languages/it.ts index e912d1345d80..e5b0687c1dd8 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -104,6 +104,7 @@ const translations: TranslationDeepObject = { saveChanges: 'Salva modifiche', submit: 'Invia', submitAnyway: 'Invia comunque', + approveAnyway: 'Approva comunque', markAsDone: 'Segna come completata', markAsDoneAnyway: 'Segna comunque come completata', submitted: 'Inviato', @@ -1537,6 +1538,10 @@ const translations: TranslationDeepObject = { pendingMatch: 'Corrispondenza in sospeso', pendingMatchWithCreditCardDescription: 'Ricevuta in attesa di abbinamento con la transazione della carta. Contrassegna come contante per annullare.', markAsCash: 'Segna come contante', + confirmApproveReportViolations: { + title: 'Approvi comunque?', + description: 'Questo report presenta violazioni della policy. Vuoi davvero approvarlo senza apportare modifiche?', + }, confirmSubmitReportViolations: { title: 'Invia il rapporto?', description: 'Questo rapporto presenta violazioni delle norme. Sei sicuro di volerlo inviare senza apportare modifiche?', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 78f0aadca0f2..254d0724c039 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -104,6 +104,7 @@ const translations: TranslationDeepObject = { saveChanges: '変更を保存', submit: '送信', submitAnyway: 'とにかく送信', + approveAnyway: 'このまま承認する', markAsDone: '完了にする', markAsDoneAnyway: 'とにかく完了にする', submitted: '送信済み', @@ -1520,6 +1521,10 @@ const translations: TranslationDeepObject = { pendingMatch: '保留中の照合', pendingMatchWithCreditCardDescription: 'レシートはカード取引との照合待ちです。現金としてマークしてキャンセルします。', markAsCash: '現金としてマーク', + confirmApproveReportViolations: { + title: 'それでも承認しますか?', + description: 'このレポートにはポリシー違反があります。変更を行わずに承認してもよろしいですか?', + }, confirmSubmitReportViolations: { title: 'レポートを送信しますか?', description: 'このレポートにはポリシー違反があります。変更を加えずに送信してもよろしいですか?', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 8ee2a67d6c38..06cabcfb099a 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -104,6 +104,7 @@ const translations: TranslationDeepObject = { saveChanges: 'Wijzigingen opslaan', submit: 'Verzenden', submitAnyway: 'Toch verzenden', + approveAnyway: 'Toch goedkeuren', markAsDone: 'Markeren als voltooid', markAsDoneAnyway: 'Toch markeren als voltooid', submitted: 'Ingediend', @@ -1535,6 +1536,10 @@ const translations: TranslationDeepObject = { pendingMatch: 'Overeenkomst in behandeling', pendingMatchWithCreditCardDescription: 'Bon wordt nog gekoppeld aan kaarttransactie. Markeer als contant om te annuleren.', markAsCash: 'Markeren als contant', + confirmApproveReportViolations: { + title: 'Toch goedkeuren?', + description: 'Dit rapport bevat beleidsinbreuken. Weet je zeker dat je het wilt goedkeuren zonder wijzigingen aan te brengen?', + }, confirmSubmitReportViolations: { title: 'Rapport verzenden?', description: 'Dit rapport heeft beleidsschendingen. Weet u zeker dat u het wilt verzenden zonder wijzigingen aan te brengen?', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 742b6b5a3b07..1e41d5d1a171 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -108,6 +108,7 @@ const translations: TranslationDeepObject = { saveChanges: 'Zapisz zmiany', submit: 'Wyślij', submitAnyway: 'Wyślij mimo to', + approveAnyway: 'Zatwierdź mimo to', markAsDone: 'Oznacz jako wykonane', markAsDoneAnyway: 'Oznacz jako wykonane mimo to', submitted: 'Przesłano', @@ -1575,6 +1576,10 @@ const translations: TranslationDeepObject = { pendingMatch: 'Oczekujące dopasowanie', pendingMatchWithCreditCardDescription: 'Oczekuje na dopasowanie paragonu do transakcji kartą. Oznacz jako gotówkę, aby anulować.', markAsCash: 'Oznacz jako gotówkę', + confirmApproveReportViolations: { + title: 'Zatwierdzić mimo to?', + description: 'Ten raport zawiera naruszenia zasad. Na pewno chcesz go zatwierdzić bez wprowadzania zmian?', + }, confirmSubmitReportViolations: { title: 'Wysłać raport?', description: 'Ten raport zawiera naruszenia zasad. Czy na pewno chcesz go wysłać bez wprowadzania zmian?', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 2a6ea32e406f..1643c7c5360b 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -104,6 +104,7 @@ const translations: TranslationDeepObject = { saveChanges: 'Salvar alterações', submit: 'Enviar', submitAnyway: 'Enviar mesmo assim', + approveAnyway: 'Aprovar mesmo assim', markAsDone: 'Marcar como concluído', markAsDoneAnyway: 'Marcar como concluído mesmo assim', submitted: 'Enviado', @@ -1537,6 +1538,10 @@ const translations: TranslationDeepObject = { pendingMatch: 'Correspondência pendente', pendingMatchWithCreditCardDescription: 'Recibo aguardando correspondência com transação do cartão. Marque como dinheiro para cancelar.', markAsCash: 'Marcar como dinheiro', + confirmApproveReportViolations: { + title: 'Aprovar mesmo assim?', + description: 'Este relatório tem violações da política. Tem certeza de que quer aprová‑lo sem fazer alterações?', + }, confirmSubmitReportViolations: { title: 'Enviar relatório?', description: 'Este relatório tem violações de política. Tem certeza de que deseja enviá-lo sem fazer alterações?', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 772926d51ff1..d59c660af94e 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -104,6 +104,7 @@ const translations: TranslationDeepObject = { saveChanges: '保存更改', submit: '提交', submitAnyway: '仍然提交', + approveAnyway: '仍然批准', markAsDone: '标记为完成', markAsDoneAnyway: '仍然标记为完成', submitted: '已提交', @@ -1473,6 +1474,10 @@ const translations: TranslationDeepObject = { pendingMatch: '待匹配', pendingMatchWithCreditCardDescription: '收据正在等待与卡片交易匹配。将其标记为现金以取消。', markAsCash: '标记为现金', + confirmApproveReportViolations: { + title: '仍然批准?', + description: '此报表存在策略违规。确定要在不进行任何更改的情况下批准吗?', + }, confirmSubmitReportViolations: { title: '提交报告?', description: '此报告存在违反政策的情况。您确定要不做任何更改就提交吗?', diff --git a/src/libs/Violations/confirmApproveViolationsThenProceed.tsx b/src/libs/Violations/confirmApproveViolationsThenProceed.tsx new file mode 100644 index 000000000000..52149a84d318 --- /dev/null +++ b/src/libs/Violations/confirmApproveViolationsThenProceed.tsx @@ -0,0 +1,48 @@ +/** Reuses the submit warning's bullet list without resolving violations when approving. */ +import type {LocaleContextProps} from '@components/LocaleContextProvider'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; +import SubmitViolationsList from '@components/SubmitViolationsList'; + +import type {CurrencyListActionsContextType} from '@hooks/useCurrencyList'; + +import showConfirmModalAfterMoreMenuDismiss from '@libs/showConfirmModalAfterMoreMenuDismiss'; + +import CONST from '@src/CONST'; + +import type getReportApproveViolationSummary from './getReportApproveViolationSummary'; + +import {buildSubmitViolationBullets, hasAnySubmitViolation} from './getReportSubmitViolationSummary'; + +type ConfirmApproveViolationsThenProceedParams = { + summary: ReturnType; + showConfirmModal: Parameters[0]; + translate: LocaleContextProps['translate']; + dateFnsLocale: LocaleContextProps['dateFnsLocale']; + convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString']; + onProceed: () => void; +}; + +function confirmApproveViolationsThenProceed({summary, showConfirmModal, translate, dateFnsLocale, convertToDisplayString, onProceed}: ConfirmApproveViolationsThenProceedParams) { + if (!hasAnySubmitViolation(summary)) { + onProceed(); + return; + } + + const bullets = buildSubmitViolationBullets({summary, translate, dateFnsLocale, convertToDisplayString}); + showConfirmModalAfterMoreMenuDismiss(showConfirmModal, { + title: translate('iou.confirmApproveReportViolations.title'), + subtitle: translate('iou.confirmApproveReportViolations.description'), + prompt: , + confirmText: translate('common.approveAnyway'), + cancelText: translate('common.cancel'), + buttonVariant: CONST.BUTTON_VARIANT.DANGER, + shouldEnablePromptScroll: true, + }).then((result) => { + if (result.action !== ModalActions.CONFIRM) { + return; + } + onProceed(); + }); +} + +export default confirmApproveViolationsThenProceed; diff --git a/src/libs/Violations/getReportApprovalWarningData.ts b/src/libs/Violations/getReportApprovalWarningData.ts new file mode 100644 index 000000000000..394ab3303167 --- /dev/null +++ b/src/libs/Violations/getReportApprovalWarningData.ts @@ -0,0 +1,31 @@ +/** Resolves report-scoped approval warnings from Search snapshots and fresher live entries. */ +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Transaction, TransactionViolations} from '@src/types/onyx'; + +import type {OnyxCollection} from 'react-native-onyx'; + +function getReportApprovalWarningData( + reportID: string | undefined, + snapshotTransactions: Transaction[] | undefined, + liveTransactions: Transaction[], + snapshotViolations: OnyxCollection, + liveViolations: OnyxCollection, +) { + const transactionsByID = new Map(); + for (const transaction of [...(snapshotTransactions ?? []), ...liveTransactions]) { + if (transaction.reportID !== reportID) { + continue; + } + transactionsByID.set(transaction.transactionID, transaction); + } + + const violations: OnyxCollection = {}; + for (const transactionID of transactionsByID.keys()) { + const key = `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}` as const; + // An empty live array clears stale snapshot warnings; selector-created undefined entries do not. + violations[key] = liveViolations?.[key] !== undefined ? liveViolations[key] : snapshotViolations?.[key]; + } + return {transactions: [...transactionsByID.values()], violations}; +} + +export default getReportApprovalWarningData; diff --git a/src/libs/Violations/getReportApproveViolationSummary.ts b/src/libs/Violations/getReportApproveViolationSummary.ts new file mode 100644 index 000000000000..89a91099f507 --- /dev/null +++ b/src/libs/Violations/getReportApproveViolationSummary.ts @@ -0,0 +1,45 @@ +/** Builds approval warnings from local data without the submitter's rejection or card-match warnings. */ +import {getTransactionViolations, isOnHold} from '@libs/TransactionUtils'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy, Report, Transaction, TransactionViolations} from '@src/types/onyx'; + +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; + +import {getReportSubmitViolationSummary} from './getReportSubmitViolationSummary'; + +function getReportApproveViolationSummary( + transactions: Array>, + violationsCollection: OnyxCollection, + report: OnyxEntry, + policy: OnyxEntry, + currentUserEmail: string, + currentUserAccountID: number, + full = true, +) { + const approvalTransactions: Transaction[] = []; + const approvalViolations: OnyxCollection = {}; + for (const transaction of transactions) { + if (!transaction?.transactionID || (!full && isOnHold(transaction))) { + continue; + } + approvalTransactions.push(transaction); + // Raw overrides must respect the approver's dismissals before warnings are classified or deduplicated. + approvalViolations[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transaction.transactionID}`] = getTransactionViolations( + transaction, + violationsCollection, + currentUserEmail, + currentUserAccountID, + report, + undefined, + policy, + ); + } + + const summary = getReportSubmitViolationSummary(approvalTransactions, approvalViolations, report, policy, currentUserEmail, currentUserAccountID, full); + + // These warnings require acknowledgement by the submitter, not the approver. + return {...summary, hasRejectedExpense: false, hasReportBeenRejected: false, hasPendingCardMatch: false}; +} + +export default getReportApproveViolationSummary; diff --git a/src/libs/Violations/getReportSubmitViolationSummary.ts b/src/libs/Violations/getReportSubmitViolationSummary.ts index c0dd445cbef6..cf72e230e086 100644 --- a/src/libs/Violations/getReportSubmitViolationSummary.ts +++ b/src/libs/Violations/getReportSubmitViolationSummary.ts @@ -40,14 +40,20 @@ type ReportSubmitViolationSummary = { }; /** Whether the "Submit report?" modal should warn about the required fields this expense has left empty. */ -function shouldWarnAboutMissingFields(transaction: Transaction, transactionViolations: OnyxEntry, report: OnyxEntry, policy: OnyxEntry): boolean { +function shouldWarnAboutMissingFields( + transaction: Transaction, + transactionViolations: OnyxEntry, + report: OnyxEntry, + policy: OnyxEntry, + shouldIncludeHeldExpenses: boolean, +): boolean { // These warnings are only for expense reports, where the merchant is required. if (!isExpenseReport(report)) { return false; } // Submitting moves a held expense to a new draft report, except on a dynamic external workflow, so it isn't part of this submission. - if (isOnHold(transaction) && !hasDynamicExternalWorkflow(policy)) { + if (!shouldIncludeHeldExpenses && isOnHold(transaction) && !hasDynamicExternalWorkflow(policy)) { return false; } @@ -71,6 +77,8 @@ function getReportSubmitViolationSummary( policy: OnyxEntry, currentUserEmail: string, currentUserAccountID: number, + // Full approval includes held expenses, whereas submission normally moves them to another report. + shouldIncludeHeldExpenses = false, ): ReportSubmitViolationSummary { let hasRejectedExpense = false; let hasPendingCardMatch = false; @@ -85,7 +93,7 @@ function getReportSubmitViolationSummary( const transactionViolations = violationsCollection?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transaction.transactionID}`]; // These fields have no violation, so this has to run before the check below skips expenses without violations. - if (shouldWarnAboutMissingFields(transaction, transactionViolations, report, policy)) { + if (shouldWarnAboutMissingFields(transaction, transactionViolations, report, policy, shouldIncludeHeldExpenses)) { hasMissingMerchant ||= isMerchantMissing(transaction); // Expense reports allow a zero amount, so only a failed scan's placeholder counts as a missing amount. hasMissingAmount ||= isFailedScanAmountPlaceholder(transaction); diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 82000f579297..cedb26b28ccc 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -280,6 +280,8 @@ type HandleActionButtonPressParams = { onAllHeldExpensesBlock?: () => void; /** Confirms any unacknowledged report violations before proceeding with the SUBMIT action. */ confirmSubmitReportViolations: (onProceed: ConfirmSubmitReportViolationsOnProceed) => void; + /** Confirms report violations before the APPROVE action proceeds; approval is skipped if omitted. */ + confirmApproveReportViolations?: (onProceed: () => void) => void; openReportSubmitToPopover?: (options?: ReportSubmitToPopoverOpenOptions) => void; currentUserAccountID: number; currentUserLogin?: string; @@ -330,6 +332,7 @@ function handleActionButtonPress({ onUndelete, onAllHeldExpensesBlock, confirmSubmitReportViolations, + confirmApproveReportViolations, currentUserAccountID, openReportSubmitToPopover, currentUserLogin, @@ -419,28 +422,30 @@ function handleActionButtonPress({ Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(snapshotReport.policyID)); return; } - getApproveActionCallback({ - hash, - item, - snapshotReport, - snapshotPolicy, - policy, - currentSearchKey, - currentUserAccountID, - currentUserLogin, - isASAPSubmitBetaEnabled, - userBillingGracePeriodEnds, - ownerBillingGracePeriodEnd, - amountOwed, - delegateEmail, - delegateAccountID, - isTrackIntentUser, - ownerLogin: submitterLogin, - allViolations, - rules, - getCurrencyDecimals, - personalDetailsByLogins, - }); + confirmApproveReportViolations?.(() => + getApproveActionCallback({ + hash, + item, + snapshotReport, + snapshotPolicy, + policy, + currentSearchKey, + currentUserAccountID, + currentUserLogin, + isASAPSubmitBetaEnabled, + userBillingGracePeriodEnds, + ownerBillingGracePeriodEnd, + amountOwed, + delegateEmail, + delegateAccountID, + isTrackIntentUser, + ownerLogin: submitterLogin, + allViolations, + rules, + getCurrencyDecimals, + personalDetailsByLogins, + }), + ); return; case CONST.SEARCH.ACTION_TYPES.SUBMIT: { if (snapshotReport.policyID && shouldRestrictUserBillableActions(policy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, currentUserAccountID)) { diff --git a/tests/ui/ReportListItemHeaderTest.tsx b/tests/ui/ReportListItemHeaderTest.tsx index 7a56f8e53df2..06b12607444f 100644 --- a/tests/ui/ReportListItemHeaderTest.tsx +++ b/tests/ui/ReportListItemHeaderTest.tsx @@ -3,13 +3,16 @@ import {act, render, screen} from '@testing-library/react-native'; import ComposeProviders from '@components/ComposeProviders'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import ExpenseReportListItem from '@components/Search/SearchList/ListItem/ExpenseReportListItem'; import ReportListItemHeader from '@components/Search/SearchList/ListItem/ReportListItemHeader'; -import type {TransactionReportGroupListItemType} from '@components/Search/SearchList/ListItem/types'; +import type {ExpenseReportListItemType, TransactionReportGroupListItemType} from '@components/Search/SearchList/ListItem/types'; import type {SearchActionsContextValue, SearchStateContextValue} from '@components/Search/types'; +import useConfirmApproveReportViolations from '@hooks/useConfirmApproveReportViolations'; + import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {PersonalDetails} from '@src/types/onyx'; +import type {PersonalDetails, Transaction, TransactionViolations} from '@src/types/onyx'; import type {ValueOf} from 'type-fest'; @@ -26,6 +29,10 @@ import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct' jest.mock('@components/ConfirmedRoute.tsx'); jest.mock('@libs/Navigation/Navigation'); jest.mock('@components/AvatarWithDisplayName.tsx'); +jest.mock('@hooks/useConfirmApproveReportViolations', () => ({ + __esModule: true, + default: jest.fn(jest.requireActual<{default: typeof useConfirmApproveReportViolations}>('@hooks/useConfirmApproveReportViolations').default), +})); jest.mock('@hooks/usePaymentContext', () => { const {default: mockUsePaymentContext} = jest.requireActual('../utils/mockUsePaymentContext'); @@ -133,20 +140,28 @@ const createReportListItem = ( }); // Helper function to wrap component with context -const renderReportListItemHeader = (reportItem: TransactionReportGroupListItemType) => { +const renderReportListItemHeader = (reportItem: TransactionReportGroupListItemType, searchState: SearchStateContextValue = mockSearchStateContext, renderExpenseReportRow = false) => { return render( - + {renderExpenseReportRow ? ( + (reportItem)} + onSelectRow={jest.fn()} + showTooltip={false} + /> + ) : ( + + )} , ); @@ -167,6 +182,29 @@ describe('ReportListItemHeader', () => { jest.clearAllMocks(); }); + it.each([false, true])('passes snapshot-only expenses and violations to approval (expense report row: %s)', async (renderExpenseReportRow) => { + // Given an unopened report whose expenses and warnings exist only in Search results + const reportItem = createReportListItem(CONST.REPORT.TYPE.EXPENSE); + const transaction = createMock({transactionID: 'snapshot-only', reportID: reportItem.reportID, merchant: 'Merchant', amount: 100, currency: 'USD'}); + const violations: TransactionViolations = [{name: CONST.VIOLATIONS.DUPLICATED_TRANSACTION, type: CONST.VIOLATION_TYPES.WARNING}]; + const key = `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transaction.transactionID}` as const; + + // When the report header renders without any corresponding live transaction or violation record + renderReportListItemHeader( + reportItem, + { + ...mockSearchStateContext, + currentSearchTransactionsByReportID: new Map([[reportItem.reportID, [transaction]]]), + currentSearchViolations: {[key]: violations}, + }, + renderExpenseReportRow, + ); + await waitForBatchedUpdatesWithAct(); + + // Then the warning hook receives the same snapshot data rather than an empty live collection + expect(useConfirmApproveReportViolations).toHaveBeenCalledWith(expect.objectContaining({transactions: [transaction], violationsCollection: {[key]: violations}})); + }); + describe('UserInfoCellsWithArrow', () => { describe('when report type is IOU', () => { it('should display only submitter without recipient on narrow layout', async () => { diff --git a/tests/ui/components/ApproveActionButtonTest.tsx b/tests/ui/components/ApproveActionButtonTest.tsx index cef504960335..7dff002cd4e2 100644 --- a/tests/ui/components/ApproveActionButtonTest.tsx +++ b/tests/ui/components/ApproveActionButtonTest.tsx @@ -1,8 +1,11 @@ import {act, render} from '@testing-library/react-native'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; import ApproveActionButton from '@components/ReportActionItem/MoneyRequestReportPreview/ApproveActionButton'; +import type useConfirmModal from '@hooks/useConfirmModal'; import useOnyx from '@hooks/useOnyx'; +import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; import {hasHeldExpensesFromTransactions} from '@libs/ReportUtils'; @@ -10,13 +13,18 @@ import {approveMoneyRequest} from '@userActions/IOU/ReportWorkflow'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Report, TransactionViolations} from '@src/types/onyx'; +import type {Report, Transaction, TransactionViolations} from '@src/types/onyx'; import type {OnyxCollection, UseOnyxResult} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import React from 'react'; +import createMock from '../../utils/createMock'; + +type ShowConfirmModal = ReturnType['showConfirmModal']; +type ModalResult = Awaited>; + const TEST_IOU_REPORT_ID = '1001'; const iouReport = { @@ -108,14 +116,32 @@ jest.mock('@hooks/useReportTransactionsCollection', () => ({ default: jest.fn(() => ({})), })); jest.mock('@hooks/useOnyx', () => jest.fn()); +jest.mock('@hooks/useTransactionsAndViolationsForReport', () => ({ + __esModule: true, + default: jest.fn(), +})); + +const mockShowConfirmModal = jest.fn, Parameters>(); +jest.mock('@hooks/useConfirmModal', () => ({ + __esModule: true, + default: () => ({showConfirmModal: mockShowConfirmModal}), +})); +// Keep modal presentation synchronous here; transition timing has separate coverage. +jest.mock('@libs/showConfirmModalAfterMoreMenuDismiss', () => ({ + __esModule: true, + default: (showConfirmModal: ShowConfirmModal, options: Parameters[0]) => showConfirmModal(options), +})); const mockedUseOnyx = jest.mocked(useOnyx); +const mockedUseTransactionsAndViolationsForReport = jest.mocked(useTransactionsAndViolationsForReport); const mockedApproveMoneyRequest = jest.mocked(approveMoneyRequest); const mockedHasHeldExpenses = jest.mocked(hasHeldExpensesFromTransactions); describe('ApproveActionButton', () => { beforeEach(() => { jest.clearAllMocks(); + mockedUseTransactionsAndViolationsForReport.mockReturnValue({transactions: {}, violations: {}, isLoaded: true}); + mockShowConfirmModal.mockReset().mockResolvedValue({action: ModalActions.CLOSE}); mockApprovalButtonProps.current = undefined; mockReportPreviewAction = CONST.REPORT.REPORT_PREVIEW_ACTIONS.APPROVE; mockIsDelegateAccessRestricted = false; @@ -211,4 +237,40 @@ describe('ApproveActionButton', () => { expect(mockShowDelegateNoAccessModal).toHaveBeenCalled(); expect(mockedApproveMoneyRequest).not.toHaveBeenCalled(); }); + + it.each([ModalActions.CLOSE, ModalActions.CONFIRM])('waits for the violation modal and handles %s without bypassing confirmation', async (action) => { + // Given a report with a visible warning and a modal that has not been answered yet + const transaction = createMock({transactionID: '1', reportID: TEST_IOU_REPORT_ID, merchant: 'Merchant', amount: 1000, currency: 'USD'}); + mockedUseTransactionsAndViolationsForReport.mockReturnValue({ + transactions: {[`${ONYXKEYS.COLLECTION.TRANSACTION}1`]: transaction}, + violations: { + [`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}1`]: [{name: CONST.VIOLATIONS.DUPLICATED_TRANSACTION, type: CONST.VIOLATION_TYPES.WARNING}], + }, + isLoaded: true, + }); + let resolveModal: ((result: ModalResult) => void) | undefined; + mockShowConfirmModal.mockReturnValue( + new Promise((resolve) => { + resolveModal = resolve; + }), + ); + render(); + + // When approval is requested, it must wait for the user's response + act(() => mockApprovalButtonProps.current?.onApprove(true)); + expect(mockShowConfirmModal).toHaveBeenCalledWith(expect.objectContaining({confirmText: 'common.approveAnyway'})); + expect(mockedApproveMoneyRequest).not.toHaveBeenCalled(); + await act(async () => { + resolveModal?.({action}); + }); + + // Then closing the warning cancels approval, and explicit confirmation approves exactly once + if (action === ModalActions.CONFIRM) { + expect(mockedApproveMoneyRequest).toHaveBeenCalledTimes(1); + expect(mockedApproveMoneyRequest).toHaveBeenCalledWith(expect.objectContaining({expenseReport: iouReport, full: true, onApproved: mockStartApprovedAnimation})); + } else { + expect(mockedApproveMoneyRequest).not.toHaveBeenCalled(); + expect(mockStartApprovedAnimation).not.toHaveBeenCalled(); + } + }); }); diff --git a/tests/unit/Search/TransactionListItemViolationsTest.tsx b/tests/unit/Search/TransactionListItemViolationsTest.tsx index 06dca1f672b5..9f28678ab789 100644 --- a/tests/unit/Search/TransactionListItemViolationsTest.tsx +++ b/tests/unit/Search/TransactionListItemViolationsTest.tsx @@ -7,6 +7,8 @@ import type * as SearchContextModule from '@components/Search/SearchContext'; import TransactionListItem from '@components/Search/SearchList/ListItem/TransactionListItem'; import type {TransactionListItemType} from '@components/Search/SearchList/ListItem/types'; +import useConfirmApproveReportViolations from '@hooks/useConfirmApproveReportViolations'; + import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {TransactionViolation} from '@src/types/onyx/TransactionViolation'; @@ -25,6 +27,11 @@ const TRANSACTION_VIOLATIONS_KEY = `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS const mockSearchResults: {current: {data: Record} | undefined} = {current: undefined}; +jest.mock('@hooks/useConfirmApproveReportViolations', () => ({ + __esModule: true, + default: jest.fn(), +})); + jest.mock('@components/Search/SearchContext', () => ({ ...jest.requireActual('@components/Search/SearchContext'), useSearchQueryContext: () => ({currentSearchHash: 1, currentSearchKey: undefined}), @@ -125,4 +132,17 @@ describe('TransactionListItem violations', () => { expect(screen.getByText(CONST.VIOLATIONS.OVER_LIMIT)).toBeTruthy(); expect(screen.queryByText(CONST.VIOLATIONS.MISSING_TAG)).toBeNull(); }); + + it('does not initialize report approval confirmation for a transaction row', async () => { + // Given transaction rows only offer View, so report approval data is unnecessary + jest.mocked(useConfirmApproveReportViolations).mockClear(); + + // When a transaction row with violations renders + renderRow(); + await waitForBatchedUpdatesWithAct(); + + // Then the row does not initialize the report-wide approval hook + expect(useConfirmApproveReportViolations).not.toHaveBeenCalled(); + expect(screen.getByText(CONST.VIOLATIONS.MISSING_TAG)).toBeTruthy(); + }); }); diff --git a/tests/unit/Search/handleActionButtonPressTest.ts b/tests/unit/Search/handleActionButtonPressTest.ts index e167062ebf3b..ba3f5f1887b5 100644 --- a/tests/unit/Search/handleActionButtonPressTest.ts +++ b/tests/unit/Search/handleActionButtonPressTest.ts @@ -369,6 +369,7 @@ describe('handleActionButtonPress', () => { rules: undefined, bankAccountList: undefined, confirmSubmitReportViolations: jest.fn(), + confirmApproveReportViolations: (onProceed) => onProceed(), personalDetailsByLogins: {}, }); @@ -406,6 +407,7 @@ describe('handleActionButtonPress', () => { rules: undefined, bankAccountList: undefined, confirmSubmitReportViolations: jest.fn(), + confirmApproveReportViolations: (onProceed) => onProceed(), personalDetailsByLogins: {}, }); expect(goToItem).toHaveBeenCalledTimes(0); @@ -449,6 +451,7 @@ describe('handleActionButtonPress', () => { getCurrencyDecimals: getCurrencyDecimalsLocal, bankAccountList: undefined, confirmSubmitReportViolations: jest.fn(), + confirmApproveReportViolations: (onProceed) => onProceed(), }); // Then: hasViolations is evaluated against the passed collection, proving the deprecated global getter is no longer used, diff --git a/tests/unit/confirmApproveViolationsThenProceedTest.ts b/tests/unit/confirmApproveViolationsThenProceedTest.ts new file mode 100644 index 000000000000..08e45f9a7e53 --- /dev/null +++ b/tests/unit/confirmApproveViolationsThenProceedTest.ts @@ -0,0 +1,104 @@ +import type {LocaleContextProps} from '@components/LocaleContextProvider'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; + +import showConfirmModalAfterMoreMenuDismiss from '@libs/showConfirmModalAfterMoreMenuDismiss'; +import confirmApproveViolationsThenProceed from '@libs/Violations/confirmApproveViolationsThenProceed'; +import type getReportApproveViolationSummary from '@libs/Violations/getReportApproveViolationSummary'; + +import CONST from '@src/CONST'; + +import createMock from '../utils/createMock'; + +jest.mock('@components/Modal/Global/ModalContext', () => ({ModalActions: {CONFIRM: 'CONFIRM', CLOSE: 'CLOSE'}})); +jest.mock('@components/SubmitViolationsList', () => 'SubmitViolationsList'); +jest.mock('@libs/showConfirmModalAfterMoreMenuDismiss', () => ({__esModule: true, default: jest.fn()})); +jest.mock('@libs/Violations/getReportSubmitViolationSummary', () => ({ + hasAnySubmitViolation: (summary: {otherViolations: Map}) => summary.otherViolations.size > 0, + buildSubmitViolationBullets: () => ['Receipt required'], +})); + +type ApprovalSummary = ReturnType; + +const translate = createMock(jest.fn((...args: [string, ...unknown[]]) => args[0])); +const dateFnsLocale = createMock({}); + +function createSummary(hasViolations = false): ApprovalSummary { + const otherViolations: ApprovalSummary['otherViolations'] = new Map(); + if (hasViolations) { + otherViolations.set(CONST.VIOLATIONS.RECEIPT_REQUIRED, {name: CONST.VIOLATIONS.RECEIPT_REQUIRED, type: CONST.VIOLATION_TYPES.WARNING}); + } + + return { + hasRejectedExpense: false, + hasReportBeenRejected: false, + hasPendingCardMatch: false, + hasMissingMerchant: false, + hasMissingAmount: false, + otherViolations, + }; +} + +function confirmApproval(summary: ApprovalSummary, onProceed: () => void) { + confirmApproveViolationsThenProceed({summary, onProceed, showConfirmModal: jest.fn(), translate, dateFnsLocale, convertToDisplayString: jest.fn()}); +} + +describe('confirmApproveViolationsThenProceed', () => { + beforeEach(() => jest.clearAllMocks()); + + it('approves immediately without a warning for a clean report', () => { + // Given a report without locally available violations + const onProceed = jest.fn(); + + // When approval is requested + confirmApproval(createSummary(), onProceed); + + // Then no confirmation is necessary + expect(onProceed).toHaveBeenCalledWith(); + expect(showConfirmModalAfterMoreMenuDismiss).not.toHaveBeenCalled(); + }); + + it('waits for explicit confirmation and leaves the violations unchanged', async () => { + // Given a report with a policy violation and a modal the approver has not answered + const summary = createSummary(true); + const onProceed = jest.fn(); + let confirm: (result: {action: typeof ModalActions.CONFIRM}) => void = () => {}; + jest.mocked(showConfirmModalAfterMoreMenuDismiss).mockReturnValue( + new Promise((resolve) => { + confirm = resolve; + }), + ); + + // When the approver requests approval, then confirms the warning + confirmApproval(summary, onProceed); + expect(onProceed).not.toHaveBeenCalled(); + expect(showConfirmModalAfterMoreMenuDismiss).toHaveBeenCalledWith( + expect.any(Function), + expect.objectContaining({ + title: 'iou.confirmApproveReportViolations.title', + subtitle: 'iou.confirmApproveReportViolations.description', + confirmText: 'common.approveAnyway', + cancelText: 'common.cancel', + }), + ); + confirm({action: ModalActions.CONFIRM}); + await Promise.resolve(); + + // Then approval proceeds once without a flag to resolve violations + expect(onProceed).toHaveBeenCalledTimes(1); + expect(onProceed).toHaveBeenCalledWith(); + expect(summary.otherViolations.size).toBe(1); + }); + + it('does not approve when the warning is cancelled or dismissed', async () => { + // Given a report with violations and a cancelled modal + const onProceed = jest.fn(); + jest.mocked(showConfirmModalAfterMoreMenuDismiss).mockResolvedValue(createMock({action: ModalActions.CLOSE})); + + // When approval is requested + confirmApproval(createSummary(true), onProceed); + await Promise.resolve(); + + // Then no approval side effects run + expect(onProceed).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/getReportApprovalWarningDataTest.ts b/tests/unit/getReportApprovalWarningDataTest.ts new file mode 100644 index 000000000000..ec19753e8f49 --- /dev/null +++ b/tests/unit/getReportApprovalWarningDataTest.ts @@ -0,0 +1,96 @@ +import getReportApprovalWarningData from '@libs/Violations/getReportApprovalWarningData'; +import getReportApproveViolationSummary from '@libs/Violations/getReportApproveViolationSummary'; +import {hasAnySubmitViolation} from '@libs/Violations/getReportSubmitViolationSummary'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Transaction, TransactionViolation} from '@src/types/onyx'; + +import createMock from '../utils/createMock'; + +const REPORT_ID = '1001'; +const APPROVER_EMAIL = 'approver@example.com'; +const violationKey = `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}1` as const; +const transaction = createMock({transactionID: '1', reportID: REPORT_ID, merchant: 'Merchant', amount: 1000, currency: 'USD'}); +const violation: TransactionViolation = {name: CONST.VIOLATIONS.DUPLICATED_TRANSACTION, type: CONST.VIOLATION_TYPES.WARNING}; + +describe('getReportApprovalWarningData', () => { + it('warns about a snapshot-only report that has never been opened', () => { + // Given a Search transaction and violation that have not hydrated into live Onyx + const snapshotViolations = {[violationKey]: [violation]}; + + // When approval resolves warning data from the snapshot + const data = getReportApprovalWarningData(REPORT_ID, [transaction], [], snapshotViolations, {}); + const summary = getReportApproveViolationSummary(data.transactions, data.violations, undefined, undefined, APPROVER_EMAIL, 1); + + // Then the report still requires confirmation + expect(summary.otherViolations.get(violation.name)).toBe(violation); + expect(hasAnySubmitViolation(summary)).toBe(true); + expect(snapshotViolations[violationKey]).toEqual([violation]); + }); + + it('keeps snapshot siblings while preferring live transactions by ID', () => { + // Given a report with one updated live expense and another snapshot-only expense + const sibling = {...transaction, transactionID: '2'}; + const liveTransaction = {...transaction, amount: 2000}; + + // When both transaction sources are resolved + const data = getReportApprovalWarningData(REPORT_ID, [transaction, sibling], [liveTransaction], {}, {}); + + // Then there are no duplicates and no missing snapshot-only siblings + expect(data.transactions).toEqual([liveTransaction, sibling]); + expect(transaction.amount).toBe(1000); + }); + + it('does not resurrect snapshot violations after a live clear', () => { + // Given an authoritative live clear and a stale snapshot warning + const snapshotViolations = {[violationKey]: [violation]}; + + // When a live empty array is present + const data = getReportApprovalWarningData(REPORT_ID, [transaction], [], snapshotViolations, {[violationKey]: []}); + + // Then the snapshot warning stays cleared + expect(data.violations?.[violationKey]).toEqual([]); + expect(hasAnySubmitViolation(getReportApproveViolationSummary(data.transactions, data.violations, undefined, undefined, '', 1))).toBe(false); + }); + + it('falls back when the live selector creates an undefined entry', () => { + // Given a selected key without a loaded live violation record + const snapshotViolations = {[violationKey]: [violation]}; + + // When the selector contains the key with an undefined value + const data = getReportApprovalWarningData(REPORT_ID, [transaction], [], snapshotViolations, {[violationKey]: undefined}); + + // Then key presence alone does not erase snapshot warnings + expect(data.violations?.[violationKey]).toEqual([violation]); + }); + + it('scopes both transactions and violations to the approved report', () => { + // Given snapshot data containing an expense for another report + const otherTransaction = {...transaction, transactionID: '2', reportID: 'other-report'}; + const otherKey = `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}2` as const; + + // When this report's warning data is resolved + const data = getReportApprovalWarningData(REPORT_ID, [transaction, otherTransaction], [], {[otherKey]: [violation]}, {}); + + // Then another report cannot contribute warnings + expect(data.transactions).toEqual([transaction]); + expect(data.violations?.[otherKey]).toBeUndefined(); + }); + + it('preserves dismissal and full versus partial approval rules for snapshot data', () => { + // Given a dismissed expense and a held expense with the same violation + const dismissed = {...transaction, comment: {dismissedViolations: {[violation.name]: {[APPROVER_EMAIL]: '2026-11-13 00:00:00'}}}}; + const held = {...transaction, transactionID: '2', comment: {hold: 'hold-action'}}; + const otherKey = `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}2` as const; + + // When warning data is resolved for full and partial approval + const data = getReportApprovalWarningData(REPORT_ID, [dismissed, held], [], {[violationKey]: [violation], [otherKey]: [violation]}, {}); + const full = getReportApproveViolationSummary(data.transactions, data.violations, undefined, undefined, APPROVER_EMAIL, 1, true); + const partial = getReportApproveViolationSummary(data.transactions, data.violations, undefined, undefined, APPROVER_EMAIL, 1, false); + + // Then only full approval warns about the held expense; the approver's dismissal still applies + expect(hasAnySubmitViolation(full)).toBe(true); + expect(hasAnySubmitViolation(partial)).toBe(false); + }); +}); diff --git a/tests/unit/getReportSubmitViolationSummaryTest.ts b/tests/unit/getReportSubmitViolationSummaryTest.ts index a83a1732305a..240e2d64c772 100644 --- a/tests/unit/getReportSubmitViolationSummaryTest.ts +++ b/tests/unit/getReportSubmitViolationSummaryTest.ts @@ -1,5 +1,6 @@ import type {LocaleContextProps} from '@components/LocaleContextProvider'; +import getReportApproveViolationSummary from '@libs/Violations/getReportApproveViolationSummary'; import { buildSubmitViolationBullets, getReportSubmitViolationSummary, @@ -21,6 +22,8 @@ const translate = jest.fn((key: string, ...params: unknown[]) => { }) as unknown as LocaleContextProps['translate']; const convertToDisplayString = jest.fn((amount: number | undefined, currency: string | undefined) => `${amount} ${currency}`); +const APPROVER_EMAIL = 'approver@example.com'; +const SUBMITTER_EMAIL = 'submitter@example.com'; // Not exercised by the violation types covered here (overCategoryLimit doesn't need a formatted date), but // required by buildSubmitViolationBullets's params shape. @@ -69,6 +72,145 @@ function createMockRejectedReport(): Report { }); } +describe('getReportApproveViolationSummary', () => { + it('omits raw violations dismissed by the approver without modifying the source data', () => { + // Given raw violations from a header, hold menu or bulk approval input + const transaction = createMockTransactionWithMerchant('Merchant', { + comment: {dismissedViolations: {[CONST.VIOLATIONS.OVER_CATEGORY_LIMIT]: {[APPROVER_EMAIL]: '2026-11-07 00:00:00'}}}, + }); + const violation = createMockViolation(CONST.VIOLATIONS.OVER_CATEGORY_LIMIT); + const violations = {[getViolationsKey('1')]: [violation]}; + + // When the current approver has already dismissed the only warning + const summary = getReportApproveViolationSummary([transaction], violations, undefined, undefined, APPROVER_EMAIL, 1); + + // Then approval needs no warning and the dismissal remains local to this summary + expect(hasAnySubmitViolation(summary)).toBe(false); + expect(violations[getViolationsKey('1')]).toEqual([violation]); + expect(transaction.comment?.dismissedViolations?.[CONST.VIOLATIONS.OVER_CATEGORY_LIMIT]).toEqual({[APPROVER_EMAIL]: '2026-11-07 00:00:00'}); + }); + + it('still warns the approver about violations dismissed only by the submitter', () => { + // Given a submitted report whose owner dismissed a violation, but its approver did not + const transaction = createMockTransactionWithMerchant('Merchant', { + comment: {dismissedViolations: {[CONST.VIOLATIONS.OVER_CATEGORY_LIMIT]: {[SUBMITTER_EMAIL]: '2026-11-07 00:00:00'}}}, + }); + const violation = createMockViolation(CONST.VIOLATIONS.OVER_CATEGORY_LIMIT); + const report = createMock({...createMockExpenseReport(), stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, ownerAccountID: 2, managerID: 1}); + + // When a different user approves the report + const summary = getReportApproveViolationSummary([transaction], {[getViolationsKey('1')]: [violation]}, report, undefined, APPROVER_EMAIL, 1); + + // Then the submitter's dismissal does not hide the warning from the approver + expect(summary.otherViolations.get(CONST.VIOLATIONS.OVER_CATEGORY_LIMIT)).toBe(violation); + }); + + it('deduplicates only after filtering each transaction dismissal', () => { + // Given two violations with the same name, only the first expense's instance is dismissed + const dismissedTransaction = createMockTransactionWithMerchant('Merchant', { + comment: {dismissedViolations: {[CONST.VIOLATIONS.OVER_CATEGORY_LIMIT]: {[APPROVER_EMAIL]: '2026-11-07 00:00:00'}}}, + }); + const visibleTransaction = createMockTransactionWithMerchant('Merchant', {transactionID: '2'}); + const dismissedViolation = createMockViolation(CONST.VIOLATIONS.OVER_CATEGORY_LIMIT, {amount: 1000, currency: 'USD'}); + const visibleViolation = createMockViolation(CONST.VIOLATIONS.OVER_CATEGORY_LIMIT, {amount: 2000, currency: 'USD'}); + + // When both expenses are summarized in report order + const summary = getReportApproveViolationSummary( + [dismissedTransaction, visibleTransaction], + {[getViolationsKey('1')]: [dismissedViolation], [getViolationsKey('2')]: [visibleViolation]}, + undefined, + undefined, + APPROVER_EMAIL, + 1, + ); + + // Then the remaining warning retains the second expense's message data + expect(summary.otherViolations.size).toBe(1); + expect(summary.otherViolations.get(CONST.VIOLATIONS.OVER_CATEGORY_LIMIT)).toBe(visibleViolation); + }); + + it('keeps missing-field warnings when an unrelated server violation is dismissed', () => { + // Given an expense missing its merchant with a dismissed policy violation + const transaction = createMockTransactionWithMerchant(CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, { + comment: {dismissedViolations: {[CONST.VIOLATIONS.OVER_CATEGORY_LIMIT]: {[APPROVER_EMAIL]: '2026-11-07 00:00:00'}}}, + }); + + // When the approver reviews the raw violations + const summary = getReportApproveViolationSummary( + [transaction], + {[getViolationsKey('1')]: [createMockViolation(CONST.VIOLATIONS.OVER_CATEGORY_LIMIT)]}, + createMockExpenseReport(), + undefined, + APPROVER_EMAIL, + 1, + ); + + // Then only the independent missing merchant warning remains + expect(summary.otherViolations.size).toBe(0); + expect(summary.hasMissingMerchant).toBe(true); + expect(hasAnySubmitViolation(summary)).toBe(true); + }); + + it('excludes dismissed violations during both full and partial approval', () => { + // Given a held expense whose only violation the approver dismissed + const transaction = createMockTransactionWithMerchant('Merchant', { + comment: {hold: 'hold-action', dismissedViolations: {[CONST.VIOLATIONS.OVER_CATEGORY_LIMIT]: {[APPROVER_EMAIL]: '2026-11-07 00:00:00'}}}, + }); + const violations = {[getViolationsKey('1')]: [createMockViolation(CONST.VIOLATIONS.OVER_CATEGORY_LIMIT)]}; + + // When full approval includes the held expense and partial approval excludes it + const full = getReportApproveViolationSummary([transaction], violations, undefined, undefined, APPROVER_EMAIL, 1, true); + const partial = getReportApproveViolationSummary([transaction], violations, undefined, undefined, APPROVER_EMAIL, 1, false); + + // Then neither mode warns about the dismissed violation + expect(hasAnySubmitViolation(full)).toBe(false); + expect(hasAnySubmitViolation(partial)).toBe(false); + }); + + it('warns about a held expense with a missing merchant when approving everything', () => { + // Given a held expense whose missing merchant is not represented by a server violation + const transaction = createMockTransactionWithMerchant(CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, {comment: {hold: 'hold-action'}}); + const report = createMockExpenseReport(); + + // When choosing full or partial approval + const full = getReportApproveViolationSummary([transaction], {}, report, undefined, '', 0, true); + const partial = getReportApproveViolationSummary([transaction], {}, report, undefined, '', 0, false); + + // Then only full approval includes the held expense's missing field warning + expect(full.hasMissingMerchant).toBe(true); + expect(hasAnySubmitViolation(partial)).toBe(false); + }); + + it('does not show submitter-only rejection or pending card-match warnings', () => { + // Given a rejected report with rejected expenses and a pending card match + const violations = { + [getViolationsKey('1')]: [createMockViolation(CONST.VIOLATIONS.AUTO_REPORTED_REJECTED_EXPENSE), createMockViolation(CONST.VIOLATIONS.RTER, {pendingPattern: true})], + }; + + // When an approver reviews its violations + const summary = getReportApproveViolationSummary([createMockTransaction('1')], violations, createMockRejectedReport(), undefined, '', 0); + + // Then the submitter's acknowledgement warnings are not shown to the approver + expect(hasAnySubmitViolation(summary)).toBe(false); + }); + + it('includes held expenses only when approving the full report', () => { + // Given a held expense with a policy violation + const transaction = createMock({transactionID: '1', comment: {hold: 'hold-action'}}); + const overLimit = createMockViolation(CONST.VIOLATIONS.OVER_CATEGORY_LIMIT); + const violations = {[getViolationsKey('1')]: [overLimit]}; + + // When choosing full or partial approval + const full = getReportApproveViolationSummary([transaction], violations, undefined, undefined, '', 0, true); + const partial = getReportApproveViolationSummary([transaction], violations, undefined, undefined, '', 0, false); + + // Then only the full approval warns about this expense, without mutating its violations + expect(full.otherViolations.get(CONST.VIOLATIONS.OVER_CATEGORY_LIMIT)).toBe(overLimit); + expect(hasAnySubmitViolation(partial)).toBe(false); + expect(violations[getViolationsKey('1')]).toEqual([overLimit]); + }); +}); + describe('getReportSubmitViolationSummary', () => { it('returns all-false/empty when there are no violations', () => { // Given a transaction with an empty violations array (the shape Onyx reports once violations have been computed and none apply) diff --git a/tests/unit/hooks/useConfirmApproval.test.ts b/tests/unit/hooks/useConfirmApproval.test.ts index 4e7246e43e29..ce6ad56d785d 100644 --- a/tests/unit/hooks/useConfirmApproval.test.ts +++ b/tests/unit/hooks/useConfirmApproval.test.ts @@ -1,5 +1,6 @@ import {act, renderHook} from '@testing-library/react-native'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; import useConfirmApproval from '@components/MoneyReportHeaderPrimaryAction/useConfirmApproval'; import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; @@ -64,6 +65,13 @@ const heldTransaction: Transaction = {...createRandomTransaction(2), transaction const mockedUseTransactionsAndViolationsForReport = jest.mocked(useTransactionsAndViolationsForReport); const mockedApproveMoneyRequest = jest.mocked(approveMoneyRequest); +const mockShowConfirmModal = jest.fn(); +jest.mock('@hooks/useConfirmModal', () => ({__esModule: true, default: () => ({showConfirmModal: mockShowConfirmModal})})); +jest.mock('@hooks/useLocalize', () => ({__esModule: true, default: () => ({translate: (key: string) => key})})); +jest.mock('@libs/showConfirmModalAfterMoreMenuDismiss', () => ({ + __esModule: true, + default: (showConfirmModal: (options: unknown) => Promise, options: unknown) => showConfirmModal(options), +})); function mockLiveTransactions(transactions: Transaction[]) { mockedUseTransactionsAndViolationsForReport.mockReturnValue({ @@ -81,6 +89,7 @@ describe('useConfirmApproval', () => { beforeEach(() => { jest.clearAllMocks(); + mockShowConfirmModal.mockReset().mockResolvedValue({action: ModalActions.CLOSE}); mockIsAccountLocked = false; mockLiveTransactions([]); return Onyx.clear().then(waitForBatchedUpdates); @@ -145,4 +154,51 @@ describe('useConfirmApproval', () => { expect(startApprovedAnimation).not.toHaveBeenCalled(); expect(mockedApproveMoneyRequest).not.toHaveBeenCalled(); }); + + it.each([ModalActions.CLOSE, ModalActions.CONFIRM])('requires confirmation for snapshot-only violations and handles %s', async (action) => { + // Given an unopened Search report whose transaction and warning exist only in its snapshot + const violationKey = `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${unheldTransaction.transactionID}` as const; + mockShowConfirmModal.mockResolvedValue({action}); + const startApprovedAnimation = jest.fn(); + const {result} = renderHook(() => + useConfirmApproval(REPORT_ID, startApprovedAnimation, { + fallbackReport: snapshotReport, + fallbackTransactions: [unheldTransaction], + fallbackViolations: {[violationKey]: [{name: CONST.VIOLATIONS.DUPLICATED_TRANSACTION, type: CONST.VIOLATION_TYPES.WARNING}]}, + }), + ); + await act(waitForBatchedUpdates); + + // When approval is requested, the snapshot warning must gate all approval side effects + await act(async () => { + result.current.onApprove(true); + expect(mockShowConfirmModal).toHaveBeenCalledTimes(1); + expect(mockedApproveMoneyRequest).not.toHaveBeenCalled(); + expect(startApprovedAnimation).not.toHaveBeenCalled(); + }); + + // Then only an explicit confirmation approves the report + expect(mockedApproveMoneyRequest).toHaveBeenCalledTimes(action === ModalActions.CONFIRM ? 1 : 0); + }); + + it('prefers a live empty violation array over an old snapshot warning', async () => { + // Given an unopened transaction whose warning has already been cleared in live Onyx + const violationKey = `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${unheldTransaction.transactionID}` as const; + await Onyx.set(violationKey, []); + const {result} = renderHook(() => + useConfirmApproval(REPORT_ID, jest.fn(), { + fallbackReport: snapshotReport, + fallbackTransactions: [unheldTransaction], + fallbackViolations: {[violationKey]: [{name: CONST.VIOLATIONS.DUPLICATED_TRANSACTION, type: CONST.VIOLATION_TYPES.WARNING}]}, + }), + ); + await act(waitForBatchedUpdates); + + // When the report is approved + act(() => result.current.onApprove(true)); + + // Then stale Search data cannot resurrect the cleared warning + expect(mockShowConfirmModal).not.toHaveBeenCalled(); + expect(mockedApproveMoneyRequest).toHaveBeenCalledTimes(1); + }); }); diff --git a/tests/unit/hooks/useSearchBulkActionsTest.ts b/tests/unit/hooks/useSearchBulkActionsTest.ts index fc767128d3d0..6e32cad62549 100644 --- a/tests/unit/hooks/useSearchBulkActionsTest.ts +++ b/tests/unit/hooks/useSearchBulkActionsTest.ts @@ -1,5 +1,6 @@ import {act, renderHook, waitFor} from '@testing-library/react-native'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; import type {SearchQueryJSON, SelectedReports, SelectedTransactions} from '@components/Search/types'; @@ -156,9 +157,14 @@ jest.mock('@components/LockedAccountModalProvider', () => ({ useLockedAccountActions: () => ({showLockedAccountModal: mockShowLockedAccountModal}), })); +const mockShowConfirmModal = jest.fn(); jest.mock('@hooks/useConfirmModal', () => ({ __esModule: true, - default: () => ({showConfirmModal: jest.fn()}), + default: () => ({showConfirmModal: mockShowConfirmModal}), +})); +jest.mock('@libs/showConfirmModalAfterMoreMenuDismiss', () => ({ + __esModule: true, + default: (showConfirmModal: (options: unknown) => Promise, options: unknown) => showConfirmModal(options), })); let mockEnabledBetas: string[] = []; @@ -1155,6 +1161,7 @@ describe('useSearchBulkActions - Approve under Select all', () => { beforeEach(async () => { jest.clearAllMocks(); + mockShowConfirmModal.mockReset().mockResolvedValue({action: ModalActions.CLOSE}); mockIsOffline = false; mockAreAllMatchingItemsSelected = true; await Onyx.clear(); @@ -1195,6 +1202,42 @@ describe('useSearchBulkActions - Approve under Select all', () => { expect(mockClearSelectedTransactions).toHaveBeenCalled(); }); + it.each([ + {action: ModalActions.CLOSE, cleared: false}, + {action: ModalActions.CONFIRM, cleared: false}, + {action: ModalActions.CLOSE, cleared: true}, + ])('handles snapshot-only warnings before bulk approval ($action, live cleared: $cleared)', async ({action, cleared}) => { + // Given an approvable report whose transaction and violation exist only in Search data + const transaction = createMock({transactionID: 'tx1', reportID: 'report1', merchant: 'Merchant', amount: 100, currency: 'USD'}); + const violationKey = `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}tx1` as const; + mockSelectedTransactions = {tx1: makeSelectedTransaction()}; + mockSelectedReports = [makeSelectedReport({canApprove: true})]; + mockCurrentSearchResults = { + data: { + [`${ONYXKEYS.COLLECTION.TRANSACTION}tx1`]: transaction, + [`${ONYXKEYS.COLLECTION.REPORT}report1`]: {reportID: 'report1', type: CONST.REPORT.TYPE.EXPENSE, policyID: 'policy1'}, + [violationKey]: [{name: CONST.VIOLATIONS.DUPLICATED_TRANSACTION, type: CONST.VIOLATION_TYPES.WARNING}], + }, + }; + if (cleared) { + await Onyx.set(violationKey, []); + } + mockShowConfirmModal.mockResolvedValue({action}); + const {result} = renderHook(() => useSearchBulkActions({queryJSON: expenseReportQueryJSON}), {wrapper: OnyxListItemProvider}); + await waitFor(() => expect(result.current.headerButtonsOptions.some((option) => option.value === CONST.SEARCH.BULK_ACTION_TYPES.APPROVE)).toBe(true)); + + // When Select all approval is requested + await act(async () => { + await result.current.headerButtonsOptions.find((option) => option.value === CONST.SEARCH.BULK_ACTION_TYPES.APPROVE)?.onSelected?.(); + }); + + // Then only confirmed or already-cleared warnings allow approval and selection cleanup + expect(mockShowConfirmModal).toHaveBeenCalledTimes(cleared ? 0 : 1); + const shouldApprove = cleared || action === ModalActions.CONFIRM; + expect(queueBulkApproveReports).toHaveBeenCalledTimes(shouldApprove ? 1 : 0); + expect(mockClearSelectedTransactions).toHaveBeenCalledTimes(shouldApprove ? 1 : 0); + }); + it('leaves out reports the user deselected from the bulk approval', async () => { // Given "Select all" on a reports search, with report2 deselected afterwards mockSelectedTransactions = {tx1: makeSelectedTransaction()};