Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
697b683
Refactor useNonReimbursablePaymentModal to be fully imperative
TMisiukiewicz Apr 3, 2026
5caacde
Refactor ProcessMoneyReportHoldMenu to use onConfirm callback pattern
TMisiukiewicz Apr 3, 2026
0815e3b
Add MoneyReportHeaderModalsContext
TMisiukiewicz Apr 3, 2026
c4656c9
Add MoneyReportHeaderModals provider component
TMisiukiewicz Apr 3, 2026
25ec7ca
Wire MoneyReportHeader to use MoneyReportHeaderModals provider
TMisiukiewicz Apr 3, 2026
add9996
Update MoneyReportHeaderPrimaryAction to consume modals context directly
TMisiukiewicz Apr 3, 2026
a7fd0d0
Migrate ProcessMoneyReportHoldMenu to imperative useHoldMenuModal hook
TMisiukiewicz Apr 3, 2026
583665e
Fix lint and type errors
TMisiukiewicz Apr 3, 2026
76ab787
Restore original non-reimbursable check in hold menu
TMisiukiewicz Apr 3, 2026
9819121
Fix isSelectionModePaymentRef reset and getNonHeldAndFullAmount param
TMisiukiewicz Apr 3, 2026
e51c7ac
Merge remote-tracking branch 'origin/main' into organize-moneyreporth…
TMisiukiewicz Apr 3, 2026
14120b2
Fix modals context by splitting provider and consumer into separate c…
TMisiukiewicz Apr 3, 2026
af57247
Inline IIFE to ternary in HoldMenuModalWrapper
TMisiukiewicz Apr 3, 2026
c48f125
Encapsulate educational modals state and data fetching
TMisiukiewicz Apr 3, 2026
115a0b6
Merge remote-tracking branch 'origin/main' into organize-moneyreporth…
TMisiukiewicz Apr 3, 2026
130af46
Move decision modals into MoneyReportHeaderModalsContext
TMisiukiewicz Apr 3, 2026
67e95ff
remove unused param from PayPrimaryAction
TMisiukiewicz Apr 3, 2026
1fd24f3
fix prettier
TMisiukiewicz Apr 3, 2026
8e07137
Merge remote-tracking branch 'origin/main' into organize-moneyreporth…
TMisiukiewicz Apr 7, 2026
e8aa264
Address PR review comments: extract useHoldMenuSubmit, re-add iOS Int…
TMisiukiewicz Apr 7, 2026
a37dcd3
Fix Prettier: sort imports in useHoldMenuSubmit
TMisiukiewicz Apr 7, 2026
6ce1795
Suppress no-deprecated for InteractionManager.runAfterInteractions
TMisiukiewicz Apr 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions src/components/Modal/Global/HoldMenuModalWrapper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import React, {useState} from 'react';
import DecisionModal from '@components/DecisionModal';
import useHoldMenuSubmit from '@hooks/useHoldMenuSubmit';
import type {ActionHandledType} from '@hooks/useHoldMenuSubmit';
import useLocalize from '@hooks/useLocalize';
import useOnyx from '@hooks/useOnyx';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport';
import ONYXKEYS from '@src/ONYXKEYS';
import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage';
import type {ModalProps} from './ModalContext';

type HoldMenuModalWrapperProps = ModalProps & {
reportID: string | undefined;
chatReportID: string | undefined;
requestType: ActionHandledType;
paymentType?: PaymentMethodType;
methodID?: number;
nonHeldAmount?: string;
fullAmount: string;
hasNonHeldExpenses?: boolean;
transactionCount: number;
onConfirm?: (full: boolean) => void;
};

