diff --git a/src/components/MoneyReportHeaderModals.tsx b/src/components/MoneyReportHeaderModals.tsx index 7821666a916b..52dfb7e2cb95 100644 --- a/src/components/MoneyReportHeaderModals.tsx +++ b/src/components/MoneyReportHeaderModals.tsx @@ -9,10 +9,9 @@ import useReportPDFDownloadModal from '@hooks/useReportPDFDownloadModal'; import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import getPlatform from '@libs/getPlatform'; -import {canIOUBePaid as canIOUBePaidAction, getNonHeldAndFullAmount, hasOnlyHeldExpenses as hasOnlyHeldExpensesReportUtils} from '@libs/ReportUtils'; +import getHoldMenuAmounts from '@libs/HoldMenuUtils'; +import {canIOUBePaid as canIOUBePaidAction} from '@libs/ReportUtils'; -import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {ReactNode} from 'react'; @@ -58,9 +57,6 @@ function MoneyReportHeaderModalsContent({reportID, children}: MoneyReportHeaderM const onlyShowPayElsewhere = !canIOUBePaid && canIOUBePaidAction(moneyRequestReport, chatReport, policy, bankAccountList, currentUserLogin ?? '', accountID, undefined, true, isChatReportArchived); const shouldShowPayButton = canIOUBePaid || onlyShowPayElsewhere; - const {nonHeldAmount, fullAmount, hasValidNonHeldAmount} = getNonHeldAndFullAmount(moneyRequestReport, shouldShowPayButton, transactions, convertToDisplayString); - const hasOnlyHeldExpenses = hasOnlyHeldExpensesReportUtils(transactions); - const transactionIDs = transactions.map((t) => t.transactionID); // Imperative modals const {showHoldMenu} = useHoldMenuModal(); @@ -68,31 +64,19 @@ function MoneyReportHeaderModalsContent({reportID, children}: MoneyReportHeaderM const {showReportPDFDownloadModal} = useReportPDFDownloadModal(); const {openHoldEducational, openRejectModal} = useMoneyReportHeaderEducationalModals(moneyRequestReport?.reportID); - const openHoldMenu = ({requestType, paymentType, methodID, onConfirm}: HoldMenuParams): Promise => { - const open = () => + // Wait one frame so the current touch animation finishes before the modal opens + const openHoldMenu = ({requestType, paymentType, methodID, onConfirm}: HoldMenuParams) => { + requestAnimationFrame(() => { showHoldMenu({ reportID: moneyRequestReport?.reportID, chatReportID: chatReport?.reportID, requestType, paymentType, methodID, - nonHeldAmount: !hasOnlyHeldExpenses && hasValidNonHeldAmount ? nonHeldAmount : undefined, - fullAmount, - hasNonHeldExpenses: !hasOnlyHeldExpenses, - transactionCount: transactionIDs.length, + ...getHoldMenuAmounts(moneyRequestReport, shouldShowPayButton, transactions, convertToDisplayString), onConfirm, }); - - // On iOS, defer by one frame so the current touch animation finishes before the modal opens - if (getPlatform() === CONST.PLATFORM.IOS) { - return new Promise((resolve) => { - requestAnimationFrame(() => { - open().then(() => resolve()); - }); - }); - } - - return open().then(() => {}); + }); }; const contextValue = { diff --git a/src/components/MoneyReportHeaderModalsContext.tsx b/src/components/MoneyReportHeaderModalsContext.tsx index 6f4dba421fda..3a9be26c24d6 100644 --- a/src/components/MoneyReportHeaderModalsContext.tsx +++ b/src/components/MoneyReportHeaderModalsContext.tsx @@ -15,7 +15,7 @@ type HoldMenuParams = { }; type MoneyReportHeaderModalsContextValue = { - openHoldMenu: (params: HoldMenuParams) => Promise; + openHoldMenu: (params: HoldMenuParams) => void; /** Resolves once the PDF download modal has finished hiding, with `CLOSE` if it was dismissed while the PDF was still generating */ openPDFDownload: () => Promise; openHoldEducational: () => void; @@ -25,7 +25,7 @@ type MoneyReportHeaderModalsContextValue = { }; const defaultValue: MoneyReportHeaderModalsContextValue = { - openHoldMenu: () => Promise.resolve(), + openHoldMenu: () => {}, openPDFDownload: () => Promise.resolve(undefined), openHoldEducational: () => {}, openRejectModal: () => {}, diff --git a/src/components/MoneyRequestReportView/SelectionToolbar/index.tsx b/src/components/MoneyRequestReportView/SelectionToolbar/index.tsx index fa044fb39432..4a54d58d461f 100644 --- a/src/components/MoneyRequestReportView/SelectionToolbar/index.tsx +++ b/src/components/MoneyRequestReportView/SelectionToolbar/index.tsx @@ -2,7 +2,6 @@ import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/ import {ModalActions} from '@components/Modal/Global/ModalContext'; import useShouldShowReportBulkActionBar from '@components/MoneyRequestReportView/useShouldShowReportBulkActionBar'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; -import ProcessMoneyReportHoldMenu from '@components/ProcessMoneyReportHoldMenu'; import {ReportSubmitToPopoverAnchor} from '@components/ReportSubmitToPopoverAnchor'; import BulkDuplicateHandler from '@components/Search/BulkDuplicateHandler'; import {useSearchSelectionActions, useSearchSelectionContext} from '@components/Search/SearchContext'; @@ -192,16 +191,6 @@ function SelectionToolbar({reportID, transactions, reportActions}: SelectionTool handleWorkspaceSelected, primaryAction, kycWallRef, - isHoldMenuVisible, - requestType, - paymentType, - selectedVBBAToPayFromHoldMenu, - handleHoldMenuClose, - handleHoldMenuConfirm, - hasOnlyHeldExpenses, - nonHeldAmount, - fullAmount, - hasValidNonHeldAmount, } = useSelectionModeReportActions({ report, chatReport, @@ -316,21 +305,6 @@ function SelectionToolbar({reportID, transactions, reportActions}: SelectionTool onClearSelection={() => clearSelectedTransactions(true)} /> )} - {isHoldMenuVisible && requestType !== undefined && ( - - )} ); } diff --git a/src/components/ProcessMoneyReportHoldMenu.tsx b/src/components/ProcessMoneyReportHoldMenu.tsx deleted file mode 100644 index 2d51cceff125..000000000000 --- a/src/components/ProcessMoneyReportHoldMenu.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import useHoldMenuSubmit from '@hooks/useHoldMenuSubmit'; -import type {ActionHandledType} from '@hooks/useHoldMenuSubmit'; -import useLocalize from '@hooks/useLocalize'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; - -import type * as OnyxTypes from '@src/types/onyx'; -import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; - -import type {OnyxEntry} from 'react-native-onyx'; - -import React from 'react'; - -import DecisionModal from './DecisionModal'; - -type ProcessMoneyReportHoldMenuProps = { - /** The chat report this report is linked to */ - chatReport: OnyxEntry; - - /** Full amount of expense report to pay */ - fullAmount: string; - - /** Whether modal is visible */ - isVisible: boolean; - - /** The report currently being looked at */ - moneyRequestReport: OnyxEntry; - - /** Not held amount of expense report */ - nonHeldAmount?: string; - - /** Callback for closing modal */ - onClose: () => void; - - /** Type of payment */ - paymentType?: PaymentMethodType; - - /** Selected VBBA ID for payment */ - methodID?: number; - - /** Number of transaction of a money request */ - transactionCount: number; - - /** Callback invoked after the user confirms pay/approve, receives whether the full amount was chosen */ - onConfirm?: (full: boolean) => void; - - /** Whether the report has non held expenses */ - hasNonHeldExpenses?: boolean; -}; - -function ProcessMoneyReportHoldMenu({ - nonHeldAmount, - fullAmount, - onClose, - isVisible, - paymentType, - methodID, - chatReport, - moneyRequestReport, - transactionCount, - onConfirm, - hasNonHeldExpenses, -}: ProcessMoneyReportHoldMenuProps) { - const {translate} = useLocalize(); - // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to apply the correct modal type - // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth - const {isSmallScreenWidth} = useResponsiveLayout(); - - const {onSubmit} = useHoldMenuSubmit({ - moneyRequestReport, - chatReport, - paymentType, - methodID, - onClose, - onConfirm, - }); - const promptText = hasNonHeldExpenses ? translate('iou.confirmPayAmount') : translate('iou.confirmPayAllHoldAmount', {count: transactionCount}); - - return ( - onSubmit(false)} - onSecondOptionSubmit={() => onSubmit(true)} - isSmallScreenWidth={isSmallScreenWidth} - /> - ); -} - -export default ProcessMoneyReportHoldMenu; -export type {ActionHandledType}; diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewBody.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewBody.tsx index f9ebe5f1a6f1..e3aa9115193a 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewBody.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewBody.tsx @@ -26,7 +26,6 @@ import {View} from 'react-native'; import {useReportPreviewData, useReportPreviewUIState} from './MoneyRequestReportPreviewContext'; import ReportPreviewActionButton from './ReportPreviewActionButton'; import ReportPreviewHeader from './ReportPreviewHeader'; -import ReportPreviewHoldMenu from './ReportPreviewHoldMenu'; import ReportPreviewTotal from './ReportPreviewTotal'; import TransactionReportCarousel from './TransactionReportCarousel'; @@ -55,7 +54,7 @@ type MoneyRequestReportPreviewBodyProps = ForwardedFSClassProps & { /** * The layout body of the money request report preview: the pressable card that composes the header, carousel, action - * button + total, and the hold menu. It owns no data — everything comes from the context slices via `useX()` hooks, + * button and total. It owns no data — everything comes from the context slices via `useX()` hooks, * except the pure layout wiring passed as props. */ function MoneyRequestReportPreviewBody({ @@ -152,7 +151,6 @@ function MoneyRequestReportPreviewBody({ - ); diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContext.ts b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContext.ts index 6cf7e21fb6a6..b425e5e40748 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContext.ts +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContext.ts @@ -1,6 +1,5 @@ import type CONST from '@src/CONST'; import type {PersonalDetails, Policy, Report, ReportAction, Transaction, TransactionViolations} from '@src/types/onyx'; -import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; import type {ConnectionName} from '@src/types/onyx/Policy'; import type {Context} from 'react'; @@ -71,8 +70,6 @@ type ReportPreviewActionState = { type ReportPreviewActions = { openReportFromPreview: () => void; - onHoldMenuOpen: (paymentType?: PaymentMethodType, canPay?: boolean, methodID?: number) => void; - onHoldMenuClose: () => void; onPaymentOptionsShow?: () => void; onPaymentOptionsHide?: () => void; stopAnimation: () => void; @@ -83,12 +80,6 @@ type ReportPreviewActions = { goToNext: () => void; }; -type ReportPreviewHoldMenuState = { - paymentType: PaymentMethodType | undefined; - canPay: boolean; - methodID: number | undefined; -}; - type ReportPreviewMeta = { setCarouselRef: ReturnType['setCarouselRef']; }; @@ -101,7 +92,6 @@ const ReportPreviewUIStateContext = createContext(undefined); const ReportPreviewActionStateContext = createContext(undefined); const ReportPreviewActionsContext = createContext(undefined); -const ReportPreviewHoldMenuContext = createContext(undefined); const ReportPreviewMetaContext = createContext(undefined); function useSliceContext(context: Context, name: string): T { @@ -120,13 +110,6 @@ const useReportPreviewUIState = () => useSliceContext(ReportPreviewUIStateContex const useReportPreviewCarouselList = () => useSliceContext(ReportPreviewCarouselListContext, 'useReportPreviewCarouselList'); const useReportPreviewActionState = () => useSliceContext(ReportPreviewActionStateContext, 'useReportPreviewActionState'); const useReportPreviewActions = () => useSliceContext(ReportPreviewActionsContext, 'useReportPreviewActions'); -const useReportPreviewHoldMenu = () => { - const value = useContext(ReportPreviewHoldMenuContext); - if (value === undefined) { - throw new Error('useReportPreviewHoldMenu must be used within a MoneyRequestReportPreviewProvider'); - } - return value; -}; const useReportPreviewMeta = () => useSliceContext(ReportPreviewMetaContext, 'useReportPreviewMeta'); export { @@ -138,7 +121,6 @@ export { ReportPreviewCarouselListContext, ReportPreviewActionStateContext, ReportPreviewActionsContext, - ReportPreviewHoldMenuContext, ReportPreviewMetaContext, useReportPreviewCarouselState, useReportPreviewAnimationState, @@ -148,7 +130,6 @@ export { useReportPreviewCarouselList, useReportPreviewActionState, useReportPreviewActions, - useReportPreviewHoldMenu, useReportPreviewMeta, }; export type {ReportPreviewActionState}; diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewProvider.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewProvider.tsx index b1d613582f52..a39090a0fd8c 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewProvider.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewProvider.tsx @@ -22,7 +22,6 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import {reportActionsLoadingStateSelector, isOptimisticReportSelector} from '@src/selectors/ReportMetaData'; import type {PersonalDetails, Policy, Report, ReportAction, Transaction, TransactionViolations} from '@src/types/onyx'; -import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; import type {ListRenderItem} from '@shopify/flash-list'; @@ -40,7 +39,6 @@ import { ReportPreviewCarouselListContext, ReportPreviewCarouselStateContext, ReportPreviewDataContext, - ReportPreviewHoldMenuContext, ReportPreviewMetaContext, ReportPreviewTransactionViolationsContext, ReportPreviewUIStateContext, @@ -112,11 +110,6 @@ function MoneyRequestReportPreviewProvider({ const pending = getPendingSubmitFollowUpAction(); return pending?.followUpAction === CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.DISMISS_MODAL_AND_OPEN_REPORT && (pending?.reportID === chatReportID || pending?.reportID === iouReportID); }); - const [holdMenu, setHoldMenu] = useState<{ - paymentType: PaymentMethodType | undefined; - canPay: boolean; - methodID: number | undefined; - } | null>(null); useFocusEffect( useCallback(() => { @@ -234,12 +227,6 @@ function MoneyRequestReportPreviewProvider({ } }; - // Only the pay flow opens this menu; approve surfaces its partial/full choice in the approve dropdown instead. - const onHoldMenuOpen = (paymentType?: PaymentMethodType, canPay?: boolean, methodID?: number) => { - setHoldMenu({paymentType, canPay: !!canPay, methodID}); - }; - const onHoldMenuClose = () => setHoldMenu(null); - const shouldShowCarouselArrows = !shouldUseNarrowLayout && !shouldShowAccessPlaceHolder && transactions.length > 2 && reportPreviewStyles.expenseCountVisible; const buttonMaxWidth = !shouldUseNarrowLayout && reportPreviewStyles.transactionPreviewCarouselStyle.width >= CONST.REPORT.TRANSACTION_PREVIEW.CAROUSEL.MIN_WIDE_WIDTH @@ -293,8 +280,6 @@ function MoneyRequestReportPreviewProvider({ const carouselStateValue = {isPreviousDisabled, isNextDisabled}; const actionsValue = { openReportFromPreview, - onHoldMenuOpen, - onHoldMenuClose, onPaymentOptionsShow, onPaymentOptionsHide, stopAnimation, @@ -315,9 +300,7 @@ function MoneyRequestReportPreviewProvider({ - - {children} - + {children} diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/PayActionButton.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/PayActionButton.tsx index 8819ef2585bd..201d093e4ee7 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/PayActionButton.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/PayActionButton.tsx @@ -5,6 +5,7 @@ import type {PaymentActionParams} from '@components/SettlementButton/types'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDelegateAccountID from '@hooks/useDelegateAccountID'; +import useHoldMenuModal from '@hooks/useHoldMenuModal'; import useLastWorkspaceNumber from '@hooks/useLastWorkspaceNumber'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -15,6 +16,7 @@ import usePermissions from '@hooks/usePermissions'; import usePolicy from '@hooks/usePolicy'; import {generateDefaultWorkspaceName} from '@libs/actions/Policy/Policy'; +import getHoldMenuAmounts from '@libs/HoldMenuUtils'; import {getTotalAmountForIOUReportPreviewButton} from '@libs/MoneyRequestReportUtils'; import {isTrackOnboardingChoice} from '@libs/OnboardingUtils'; import {hasDynamicExternalWorkflow} from '@libs/PolicyUtils'; @@ -25,6 +27,7 @@ import {payInvoice, payMoneyRequest} from '@userActions/IOU/PayMoneyRequest'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; import {hasSeenTourSelector} from '@selectors/Onboarding'; import React from 'react'; @@ -47,7 +50,8 @@ function PayActionButton() { const {iouReportID, chatReportID, chatReport, transactions} = useReportPreviewData(); const {isPaidAnimationRunning, isApprovedAnimationRunning} = useReportPreviewAnimationState(); - const {stopAnimation, startAnimation, onPaymentOptionsShow, onPaymentOptionsHide, onHoldMenuOpen} = useReportPreviewActions(); + const {stopAnimation, startAnimation, onPaymentOptionsShow, onPaymentOptionsHide} = useReportPreviewActions(); + const {showHoldMenu} = useHoldMenuModal(); const {buttonMaxWidth} = useReportPreviewUIState(); const {reportPreviewAction, canIOUBePaid, onlyShowPayElsewhere, shouldShowPayButton} = useReportPreviewActionState(); @@ -74,6 +78,24 @@ function PayActionButton() { const formattedAmount = getTotalAmountForIOUReportPreviewButton(iouReport, policy, reportPreviewAction, transactions, convertToDisplayString); + // Only the pay flow opens this menu. Approve surfaces its partial/full choice in the approve dropdown instead. + const openHoldMenu = (paymentType: PaymentMethodType, methodID?: number) => { + if (!iouReport) { + return; + } + // Wait one frame so the current touch animation finishes before the modal opens + requestAnimationFrame(() => { + showHoldMenu({ + reportID: iouReportID, + chatReportID, + paymentType, + methodID, + ...getHoldMenuAmounts(iouReport, shouldShowPayButton, transactions, convertToDisplayString), + onConfirm: startAnimation, + }); + }); + }; + const confirmPayment = ({paymentType: type, payAsBusiness, methodID, paymentMethod}: PaymentActionParams) => { if (!type) { return; @@ -81,7 +103,7 @@ function PayActionButton() { if (isDelegateAccessRestricted) { showDelegateNoAccessModal(); } else if (hasHeldExpensesReportUtils(transactions)) { - onHoldMenuOpen(type, shouldShowPayButton, methodID); + openHoldMenu(type, methodID); } else if (chatReport && iouReport) { const currentChatReport = getReportOrDraftReport(chatReportID) ?? chatReport; if (isInvoiceReportUtils(iouReport)) { diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/ReportPreviewHoldMenu.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/ReportPreviewHoldMenu.tsx deleted file mode 100644 index b26b6c258ce5..000000000000 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/ReportPreviewHoldMenu.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import ProcessMoneyReportHoldMenu from '@components/ProcessMoneyReportHoldMenu'; - -import {useCurrencyListActions} from '@hooks/useCurrencyList'; - -import {getNonHeldAndFullAmount, hasOnlyHeldExpenses as hasOnlyHeldExpensesReportUtils} from '@libs/ReportUtils'; - -import React from 'react'; - -import {useReportPreviewActions, useReportPreviewData, useReportPreviewHoldMenu} from './MoneyRequestReportPreviewContext'; - -/** - * Hold/approve confirmation menu for the money request report preview. Visibility and open parameters live in the - * provider so sibling components communicate through context actions instead of imperative refs. - */ -function ReportPreviewHoldMenu() { - const holdMenu = useReportPreviewHoldMenu(); - const {iouReport, chatReport, transactions} = useReportPreviewData(); - const {startAnimation, onHoldMenuClose} = useReportPreviewActions(); - const {convertToDisplayString} = useCurrencyListActions(); - - if (!holdMenu || !iouReport) { - return null; - } - - const hasOnlyHeldExpenses = hasOnlyHeldExpensesReportUtils(transactions); - const {nonHeldAmount, fullAmount, hasValidNonHeldAmount} = getNonHeldAndFullAmount(iouReport, holdMenu.canPay, transactions, convertToDisplayString); - - return ( - - ); -} - -export default ReportPreviewHoldMenu; diff --git a/src/hooks/useSelectionModePayment.ts b/src/hooks/useSelectionModePayment.ts index 6c5c30783126..46662f2d65d9 100644 --- a/src/hooks/useSelectionModePayment.ts +++ b/src/hooks/useSelectionModePayment.ts @@ -2,7 +2,6 @@ import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/ import {KYCWallContext} from '@components/KYCWall/KYCWallContext'; import {useLockedAccountActions, useLockedAccountState} from '@components/LockedAccountModalProvider'; import type {PopoverMenuItem} from '@components/PopoverMenu'; -import type {ActionHandledType} from '@components/ProcessMoneyReportHoldMenu'; import {useSearchQueryContext, useSearchResultsContext} from '@components/Search/SearchContext'; import type {PaymentActionParams} from '@components/SettlementButton/types'; @@ -29,6 +28,8 @@ import {loginSelector} from '@selectors/PersonalDetails'; import truncate from 'lodash/truncate'; import {useContext, useEffect, useRef} from 'react'; +import type {ActionHandledType} from './useHoldMenuSubmit'; + import useActiveAdminPolicies from './useActiveAdminPolicies'; import {useCurrencyListActions} from './useCurrencyList'; import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; diff --git a/src/hooks/useSelectionModeReportActions.ts b/src/hooks/useSelectionModeReportActions.ts index 0d10b99dce71..6ae5039bddbd 100644 --- a/src/hooks/useSelectionModeReportActions.ts +++ b/src/hooks/useSelectionModeReportActions.ts @@ -1,16 +1,16 @@ import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; import type {PopoverMenuItem} from '@components/PopoverMenu'; -import type {ActionHandledType} from '@components/ProcessMoneyReportHoldMenu'; import {useSearchSelectionActions} from '@components/Search/SearchContext'; import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; +import getHoldMenuAmounts from '@libs/HoldMenuUtils'; import {getTotalAmountForIOUReportPreviewButton, isEveryReportTransactionSelected} from '@libs/MoneyRequestReportUtils'; import TransitionTracker from '@libs/Navigation/TransitionTracker'; import type {KYCFlowEvent, TriggerKYCFlow} from '@libs/PaymentUtils'; import {isSubmitPolicy} from '@libs/PolicyUtils'; import {getReportPrimaryAction} from '@libs/ReportPrimaryActionUtils'; import {getSecondaryReportActions} from '@libs/ReportSecondaryActionUtils'; -import {canIOUBePaid as canIOUBePaidAction, getNonHeldAndFullAmount, hasOnlyHeldExpenses as hasOnlyHeldExpensesReportUtils, hasUpdatedTotal, shouldShowMarkAsDone} from '@libs/ReportUtils'; +import {canIOUBePaid as canIOUBePaidAction, hasUpdatedTotal, shouldShowMarkAsDone} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -22,10 +22,12 @@ import type {OnyxEntry} from 'react-native-onyx'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; import {loginSelector} from '@selectors/PersonalDetails'; -import {useState} from 'react'; + +import type {ActionHandledType} from './useHoldMenuSubmit'; import {useCurrencyListActions} from './useCurrencyList'; import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; +import useHoldMenuModal from './useHoldMenuModal'; import {useMemoizedLazyExpensifyIcons} from './useLazyAsset'; import useLifecycleActions from './useLifecycleActions'; import useLocalize from './useLocalize'; @@ -81,11 +83,49 @@ function useSelectionModeReportActions({ const expensifyIcons = useMemoizedLazyExpensifyIcons(['Send', 'ThumbsUp', 'Cash', 'ArrowRight'] as const); - // Hold menu state (managed locally on mobile; desktop uses modals context) - const [isHoldMenuVisible, setIsHoldMenuVisible] = useState(false); - const [paymentType, setPaymentType] = useState(); - const [requestType, setRequestType] = useState(); - const [selectedVBBAToPayFromHoldMenu, setSelectedVBBAToPayFromHoldMenu] = useState(undefined); + const {showHoldMenu} = useHoldMenuModal(); + + // Payment flow + const nonPendingDeleteTransactions = transactions.filter((t): t is OnyxTypes.Transaction => !!t && (isOffline || t.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE)); + + const getCanIOUBePaid = (onlyShowPayElsewhere = false) => + canIOUBePaidAction( + report, + chatReport, + policy, + bankAccountList, + currentUserLogin ?? '', + currentUserAccountID, + transactions, + onlyShowPayElsewhere, + isChatReportArchived, + invoiceReceiverPolicy, + ); + const canIOUBePaid = getCanIOUBePaid(); + const onlyShowPayElsewhere = !canIOUBePaid && getCanIOUBePaid(true); + const shouldShowPayButton = canIOUBePaid || onlyShowPayElsewhere; + const canAllowSettlement = hasUpdatedTotal(report, policy); + + const totalAmount = getTotalAmountForIOUReportPreviewButton(report, policy, CONST.REPORT.PRIMARY_ACTIONS.PAY, nonPendingDeleteTransactions, convertToDisplayString); + + const openHoldMenu = (requestType: ActionHandledType, paymentType?: PaymentMethodType, methodID?: number, onConfirm?: () => void) => { + // Wait one frame so the current touch animation finishes before the modal opens + requestAnimationFrame(() => { + showHoldMenu({ + reportID: report?.reportID, + chatReportID: chatReport?.reportID, + requestType, + paymentType, + methodID: paymentType === CONST.IOU.PAYMENT_TYPE.VBBA ? methodID : undefined, + ...getHoldMenuAmounts(report, shouldShowPayButton, transactions, convertToDisplayString), + onConfirm: () => { + onConfirm?.(); + clearSelectedTransactions(true); + turnOffMobileSelectionMode(); + }, + }); + }); + }; // Submit/approve via shared useLifecycleActions (no animations on mobile, skipAnimation=true) const { @@ -101,13 +141,7 @@ function useSelectionModeReportActions({ startApprovedAnimation: () => {}, startAnimation: () => {}, startSubmittingAnimation: () => {}, - onHoldMenuOpen: (rt, _onConfirm, pt) => { - setRequestType(rt); - if (pt) { - setPaymentType(pt); - } - setIsHoldMenuVisible(true); - }, + onHoldMenuOpen: (requestType, onConfirm, paymentType) => openHoldMenu(requestType, paymentType, undefined, onConfirm), onCleanup: turnOffMobileSelectionMode, }); @@ -119,30 +153,6 @@ function useSelectionModeReportActions({ lifecycleConfirmApproval(true); }; - // Payment flow - const nonPendingDeleteTransactions = transactions.filter((t): t is OnyxTypes.Transaction => !!t && (isOffline || t.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE)); - - const getCanIOUBePaid = (onlyShowPayElsewhere = false) => - canIOUBePaidAction( - report, - chatReport, - policy, - bankAccountList, - currentUserLogin ?? '', - currentUserAccountID, - transactions, - onlyShowPayElsewhere, - isChatReportArchived, - invoiceReceiverPolicy, - ); - const canIOUBePaid = getCanIOUBePaid(); - const onlyShowPayElsewhere = !canIOUBePaid && getCanIOUBePaid(true); - const shouldShowPayButton = canIOUBePaid || onlyShowPayElsewhere; - const canAllowSettlement = hasUpdatedTotal(report, policy); - - const totalAmount = getTotalAmountForIOUReportPreviewButton(report, policy, CONST.REPORT.PRIMARY_ACTIONS.PAY, nonPendingDeleteTransactions, convertToDisplayString); - const {nonHeldAmount, fullAmount, hasValidNonHeldAmount} = getNonHeldAndFullAmount(report, shouldShowPayButton, transactions, convertToDisplayString); - // Primary/secondary action detection const currentUserEmail = currentUserLogin ?? ''; @@ -223,12 +233,7 @@ function useSelectionModeReportActions({ onlyShowPayElsewhere, hasPayAction, allExpensesSelected, - onHoldMenuOpen: ({requestType: rt, paymentType: pt, methodID}) => { - setRequestType(rt); - setPaymentType(pt); - setSelectedVBBAToPayFromHoldMenu(methodID); - setIsHoldMenuVisible(true); - }, + onHoldMenuOpen: ({requestType, paymentType, methodID}) => openHoldMenu(requestType, paymentType, methodID), onPaymentComplete: () => { clearSelectedTransactions(true); turnOffMobileSelectionMode(); @@ -286,29 +291,11 @@ function useSelectionModeReportActions({ return actions; })(); - const handleHoldMenuClose = () => { - setSelectedVBBAToPayFromHoldMenu(undefined); - setIsHoldMenuVisible(false); - }; - - const handleHoldMenuConfirm = () => { - clearSelectedTransactions(true); - turnOffMobileSelectionMode(); - }; - return { selectionModeReportLevelActions, allExpensesSelected, shouldBlockSubmit: effectiveShouldBlockSubmit, isBlockSubmitDueToPreventSelfApproval, - - // Hold menu state - isHoldMenuVisible, - requestType, - paymentType, - selectedVBBAToPayFromHoldMenu, - handleHoldMenuClose, - handleHoldMenuConfirm, confirmPayment, confirmApproval, @@ -322,10 +309,6 @@ function useSelectionModeReportActions({ canAllowSettlement, isAnyTransactionOnHold, isInvoiceReport, - hasOnlyHeldExpenses: hasOnlyHeldExpensesReportUtils(transactions), - nonHeldAmount, - fullAmount, - hasValidNonHeldAmount, // KYC dropdown integration onSelectionModePaymentSelect, diff --git a/src/libs/HoldMenuUtils.ts b/src/libs/HoldMenuUtils.ts new file mode 100644 index 000000000000..1f0112ff0a2d --- /dev/null +++ b/src/libs/HoldMenuUtils.ts @@ -0,0 +1,33 @@ +import type {CurrencyListActionsContextType} from '@hooks/useCurrencyList'; + +import type {Report, Transaction} from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import {getNonHeldAndFullAmount, hasOnlyHeldExpenses} from './ReportUtils'; + +type HoldMenuAmounts = { + nonHeldAmount: string | undefined; + fullAmount: string; + hasNonHeldExpenses: boolean; + transactionCount: number; +}; + +/** Builds the amount props the hold menu shows for a report: the non-held and full amounts, and the transaction count */ +function getHoldMenuAmounts( + report: OnyxEntry, + shouldShowPayButton: boolean, + transactions: Transaction[], + convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString'], +): HoldMenuAmounts { + const hasNonHeldExpenses = !hasOnlyHeldExpenses(transactions); + const {nonHeldAmount, fullAmount, hasValidNonHeldAmount} = getNonHeldAndFullAmount(report, shouldShowPayButton, transactions, convertToDisplayString); + return { + nonHeldAmount: hasNonHeldExpenses && hasValidNonHeldAmount ? nonHeldAmount : undefined, + fullAmount, + hasNonHeldExpenses, + transactionCount: transactions.length, + }; +} + +export default getHoldMenuAmounts; diff --git a/tests/ui/MoneyRequestReportPreview.test.tsx b/tests/ui/MoneyRequestReportPreview.test.tsx index c7f2638e4fd2..bf3eaaa820f4 100644 --- a/tests/ui/MoneyRequestReportPreview.test.tsx +++ b/tests/ui/MoneyRequestReportPreview.test.tsx @@ -5,8 +5,6 @@ import {CurrencyListContextProvider} from '@components/CurrencyListContextProvid import {LocaleContextProvider} from '@components/LocaleContextProvider'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; import MoneyRequestReportPreview from '@components/ReportActionItem/MoneyRequestReportPreview'; -import type * as MoneyRequestReportPreviewContext from '@components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContext'; -import type ReportPreviewActionButton from '@components/ReportActionItem/MoneyRequestReportPreview/ReportPreviewActionButton'; import type {MoneyRequestReportPreviewProps} from '@components/ReportActionItem/MoneyRequestReportPreview/types'; import ScreenWrapper from '@components/ScreenWrapper'; import {ShowContextMenuActionsContext, ShowContextMenuStateContext} from '@components/ShowContextMenuContext'; @@ -29,7 +27,6 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {Route} from '@src/ROUTES'; import ROUTES from '@src/ROUTES'; import type {Report, ReportAction, Transaction, TransactionViolation, TransactionViolations} from '@src/types/onyx'; -import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; import {toCollectionDataSet} from '@src/types/utils/CollectionDataSet'; import type {OnyxCollection, OnyxEntry, OnyxMergeInput} from 'react-native-onyx'; @@ -133,24 +130,6 @@ jest.mock('@hooks/useReportTransactionsCollection', () => ({ default: () => mockUseReportTransactionsCollection(), })); -type OnHoldMenuOpen = (paymentType?: PaymentMethodType, canPay?: boolean, methodID?: number) => void; - -// Capture onHoldMenuOpen so a held-expense payment can be triggered with a chosen bank account. -const mockOnHoldMenuOpenHolder: {current?: OnHoldMenuOpen} = {current: undefined}; -jest.mock('@components/ReportActionItem/MoneyRequestReportPreview/ReportPreviewActionButton', () => { - const actualReact = jest.requireActual('react'); - const actualModule = jest.requireActual<{default: typeof ReportPreviewActionButton}>('@components/ReportActionItem/MoneyRequestReportPreview/ReportPreviewActionButton'); - const {useReportPreviewActions} = jest.requireActual('@components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContext'); - return { - __esModule: true, - default: function MockReportPreviewActionButton() { - const {onHoldMenuOpen} = useReportPreviewActions(); - mockOnHoldMenuOpenHolder.current = onHoldMenuOpen; - return actualReact.createElement(actualModule.default); - }, - }; -}); - // The RHP widths never reach the rendered output, so capture the calls to assert they are requested and released. const mockMarkReportRHPWidth = jest.fn(); const mockUnmarkReportRHPWidth = jest.fn(); @@ -162,19 +141,6 @@ jest.mock('@components/WideRHPContextProvider', () => ({ }), })); -const mockHoldMenuPropsHolder: { - current?: {isVisible?: boolean; paymentType?: PaymentMethodType; methodID?: number}; -} = {current: undefined}; -jest.mock('@components/ProcessMoneyReportHoldMenu', () => ({ - __esModule: true, - default: (props: {isVisible?: boolean; paymentType?: PaymentMethodType; methodID?: number}) => { - mockHoldMenuPropsHolder.current = props; - return null; - }, -})); - -const SELECTED_BANK_ACCOUNT_ID = 9999; - const getIOUActionForReportID = (reportID: string | undefined, transactionID: string | undefined) => { if (!reportID || !transactionID) { return undefined; @@ -311,8 +277,6 @@ describe('MoneyRequestReportPreview', () => { jest.clearAllMocks(); mockUseNetwork.mockReturnValue({isOffline: false}); mockDeferredValueOverride = undefined; - mockOnHoldMenuOpenHolder.current = undefined; - mockHoldMenuPropsHolder.current = undefined; setReportPreviewData(); return act(async () => { await Onyx.clear(); @@ -372,28 +336,6 @@ describe('MoneyRequestReportPreview', () => { expect(screen.queryByText(TestHelper.translateLocal('common.total'))).not.toBeOnTheScreen(); }); - it('forwards the selected bank account to the hold menu when paying a held expense from the preview', async () => { - renderPage({}); - await waitForBatchedUpdatesWithAct(); - setCurrentWidth(); - await act(async () => { - await Onyx.mergeCollection(ONYXKEYS.COLLECTION.TRANSACTION, mockOnyxTransactions); - await waitForBatchedUpdatesWithAct(); - }); - await waitForBatchedUpdatesWithAct(); - - expect(mockOnHoldMenuOpenHolder.current).toBeDefined(); - - act(() => { - mockOnHoldMenuOpenHolder.current?.(CONST.IOU.PAYMENT_TYPE.VBBA, true, SELECTED_BANK_ACCOUNT_ID); - }); - await waitForBatchedUpdatesWithAct(); - - expect(mockHoldMenuPropsHolder.current?.isVisible).toBe(true); - expect(mockHoldMenuPropsHolder.current?.paymentType).toBe(CONST.IOU.PAYMENT_TYPE.VBBA); - expect(mockHoldMenuPropsHolder.current?.methodID).toBe(SELECTED_BANK_ACCOUNT_ID); - }); - it('renders RBR for every transaction with violations', async () => { renderPage({}); await waitForBatchedUpdatesWithAct(); diff --git a/tests/ui/components/PayActionButtonTest.tsx b/tests/ui/components/PayActionButtonTest.tsx index e01dc2fd1a75..487b8549c9af 100644 --- a/tests/ui/components/PayActionButtonTest.tsx +++ b/tests/ui/components/PayActionButtonTest.tsx @@ -47,7 +47,6 @@ const mockReportPreviewActions = { startApprovedAnimation: jest.fn(), onPaymentOptionsShow: jest.fn(), onPaymentOptionsHide: jest.fn(), - onHoldMenuOpen: jest.fn(), }; const mockReportPreviewUIState = {buttonMaxWidth: {}}; const mockReportPreviewActionState = { @@ -126,6 +125,14 @@ jest.mock('@hooks/usePolicy', () => ({__esModule: true, default: jest.fn(() => u jest.mock('@hooks/useReportTransactionsCollection', () => ({__esModule: true, default: jest.fn(() => ({}))})); jest.mock('@hooks/useOnyx', () => jest.fn()); +const mockShowHoldMenu = jest.fn(); +jest.mock('@hooks/useHoldMenuModal', () => ({__esModule: true, default: jest.fn(() => ({showHoldMenu: mockShowHoldMenu}))})); +// The hold menu waits one animation frame before opening; run the frame right away so it opens synchronously. +jest.spyOn(global, 'requestAnimationFrame').mockImplementation((callback) => { + callback(0); + return 0; +}); + jest.mock('@components/DelegateNoAccessModalProvider', () => ({ __esModule: true, useDelegateNoAccessState: jest.fn(() => ({isDelegateAccessRestricted: false})), @@ -147,8 +154,7 @@ const mockedPayMoneyRequest = jest.mocked(payMoneyRequest); const mockedHasHeldExpenses = jest.mocked(hasHeldExpensesFromTransactions); const mockedHasViolations = jest.mocked(hasViolations); -function renderPayActionButton(onHoldMenuOpen: jest.Mock) { - mockReportPreviewActions.onHoldMenuOpen = onHoldMenuOpen; +function renderPayActionButton() { return render(); } @@ -172,7 +178,7 @@ describe('PayActionButton', () => { }); it('forwards the selected bank account to payMoneyRequest when paying with a business bank account', () => { - renderPayActionButton(jest.fn()); + renderPayActionButton(); act(() => { mockOnPressHolder.current?.({ @@ -192,7 +198,7 @@ describe('PayActionButton', () => { }); it('does not send a bank account for non-VBBA payment types', () => { - renderPayActionButton(jest.fn()); + renderPayActionButton(); act(() => { mockOnPressHolder.current?.({ @@ -204,11 +210,12 @@ describe('PayActionButton', () => { expect(mockedPayMoneyRequest).toHaveBeenCalledWith(expect.objectContaining({methodID: undefined})); }); - it('forwards the selected bank account to the hold menu when the report has held expenses', () => { + it('opens the global hold menu with the selected bank account when the report has held expenses', () => { + // Given a report preview whose report has held expenses mockedHasHeldExpenses.mockReturnValue(true); - const onHoldMenuOpen = jest.fn(); - renderPayActionButton(onHoldMenuOpen); + renderPayActionButton(); + // When the user pays with a business bank account act(() => { mockOnPressHolder.current?.({ paymentType: CONST.IOU.PAYMENT_TYPE.VBBA, @@ -218,7 +225,31 @@ describe('PayActionButton', () => { }); }); - expect(onHoldMenuOpen).toHaveBeenCalledWith(CONST.IOU.PAYMENT_TYPE.VBBA, expect.anything(), SELECTED_BANK_ACCOUNT_ID); + // Then the global hold menu opens for this report with the chosen account, and the payment waits for the user's choice + expect(mockShowHoldMenu).toHaveBeenCalledWith( + expect.objectContaining({ + reportID: TEST_IOU_REPORT_ID, + chatReportID: TEST_CHAT_REPORT_ID, + paymentType: CONST.IOU.PAYMENT_TYPE.VBBA, + methodID: SELECTED_BANK_ACCOUNT_ID, + transactionCount: 0, + onConfirm: mockReportPreviewActions.startAnimation, + }), + ); expect(mockedPayMoneyRequest).not.toHaveBeenCalled(); }); + + it('does not open the hold menu when the report has no held expenses', () => { + // Given a report preview without held expenses + renderPayActionButton(); + + // When the user pays + act(() => { + mockOnPressHolder.current?.({paymentType: CONST.IOU.PAYMENT_TYPE.ELSEWHERE}); + }); + + // Then the payment goes straight through without asking about held expenses + expect(mockShowHoldMenu).not.toHaveBeenCalled(); + expect(mockedPayMoneyRequest).toHaveBeenCalled(); + }); }); diff --git a/tests/ui/components/ReportPreviewHoldMenuTest.tsx b/tests/ui/components/ReportPreviewHoldMenuTest.tsx deleted file mode 100644 index d743415b3711..000000000000 --- a/tests/ui/components/ReportPreviewHoldMenuTest.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import {act, render} from '@testing-library/react-native'; - -import {ReportPreviewActionsContext, ReportPreviewDataContext, ReportPreviewHoldMenuContext} from '@components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContext'; -import ReportPreviewHoldMenu from '@components/ReportActionItem/MoneyRequestReportPreview/ReportPreviewHoldMenu'; - -import CONST from '@src/CONST'; -import type {Report, Transaction} from '@src/types/onyx'; -import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; - -import type {OnyxEntry} from 'react-native-onyx'; - -import React from 'react'; - -import {actionR14932 as mockAction} from '../../../__mocks__/reportData/actions'; -import {chatReportR14932 as mockChatReport, iouReportR14932 as mockIOUReport} from '../../../__mocks__/reportData/reports'; -import createRandomTransaction from '../../utils/collections/transaction'; - -const SELECTED_BANK_ACCOUNT_ID = 9999; - -type CapturedHoldMenuProps = { - isVisible?: boolean; - paymentType?: PaymentMethodType; - methodID?: number; - nonHeldAmount?: string; - hasNonHeldExpenses?: boolean; - onConfirm?: (full: boolean) => void; -}; - -// Capture the props ReportPreviewHoldMenu forwards to the underlying menu so the context-driven wiring can be asserted. -const mockHoldMenuPropsHolder: {current?: CapturedHoldMenuProps} = {current: undefined}; -jest.mock('@components/ProcessMoneyReportHoldMenu', () => ({ - __esModule: true, - default: (props: CapturedHoldMenuProps) => { - mockHoldMenuPropsHolder.current = props; - return null; - }, -})); - -const heldTransaction: Transaction = {...createRandomTransaction(1), comment: {...createRandomTransaction(1).comment, hold: 'holdReportActionID'}}; -const nonHeldTransaction: Transaction = {...createRandomTransaction(2), comment: {...createRandomTransaction(2).comment, hold: undefined}}; - -const defaultHoldMenuState = { - paymentType: CONST.IOU.PAYMENT_TYPE.VBBA as PaymentMethodType, - canPay: true, - methodID: SELECTED_BANK_ACCOUNT_ID, -}; - -const mockActionsValue = { - openReportFromPreview: jest.fn(), - onHoldMenuOpen: jest.fn(), - onHoldMenuClose: jest.fn(), - stopAnimation: jest.fn(), - startAnimation: jest.fn(), - startApprovedAnimation: jest.fn(), - startSubmittingAnimation: jest.fn(), - goToPrevious: jest.fn(), - goToNext: jest.fn(), -}; - -function renderHoldMenu( - overrides: { - holdMenu?: typeof defaultHoldMenuState | null; - iouReport?: OnyxEntry; - transactions?: Transaction[]; - } = {}, -) { - // An explicit `iouReport: undefined` override must not fall back to the mock report, so presence is checked instead of relying on parameter defaults - const iouReport = Object.prototype.hasOwnProperty.call(overrides, 'iouReport') ? overrides.iouReport : mockIOUReport; - const holdMenu = overrides.holdMenu === undefined ? defaultHoldMenuState : overrides.holdMenu; - const transactions = overrides.transactions ?? [heldTransaction, nonHeldTransaction]; - const dataValue = { - iouReportID: iouReport?.reportID, - chatReportID: mockChatReport.reportID, - action: mockAction, - iouReport, - chatReport: mockChatReport, - transactions, - policy: undefined, - invoiceReceiverPolicy: undefined, - invoiceReceiverPersonalDetail: null, - }; - - return render( - - - - - - - , - ); -} - -describe('ReportPreviewHoldMenu', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockHoldMenuPropsHolder.current = undefined; - }); - - it('renders nothing while the hold menu state is closed', () => { - renderHoldMenu({holdMenu: null}); - - expect(mockHoldMenuPropsHolder.current).toBeUndefined(); - }); - - it('renders nothing when the IOU report is not available', () => { - renderHoldMenu({iouReport: undefined}); - - expect(mockHoldMenuPropsHolder.current).toBeUndefined(); - }); - - it('forwards the payment type and selected bank account from the context state', () => { - renderHoldMenu(); - - expect(mockHoldMenuPropsHolder.current?.isVisible).toBe(true); - expect(mockHoldMenuPropsHolder.current?.paymentType).toBe(CONST.IOU.PAYMENT_TYPE.VBBA); - expect(mockHoldMenuPropsHolder.current?.methodID).toBe(SELECTED_BANK_ACCOUNT_ID); - }); - - it('offers a non-held amount when the report mixes held and non-held expenses', () => { - // Make the unheld total differ from the full total so the partial amount is considered valid for an expense report - renderHoldMenu({iouReport: {...mockIOUReport, unheldTotal: (mockIOUReport.total ?? 0) / 2}}); - - expect(mockHoldMenuPropsHolder.current?.hasNonHeldExpenses).toBe(true); - expect(mockHoldMenuPropsHolder.current?.nonHeldAmount).toBeDefined(); - }); - - it('omits the non-held amount when every expense is on hold', () => { - renderHoldMenu({transactions: [heldTransaction]}); - - expect(mockHoldMenuPropsHolder.current?.hasNonHeldExpenses).toBe(false); - expect(mockHoldMenuPropsHolder.current?.nonHeldAmount).toBeUndefined(); - }); - - it('starts the paid animation when confirming a payment', () => { - renderHoldMenu(); - - act(() => { - mockHoldMenuPropsHolder.current?.onConfirm?.(true); - }); - - expect(mockActionsValue.startAnimation).toHaveBeenCalled(); - expect(mockActionsValue.startApprovedAnimation).not.toHaveBeenCalled(); - }); -}); diff --git a/tests/unit/HoldMenuUtilsTest.ts b/tests/unit/HoldMenuUtilsTest.ts new file mode 100644 index 000000000000..ca5c94a1f6d9 --- /dev/null +++ b/tests/unit/HoldMenuUtilsTest.ts @@ -0,0 +1,63 @@ +import getHoldMenuAmounts from '@libs/HoldMenuUtils'; +import {getNonHeldAndFullAmount, hasOnlyHeldExpenses} from '@libs/ReportUtils'; + +import {createRandomReport} from '../utils/collections/reports'; +import createRandomTransaction from '../utils/collections/transaction'; + +jest.mock('@libs/ReportUtils', () => ({ + getNonHeldAndFullAmount: jest.fn(), + hasOnlyHeldExpenses: jest.fn(), +})); + +const mockGetNonHeldAndFullAmount = jest.mocked(getNonHeldAndFullAmount); +const mockHasOnlyHeldExpenses = jest.mocked(hasOnlyHeldExpenses); + +const report = createRandomReport(1, undefined); +const transactions = [createRandomTransaction(1), createRandomTransaction(2), createRandomTransaction(3)]; +const convertToDisplayString = jest.fn(); + +describe('HoldMenuUtils', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getHoldMenuAmounts', () => { + it('returns the non-held amount when the report has a valid non-held amount', () => { + // Given a report where some expenses are not held and the non-held amount is valid + mockHasOnlyHeldExpenses.mockReturnValue(false); + mockGetNonHeldAndFullAmount.mockReturnValue({nonHeldAmount: '$10.00', fullAmount: '$30.00', hasValidNonHeldAmount: true}); + + // When the hold menu amounts are built + const amounts = getHoldMenuAmounts(report, true, transactions, convertToDisplayString); + + // Then the menu offers both amounts, so the user can pay only the non-held expenses + expect(amounts).toEqual({nonHeldAmount: '$10.00', fullAmount: '$30.00', hasNonHeldExpenses: true, transactionCount: 3}); + expect(mockGetNonHeldAndFullAmount).toHaveBeenCalledWith(report, true, transactions, convertToDisplayString); + }); + + it('drops the non-held amount when every expense is held', () => { + // Given a report where every expense is held + mockHasOnlyHeldExpenses.mockReturnValue(true); + mockGetNonHeldAndFullAmount.mockReturnValue({nonHeldAmount: '$0.00', fullAmount: '$30.00', hasValidNonHeldAmount: true}); + + // When the hold menu amounts are built + const amounts = getHoldMenuAmounts(report, true, transactions, convertToDisplayString); + + // Then there is no non-held amount to offer, so the menu only shows the full amount + expect(amounts).toEqual({nonHeldAmount: undefined, fullAmount: '$30.00', hasNonHeldExpenses: false, transactionCount: 3}); + }); + + it('drops the non-held amount when it is not valid', () => { + // Given a report with non-held expenses whose non-held amount is not valid + mockHasOnlyHeldExpenses.mockReturnValue(false); + mockGetNonHeldAndFullAmount.mockReturnValue({nonHeldAmount: '$10.00', fullAmount: '$30.00', hasValidNonHeldAmount: false}); + + // When the hold menu amounts are built + const amounts = getHoldMenuAmounts(report, true, transactions, convertToDisplayString); + + // Then the invalid amount is not offered + expect(amounts.nonHeldAmount).toBeUndefined(); + expect(amounts.hasNonHeldExpenses).toBe(true); + }); + }); +}); diff --git a/tests/unit/hooks/useSelectionModeReportActions.test.ts b/tests/unit/hooks/useSelectionModeReportActions.test.ts index 344545de28c2..bd2af7e5ab61 100644 --- a/tests/unit/hooks/useSelectionModeReportActions.test.ts +++ b/tests/unit/hooks/useSelectionModeReportActions.test.ts @@ -3,9 +3,11 @@ import {act, renderHook, waitFor} from '@testing-library/react-native'; import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider'; import {useLockedAccountActions, useLockedAccountState} from '@components/LockedAccountModalProvider'; +import type {HoldMenuModalWrapperProps} from '@components/Modal/Global/HoldMenuModalWrapper'; import useLifecycleActions from '@hooks/useLifecycleActions'; import type {ReportSubmitToPopoverOpenOptions} from '@hooks/useReportSubmitToPopover'; +import useSelectionModePayment from '@hooks/useSelectionModePayment'; import useSelectionModeReportActions from '@hooks/useSelectionModeReportActions'; import {submitReport} from '@libs/actions/IOU/ReportWorkflow'; @@ -166,6 +168,19 @@ jest.mock('@hooks/useSelectionModePayment', () => ({ })), })); +const mockShowHoldMenu = jest.fn]>(); + +jest.mock('@hooks/useHoldMenuModal', () => ({ + __esModule: true, + default: jest.fn(() => ({showHoldMenu: mockShowHoldMenu})), +})); + +// The hold menu waits one animation frame before opening; run the frame right away so it opens synchronously. +jest.spyOn(global, 'requestAnimationFrame').mockImplementation((callback) => { + callback(0); + return 0; +}); + jest.mock('@hooks/useLazyAsset', () => ({ __esModule: true, useMemoizedLazyExpensifyIcons: jest.fn(() => ({ @@ -525,19 +540,65 @@ describe('useSelectionModeReportActions', () => { }); }); - describe('hold menu state', () => { - it('initializes hold menu as not visible', () => { - const {result} = renderSelectionModeHook(); + describe('hold menu', () => { + it('opens the global hold menu with the selected bank account when paying a report with held expenses', () => { + // Given selection mode on a report + renderSelectionModeHook(); + const {onHoldMenuOpen} = jest.mocked(useSelectionModePayment).mock.calls.at(-1)?.[0] ?? {}; + + // When paying with a business bank account while some expenses are on hold + act(() => { + onHoldMenuOpen?.({requestType: CONST.IOU.REPORT_ACTION_TYPE.PAY, paymentType: CONST.IOU.PAYMENT_TYPE.VBBA, methodID: 42}); + }); - expect(result.current.isHoldMenuVisible).toBe(false); + // Then the global hold menu opens for the report with the amounts and chosen account + expect(mockShowHoldMenu).toHaveBeenCalledWith( + expect.objectContaining({ + reportID: TEST_REPORT_ID, + chatReportID: TEST_CHAT_REPORT_ID, + requestType: CONST.IOU.REPORT_ACTION_TYPE.PAY, + paymentType: CONST.IOU.PAYMENT_TYPE.VBBA, + methodID: 42, + nonHeldAmount: '$100.00', + fullAmount: '$100.00', + hasNonHeldExpenses: true, + transactionCount: 2, + }), + ); + }); + + it('does not forward a bank account for non-VBBA payments', () => { + // Given selection mode on a report + renderSelectionModeHook(); + const {onHoldMenuOpen} = jest.mocked(useSelectionModePayment).mock.calls.at(-1)?.[0] ?? {}; + + // When paying elsewhere while some expenses are on hold + act(() => { + onHoldMenuOpen?.({requestType: CONST.IOU.REPORT_ACTION_TYPE.PAY, paymentType: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, methodID: 42}); + }); + + // Then the hold menu does not receive a bank account, since only VBBA payments use one + expect(mockShowHoldMenu).toHaveBeenCalledWith(expect.objectContaining({paymentType: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, methodID: undefined})); }); - it('returns hold menu amounts from getNonHeldAndFullAmount', () => { - const {result} = renderSelectionModeHook(); + it('runs the lifecycle confirm callback and clears the selection when the hold menu is confirmed', () => { + // Given selection mode on a report, and a lifecycle action (mark as paid) that opens the hold menu with its own confirm callback + renderSelectionModeHook(); + const {onHoldMenuOpen} = jest.mocked(useLifecycleActions).mock.calls.at(-1)?.[0] ?? {}; + const lifecycleOnConfirm = jest.fn(); + act(() => { + onHoldMenuOpen?.(CONST.IOU.REPORT_ACTION_TYPE.PAY, lifecycleOnConfirm, CONST.IOU.PAYMENT_TYPE.ELSEWHERE); + }); - expect(result.current.nonHeldAmount).toBe('$100.00'); - expect(result.current.fullAmount).toBe('$100.00'); - expect(result.current.hasValidNonHeldAmount).toBe(true); + // When the user confirms the hold menu + const {onConfirm} = mockShowHoldMenu.mock.calls.at(-1)?.[0] ?? {}; + act(() => { + onConfirm?.(true); + }); + + // Then the lifecycle callback is no longer dropped, and the selection is cleared like before + expect(lifecycleOnConfirm).toHaveBeenCalled(); + expect(mockClearSelectedTransactions).toHaveBeenCalledWith(true); }); }); @@ -703,29 +764,6 @@ describe('useSelectionModeReportActions', () => { }); }); - describe('handleHoldMenuClose', () => { - it('resets hold menu state', () => { - const {result} = renderSelectionModeHook(); - - expect(result.current.isHoldMenuVisible).toBe(false); - - act(() => { - result.current.handleHoldMenuClose(); - }); - expect(result.current.isHoldMenuVisible).toBe(false); - }); - }); - - describe('handleHoldMenuConfirm', () => { - it('clears selected transactions', () => { - const {result} = renderSelectionModeHook(); - - result.current.handleHoldMenuConfirm(); - - expect(mockClearSelectedTransactions).toHaveBeenCalledWith(true); - }); - }); - describe('selectionModeKYCSuccess', () => { it('exposes selectionModeKYCSuccess from useSelectionModePayment', () => { const {result} = renderSelectionModeHook();