diff --git a/src/components/HeaderWithBackButton/headerWithBackButtonPropTypes.js b/src/components/HeaderWithBackButton/headerWithBackButtonPropTypes.js index d2cdc5b29898..8db9fbb3a321 100644 --- a/src/components/HeaderWithBackButton/headerWithBackButtonPropTypes.js +++ b/src/components/HeaderWithBackButton/headerWithBackButtonPropTypes.js @@ -31,12 +31,18 @@ const propTypes = { /** Whether we should show a get assistance (question mark) button */ shouldShowGetAssistanceButton: PropTypes.bool, + /** Whether we should disable the get assistance button */ + shouldDisableGetAssistanceButton: PropTypes.bool, + /** Whether we should show a pin button */ shouldShowPinButton: PropTypes.bool, /** Whether we should show a more options (threedots) button */ shouldShowThreeDotsButton: PropTypes.bool, + /** Whether we should disable threedots button */ + shouldDisableThreeDotsButton: PropTypes.bool, + /** List of menu items for more(three dots) menu */ threeDotsMenuItems: ThreeDotsMenuItemPropTypes, @@ -84,6 +90,9 @@ const propTypes = { /** Children to wrap in Header */ children: PropTypes.node, + + /** Single execution function to prevent concurrent navigation actions */ + singleExecution: PropTypes.func, }; export default propTypes; diff --git a/src/components/HeaderWithBackButton/index.js b/src/components/HeaderWithBackButton/index.js index 6a02ce02237d..fc1f1a533ab7 100755 --- a/src/components/HeaderWithBackButton/index.js +++ b/src/components/HeaderWithBackButton/index.js @@ -18,6 +18,7 @@ import headerWithBackButtonPropTypes from './headerWithBackButtonPropTypes'; import useThrottledButtonState from '../../hooks/useThrottledButtonState'; import useLocalize from '../../hooks/useLocalize'; import useKeyboardState from '../../hooks/useKeyboardState'; +import useWaitForNavigation from '../../hooks/useWaitForNavigation'; function HeaderWithBackButton({ iconFill = undefined, @@ -35,8 +36,10 @@ function HeaderWithBackButton({ shouldShowCloseButton = false, shouldShowDownloadButton = false, shouldShowGetAssistanceButton = false, + shouldDisableGetAssistanceButton = false, shouldShowPinButton = false, shouldShowThreeDotsButton = false, + shouldDisableThreeDotsButton = false, stepCounter = null, subtitle = '', title = '', @@ -49,10 +52,12 @@ function HeaderWithBackButton({ shouldEnableDetailPageNavigation = false, children = null, shouldOverlay = false, + singleExecution = (func) => func, }) { const [isDownloadButtonActive, temporarilyDisableDownloadButton] = useThrottledButtonState(); const {translate} = useLocalize(); const {isKeyboardShown} = useKeyboardState(); + const waitForNavigate = useWaitForNavigation(); return ( Navigation.navigate(ROUTES.GET_ASSISTANCE.getRoute(guidesCallTaskID))} + disabled={shouldDisableGetAssistanceButton} + onPress={singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.GET_ASSISTANCE.getRoute(guidesCallTaskID))))} style={[styles.touchableButtonImage]} accessibilityRole="button" accessibilityLabel={translate('getAssistancePage.questionMarkButtonTooltip')} @@ -141,6 +147,7 @@ function HeaderWithBackButton({ {shouldShowPinButton && } {shouldShowThreeDotsButton && ( ))} diff --git a/src/components/ThreeDotsMenu/index.js b/src/components/ThreeDotsMenu/index.js index 3aac98fa1275..ab7ca57ed721 100644 --- a/src/components/ThreeDotsMenu/index.js +++ b/src/components/ThreeDotsMenu/index.js @@ -50,12 +50,16 @@ const propTypes = { /** Whether the popover menu should overlay the current view */ shouldOverlay: PropTypes.bool, + /** Whether the menu is disabled */ + disabled: PropTypes.bool, + /** Should we announce the Modal visibility changes? */ shouldSetModalVisibility: PropTypes.bool, }; const defaultProps = { iconTooltip: 'common.more', + disabled: false, iconFill: undefined, iconStyles: [], icon: Expensicons.ThreeDots, @@ -68,7 +72,7 @@ const defaultProps = { shouldSetModalVisibility: true, }; -function ThreeDotsMenu({iconTooltip, icon, iconFill, iconStyles, onIconPress, menuItems, anchorPosition, anchorAlignment, shouldOverlay, shouldSetModalVisibility}) { +function ThreeDotsMenu({iconTooltip, icon, iconFill, iconStyles, onIconPress, menuItems, anchorPosition, anchorAlignment, shouldOverlay, shouldSetModalVisibility, disabled}) { const [isPopupMenuVisible, setPopupMenuVisible] = useState(false); const buttonRef = useRef(null); const {translate} = useLocalize(); @@ -96,6 +100,7 @@ function ThreeDotsMenu({iconTooltip, icon, iconFill, iconStyles, onIconPress, me onIconPress(); } }} + disabled={disabled} onMouseDown={(e) => { /* Keep the focus state on mWeb like we did on the native apps. */ if (!Browser.isMobile()) { diff --git a/src/pages/settings/AboutPage/AboutPage.js b/src/pages/settings/AboutPage/AboutPage.js index 25b6197f87f8..36f088487469 100644 --- a/src/pages/settings/AboutPage/AboutPage.js +++ b/src/pages/settings/AboutPage/AboutPage.js @@ -1,5 +1,5 @@ import _ from 'underscore'; -import React from 'react'; +import React, {useRef, useMemo} from 'react'; import {ScrollView, View} from 'react-native'; import DeviceInfo from 'react-native-device-info'; import HeaderWithBackButton from '../../../components/HeaderWithBackButton'; @@ -13,7 +13,6 @@ import * as Expensicons from '../../../components/Icon/Expensicons'; import ScreenWrapper from '../../../components/ScreenWrapper'; import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize'; import withWindowDimensions, {windowDimensionsPropTypes} from '../../../components/withWindowDimensions'; -import MenuItem from '../../../components/MenuItem'; import Logo from '../../../../assets/images/new-expensify.svg'; import pkg from '../../../../package.json'; import * as Report from '../../../libs/actions/Report'; @@ -22,6 +21,8 @@ import compose from '../../../libs/compose'; import * as ReportActionContextMenu from '../../home/report/ContextMenu/ReportActionContextMenu'; import {CONTEXT_MENU_TYPES} from '../../home/report/ContextMenu/ContextMenuActions'; import * as Environment from '../../../libs/Environment/Environment'; +import MenuItemList from '../../../components/MenuItemList'; +import useWaitForNavigation from '../../../hooks/useWaitForNavigation'; const propTypes = { ...withLocalizePropTypes, @@ -40,46 +41,57 @@ function getFlavor() { } function AboutPage(props) { - let popoverAnchor; - const menuItems = [ - { - translationKey: 'initialSettingsPage.aboutPage.appDownloadLinks', - icon: Expensicons.Link, - action: () => { - Navigation.navigate(ROUTES.SETTINGS_APP_DOWNLOAD_LINKS); + const {translate} = props; + const popoverAnchor = useRef(null); + const waitForNavigate = useWaitForNavigation(); + + const menuItems = useMemo(() => { + const baseMenuItems = [ + { + translationKey: 'initialSettingsPage.aboutPage.appDownloadLinks', + icon: Expensicons.Link, + action: waitForNavigate(() => Navigation.navigate(ROUTES.SETTINGS_APP_DOWNLOAD_LINKS)), + }, + { + translationKey: 'initialSettingsPage.aboutPage.viewKeyboardShortcuts', + icon: Expensicons.Keyboard, + action: waitForNavigate(() => Navigation.navigate(ROUTES.KEYBOARD_SHORTCUTS)), }, - }, - { - translationKey: 'initialSettingsPage.aboutPage.viewKeyboardShortcuts', - icon: Expensicons.Keyboard, - action: () => { - Navigation.navigate(ROUTES.KEYBOARD_SHORTCUTS); + { + translationKey: 'initialSettingsPage.aboutPage.viewTheCode', + icon: Expensicons.Eye, + iconRight: Expensicons.NewWindow, + action: () => { + Link.openExternalLink(CONST.GITHUB_URL); + }, }, - }, - { - translationKey: 'initialSettingsPage.aboutPage.viewTheCode', - icon: Expensicons.Eye, - iconRight: Expensicons.NewWindow, - action: () => { - Link.openExternalLink(CONST.GITHUB_URL); + { + translationKey: 'initialSettingsPage.aboutPage.viewOpenJobs', + icon: Expensicons.MoneyBag, + iconRight: Expensicons.NewWindow, + action: () => { + Link.openExternalLink(CONST.UPWORK_URL); + }, + link: CONST.UPWORK_URL, }, - link: CONST.GITHUB_URL, - }, - { - translationKey: 'initialSettingsPage.aboutPage.viewOpenJobs', - icon: Expensicons.MoneyBag, - iconRight: Expensicons.NewWindow, - action: () => { - Link.openExternalLink(CONST.UPWORK_URL); + { + translationKey: 'initialSettingsPage.aboutPage.reportABug', + icon: Expensicons.Bug, + action: waitForNavigate(Report.navigateToConciergeChat), }, - link: CONST.UPWORK_URL, - }, - { - translationKey: 'initialSettingsPage.aboutPage.reportABug', - icon: Expensicons.Bug, - action: Report.navigateToConciergeChat, - }, - ]; + ]; + return _.map(baseMenuItems, (item) => ({ + key: item.translationKey, + title: translate(item.translationKey), + icon: item.icon, + iconRight: item.iconRight, + onPress: item.action, + shouldShowRightIcon: true, + onSecondaryInteraction: !_.isEmpty(item.link) ? (e) => ReportActionContextMenu.showContextMenu(CONTEXT_MENU_TYPES.LINK, e, item.link, popoverAnchor) : undefined, + ref: popoverAnchor, + shouldBlockSelection: Boolean(item.link), + })); + }, [translate, waitForNavigate]); return ( {props.translate('initialSettingsPage.aboutPage.description')} - {_.map(menuItems, (item) => ( - item.action()} - shouldBlockSelection={Boolean(item.link)} - onSecondaryInteraction={ - !_.isEmpty(item.link) ? (e) => ReportActionContextMenu.showContextMenu(CONTEXT_MENU_TYPES.LINK, e, item.link, popoverAnchor) : undefined - } - ref={(el) => (popoverAnchor = el)} - shouldShowRightIcon - /> - ))} + @@ -357,7 +358,8 @@ function InitialSettingsPage(props) { diff --git a/src/pages/settings/Security/SecuritySettingsPage.js b/src/pages/settings/Security/SecuritySettingsPage.js index 704ea17422bd..dac4ed4f872f 100644 --- a/src/pages/settings/Security/SecuritySettingsPage.js +++ b/src/pages/settings/Security/SecuritySettingsPage.js @@ -1,20 +1,21 @@ -import _ from 'underscore'; -import React from 'react'; -import {View, ScrollView} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; import PropTypes from 'prop-types'; -import Navigation from '../../../libs/Navigation/Navigation'; +import React, {useMemo} from 'react'; +import {ScrollView, View} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import _ from 'underscore'; +import ONYXKEYS from '../../../ONYXKEYS'; import ROUTES from '../../../ROUTES'; import SCREENS from '../../../SCREENS'; -import styles from '../../../styles/styles'; import * as Expensicons from '../../../components/Icon/Expensicons'; -import themeColors from '../../../styles/themes/default'; -import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize'; -import MenuItem from '../../../components/MenuItem'; -import compose from '../../../libs/compose'; -import ONYXKEYS from '../../../ONYXKEYS'; import IllustratedHeaderPageLayout from '../../../components/IllustratedHeaderPageLayout'; import * as LottieAnimations from '../../../components/LottieAnimations'; +import MenuItemList from '../../../components/MenuItemList'; +import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize'; +import useWaitForNavigation from '../../../hooks/useWaitForNavigation'; +import Navigation from '../../../libs/Navigation/Navigation'; +import compose from '../../../libs/compose'; +import styles from '../../../styles/styles'; +import themeColors from '../../../styles/themes/default'; const propTypes = { ...withLocalizePropTypes, @@ -33,24 +34,36 @@ const defaultProps = { }; function SecuritySettingsPage(props) { - const menuItems = [ - { - translationKey: 'twoFactorAuth.headerTitle', - icon: Expensicons.Shield, - action: () => Navigation.navigate(ROUTES.SETTINGS_2FA), - }, - { - translationKey: 'closeAccountPage.closeAccount', - icon: Expensicons.ClosedSign, - action: () => { - Navigation.navigate(ROUTES.SETTINGS_CLOSE); + const {translate} = props; + const waitForNavigate = useWaitForNavigation(); + + const menuItems = useMemo(() => { + const baseMenuItems = [ + { + translationKey: 'twoFactorAuth.headerTitle', + icon: Expensicons.Shield, + action: waitForNavigate(() => Navigation.navigate(ROUTES.SETTINGS_2FA)), }, - }, - ]; + { + translationKey: 'closeAccountPage.closeAccount', + icon: Expensicons.ClosedSign, + action: waitForNavigate(() => Navigation.navigate(ROUTES.SETTINGS_CLOSE)), + }, + ]; + + return _.map(baseMenuItems, (item) => ({ + key: item.translationKey, + title: translate(item.translationKey), + icon: item.icon, + iconRight: item.iconRight, + onPress: item.action, + shouldShowRightIcon: true, + })); + }, [translate, waitForNavigate]); return ( Navigation.goBack(ROUTES.SETTINGS)} shouldShowBackButton illustration={LottieAnimations.Safe} @@ -58,16 +71,10 @@ function SecuritySettingsPage(props) { > - {_.map(menuItems, (item) => ( - item.action()} - shouldShowRightIcon - /> - ))} + diff --git a/src/pages/workspace/WorkspaceInitialPage.js b/src/pages/workspace/WorkspaceInitialPage.js index d275b7f0dd10..5c754a1ef5fb 100644 --- a/src/pages/workspace/WorkspaceInitialPage.js +++ b/src/pages/workspace/WorkspaceInitialPage.js @@ -32,6 +32,8 @@ import * as ReimbursementAccountProps from '../ReimbursementAccount/reimbursemen import * as ReportUtils from '../../libs/ReportUtils'; import withWindowDimensions from '../../components/withWindowDimensions'; import PressableWithoutFeedback from '../../components/Pressable/PressableWithoutFeedback'; +import useSingleExecution from '../../hooks/useSingleExecution'; +import useWaitForNavigation from '../../hooks/useWaitForNavigation'; const propTypes = { ...policyPropTypes, @@ -70,6 +72,8 @@ function WorkspaceInitialPage(props) { const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isCurrencyModalOpen, setIsCurrencyModalOpen] = useState(false); const hasPolicyCreationError = Boolean(policy.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD && policy.errors); + const waitForNavigate = useWaitForNavigation(); + const {singleExecution, isExecuting} = useSingleExecution(); /** * Call the delete policy and hide the modal @@ -129,39 +133,39 @@ function WorkspaceInitialPage(props) { { translationKey: 'workspace.common.settings', icon: Expensicons.Gear, - action: () => Navigation.navigate(ROUTES.WORKSPACE_SETTINGS.getRoute(policy.id)), + action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_SETTINGS.getRoute(policy.id)))), brickRoadIndicator: hasGeneralSettingsError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : '', }, { translationKey: 'workspace.common.card', icon: Expensicons.ExpensifyCard, - action: () => Navigation.navigate(ROUTES.WORKSPACE_CARD.getRoute(policy.id)), + action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_CARD.getRoute(policy.id)))), }, { translationKey: 'workspace.common.reimburse', icon: Expensicons.Receipt, - action: () => Navigation.navigate(ROUTES.WORKSPACE_REIMBURSE.getRoute(policy.id)), + action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_REIMBURSE.getRoute(policy.id)))), error: hasCustomUnitsError, }, { translationKey: 'workspace.common.bills', icon: Expensicons.Bill, - action: () => Navigation.navigate(ROUTES.WORKSPACE_BILLS.getRoute(policy.id)), + action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_BILLS.getRoute(policy.id)))), }, { translationKey: 'workspace.common.invoices', icon: Expensicons.Invoice, - action: () => Navigation.navigate(ROUTES.WORKSPACE_INVOICES.getRoute(policy.id)), + action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_INVOICES.getRoute(policy.id)))), }, { translationKey: 'workspace.common.travel', icon: Expensicons.Luggage, - action: () => Navigation.navigate(ROUTES.WORKSPACE_TRAVEL.getRoute(policy.id)), + action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_TRAVEL.getRoute(policy.id)))), }, { translationKey: 'workspace.common.members', icon: Expensicons.Users, - action: () => Navigation.navigate(ROUTES.WORKSPACE_MEMBERS.getRoute(policy.id)), + action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_MEMBERS.getRoute(policy.id)))), brickRoadIndicator: hasMembersError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : '', }, { @@ -169,7 +173,7 @@ function WorkspaceInitialPage(props) { icon: Expensicons.Bank, action: () => policy.outputCurrency === CONST.CURRENCY.USD - ? ReimbursementAccount.navigateToBankAccountRoute(policy.id, Navigation.getActiveRoute().replace(/\?.*/, '')) + ? singleExecution(waitForNavigate(() => ReimbursementAccount.navigateToBankAccountRoute(policy.id, Navigation.getActiveRoute().replace(/\?.*/, ''))))() : setIsCurrencyModalOpen(true), brickRoadIndicator: !_.isEmpty(props.reimbursementAccount.errors) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : '', }, @@ -208,6 +212,9 @@ function WorkspaceInitialPage(props) { title={props.translate('workspace.common.workspace')} shouldShowThreeDotsButton shouldShowGetAssistanceButton + singleExecution={singleExecution} + shouldDisableGetAssistanceButton={isExecuting} + shouldDisableThreeDotsButton={isExecuting} guidesCallTaskID={CONST.GUIDES_CALL_TASK_IDS.WORKSPACE_INITIAL} threeDotsMenuItems={threeDotsMenuItems} threeDotsAnchorPosition={styles.threeDotsPopoverOffset(props.windowWidth)} @@ -225,9 +232,9 @@ function WorkspaceInitialPage(props) { openEditor(policy.id)} + onPress={singleExecution(waitForNavigate(() => openEditor(policy.id)))} accessibilityLabel={props.translate('workspace.common.settings')} accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} > @@ -245,9 +252,9 @@ function WorkspaceInitialPage(props) { {!_.isEmpty(policy.name) && ( openEditor(policy.id)} + onPress={singleExecution(waitForNavigate(() => openEditor(policy.id)))} accessibilityLabel={props.translate('workspace.common.settings')} accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} > @@ -262,15 +269,19 @@ function WorkspaceInitialPage(props) { )} + {/* + Ideally we should use MenuList component for MenuItems with singleExecution/Navigation actions. + In this case where user can click on workspace avatar or menu items, we need to have a check for `isExecuting`. So, we are directly mapping menuItems. + */} {_.map(menuItems, (item) => ( item.action()} + onPress={item.action} shouldShowRightIcon brickRoadIndicator={item.brickRoadIndicator} />