function HoldMenuModalWrapper({
closeModal,
reportID,
chatReportID,
requestType,
paymentType,
methodID,
nonHeldAmount = '0',
fullAmount,
hasNonHeldExpenses,
transactionCount,
onConfirm,
}: HoldMenuModalWrapperProps) {
const [isVisible, setIsVisible] = useState(true);
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 [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`);
const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`);

const {transactions: reportTransactions} = useTransactionsAndViolationsForReport(moneyRequestReport?.reportID);
const transactions = Object.values(reportTransactions);

const {onSubmit, isApprove} = useHoldMenuSubmit({
moneyRequestReport,
chatReport,
requestType,
paymentType,
methodID,
onClose: () => setIsVisible(false),
onConfirm,
transactions,
});

return (
<DecisionModal
title={translate(isApprove ? 'iou.confirmApprove' : 'iou.confirmPay')}
onClose={() => setIsVisible(false)}
isVisible={isVisible}
prompt={
hasNonHeldExpenses
? translate(isApprove ? 'iou.confirmApprovalAmount' : 'iou.confirmPayAmount')
: translate(isApprove ? 'iou.confirmApprovalAllHoldAmount' : 'iou.confirmPayAllHoldAmount', {count: transactionCount})
}
firstOptionText={hasNonHeldExpenses ? `${translate(isApprove ? 'iou.approveOnly' : 'iou.payOnly')} ${nonHeldAmount}` : undefined}
secondOptionText={`${translate(isApprove ? 'iou.approve' : 'iou.pay')} ${fullAmount}`}
onFirstOptionSubmit={() => onSubmit(false)}
onSecondOptionSubmit={() => onSubmit(true)}
isSmallScreenWidth={isSmallScreenWidth}
onModalHide={() => {
if (isVisible) {
return;
}
closeModal({action: 'CLOSE'});
}}
/>
);
}

export default HoldMenuModalWrapper;
export type {ActionHandledType, HoldMenuModalWrapperProps};
Comment thread
TMisiukiewicz marked this conversation as resolved.
189 changes: 56 additions & 133 deletions src/components/MoneyReportHeader.tsx

Large diffs are not rendered by default.

