-
Notifications
You must be signed in to change notification settings - Fork 4k
Consolidate ConfirmModal instances into a global component v3 #71169
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
roryabraham
merged 26 commits into
Expensify:main
from
callstack-internal:feat/single-modal-instance
Nov 7, 2025
Merged
Changes from all commits
Commits
Show all changes
26 commits
Select commit
Hold shift + click to select a range
cf40f3a
Initial implementation of ModalProvider and ConfirmModalWrapper
sosek108 9965b84
Reaplication of useModalHook inside of MoneyReportHeader.tsx
sosek108 6bc577c
Change modal title when deleting report
sosek108 7d4d79a
Fixes for deploy blockers
sosek108 690c88d
Linter fixes
sosek108 f567b69
Merge branch 'main' into feat/single-modal-instance
sosek108 4501413
Add constants for modals action
sosek108 32ca4b5
lint changes
sosek108 263df67
modals animations
sosek108 b034e81
clean
sosek108 4536685
Merge branch 'main' into feat/single-modal-instance
sosek108 a5f3422
Changes related to code review
sosek108 8177e37
Merge branch 'main' into feat/single-modal-instance
sosek108 eb993fd
Merge branch 'main' into feat/single-modal-instance
sosek108 694b92d
Merge branch 'main' into feat/single-modal-instance
sosek108 e0e80e6
Move customApprovalWorkflow to global modal hook
sosek108 dc7a730
Merge branch 'main' into feat/single-modal-instance
sosek108 ca0c73b
Merge branch 'main' into feat/single-modal-instance
sosek108 1796003
fix the goBack when report is deleted issue
sosek108 0b8f26c
Merge branch 'main' into feat/single-modal-instance
sosek108 ca88e53
Merge branch 'main' into feat/single-modal-instance
sosek108 243a9e3
Merge branch 'main' into feat/single-modal-instance
sosek108 f2b00c1
Fix useCallback dep array
sosek108 2e0be8c
Merge branch 'main' into feat/single-modal-instance
sosek108 a79f23f
Switch isExported check
sosek108 ba62b5a
Merge branch 'main' into feat/single-modal-instance
sosek108 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| import React, {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<ConfirmModalProps, 'onConfirm' | 'onCancel' | 'isVisible'>; | ||
|
|
||
| // 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 [isVisible, setIsVisible] = useState(true); | ||
| const [closeAction, setCloseAction] = useState<typeof ModalActions.CONFIRM | typeof ModalActions.CLOSE>(ModalActions.CLOSE); | ||
|
|
||
| const handleConfirm = () => { | ||
| setCloseAction(ModalActions.CONFIRM); | ||
| setIsVisible(false); | ||
| }; | ||
|
|
||
| const handleCancel = () => { | ||
| setCloseAction(ModalActions.CLOSE); | ||
| setIsVisible(false); | ||
| }; | ||
|
|
||
| const handleModalHide = () => { | ||
| if (isVisible) { | ||
| return; | ||
| } | ||
| closeModal({action: closeAction}); | ||
| }; | ||
|
|
||
| const shortcutConfig = { | ||
| isActive: activeElementRole !== CONST.ROLE.BUTTON, | ||
| shouldPreventDefault: false, | ||
| shouldBubble: false, | ||
| }; | ||
|
|
||
| useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ENTER, handleConfirm, shortcutConfig); | ||
|
|
||
| return ( | ||
| <ConfirmModal | ||
| // eslint-disable-next-line react/jsx-props-no-spreading | ||
| {...props} | ||
| isVisible={isVisible} | ||
| onConfirm={handleConfirm} | ||
| onCancel={handleCancel} | ||
| onModalHide={handleModalHide} | ||
| /> | ||
| ); | ||
| } | ||
|
|
||
| ConfirmModalWrapper.displayName = 'ConfirmModalWrapper'; | ||
|
|
||
| export default ConfirmModalWrapper; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,107 @@ | ||
| import noop from 'lodash/noop'; | ||
| import React, {useCallback, useContext, useMemo, useState} from 'react'; | ||
| import Log from '@libs/Log'; | ||
| import CONST from '@src/CONST'; | ||
|
|
||
| const ModalActions = { | ||
| CONFIRM: 'CONFIRM', | ||
| CLOSE: 'CLOSE', | ||
| } as const; | ||
|
|
||
| type ModalAction = (typeof ModalActions)[keyof typeof ModalActions]; | ||
|
|
||
| type ModalStateChangePayload<A extends ModalAction = ModalAction> = {action: A}; | ||
|
|
||
| type ModalProps = { | ||
| closeModal: (param?: ModalStateChangePayload) => void; | ||
| }; | ||
|
|
||
| type ModalContextType = { | ||
| showModal<P extends ModalProps>(options: {component: React.FunctionComponent<P>; props?: Omit<P, 'closeModal'>; id?: string; isCloseable?: boolean}): Promise<ModalStateChangePayload>; | ||
| closeModal(data?: ModalStateChangePayload): void; | ||
| }; | ||
|
|
||
| const ModalContext = React.createContext<ModalContextType>({ | ||
| showModal: () => Promise.resolve({action: 'CLOSE'}), | ||
| closeModal: noop, | ||
| }); | ||
|
|
||
| const useModal = () => useContext(ModalContext); | ||
|
|
||
| let modalID = 1; | ||
|
|
||
| type ModalInfo = { | ||
| id: string; | ||
| component: React.FunctionComponent<ModalProps>; | ||
| props?: Record<string, unknown>; | ||
| promiseWithResolvers: ReturnType<typeof Promise.withResolvers<ModalStateChangePayload>>; | ||
| isCloseable: boolean; | ||
| }; | ||
|
|
||
| function ModalProvider({children}: {children: React.ReactNode}) { | ||
| const [modalStack, setModalStack] = useState<{modals: ModalInfo[]}>({modals: []}); | ||
|
|
||
| const showModal = useCallback<ModalContextType['showModal']>(({component, props, id, isCloseable = true}) => { | ||
| // This is a promise that will resolve when the modal is closed | ||
| let closeModalPromise: Promise<ModalStateChangePayload> | 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<ModalStateChangePayload>(); | ||
| closeModalPromise = promiseWithResolvers.promise; | ||
|
|
||
| return { | ||
| ...prevState, | ||
| modals: [...prevState.modals, {component: component as React.FunctionComponent<ModalProps>, 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<ModalContextType['closeModal']>((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 ( | ||
| <ModalContext.Provider value={contextValue}> | ||
| {children} | ||
| {!!ModalComponent && ( | ||
| <ModalComponent | ||
| // eslint-disable-next-line react/jsx-props-no-spreading | ||
| {...modalToRender.props} | ||
| key={modalToRender.id} | ||
| closeModal={closeModal} | ||
| /> | ||
| )} | ||
| </ModalContext.Provider> | ||
| ); | ||
| } | ||
|
|
||
| export type {ModalProps}; | ||
| export {ModalProvider, useModal, ModalActions}; |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.