From cf40f3a1d249e0a7725b6df8d52696e16c2779be Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Wed, 24 Sep 2025 11:08:05 +0200 Subject: [PATCH 01/14] Initial implementation of ModalProvider and ConfirmModalWrapper --- src/App.tsx | 2 + src/components/ConfirmModal.tsx | 1 + .../Modal/Global/ConfirmModalWrapper.tsx | 51 +++++++++ src/components/Modal/Global/ModalContext.tsx | 102 ++++++++++++++++++ src/components/Modal/Global/index.ts | 7 ++ src/components/Modal/Global/useModalHook.ts | 23 ++++ 6 files changed, 186 insertions(+) create mode 100644 src/components/Modal/Global/ConfirmModalWrapper.tsx create mode 100644 src/components/Modal/Global/ModalContext.tsx create mode 100644 src/components/Modal/Global/index.ts create mode 100644 src/components/Modal/Global/useModalHook.ts diff --git a/src/App.tsx b/src/App.tsx index 683f6772a9bb..3f9df645e5cb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -51,6 +51,7 @@ import './setup/backgroundTask'; import './setup/fraudProtection'; import './setup/hybridApp'; import {SplashScreenStateContextProvider} from './SplashScreenStateContext'; +import {ModalProvider} from '@components/Modal/Global'; LogBox.ignoreLogs([ // Basically it means that if the app goes in the background and back to foreground on Android, @@ -120,6 +121,7 @@ function App() { InputBlurContextProvider, FullScreenBlockingViewContextProvider, FullScreenLoaderContextProvider, + ModalProvider, SidePanelContextProvider, ]} > diff --git a/src/components/ConfirmModal.tsx b/src/components/ConfirmModal.tsx index e1e43be55752..ea4ebd3b41b5 100755 --- a/src/components/ConfirmModal.tsx +++ b/src/components/ConfirmModal.tsx @@ -219,3 +219,4 @@ function ConfirmModal({ ConfirmModal.displayName = 'ConfirmModal'; export default ConfirmModal; +export type {ConfirmModalProps}; \ No newline at end of file diff --git a/src/components/Modal/Global/ConfirmModalWrapper.tsx b/src/components/Modal/Global/ConfirmModalWrapper.tsx new file mode 100644 index 000000000000..4bd20a0aa056 --- /dev/null +++ b/src/components/Modal/Global/ConfirmModalWrapper.tsx @@ -0,0 +1,51 @@ +import React, {useCallback, useMemo} from 'react'; +import type {ConfirmModalProps} from '@components/ConfirmModal'; +import ConfirmModal from '@components/ConfirmModal'; +import useActiveElementRole from '@hooks/useActiveElementRole'; +import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; +import CONST from '@src/CONST'; +import type {ModalProps} from './ModalContext'; + +type ConfirmModalWrapperProps = ModalProps & Omit; + +// This wrapper bridges the ConfirmModal API with the global modal system, providing handlers for the onConfirm and onCancel callbacks to ConfirmModal. +// TODOS after migrating all ConfirmModal instances to use showConfirmModal: +// - handle closeModal inside ConfirmModal +// - remove ConfirmModalWrapper + +function ConfirmModalWrapper({closeModal, ...props}: ConfirmModalWrapperProps) { + const activeElementRole = useActiveElementRole(); + + const handleConfirm = useCallback(() => { + closeModal({action: 'CONFIRM'}); + }, [closeModal]); + + const handleCancel = useCallback(() => { + closeModal({action: 'CLOSE'}); + }, [closeModal]); + + const shortcutConfig = useMemo( + () => ({ + isActive: activeElementRole !== CONST.ROLE.BUTTON, + shouldPreventDefault: false, + shouldBubble: false, + }), + [activeElementRole], + ); + + useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ENTER, handleConfirm, shortcutConfig); + + return ( + + ); +} + +ConfirmModalWrapper.displayName = 'ConfirmModalWrapper'; + +export default ConfirmModalWrapper; \ No newline at end of file diff --git a/src/components/Modal/Global/ModalContext.tsx b/src/components/Modal/Global/ModalContext.tsx new file mode 100644 index 000000000000..4793ff96ae78 --- /dev/null +++ b/src/components/Modal/Global/ModalContext.tsx @@ -0,0 +1,102 @@ +import noop from 'lodash/noop'; +import React, {useCallback, useContext, useMemo, useState} from 'react'; +import Log from '@libs/Log'; +import CONST from '@src/CONST'; + +type ModalAction = 'CONFIRM' | 'CLOSE'; + +type ModalStateChangePayload = {action: A}; + +type ModalProps = { + closeModal: (param?: ModalStateChangePayload) => void; +}; + +type ModalContextType = { + showModal

(options: {component: React.FunctionComponent

; props?: Omit; id?: string; isCloseable?: boolean}): Promise; + closeModal(data?: ModalStateChangePayload): void; +}; + +const ModalContext = React.createContext({ + showModal: () => Promise.resolve({action: 'CLOSE'}), + closeModal: noop, +}); + +const useModal = () => useContext(ModalContext); + +let modalID = 1; + +type ModalInfo = { + id: string; + component: React.FunctionComponent; + props?: Record; + promiseWithResolvers: ReturnType>; + isCloseable: boolean; +}; + +function PromiseModalProvider({children}: {children: React.ReactNode}) { + const [modalStack, setModalStack] = useState<{modals: ModalInfo[]}>({modals: []}); + + const showModal = useCallback(({component, props, id, isCloseable = true}) => { + // This is a promise that will resolve when the modal is closed + let closeModalPromise: Promise | null = null; + + setModalStack((prevState) => { + // Check current state for existing modal + const existingModal = id ? prevState.modals.find((modal: ModalInfo) => modal.id === id) : undefined; + if (existingModal) { + // There is already a modal with this ID. Return the existing promise and don't modify state. + closeModalPromise = existingModal.promiseWithResolvers.promise; + return prevState; // No state change needed + } + + // Create a new promise with resolvers to be resolved when the modal is closed + const promiseWithResolvers = Promise.withResolvers(); + closeModalPromise = promiseWithResolvers.promise; + + return { + ...prevState, + modals: [...prevState.modals, {component: component as React.FunctionComponent, props, promiseWithResolvers, isCloseable, id: id ?? String(modalID++)}], + }; + }); + + // At this point, closeModalPromise should always be assigned + if (!closeModalPromise) { + Log.alert(`${CONST.ERROR.ENSURE_BUG_BOT} Failed to create modal promise. This should never happen.`); + throw new Error('Failed to create modal promise'); + } + + return closeModalPromise; + }, []); + + const closeModal = useCallback((data = {action: 'CLOSE'}) => { + setModalStack((prevState) => { + const lastModal = prevState.modals.at(-1); + lastModal?.promiseWithResolvers.resolve(data); + return { + ...prevState, + modals: prevState.modals.slice(0, -1), + }; + }); + }, []); + + const contextValue = useMemo(() => ({showModal, closeModal}), [closeModal, showModal]); + const modalToRender = modalStack.modals.length > 0 ? modalStack.modals.at(modalStack.modals.length - 1) : null; + const ModalComponent = modalToRender?.component; + + return ( + + {children} + {!!ModalComponent && ( + + )} + + ); +} + +export type {ModalProps}; +export {PromiseModalProvider, useModal}; \ No newline at end of file diff --git a/src/components/Modal/Global/index.ts b/src/components/Modal/Global/index.ts new file mode 100644 index 000000000000..4992acb01065 --- /dev/null +++ b/src/components/Modal/Global/index.ts @@ -0,0 +1,7 @@ +export {PromiseModalProvider, useModal} from './ModalContext'; +export {default as useModalHook} from './useModalHook'; +export {default as ConfirmModalWrapper} from './ConfirmModalWrapper'; +export type {ModalProps} from './ModalContext'; + +export {PromiseModalProvider as ModalProvider} from './ModalContext'; +export {default as usePromiseModal} from './useModalHook'; \ No newline at end of file diff --git a/src/components/Modal/Global/useModalHook.ts b/src/components/Modal/Global/useModalHook.ts new file mode 100644 index 000000000000..e76dcedacaa0 --- /dev/null +++ b/src/components/Modal/Global/useModalHook.ts @@ -0,0 +1,23 @@ +import ConfirmModalWrapper from './ConfirmModalWrapper'; +import {useModal} from './ModalContext'; +import type {ModalProps} from './ModalContext'; + +type ConfirmModalOptions = Omit, keyof ModalProps>; + +const useConfirmModal = () => { + const context = useModal(); + + const showConfirmModal = (options: ConfirmModalOptions) => { + return context.showModal({ + component: ConfirmModalWrapper, + props: options, + }); + }; + + return { + ...context, + showConfirmModal, + }; +}; + +export default useConfirmModal; \ No newline at end of file From 9965b84317390b11c069a7582f47a57dcb714782 Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Wed, 24 Sep 2025 15:27:37 +0200 Subject: [PATCH 02/14] Reaplication of useModalHook inside of MoneyReportHeader.tsx --- src/App.tsx | 2 +- src/components/ConfirmModal.tsx | 2 +- .../Modal/Global/ConfirmModalWrapper.tsx | 2 +- src/components/Modal/Global/ModalContext.tsx | 2 +- src/components/Modal/Global/index.ts | 2 +- src/components/Modal/Global/useModalHook.ts | 2 +- src/components/MoneyReportHeader.tsx | 375 ++++++++++-------- 7 files changed, 205 insertions(+), 182 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 3f9df645e5cb..5c5894bae2dd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ import {LogBox, View} from 'react-native'; import {GestureHandlerRootView} from 'react-native-gesture-handler'; import {PickerStateProvider} from 'react-native-picker-select'; import {SafeAreaProvider} from 'react-native-safe-area-context'; +import {ModalProvider} from './components/Modal/Global'; import '../wdyr'; import {ActionSheetAwareScrollViewProvider} from './components/ActionSheetAwareScrollView'; import ActiveElementRoleProvider from './components/ActiveElementRoleProvider'; @@ -51,7 +52,6 @@ import './setup/backgroundTask'; import './setup/fraudProtection'; import './setup/hybridApp'; import {SplashScreenStateContextProvider} from './SplashScreenStateContext'; -import {ModalProvider} from '@components/Modal/Global'; LogBox.ignoreLogs([ // Basically it means that if the app goes in the background and back to foreground on Android, diff --git a/src/components/ConfirmModal.tsx b/src/components/ConfirmModal.tsx index ea4ebd3b41b5..ca379190348c 100755 --- a/src/components/ConfirmModal.tsx +++ b/src/components/ConfirmModal.tsx @@ -219,4 +219,4 @@ function ConfirmModal({ ConfirmModal.displayName = 'ConfirmModal'; export default ConfirmModal; -export type {ConfirmModalProps}; \ No newline at end of file +export type {ConfirmModalProps}; diff --git a/src/components/Modal/Global/ConfirmModalWrapper.tsx b/src/components/Modal/Global/ConfirmModalWrapper.tsx index 4bd20a0aa056..257c5dab696a 100644 --- a/src/components/Modal/Global/ConfirmModalWrapper.tsx +++ b/src/components/Modal/Global/ConfirmModalWrapper.tsx @@ -48,4 +48,4 @@ function ConfirmModalWrapper({closeModal, ...props}: ConfirmModalWrapperProps) { ConfirmModalWrapper.displayName = 'ConfirmModalWrapper'; -export default ConfirmModalWrapper; \ No newline at end of file +export default ConfirmModalWrapper; diff --git a/src/components/Modal/Global/ModalContext.tsx b/src/components/Modal/Global/ModalContext.tsx index 4793ff96ae78..93f4b63d0c11 100644 --- a/src/components/Modal/Global/ModalContext.tsx +++ b/src/components/Modal/Global/ModalContext.tsx @@ -99,4 +99,4 @@ function PromiseModalProvider({children}: {children: React.ReactNode}) { } export type {ModalProps}; -export {PromiseModalProvider, useModal}; \ No newline at end of file +export {PromiseModalProvider, useModal}; diff --git a/src/components/Modal/Global/index.ts b/src/components/Modal/Global/index.ts index 4992acb01065..f2c3ac87c320 100644 --- a/src/components/Modal/Global/index.ts +++ b/src/components/Modal/Global/index.ts @@ -4,4 +4,4 @@ export {default as ConfirmModalWrapper} from './ConfirmModalWrapper'; export type {ModalProps} from './ModalContext'; export {PromiseModalProvider as ModalProvider} from './ModalContext'; -export {default as usePromiseModal} from './useModalHook'; \ No newline at end of file +export {default as usePromiseModal} from './useModalHook'; diff --git a/src/components/Modal/Global/useModalHook.ts b/src/components/Modal/Global/useModalHook.ts index e76dcedacaa0..b6d5b3737d28 100644 --- a/src/components/Modal/Global/useModalHook.ts +++ b/src/components/Modal/Global/useModalHook.ts @@ -20,4 +20,4 @@ const useConfirmModal = () => { }; }; -export default useConfirmModal; \ No newline at end of file +export default useConfirmModal; diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 6826b4dc6f03..802e82cfbe99 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -4,6 +4,7 @@ import React, {useCallback, useContext, useEffect, useMemo, useState} from 'reac import {InteractionManager, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; +import useConfirmModal from '@components/Modal/Global/useModalHook'; import useDuplicateTransactionsAndViolations from '@hooks/useDuplicateTransactionsAndViolations'; import useLoadingBarVisibility from '@hooks/useLoadingBarVisibility'; import useLocalize from '@hooks/useLocalize'; @@ -41,8 +42,8 @@ import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import type {ReportsSplitNavigatorParamList, SearchFullscreenNavigatorParamList, SearchReportParamList} from '@libs/Navigation/types'; import {buildOptimisticNextStepForPreventSelfApprovalsEnabled} from '@libs/NextStepUtils'; -import {isSecondaryActionAPaymentOption, selectPaymentType} from '@libs/PaymentUtils'; import type {KYCFlowEvent, TriggerKYCFlow} from '@libs/PaymentUtils'; +import {isSecondaryActionAPaymentOption, selectPaymentType} from '@libs/PaymentUtils'; import {getConnectedIntegration, getValidConnectedIntegration} from '@libs/PolicyUtils'; import {getIOUActionForReportID, getOriginalMessage, getReportAction, isMoneyRequestAction} from '@libs/ReportActionsUtils'; import {getAllExpensesToHoldIfApplicable, getReportPrimaryAction, isMarkAsResolvedAction} from '@libs/ReportPrimaryActionUtils'; @@ -115,7 +116,6 @@ import BrokenConnectionDescription from './BrokenConnectionDescription'; import Button from './Button'; import ButtonWithDropdownMenu from './ButtonWithDropdownMenu'; import type {DropdownOption} from './ButtonWithDropdownMenu/types'; -import ConfirmModal from './ConfirmModal'; import DecisionModal from './DecisionModal'; import {DelegateNoAccessContext} from './DelegateNoAccessModalProvider'; import Header from './Header'; @@ -235,16 +235,10 @@ function MoneyReportHeader({ const transactionViolations = useTransactionViolations(transaction?.transactionID); const [downloadErrorModalVisible, setDownloadErrorModalVisible] = useState(false); - const [isCancelPaymentModalVisible, setIsCancelPaymentModalVisible] = useState(false); - const [isDeleteExpenseModalVisible, setIsDeleteExpenseModalVisible] = useState(false); - const [isDeleteReportModalVisible, setIsDeleteReportModalVisible] = useState(false); - const [isUnapproveModalVisible, setIsUnapproveModalVisible] = useState(false); - const [isReopenWarningModalVisible, setIsReopenWarningModalVisible] = useState(false); const [isPDFModalVisible, setIsPDFModalVisible] = useState(false); - const [isExportWithTemplateModalVisible, setIsExportWithTemplateModalVisible] = useState(false); const [exportModalStatus, setExportModalStatus] = useState(null); - + const {showConfirmModal} = useConfirmModal(); const {isPaidAnimationRunning, isApprovedAnimationRunning, isSubmittingAnimationRunning, startAnimation, stopAnimation, startApprovedAnimation, startSubmittingAnimation} = usePaymentAnimations(); const styles = useThemeStyles(); @@ -303,13 +297,27 @@ function MoneyReportHeader({ const {selectedTransactionIDs, removeTransaction, clearSelectedTransactions, currentSearchQueryJSON, currentSearchKey} = useSearchContext(); + const showExportProgressModal = useCallback(() => { + return showConfirmModal({ + title: translate('export.exportInProgress'), + prompt: translate('export.conciergeWillSend'), + confirmText: translate('common.buttonConfirm'), + shouldShowCancelButton: false, + }); + }, [showConfirmModal, translate]); + const beginExportWithTemplate = useCallback( (templateName: string, templateType: string, transactionIDList: string[], policyID?: string) => { if (!moneyRequestReport) { return; } - setIsExportWithTemplateModalVisible(true); + showExportProgressModal().then((result) => { + if (result.action !== 'CONFIRM') { + return; + } + clearSelectedTransactions(undefined, true); + }); queueExportSearchWithTemplate({ templateName, templateType, @@ -319,16 +327,11 @@ function MoneyReportHeader({ policyID, }); }, - [moneyRequestReport], + [moneyRequestReport, showExportProgressModal, clearSelectedTransactions], ); const [offlineModalVisible, setOfflineModalVisible] = useState(false); - const { - options: selectedTransactionsOptions, - handleDeleteTransactions, - isDeleteModalVisible: hookDeleteModalVisible, - hideDeleteModal, - } = useSelectedTransactionsActions({ + const {options: originalSelectedTransactionsOptions, handleDeleteTransactions} = useSelectedTransactionsActions({ report: moneyRequestReport, reportActions, allTransactionsLength: transactions.length, @@ -339,8 +342,6 @@ function MoneyReportHeader({ beginExportWithTemplate: (templateName, templateType, transactionIDList, policyID) => beginExportWithTemplate(templateName, templateType, transactionIDList, policyID), }); - const shouldShowSelectedTransactionsButton = !!selectedTransactionsOptions.length && !transactionThreadReportID; - const canIOUBePaid = useMemo(() => getCanIOUBePaid(), [getCanIOUBePaid]); const onlyShowPayElsewhere = useMemo(() => !canIOUBePaid && getCanIOUBePaid(true), [canIOUBePaid, getCanIOUBePaid]); @@ -860,6 +861,24 @@ function MoneyReportHeader({ return getSecondaryExportReportActions(moneyRequestReport, policy, exportTemplates); }, [moneyRequestReport, policy, exportTemplates]); + const connectedIntegrationName = connectedIntegration ? translate('workspace.accounting.connectionName', {connectionName: connectedIntegration}) : ''; + const unapproveWarningText = useMemo( + () => ( + + {translate('iou.headsUp')}{' '} + {translate('iou.unapproveWithIntegrationWarning', {accountingIntegration: connectedIntegrationName})} + + ), + [connectedIntegrationName, styles.noWrap, styles.textStrong, translate], + ); + + const reopenExportedReportWarningText = ( + + {translate('iou.headsUp')} + {translate('iou.reopenExportedReportConfirmation', {connectionName: integrationNameFromExportMessage ?? ''})} + + ); + const secondaryActionsImplementation: Record< ValueOf, DropdownOption> & Pick @@ -919,7 +938,18 @@ function MoneyReportHeader({ } if (isExported) { - setIsUnapproveModalVisible(true); + showConfirmModal({ + title: translate('iou.unapproveReport'), + prompt: unapproveWarningText, + confirmText: translate('iou.unapproveReport'), + cancelText: translate('common.cancel'), + danger: true, + }).then((result) => { + if (result.action !== 'CONFIRM') { + return; + } + unapproveExpenseReport(moneyRequestReport); + }); return; } @@ -931,7 +961,18 @@ function MoneyReportHeader({ icon: Expensicons.Clear, value: CONST.REPORT.SECONDARY_ACTIONS.CANCEL_PAYMENT, onSelected: () => { - setIsCancelPaymentModalVisible(true); + showConfirmModal({ + title: translate('iou.cancelPayment'), + prompt: translate('iou.cancelPaymentConfirmation'), + confirmText: translate('iou.cancelPayment'), + cancelText: translate('common.dismiss'), + danger: true, + }).then((result) => { + if (result.action !== 'CONFIRM' || !chatReport) { + return; + } + cancelPayment(moneyRequestReport, chatReport); + }); }, }, [CONST.REPORT.SECONDARY_ACTIONS.HOLD]: { @@ -1018,11 +1059,72 @@ function MoneyReportHeader({ icon: Expensicons.Trashcan, value: CONST.REPORT.SECONDARY_ACTIONS.DELETE, onSelected: () => { - if (Object.keys(transactions).length === 1) { - setIsDeleteExpenseModalVisible(true); - } else { - setIsDeleteReportModalVisible(true); + const transactionCount = Object.keys(transactions).length; + + if (transactionCount === 1) { + showConfirmModal({ + title: translate('iou.deleteExpense', {count: 1}), + prompt: translate('iou.deleteConfirmation', {count: 1}), + confirmText: translate('common.delete'), + cancelText: translate('common.cancel'), + danger: true, + }).then((result) => { + if (result.action !== 'CONFIRM') { + return; + } + let goBackRoute: Route | undefined; + if (transactionThreadReportID) { + if (!requestParentReportAction || !transaction?.transactionID) { + throw new Error('Missing data!'); + } + InteractionManager.runAfterInteractions(() => { + deleteMoneyRequest(transaction?.transactionID, requestParentReportAction, duplicateTransactions, duplicateTransactionViolations); + removeTransaction(transaction.transactionID); + }); + goBackRoute = getNavigationUrlOnMoneyRequestDelete(transaction.transactionID, requestParentReportAction, false); + } + + if (goBackRoute) { + Navigation.setNavigationActionToMicrotaskQueue(() => navigateOnDeleteExpense(goBackRoute)); + } + }); + return; + } + + if (transactionCount === 0) { + showConfirmModal({ + title: translate('iou.deleteReport'), + prompt: translate('iou.deleteReportConfirmation'), + confirmText: translate('common.delete'), + cancelText: translate('common.cancel'), + danger: true, + }).then((result) => { + if (result.action !== 'CONFIRM') { + return; + } + const reportID = moneyRequestReport?.reportID; + if (reportID) { + deleteAppReport(reportID); + } + }); + return; } + + showConfirmModal({ + title: translate('iou.deleteExpense', {count: transactionCount}), + prompt: translate('iou.deleteConfirmation', {count: transactionCount}), + confirmText: translate('common.delete'), + cancelText: translate('common.cancel'), + danger: true, + }).then((result) => { + if (result.action !== 'CONFIRM') { + return; + } + const reportID = moneyRequestReport?.reportID; + if (reportID) { + deleteAppReport(reportID); + } + }); }, }, [CONST.REPORT.SECONDARY_ACTIONS.RETRACT]: { @@ -1038,8 +1140,19 @@ function MoneyReportHeader({ icon: Expensicons.CircularArrowBackwards, value: CONST.REPORT.SECONDARY_ACTIONS.REOPEN, onSelected: () => { - if (isExported) { - setIsReopenWarningModalVisible(true); + if (!isExported) { + showConfirmModal({ + title: translate('iou.reopenReport'), + prompt: reopenExportedReportWarningText, + confirmText: translate('iou.reopenReport'), + cancelText: translate('common.cancel'), + danger: true, + }).then((result) => { + if (result.action !== 'CONFIRM') { + return; + } + reopenReport(moneyRequestReport); + }); return; } reopenReport(moneyRequestReport); @@ -1101,19 +1214,67 @@ function MoneyReportHeader({ const shouldShowBackButton = shouldDisplayBackButton || shouldUseNarrowLayout; - const connectedIntegrationName = connectedIntegration ? translate('workspace.accounting.connectionName', {connectionName: connectedIntegration}) : ''; - const unapproveWarningText = useMemo( - () => ( - - {translate('iou.headsUp')}{' '} - {translate('iou.unapproveWithIntegrationWarning', {accountingIntegration: connectedIntegrationName})} - - ), - [connectedIntegrationName, styles.noWrap, styles.textStrong, translate], - ); - const isMobileSelectionModeEnabled = useMobileSelectionMode(); + const showDeleteModal = useCallback(() => { + showConfirmModal({ + title: translate('iou.deleteExpense', {count: selectedTransactionIDs.length}), + prompt: translate('iou.deleteConfirmation', {count: selectedTransactionIDs.length}), + confirmText: translate('common.delete'), + cancelText: translate('common.cancel'), + danger: true, + }).then((result) => { + if (result.action !== 'CONFIRM') { + return; + } + if (transactions.filter((trans) => trans.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE).length === selectedTransactionIDs.length) { + Navigation.goBack(route.params?.backTo); + } + handleDeleteTransactions(); + }); + }, [showConfirmModal, translate, selectedTransactionIDs.length, transactions, handleDeleteTransactions, route.params?.backTo]); + + const showExportAgainModal = useCallback(() => { + if (!connectedIntegration) { + return; + } + showConfirmModal({ + title: translate('workspace.exportAgainModal.title'), + prompt: translate('workspace.exportAgainModal.description', { + connectionName: connectedIntegration ?? connectedIntegrationFallback, + reportName: moneyRequestReport?.reportName ?? '', + }), + confirmText: translate('workspace.exportAgainModal.confirmText'), + cancelText: translate('workspace.exportAgainModal.cancelText'), + }).then((result) => { + if (result.action !== 'CONFIRM') { + return; + } + confirmExport(); + }); + }, [showConfirmModal, translate, connectedIntegration, connectedIntegrationFallback, moneyRequestReport?.reportName, confirmExport]); + + useEffect(() => { + if (!exportModalStatus) { + return; + } + showExportAgainModal(); + }, [exportModalStatus, showExportAgainModal]); + + const selectedTransactionsOptions = useMemo(() => { + return originalSelectedTransactionsOptions.map((option) => { + if (option.text === translate('common.delete')) { + return { + ...option, + onSelected: showDeleteModal, + }; + } + return option; + }); + }, [originalSelectedTransactionsOptions, translate, showDeleteModal]); + + const shouldShowSelectedTransactionsButton = !!selectedTransactionsOptions.length && !transactionThreadReportID; + if (isMobileSelectionModeEnabled) { // If mobile selection mode is enabled but only one or no transactions remain, turn it off if (transactions.length <= 1) { @@ -1130,13 +1291,6 @@ function MoneyReportHeader({ /> ); } - - const reopenExportedReportWarningText = ( - - {translate('iou.headsUp')} - {translate('iou.reopenExportedReportConfirmation', {connectionName: integrationNameFromExportMessage ?? ''})} - - ); const onPaymentSelect = (event: KYCFlowEvent, iouPaymentType: PaymentMethodType, triggerKYCFlow: TriggerKYCFlow) => selectPaymentType(event, iouPaymentType, triggerKYCFlow, policy, confirmPayment, isUserValidated, confirmApproval, moneyRequestReport); @@ -1276,125 +1430,6 @@ function MoneyReportHeader({ isVisible={downloadErrorModalVisible} onClose={() => setDownloadErrorModalVisible(false)} /> - { - if (!chatReport) { - return; - } - cancelPayment(moneyRequestReport, chatReport); - setIsCancelPaymentModalVisible(false); - }} - onCancel={() => setIsCancelPaymentModalVisible(false)} - prompt={translate('iou.cancelPaymentConfirmation')} - confirmText={translate('iou.cancelPayment')} - cancelText={translate('common.dismiss')} - danger - shouldEnableNewFocusManagement - /> - { - let goBackRoute: Route | undefined; - setIsDeleteExpenseModalVisible(false); - if (transactionThreadReportID) { - if (!requestParentReportAction || !transaction?.transactionID) { - throw new Error('Missing data!'); - } - // it's deleting transaction but not the report which leads to bug (that is actually also on staging) - // Money request should be deleted when interactions are done, to not show the not found page before navigating to goBackRoute - InteractionManager.runAfterInteractions(() => { - deleteMoneyRequest(transaction?.transactionID, requestParentReportAction, duplicateTransactions, duplicateTransactionViolations); - removeTransaction(transaction.transactionID); - }); - goBackRoute = getNavigationUrlOnMoneyRequestDelete(transaction.transactionID, requestParentReportAction, false); - } - - if (goBackRoute) { - Navigation.setNavigationActionToMicrotaskQueue(() => navigateOnDeleteExpense(goBackRoute)); - } - }} - onCancel={() => setIsDeleteExpenseModalVisible(false)} - prompt={translate('iou.deleteConfirmation', {count: 1})} - confirmText={translate('common.delete')} - cancelText={translate('common.cancel')} - danger - shouldEnableNewFocusManagement - /> - { - if (transactions.filter((trans) => trans.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE).length === selectedTransactionIDs.length) { - Navigation.goBack(route.params?.backTo); - } - handleDeleteTransactions(); - }} - onCancel={hideDeleteModal} - prompt={translate('iou.deleteConfirmation', {count: selectedTransactionIDs.length})} - confirmText={translate('common.delete')} - cancelText={translate('common.cancel')} - danger - shouldEnableNewFocusManagement - /> - { - setIsDeleteReportModalVisible(false); - - Navigation.goBack(); - InteractionManager.runAfterInteractions(() => { - deleteAppReport(moneyRequestReport?.reportID); - }); - }} - onCancel={() => setIsDeleteReportModalVisible(false)} - prompt={translate('iou.deleteReportConfirmation')} - confirmText={translate('common.delete')} - cancelText={translate('common.cancel')} - danger - shouldEnableNewFocusManagement - /> - {!!connectedIntegration && ( - setExportModalStatus(null)} - prompt={translate('workspace.exportAgainModal.description', {connectionName: connectedIntegration, reportName: moneyRequestReport?.reportName ?? ''})} - confirmText={translate('workspace.exportAgainModal.confirmText')} - cancelText={translate('workspace.exportAgainModal.cancelText')} - isVisible={!!exportModalStatus} - /> - )} - { - setIsUnapproveModalVisible(false); - unapproveExpenseReport(moneyRequestReport); - }} - cancelText={translate('common.cancel')} - onCancel={() => setIsUnapproveModalVisible(false)} - prompt={unapproveWarningText} - /> - { - setIsReopenWarningModalVisible(false); - reopenReport(moneyRequestReport); - }} - cancelText={translate('common.cancel')} - onCancel={() => setIsReopenWarningModalVisible(false)} - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - prompt={reopenExportedReportWarningText} - /> - { - setIsExportWithTemplateModalVisible(false); - clearSelectedTransactions(undefined, true); - }} - onCancel={() => setIsExportWithTemplateModalVisible(false)} - isVisible={isExportWithTemplateModalVisible} - title={translate('export.exportInProgress')} - prompt={translate('export.conciergeWillSend')} - confirmText={translate('common.buttonConfirm')} - shouldShowCancelButton={false} - /> ); } From 6bc577cdca42f48446c567baa0acd4ab563ba10b Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Wed, 24 Sep 2025 16:15:45 +0200 Subject: [PATCH 03/14] Change modal title when deleting report --- src/components/MoneyReportHeader.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 802e82cfbe99..8b700918513f 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -1111,8 +1111,8 @@ function MoneyReportHeader({ } showConfirmModal({ - title: translate('iou.deleteExpense', {count: transactionCount}), - prompt: translate('iou.deleteConfirmation', {count: transactionCount}), + title: translate('iou.deleteReport'), + prompt: translate('iou.deleteReportConfirmation'), confirmText: translate('common.delete'), cancelText: translate('common.cancel'), danger: true, From 7d4d79a01d40cddfbbbaf1448c7d7fef9ef7e9f1 Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Thu, 25 Sep 2025 12:39:09 +0200 Subject: [PATCH 04/14] Fixes for deploy blockers --- src/components/MoneyReportHeader.tsx | 20 +------------------- 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 8b700918513f..71d75e4299b5 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -1091,25 +1091,6 @@ function MoneyReportHeader({ return; } - if (transactionCount === 0) { - showConfirmModal({ - title: translate('iou.deleteReport'), - prompt: translate('iou.deleteReportConfirmation'), - confirmText: translate('common.delete'), - cancelText: translate('common.cancel'), - danger: true, - }).then((result) => { - if (result.action !== 'CONFIRM') { - return; - } - const reportID = moneyRequestReport?.reportID; - if (reportID) { - deleteAppReport(reportID); - } - }); - return; - } - showConfirmModal({ title: translate('iou.deleteReport'), prompt: translate('iou.deleteReportConfirmation'), @@ -1248,6 +1229,7 @@ function MoneyReportHeader({ cancelText: translate('workspace.exportAgainModal.cancelText'), }).then((result) => { if (result.action !== 'CONFIRM') { + setExportModalStatus(null) return; } confirmExport(); From 690c88d71d09f4fc6af47e4d7ba154f4a2e8dee3 Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Thu, 25 Sep 2025 12:49:30 +0200 Subject: [PATCH 05/14] Linter fixes --- src/App.tsx | 2 +- src/components/MoneyReportHeader.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 5c5894bae2dd..b6a7eea23d9e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,7 +4,6 @@ import {LogBox, View} from 'react-native'; import {GestureHandlerRootView} from 'react-native-gesture-handler'; import {PickerStateProvider} from 'react-native-picker-select'; import {SafeAreaProvider} from 'react-native-safe-area-context'; -import {ModalProvider} from './components/Modal/Global'; import '../wdyr'; import {ActionSheetAwareScrollViewProvider} from './components/ActionSheetAwareScrollView'; import ActiveElementRoleProvider from './components/ActiveElementRoleProvider'; @@ -22,6 +21,7 @@ import InitialURLContextProvider from './components/InitialURLContextProvider'; import {InputBlurContextProvider} from './components/InputBlurContext'; import KeyboardProvider from './components/KeyboardProvider'; import {LocaleContextProvider} from './components/LocaleContextProvider'; +import {ModalProvider} from './components/Modal/Global'; import NavigationBar from './components/NavigationBar'; import OnyxListItemProvider from './components/OnyxListItemProvider'; import PopoverContextProvider from './components/PopoverProvider'; diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 71d75e4299b5..9c720898c508 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -4,7 +4,6 @@ import React, {useCallback, useContext, useEffect, useMemo, useState} from 'reac import {InteractionManager, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; -import useConfirmModal from '@components/Modal/Global/useModalHook'; import useDuplicateTransactionsAndViolations from '@hooks/useDuplicateTransactionsAndViolations'; import useLoadingBarVisibility from '@hooks/useLoadingBarVisibility'; import useLocalize from '@hooks/useLocalize'; @@ -127,6 +126,7 @@ import KYCWall from './KYCWall'; import type {PaymentMethod} from './KYCWall/types'; import LoadingBar from './LoadingBar'; import Modal from './Modal'; +import useConfirmModal from './Modal/Global/useModalHook'; import MoneyReportHeaderStatusBar from './MoneyReportHeaderStatusBar'; import MoneyReportHeaderStatusBarSkeleton from './MoneyReportHeaderStatusBarSkeleton'; import type {MoneyRequestHeaderStatusBarProps} from './MoneyRequestHeaderStatusBar'; @@ -1229,7 +1229,7 @@ function MoneyReportHeader({ cancelText: translate('workspace.exportAgainModal.cancelText'), }).then((result) => { if (result.action !== 'CONFIRM') { - setExportModalStatus(null) + setExportModalStatus(null); return; } confirmExport(); From 4501413aa2843de3b53359c2075b8349843ac98a Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Thu, 9 Oct 2025 11:25:17 +0200 Subject: [PATCH 06/14] Add constants for modals action --- src/components/Modal/Global/ModalContext.tsx | 11 +++++++++-- src/components/MoneyReportHeader.tsx | 17 +++++++++-------- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/src/components/Modal/Global/ModalContext.tsx b/src/components/Modal/Global/ModalContext.tsx index 93f4b63d0c11..622a25878f31 100644 --- a/src/components/Modal/Global/ModalContext.tsx +++ b/src/components/Modal/Global/ModalContext.tsx @@ -3,7 +3,14 @@ import React, {useCallback, useContext, useMemo, useState} from 'react'; import Log from '@libs/Log'; import CONST from '@src/CONST'; -type ModalAction = 'CONFIRM' | 'CLOSE'; +// type ModalAction = 'CONFIRM' | 'CLOSE'; + +const ModalActions = { + CONFIRM: 'CONFIRM', + CLOSE: 'CLOSE', +} as const; + +type ModalAction = typeof ModalActions[keyof typeof ModalActions]; type ModalStateChangePayload = {action: A}; @@ -99,4 +106,4 @@ function PromiseModalProvider({children}: {children: React.ReactNode}) { } export type {ModalProps}; -export {PromiseModalProvider, useModal}; +export {PromiseModalProvider, useModal, ModalActions}; diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index e12a33447e03..3afd5a462d75 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -143,6 +143,7 @@ import {useSearchContext} from './Search/SearchContext'; import AnimatedSettlementButton from './SettlementButton/AnimatedSettlementButton'; import Text from './Text'; import {WideRHPContext} from './WideRHPContextProvider'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; type MoneyReportHeaderProps = { /** The report currently being looked at */ @@ -324,7 +325,7 @@ function MoneyReportHeader({ } showExportProgressModal().then((result) => { - if (result.action !== 'CONFIRM') { + if (result.action !== ModalActions.CONFIRM) { return; } clearSelectedTransactions(undefined, true); @@ -980,7 +981,7 @@ function MoneyReportHeader({ cancelText: translate('common.cancel'), danger: true, }).then((result) => { - if (result.action !== 'CONFIRM') { + if (result.action !== ModalActions.CONFIRM) { return; } unapproveExpenseReport(moneyRequestReport); @@ -1003,7 +1004,7 @@ function MoneyReportHeader({ cancelText: translate('common.dismiss'), danger: true, }).then((result) => { - if (result.action !== 'CONFIRM' || !chatReport) { + if (result.action !== ModalActions.CONFIRM || !chatReport) { return; } cancelPayment(moneyRequestReport, chatReport); @@ -1104,7 +1105,7 @@ function MoneyReportHeader({ cancelText: translate('common.cancel'), danger: true, }).then((result) => { - if (result.action !== 'CONFIRM') { + if (result.action !== ModalActions.CONFIRM) { return; } let goBackRoute: Route | undefined; @@ -1133,7 +1134,7 @@ function MoneyReportHeader({ cancelText: translate('common.cancel'), danger: true, }).then((result) => { - if (result.action !== 'CONFIRM') { + if (result.action !== ModalActions.CONFIRM) { return; } const reportID = moneyRequestReport?.reportID; @@ -1164,7 +1165,7 @@ function MoneyReportHeader({ cancelText: translate('common.cancel'), danger: true, }).then((result) => { - if (result.action !== 'CONFIRM') { + if (result.action !== ModalActions.CONFIRM) { return; } reopenReport(moneyRequestReport); @@ -1246,7 +1247,7 @@ function MoneyReportHeader({ cancelText: translate('common.cancel'), danger: true, }).then((result) => { - if (result.action !== 'CONFIRM') { + if (result.action !== ModalActions.CONFIRM) { return; } if (transactions.filter((trans) => trans.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE).length === selectedTransactionIDs.length) { @@ -1269,7 +1270,7 @@ function MoneyReportHeader({ confirmText: translate('workspace.exportAgainModal.confirmText'), cancelText: translate('workspace.exportAgainModal.cancelText'), }).then((result) => { - if (result.action !== 'CONFIRM') { + if (result.action !== ModalActions.CONFIRM) { setExportModalStatus(null); return; } From 32ca4b5fc3a2fbb2bb532ab53e1bc6d68e3eace4 Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Thu, 9 Oct 2025 11:44:22 +0200 Subject: [PATCH 07/14] lint changes --- src/components/Modal/Global/ModalContext.tsx | 2 +- src/components/MoneyReportHeader.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Modal/Global/ModalContext.tsx b/src/components/Modal/Global/ModalContext.tsx index 622a25878f31..1d30e2c297f5 100644 --- a/src/components/Modal/Global/ModalContext.tsx +++ b/src/components/Modal/Global/ModalContext.tsx @@ -10,7 +10,7 @@ const ModalActions = { CLOSE: 'CLOSE', } as const; -type ModalAction = typeof ModalActions[keyof typeof ModalActions]; +type ModalAction = (typeof ModalActions)[keyof typeof ModalActions]; type ModalStateChangePayload = {action: A}; diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 3afd5a462d75..c967615b74f6 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -130,6 +130,7 @@ import KYCWall from './KYCWall'; import type {PaymentMethod} from './KYCWall/types'; import LoadingBar from './LoadingBar'; import Modal from './Modal'; +import {ModalActions} from './Modal/Global/ModalContext'; import useConfirmModal from './Modal/Global/useModalHook'; import MoneyReportHeaderStatusBar from './MoneyReportHeaderStatusBar'; import MoneyReportHeaderStatusBarSkeleton from './MoneyReportHeaderStatusBarSkeleton'; @@ -143,7 +144,6 @@ import {useSearchContext} from './Search/SearchContext'; import AnimatedSettlementButton from './SettlementButton/AnimatedSettlementButton'; import Text from './Text'; import {WideRHPContext} from './WideRHPContextProvider'; -import {ModalActions} from '@components/Modal/Global/ModalContext'; type MoneyReportHeaderProps = { /** The report currently being looked at */ From 263df6760190e830c6eac86fb06e55c106879218 Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Thu, 9 Oct 2025 12:30:43 +0200 Subject: [PATCH 08/14] modals animations --- .../Modal/Global/ConfirmModalWrapper.tsx | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/src/components/Modal/Global/ConfirmModalWrapper.tsx b/src/components/Modal/Global/ConfirmModalWrapper.tsx index 257c5dab696a..a12602126a98 100644 --- a/src/components/Modal/Global/ConfirmModalWrapper.tsx +++ b/src/components/Modal/Global/ConfirmModalWrapper.tsx @@ -1,9 +1,10 @@ -import React, {useCallback, useMemo} from 'react'; +import React, {useCallback, useMemo, useState} from 'react'; import type {ConfirmModalProps} from '@components/ConfirmModal'; import ConfirmModal from '@components/ConfirmModal'; import useActiveElementRole from '@hooks/useActiveElementRole'; import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; import CONST from '@src/CONST'; +import {ModalActions} from './ModalContext'; import type {ModalProps} from './ModalContext'; type ConfirmModalWrapperProps = ModalProps & Omit; @@ -15,14 +16,25 @@ type ConfirmModalWrapperProps = ModalProps & Omit(ModalActions.CLOSE); const handleConfirm = useCallback(() => { - closeModal({action: 'CONFIRM'}); - }, [closeModal]); + setCloseAction(ModalActions.CONFIRM); + setIsClosing(true); + }, []); const handleCancel = useCallback(() => { - closeModal({action: 'CLOSE'}); - }, [closeModal]); + setCloseAction(ModalActions.CLOSE); + setIsClosing(true); + }, []); + + const handleModalHide = useCallback(() => { + if (!isClosing) { + return; + } + closeModal({action: closeAction}); + }, [isClosing, closeModal, closeAction]); const shortcutConfig = useMemo( () => ({ @@ -39,9 +51,10 @@ function ConfirmModalWrapper({closeModal, ...props}: ConfirmModalWrapperProps) { ); } From b034e8129936188d7a9d1345932fcb6f07a3b3e6 Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Thu, 9 Oct 2025 18:33:43 +0200 Subject: [PATCH 09/14] clean --- src/components/Modal/Global/ModalContext.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/components/Modal/Global/ModalContext.tsx b/src/components/Modal/Global/ModalContext.tsx index 1d30e2c297f5..b347f37a4fda 100644 --- a/src/components/Modal/Global/ModalContext.tsx +++ b/src/components/Modal/Global/ModalContext.tsx @@ -3,8 +3,6 @@ import React, {useCallback, useContext, useMemo, useState} from 'react'; import Log from '@libs/Log'; import CONST from '@src/CONST'; -// type ModalAction = 'CONFIRM' | 'CLOSE'; - const ModalActions = { CONFIRM: 'CONFIRM', CLOSE: 'CLOSE', From a5f34227bd814722096091704963fe47290f4c8a Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Tue, 21 Oct 2025 17:46:39 +0200 Subject: [PATCH 10/14] Changes related to code review --- src/App.tsx | 2 +- .../ButtonWithDropdownMenu/types.ts | 2 +- .../Modal/Global/ConfirmModalWrapper.tsx | 37 +++--- src/components/Modal/Global/ModalContext.tsx | 4 +- src/components/Modal/Global/index.ts | 7 -- src/components/MoneyReportHeader.tsx | 117 +++++++++--------- .../useConfirmModal.ts} | 7 +- 7 files changed, 80 insertions(+), 96 deletions(-) delete mode 100644 src/components/Modal/Global/index.ts rename src/{components/Modal/Global/useModalHook.ts => hooks/useConfirmModal.ts} (68%) diff --git a/src/App.tsx b/src/App.tsx index a0f3de5d6c15..3e9c25addcf1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -22,7 +22,7 @@ import {InputBlurContextProvider} from './components/InputBlurContext'; import KeyboardProvider from './components/KeyboardProvider'; import KYCWallContextProvider from './components/KYCWall/KYCWallContext'; import {LocaleContextProvider} from './components/LocaleContextProvider'; -import {ModalProvider} from './components/Modal/Global'; +import {ModalProvider} from './components/Modal/Global/ModalContext'; import NavigationBar from './components/NavigationBar'; import OnyxListItemProvider from './components/OnyxListItemProvider'; import PopoverContextProvider from './components/PopoverProvider'; diff --git a/src/components/ButtonWithDropdownMenu/types.ts b/src/components/ButtonWithDropdownMenu/types.ts index 4877c60b4161..2cfc5ee024b1 100644 --- a/src/components/ButtonWithDropdownMenu/types.ts +++ b/src/components/ButtonWithDropdownMenu/types.ts @@ -30,7 +30,7 @@ type DropdownOption = { iconHeight?: number; iconDescription?: string; additionalIconStyles?: StyleProp; - onSelected?: () => void; + onSelected?: () => void | Promise; disabled?: boolean; iconFill?: string; interactive?: boolean; diff --git a/src/components/Modal/Global/ConfirmModalWrapper.tsx b/src/components/Modal/Global/ConfirmModalWrapper.tsx index a12602126a98..d0535fbbfd08 100644 --- a/src/components/Modal/Global/ConfirmModalWrapper.tsx +++ b/src/components/Modal/Global/ConfirmModalWrapper.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useState} from 'react'; import type {ConfirmModalProps} from '@components/ConfirmModal'; import ConfirmModal from '@components/ConfirmModal'; import useActiveElementRole from '@hooks/useActiveElementRole'; @@ -16,34 +16,31 @@ type ConfirmModalWrapperProps = ModalProps & Omit(ModalActions.CLOSE); - const handleConfirm = useCallback(() => { + const handleConfirm = () => { setCloseAction(ModalActions.CONFIRM); - setIsClosing(true); - }, []); + setIsVisible(false); + }; - const handleCancel = useCallback(() => { + const handleCancel = () => { setCloseAction(ModalActions.CLOSE); - setIsClosing(true); - }, []); + setIsVisible(false); + }; - const handleModalHide = useCallback(() => { - if (!isClosing) { + const handleModalHide = () => { + if (isVisible) { return; } closeModal({action: closeAction}); - }, [isClosing, closeModal, closeAction]); + }; - const shortcutConfig = useMemo( - () => ({ - isActive: activeElementRole !== CONST.ROLE.BUTTON, - shouldPreventDefault: false, - shouldBubble: false, - }), - [activeElementRole], - ); + const shortcutConfig = { + isActive: activeElementRole !== CONST.ROLE.BUTTON, + shouldPreventDefault: false, + shouldBubble: false, + }; useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ENTER, handleConfirm, shortcutConfig); @@ -51,7 +48,7 @@ function ConfirmModalWrapper({closeModal, ...props}: ConfirmModalWrapperProps) { ({modals: []}); const showModal = useCallback(({component, props, id, isCloseable = true}) => { @@ -104,4 +104,4 @@ function PromiseModalProvider({children}: {children: React.ReactNode}) { } export type {ModalProps}; -export {PromiseModalProvider, useModal, ModalActions}; +export {ModalProvider, useModal, ModalActions}; diff --git a/src/components/Modal/Global/index.ts b/src/components/Modal/Global/index.ts deleted file mode 100644 index f2c3ac87c320..000000000000 --- a/src/components/Modal/Global/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export {PromiseModalProvider, useModal} from './ModalContext'; -export {default as useModalHook} from './useModalHook'; -export {default as ConfirmModalWrapper} from './ConfirmModalWrapper'; -export type {ModalProps} from './ModalContext'; - -export {PromiseModalProvider as ModalProvider} from './ModalContext'; -export {default as usePromiseModal} from './useModalHook'; diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index c14a135d403f..df2bfdaa809c 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -5,6 +5,7 @@ import React, {useCallback, useContext, useEffect, useMemo, useState} from 'reac import {InteractionManager, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; +import useConfirmModal from '@hooks/useConfirmModal'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDuplicateTransactionsAndViolations from '@hooks/useDuplicateTransactionsAndViolations'; import useGetIOUReportFromReportAction from '@hooks/useGetIOUReportFromReportAction'; @@ -136,7 +137,6 @@ import type {PaymentMethod} from './KYCWall/types'; import LoadingBar from './LoadingBar'; import Modal from './Modal'; import {ModalActions} from './Modal/Global/ModalContext'; -import useConfirmModal from './Modal/Global/useModalHook'; import MoneyReportHeaderKYCDropdown from './MoneyReportHeaderKYCDropdown'; import MoneyReportHeaderStatusBar from './MoneyReportHeaderStatusBar'; import MoneyReportHeaderStatusBarSkeleton from './MoneyReportHeaderStatusBarSkeleton'; @@ -985,25 +985,25 @@ function MoneyReportHeader({ text: translate('iou.unapprove'), icon: Expensicons.CircularArrowBackwards, value: CONST.REPORT.SECONDARY_ACTIONS.UNAPPROVE, - onSelected: () => { + onSelected: async () => { if (isDelegateAccessRestricted) { showDelegateNoAccessModal(); return; } if (isExported) { - showConfirmModal({ + const result = await showConfirmModal({ title: translate('iou.unapproveReport'), prompt: unapproveWarningText, confirmText: translate('iou.unapproveReport'), cancelText: translate('common.cancel'), danger: true, - }).then((result) => { - if (result.action !== ModalActions.CONFIRM) { - return; - } - unapproveExpenseReport(moneyRequestReport); }); + + if (result.action !== ModalActions.CONFIRM) { + return; + } + unapproveExpenseReport(moneyRequestReport); return; } @@ -1014,19 +1014,19 @@ function MoneyReportHeader({ text: translate('iou.cancelPayment'), icon: Expensicons.Clear, value: CONST.REPORT.SECONDARY_ACTIONS.CANCEL_PAYMENT, - onSelected: () => { - showConfirmModal({ + onSelected: async () => { + const result = await showConfirmModal({ title: translate('iou.cancelPayment'), prompt: translate('iou.cancelPaymentConfirmation'), confirmText: translate('iou.cancelPayment'), cancelText: translate('common.dismiss'), danger: true, - }).then((result) => { - if (result.action !== ModalActions.CONFIRM || !chatReport) { - return; - } - cancelPayment(moneyRequestReport, chatReport, policy, isASAPSubmitBetaEnabled, accountID, email ?? '', hasViolations); }); + + if (result.action !== ModalActions.CONFIRM || !chatReport) { + return; + } + cancelPayment(moneyRequestReport, chatReport, policy, isASAPSubmitBetaEnabled, accountID, email ?? '', hasViolations); }, }, [CONST.REPORT.SECONDARY_ACTIONS.HOLD]: { @@ -1112,69 +1112,64 @@ function MoneyReportHeader({ text: translate('common.delete'), icon: Expensicons.Trashcan, value: CONST.REPORT.SECONDARY_ACTIONS.DELETE, - onSelected: () => { + onSelected: async () => { const transactionCount = Object.keys(transactions).length; if (transactionCount === 1) { - showConfirmModal({ + const result = await showConfirmModal({ title: translate('iou.deleteExpense', {count: 1}), prompt: translate('iou.deleteConfirmation', {count: 1}), confirmText: translate('common.delete'), cancelText: translate('common.cancel'), danger: true, - }).then((result) => { - if (result.action !== ModalActions.CONFIRM) { - return; + }); + + if (result.action !== ModalActions.CONFIRM) { + return; + } + let goBackRoute: Route | undefined; + if (transactionThreadReportID) { + if (!requestParentReportAction || !transaction?.transactionID) { + throw new Error('Missing data!'); } - let goBackRoute: Route | undefined; - if (transactionThreadReportID) { - if (!requestParentReportAction || !transaction?.transactionID) { - throw new Error('Missing data!'); - } - InteractionManager.runAfterInteractions(() => { - deleteMoneyRequest( - transaction?.transactionID, - requestParentReportAction, - duplicateTransactions, - duplicateTransactionViolations, - iouReport, - chatIOUReport, - isChatIOUReportArchived, - ); - removeTransaction(transaction.transactionID); - }); - goBackRoute = getNavigationUrlOnMoneyRequestDelete( - transaction.transactionID, + // it's deleting transaction but not the report which leads to bug (that is actually also on staging) + // Money request should be deleted when interactions are done, to not show the not found page before navigating to goBackRoute + // eslint-disable-next-line @typescript-eslint/no-deprecated + InteractionManager.runAfterInteractions(() => { + deleteMoneyRequest( + transaction?.transactionID, requestParentReportAction, + duplicateTransactions, + duplicateTransactionViolations, iouReport, chatIOUReport, isChatIOUReportArchived, - false, ); - } + removeTransaction(transaction.transactionID); + }); + goBackRoute = getNavigationUrlOnMoneyRequestDelete(transaction.transactionID, requestParentReportAction, iouReport, chatIOUReport, isChatIOUReportArchived, false); + } - if (goBackRoute) { - Navigation.setNavigationActionToMicrotaskQueue(() => navigateOnDeleteExpense(goBackRoute)); - } - }); + if (goBackRoute) { + Navigation.setNavigationActionToMicrotaskQueue(() => navigateOnDeleteExpense(goBackRoute)); + } return; } - showConfirmModal({ + const result = await showConfirmModal({ title: translate('iou.deleteReport'), prompt: translate('iou.deleteReportConfirmation'), confirmText: translate('common.delete'), cancelText: translate('common.cancel'), danger: true, - }).then((result) => { - if (result.action !== ModalActions.CONFIRM) { - return; - } - const reportID = moneyRequestReport?.reportID; - if (reportID) { - deleteAppReport(reportID); - } }); + if (result.action !== ModalActions.CONFIRM) { + return; + } + const reportID = moneyRequestReport?.reportID; + if (reportID) { + deleteAppReport(reportID); + } }, }, [CONST.REPORT.SECONDARY_ACTIONS.RETRACT]: { @@ -1189,20 +1184,20 @@ function MoneyReportHeader({ text: translate('iou.retract'), icon: Expensicons.CircularArrowBackwards, value: CONST.REPORT.SECONDARY_ACTIONS.REOPEN, - onSelected: () => { + onSelected: async () => { if (!isExported) { - showConfirmModal({ + const result = await showConfirmModal({ title: translate('iou.reopenReport'), prompt: reopenExportedReportWarningText, confirmText: translate('iou.reopenReport'), cancelText: translate('common.cancel'), danger: true, - }).then((result) => { - if (result.action !== ModalActions.CONFIRM) { - return; - } - reopenReport(moneyRequestReport); }); + + if (result.action !== ModalActions.CONFIRM) { + return; + } + reopenReport(moneyRequestReport); return; } reopenReport(moneyRequestReport); diff --git a/src/components/Modal/Global/useModalHook.ts b/src/hooks/useConfirmModal.ts similarity index 68% rename from src/components/Modal/Global/useModalHook.ts rename to src/hooks/useConfirmModal.ts index b6d5b3737d28..ef1d586021f0 100644 --- a/src/components/Modal/Global/useModalHook.ts +++ b/src/hooks/useConfirmModal.ts @@ -1,6 +1,6 @@ -import ConfirmModalWrapper from './ConfirmModalWrapper'; -import {useModal} from './ModalContext'; -import type {ModalProps} from './ModalContext'; +import ConfirmModalWrapper from '@components/Modal/Global/ConfirmModalWrapper'; +import type {ModalProps} from '@components/Modal/Global/ModalContext'; +import {useModal} from '@components/Modal/Global/ModalContext'; type ConfirmModalOptions = Omit, keyof ModalProps>; @@ -19,5 +19,4 @@ const useConfirmModal = () => { showConfirmModal, }; }; - export default useConfirmModal; From e0e80e6647bb9be65ec11c8536e78224d522745d Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Fri, 24 Oct 2025 09:35:40 +0200 Subject: [PATCH 11/14] Move customApprovalWorkflow to global modal hook --- src/components/MoneyReportHeader.tsx | 32 ++++++++++++++-------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 838c24036c58..db7069eb5b1d 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -254,7 +254,6 @@ function MoneyReportHeader({ const transactionViolations = useTransactionViolations(transaction?.transactionID); const [downloadErrorModalVisible, setDownloadErrorModalVisible] = useState(false); const [isPDFModalVisible, setIsPDFModalVisible] = useState(false); - const [isDEWModalVisible, setIsDEWModalVisible] = useState(false); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {canBeMissing: true}); const [allBetas] = useOnyx(ONYXKEYS.BETAS, {canBeMissing: true}); const [allTransactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, {canBeMissing: true}); @@ -471,9 +470,22 @@ function MoneyReportHeader({ ], ); + const showDWEModal = async () => { + const result = await showConfirmModal({ + confirmText: translate('customApprovalWorkflow.goToExpensifyClassic'), + title: translate('customApprovalWorkflow.title'), + prompt: translate('customApprovalWorkflow.description'), + shouldShowCancelButton: false, + }); + + if (result.action === ModalActions.CONFIRM) { + openOldDotLink(CONST.OLDDOT_URLS.INBOX); + } + }; + const confirmApproval = () => { if (hasDynamicExternalWorkflow(policy)) { - setIsDEWModalVisible(true); + showDWEModal(); return; } setRequestType(CONST.IOU.REPORT_ACTION_TYPE.APPROVE); @@ -731,7 +743,7 @@ function MoneyReportHeader({ return; } if (hasDynamicExternalWorkflow(policy)) { - setIsDEWModalVisible(true); + showDWEModal(); return; } startSubmittingAnimation(); @@ -946,7 +958,7 @@ function MoneyReportHeader({ return; } if (hasDynamicExternalWorkflow(policy)) { - setIsDEWModalVisible(true); + showDWEModal(); return; } submitReport(moneyRequestReport, policy, accountID, email ?? '', hasViolations, isASAPSubmitBetaEnabled); @@ -1507,18 +1519,6 @@ function MoneyReportHeader({ )} - { - setIsDEWModalVisible(false); - openOldDotLink(CONST.OLDDOT_URLS.INBOX); - }} - onCancel={() => setIsDEWModalVisible(false)} - prompt={translate('customApprovalWorkflow.description')} - confirmText={translate('customApprovalWorkflow.goToExpensifyClassic')} - shouldShowCancelButton={false} - /> ); } From 1796003ed228518aac14118e62f01b56dd936033 Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Fri, 31 Oct 2025 12:34:28 +0100 Subject: [PATCH 12/14] fix the goBack when report is deleted issue --- src/components/MoneyReportHeader.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 60d38e1e7f94..baaac0a1cd14 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -1147,10 +1147,11 @@ function MoneyReportHeader({ if (result.action !== ModalActions.CONFIRM) { return; } - const reportID = moneyRequestReport?.reportID; - if (reportID) { - deleteAppReport(reportID); - } + Navigation.goBack(route.params?.backTo); + // eslint-disable-next-line @typescript-eslint/no-deprecated + InteractionManager.runAfterInteractions(() => { + deleteAppReport(moneyRequestReport?.reportID); + }); }, }, [CONST.REPORT.SECONDARY_ACTIONS.RETRACT]: { From f2b00c170d2a5ad02f6b80f6caaeb1317647e5ce Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Tue, 4 Nov 2025 16:19:11 +0100 Subject: [PATCH 13/14] Fix useCallback dep array --- src/components/MoneyReportHeader.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index a08045f121a4..2a5813e520fa 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -1275,7 +1275,7 @@ function MoneyReportHeader({ } handleDeleteTransactions(); }); - }, [showConfirmModal, translate, selectedTransactionIDs.length, transactions, handleDeleteTransactions, route.params?.backTo]); + }, [showConfirmModal, translate, selectedTransactionIDs.length, transactions, handleDeleteTransactions, route.params?.backTo, chatReport?.reportID]); const showExportAgainModal = useCallback(() => { if (!connectedIntegration) { From a79f23f06c72915e70b8f8e2a73385d69e09b1a7 Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Wed, 5 Nov 2025 11:19:10 +0100 Subject: [PATCH 14/14] Switch isExported check --- src/components/MoneyReportHeader.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 9085453030a0..b9a77bd24c38 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -1168,7 +1168,7 @@ function MoneyReportHeader({ icon: Expensicons.CircularArrowBackwards, value: CONST.REPORT.SECONDARY_ACTIONS.REOPEN, onSelected: async () => { - if (!isExported) { + if (isExported) { const result = await showConfirmModal({ title: translate('iou.reopenReport'), prompt: reopenExportedReportWarningText,