69 changes: 48 additions & 21 deletions src/components/MoneyReportHeaderEducationalModals.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import {shouldFailAllRequestsSelector} from '@selectors/Network';
import React from 'react';
import type {OnyxEntry} from 'react-native-onyx';
import React, {useImperativeHandle, useState} from 'react';
import type {Ref} from 'react';
import type {ValueOf} from 'type-fest';
import useNetwork from '@hooks/useNetwork';
import useOnyx from '@hooks/useOnyx';
import usePaginatedReportActions from '@hooks/usePaginatedReportActions';
import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport';
import {setNameValuePair} from '@libs/actions/User';
import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
import {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils';
import Navigation from '@libs/Navigation/Navigation';
import {getFilteredReportActionsForReportView, getOneTransactionThreadReportID, getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils';
import {changeMoneyRequestHoldStatus, rejectMoneyRequestReason} from '@libs/ReportUtils';
import {dismissRejectUseExplanation} from '@userActions/IOU';
import CONST from '@src/CONST';
Expand All @@ -19,30 +24,52 @@ type RejectModalAction = ValueOf<
typeof CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.HOLD | typeof CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.REJECT | typeof CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.REJECT_BULK
>;

type MoneyReportHeaderEducationalModalsHandle = {
openHoldEducational: () => void;
openRejectModal: (action: RejectModalAction) => void;
};

type MoneyReportHeaderEducationalModalsProps = {
requestParentReportAction: OnyxTypes.ReportAction | null | undefined;
transaction: OnyxEntry<OnyxTypes.Transaction>;
reportID: string | undefined;
isHoldEducationalVisible: boolean;
rejectModalAction: RejectModalAction | null;
onHoldEducationalDismissed: () => void;
onRejectModalDismissed: () => void;
ref: Ref<MoneyReportHeaderEducationalModalsHandle>;
};

function MoneyReportHeaderEducationalModals({
requestParentReportAction,
transaction,
reportID,
isHoldEducationalVisible,
rejectModalAction,
onHoldEducationalDismissed,
onRejectModalDismissed,
}: MoneyReportHeaderEducationalModalsProps) {
function MoneyReportHeaderEducationalModals({reportID, ref}: MoneyReportHeaderEducationalModalsProps) {
const [isHoldEducationalModalVisible, setIsHoldEducationalModalVisible] = useState(false);
const [rejectModalAction, setRejectModalAction] = useState<RejectModalAction | null>(null);

const {isOffline} = useNetwork();
const [shouldFailAllRequests] = useOnyx(ONYXKEYS.NETWORK, {selector: shouldFailAllRequestsSelector});

// Fetch report data needed for educational modals
const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`);
const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${moneyRequestReport?.chatReportID}`);
const {reportActions: unfilteredReportActions} = usePaginatedReportActions(moneyRequestReport?.reportID);
const reportActions = getFilteredReportActionsForReportView(unfilteredReportActions);
const {transactions: reportTransactions} = useTransactionsAndViolationsForReport(moneyRequestReport?.reportID);

// Derive transaction thread and parent action
const nonDeletedTransactions = getAllNonDeletedTransactions(reportTransactions, reportActions, isOffline, true);
const visibleTransactionsForThreadID = nonDeletedTransactions?.filter((t) => isOffline || t.pendingAction !== 'delete');
const reportTransactionIDs = visibleTransactionsForThreadID?.map((t) => t.transactionID);
const transactionThreadReportID = getOneTransactionThreadReportID(moneyRequestReport, chatReport, reportActions ?? [], isOffline, reportTransactionIDs);
const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`);

const requestParentReportAction =
reportActions && transactionThreadReport?.parentReportActionID
? reportActions.find((action): action is OnyxTypes.ReportAction<typeof CONST.REPORT.ACTIONS.TYPE.IOU> => action.reportActionID === transactionThreadReport.parentReportActionID)
: null;

const iouTransactionID = isMoneyRequestAction(requestParentReportAction) ? getOriginalMessage(requestParentReportAction)?.IOUTransactionID : undefined;
const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(iouTransactionID)}`);

useImperativeHandle(ref, () => ({
openHoldEducational: () => setIsHoldEducationalModalVisible(true),
openRejectModal: (action: RejectModalAction) => setRejectModalAction(action),
}));

const dismissModalAndUpdateUseHold = () => {
onHoldEducationalDismissed();
setIsHoldEducationalModalVisible(false);
setNameValuePair(ONYXKEYS.NVP_DISMISSED_HOLD_USE_EXPLANATION, true, false, !shouldFailAllRequests);
if (requestParentReportAction) {
changeMoneyRequestHoldStatus(requestParentReportAction, transaction, isOffline);
Expand Down Expand Up @@ -70,7 +97,7 @@ function MoneyReportHeaderEducationalModals({
rejectMoneyRequestReason(requestParentReportAction);
}
}
onRejectModalDismissed();
setRejectModalAction(null);
};

return (
Expand All @@ -81,7 +108,7 @@ function MoneyReportHeaderEducationalModals({
onConfirm={dismissRejectModalBasedOnAction}
/>
)}
{!!isHoldEducationalVisible && (
{!!isHoldEducationalModalVisible && (
<HoldSubmitterEducationalModal
onClose={dismissModalAndUpdateUseHold}
onConfirm={dismissModalAndUpdateUseHold}
Expand All @@ -91,5 +118,5 @@ function MoneyReportHeaderEducationalModals({
);
}

export type {RejectModalAction};
export type {RejectModalAction, MoneyReportHeaderEducationalModalsHandle};
export default MoneyReportHeaderEducationalModals;
127 changes: 127 additions & 0 deletions src/components/MoneyReportHeaderModals.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import React, {useRef, useState} from 'react';
import type {ReactNode} from 'react';
import {InteractionManager} from 'react-native';
import useDecisionModal from '@hooks/useDecisionModal';
import useHoldMenuModal from '@hooks/useHoldMenuModal';
import useLocalize from '@hooks/useLocalize';
import useOnyx from '@hooks/useOnyx';
import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport';
import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
import getPlatform from '@libs/getPlatform';
import {getNonHeldAndFullAmount, hasOnlyHeldExpenses as hasOnlyHeldExpensesReportUtils, hasOnlyNonReimbursableTransactions} from '@libs/ReportUtils';
import {canIOUBePaid as canIOUBePaidAction} from '@userActions/IOU';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import MoneyReportHeaderEducationalModals from './MoneyReportHeaderEducationalModals';
import type {MoneyReportHeaderEducationalModalsHandle, RejectModalAction} from './MoneyReportHeaderEducationalModals';
import MoneyReportHeaderModalsContext from './MoneyReportHeaderModalsContext';
import type {HoldMenuParams} from './MoneyReportHeaderModalsContext';
import ReportPDFDownloadModal from './ReportPDFDownloadModal';

type MoneyReportHeaderModalsProps = {
reportID: string | undefined;
children: ReactNode;
};

function MoneyReportHeaderModals({reportID, children}: MoneyReportHeaderModalsProps) {
// PDF modal state
const [isPDFModalVisible, setIsPDFModalVisible] = useState(false);

// Educational modals ref
const educationalModalsRef = useRef<MoneyReportHeaderEducationalModalsHandle>(null);

// Fetch data from IDs
const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`);
const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(moneyRequestReport?.policyID)}`);
const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${moneyRequestReport?.chatReportID}`);
const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST);

const {transactions: reportTransactions} = useTransactionsAndViolationsForReport(moneyRequestReport?.reportID);
const transactions = Object.values(reportTransactions);

// Derive data for hold menu
const canIOUBePaid = canIOUBePaidAction(moneyRequestReport, chatReport, policy, bankAccountList);
const onlyShowPayElsewhere = !canIOUBePaid && canIOUBePaidAction(moneyRequestReport, chatReport, policy, bankAccountList, undefined, true);
const reportHasOnlyNonReimbursableTransactions = hasOnlyNonReimbursableTransactions(moneyRequestReport?.reportID, transactions);
const shouldShowPayButton = canIOUBePaid || onlyShowPayElsewhere || reportHasOnlyNonReimbursableTransactions;
const {nonHeldAmount, fullAmount, hasValidNonHeldAmount} = getNonHeldAndFullAmount(moneyRequestReport, shouldShowPayButton);
const hasOnlyHeldExpenses = hasOnlyHeldExpensesReportUtils(moneyRequestReport?.reportID);
const transactionIDs = transactions.map((t) => t.transactionID);

// Imperative modals
const {showHoldMenu} = useHoldMenuModal();
const {showDecisionModal} = useDecisionModal();
const {translate} = useLocalize();

const showOfflineModal = () => {
showDecisionModal({
title: translate('common.youAppearToBeOffline'),
prompt: translate('common.offlinePrompt'),
secondOptionText: translate('common.buttonConfirm'),
});
};

const showDownloadErrorModal = () => {
showDecisionModal({
title: translate('common.downloadFailedTitle'),
prompt: translate('common.downloadFailedDescription'),
secondOptionText: translate('common.buttonConfirm'),
});
};

const openHoldMenu = ({requestType, paymentType, methodID, onConfirm}: HoldMenuParams): Promise<void> => {
const open = () =>
showHoldMenu({
reportID: moneyRequestReport?.reportID,
chatReportID: chatReport?.reportID,
requestType,
paymentType,
methodID,
nonHeldAmount: !hasOnlyHeldExpenses && hasValidNonHeldAmount ? nonHeldAmount : undefined,
fullAmount,
hasNonHeldExpenses: !hasOnlyHeldExpenses,
transactionCount: transactionIDs.length,
onConfirm,
});

// On iOS, delay opening the hold menu until active touch interactions finish to prevent visual glitches
if (getPlatform() === CONST.PLATFORM.IOS) {
return new Promise<void>((resolve) => {
// eslint-disable-next-line @typescript-eslint/no-deprecated -- InteractionManager is widely used across the codebase (120+ files) and kept alive via a dedicated RN patch
InteractionManager.runAfterInteractions(() => {
open().then(() => resolve());
});
});
}

return open().then(() => {});
};

const contextValue = {
openHoldMenu,
openPDFDownload: () => setIsPDFModalVisible(true),
openHoldEducational: () => educationalModalsRef.current?.openHoldEducational(),
openRejectModal: (action: RejectModalAction) => educationalModalsRef.current?.openRejectModal(action),
showOfflineModal,
showDownloadErrorModal,
};

return (
<MoneyReportHeaderModalsContext.Provider value={contextValue}>
{children}

<MoneyReportHeaderEducationalModals
ref={educationalModalsRef}
reportID={moneyRequestReport?.reportID}
/>

<ReportPDFDownloadModal
reportID={moneyRequestReport?.reportID}
isVisible={isPDFModalVisible}
onClose={() => setIsPDFModalVisible(false)}
/>
</MoneyReportHeaderModalsContext.Provider>
);
}

export default MoneyReportHeaderModals;
39 changes: 39 additions & 0 deletions src/components/MoneyReportHeaderModalsContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import {createContext, useContext} from 'react';
import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage';
import type {ActionHandledType} from './Modal/Global/HoldMenuModalWrapper';
import type {RejectModalAction} from './MoneyReportHeaderEducationalModals';

type HoldMenuParams = {
requestType: ActionHandledType;
paymentType?: PaymentMethodType;
methodID?: number;
onConfirm?: (full: boolean) => void;
};

type MoneyReportHeaderModalsContextValue = {
openHoldMenu: (params: HoldMenuParams) => Promise<void>;
openPDFDownload: () => void;
openHoldEducational: () => void;
openRejectModal: (action: RejectModalAction) => void;
showOfflineModal: () => void;
showDownloadErrorModal: () => void;
};

const defaultValue: MoneyReportHeaderModalsContextValue = {
openHoldMenu: () => Promise.resolve(),
openPDFDownload: () => {},
openHoldEducational: () => {},
openRejectModal: () => {},
showOfflineModal: () => {},
showDownloadErrorModal: () => {},
};

const MoneyReportHeaderModalsContext = createContext<MoneyReportHeaderModalsContextValue>(defaultValue);

function useMoneyReportHeaderModals() {
return useContext(MoneyReportHeaderModalsContext);
}

export default MoneyReportHeaderModalsContext;
export {useMoneyReportHeaderModals};
export type {HoldMenuParams};
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,14 @@ import useOnyx from '@hooks/useOnyx';
import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
import {getNextApproverAccountID, isReportOwner} from '@libs/ReportUtils';
import ONYXKEYS from '@src/ONYXKEYS';
import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage';
import useConfirmApproval from './useConfirmApproval';

type ApprovePrimaryActionProps = {
reportID: string | undefined;
startApprovedAnimation: () => void;
onHoldMenuOpen: (requestType: string, paymentType?: PaymentMethodType) => void;
};

function ApprovePrimaryAction({reportID, startApprovedAnimation, onHoldMenuOpen}: ApprovePrimaryActionProps) {
function ApprovePrimaryAction({reportID, startApprovedAnimation}: ApprovePrimaryActionProps) {
const {translate} = useLocalize();

const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`);
Expand All @@ -25,7 +23,7 @@ function ApprovePrimaryAction({reportID, startApprovedAnimation, onHoldMenuOpen}
isReportOwner(moneyRequestReport) && (nextApproverAccountID === moneyRequestReport?.ownerAccountID || moneyRequestReport?.managerID === moneyRequestReport?.ownerAccountID);
const isBlockSubmitDueToPreventSelfApproval = isSubmitterSameAsNextApprover && policy?.preventSelfApproval;

const confirmApproval = useConfirmApproval(reportID, startApprovedAnimation, onHoldMenuOpen);
const confirmApproval = useConfirmApproval(reportID, startApprovedAnimation);

return (
<Button
Expand Down
Loading
Loading