diff --git a/assets/images/cards-and-domains.svg b/assets/images/cards-and-domains.svg new file mode 100644 index 0000000000000..4467ad4cf2223 --- /dev/null +++ b/assets/images/cards-and-domains.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/images/home.svg b/assets/images/home.svg new file mode 100644 index 0000000000000..6b2411407be7a --- /dev/null +++ b/assets/images/home.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/images/new-expensify.svg b/assets/images/new-expensify.svg index 38276ecd93851..dc7aec87c6fd5 100644 --- a/assets/images/new-expensify.svg +++ b/assets/images/new-expensify.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/assets/images/wrench.svg b/assets/images/wrench.svg new file mode 100644 index 0000000000000..2865c40eb9524 --- /dev/null +++ b/assets/images/wrench.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/patches/@react-navigation+stack+6.3.16+002+dontDetachScreen.patch b/patches/@react-navigation+stack+6.3.16+002+dontDetachScreen.patch index d64fc4fecf744..877521094cd4f 100644 --- a/patches/@react-navigation+stack+6.3.16+002+dontDetachScreen.patch +++ b/patches/@react-navigation+stack+6.3.16+002+dontDetachScreen.patch @@ -43,7 +43,7 @@ index 7558eb3..b7bb75e 100644 }) : STATE_TRANSITIONING_OR_BELOW_TOP; } + -+ const isHomeScreenAndNotOnTop = route.name === 'Home' && isScreenActive !== STATE_ON_TOP; ++ const isHomeScreenAndNotOnTop = (route.name === 'BottomTabNavigator' || route.name === 'Settings_Root') && isScreenActive !== STATE_ON_TOP; + const { headerShown = true, diff --git a/src/App.js b/src/App.js index 3553900bbc7fc..8045f4eb30adc 100644 --- a/src/App.js +++ b/src/App.js @@ -6,6 +6,7 @@ import Onyx from 'react-native-onyx'; import {PickerStateProvider} from 'react-native-picker-select'; import {SafeAreaProvider} from 'react-native-safe-area-context'; import '../wdyr'; +import ActiveWorkspaceContextProvider from './components/ActiveWorkspace/ActiveWorkspaceProvider'; import ColorSchemeWrapper from './components/ColorSchemeWrapper'; import ComposeProviders from './components/ComposeProviders'; import CustomStatusBarAndBackground from './components/CustomStatusBarAndBackground'; @@ -69,6 +70,7 @@ function App() { PickerStateProvider, EnvironmentProvider, CustomStatusBarAndBackgroundContextProvider, + ActiveWorkspaceContextProvider, ]} > diff --git a/src/CONST.ts b/src/CONST.ts index c6849db630f20..e0e5753031083 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -480,6 +480,8 @@ const CONST = { // Use Environment.getEnvironmentURL to get the complete URL with port number DEV_NEW_EXPENSIFY_URL: 'https://dev.new.expensify.com:', EXPENSIFY_INBOX_URL: 'https://www.expensify.com/inbox', + ADMIN_POLICIES_URL: 'admin_policies', + ADMIN_DOMAINS_URL: 'admin_domains', SIGN_IN_FORM_WIDTH: 300, @@ -993,6 +995,7 @@ const CONST = { 3: 100, }, }, + CENTRAL_PANE_ANIMATION_HEIGHT: 200, LHN_SKELETON_VIEW_ITEM_HEIGHT: 64, EXPENSIFY_PARTNER_NAME: 'expensify.com', EMAIL: { @@ -1445,7 +1448,7 @@ const CONST = { GUIDES_CALL_TASK_IDS: { CONCIERGE_DM: 'NewExpensifyConciergeDM', WORKSPACE_INITIAL: 'WorkspaceHome', - WORKSPACE_SETTINGS: 'WorkspaceGeneralSettings', + WORKSPACE_OVERVIEW: 'WorkspaceGeneralSettings', WORKSPACE_CARD: 'WorkspaceCorporateCards', WORKSPACE_REIMBURSE: 'WorkspaceReimburseReceipts', WORKSPACE_BILLS: 'WorkspacePayBills', @@ -3062,7 +3065,11 @@ const CONST = { DEFAULT: 5, CAROUSEL: 3, }, + BRICK_ROAD: { + GBR: 'info', + RBR: 'error', + }, VIOLATIONS: { ALL_TAG_LEVELS_REQUIRED: 'allTagLevelsRequired', AUTO_REPORTED_REJECTED_EXPENSE: 'autoReportedRejectedExpense', @@ -3106,6 +3113,11 @@ const CONST = { EMAIL: 'EMAIL', REPORT: 'REPORT', }, + + WORKSPACE_SWITCHER: { + NAME: 'Expensify', + SUBSCRIPT_ICON_SIZE: 8, + }, } as const; export default CONST; diff --git a/src/NAVIGATORS.ts b/src/NAVIGATORS.ts index c68a950d35010..3bc9c5e57b9bf 100644 --- a/src/NAVIGATORS.ts +++ b/src/NAVIGATORS.ts @@ -4,6 +4,7 @@ * */ export default { CENTRAL_PANE_NAVIGATOR: 'CentralPaneNavigator', + BOTTOM_TAB_NAVIGATOR: 'BottomTabNavigator', LEFT_MODAL_NAVIGATOR: 'LeftModalNavigator', RIGHT_MODAL_NAVIGATOR: 'RightModalNavigator', FULL_SCREEN_NAVIGATOR: 'FullScreenNavigator', diff --git a/src/ROUTES.ts b/src/ROUTES.ts index e8a860582bb15..75f92c76c4b7c 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -14,6 +14,8 @@ function getUrlWithBackToParam(url: TUrl, backTo?: string): const ROUTES = { HOME: '', + ALL_SETTINGS: 'all-settings', + // This is a utility route used to go to the user's concierge chat, or the sign-in page if the user's not authenticated CONCIERGE: 'concierge', FLAG_COMMENT: { @@ -55,7 +57,7 @@ const ROUTES = { route: 'bank-account/:stepToOpen?', getRoute: (stepToOpen = '', policyID = '', backTo?: string) => getUrlWithBackToParam(`bank-account/${stepToOpen}?policyID=${policyID}`, backTo), }, - + WORKSPACE_SWITCHER: 'workspaceSwitcher', SETTINGS: 'settings', SETTINGS_PROFILE: 'settings/profile', SETTINGS_SHARE_CODE: 'settings/shareCode', @@ -438,13 +440,17 @@ const ROUTES = { route: 'workspace/:policyID/invite-message', getRoute: (policyID: string) => `workspace/${policyID}/invite-message` as const, }, - WORKSPACE_SETTINGS: { - route: 'workspace/:policyID/settings', - getRoute: (policyID: string) => `workspace/${policyID}/settings` as const, + WORKSPACE_OVERVIEW: { + route: 'workspace/:policyID/overview', + getRoute: (policyID: string) => `workspace/${policyID}/overview` as const, + }, + WORKSPACE_OVERVIEW_CURRENCY: { + route: 'workspace/:policyID/overview/currency', + getRoute: (policyID: string) => `workspace/${policyID}/overview/currency` as const, }, - WORKSPACE_SETTINGS_CURRENCY: { - route: 'workspace/:policyID/settings/currency', - getRoute: (policyID: string) => `workspace/${policyID}/settings/currency` as const, + WORKSPACE_OVERVIEW_NAME: { + route: 'workspace/:policyID/overview/name', + getRoute: (policyID: string) => `workspace/${policyID}/overview/name` as const, }, WORKSPACE_CARD: { route: 'workspace/:policyID/card', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 703cb309d641b..d9e637d0f316e 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -12,11 +12,13 @@ const PROTECTED_SCREENS = { const SCREENS = { ...PROTECTED_SCREENS, + ALL_SETTINGS: 'AllSettings', REPORT: 'Report', NOT_FOUND: 'not-found', TRANSITION_BETWEEN_APPS: 'TransitionBetweenApps', VALIDATE_LOGIN: 'ValidateLogin', UNLINK_LOGIN: 'UnlinkLogin', + SETTINGS_CENTRAL_PANE: 'SettingsCentralPane', SETTINGS: { ROOT: 'Settings_Root', SHARE_CODE: 'Settings_Share_Code', @@ -83,6 +85,10 @@ const SCREENS = { }, LEFT_MODAL: { SEARCH: 'Search', + WORKSPACE_SWITCHER: 'WorkspaceSwitcher', + }, + WORKSPACE_SWITCHER: { + ROOT: 'WorkspaceSwitcher_Root', }, RIGHT_MODAL: { SETTINGS: 'Settings', @@ -192,7 +198,7 @@ const SCREENS = { WORKSPACE: { INITIAL: 'Workspace_Initial', - SETTINGS: 'Workspace_Settings', + OVERVIEW: 'Workspace_Overview', CARD: 'Workspace_Card', REIMBURSE: 'Workspace_Reimburse', RATE_AND_UNIT: 'Workspace_RateAndUnit', @@ -202,7 +208,8 @@ const SCREENS = { MEMBERS: 'Workspace_Members', INVITE: 'Workspace_Invite', INVITE_MESSAGE: 'Workspace_Invite_Message', - CURRENCY: 'Workspace_Settings_Currency', + CURRENCY: 'Workspace_Overview_Currency', + NAME: 'Workspace_Overview_Name', }, EDIT_REQUEST: { diff --git a/src/components/ActiveWorkspace/ActiveWorkspaceContext.tsx b/src/components/ActiveWorkspace/ActiveWorkspaceContext.tsx new file mode 100644 index 0000000000000..aefc4954f9219 --- /dev/null +++ b/src/components/ActiveWorkspace/ActiveWorkspaceContext.tsx @@ -0,0 +1,11 @@ +import {createContext} from 'react'; + +type ActiveWorkspaceContextType = { + activeWorkspaceID?: string; + setActiveWorkspaceID: (activeWorkspaceID?: string) => void; +} + +const ActiveWorkspaceContext = createContext({activeWorkspaceID: undefined, setActiveWorkspaceID: () => undefined}); + +export default ActiveWorkspaceContext; +export {type ActiveWorkspaceContextType}; \ No newline at end of file diff --git a/src/components/ActiveWorkspace/ActiveWorkspaceProvider.tsx b/src/components/ActiveWorkspace/ActiveWorkspaceProvider.tsx new file mode 100644 index 0000000000000..a602a6b602696 --- /dev/null +++ b/src/components/ActiveWorkspace/ActiveWorkspaceProvider.tsx @@ -0,0 +1,17 @@ +import React, {useMemo, useState} from 'react'; +import ActiveWorkspaceContext from './ActiveWorkspaceContext'; + +function ActiveWorkspaceContextProvider({children}: React.PropsWithChildren) { + const [activeWorkspaceID, setActiveWorkspaceID] = useState(undefined); + + const value = useMemo( + () => ({ + activeWorkspaceID, + setActiveWorkspaceID, + }), [activeWorkspaceID] + ) + + return {children}; +} + +export default ActiveWorkspaceContextProvider; \ No newline at end of file diff --git a/src/components/AvatarWithImagePicker.js b/src/components/AvatarWithImagePicker.js index 71193147c292e..86c74f4aa4f8f 100644 --- a/src/components/AvatarWithImagePicker.js +++ b/src/components/AvatarWithImagePicker.js @@ -95,6 +95,9 @@ const propTypes = { horizontal: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL)), vertical: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_VERTICAL)), }), + + /** Style applied to the avatar */ + avatarStyle: stylePropTypes.isRequired, }; const defaultProps = { @@ -142,6 +145,7 @@ function AvatarWithImagePicker({ anchorAlignment, onImageSelected, editorMaskImage, + avatarStyle, }) { const theme = useTheme(); const styles = useThemeStyles(); @@ -288,7 +292,7 @@ function AvatarWithImagePicker({ return ( - + {source ? ( ); } diff --git a/src/components/FloatingActionButton.tsx b/src/components/FloatingActionButton.tsx index 2e9996a92f873..3cffeda7c89ff 100644 --- a/src/components/FloatingActionButton.tsx +++ b/src/components/FloatingActionButton.tsx @@ -97,11 +97,10 @@ function FloatingActionButton({onPress, isActive, accessibilityLabel, role}: Flo return ( - + { fabPressable.current = el; - if (buttonRef && 'current' in buttonRef) { buttonRef.current = el; } diff --git a/src/components/HeaderPageLayout.tsx b/src/components/HeaderPageLayout.tsx index 304bb2ce49b16..6f7e66ba42134 100644 --- a/src/components/HeaderPageLayout.tsx +++ b/src/components/HeaderPageLayout.tsx @@ -36,8 +36,10 @@ type HeaderPageLayoutProps = ChildrenProps & /** Style to apply to the whole section container */ style?: StyleProp; - }; + /** Whether or not to show the offline indicator */ + shouldShowOfflineIndicator?: boolean; + }; function HeaderPageLayout({ backgroundColor, children, @@ -47,6 +49,7 @@ function HeaderPageLayout({ childrenContainerStyles, style, headerContent, + shouldShowOfflineIndicator = false, ...rest }: HeaderPageLayoutProps) { const theme = useTheme(); @@ -70,6 +73,7 @@ function HeaderPageLayout({ includeSafeAreaPaddingBottom={false} offlineIndicatorStyle={[appBGColor]} testID={HeaderPageLayout.displayName} + shouldShowOfflineIndicator={shouldShowOfflineIndicator} > {({safeAreaPaddingBottomStyle}) => ( <> diff --git a/src/components/HeaderWithBackButton/index.tsx b/src/components/HeaderWithBackButton/index.tsx index 209803f2a5d1d..1481c1030648b 100755 --- a/src/components/HeaderWithBackButton/index.tsx +++ b/src/components/HeaderWithBackButton/index.tsx @@ -70,7 +70,7 @@ function HeaderWithBackButton({ // Hover on some part of close icons will not work on Electron if dragArea is true // https://github.com/Expensify/App/issues/29598 dataSet={{dragArea: false}} - style={[styles.headerBar, shouldShowBorderBottom && styles.borderBottom, shouldShowBackButton && styles.pl2]} + style={[styles.headerBar, shouldShowBorderBottom && styles.borderBottom, shouldShowBackButton ? styles.pl2 : styles.pl5]} > {shouldShowBackButton && ( diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts index 2ddee8b2939b9..3a1f2926dab99 100644 --- a/src/components/Icon/Expensicons.ts +++ b/src/components/Icon/Expensicons.ts @@ -25,6 +25,7 @@ import Building from '@assets/images/building.svg'; import Calendar from '@assets/images/calendar.svg'; import Camera from '@assets/images/camera.svg'; import Car from '@assets/images/car.svg'; +import CardsAndDomains from '@assets/images/cards-and-domains.svg'; import Cash from '@assets/images/cash.svg'; import Chair from '@assets/images/chair.svg'; import ChatBubble from '@assets/images/chatbubble.svg'; @@ -51,6 +52,7 @@ import EReceiptIcon from '@assets/images/eReceiptIcon.svg'; import Exclamation from '@assets/images/exclamation.svg'; import Exit from '@assets/images/exit.svg'; import Expand from '@assets/images/expand.svg'; +import ExpensifyAppIcon from '@assets/images/expensify-app-icon.svg'; import ExpensifyCard from '@assets/images/expensify-card-icon.svg'; import ExpensifyFooterLogoVertical from '@assets/images/expensify-footer-logo-vertical.svg'; import ExpensifyFooterLogo from '@assets/images/expensify-footer-logo.svg'; @@ -67,6 +69,7 @@ import Globe from '@assets/images/globe.svg'; import Hashtag from '@assets/images/hashtag.svg'; import Heart from '@assets/images/heart.svg'; import History from '@assets/images/history.svg'; +import Home from '@assets/images/home.svg'; import Hourglass from '@assets/images/hourglass.svg'; import ImageCropCircleMask from '@assets/images/image-crop-circle-mask.svg'; import ImageCropSquareMask from '@assets/images/image-crop-square-mask.svg'; @@ -128,6 +131,7 @@ import User from '@assets/images/user.svg'; import Users from '@assets/images/users.svg'; import Wallet from '@assets/images/wallet.svg'; import Workspace from '@assets/images/workspace-default-avatar.svg'; +import Wrench from '@assets/images/wrench.svg'; import Zoom from '@assets/images/zoom.svg'; import LoungeAccess from './svgs/LoungeAccessIcon'; @@ -142,6 +146,7 @@ export { ArrowRight, ArrowRightLong, ArrowsUpDown, + Wrench, BackArrow, Bank, Bill, @@ -154,6 +159,7 @@ export { Calendar, Camera, Car, + CardsAndDomains, Cash, ChatBubble, ChatBubbles, @@ -182,6 +188,7 @@ export { EmptyStateAttachReceipt, Exclamation, Exit, + ExpensifyAppIcon, ExpensifyCard, ExpensifyWordmark, ExpensifyFooterLogo, @@ -202,6 +209,7 @@ export { Hashtag, Heart, History, + Home, Hourglass, ImageCropCircleMask, ImageCropSquareMask, diff --git a/src/components/Icon/index.tsx b/src/components/Icon/index.tsx index 20f3fd4a8acb3..d3214565ba467 100644 --- a/src/components/Icon/index.tsx +++ b/src/components/Icon/index.tsx @@ -104,4 +104,5 @@ function Icon({ Icon.displayName = 'Icon'; +export type {IconProps}; export default Icon; diff --git a/src/components/IllustratedHeaderPageLayout.tsx b/src/components/IllustratedHeaderPageLayout.tsx index 72ec0adf7672e..c40a4e33e67ad 100644 --- a/src/components/IllustratedHeaderPageLayout.tsx +++ b/src/components/IllustratedHeaderPageLayout.tsx @@ -21,6 +21,8 @@ type IllustratedHeaderPageLayoutProps = HeaderPageLayoutProps & { function IllustratedHeaderPageLayout({backgroundColor, children, illustration, overlayContent, ...rest}: IllustratedHeaderPageLayoutProps) { const theme = useTheme(); const styles = useThemeStyles(); + const shouldLimitHeight = !rest.shouldShowBackButton; + return ( {overlayContent?.()} } - headerContainerStyles={[styles.justifyContentCenter, styles.w100]} + headerContainerStyles={[styles.justifyContentCenter, styles.w100, shouldLimitHeight && styles.centralPaneAnimation]} // eslint-disable-next-line react/jsx-props-no-spreading {...rest} > diff --git a/src/components/MenuItem.tsx b/src/components/MenuItem.tsx index ce44db72598a8..b3ed858cbd622 100644 --- a/src/components/MenuItem.tsx +++ b/src/components/MenuItem.tsx @@ -223,6 +223,9 @@ type MenuItemProps = (IconProps | AvatarProps | NoIcon) & { /** Determines how the icon should be resized to fit its container */ contentFit?: ImageContentFit; + + /** Is this in the Pane */ + isPaneMenu?: boolean; }; function MenuItem( @@ -285,6 +288,7 @@ function MenuItem( titleWithTooltips, displayInDefaultIconColor = false, contentFit = 'cover', + isPaneMenu = false, }: MenuItemProps, ref: ForwardedRef, ) { @@ -435,7 +439,8 @@ function MenuItem( fill={ displayInDefaultIconColor ? undefined - : iconFill ?? StyleUtils.getIconFillColor(getButtonState(focused || isHovered, pressed, success, disabled, interactive), true) + : iconFill ?? + StyleUtils.getIconFillColor(getButtonState(focused || isHovered, pressed, success, disabled, interactive), true, isPaneMenu) } /> )} diff --git a/src/components/MultipleAvatars.tsx b/src/components/MultipleAvatars.tsx index a6f34cd459fcc..8ab26595b9824 100644 --- a/src/components/MultipleAvatars.tsx +++ b/src/components/MultipleAvatars.tsx @@ -153,6 +153,7 @@ function MultipleAvatars({ )} + {option.brickRoadIndicator === CONST.BRICK_ROAD_INDICATOR_STATUS.INFO && ( + + + + )} {showSelectedState && ( <> {shouldShowSelectedStateAsButton && !isSelected ? ( diff --git a/src/components/OptionsList/BaseOptionsList.js b/src/components/OptionsList/BaseOptionsList.js index bd3695eb7aa92..0441cdf3b7095 100644 --- a/src/components/OptionsList/BaseOptionsList.js +++ b/src/components/OptionsList/BaseOptionsList.js @@ -191,6 +191,10 @@ function BaseOptionsList({ return true; } + if (option.policyID && option.policyID === item.policyID) { + return true; + } + if (_.isEmpty(option.name)) { return false; } @@ -201,7 +205,7 @@ function BaseOptionsList({ return ( 0 && shouldHaveOptionSeparator} shouldDisableRowInnerPadding={shouldDisableRowInnerPadding} diff --git a/src/components/OptionsSelector/BaseOptionsSelector.js b/src/components/OptionsSelector/BaseOptionsSelector.js index 792073b72613c..3611922729d96 100755 --- a/src/components/OptionsSelector/BaseOptionsSelector.js +++ b/src/components/OptionsSelector/BaseOptionsSelector.js @@ -8,7 +8,7 @@ import Button from '@components/Button'; import FixedFooter from '@components/FixedFooter'; import FormHelpMessage from '@components/FormHelpMessage'; import Icon from '@components/Icon'; -import {Info} from '@components/Icon/Expensicons'; +import {Info, MagnifyingGlass} from '@components/Icon/Expensicons'; import OptionsList from '@components/OptionsList'; import {PressableWithoutFeedback} from '@components/Pressable'; import ShowMoreButton from '@components/ShowMoreButton'; @@ -492,6 +492,7 @@ class BaseOptionsSelector extends Component { spellCheck={false} shouldInterceptSwipe={this.props.shouldTextInputInterceptSwipe} isLoading={this.props.isLoadingNewOptions} + iconLeft={MagnifyingGlass} testID="options-selector-input" /> ); @@ -502,6 +503,7 @@ class BaseOptionsSelector extends Component { onSelectRow={this.props.onSelectRow ? this.selectRow : undefined} sections={this.state.sections} focusedIndex={this.state.focusedIndex} + disableFocusOptions={this.props.disableFocusOptions} selectedOptions={this.props.selectedOptions} canSelectMultipleOptions={this.props.canSelectMultipleOptions} shouldShowMultipleOptionSelectorAsButton={this.props.shouldShowMultipleOptionSelectorAsButton} diff --git a/src/components/OptionsSelector/optionsSelectorPropTypes.js b/src/components/OptionsSelector/optionsSelectorPropTypes.js index e52187fa76d78..847409c702698 100644 --- a/src/components/OptionsSelector/optionsSelectorPropTypes.js +++ b/src/components/OptionsSelector/optionsSelectorPropTypes.js @@ -72,6 +72,9 @@ const propTypes = { /** Whether to disable interactivity of option rows */ isDisabled: PropTypes.bool, + /** Whether to disable focus options of rows */ + disableFocusOptions: PropTypes.bool, + /** Display the text of the option in bold font style */ boldStyle: PropTypes.bool, @@ -163,6 +166,7 @@ const defaultProps = { shouldShowOptions: true, disableArrowKeysActions: false, isDisabled: false, + disableFocusOptions: false, shouldHaveOptionSeparator: false, initiallyFocusedOptionKey: undefined, maxLength: CONST.SEARCH_MAX_LENGTH, diff --git a/src/components/QRShare/index.tsx b/src/components/QRShare/index.tsx index c7e9e7637a6c9..45a4a4fd4964c 100644 --- a/src/components/QRShare/index.tsx +++ b/src/components/QRShare/index.tsx @@ -9,12 +9,15 @@ import QRCode from '@components/QRCode'; import Text from '@components/Text'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; import variables from '@styles/variables'; +import CONST from '@src/CONST'; import type {QRShareHandle, QRShareProps} from './types'; function QRShare({url, title, subtitle, logo, logoRatio, logoMarginRatio}: QRShareProps, ref: ForwardedRef) { const styles = useThemeStyles(); const theme = useTheme(); + const {isSmallScreenWidth} = useWindowDimensions(); const [qrCodeSize, setQrCodeSize] = useState(1); const svgRef = useRef(); @@ -29,7 +32,11 @@ function QRShare({url, title, subtitle, logo, logoRatio, logoMarginRatio}: QRSha const onLayout = (event: LayoutChangeEvent) => { const containerWidth = event.nativeEvent.layout.width - variables.qrShareHorizontalPadding * 2 || 0; - setQrCodeSize(Math.max(1, containerWidth)); + if (isSmallScreenWidth) { + setQrCodeSize(Math.max(1, containerWidth)); + return; + } + setQrCodeSize(Math.max(1, Math.min(containerWidth, CONST.CENTRAL_PANE_ANIMATION_HEIGHT))); }; return ( diff --git a/src/components/ReimbursementAccountLoadingIndicator.js b/src/components/ReimbursementAccountLoadingIndicator.js index bc0e70e644198..5dbace45966f3 100644 --- a/src/components/ReimbursementAccountLoadingIndicator.js +++ b/src/components/ReimbursementAccountLoadingIndicator.js @@ -24,7 +24,7 @@ function ReimbursementAccountLoadingIndicator(props) { const {translate} = useLocalize(); return ( diff --git a/src/components/ScreenWrapper.tsx b/src/components/ScreenWrapper.tsx index 0653e2ff85777..5c96b4ad61686 100644 --- a/src/components/ScreenWrapper.tsx +++ b/src/components/ScreenWrapper.tsx @@ -77,7 +77,7 @@ type ScreenWrapperProps = { shouldEnableMinHeight?: boolean; /** Whether to show offline indicator */ - shouldShowOfflineIndicator?: boolean; + shouldShowOfflineIndicatorSmallWidth?: boolean; /** * The navigation prop is passed by the navigator. It is used to trigger the onEntryTransitionEnd callback @@ -86,6 +86,9 @@ type ScreenWrapperProps = { * This is required because transitionEnd event doesn't trigger in the testing environment. */ navigation?: StackNavigationProp; + + /** Is central pane */ + shouldShowOfflineIndicator?: boolean; }; function ScreenWrapper( @@ -99,13 +102,14 @@ function ScreenWrapper( shouldEnablePickerAvoiding = true, headerGapStyles, children, - shouldShowOfflineIndicator = true, + shouldShowOfflineIndicatorSmallWidth = true, offlineIndicatorStyle, style, shouldDismissKeyboardBeforeClose = true, onEntryTransitionEnd, testID, navigation: navigationProp, + shouldShowOfflineIndicator = false, }: ScreenWrapperProps, ref: ForwardedRef, ) { @@ -198,7 +202,7 @@ function ScreenWrapper( } // We always need the safe area padding bottom if we're showing the offline indicator since it is bottom-docked. - if (includeSafeAreaPaddingBottom || (isOffline && shouldShowOfflineIndicator)) { + if (includeSafeAreaPaddingBottom || (isOffline && shouldShowOfflineIndicatorSmallWidth)) { paddingStyle.paddingBottom = paddingBottom; } @@ -237,7 +241,13 @@ function ScreenWrapper( }) : children } - {isSmallScreenWidth && shouldShowOfflineIndicator && } + {isSmallScreenWidth && shouldShowOfflineIndicatorSmallWidth && } + {!isSmallScreenWidth && shouldShowOfflineIndicator && ( + + )} diff --git a/src/components/Search.tsx b/src/components/Search.tsx index 0b02c5dc5b5b5..38135fd2631e8 100644 --- a/src/components/Search.tsx +++ b/src/components/Search.tsx @@ -2,6 +2,7 @@ import React from 'react'; import type {GestureResponderEvent, StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; import useLocalize from '@hooks/useLocalize'; +import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import variables from '@styles/variables'; import CONST from '@src/CONST'; @@ -23,37 +24,44 @@ type SearchProps = { // Styles to apply on the outer element style?: StyleProp; + + /** Styles to apply to the outermost element */ + containerStyle?: StyleProp; }; -function Search({onPress, placeholder, tooltip, style}: SearchProps) { +function Search({onPress, placeholder, tooltip, style, containerStyle}: SearchProps) { const styles = useThemeStyles(); + const theme = useTheme(); const {translate} = useLocalize(); return ( - - - {({hovered}) => ( - - - - {placeholder ?? translate('common.searchWithThreeDots')} - - - )} - - + + + + {({hovered}) => ( + + + + {placeholder ?? translate('common.searchWithThreeDots')} + + + )} + + + ); } diff --git a/src/components/Section/index.tsx b/src/components/Section/index.tsx index f24316a5f1bb2..e7d27569626e3 100644 --- a/src/components/Section/index.tsx +++ b/src/components/Section/index.tsx @@ -6,6 +6,7 @@ import type {MenuItemWithLink} from '@components/MenuItemList'; import MenuItemList from '@components/MenuItemList'; import Text from '@components/Text'; import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; import type IconAsset from '@src/types/utils/IconAsset'; import IconSection from './IconSection'; @@ -65,10 +66,11 @@ function Section({ titleStyles, }: SectionProps) { const styles = useThemeStyles(); + const {isSmallScreenWidth} = useWindowDimensions(); return ( <> - + {cardLayout === CARD_LAYOUT.ICON_ON_TOP && ( ; /** Background color used for subscript avatar border */ backgroundColor?: string; + /** Subscript avatar URL or icon */ + secondaryAvatar?: SubAvatar; + + /** Subscript icon */ + subscriptIcon?: SubIcon; + /** Removes margin from around the avatar, used for the chat view */ noMargin?: boolean; @@ -47,7 +63,7 @@ type SubscriptAvatarProps = { showTooltip?: boolean; }; -function SubscriptAvatar({mainAvatar = {}, secondaryAvatar = {}, size = CONST.AVATAR_SIZE.DEFAULT, backgroundColor, noMargin = false, showTooltip = true}: SubscriptAvatarProps) { +function SubscriptAvatar({mainAvatar = {}, secondaryAvatar, subscriptIcon, size = CONST.AVATAR_SIZE.DEFAULT, backgroundColor, noMargin = false, showTooltip = true}: SubscriptAvatarProps) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -73,30 +89,59 @@ function SubscriptAvatar({mainAvatar = {}, secondaryAvatar = {}, size = CONST.AV /> - + {secondaryAvatar && ( + + + + + + )} + {subscriptIcon && ( - - + )} ); } diff --git a/src/components/TextInput/BaseTextInput/index.tsx b/src/components/TextInput/BaseTextInput/index.tsx index 9c3899979aaa2..02e4476975a3e 100644 --- a/src/components/TextInput/BaseTextInput/index.tsx +++ b/src/components/TextInput/BaseTextInput/index.tsx @@ -36,6 +36,7 @@ function BaseTextInput( placeholder = '', errorText = '', icon = null, + iconLeft = null, textInputContainerStyles, touchableInputWrapperStyle, containerStyles, @@ -317,6 +318,16 @@ function BaseTextInput( ) : null} + {!inputProps.secureTextEntry && iconLeft && ( + + + + )} {Boolean(prefixCharacter) && ( ; diff --git a/src/components/WalletSection.tsx b/src/components/WalletSection.tsx deleted file mode 100644 index 0c966367be450..0000000000000 --- a/src/components/WalletSection.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import React from 'react'; -import useThemeStyles from '@hooks/useThemeStyles'; -import type ChildrenProps from '@src/types/utils/ChildrenProps'; -import type IconAsset from '@src/types/utils/IconAsset'; -import Section from './Section'; - -type WalletSectionProps = ChildrenProps & { - /** The icon to display along with the title */ - icon: IconAsset; - - /** The text to display in the subtitle of the section */ - subtitle: string; - - /** The text to display in the title of the section */ - title: string; -}; - -function WalletSection({children, icon, subtitle, title}: WalletSectionProps) { - const styles = useThemeStyles(); - return ( -
- {children} -
- ); -} - -WalletSection.displayName = 'WalletSection'; - -export default WalletSection; diff --git a/src/components/WorkspaceSwitcherButton.tsx b/src/components/WorkspaceSwitcherButton.tsx new file mode 100644 index 0000000000000..6decdfedc032a --- /dev/null +++ b/src/components/WorkspaceSwitcherButton.tsx @@ -0,0 +1,54 @@ +import React, { useMemo } from 'react'; +import useLocalize from '@hooks/useLocalize'; +import CONST from '@src/CONST'; +import Navigation from '@libs/Navigation/Navigation'; +import ROUTES from '@src/ROUTES'; +import useActiveWorkspace from '@hooks/useActiveWorkspace'; +import {getPolicy, getDefaultWorkspaceAvatar} from "@libs/ReportUtils" +import * as Expensicons from './Icon/Expensicons'; +import {PressableWithFeedback} from './Pressable'; +import SubscriptAvatar from './SubscriptAvatar'; + +function WorkspaceSwitcherButton() { + const {translate} = useLocalize(); + const {activeWorkspaceID} = useActiveWorkspace(); + + + const {source, name, type} = useMemo(() => { + + if(!activeWorkspaceID) { + return {source: Expensicons.ExpensifyAppIcon, name: CONST.WORKSPACE_SWITCHER.NAME, type: CONST.ICON_TYPE_AVATAR} + } + + const policy = getPolicy(activeWorkspaceID); + const avatar = policy?.avatar && policy?.avatar?.length > 0 ? policy.avatar : getDefaultWorkspaceAvatar(policy?.name); + return { + source: avatar, + name: policy?.name, + type: CONST.ICON_TYPE_WORKSPACE, + } + }, [activeWorkspaceID]); + + + return ( + { + Navigation.navigate(ROUTES.WORKSPACE_SWITCHER); + }} + > + + + ); +} + +WorkspaceSwitcherButton.displayName = 'WorkspaceSwitcherButton'; + +export default WorkspaceSwitcherButton; diff --git a/src/components/menuItemPropTypes.js b/src/components/menuItemPropTypes.js index 83d2feca7a0a2..3b21cd13c8484 100644 --- a/src/components/menuItemPropTypes.js +++ b/src/components/menuItemPropTypes.js @@ -169,6 +169,9 @@ const propTypes = { /** Icon should be displayed in its own color */ displayInDefaultIconColor: PropTypes.bool, + + /** Is this menu item in the settings pane */ + isPaneMenu: PropTypes.bool, }; export default propTypes; diff --git a/src/hooks/useActiveRoute.ts b/src/hooks/useActiveRoute.ts new file mode 100644 index 0000000000000..651d00a0c37c8 --- /dev/null +++ b/src/hooks/useActiveRoute.ts @@ -0,0 +1,8 @@ +import {useContext} from 'react'; +import ActiveRouteContext from '@libs/Navigation/AppNavigator/Navigators/ActiveRouteContext'; + +function useActiveRoute(): string { + return useContext(ActiveRouteContext); +} + +export default useActiveRoute; diff --git a/src/hooks/useActiveWorkspace.ts b/src/hooks/useActiveWorkspace.ts new file mode 100644 index 0000000000000..b1451e7beb760 --- /dev/null +++ b/src/hooks/useActiveWorkspace.ts @@ -0,0 +1,9 @@ +import {useContext} from 'react'; +import ActiveWorkspaceContext from '@components/ActiveWorkspace/ActiveWorkspaceContext'; +import type { ActiveWorkspaceContextType } from '@components/ActiveWorkspace/ActiveWorkspaceContext'; + +function useActiveWorkspace(): ActiveWorkspaceContextType { + return useContext(ActiveWorkspaceContext); +} + +export default useActiveWorkspace; diff --git a/src/languages/en.ts b/src/languages/en.ts index c57b1ce310b58..02196a28a0353 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -136,6 +136,7 @@ export default { magicCode: 'Magic code', twoFactorCode: 'Two-factor code', workspaces: 'Workspaces', + chats: 'Chats', profile: 'Profile', referral: 'Referral', payments: 'Payments', @@ -530,6 +531,10 @@ export default { listOfChats: 'List of chats', saveTheWorld: 'Save the world', }, + allSettingsScreen: { + subscriptions: 'Subscriptions', + cardsAndDomains: 'Cards & Domains', + }, tabSelector: { chat: 'Chat', room: 'Room', @@ -801,6 +806,9 @@ export default { phrase4: 'Privacy', }, help: 'Help', + accountSettings: 'Account Settings', + account: 'Account', + general: 'General', }, closeAccountPage: { closeAccount: 'Close account', @@ -1510,6 +1518,7 @@ export default { travel: 'Travel', members: 'Members', plan: 'Plan', + overview: 'Overview', bankAccount: 'Bank account', connectBankAccount: 'Connect bank account', testTransactions: 'Test transactions', @@ -1521,6 +1530,9 @@ export default { memberNotFound: 'Member not found. To invite a new member to the workspace, please use the Invite button above.', notAuthorized: `You do not have access to this page. Are you trying to join the workspace? Please reach out to the owner of this workspace so they can add you as a member! Something else? Reach out to ${CONST.EMAIL.CONCIERGE}`, goToRoom: ({roomName}: GoToRoomParams) => `Go to ${roomName} room`, + workspaceName: 'Workspace name', + workspaceOwner: 'Owner', + workspaceType: 'Workspace type', workspaceAvatar: 'Workspace avatar', mustBeOnlineToViewMembers: 'You must be online in order to view members of this workspace.', }, @@ -1531,7 +1543,7 @@ export default { }, emptyWorkspace: { title: 'Create a workspace', - subtitle: 'Manage business expenses, issue cards, send invoices, and more.', + subtitle: 'Workspaces are where you’ll chat with your team, reimburse expenses, issue cards, send invoices, pay bills, and more - all in one place.', createAWorkspaceCTA: 'Get Started', features: { trackAndCollect: 'Track and collect receipts', @@ -1541,6 +1553,10 @@ export default { notFound: 'No workspace found', description: 'Rooms are a great place to discuss and work with multiple people. To begin collaborating, create or join a workspace', }, + switcher: { + headerTitle: 'Choose a workspace', + everythingSection: 'Everything', + }, new: { newWorkspace: 'New workspace', getTheExpensifyCardAndMore: 'Get the Expensify Card and more', diff --git a/src/languages/es.ts b/src/languages/es.ts index b83385b602c8b..fffcf6a23aa01 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -126,6 +126,7 @@ export default { magicCode: 'Código mágico', twoFactorCode: 'Autenticación de dos factores', workspaces: 'Espacios de trabajo', + chats: 'Chats', profile: 'Perfil', referral: 'Remisión', payments: 'Pagos', @@ -523,6 +524,10 @@ export default { listOfChats: 'lista de chats', saveTheWorld: 'Salvar el mundo', }, + allSettingsScreen: { + subscriptions: 'Suscripciones', + cardsAndDomains: 'Tarjetas y Dominios', + }, tabSelector: { chat: 'Chat', room: 'Sala', @@ -796,6 +801,9 @@ export default { phrase4: 'Privacidad', }, help: 'Ayuda', + accountSettings: 'Configuración de la cuenta', + account: 'Cuenta', + general: 'General', }, closeAccountPage: { closeAccount: 'Cerrar cuenta', @@ -1533,6 +1541,7 @@ export default { travel: 'Viajes', members: 'Miembros', plan: 'Plan', + overview: 'Descripción', bankAccount: 'Cuenta bancaria', connectBankAccount: 'Conectar cuenta bancaria', testTransactions: 'Transacciones de prueba', @@ -1544,6 +1553,9 @@ export default { memberNotFound: 'Miembro no encontrado. Para invitar a un nuevo miembro al espacio de trabajo, por favor, utiliza el botón Invitar que está arriba.', notAuthorized: `No tienes acceso a esta página. ¿Estás tratando de unirte al espacio de trabajo? Comunícate con el propietario de este espacio de trabajo para que pueda añadirte como miembro. ¿Necesitas algo más? Comunícate con ${CONST.EMAIL.CONCIERGE}`, goToRoom: ({roomName}: GoToRoomParams) => `Ir a la sala ${roomName}`, + workspaceName: 'Nombre del espacio de trabajo', + workspaceOwner: 'Dueño', + workspaceType: 'Tipo de espacio de trabajo', workspaceAvatar: 'Espacio de trabajo avatar', mustBeOnlineToViewMembers: 'Debes estar en línea para poder ver los miembros de este espacio de trabajo.', }, @@ -1564,6 +1576,10 @@ export default { notFound: 'No se encontró ningún espacio de trabajo', description: 'Las salas son un gran lugar para discutir y trabajar con varias personas. Para comenzar a colaborar, cree o únase a un espacio de trabajo', }, + switcher: { + headerTitle: 'Elige un espacio de trabajo', + everythingSection: 'Todo', + }, new: { newWorkspace: 'Nuevo espacio de trabajo', getTheExpensifyCardAndMore: 'Consigue la Tarjeta Expensify y más', diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx index 7286615e6ba69..f422af8dcfbc1 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx @@ -8,11 +8,11 @@ import useWindowDimensions from '@hooks/useWindowDimensions'; import KeyboardShortcut from '@libs/KeyboardShortcut'; import getCurrentUrl from '@libs/Navigation/currentUrl'; import Navigation from '@libs/Navigation/Navigation'; +import type {AuthScreensParamList} from '@libs/Navigation/types'; import NetworkConnection from '@libs/NetworkConnection'; import * as Pusher from '@libs/Pusher/pusher'; import PusherConnectionManager from '@libs/PusherConnectionManager'; import * as SessionUtils from '@libs/SessionUtils'; -import type {AuthScreensParamList} from '@navigation/types'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import DesktopSignInRedirectPage from '@pages/signin/DesktopSignInRedirectPage'; import SearchInputManager from '@pages/workspace/SearchInputManager'; @@ -36,7 +36,9 @@ import type {SelectedTimezone, Timezone} from '@src/types/onyx/PersonalDetails'; import createCustomStackNavigator from './createCustomStackNavigator'; import defaultScreenOptions from './defaultScreenOptions'; import getRootNavigatorScreenOptions from './getRootNavigatorScreenOptions'; +import BottomTabNavigator from './Navigators/BottomTabNavigator'; import CentralPaneNavigator from './Navigators/CentralPaneNavigator'; +import FullScreenNavigator from './Navigators/FullScreenNavigator'; import LeftModalNavigator from './Navigators/LeftModalNavigator'; import RightModalNavigator from './Navigators/RightModalNavigator'; @@ -55,7 +57,6 @@ type AuthScreensProps = { }; const loadReportAttachments = () => require('../../../pages/home/report/ReportAttachments').default as React.ComponentType; -const loadSidebarScreen = () => require('../../../pages/home/sidebar/SidebarScreen').default as React.ComponentType; const loadValidateLoginPage = () => require('../../../pages/ValidateLoginPage').default as React.ComponentType; const loadLogOutPreviousUserPage = () => require('../../../pages/LogOutPreviousUserPage').default as React.ComponentType; const loadConciergePage = () => require('../../../pages/ConciergePage').default as React.ComponentType; @@ -251,9 +252,9 @@ function AuthScreens({lastUpdateIDAppliedToClient, session, lastOpenedPublicRoom + React.ComponentType>>; @@ -40,7 +42,7 @@ type Screens = Partial React.ComponentType>>; * * @param screens key/value pairs where the key is the name of the screen and the value is a functon that returns the lazy-loaded component */ -function createModalStackNavigator(screens: Screens): React.ComponentType { +function createModalStackNavigator(screens: Screens, getScreenOptions?: (styles: ThemeStyles) => StackNavigationOptions): React.ComponentType { const ModalStackNavigator = createStackNavigator(); function ModalStack() { @@ -56,7 +58,7 @@ function createModalStackNavigator(screens: ); return ( - + {Object.keys(screens as Required).map((name) => ( require('../../../pages/TeachersUnite/ImTeacherPage').default as React.ComponentType, }); +const AccountSettingsModalStackNavigator = createModalStackNavigator( + { + [SCREENS.SETTINGS.ROOT]: () => require('../../../pages/settings/InitialSettingsPage').default as React.ComponentType, + [SCREENS.SETTINGS.WORKSPACES]: () => require('../../../pages/workspace/WorkspacesListPage').default as React.ComponentType, + [SCREENS.SETTINGS.PREFERENCES.ROOT]: () => require('../../../pages/settings/Preferences/PreferencesPage').default as React.ComponentType, + [SCREENS.SETTINGS.SECURITY]: () => require('../../../pages/settings/Security/SecuritySettingsPage').default as React.ComponentType, + [SCREENS.SETTINGS.PROFILE.ROOT]: () => require('../../../pages/settings/Profile/ProfilePage').default as React.ComponentType, + [SCREENS.SETTINGS.SHARE_CODE]: () => require('../../../pages/ShareCodePage').default as React.ComponentType, + [SCREENS.SETTINGS.WALLET.ROOT]: () => require('../../../pages/settings/Wallet/WalletPage').default as React.ComponentType, + [SCREENS.SETTINGS.ABOUT]: () => require('../../../pages/settings/AboutPage/AboutPage').default as React.ComponentType, + }, + (styles) => ({cardStyle: styles.navigationScreenCardStyle, headerShown: false}), +); +const WorkspaceSwitcherModalStackNavigator = createModalStackNavigator({ + [SCREENS.WORKSPACE_SWITCHER.ROOT]: () => require('../../../pages/WorkspaceSwitcherPage').default as React.ComponentType, +}); + const SettingsModalStackNavigator = createModalStackNavigator({ - [SCREENS.SETTINGS.ROOT]: () => require('../../../pages/settings/InitialSettingsPage').default as React.ComponentType, [SCREENS.SETTINGS.SHARE_CODE]: () => require('../../../pages/ShareCodePage').default as React.ComponentType, - [SCREENS.SETTINGS.WORKSPACES]: () => require('../../../pages/workspace/WorkspacesListPage').default as React.ComponentType, [SCREENS.SETTINGS.PROFILE.ROOT]: () => require('../../../pages/settings/Profile/ProfilePage').default as React.ComponentType, [SCREENS.SETTINGS.PROFILE.PRONOUNS]: () => require('../../../pages/settings/Profile/PronounsPage').default as React.ComponentType, [SCREENS.SETTINGS.PROFILE.DISPLAY_NAME]: () => require('../../../pages/settings/Profile/DisplayNamePage').default as React.ComponentType, @@ -226,17 +243,13 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../pages/settings/Profile/CustomStatus/SetDatePage').default as React.ComponentType, [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_TIME]: () => require('../../../pages/settings/Profile/CustomStatus/SetTimePage').default as React.ComponentType, [SCREENS.WORKSPACE.INITIAL]: () => require('../../../pages/workspace/WorkspaceInitialPage').default as React.ComponentType, - [SCREENS.WORKSPACE.SETTINGS]: () => require('../../../pages/workspace/WorkspaceSettingsPage').default as React.ComponentType, - [SCREENS.WORKSPACE.CURRENCY]: () => require('../../../pages/workspace/WorkspaceSettingsCurrencyPage').default as React.ComponentType, [SCREENS.WORKSPACE.CARD]: () => require('../../../pages/workspace/card/WorkspaceCardPage').default as React.ComponentType, [SCREENS.WORKSPACE.REIMBURSE]: () => require('../../../pages/workspace/reimburse/WorkspaceReimbursePage').default as React.ComponentType, [SCREENS.WORKSPACE.RATE_AND_UNIT]: () => require('../../../pages/workspace/reimburse/WorkspaceRateAndUnitPage').default as React.ComponentType, - [SCREENS.WORKSPACE.BILLS]: () => require('../../../pages/workspace/bills/WorkspaceBillsPage').default as React.ComponentType, - [SCREENS.WORKSPACE.INVOICES]: () => require('../../../pages/workspace/invoices/WorkspaceInvoicesPage').default as React.ComponentType, - [SCREENS.WORKSPACE.TRAVEL]: () => require('../../../pages/workspace/travel/WorkspaceTravelPage').default as React.ComponentType, - [SCREENS.WORKSPACE.MEMBERS]: () => require('../../../pages/workspace/WorkspaceMembersPage').default as React.ComponentType, [SCREENS.WORKSPACE.INVITE]: () => require('../../../pages/workspace/WorkspaceInvitePage').default as React.ComponentType, [SCREENS.WORKSPACE.INVITE_MESSAGE]: () => require('../../../pages/workspace/WorkspaceInviteMessagePage').default as React.ComponentType, + [SCREENS.WORKSPACE.NAME]: () => require('../../../pages/workspace/WorkspaceNamePage').default as React.ComponentType, + [SCREENS.WORKSPACE.CURRENCY]: () => require('../../../pages/workspace/WorkspaceOverviewCurrencyPage').default as React.ComponentType, [SCREENS.REIMBURSEMENT_ACCOUNT]: () => require('../../../pages/ReimbursementAccount/ReimbursementAccountPage').default as React.ComponentType, [SCREENS.GET_ASSISTANCE]: () => require('../../../pages/GetAssistancePage').default as React.ComponentType, [SCREENS.SETTINGS.TWO_FACTOR_AUTH]: () => require('../../../pages/settings/Security/TwoFactorAuth/TwoFactorAuthPage').default as React.ComponentType, @@ -288,30 +301,32 @@ const ProcessMoneyRequestHoldStackNavigator = createModalStackNavigator({ }); export { - MoneyRequestModalStackNavigator, - SplitDetailsModalStackNavigator, + AccountSettingsModalStackNavigator, + AddPersonalBankAccountModalStackNavigator, DetailsModalStackNavigator, + EditRequestStackNavigator, + EnablePaymentsStackNavigator, + FlagCommentStackNavigator, + MoneyRequestModalStackNavigator, + NewChatModalStackNavigator, + NewTaskModalStackNavigator, + NewTeachersUniteNavigator, + PrivateNotesModalStackNavigator, ProfileModalStackNavigator, + ReferralModalStackNavigator, + WorkspaceSwitcherModalStackNavigator, + ReimbursementAccountModalStackNavigator, ReportDetailsModalStackNavigator, - TaskModalStackNavigator, + ReportParticipantsModalStackNavigator, ReportSettingsModalStackNavigator, ReportWelcomeMessageModalStackNavigator, - ReportParticipantsModalStackNavigator, + RoomInviteModalStackNavigator, + RoomMembersModalStackNavigator, SearchModalStackNavigator, - NewChatModalStackNavigator, - NewTaskModalStackNavigator, SettingsModalStackNavigator, - EnablePaymentsStackNavigator, - AddPersonalBankAccountModalStackNavigator, - ReimbursementAccountModalStackNavigator, - WalletStatementStackNavigator, - FlagCommentStackNavigator, - EditRequestStackNavigator, - PrivateNotesModalStackNavigator, - NewTeachersUniteNavigator, SignInModalStackNavigator, - RoomMembersModalStackNavigator, - RoomInviteModalStackNavigator, - ReferralModalStackNavigator, + SplitDetailsModalStackNavigator, + TaskModalStackNavigator, + WalletStatementStackNavigator, ProcessMoneyRequestHoldStackNavigator, }; diff --git a/src/libs/Navigation/AppNavigator/Navigators/ActiveRouteContext.ts b/src/libs/Navigation/AppNavigator/Navigators/ActiveRouteContext.ts new file mode 100644 index 0000000000000..d1d14d43af1a0 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/ActiveRouteContext.ts @@ -0,0 +1,5 @@ +import React from 'react'; + +const ActiveRouteContext = React.createContext(''); + +export default ActiveRouteContext; diff --git a/src/libs/Navigation/AppNavigator/Navigators/BottomTabNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/BottomTabNavigator.tsx new file mode 100644 index 0000000000000..e8d1d0c99de1e --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/BottomTabNavigator.tsx @@ -0,0 +1,44 @@ +import {useNavigationState} from '@react-navigation/native'; +import type {StackNavigationOptions} from '@react-navigation/stack'; +import React from 'react'; +import createCustomBottomTabNavigator from '@libs/Navigation/AppNavigator/createCustomBottomTabNavigator'; +import getTopmostCentralPaneRoute from '@libs/Navigation/getTopmostCentralPaneRoute'; +import type {BottomTabNavigatorParamList} from '@libs/Navigation/types'; +import AllSettingsScreen from '@pages/home/sidebar/AllSettingsScreen'; +import SidebarScreen from '@pages/home/sidebar/SidebarScreen'; +import SCREENS from '@src/SCREENS'; +import ActiveRouteContext from './ActiveRouteContext'; + +const loadWorkspaceInitialPage = () => require('../../../../pages/workspace/WorkspaceInitialPage').default as React.ComponentType; + +const Tab = createCustomBottomTabNavigator(); + +const screenOptions: StackNavigationOptions = { + headerShown: false, +}; + +function BottomTabNavigator() { + const activeRoute = useNavigationState(getTopmostCentralPaneRoute); + return ( + + + + + + + + ); +} + +BottomTabNavigator.displayName = 'BottomTabNavigator'; + +export default BottomTabNavigator; diff --git a/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/BaseCentralPaneNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/BaseCentralPaneNavigator.tsx index f2cdd140f7d8e..53928b71be4e8 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/BaseCentralPaneNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/BaseCentralPaneNavigator.tsx @@ -11,23 +11,44 @@ const Stack = createStackNavigator(); const url = getCurrentUrl(); const openOnAdminRoom = url ? new URL(url).searchParams.get('openOnAdminRoom') : undefined; +type Screens = Partial React.ComponentType>>; + +const workspaceSettingsScreens = { + [SCREENS.SETTINGS.WORKSPACES]: () => require('../../../../../pages/workspace/WorkspacesListPage').default as React.ComponentType, + [SCREENS.WORKSPACE.OVERVIEW]: () => require('../../../../../pages/workspace/WorkspaceOverviewPage').default as React.ComponentType, + [SCREENS.WORKSPACE.CARD]: () => require('../../../../../pages/workspace/card/WorkspaceCardPage').default as React.ComponentType, + [SCREENS.WORKSPACE.REIMBURSE]: () => require('../../../../../pages/workspace/reimburse/WorkspaceReimbursePage').default as React.ComponentType, + [SCREENS.WORKSPACE.BILLS]: () => require('../../../../../pages/workspace/bills/WorkspaceBillsPage').default as React.ComponentType, + [SCREENS.WORKSPACE.INVOICES]: () => require('../../../../../pages/workspace/invoices/WorkspaceInvoicesPage').default as React.ComponentType, + [SCREENS.WORKSPACE.TRAVEL]: () => require('../../../../../pages/workspace/travel/WorkspaceTravelPage').default as React.ComponentType, + [SCREENS.WORKSPACE.MEMBERS]: () => require('../../../../../pages/workspace/WorkspaceMembersPage').default as React.ComponentType, +} satisfies Screens; + function BaseCentralPaneNavigator() { const styles = useThemeStyles(); + const options = { + headerShown: false, + title: 'New Expensify', + + // Prevent unnecessary scrolling + cardStyle: styles.cardStyleNavigator, + }; return ( - + + + {Object.entries(workspaceSettingsScreens).map(([screenName, componentGetter]) => ( + + ))} ); } diff --git a/src/libs/Navigation/AppNavigator/Navigators/FullScreenNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/FullScreenNavigator.tsx new file mode 100644 index 0000000000000..4fcf2a114c9ca --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/FullScreenNavigator.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import {View} from 'react-native'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; +import createCustomFullScreenNavigator from '@libs/Navigation/AppNavigator/createCustomFullScreenNavigator'; +import getRootNavigatorScreenOptions from '@libs/Navigation/AppNavigator/getRootNavigatorScreenOptions'; +import * as ModalStackNavigators from '@libs/Navigation/AppNavigator/ModalStackNavigators'; +import SCREENS from '@src/SCREENS'; + +const loadPage = () => require('../../../../pages/settings/InitialSettingsPage').default as React.ComponentType; + +const RootStack = createCustomFullScreenNavigator(); + +function FullScreenNavigator() { + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const {isSmallScreenWidth} = useWindowDimensions(); + const screenOptions = getRootNavigatorScreenOptions(isSmallScreenWidth, styles, StyleUtils); + + return ( + + + + + + + ); +} + +FullScreenNavigator.displayName = 'FullScreenNavigator'; + +export default FullScreenNavigator; diff --git a/src/libs/Navigation/AppNavigator/Navigators/LeftModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/LeftModalNavigator.tsx index 4e78231b6b6ef..8f76d8fbdd7b9 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/LeftModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/LeftModalNavigator.tsx @@ -35,6 +35,10 @@ function LeftModalNavigator({navigation}: LeftModalNavigatorProps) { name={SCREENS.LEFT_MODAL.SEARCH} component={ModalStackNavigators.SearchModalStackNavigator} /> + diff --git a/src/libs/Navigation/AppNavigator/Navigators/PublicBottomTabNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/PublicBottomTabNavigator.tsx new file mode 100644 index 0000000000000..b61b35ae20ff9 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/PublicBottomTabNavigator.tsx @@ -0,0 +1,29 @@ +import type {StackNavigationOptions} from '@react-navigation/stack'; +import React from 'react'; +import createCustomBottomTabNavigator from '@libs/Navigation/AppNavigator/createCustomBottomTabNavigator'; +import type {BottomTabNavigatorParamList} from '@libs/Navigation/types'; +import SignInPage from '@pages/signin/SignInPage'; +import SCREENS from '@src/SCREENS'; + +// This type is not exactly right because we are using the same route in public and auth screens. +const Tab = createCustomBottomTabNavigator(); + +const screenOptions: StackNavigationOptions = { + headerShown: false, +}; + +// The structure for the HOME route have to be the same in public and auth screens. That's why we need to wrap the HOME screen with "fake" bottomTabNavigator. +function PublicBottomTabNavigator() { + return ( + + + + ); +} + +PublicBottomTabNavigator.displayName = 'BottomTabNavigator'; + +export default PublicBottomTabNavigator; diff --git a/src/libs/Navigation/AppNavigator/PublicScreens.tsx b/src/libs/Navigation/AppNavigator/PublicScreens.tsx index 5c3171214bd93..0324cc85dcfa0 100644 --- a/src/libs/Navigation/AppNavigator/PublicScreens.tsx +++ b/src/libs/Navigation/AppNavigator/PublicScreens.tsx @@ -8,6 +8,7 @@ import SAMLSignInPage from '@pages/signin/SAMLSignInPage'; import SignInPage from '@pages/signin/SignInPage'; import UnlinkLoginPage from '@pages/UnlinkLoginPage'; import ValidateLoginPage from '@pages/ValidateLoginPage'; +import NAVIGATORS from '@src/NAVIGATORS'; import SCREENS from '@src/SCREENS'; import defaultScreenOptions from './defaultScreenOptions'; @@ -16,8 +17,9 @@ const RootStack = createStackNavigator(); function PublicScreens() { return ( + {/* The structure for the HOME route have to be the same in public and auth screens. That's why we need to wrap the HOME screen with "fake" bottomTabNavigator. */} diff --git a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx new file mode 100644 index 0000000000000..c71178f2969d9 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx @@ -0,0 +1,76 @@ +import {useNavigationState} from '@react-navigation/native'; +import React from 'react'; +import {View} from 'react-native'; +import Icon from '@components/Icon'; +import * as Expensicons from '@components/Icon/Expensicons'; +import {PressableWithFeedback} from '@components/Pressable'; +import Tooltip from '@components/Tooltip'; +import useLocalize from '@hooks/useLocalize'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import getTopmostBottomTabRoute from '@libs/Navigation/getTopmostBottomTabRoute'; +import Navigation from '@libs/Navigation/Navigation'; +import type {RootStackParamList} from '@libs/Navigation/types'; +import BottomTabBarFloatingActionButton from '@pages/home/sidebar/BottomTabBarFloatingActionButton'; +import variables from '@styles/variables'; +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; + +function BottomTabBar() { + const theme = useTheme(); + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + // Parent navigator of the bottom tab bar is the root navigator. + const currentTabName = useNavigationState((state) => { + const topmostBottomTabRoute = getTopmostBottomTabRoute(state); + return topmostBottomTabRoute?.name; + }); + + return ( + + + { + Navigation.navigate(ROUTES.HOME); + }} + role={CONST.ROLE.BUTTON} + accessibilityLabel={translate('common.chats')} + wrapperStyle={styles.flexGrow1} + style={styles.bottomTabBarItem} + > + + + + + + { + Navigation.navigate(ROUTES.ALL_SETTINGS); + }} + role={CONST.ROLE.BUTTON} + accessibilityLabel={translate('common.settings')} + wrapperStyle={styles.flexGrow1} + style={styles.bottomTabBarItem} + > + + + + + ); +} + +BottomTabBar.displayName = 'BottomTabBar'; + +export default BottomTabBar; diff --git a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/TopBar.tsx b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/TopBar.tsx new file mode 100644 index 0000000000000..43e466bdbec04 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/TopBar.tsx @@ -0,0 +1,50 @@ +import React, {useCallback} from 'react'; +import {View} from 'react-native'; +import Search from '@components/Search'; +import WorkspaceSwitcherButton from '@components/WorkspaceSwitcherButton'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import Navigation from '@libs/Navigation/Navigation'; +import SignInOrAvatarWithOptionalStatus from '@pages/home/sidebar/SignInOrAvatarWithOptionalStatus'; +import * as Session from '@userActions/Session'; +import ROUTES from '@src/ROUTES'; + +type Props = { + isCreateMenuOpen?: boolean; +}; + +function TopBar({isCreateMenuOpen = false}: Props) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + const showSearchPage = useCallback(() => { + if (isCreateMenuOpen) { + // Prevent opening Search page when click Search icon quickly after clicking FAB icon + return; + } + + Navigation.navigate(ROUTES.SEARCH); + }, [isCreateMenuOpen]); + + return ( + + + + + + ); +} + +TopBar.displayName = 'TopBar'; + +export default TopBar; diff --git a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/index.tsx b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/index.tsx new file mode 100644 index 0000000000000..dd6f112928a7f --- /dev/null +++ b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/index.tsx @@ -0,0 +1,91 @@ +import type {DefaultNavigatorOptions, ParamListBase, StackActionHelpers, StackNavigationState, StackRouterOptions} from '@react-navigation/native'; +import {createNavigatorFactory, StackRouter, useNavigationBuilder} from '@react-navigation/native'; +import type {StackNavigationEventMap, StackNavigationOptions} from '@react-navigation/stack'; +import {StackView} from '@react-navigation/stack'; +import PropTypes from 'prop-types'; +import React from 'react'; +import {View} from 'react-native'; +import ScreenWrapper from '@components/ScreenWrapper'; +import useThemeStyles from '@hooks/useThemeStyles'; +import type {NavigationStateRoute} from '@libs/Navigation/types'; +import SCREENS from '@src/SCREENS'; +import BottomTabBar from './BottomTabBar'; +import TopBar from './TopBar'; + +type CustomNavigatorProps = DefaultNavigatorOptions, StackNavigationOptions, StackNavigationEventMap> & { + initialRouteName: string; +}; + +const propTypes = { + /* Children for the useNavigationBuilder hook */ + children: PropTypes.oneOfType([PropTypes.func, PropTypes.node]).isRequired, + + /* initialRouteName for this navigator */ + initialRouteName: PropTypes.oneOf([PropTypes.string, undefined]), + + /* Screen options defined for this navigator */ + // eslint-disable-next-line react/forbid-prop-types + screenOptions: PropTypes.object, +}; + +const defaultProps = { + initialRouteName: undefined, + screenOptions: undefined, +}; + +function getStateToRender(state: StackNavigationState): StackNavigationState { + const routesToRender = [state.routes.at(-1)] as NavigationStateRoute[]; + // We need to render at least one HOME screen to make sure everything load properly. + if (routesToRender[0].name !== SCREENS.HOME) { + const routeToRender = state.routes.find((route) => route.name === SCREENS.HOME); + if (routeToRender) { + routesToRender.unshift(routeToRender); + } + } + + return {...state, routes: routesToRender, index: routesToRender.length - 1}; +} + +function CustomBottomTabNavigator({initialRouteName, children, screenOptions, ...props}: CustomNavigatorProps) { + const {state, navigation, descriptors, NavigationContent} = useNavigationBuilder< + StackNavigationState, + StackRouterOptions, + StackActionHelpers, + StackNavigationOptions, + StackNavigationEventMap + >(StackRouter, { + children, + screenOptions, + initialRouteName, + }); + + const styles = useThemeStyles(); + const stateToRender = getStateToRender(state); + + return ( + + + + + + + + + + ); +} + +CustomBottomTabNavigator.defaultProps = defaultProps; +CustomBottomTabNavigator.propTypes = propTypes; +CustomBottomTabNavigator.displayName = 'CustomBottomTabNavigator'; + +export default createNavigatorFactory(CustomBottomTabNavigator); diff --git a/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/CustomFullScreenRouter.tsx b/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/CustomFullScreenRouter.tsx new file mode 100644 index 0000000000000..00acc89761b68 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/CustomFullScreenRouter.tsx @@ -0,0 +1,60 @@ +import type {NavigationState, ParamListBase, PartialState, RouterConfigOptions, StackNavigationState} from '@react-navigation/native'; +import {StackRouter} from '@react-navigation/native'; +import getIsSmallScreenWidth from '@libs/getIsSmallScreenWidth'; +import SCREENS from '@src/SCREENS'; +import type {FullScreenNavigatorRouterOptions} from './types'; + +// TODO: export states to separate file +type State = NavigationState | PartialState; + +const isAtLeastOneInState = (state: State, screenName: string): boolean => !!state.routes.find((route) => route.name === screenName); + +/** + * Adds report route without any specific reportID to the state. + * The report screen will self set proper reportID param based on the helper function findLastAccessedReport (look at ReportScreenWrapper for more info) + */ +const addCentralPaneNavigatorRoute = (state: State) => { + const centralPaneNavigatorRoute = { + name: SCREENS.SETTINGS_CENTRAL_PANE, + state: { + routes: [ + { + name: SCREENS.SETTINGS.PROFILE.ROOT, + }, + ], + }, + }; + state.routes.splice(1, 0, centralPaneNavigatorRoute); + // eslint-disable-next-line no-param-reassign, @typescript-eslint/non-nullable-type-assertion-style + (state.index as number) = state.routes.length - 1; +}; + +function CustomFullScreenRouter(options: FullScreenNavigatorRouterOptions) { + const stackRouter = StackRouter(options); + + return { + ...stackRouter, + getInitialState({routeNames, routeParamList, routeGetIdList}: RouterConfigOptions) { + const isSmallScreenWidth = getIsSmallScreenWidth(); + const initialState = stackRouter.getInitialState({routeNames, routeParamList, routeGetIdList}); + if (!isAtLeastOneInState(initialState, SCREENS.SETTINGS_CENTRAL_PANE) && !isSmallScreenWidth) { + addCentralPaneNavigatorRoute(initialState); + } + return initialState; + }, + getRehydratedState(partialState: StackNavigationState, {routeNames, routeParamList, routeGetIdList}: RouterConfigOptions): StackNavigationState { + const isSmallScreenWidth = getIsSmallScreenWidth(); + if (!isAtLeastOneInState(partialState, SCREENS.SETTINGS_CENTRAL_PANE) && !isSmallScreenWidth) { + // If we added a route we need to make sure that the state.stale is true to generate new key for this route + + // eslint-disable-next-line no-param-reassign + (partialState.stale as boolean) = true; + addCentralPaneNavigatorRoute(partialState); + } + const state = stackRouter.getRehydratedState(partialState, {routeNames, routeParamList, routeGetIdList}); + return state; + }, + }; +} + +export default CustomFullScreenRouter; diff --git a/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/index.native.tsx b/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/index.native.tsx new file mode 100644 index 0000000000000..808f06c61278b --- /dev/null +++ b/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/index.native.tsx @@ -0,0 +1,36 @@ +import type {ParamListBase, StackActionHelpers, StackNavigationState} from '@react-navigation/native'; +import {createNavigatorFactory, useNavigationBuilder} from '@react-navigation/native'; +import type {StackNavigationEventMap, StackNavigationOptions} from '@react-navigation/stack'; +import {StackView} from '@react-navigation/stack'; +import CustomFullScreenRouter from './CustomFullScreenRouter'; +import type {FullScreenNavigatorProps, FullScreenNavigatorRouterOptions} from './types'; + +function FullScreenNavigator(props: FullScreenNavigatorProps) { + const {navigation, state, descriptors, NavigationContent} = useNavigationBuilder< + StackNavigationState, + FullScreenNavigatorRouterOptions, + StackActionHelpers, + StackNavigationOptions, + StackNavigationEventMap + >(CustomFullScreenRouter, { + children: props.children, + screenOptions: props.screenOptions, + initialRouteName: props.initialRouteName, + }); + + return ( + + + + ); +} + +FullScreenNavigator.displayName = 'FullScreenNavigator'; + +export default createNavigatorFactory, StackNavigationOptions, StackNavigationEventMap, typeof FullScreenNavigator>(FullScreenNavigator); diff --git a/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/index.tsx b/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/index.tsx new file mode 100644 index 0000000000000..228fe7010de44 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/index.tsx @@ -0,0 +1,70 @@ +import type {ParamListBase, StackActionHelpers, StackNavigationState} from '@react-navigation/native'; +import {createNavigatorFactory, useNavigationBuilder} from '@react-navigation/native'; +import type {StackNavigationEventMap, StackNavigationOptions} from '@react-navigation/stack'; +import {StackView} from '@react-navigation/stack'; +import React, {useMemo} from 'react'; +import SCREENS from '@src/SCREENS'; +import CustomFullScreenRouter from './CustomFullScreenRouter'; +import type {FullScreenNavigatorProps, FullScreenNavigatorRouterOptions} from './types'; + +// TODO-IDEAL: Extract to utils with ./createCustomStackNavigator/index.tsx +type Routes = StackNavigationState['routes']; +function reduceReportRoutes(routes: Routes): Routes { + const result: Routes = []; + let count = 0; + const reverseRoutes = [...routes].reverse(); + + reverseRoutes.forEach((route) => { + if (route.name === SCREENS.SETTINGS_CENTRAL_PANE) { + // Remove all report routes except the last 3. This will improve performance. + if (count < 3) { + result.push(route); + count++; + } + } else { + result.push(route); + } + }); + + return result.reverse(); +} + +function FullScreenNavigator(props: FullScreenNavigatorProps) { + const {navigation, state, descriptors, NavigationContent} = useNavigationBuilder< + StackNavigationState, + FullScreenNavigatorRouterOptions, + StackActionHelpers, + StackNavigationOptions, + StackNavigationEventMap + >(CustomFullScreenRouter, { + children: props.children, + screenOptions: props.screenOptions, + initialRouteName: props.initialRouteName, + }); + + const stateToRender = useMemo(() => { + const result = reduceReportRoutes(state.routes); + + return { + ...state, + index: result.length - 1, + routes: [...result], + }; + }, [state]); + + return ( + + + + ); +} + +FullScreenNavigator.displayName = 'FullScreenNavigator'; + +export default createNavigatorFactory, StackNavigationOptions, StackNavigationEventMap, typeof FullScreenNavigator>(FullScreenNavigator); diff --git a/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/types.ts b/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/types.ts new file mode 100644 index 0000000000000..1ea0765d15d4f --- /dev/null +++ b/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/types.ts @@ -0,0 +1,12 @@ +import type {DefaultNavigatorOptions, ParamListBase, StackNavigationState, StackRouterOptions} from '@react-navigation/native'; +import type {StackNavigationEventMap, StackNavigationOptions} from '@react-navigation/stack'; + +type FullScreenNavigatorConfig = { + isSmallScreenWidth: boolean; +}; + +type FullScreenNavigatorRouterOptions = StackRouterOptions; + +type FullScreenNavigatorProps = DefaultNavigatorOptions, StackNavigationOptions, StackNavigationEventMap> & FullScreenNavigatorConfig; + +export type {FullScreenNavigatorConfig, FullScreenNavigatorProps, FullScreenNavigatorRouterOptions}; diff --git a/src/libs/Navigation/AppNavigator/createCustomStackNavigator/CustomRouter.ts b/src/libs/Navigation/AppNavigator/createCustomStackNavigator/CustomRouter.ts index 10cee2c859524..1ba64a45723a5 100644 --- a/src/libs/Navigation/AppNavigator/createCustomStackNavigator/CustomRouter.ts +++ b/src/libs/Navigation/AppNavigator/createCustomStackNavigator/CustomRouter.ts @@ -1,79 +1,99 @@ -import type {NavigationState, PartialState, RouterConfigOptions, StackNavigationState} from '@react-navigation/native'; -import {StackRouter} from '@react-navigation/native'; +import type {RouterConfigOptions, StackNavigationState} from '@react-navigation/native'; +import {getPathFromState, StackRouter} from '@react-navigation/native'; import type {ParamListBase} from '@react-navigation/routers'; import getIsSmallScreenWidth from '@libs/getIsSmallScreenWidth'; +import getAdaptedStateFromPath from '@libs/Navigation/getAdaptedStateFromPath'; +import getTopmostBottomTabRoute from '@libs/Navigation/getTopmostBottomTabRoute'; +import getTopmostCentralPaneRoute from '@libs/Navigation/getTopmostCentralPaneRoute'; +import linkingConfig from '@libs/Navigation/linkingConfig'; +import type {NavigationPartialRoute, RootStackParamList, State} from '@libs/Navigation/types'; import NAVIGATORS from '@src/NAVIGATORS'; -import SCREENS from '@src/SCREENS'; import type {ResponsiveStackNavigatorRouterOptions} from './types'; -type State = NavigationState | PartialState; +function insertRootRoute(state: State, routeToInsert: NavigationPartialRoute) { + const nonModalRoutes = state.routes.filter((route) => route.name !== NAVIGATORS.RIGHT_MODAL_NAVIGATOR && route.name !== NAVIGATORS.LEFT_MODAL_NAVIGATOR); + const modalRoutes = state.routes.filter((route) => route.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR || route.name === NAVIGATORS.LEFT_MODAL_NAVIGATOR); -const isAtLeastOneCentralPaneNavigatorInState = (state: State): boolean => !!state.routes.find((route) => route.name === NAVIGATORS.CENTRAL_PANE_NAVIGATOR); + // @ts-expect-error Updating read only property + // noinspection JSConstantReassignment + state.routes = [...nonModalRoutes, routeToInsert, ...modalRoutes]; // eslint-disable-line -const getTopMostReportIDFromRHP = (state: State): string => { - if (!state) { - return ''; - } + // @ts-expect-error Updating read only property + // noinspection JSConstantReassignment + state.index = state.routes.length - 1; // eslint-disable-line - const topmostRightPane = state.routes.filter((route) => route.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR).at(-1); + // @ts-expect-error Updating read only property + // noinspection JSConstantReassignment + state.stale = true; // eslint-disable-line +} - if (topmostRightPane?.state) { - return getTopMostReportIDFromRHP(topmostRightPane.state); +function compareAndAdaptState(state: StackNavigationState) { + // If the state of the last path is not defined the getPathFromState won't work correctly. + if (!state?.routes.at(-1)?.state) { + return; } - const topmostRoute = state.routes.at(-1); + // We need to be sure that the bottom tab state is defined. + const topmostBottomTabRoute = getTopmostBottomTabRoute(state); + const isSmallScreenWidth = getIsSmallScreenWidth(); - if (topmostRoute?.state) { - return getTopMostReportIDFromRHP(topmostRoute.state); - } + // This solutions is heurestis and will work for our cases. We may need to improve it in the future if we will have more cases to handle. + if (topmostBottomTabRoute && !isSmallScreenWidth) { + const fullScreenRoute = state.routes.find((route) => route.name === NAVIGATORS.FULL_SCREEN_NAVIGATOR); - if (topmostRoute?.params && 'reportID' in topmostRoute.params && typeof topmostRoute.params.reportID === 'string' && topmostRoute.params.reportID) { - return topmostRoute.params.reportID; - } + // If there is fullScreenRoute we don't need to add anything. + if (fullScreenRoute) { + return; + } - return ''; -}; -/** - * Adds report route without any specific reportID to the state. - * The report screen will self set proper reportID param based on the helper function findLastAccessedReport (look at ReportScreenWrapper for more info) - * - * @param state - react-navigation state - */ -const addCentralPaneNavigatorRoute = (state: State) => { - const reportID = getTopMostReportIDFromRHP(state); - const centralPaneNavigatorRoute = { - name: NAVIGATORS.CENTRAL_PANE_NAVIGATOR, - state: { - routes: [ - { - name: SCREENS.REPORT, - params: { - reportID, - }, - }, - ], - }, - }; - state.routes.splice(1, 0, centralPaneNavigatorRoute); - // eslint-disable-next-line no-param-reassign, @typescript-eslint/non-nullable-type-assertion-style - (state.index as number) = state.routes.length - 1; -}; + // We will generate a template state and compare the current state with it. + // If there is a differences in the screens that should be visible under the overlay, we will add the screen from templateState to the current state. + const pathFromCurrentState = getPathFromState(state, linkingConfig.config); + const templateState = getAdaptedStateFromPath(pathFromCurrentState, linkingConfig.config); + + if (!templateState) { + return; + } + + const templateFullScreenRoute = templateState.routes.find((route) => route.name === NAVIGATORS.FULL_SCREEN_NAVIGATOR); + + // If templateFullScreenRoute is defined, and full screen route is not in the state, we need to add it. + if (templateFullScreenRoute) { + insertRootRoute(state, templateFullScreenRoute); + return; + } + + const topmostCentralPaneRoute = state.routes.filter((route) => route.name === NAVIGATORS.CENTRAL_PANE_NAVIGATOR).at(-1); + const templateCentralPaneRoute = templateState.routes.find((route) => route.name === NAVIGATORS.CENTRAL_PANE_NAVIGATOR); + + const topmostCentralPaneRouteExtracted = getTopmostCentralPaneRoute(state); + const templateCentralPaneRouteExtracted = getTopmostCentralPaneRoute(templateState as State); + + // If there is no templateCentralPaneRoute, we don't have anything to add. + if (!templateCentralPaneRoute) { + return; + } + + // If there is no topmostCentralPaneRoute in the state and template state has one, we need to add it. + if (!topmostCentralPaneRoute) { + insertRootRoute(state, templateCentralPaneRoute); + return; + } + + // If there is central pane route in state and template state has one, we need to check if they are the same. + if (topmostCentralPaneRouteExtracted && templateCentralPaneRouteExtracted && topmostCentralPaneRouteExtracted.name !== templateCentralPaneRouteExtracted.name) { + insertRootRoute(state, templateCentralPaneRoute); + } + } +} function CustomRouter(options: ResponsiveStackNavigatorRouterOptions) { const stackRouter = StackRouter(options); return { ...stackRouter, - getRehydratedState(partialState: StackNavigationState, {routeNames, routeParamList, routeGetIdList}: RouterConfigOptions): StackNavigationState { - const isSmallScreenWidth = getIsSmallScreenWidth(); - // Make sure that there is at least one CentralPaneNavigator (ReportScreen by default) in the state if this is a wide layout - if (!isAtLeastOneCentralPaneNavigatorInState(partialState) && !isSmallScreenWidth) { - // If we added a route we need to make sure that the state.stale is true to generate new key for this route - - // eslint-disable-next-line no-param-reassign - (partialState.stale as boolean) = true; - addCentralPaneNavigatorRoute(partialState); - } + getRehydratedState(partialState: StackNavigationState, {routeNames, routeParamList, routeGetIdList}: RouterConfigOptions): StackNavigationState { + compareAndAdaptState(partialState); const state = stackRouter.getRehydratedState(partialState, {routeNames, routeParamList, routeGetIdList}); return state; }, diff --git a/src/libs/Navigation/AppNavigator/createCustomStackNavigator/index.tsx b/src/libs/Navigation/AppNavigator/createCustomStackNavigator/index.tsx index 3f6025d5ff0c6..2a517c45eb0d9 100644 --- a/src/libs/Navigation/AppNavigator/createCustomStackNavigator/index.tsx +++ b/src/libs/Navigation/AppNavigator/createCustomStackNavigator/index.tsx @@ -2,8 +2,9 @@ import type {ParamListBase, StackActionHelpers, StackNavigationState} from '@rea import {createNavigatorFactory, useNavigationBuilder} from '@react-navigation/native'; import type {StackNavigationEventMap, StackNavigationOptions} from '@react-navigation/stack'; import {StackView} from '@react-navigation/stack'; -import React, {useMemo, useRef} from 'react'; +import React, {useEffect, useMemo} from 'react'; import useWindowDimensions from '@hooks/useWindowDimensions'; +import navigationRef from '@libs/Navigation/navigationRef'; import NAVIGATORS from '@src/NAVIGATORS'; import CustomRouter from './CustomRouter'; import type {ResponsiveStackNavigatorProps, ResponsiveStackNavigatorRouterOptions} from './types'; @@ -32,10 +33,6 @@ function reduceReportRoutes(routes: Routes): Routes { function ResponsiveStackNavigator(props: ResponsiveStackNavigatorProps) { const {isSmallScreenWidth} = useWindowDimensions(); - const isSmallScreenWidthRef = useRef(isSmallScreenWidth); - - isSmallScreenWidthRef.current = isSmallScreenWidth; - const {navigation, state, descriptors, NavigationContent} = useNavigationBuilder< StackNavigationState, ResponsiveStackNavigatorRouterOptions, @@ -48,6 +45,13 @@ function ResponsiveStackNavigator(props: ResponsiveStackNavigatorProps) { initialRouteName: props.initialRouteName, }); + useEffect(() => { + if (!navigationRef.isReady()) { + return; + } + navigationRef.resetRoot(navigationRef.getRootState()); + }, [isSmallScreenWidth]); + const stateToRender = useMemo(() => { const result = reduceReportRoutes(state.routes); diff --git a/src/libs/Navigation/CENTRAL_PANE_TO_RHP_MAPPING.ts b/src/libs/Navigation/CENTRAL_PANE_TO_RHP_MAPPING.ts new file mode 100755 index 0000000000000..cc5adea902031 --- /dev/null +++ b/src/libs/Navigation/CENTRAL_PANE_TO_RHP_MAPPING.ts @@ -0,0 +1,10 @@ +import SCREENS from '@src/SCREENS'; +import type {CentralPaneName} from './types'; + +const CENTRAL_PANE_TO_RHP_MAPPING: Partial> = { + [SCREENS.WORKSPACE.OVERVIEW]: [SCREENS.WORKSPACE.NAME, SCREENS.WORKSPACE.CURRENCY], + [SCREENS.WORKSPACE.REIMBURSE]: [SCREENS.WORKSPACE.RATE_AND_UNIT], + [SCREENS.WORKSPACE.MEMBERS]: [SCREENS.WORKSPACE.INVITE, SCREENS.WORKSPACE.INVITE_MESSAGE], +}; + +export default CENTRAL_PANE_TO_RHP_MAPPING; diff --git a/src/libs/Navigation/FULL_SCREEN_TO_RHP_MAPPING.ts b/src/libs/Navigation/FULL_SCREEN_TO_RHP_MAPPING.ts new file mode 100755 index 0000000000000..552ac8850b42f --- /dev/null +++ b/src/libs/Navigation/FULL_SCREEN_TO_RHP_MAPPING.ts @@ -0,0 +1,41 @@ +import SCREENS from '@src/SCREENS'; +import type {FullScreenName} from './types'; + +const FULL_SCREEN_TO_RHP_MAPPING: Partial> = { + [SCREENS.SETTINGS.PROFILE.ROOT]: [ + SCREENS.SETTINGS.PROFILE.DISPLAY_NAME, + SCREENS.SETTINGS.PROFILE.CONTACT_METHODS, + SCREENS.SETTINGS.PROFILE.CONTACT_METHOD_DETAILS, + SCREENS.SETTINGS.PROFILE.NEW_CONTACT_METHOD, + SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER, + SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_DATE, + SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_TIME, + SCREENS.SETTINGS.PROFILE.STATUS, + SCREENS.SETTINGS.PROFILE.PRONOUNS, + SCREENS.SETTINGS.PROFILE.TIMEZONE, + SCREENS.SETTINGS.PROFILE.TIMEZONE_SELECT, + SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.INITIAL, + SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.LEGAL_NAME, + SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.DATE_OF_BIRTH, + SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.ADDRESS, + SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.ADDRESS_COUNTRY, + ], + [SCREENS.SETTINGS.PREFERENCES.ROOT]: [SCREENS.SETTINGS.PREFERENCES.PRIORITY_MODE, SCREENS.SETTINGS.PREFERENCES.LANGUAGE, SCREENS.SETTINGS.PREFERENCES.THEME], + [SCREENS.SETTINGS.WALLET.ROOT]: [ + SCREENS.SETTINGS.WALLET.DOMAIN_CARD, + SCREENS.SETTINGS.WALLET.CARD_GET_PHYSICAL.NAME, + SCREENS.SETTINGS.WALLET.CARD_GET_PHYSICAL.PHONE, + SCREENS.SETTINGS.WALLET.CARD_GET_PHYSICAL.ADDRESS, + SCREENS.SETTINGS.WALLET.CARD_GET_PHYSICAL.CONFIRM, + SCREENS.SETTINGS.WALLET.TRANSFER_BALANCE, + SCREENS.SETTINGS.WALLET.CHOOSE_TRANSFER_ACCOUNT, + SCREENS.SETTINGS.WALLET.ENABLE_PAYMENTS, + SCREENS.SETTINGS.WALLET.CARD_ACTIVATE, + SCREENS.SETTINGS.WALLET.REPORT_VIRTUAL_CARD_FRAUD, + SCREENS.SETTINGS.WALLET.CARDS_DIGITAL_DETAILS_UPDATE_ADDRESS, + ], + [SCREENS.SETTINGS.SECURITY]: [SCREENS.SETTINGS.TWO_FACTOR_AUTH, SCREENS.SETTINGS.CLOSE], + [SCREENS.SETTINGS.ABOUT]: [SCREENS.SETTINGS.APP_DOWNLOAD_LINKS, SCREENS.KEYBOARD_SHORTCUTS], +}; + +export default FULL_SCREEN_TO_RHP_MAPPING; diff --git a/src/libs/Navigation/Navigation.ts b/src/libs/Navigation/Navigation.ts index 99321d7734c67..9bdbd4b4f5552 100644 --- a/src/libs/Navigation/Navigation.ts +++ b/src/libs/Navigation/Navigation.ts @@ -1,5 +1,5 @@ import {findFocusedRoute} from '@react-navigation/core'; -import type {EventArg, NavigationContainerEventMap, NavigationState, PartialState} from '@react-navigation/native'; +import type {EventArg, NavigationContainerEventMap} from '@react-navigation/native'; import {CommonActions, getPathFromState, StackActions} from '@react-navigation/native'; import Log from '@libs/Log'; import CONST from '@src/CONST'; @@ -13,7 +13,7 @@ import originalGetTopmostReportId from './getTopmostReportId'; import linkingConfig from './linkingConfig'; import linkTo from './linkTo'; import navigationRef from './navigationRef'; -import type {StateOrRoute} from './types'; +import type {State, StateOrRoute} from './types'; let resolveNavigationIsReadyPromise: () => void; const navigationIsReadyPromise = new Promise((resolve) => { @@ -60,7 +60,10 @@ function getActiveRouteIndex(stateOrRoute: StateOrRoute, index?: number): number return getActiveRouteIndex(childActiveRoute, stateOrRoute.state.index ?? 0); } - if ('name' in stateOrRoute && (stateOrRoute.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR || stateOrRoute.name === NAVIGATORS.LEFT_MODAL_NAVIGATOR)) { + if ( + 'name' in stateOrRoute && + (stateOrRoute.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR || stateOrRoute.name === NAVIGATORS.LEFT_MODAL_NAVIGATOR || stateOrRoute.name === NAVIGATORS.FULL_SCREEN_NAVIGATOR) + ) { return 0; } @@ -149,7 +152,7 @@ function navigate(route: Route = ROUTES.HOME, type?: string) { * @param shouldEnforceFallback - Enforces navigation to fallback route * @param shouldPopToTop - Should we navigate to LHN on back press */ -function goBack(fallbackRoute: Route, shouldEnforceFallback = false, shouldPopToTop = false) { +function goBack(fallbackRoute: Route = '', shouldEnforceFallback = false, shouldPopToTop = false) { if (!canNavigate('goBack')) { return; } @@ -201,6 +204,14 @@ function goBack(fallbackRoute: Route, shouldEnforceFallback = false, shouldPopTo navigationRef.current.goBack(); } +/** + * Close the full screen modal. + */ +function closeFullScreen() { + const rootState = navigationRef.getRootState(); + navigationRef.dispatch({...StackActions.pop(), target: rootState.key}); +} + /** * Update route params for the specified route. */ @@ -258,7 +269,7 @@ function setIsNavigationReady() { * * @param state - react-navigation state object */ -function navContainsProtectedRoutes(state: NavigationState | PartialState | undefined): boolean { +function navContainsProtectedRoutes(state: State | undefined): boolean { if (!state?.routeNames || !Array.isArray(state.routeNames)) { return false; } @@ -313,6 +324,7 @@ export default { getRouteNameFromStateEvent, getTopmostReportActionId, waitForProtectedRoutes, + closeFullScreen, }; export {navigationRef}; diff --git a/src/libs/Navigation/NavigationRoot.tsx b/src/libs/Navigation/NavigationRoot.tsx index 79ec18d28d4f2..31a68131da737 100644 --- a/src/libs/Navigation/NavigationRoot.tsx +++ b/src/libs/Navigation/NavigationRoot.tsx @@ -72,14 +72,6 @@ function NavigationRoot({authenticated, onReady}: NavigationRootProps) { Navigation.setShouldPopAllStateOnUP(); }, [isSmallScreenWidth]); - useEffect(() => { - if (!navigationRef.isReady() || !authenticated) { - return; - } - // We need to force state rehydration so the CustomRouter can add the CentralPaneNavigator route if necessary. - navigationRef.resetRoot(navigationRef.getRootState()); - }, [isSmallScreenWidth, authenticated]); - const handleStateChange = (state: NavigationState | undefined) => { if (!state) { return; diff --git a/src/libs/Navigation/TAB_TO_CENTRAL_PANE_MAPPING.ts b/src/libs/Navigation/TAB_TO_CENTRAL_PANE_MAPPING.ts new file mode 100755 index 0000000000000..8889313c1b281 --- /dev/null +++ b/src/libs/Navigation/TAB_TO_CENTRAL_PANE_MAPPING.ts @@ -0,0 +1,18 @@ +import SCREENS from '@src/SCREENS'; +import type {BottomTabName, CentralPaneName} from './types'; + +const TAB_TO_CENTRAL_PANE_MAPPING: Record = { + [SCREENS.HOME]: [SCREENS.REPORT], + [SCREENS.ALL_SETTINGS]: [SCREENS.SETTINGS.WORKSPACES], + [SCREENS.WORKSPACE.INITIAL]: [ + SCREENS.WORKSPACE.OVERVIEW, + SCREENS.WORKSPACE.CARD, + SCREENS.WORKSPACE.REIMBURSE, + SCREENS.WORKSPACE.BILLS, + SCREENS.WORKSPACE.INVOICES, + SCREENS.WORKSPACE.TRAVEL, + SCREENS.WORKSPACE.MEMBERS, + ], +}; + +export default TAB_TO_CENTRAL_PANE_MAPPING; diff --git a/src/libs/Navigation/dismissModal.ts b/src/libs/Navigation/dismissModal.ts index 917f1c86c90c0..fa339b8cfba45 100644 --- a/src/libs/Navigation/dismissModal.ts +++ b/src/libs/Navigation/dismissModal.ts @@ -26,6 +26,7 @@ function dismissModal(targetReportID: string, navigationRef: NavigationContainer const state = navigationRef.getState(); const lastRoute = state.routes.at(-1); switch (lastRoute?.name) { + case NAVIGATORS.FULL_SCREEN_NAVIGATOR: case NAVIGATORS.LEFT_MODAL_NAVIGATOR: case NAVIGATORS.RIGHT_MODAL_NAVIGATOR: case SCREENS.NOT_FOUND: diff --git a/src/libs/Navigation/getAdaptedStateFromPath.ts b/src/libs/Navigation/getAdaptedStateFromPath.ts new file mode 100644 index 0000000000000..e7232154dfda0 --- /dev/null +++ b/src/libs/Navigation/getAdaptedStateFromPath.ts @@ -0,0 +1,178 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import type {NavigationState, PartialState} from '@react-navigation/native'; +import {getStateFromPath} from '@react-navigation/native'; +import getIsSmallScreenWidth from '@libs/getIsSmallScreenWidth'; +import NAVIGATORS from '@src/NAVIGATORS'; +import SCREENS from '@src/SCREENS'; +import CENTRAL_PANE_TO_RHP_MAPPING from './CENTRAL_PANE_TO_RHP_MAPPING'; +import FULL_SCREEN_TO_RHP_MAPPING from './FULL_SCREEN_TO_RHP_MAPPING'; +import getMatchingBottomTabRouteForState from './getMatchingBottomTabRouteForState'; +import getMatchingCentralPaneRouteForState from './getMatchingCentralPaneRouteForState'; +import getTopmostNestedRHPRoute from './getTopmostNestedRHPRoute'; +import type {BottomTabName, CentralPaneName, FullScreenName, NavigationPartialRoute, RootStackParamList} from './types'; + +function createBottomTabNavigator(route: NavigationPartialRoute): NavigationPartialRoute { + const routesForBottomTabNavigator: Array> = [{name: SCREENS.HOME}]; + + if (route.name !== SCREENS.HOME) { + // If the generated state requires tab other than HOME, we need to insert it. + routesForBottomTabNavigator.push(route); + } + + return { + name: NAVIGATORS.BOTTOM_TAB_NAVIGATOR, + state: {routes: routesForBottomTabNavigator}, + }; +} + +function createCentralPaneNavigator(route: NavigationPartialRoute): NavigationPartialRoute { + return { + name: NAVIGATORS.CENTRAL_PANE_NAVIGATOR, + state: {routes: [route]}, + }; +} + +function createFullScreenNavigator(route: NavigationPartialRoute): NavigationPartialRoute { + const routes = []; + + routes.push({name: SCREENS.SETTINGS.ROOT}); + routes.push({ + name: SCREENS.SETTINGS_CENTRAL_PANE, + state: { + routes: [route], + }, + }); + + return { + name: NAVIGATORS.FULL_SCREEN_NAVIGATOR, + state: { + routes, + }, + }; +} + +// This function will return CentralPaneNavigator route or FullScreenNavigator route. +function getMatchingRootRouteForRHPRoute(route: NavigationPartialRoute): NavigationPartialRoute { + // Check for CentralPaneNavigator + for (const [centralPaneName, RHPNames] of Object.entries(CENTRAL_PANE_TO_RHP_MAPPING)) { + if (RHPNames.includes(route.name)) { + return createCentralPaneNavigator({name: centralPaneName as CentralPaneName, params: route.params}); + } + } + + // Check for FullScreenNavigator + for (const [fullScreenName, RHPNames] of Object.entries(FULL_SCREEN_TO_RHP_MAPPING)) { + if (RHPNames && RHPNames.includes(route.name)) { + return createFullScreenNavigator({name: fullScreenName as FullScreenName, params: route.params}); + } + } + + // Default route + return createCentralPaneNavigator({name: SCREENS.REPORT, params: route.params}); +} + +function getAdaptedState(state: PartialState>) { + const isSmallScreenWidth = getIsSmallScreenWidth(); + + // We need to check what is defined to know what we need to add. + const bottomTabNavigator = state.routes.find((route) => route.name === NAVIGATORS.BOTTOM_TAB_NAVIGATOR); + const centralPaneNavigator = state.routes.find((route) => route.name === NAVIGATORS.CENTRAL_PANE_NAVIGATOR); + const fullScreenNavigator = state.routes.find((route) => route.name === NAVIGATORS.FULL_SCREEN_NAVIGATOR); + const rhpNavigator = state.routes.find((route) => route.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR); + const lhpNavigator = state.routes.find((route) => route.name === NAVIGATORS.LEFT_MODAL_NAVIGATOR); + + if (rhpNavigator) { + // Routes + // - matching bottom tab + // - matching root route for rhp + // - found rhp + + // This one will be defined because rhpNavigator is defined. + const topmostNestedRHPRoute = getTopmostNestedRHPRoute(state); + const routes = []; + + if (topmostNestedRHPRoute) { + const matchingRootRoute = getMatchingRootRouteForRHPRoute(topmostNestedRHPRoute); + // If the root route is type of FullScreenNavigator, the default bottom tab will be added. + const matchingBottomTabRoute = getMatchingBottomTabRouteForState({routes: [matchingRootRoute]}); + routes.push(createBottomTabNavigator(matchingBottomTabRoute)); + routes.push(matchingRootRoute); + } + + routes.push(rhpNavigator); + return {routes}; + } + if (lhpNavigator) { + // Routes + // - default bottom tab + // - default central pane on desktop layout + // - found lhp + const routes = []; + routes.push(createBottomTabNavigator({name: SCREENS.HOME})); + if (!isSmallScreenWidth) { + routes.push(createCentralPaneNavigator({name: SCREENS.REPORT})); + } + routes.push(lhpNavigator); + + return {routes}; + } + if (fullScreenNavigator) { + // Routes + // - default bottom tab + // - default central pane on desktop layout + // - found fullscreen + const routes = []; + routes.push(createBottomTabNavigator({name: SCREENS.HOME})); + if (!isSmallScreenWidth) { + routes.push(createCentralPaneNavigator({name: SCREENS.REPORT})); + } + routes.push(fullScreenNavigator); + + return {routes}; + } + if (centralPaneNavigator) { + // Routes + // - matching bottom tab + // - found central pane + const routes = []; + const matchingBottomTabRoute = getMatchingBottomTabRouteForState(state); + routes.push(createBottomTabNavigator(matchingBottomTabRoute)); + routes.push(centralPaneNavigator); + + return {routes}; + } + if (bottomTabNavigator) { + // Routes + // - found bottom tab + // - matching central pane on desktop layout + if (isSmallScreenWidth) { + return state; + } + + const routes = [...state.routes]; + const matchingCentralPaneRoute = getMatchingCentralPaneRouteForState(state); + if (matchingCentralPaneRoute) { + routes.push(createCentralPaneNavigator(matchingCentralPaneRoute)); + } + + return { + routes, + }; + } + + return state; +} + +const getAdaptedStateFromPath: typeof getStateFromPath = (path, options) => { + const state = getStateFromPath(path, options); + + if (state === undefined) { + throw new Error('Unable to parse path'); + } + + const adaptedState = getAdaptedState(state as PartialState>); + + return adaptedState; +}; + +export default getAdaptedStateFromPath; diff --git a/src/libs/Navigation/getMatchingBottomTabRouteForState.ts b/src/libs/Navigation/getMatchingBottomTabRouteForState.ts new file mode 100644 index 0000000000000..2eb2a650ea06a --- /dev/null +++ b/src/libs/Navigation/getMatchingBottomTabRouteForState.ts @@ -0,0 +1,28 @@ +// import CONST from '@src/CONST'; +import SCREENS from '@src/SCREENS'; +import getTopmostCentralPaneRoute from './getTopmostCentralPaneRoute'; +import TAB_TO_CENTRAL_PANE_MAPPING from './TAB_TO_CENTRAL_PANE_MAPPING'; +import type {BottomTabName, NavigationPartialRoute, RootStackParamList, State} from './types'; + +// Get the route that matches the topmost central pane route in the navigation stack. e.g REPORT -> HOME +function getMatchingBottomTabRouteForState(state: State): NavigationPartialRoute { + const defaultRoute = {name: SCREENS.HOME}; + const topmostCentralPaneRoute = getTopmostCentralPaneRoute(state); + + if (topmostCentralPaneRoute === undefined) { + return defaultRoute; + } + + for (const [tabName, centralPaneNames] of Object.entries(TAB_TO_CENTRAL_PANE_MAPPING)) { + if (centralPaneNames.includes(topmostCentralPaneRoute.name)) { + if (tabName === SCREENS.WORKSPACE.INITIAL) { + return {name: tabName, params: topmostCentralPaneRoute.params}; + } + return {name: tabName as BottomTabName}; + } + } + + return defaultRoute; +} + +export default getMatchingBottomTabRouteForState; diff --git a/src/libs/Navigation/getMatchingCentralPaneRouteForState.ts b/src/libs/Navigation/getMatchingCentralPaneRouteForState.ts new file mode 100644 index 0000000000000..1422cc8d3e214 --- /dev/null +++ b/src/libs/Navigation/getMatchingCentralPaneRouteForState.ts @@ -0,0 +1,55 @@ +import NAVIGATORS from '@src/NAVIGATORS'; +import SCREENS from '@src/SCREENS'; +import getTopmostBottomTabRoute from './getTopmostBottomTabRoute'; +import TAB_TO_CENTRAL_PANE_MAPPING from './TAB_TO_CENTRAL_PANE_MAPPING'; +import type {CentralPaneName, NavigationPartialRoute, RootStackParamList, State} from './types'; + +/** + * @param state - react-navigation state + */ +const getTopMostReportIDFromRHP = (state: State): string => { + if (!state) { + return ''; + } + + const topmostRightPane = state.routes.filter((route) => route.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR).at(-1); + + if (topmostRightPane?.state) { + return getTopMostReportIDFromRHP(topmostRightPane.state); + } + + const topmostRoute = state.routes.at(-1); + + if (topmostRoute?.state) { + return getTopMostReportIDFromRHP(topmostRoute.state); + } + + if (topmostRoute?.params && 'reportID' in topmostRoute.params && typeof topmostRoute.params.reportID === 'string') { + return topmostRoute.params.reportID; + } + + return ''; +}; + +// Get matching central pane route for bottom tab navigator. e.g HOME -> REPORT +function getMatchingCentralPaneRouteForState(state: State): NavigationPartialRoute | undefined { + const topmostBottomTabRoute = getTopmostBottomTabRoute(state); + + if (!topmostBottomTabRoute) { + return; + } + + const centralPaneName = TAB_TO_CENTRAL_PANE_MAPPING[topmostBottomTabRoute.name][0]; + + if (topmostBottomTabRoute.name === SCREENS.WORKSPACE.INITIAL) { + return {name: centralPaneName, params: topmostBottomTabRoute.params}; + } + + if (topmostBottomTabRoute.name === SCREENS.HOME) { + return {name: centralPaneName, params: {reportID: getTopMostReportIDFromRHP(state)}}; + } + + return {name: centralPaneName}; +} + +export default getMatchingCentralPaneRouteForState; diff --git a/src/libs/Navigation/getStateFromPath.ts b/src/libs/Navigation/getStateFromPath.ts index 0476ffac14942..50254bb3898dd 100644 --- a/src/libs/Navigation/getStateFromPath.ts +++ b/src/libs/Navigation/getStateFromPath.ts @@ -10,7 +10,8 @@ import linkingConfig from './linkingConfig'; function getStateFromPath(path: Route): PartialState { const normalizedPath = !path.startsWith('/') ? `/${path}` : path; - const state = linkingConfig.getStateFromPath ? linkingConfig.getStateFromPath(normalizedPath, linkingConfig.config) : RNGetStateFromPath(normalizedPath, linkingConfig.config); + // This function is used in the linkTo function where we want to use default getStateFromPath function. + const state = RNGetStateFromPath(normalizedPath, linkingConfig.config); if (!state) { throw new Error('Failed to parse the path to a navigation state.'); diff --git a/src/libs/Navigation/getTopmostBottomTabRoute.ts b/src/libs/Navigation/getTopmostBottomTabRoute.ts new file mode 100644 index 0000000000000..8cfc60d99c4a8 --- /dev/null +++ b/src/libs/Navigation/getTopmostBottomTabRoute.ts @@ -0,0 +1,20 @@ +import type {BottomTabName, NavigationPartialRoute, RootStackParamList, State} from './types'; + +function getTopmostBottomTabRoute(state: State): NavigationPartialRoute | undefined { + const bottomTabNavigatorRoute = state.routes[0]; + + // The bottomTabNavigatorRoute state may be empty if we just logged in. + if (!bottomTabNavigatorRoute || bottomTabNavigatorRoute.name !== 'BottomTabNavigator' || bottomTabNavigatorRoute.state === undefined) { + return undefined; + } + + const topmostBottomTabRoute = bottomTabNavigatorRoute.state.routes.at(-1); + + if (!topmostBottomTabRoute) { + throw new Error('BottomTabNavigator route have no routes.'); + } + + return {name: topmostBottomTabRoute.name as BottomTabName, params: topmostBottomTabRoute.params}; +} + +export default getTopmostBottomTabRoute; diff --git a/src/libs/Navigation/getTopmostCentralPaneName.ts b/src/libs/Navigation/getTopmostCentralPaneName.ts new file mode 100644 index 0000000000000..03f749abcec41 --- /dev/null +++ b/src/libs/Navigation/getTopmostCentralPaneName.ts @@ -0,0 +1,19 @@ +import type {NavigationState, PartialState} from '@react-navigation/native'; +import SCREENS from '@src/SCREENS'; + +// Get the name of topmost report in the navigation stack. +function getTopmostCentralPaneName(state: NavigationState | PartialState): string | undefined { + if (!state) { + return; + } + + const topmostCentralPane = state.routes.filter((route) => typeof route !== 'number' && 'name' in route && route.name === SCREENS.SETTINGS_CENTRAL_PANE).at(-1); + + if (!topmostCentralPane || typeof topmostCentralPane === 'number' || !('state' in topmostCentralPane)) { + return; + } + + return topmostCentralPane.state?.routes.at(-1)?.name; +} + +export default getTopmostCentralPaneName; diff --git a/src/libs/Navigation/getTopmostCentralPaneRoute.ts b/src/libs/Navigation/getTopmostCentralPaneRoute.ts new file mode 100644 index 0000000000000..934cca1a673ed --- /dev/null +++ b/src/libs/Navigation/getTopmostCentralPaneRoute.ts @@ -0,0 +1,30 @@ +import NAVIGATORS from '@src/NAVIGATORS'; +import type {CentralPaneName, NavigationPartialRoute, RootStackParamList, State} from './types'; + +// Get the name of topmost central pane route in the navigation stack. +function getTopmostCentralPaneRoute(state: State): NavigationPartialRoute | undefined { + if (!state) { + return; + } + + const topmostCentralPane = state.routes.filter((route) => route.name === NAVIGATORS.CENTRAL_PANE_NAVIGATOR).at(-1); + + if (!topmostCentralPane) { + return; + } + + if (!!topmostCentralPane.params && 'screen' in topmostCentralPane.params) { + return {name: topmostCentralPane.params.screen as CentralPaneName, params: topmostCentralPane.params.params}; + } + + if (!topmostCentralPane.state) { + return; + } + + // There will be at least one route in the central pane navigator. + const {name, params} = topmostCentralPane.state.routes.at(-1) as NavigationPartialRoute; + + return {name, params}; +} + +export default getTopmostCentralPaneRoute; diff --git a/src/libs/Navigation/getTopmostNestedRHPRoute.ts b/src/libs/Navigation/getTopmostNestedRHPRoute.ts new file mode 100644 index 0000000000000..4edcb4e7001ee --- /dev/null +++ b/src/libs/Navigation/getTopmostNestedRHPRoute.ts @@ -0,0 +1,25 @@ +import NAVIGATORS from '@src/NAVIGATORS'; +import type {NavigationPartialRoute, State} from './types'; + +/** + * @param state - react-navigation state + */ +const getTopmostNestedRHPRoute = (state: State): NavigationPartialRoute | undefined => { + const topmostRightPane = state.routes.filter((route) => route.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR).at(-1); + + if (topmostRightPane?.state) { + return getTopmostNestedRHPRoute(topmostRightPane.state); + } + + const topmostRoute = state.routes.at(-1); + + if (topmostRoute?.state) { + return getTopmostNestedRHPRoute(topmostRoute.state); + } + + if (topmostRoute) { + return {name: topmostRoute.name, params: topmostRoute.params}; + } +}; + +export default getTopmostNestedRHPRoute; diff --git a/src/libs/Navigation/linkTo.ts b/src/libs/Navigation/linkTo.ts index b7746108ac93c..c60bab23c95b0 100644 --- a/src/libs/Navigation/linkTo.ts +++ b/src/libs/Navigation/linkTo.ts @@ -1,14 +1,20 @@ import {getActionFromState} from '@react-navigation/core'; import type {NavigationAction, NavigationContainerRef, NavigationState, PartialState} from '@react-navigation/native'; import type {Writable} from 'type-fest'; +import getIsSmallScreenWidth from '@libs/getIsSmallScreenWidth'; import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; import type {Route} from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; import dismissModal from './dismissModal'; +import getMatchingBottomTabRouteForState from './getMatchingBottomTabRouteForState'; +import getMatchingCentralPaneRouteForState from './getMatchingCentralPaneRouteForState'; import getStateFromPath from './getStateFromPath'; +import getTopmostBottomTabRoute from './getTopmostBottomTabRoute'; +import getTopmostCentralPaneRoute from './getTopmostCentralPaneRoute'; import getTopmostReportId from './getTopmostReportId'; import linkingConfig from './linkingConfig'; -import type {NavigationRoot, RootStackParamList, StackNavigationAction} from './types'; +import type {NavigationRoot, RootStackParamList, StackNavigationAction, State} from './types'; type ActionPayloadParams = { screen?: string; @@ -29,7 +35,7 @@ type ActionPayload = { */ function getMinimalAction(action: NavigationAction, state: NavigationState): Writable { let currentAction: NavigationAction = action; - let currentState: NavigationState | PartialState | undefined = state; + let currentState: State | undefined = state; let currentTargetKey: string | undefined; while (currentAction.payload && 'name' in currentAction.payload && currentState?.routes[currentState.index ?? -1].name === currentAction.payload.name) { @@ -56,6 +62,27 @@ function getMinimalAction(action: NavigationAction, state: NavigationState): Wri return currentAction; } +// Because we need to change the type to push, we also need to set target for this action to the bottom tab navigator. +function getActionForBottomTabNavigator(action: StackNavigationAction, state: NavigationState): Writable | undefined { + const bottomTabNavigatorRoute = state.routes.at(0); + + if (!bottomTabNavigatorRoute || bottomTabNavigatorRoute.state === undefined || !action || action.type !== CONST.NAVIGATION.ACTION_TYPE.NAVIGATE) { + return; + } + + const params = action.payload.params as ActionPayloadParams; + const screen = params.screen; + + return { + type: CONST.NAVIGATION.ACTION_TYPE.PUSH, + payload: { + name: screen, + params: params.params, + }, + target: bottomTabNavigatorRoute.state.key, + }; +} + function isModalNavigator(targetNavigator?: string) { return targetNavigator === NAVIGATORS.LEFT_MODAL_NAVIGATOR || targetNavigator === NAVIGATORS.RIGHT_MODAL_NAVIGATOR; } @@ -74,12 +101,13 @@ export default function linkTo(navigation: NavigationContainerRef; + const stateFromPath = getStateFromPath(path) as PartialState>; + const action: StackNavigationAction = getActionFromState(stateFromPath, linkingConfig.config); // If action type is different than NAVIGATE we can't change it to the PUSH safely if (action?.type === CONST.NAVIGATION.ACTION_TYPE.NAVIGATE) { + const topmostCentralPaneRoute = getTopmostCentralPaneRoute(rootState); const topRouteName = rootState?.routes?.at(-1)?.name; const isTargetNavigatorOnTop = topRouteName === action.payload.name; @@ -88,7 +116,21 @@ export default function linkTo(navigation: NavigationContainerRef = { + getStateFromPath: getAdaptedStateFromPath, prefixes: [ 'app://-/', 'new-expensify://', @@ -17,7 +19,7 @@ const linkingConfig: LinkingOptions = { CONST.STAGING_NEW_EXPENSIFY_URL, ], config: { - initialRouteName: SCREENS.HOME, + initialRouteName: NAVIGATORS.BOTTOM_TAB_NAVIGATOR, screens: { // Main Routes [SCREENS.VALIDATE_LOGIN]: ROUTES.VALIDATE_LOGIN, @@ -31,13 +33,43 @@ const linkingConfig: LinkingOptions = { [SCREENS.REPORT_ATTACHMENTS]: ROUTES.REPORT_ATTACHMENTS.route, // Sidebar - [SCREENS.HOME]: { - path: ROUTES.HOME, + [NAVIGATORS.BOTTOM_TAB_NAVIGATOR]: { + path: '', + initialRouteName: SCREENS.HOME, + screens: { + [SCREENS.HOME]: ROUTES.HOME, + [SCREENS.ALL_SETTINGS]: ROUTES.ALL_SETTINGS, + [SCREENS.WORKSPACE.INITIAL]: { + path: ROUTES.WORKSPACE_INITIAL.route, + exact: true, + }, + }, }, [NAVIGATORS.CENTRAL_PANE_NAVIGATOR]: { screens: { [SCREENS.REPORT]: ROUTES.REPORT_WITH_ID.route, + + [SCREENS.SETTINGS.WORKSPACES]: ROUTES.SETTINGS_WORKSPACES, + [SCREENS.WORKSPACE.OVERVIEW]: ROUTES.WORKSPACE_OVERVIEW.route, + [SCREENS.WORKSPACE.CARD]: { + path: ROUTES.WORKSPACE_CARD.route, + }, + [SCREENS.WORKSPACE.REIMBURSE]: { + path: ROUTES.WORKSPACE_REIMBURSE.route, + }, + [SCREENS.WORKSPACE.BILLS]: { + path: ROUTES.WORKSPACE_BILLS.route, + }, + [SCREENS.WORKSPACE.INVOICES]: { + path: ROUTES.WORKSPACE_INVOICES.route, + }, + [SCREENS.WORKSPACE.TRAVEL]: { + path: ROUTES.WORKSPACE_TRAVEL.route, + }, + [SCREENS.WORKSPACE.MEMBERS]: { + path: ROUTES.WORKSPACE_MEMBERS.route, + }, }, }, [SCREENS.NOT_FOUND]: '*', @@ -48,23 +80,19 @@ const linkingConfig: LinkingOptions = { [SCREENS.SEARCH_ROOT]: ROUTES.SEARCH, }, }, + [SCREENS.LEFT_MODAL.WORKSPACE_SWITCHER]: { + screens: { + [SCREENS.WORKSPACE_SWITCHER.ROOT]: { + path: ROUTES.WORKSPACE_SWITCHER, + }, + }, + }, }, }, [NAVIGATORS.RIGHT_MODAL_NAVIGATOR]: { screens: { [SCREENS.RIGHT_MODAL.SETTINGS]: { screens: { - [SCREENS.SETTINGS.ROOT]: { - path: ROUTES.SETTINGS, - }, - [SCREENS.SETTINGS.WORKSPACES]: { - path: ROUTES.SETTINGS_WORKSPACES, - exact: true, - }, - [SCREENS.SETTINGS.PREFERENCES.ROOT]: { - path: ROUTES.SETTINGS_PREFERENCES, - exact: true, - }, [SCREENS.SETTINGS.PREFERENCES.PRIORITY_MODE]: { path: ROUTES.SETTINGS_PRIORITY_MODE, exact: true, @@ -81,14 +109,6 @@ const linkingConfig: LinkingOptions = { path: ROUTES.SETTINGS_CLOSE, exact: true, }, - [SCREENS.SETTINGS.SECURITY]: { - path: ROUTES.SETTINGS_SECURITY, - exact: true, - }, - [SCREENS.SETTINGS.WALLET.ROOT]: { - path: ROUTES.SETTINGS_WALLET, - exact: true, - }, [SCREENS.SETTINGS.WALLET.DOMAIN_CARD]: { path: ROUTES.SETTINGS_WALLET_DOMAINCARD.route, exact: true, @@ -145,10 +165,6 @@ const linkingConfig: LinkingOptions = { path: ROUTES.SETTINGS_ADD_BANK_ACCOUNT, exact: true, }, - [SCREENS.SETTINGS.PROFILE.ROOT]: { - path: ROUTES.SETTINGS_PROFILE, - exact: true, - }, [SCREENS.SETTINGS.PROFILE.PRONOUNS]: { path: ROUTES.SETTINGS_PRONOUNS, exact: true, @@ -165,10 +181,6 @@ const linkingConfig: LinkingOptions = { path: ROUTES.SETTINGS_TIMEZONE_SELECT, exact: true, }, - [SCREENS.SETTINGS.ABOUT]: { - path: ROUTES.SETTINGS_ABOUT, - exact: true, - }, [SCREENS.SETTINGS.APP_DOWNLOAD_LINKS]: { path: ROUTES.SETTINGS_APP_DOWNLOAD_LINKS, exact: true, @@ -211,10 +223,6 @@ const linkingConfig: LinkingOptions = { path: ROUTES.SETTINGS_2FA.route, exact: true, }, - [SCREENS.SETTINGS.SHARE_CODE]: { - path: ROUTES.SETTINGS_SHARE_CODE, - exact: true, - }, [SCREENS.SETTINGS.PROFILE.STATUS]: { path: ROUTES.SETTINGS_STATUS, exact: true, @@ -228,36 +236,12 @@ const linkingConfig: LinkingOptions = { [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_TIME]: { path: ROUTES.SETTINGS_STATUS_CLEAR_AFTER_TIME, }, - [SCREENS.WORKSPACE.INITIAL]: { - path: ROUTES.WORKSPACE_INITIAL.route, - }, - [SCREENS.WORKSPACE.SETTINGS]: { - path: ROUTES.WORKSPACE_SETTINGS.route, - }, [SCREENS.WORKSPACE.CURRENCY]: { - path: ROUTES.WORKSPACE_SETTINGS_CURRENCY.route, - }, - [SCREENS.WORKSPACE.CARD]: { - path: ROUTES.WORKSPACE_CARD.route, - }, - [SCREENS.WORKSPACE.REIMBURSE]: { - path: ROUTES.WORKSPACE_REIMBURSE.route, + path: ROUTES.WORKSPACE_OVERVIEW_CURRENCY.route, }, [SCREENS.WORKSPACE.RATE_AND_UNIT]: { path: ROUTES.WORKSPACE_RATE_AND_UNIT.route, }, - [SCREENS.WORKSPACE.BILLS]: { - path: ROUTES.WORKSPACE_BILLS.route, - }, - [SCREENS.WORKSPACE.INVOICES]: { - path: ROUTES.WORKSPACE_INVOICES.route, - }, - [SCREENS.WORKSPACE.TRAVEL]: { - path: ROUTES.WORKSPACE_TRAVEL.route, - }, - [SCREENS.WORKSPACE.MEMBERS]: { - path: ROUTES.WORKSPACE_MEMBERS.route, - }, [SCREENS.WORKSPACE.INVITE]: { path: ROUTES.WORKSPACE_INVITE.route, }, @@ -274,6 +258,7 @@ const linkingConfig: LinkingOptions = { [SCREENS.KEYBOARD_SHORTCUTS]: { path: ROUTES.KEYBOARD_SHORTCUTS, }, + [SCREENS.WORKSPACE.NAME]: ROUTES.WORKSPACE_OVERVIEW_NAME.route, }, }, [SCREENS.RIGHT_MODAL.PRIVATE_NOTES]: { @@ -499,6 +484,43 @@ const linkingConfig: LinkingOptions = { }, }, }, + + [NAVIGATORS.FULL_SCREEN_NAVIGATOR]: { + initialRouteName: SCREENS.SETTINGS.ROOT, + screens: { + [SCREENS.SETTINGS.ROOT]: { + path: ROUTES.SETTINGS, + }, + [SCREENS.SETTINGS_CENTRAL_PANE]: { + screens: { + [SCREENS.SETTINGS.SHARE_CODE]: { + path: ROUTES.SETTINGS_SHARE_CODE, + exact: true, + }, + [SCREENS.SETTINGS.PROFILE.ROOT]: { + path: ROUTES.SETTINGS_PROFILE, + exact: true, + }, + [SCREENS.SETTINGS.PREFERENCES.ROOT]: { + path: ROUTES.SETTINGS_PREFERENCES, + exact: true, + }, + [SCREENS.SETTINGS.SECURITY]: { + path: ROUTES.SETTINGS_SECURITY, + exact: true, + }, + [SCREENS.SETTINGS.WALLET.ROOT]: { + path: ROUTES.SETTINGS_WALLET, + exact: true, + }, + [SCREENS.SETTINGS.ABOUT]: { + path: ROUTES.SETTINGS_ABOUT, + exact: true, + }, + }, + }, + }, + }, }, }, }; diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 90f5361f11f40..e7d2049951626 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1,5 +1,15 @@ /* eslint-disable @typescript-eslint/naming-convention */ -import type {CommonActions, NavigationContainerRefWithCurrent, NavigationHelpers, NavigationState, NavigatorScreenParams, PartialRoute, Route} from '@react-navigation/native'; +import type { + CommonActions, + NavigationContainerRefWithCurrent, + NavigationHelpers, + NavigationState, + NavigatorScreenParams, + ParamListBase, + PartialRoute, + PartialState, + Route, +} from '@react-navigation/native'; import type {ValueOf} from 'type-fest'; import type CONST from '@src/CONST'; import type NAVIGATORS from '@src/NAVIGATORS'; @@ -30,8 +40,9 @@ type ActionNavigate = { type StackNavigationAction = GoBackAction | ResetAction | SetParamsAction | ActionNavigate | undefined; type NavigationStateRoute = NavigationState['routes'][number]; -type NavigationPartialRoute = PartialRoute>; +type NavigationPartialRoute = PartialRoute>; type StateOrRoute = NavigationState | NavigationStateRoute | NavigationPartialRoute; +type State = NavigationState | PartialState>; type CentralPaneNavigatorParamList = { [SCREENS.REPORT]: { @@ -39,6 +50,37 @@ type CentralPaneNavigatorParamList = { reportID: string; openOnAdminRoom?: boolean; }; + + [SCREENS.SETTINGS.WORKSPACES]: undefined; + [SCREENS.WORKSPACE.OVERVIEW]: { + policyID: string; + }; + [SCREENS.WORKSPACE.CARD]: { + policyID: string; + }; + [SCREENS.WORKSPACE.REIMBURSE]: { + policyID: string; + }; + [SCREENS.WORKSPACE.BILLS]: { + policyID: string; + }; + [SCREENS.WORKSPACE.INVOICES]: { + policyID: string; + }; + [SCREENS.WORKSPACE.TRAVEL]: { + policyID: string; + }; + [SCREENS.WORKSPACE.MEMBERS]: { + policyID: string; + }; + [SCREENS.REIMBURSEMENT_ACCOUNT]: { + stepToOpen: string; + policyID: string; + }; +}; + +type WorkspaceSwitcherNavigatorParamList = { + [SCREENS.WORKSPACE_SWITCHER.ROOT]: undefined; }; type SettingsNavigatorParamList = { @@ -86,37 +128,14 @@ type SettingsNavigatorParamList = { [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_DATE]: undefined; [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_TIME]: undefined; [SCREENS.WORKSPACE.INITIAL]: undefined; - [SCREENS.WORKSPACE.SETTINGS]: undefined; [SCREENS.WORKSPACE.CURRENCY]: undefined; - [SCREENS.WORKSPACE.CARD]: { - policyID: string; - }; - [SCREENS.WORKSPACE.REIMBURSE]: { - policyID: string; - }; [SCREENS.WORKSPACE.RATE_AND_UNIT]: undefined; - [SCREENS.WORKSPACE.BILLS]: { - policyID: string; - }; - [SCREENS.WORKSPACE.INVOICES]: { - policyID: string; - }; - [SCREENS.WORKSPACE.TRAVEL]: { - policyID: string; - }; - [SCREENS.WORKSPACE.MEMBERS]: { - policyID: string; - }; [SCREENS.WORKSPACE.INVITE]: { policyID: string; }; [SCREENS.WORKSPACE.INVITE_MESSAGE]: { policyID: string; }; - [SCREENS.REIMBURSEMENT_ACCOUNT]: { - stepToOpen: string; - policyID: string; - }; [SCREENS.GET_ASSISTANCE]: { taskID: string; }; @@ -352,6 +371,7 @@ type PrivateNotesNavigatorParamList = { type LeftModalNavigatorParamList = { [SCREENS.LEFT_MODAL.SEARCH]: NavigatorScreenParams; + [SCREENS.LEFT_MODAL.WORKSPACE_SWITCHER]: NavigatorScreenParams; }; type RightModalNavigatorParamList = { @@ -381,8 +401,29 @@ type RightModalNavigatorParamList = { [SCREENS.RIGHT_MODAL.PRIVATE_NOTES]: NavigatorScreenParams; }; -type PublicScreensParamList = { +type SettingsCentralPaneNavigatorParamList = { + [SCREENS.SETTINGS.SHARE_CODE]: undefined; + [SCREENS.SETTINGS.WORKSPACES]: undefined; + [SCREENS.SETTINGS.PROFILE.ROOT]: undefined; + [SCREENS.SETTINGS.PREFERENCES.ROOT]: undefined; + [SCREENS.SETTINGS.SECURITY]: undefined; + [SCREENS.SETTINGS.WALLET.ROOT]: undefined; + [SCREENS.SETTINGS.ABOUT]: undefined; +}; + +type FullScreenNavigatorParamList = { + [SCREENS.SETTINGS.ROOT]: undefined; + [SCREENS.SETTINGS_CENTRAL_PANE]: NavigatorScreenParams; +}; + +type BottomTabNavigatorParamList = { [SCREENS.HOME]: undefined; + [SCREENS.ALL_SETTINGS]: undefined; + [SCREENS.WORKSPACE.INITIAL]: undefined; +}; + +type PublicScreensParamList = { + [NAVIGATORS.BOTTOM_TAB_NAVIGATOR]: NavigatorScreenParams; [SCREENS.TRANSITION_BETWEEN_APPS]: { shouldForceLogin: string; email: string; @@ -403,7 +444,7 @@ type PublicScreensParamList = { }; type AuthScreensParamList = { - [SCREENS.HOME]: undefined; + [NAVIGATORS.BOTTOM_TAB_NAVIGATOR]: NavigatorScreenParams; [NAVIGATORS.CENTRAL_PANE_NAVIGATOR]: NavigatorScreenParams; [SCREENS.VALIDATE_LOGIN]: { accountID: string; @@ -423,20 +464,32 @@ type AuthScreensParamList = { [SCREENS.NOT_FOUND]: undefined; [NAVIGATORS.LEFT_MODAL_NAVIGATOR]: NavigatorScreenParams; [NAVIGATORS.RIGHT_MODAL_NAVIGATOR]: NavigatorScreenParams; + [NAVIGATORS.FULL_SCREEN_NAVIGATOR]: NavigatorScreenParams; [SCREENS.DESKTOP_SIGN_IN_REDIRECT]: undefined; }; type RootStackParamList = PublicScreensParamList & AuthScreensParamList; +type BottomTabName = keyof BottomTabNavigatorParamList; + +type CentralPaneName = keyof CentralPaneNavigatorParamList; + +type FullScreenName = keyof SettingsCentralPaneNavigatorParamList; + export type { NavigationRef, StackNavigationAction, CentralPaneNavigatorParamList, + BottomTabName, + CentralPaneName, + FullScreenName, RootStackParamList, StateOrRoute, NavigationStateRoute, + NavigationPartialRoute, NavigationRoot, AuthScreensParamList, + BottomTabNavigatorParamList, LeftModalNavigatorParamList, RightModalNavigatorParamList, PublicScreensParamList, @@ -465,4 +518,6 @@ export type { SignInNavigatorParamList, ReferralDetailsNavigatorParamList, ReimbursementAccountNavigatorParamList, + State, + WorkspaceSwitcherNavigatorParamList, }; diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 2c5c72ab1c397..3e8950e393f3d 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -86,10 +86,7 @@ function getPolicyBrickRoadIndicatorStatus(policy: OnyxEntry, policyMemb */ function shouldShowPolicy(policy: OnyxEntry, isOffline: boolean): boolean { return ( - !!policy && - policy?.isPolicyExpenseChatEnabled && - policy?.role === CONST.POLICY.ROLE.ADMIN && - (isOffline || policy?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || Object.keys(policy.errors ?? {}).length > 0) + !!policy && policy?.isPolicyExpenseChatEnabled && (isOffline || policy?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || Object.keys(policy.errors ?? {}).length > 0) ); } diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 0d7658adf180b..a91a113624746 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -25,6 +25,7 @@ import type DeepValueOf from '@src/types/utils/DeepValueOf'; import type {EmptyObject} from '@src/types/utils/EmptyObject'; import {isEmptyObject, isNotEmptyObject} from '@src/types/utils/EmptyObject'; import type IconAsset from '@src/types/utils/IconAsset'; +import Log from "./Log" import * as CurrencyUtils from './CurrencyUtils'; import DateUtils from './DateUtils'; import isReportMessageAttachment from './isReportMessageAttachment'; @@ -326,7 +327,7 @@ type OptionData = { text: string; alternateText?: string | null; allReportErrors?: Errors | null; - brickRoadIndicator?: typeof CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR | '' | null; + brickRoadIndicator?: typeof CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR | typeof CONST.BRICK_ROAD_INDICATOR_STATUS.INFO | '' | null; tooltipText?: string | null; alternateTextMaxLines?: number; boldStyle?: boolean; @@ -3437,11 +3438,18 @@ function shouldHideReport(report: OnyxEntry, currentReportId: string): b * This logic is very specific and the order of the logic is very important. It should fail quickly in most cases and also * filter out the majority of reports before filtering out very specific minority of reports. */ -function shouldReportBeInOptionList(report: OnyxEntry, currentReportId: string, isInGSDMode: boolean, betas: Beta[], policies: OnyxCollection, excludeEmptyChats = false) { +function shouldReportBeInOptionList(report: OnyxEntry, currentReportId: string, isInGSDMode: boolean, betas: Beta[], policies: OnyxCollection, excludeEmptyChats = false, activeWorkspaceID: string | undefined = undefined) { const isInDefaultMode = !isInGSDMode; // Exclude reports that have no data because there wouldn't be anything to show in the option item. // This can happen if data is currently loading from the server or a report is in various stages of being created. // This can also happen for anyone accessing a public room or archived room for which they don't have access to the underlying policy. + // Optionally exclude reports that do not belong to currently active workspace + + Log.info(`active workspace id ${activeWorkspaceID}`) + if (activeWorkspaceID && report?.policyID !== activeWorkspaceID && !isConciergeChatReport(report)) { + return false; + } + if ( !report?.reportID || !report?.type || diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index 6e46ec3200667..b47c66e1a4d87 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -120,6 +120,7 @@ function getOrderedReportIDs( policies: Record, priorityMode: ValueOf, allReportActions: OnyxCollection, + activeWorkspaceID: string | undefined = undefined, ): string[] { // Generate a unique cache key based on the function arguments const cachedReportsKey = JSON.stringify( @@ -151,7 +152,7 @@ function getOrderedReportIDs( const isInDefaultMode = !isInGSDMode; const allReportsDictValues = Object.values(allReports); // Filter out all the reports that shouldn't be displayed - const reportsToDisplay = allReportsDictValues.filter((report) => ReportUtils.shouldReportBeInOptionList(report, currentReportId ?? '', isInGSDMode, betas, policies, true)); + const reportsToDisplay = allReportsDictValues.filter((report) => ReportUtils.shouldReportBeInOptionList(report, currentReportId ?? '', isInGSDMode, betas, policies, true, activeWorkspaceID)); if (reportsToDisplay.length === 0) { // Display Concierge chat report when there is no report to be displayed diff --git a/src/libs/WorkspacesUtils.ts b/src/libs/WorkspacesUtils.ts new file mode 100644 index 0000000000000..5ce3f1f465fde --- /dev/null +++ b/src/libs/WorkspacesUtils.ts @@ -0,0 +1,102 @@ +import Onyx, {OnyxCollection} from 'react-native-onyx'; +import {ValueOf} from 'type-fest'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import {Report} from '@src/types/onyx'; +import * as OptionsListUtils from './OptionsListUtils'; +import * as ReportActionsUtils from './ReportActionsUtils'; +import * as ReportUtils from './ReportUtils'; + +let allReports: OnyxCollection; + +type BrickRoad = ValueOf | undefined; + +Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (value) => (allReports = value), +}); + +/** + * @param report + * @returns BrickRoad for the policy passed as a param + */ +const getBrickRoadForPolicy = (report: Report): BrickRoad => { + const reportActions = ReportActionsUtils.getAllReportActions(report.reportID); + const reportErrors = OptionsListUtils.getAllReportErrors(report, reportActions); + const doesReportContainErrors = Object.keys(reportErrors ?? {}).length !== 0 ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined; + if (doesReportContainErrors) { + return CONST.BRICK_ROAD.RBR; + } + + // To determine if the report requires attention from the current user, we need to load the parent report action + let itemParentReportAction = {}; + if (report.parentReportID) { + const itemParentReportActions = ReportActionsUtils.getAllReportActions(report.parentReportID); + itemParentReportAction = report.parentReportActionID ? itemParentReportActions[report.parentReportActionID] : {}; + } + const reportOption = {...report, isUnread: ReportUtils.isUnread(report), isUnreadWithMention: ReportUtils.isUnreadWithMention(report)}; + const shouldShowGreenDotIndicator = ReportUtils.requiresAttentionFromCurrentUser(reportOption, itemParentReportAction); + return shouldShowGreenDotIndicator ? CONST.BRICK_ROAD.GBR : undefined; +}; + +/** + * @returns a map where the keys are policyIDs and the values are BrickRoads for each policy + */ +function getWorkspacesBrickRoads(): Record { + if (!allReports) { + return {}; + } + + // The key in this map is the workspace id + const workspacesBrickRoadsMap: Record = {}; + + Object.keys(allReports).forEach((report) => { + const policyID = allReports?.[report]?.policyID; + const policyReport = allReports ? allReports[report] : null; + if (!policyID || !policyReport || workspacesBrickRoadsMap[policyID] === CONST.BRICK_ROAD.RBR) { + return; + } + const workspaceBrickRoad = getBrickRoadForPolicy(policyReport); + + if (!workspaceBrickRoad && !!workspacesBrickRoadsMap[policyID]) { + return; + } + + workspacesBrickRoadsMap[policyID] = workspaceBrickRoad; + }); + + return workspacesBrickRoadsMap; +} + +/** + * @returns a map where the keys are policyIDs and the values are truthy booleans if policy has unread content + */ +function getWorkspacesUnreadStatuses(): Record { + if (!allReports) { + return {}; + } + + const workspacesUnreadStatuses: Record = {}; + + Object.keys(allReports).forEach((report) => { + const policyID = allReports?.[report]?.policyID; + const policyReport = allReports ? allReports[report] : null; + if (!policyID || !policyReport) { + return; + } + + const unreadStatus = ReportUtils.isUnread(policyReport); + + if (unreadStatus) { + workspacesUnreadStatuses[policyID] = true; + } else { + workspacesUnreadStatuses[policyID] = false; + } + }); + + return workspacesUnreadStatuses; +} + +export {getBrickRoadForPolicy, getWorkspacesBrickRoads, getWorkspacesUnreadStatuses}; +export type {BrickRoad}; diff --git a/src/libs/actions/App.ts b/src/libs/actions/App.ts index 798d94bfb0e03..306e33aded95a 100644 --- a/src/libs/actions/App.ts +++ b/src/libs/actions/App.ts @@ -116,7 +116,7 @@ function setLocale(locale: Locale) { function setLocaleAndNavigate(locale: Locale) { setLocale(locale); - Navigation.goBack(ROUTES.SETTINGS_PREFERENCES); + Navigation.goBack(); } function setSidebarLoaded() { diff --git a/src/libs/actions/PersonalDetails.ts b/src/libs/actions/PersonalDetails.ts index 0e1662da4d555..30198e32fe97d 100644 --- a/src/libs/actions/PersonalDetails.ts +++ b/src/libs/actions/PersonalDetails.ts @@ -129,7 +129,7 @@ function updatePronouns(pronouns: string) { }); } - Navigation.goBack(ROUTES.SETTINGS_PROFILE); + Navigation.goBack(); } function updateDisplayName(firstName: string, lastName: string) { @@ -161,7 +161,7 @@ function updateDisplayName(firstName: string, lastName: string) { }); } - Navigation.goBack(ROUTES.SETTINGS_PROFILE); + Navigation.goBack(); } function updateLegalName(legalFirstName: string, legalLastName: string) { diff --git a/src/libs/actions/TwoFactorAuthActions.ts b/src/libs/actions/TwoFactorAuthActions.ts index c4b74836f9dbb..56b42f7ee0d38 100644 --- a/src/libs/actions/TwoFactorAuthActions.ts +++ b/src/libs/actions/TwoFactorAuthActions.ts @@ -2,7 +2,6 @@ import Onyx from 'react-native-onyx'; import Navigation from '@libs/Navigation/Navigation'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Route} from '@src/ROUTES'; -import ROUTES from '@src/ROUTES'; import type {TwoFactorAuthStep} from '@src/types/onyx/Account'; /** @@ -22,7 +21,7 @@ function setCodesAreCopied() { function quitAndNavigateBack(backTo?: Route) { clearTwoFactorAuthData(); // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - Navigation.goBack(backTo || ROUTES.SETTINGS_SECURITY); + Navigation.goBack(backTo || ''); } export {clearTwoFactorAuthData, setTwoFactorAuthStep, quitAndNavigateBack, setCodesAreCopied}; diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts index 8da451f9d64db..1b168fee90bb8 100644 --- a/src/libs/actions/User.ts +++ b/src/libs/actions/User.ts @@ -607,7 +607,7 @@ function updateChatPriorityMode(mode: ValueOf, autom API.write('UpdateChatPriorityMode', parameters, {optimisticData}); if (!autoSwitchedToFocusMode) { - Navigation.goBack(ROUTES.SETTINGS_PREFERENCES); + Navigation.goBack(); } } @@ -801,7 +801,7 @@ function updateTheme(theme: ValueOf) { API.write('UpdateTheme', parameters, {optimisticData}); - Navigation.navigate(ROUTES.SETTINGS_PREFERENCES); + Navigation.goBack(); } /** diff --git a/src/pages/AddPersonalBankAccountPage.js b/src/pages/AddPersonalBankAccountPage.js index 09b73ea158f96..182ef2ba4eacd 100644 --- a/src/pages/AddPersonalBankAccountPage.js +++ b/src/pages/AddPersonalBankAccountPage.js @@ -97,7 +97,7 @@ function AddPersonalBankAccountPage({personalBankAccount, plaidData}) { diff --git a/src/pages/EnablePayments/AdditionalDetailsStep.js b/src/pages/EnablePayments/AdditionalDetailsStep.js index 18da2c11a0e6c..1f50406b89be4 100644 --- a/src/pages/EnablePayments/AdditionalDetailsStep.js +++ b/src/pages/EnablePayments/AdditionalDetailsStep.js @@ -149,7 +149,7 @@ function AdditionalDetailsStep({walletAdditionalDetails, translate, currentUserP if (!_.isEmpty(walletAdditionalDetails.questions)) { return ( diff --git a/src/pages/NewChatPage.js b/src/pages/NewChatPage.js index 211cb303d061b..f3c4b20696a9a 100755 --- a/src/pages/NewChatPage.js +++ b/src/pages/NewChatPage.js @@ -219,7 +219,7 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, i diff --git a/src/pages/ProfilePage.js b/src/pages/ProfilePage.js index 8432d25b6ad75..47c11aedf1ec8 100755 --- a/src/pages/ProfilePage.js +++ b/src/pages/ProfilePage.js @@ -170,10 +170,10 @@ function ProfilePage(props) { > diff --git a/src/pages/ReimbursementAccount/BankAccountPlaidStep.js b/src/pages/ReimbursementAccount/BankAccountPlaidStep.js index c235a31f626fc..b32d211a8a3db 100644 --- a/src/pages/ReimbursementAccount/BankAccountPlaidStep.js +++ b/src/pages/ReimbursementAccount/BankAccountPlaidStep.js @@ -90,7 +90,7 @@ function BankAccountPlaidStep(props) { + Navigation.goBack(isReport ? ROUTES.REPORT_WITH_ID_DETAILS.getRoute(this.props.report.reportID) : ROUTES.SETTINGS)} + onBackButtonPress={() => Navigation.goBack(isReport ? ROUTES.REPORT_WITH_ID_DETAILS.getRoute(this.props.report.reportID) : ROUTES.SETTINGS.ROOT)} + shouldShowBackButton={isReport || this.props.isSmallScreenWidth} /> @@ -106,6 +112,7 @@ class ShareCodePage extends React.Component { successIcon={Expensicons.Checkmark} successText={this.props.translate('qrCodes.copied')} onPress={() => Clipboard.setString(url)} + shouldLimitWidth={false} /> {isNative && ( @@ -134,4 +141,4 @@ ShareCodePage.propTypes = propTypes; ShareCodePage.defaultProps = defaultProps; ShareCodePage.displayName = 'ShareCodePage'; -export default compose(withEnvironment, withLocalize, withCurrentUserPersonalDetails, withThemeStyles)(ShareCodePage); +export default compose(withEnvironment, withLocalize, withCurrentUserPersonalDetails, withThemeStyles, withWindowDimensions)(ShareCodePage); diff --git a/src/pages/WorkspaceSwitcherPage.js b/src/pages/WorkspaceSwitcherPage.js new file mode 100644 index 0000000000000..3fc46acc1994c --- /dev/null +++ b/src/pages/WorkspaceSwitcherPage.js @@ -0,0 +1,317 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +import PropTypes from 'prop-types'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import {View} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import _ from 'underscore'; +import HeaderPageLayout from '@components/HeaderPageLayout'; +import Icon from '@components/Icon'; +import * as Expensicons from '@components/Icon/Expensicons'; +import OptionRow from '@components/OptionRow'; +import OptionsSelector from '@components/OptionsSelector'; +import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; +import Text from '@components/Text'; +import useAutoFocusInput from '@hooks/useAutoFocusInput'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useTheme from '@hooks/useTheme'; +import useActiveWorkspace from '@hooks/useActiveWorkspace'; +import useThemeStyles from '@hooks/useThemeStyles'; +import Navigation from '@libs/Navigation/Navigation'; +import * as PolicyUtils from '@libs/PolicyUtils'; +import * as ReportUtils from '@libs/ReportUtils'; +import {getWorkspacesBrickRoads, getWorkspacesUnreadStatuses} from '@libs/WorkspacesUtils'; +import * as App from '@userActions/App'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; +import WorkspaceCardCreateAWorkspace from './workspace/card/WorkspaceCardCreateAWorkspace'; + +const propTypes = { + /** The list of this user's policies */ + policies: PropTypes.objectOf( + PropTypes.shape({ + /** The ID of the policy */ + id: PropTypes.string, + + /** The name of the policy */ + name: PropTypes.string, + + /** The type of the policy */ + type: PropTypes.string, + + /** The user's role in the policy */ + role: PropTypes.string, + + /** The current action that is waiting to happen on the policy */ + pendingAction: PropTypes.oneOf(_.values(CONST.RED_BRICK_ROAD_PENDING_ACTION)), + }), + ), +}; + +const defaultProps = { + policies: {}, +}; + +const MINIMUM_WORKSPACES_TO_SHOW_SEARCH = 8; +const EXPENSIFY_TITLE = 'Expensify'; + +function WorkspaceSwitcherPage({policies}) { + const theme = useTheme(); + const styles = useThemeStyles(); + const {isOffline} = useNetwork(); + const [selectedOption, setSelectedOption] = useState(); + const [searchTerm, setSearchTerm] = useState(''); + const {inputCallbackRef} = useAutoFocusInput(); + const {translate} = useLocalize(); + const {activeWorkspaceID, setActiveWorkspaceID} = useActiveWorkspace(); + + const brickRoadsForPolicies = useMemo(() => getWorkspacesBrickRoads(), []); + const unreadStatusesForPolicies = useMemo(() => getWorkspacesUnreadStatuses(), []); + + const getIndicatorTypeForPolicy = useCallback( + (policyId) => { + if (policyId && policyId !== activeWorkspaceID) { + return brickRoadsForPolicies[policyId]; + } + + if (_.values(brickRoadsForPolicies).includes(CONST.BRICK_ROAD.RBR)) { + return CONST.BRICK_ROAD.RBR; + } + + if (_.values(brickRoadsForPolicies).includes(CONST.BRICK_ROAD.GBR)) { + return CONST.BRICK_ROAD.GBR; + } + + return undefined; + }, + [activeWorkspaceID, brickRoadsForPolicies], + ); + + const hasUnreadData = useCallback( + // TO DO: Implement checking if policy has some unread data + // eslint-disable-next-line no-unused-vars + (policyId) => { + if (policyId) { + return unreadStatusesForPolicies[policyId]; + } + + return _.some(_.values(unreadStatusesForPolicies), (status) => status); + }, + [unreadStatusesForPolicies], + ); + + const selectPolicy = useCallback((option) => { + const policyID = option.policyID; + + if (policyID) { + setSelectedOption(option); + } else { + setSelectedOption(undefined); + } + // Temporary: This will be handled in custom navigation function that also puts policyID in BottomTabNavigator state + setActiveWorkspaceID(policyID); + Navigation.goBack(); + Navigation.navigate(ROUTES.HOME); + }, []); + + const onChangeText = useCallback((newSearchTerm) => { + setSearchTerm(newSearchTerm); + }, []); + + const usersWorkspaces = useMemo( + () => + _.chain(policies) + .filter((policy) => PolicyUtils.shouldShowPolicy(policy, isOffline)) + .map((policy) => ({ + text: policy.name, + policyID: policy.id, + brickRoadIndicator: getIndicatorTypeForPolicy(policy.id), + icons: [ + { + source: policy.avatar ? policy.avatar : ReportUtils.getDefaultWorkspaceAvatar(policy.name), + fallbackIcon: Expensicons.FallbackWorkspaceAvatar, + name: policy.name, + type: CONST.ICON_TYPE_WORKSPACE, + }, + ], + boldStyle: hasUnreadData(policy.id), + keyForList: policy.id, + })) + .sortBy((policy) => policy.text.toLowerCase()) + .value(), + [policies, getIndicatorTypeForPolicy, hasUnreadData], + ); + + const filteredUserWorkspaces = useMemo(() => _.filter(usersWorkspaces, (policy) => policy.text.toLowerCase().startsWith(searchTerm.toLowerCase())), [searchTerm, usersWorkspaces]); + + const usersWorkspacesSectionData = useMemo( + () => ({ + data: filteredUserWorkspaces, + shouldShow: true, + indexOffset: 0, + }), + [filteredUserWorkspaces], + ); + + const everythingSection = useMemo(() => { + const option = { + text: EXPENSIFY_TITLE, + icons: [ + { + source: Expensicons.ExpensifyAppIcon, + name: EXPENSIFY_TITLE, + type: CONST.ICON_TYPE_AVATAR, + }, + ], + brickRoadIndicator: getIndicatorTypeForPolicy(undefined), + boldStyle: hasUnreadData(undefined), + }; + + return ( + <> + + + {translate('workspace.switcher.everythingSection')} + + + + + + + ); + }, [ + activeWorkspaceID, + getIndicatorTypeForPolicy, + hasUnreadData, + selectPolicy, + styles.alignItemsCenter, + styles.flexRow, + styles.justifyContentBetween, + styles.label, + styles.mb3, + styles.mh4, + theme.textSupporting, + translate, + ]); + + const workspacesSection = useMemo( + () => ( + <> + 0 ? [styles.mb1] : [styles.mb3])]}> + + + {translate('common.workspaces')} + + + { + App.createWorkspaceWithPolicyDraftAndNavigateToIt(); + }} + > + {({hovered}) => ( + + )} + + + + {usersWorkspaces.length > 0 ? ( + = MINIMUM_WORKSPACES_TO_SHOW_SEARCH} + onChangeText={onChangeText} + selectedOptions={selectedOption ? [selectedOption] : []} + onSelectRow={selectPolicy} + shouldPreventDefaultFocusOnSelectRow + highlightSelectedOptions + shouldShowOptions + autoFocus={false} + disableFocusOptions + canSelectMultipleOptions={false} + shouldShowSubscript={false} + showTitleTooltip={false} + contentContainerStyles={[styles.pt0, styles.mt0]} + /> + ) : ( + + )} + + ), + [ + inputCallbackRef, + onChangeText, + searchTerm, + selectPolicy, + selectedOption, + styles.alignItemsEnd, + styles.borderRadiusNormal, + styles.buttonDefaultBG, + styles.buttonHoveredBG, + styles.flexRow, + styles.justifyContentBetween, + styles.label, + styles.mb1, + styles.mb3, + styles.mh4, + styles.mt0, + styles.mt2, + styles.mt3, + styles.p2, + styles.pt0, + theme.textSupporting, + translate, + usersWorkspaces.length, + usersWorkspacesSectionData, + ], + ); + + useEffect(() => { + if (!activeWorkspaceID) { + return; + } + const optionToSet = _.find(usersWorkspaces, (option) => option.policyID === activeWorkspaceID); + setSelectedOption(optionToSet); + }, [activeWorkspaceID, usersWorkspaces]); + + return ( + + {everythingSection} + {workspacesSection} + + ); +} + +WorkspaceSwitcherPage.propTypes = propTypes; +WorkspaceSwitcherPage.defaultProps = defaultProps; +WorkspaceSwitcherPage.displayName = 'WorkspaceSwitcherPage'; + +export default withOnyx({ + policies: { + key: ONYXKEYS.COLLECTION.POLICY, + }, +})(WorkspaceSwitcherPage); diff --git a/src/pages/home/sidebar/AllSettingsScreen.js b/src/pages/home/sidebar/AllSettingsScreen.js new file mode 100644 index 0000000000000..062cf6607fa3e --- /dev/null +++ b/src/pages/home/sidebar/AllSettingsScreen.js @@ -0,0 +1,97 @@ +import React, {useMemo} from 'react'; +import {ScrollView} from 'react-native'; +import _ from 'underscore'; +import Breadcrumbs from '@components/Breadcrumbs'; +import * as Expensicons from '@components/Icon/Expensicons'; +import MenuItemList from '@components/MenuItemList'; +import ScreenWrapper from '@components/ScreenWrapper'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWaitForNavigation from '@hooks/useWaitForNavigation'; +import Navigation from '@libs/Navigation/Navigation'; +import * as Link from '@userActions/Link'; +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; + +function AllSettingsScreen() { + const styles = useThemeStyles(); + const waitForNavigate = useWaitForNavigation(); + const {translate} = useLocalize(); + + /** + * Retuns a list of menu items data for "everything" settings + * @returns {Object} object with translationKey, style and items + */ + const menuItems = useMemo(() => { + const baseMenuItems = [ + { + translationKey: 'common.workspaces', + icon: Expensicons.Building, + action: () => { + waitForNavigate(() => { + Navigation.navigate(ROUTES.SETTINGS_WORKSPACES); + })(); + }, + focused: true, + }, + { + translationKey: 'allSettingsScreen.subscriptions', + icon: Expensicons.MoneyBag, + action: () => { + Link.openOldDotLink(CONST.ADMIN_POLICIES_URL); + }, + shouldShowRightIcon: true, + iconRight: Expensicons.NewWindow, + link: CONST.ADMIN_POLICIES_URL, + }, + { + translationKey: 'allSettingsScreen.cardsAndDomains', + icon: Expensicons.CardsAndDomains, + action: () => { + Link.openOldDotLink(CONST.ADMIN_DOMAINS_URL); + }, + shouldShowRightIcon: true, + iconRight: Expensicons.NewWindow, + link: CONST.ADMIN_DOMAINS_URL, + }, + ]; + return _.map(baseMenuItems, (item) => ({ + key: item.translationKey, + title: translate(item.translationKey), + icon: item.icon, + iconRight: item.iconRight, + onPress: item.action, + shouldShowRightIcon: item.shouldShowRightIcon, + shouldBlockSelection: Boolean(item.link), + wrapperStyle: styles.sectionMenuItem, + isPaneMenu: true, + focused: item.focused, + })); + }, [styles, translate, waitForNavigate]); + + return ( + + + + + + + ); +} + +AllSettingsScreen.displayName = 'AllSettingsScreen'; + +export default AllSettingsScreen; diff --git a/src/pages/home/sidebar/BottomTabBarFloatingActionButton/index.native.tsx b/src/pages/home/sidebar/BottomTabBarFloatingActionButton/index.native.tsx new file mode 100644 index 0000000000000..e4cd0244b7968 --- /dev/null +++ b/src/pages/home/sidebar/BottomTabBarFloatingActionButton/index.native.tsx @@ -0,0 +1,3 @@ +import FloatingActionButtonAndPopover from '@pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover'; + +export default FloatingActionButtonAndPopover; diff --git a/src/pages/home/sidebar/BottomTabBarFloatingActionButton/index.tsx b/src/pages/home/sidebar/BottomTabBarFloatingActionButton/index.tsx new file mode 100644 index 0000000000000..788dd4ae5bc84 --- /dev/null +++ b/src/pages/home/sidebar/BottomTabBarFloatingActionButton/index.tsx @@ -0,0 +1,42 @@ +import React, {useCallback, useRef} from 'react'; +import FloatingActionButtonAndPopover from '@pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover'; +import type FloatingActionButtonPopoverMenuRef from './types'; + +function BottomTabBarFloatingActionButton() { + const popoverModal = useRef(null); + + /** + * Method to hide popover when dragover. + */ + const hidePopoverOnDragOver = useCallback(() => { + if (!popoverModal.current) { + return; + } + popoverModal.current.hideCreateMenu(); + }, []); + + /** + * Method create event listener + */ + const createDragoverListener = () => { + document.addEventListener('dragover', hidePopoverOnDragOver); + }; + + /** + * Method remove event listener. + */ + const removeDragoverListener = () => { + document.removeEventListener('dragover', hidePopoverOnDragOver); + }; + + return ( + + ); +} + +export default BottomTabBarFloatingActionButton; diff --git a/src/pages/home/sidebar/BottomTabBarFloatingActionButton/types.ts b/src/pages/home/sidebar/BottomTabBarFloatingActionButton/types.ts new file mode 100644 index 0000000000000..e8fd03ee2adc5 --- /dev/null +++ b/src/pages/home/sidebar/BottomTabBarFloatingActionButton/types.ts @@ -0,0 +1,5 @@ +type FloatingActionButtonPopoverMenuRef = { + hideCreateMenu: () => void; +}; + +export default FloatingActionButtonPopoverMenuRef; diff --git a/src/pages/home/sidebar/SidebarLinks.js b/src/pages/home/sidebar/SidebarLinks.js index ffcba2048d187..127c8b72f336f 100644 --- a/src/pages/home/sidebar/SidebarLinks.js +++ b/src/pages/home/sidebar/SidebarLinks.js @@ -3,18 +3,11 @@ import PropTypes from 'prop-types'; import React, {useCallback, useEffect, useRef} from 'react'; import {InteractionManager, StyleSheet, View} from 'react-native'; import _ from 'underscore'; -import LogoComponent from '@assets/images/expensify-wordmark.svg'; -import Header from '@components/Header'; -import Icon from '@components/Icon'; -import * as Expensicons from '@components/Icon/Expensicons'; -import ImageSVG from '@components/ImageSVG'; +import Breadcrumbs from '@components/Breadcrumbs'; import LHNOptionsList from '@components/LHNOptionsList/LHNOptionsList'; import OptionsListSkeletonView from '@components/OptionsListSkeletonView'; -import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; -import Tooltip from '@components/Tooltip'; import useLocalize from '@hooks/useLocalize'; import useStyleUtils from '@hooks/useStyleUtils'; -import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import KeyboardShortcut from '@libs/KeyboardShortcut'; @@ -23,13 +16,11 @@ import onyxSubscribe from '@libs/onyxSubscribe'; import SidebarUtils from '@libs/SidebarUtils'; import * as ReportActionContextMenu from '@pages/home/report/ContextMenu/ReportActionContextMenu'; import safeAreaInsetPropTypes from '@pages/safeAreaInsetPropTypes'; -import variables from '@styles/variables'; import * as App from '@userActions/App'; -import * as Session from '@userActions/Session'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import SignInOrAvatarWithOptionalStatus from './SignInOrAvatarWithOptionalStatus'; +import useActiveWorkspace from '@hooks/useActiveWorkspace'; const basePropTypes = { /** Toggles the navigation menu open and closed */ @@ -53,12 +44,12 @@ const propTypes = { }; function SidebarLinks({onLinkClick, insets, optionListItems, isLoading, priorityMode = CONST.PRIORITY_MODE.DEFAULT, isActiveReport, isCreateMenuOpen}) { - const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const modal = useRef({}); const {translate, updateLocale} = useLocalize(); const {isSmallScreenWidth} = useWindowDimensions(); + const {activeWorkspaceID} = useActiveWorkspace(); useEffect(() => { if (!isSmallScreenWidth) { @@ -117,15 +108,6 @@ function SidebarLinks({onLinkClick, insets, optionListItems, isLoading, priority // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const showSearchPage = useCallback(() => { - if (isCreateMenuOpen) { - // Prevent opening Search page when click Search icon quickly after clicking FAB icon - return; - } - - Navigation.navigate(ROUTES.SEARCH); - }, [isCreateMenuOpen]); - /** * Show Report page with selected report id * @@ -152,38 +134,17 @@ function SidebarLinks({onLinkClick, insets, optionListItems, isLoading, priority return ( - -
- } - role={CONST.ROLE.PRESENTATION} - shouldShowEnvironmentBadge - /> - - - - - - -
+ { - const reportIDs = SidebarUtils.getOrderedReportIDs(null, chatReports, betas, policies, priorityMode, allReportActions); + const reportIDs = SidebarUtils.getOrderedReportIDs(null, chatReports, betas, policies, priorityMode, allReportActions, activeWorkspaceID); if (deepEqual(reportIDsRef.current, reportIDs)) { return reportIDsRef.current; @@ -85,7 +87,7 @@ function SidebarLinksData({isFocused, allReportActions, betas, chatReports, curr reportIDsRef.current = reportIDs; } return reportIDsRef.current || []; - }, [allReportActions, betas, chatReports, policies, priorityMode, isLoading, network.isOffline]); + }, [chatReports, betas, policies, priorityMode, allReportActions, activeWorkspaceID, isLoading, network.isOffline]); // We need to make sure the current report is in the list of reports, but we do not want // to have to re-generate the list every time the currentReportID changes. To do that @@ -94,10 +96,10 @@ function SidebarLinksData({isFocused, allReportActions, betas, chatReports, curr // case we re-generate the list a 2nd time with the current report included. const optionListItemsWithCurrentReport = useMemo(() => { if (currentReportID && !_.contains(optionListItems, currentReportID)) { - return SidebarUtils.getOrderedReportIDs(currentReportID, chatReports, betas, policies, priorityMode, allReportActions); + return SidebarUtils.getOrderedReportIDs(currentReportID, chatReports, betas, policies, priorityMode, allReportActions, activeWorkspaceID); } return optionListItems; - }, [currentReportID, optionListItems, chatReports, betas, policies, priorityMode, allReportActions]); + }, [currentReportID, optionListItems, chatReports, betas, policies, priorityMode, allReportActions, activeWorkspaceID]); const currentReportIDRef = useRef(currentReportID); currentReportIDRef.current = currentReportID; diff --git a/src/pages/home/sidebar/SidebarScreen/BaseSidebarScreen.js b/src/pages/home/sidebar/SidebarScreen/BaseSidebarScreen.js index a6853316b582e..5c3582343458d 100644 --- a/src/pages/home/sidebar/SidebarScreen/BaseSidebarScreen.js +++ b/src/pages/home/sidebar/SidebarScreen/BaseSidebarScreen.js @@ -30,18 +30,16 @@ function BaseSidebarScreen(props) { shouldEnableKeyboardAvoidingView={false} style={[styles.sidebar, Browser.isMobile() ? styles.userSelectNone : {}]} testID={BaseSidebarScreen.displayName} + includePaddingTop={false} > {({insets}) => ( - <> - - - - {props.children} - + + + )}
); diff --git a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js index 9aeabefd645dd..705c22aaaefb3 100644 --- a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js +++ b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js @@ -6,10 +6,11 @@ import {withOnyx} from 'react-native-onyx'; import FloatingActionButton from '@components/FloatingActionButton'; import * as Expensicons from '@components/Icon/Expensicons'; import PopoverMenu from '@components/PopoverMenu'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; +import {withLocalizePropTypes} from '@components/withLocalize'; import withNavigation from '@components/withNavigation'; import withNavigationFocus from '@components/withNavigationFocus'; import withWindowDimensions from '@components/withWindowDimensions'; +import useLocalize from '@hooks/useLocalize'; import usePrevious from '@hooks/usePrevious'; import useThemeStyles from '@hooks/useThemeStyles'; import compose from '@libs/compose'; @@ -76,9 +77,10 @@ const defaultProps = { */ function FloatingActionButtonAndPopover(props) { const styles = useThemeStyles(); + const {translate} = useLocalize(); const [isCreateMenuActive, setIsCreateMenuActive] = useState(false); const isAnonymousUser = Session.isAnonymousUser(); - const anchorRef = useRef(null); + const fabRef = useRef(null); const prevIsFocused = usePrevious(props.isFocused); @@ -169,8 +171,16 @@ function FloatingActionButtonAndPopover(props) { }, })); + const toggleCreateMenu = () => { + if (isCreateMenuActive) { + hideCreateMenu(); + } else { + showCreateMenu(); + } + }; + return ( - + interceptAnonymousUser(() => Navigation.navigate(ROUTES.NEW)), }, { icon: Expensicons.MoneyCircle, - text: props.translate('iou.requestMoney'), + text: translate('iou.requestMoney'), onSelected: () => interceptAnonymousUser(() => Navigation.navigate( @@ -197,19 +207,19 @@ function FloatingActionButtonAndPopover(props) { }, { icon: Expensicons.Send, - text: props.translate('iou.sendMoney'), + text: translate('iou.sendMoney'), onSelected: () => interceptAnonymousUser(() => IOU.startMoneyRequest(CONST.IOU.TYPE.SEND)), }, ...[ { icon: Expensicons.Task, - text: props.translate('newTaskPage.assignTask'), + text: translate('newTaskPage.assignTask'), onSelected: () => interceptAnonymousUser(() => Task.clearOutTaskInfoAndNavigate()), }, ], { icon: Expensicons.Heart, - text: props.translate('sidebarScreen.saveTheWorld'), + text: translate('sidebarScreen.saveTheWorld'), onSelected: () => interceptAnonymousUser(() => Navigation.navigate(ROUTES.TEACHERS_UNITE)), }, ...(!props.isLoading && !Policy.hasActiveFreePolicy(props.allPolicies) @@ -220,28 +230,22 @@ function FloatingActionButtonAndPopover(props) { icon: Expensicons.NewWorkspace, iconWidth: 46, iconHeight: 40, - text: props.translate('workspace.new.newWorkspace'), - description: props.translate('workspace.new.getTheExpensifyCardAndMore'), + text: translate('workspace.new.newWorkspace'), + description: translate('workspace.new.getTheExpensifyCardAndMore'), onSelected: () => interceptAnonymousUser(() => App.createWorkspaceWithPolicyDraftAndNavigateToIt()), }, ] : []), ]} withoutOverlay - anchorRef={anchorRef} + anchorRef={fabRef} /> { - if (isCreateMenuActive) { - hideCreateMenu(); - } else { - showCreateMenu(); - } - }} + ref={fabRef} + onPress={toggleCreateMenu} /> ); @@ -262,7 +266,6 @@ const FloatingActionButtonAndPopoverWithRef = forwardRef((props, ref) => ( FloatingActionButtonAndPopoverWithRef.displayName = 'FloatingActionButtonAndPopoverWithRef'; export default compose( - withLocalize, withNavigation, withNavigationFocus, withWindowDimensions, diff --git a/src/pages/home/sidebar/SidebarScreen/index.js b/src/pages/home/sidebar/SidebarScreen/index.js index 0b4c520c78a2c..7086e8a8561a6 100755 --- a/src/pages/home/sidebar/SidebarScreen/index.js +++ b/src/pages/home/sidebar/SidebarScreen/index.js @@ -1,50 +1,18 @@ -import React, {useCallback, useRef} from 'react'; +import React from 'react'; import useWindowDimensions from '@hooks/useWindowDimensions'; import FreezeWrapper from '@libs/Navigation/FreezeWrapper'; import BaseSidebarScreen from './BaseSidebarScreen'; -import FloatingActionButtonAndPopover from './FloatingActionButtonAndPopover'; import sidebarPropTypes from './sidebarPropTypes'; function SidebarScreen(props) { - const popoverModal = useRef(null); const {isSmallScreenWidth} = useWindowDimensions(); - /** - * Method to hide popover when dragover. - */ - const hidePopoverOnDragOver = useCallback(() => { - if (!popoverModal.current) { - return; - } - popoverModal.current.hideCreateMenu(); - }, []); - - /** - * Method create event listener - */ - const createDragoverListener = () => { - document.addEventListener('dragover', hidePopoverOnDragOver); - }; - - /** - * Method remove event listener. - */ - const removeDragoverListener = () => { - document.removeEventListener('dragover', hidePopoverOnDragOver); - }; - return ( - - + /> ); } diff --git a/src/pages/home/sidebar/SidebarScreen/index.native.js b/src/pages/home/sidebar/SidebarScreen/index.native.js deleted file mode 100755 index 36724c02d278f..0000000000000 --- a/src/pages/home/sidebar/SidebarScreen/index.native.js +++ /dev/null @@ -1,25 +0,0 @@ -import React from 'react'; -import useWindowDimensions from '@hooks/useWindowDimensions'; -import FreezeWrapper from '@libs/Navigation/FreezeWrapper'; -import BaseSidebarScreen from './BaseSidebarScreen'; -import FloatingActionButtonAndPopover from './FloatingActionButtonAndPopover'; -import sidebarPropTypes from './sidebarPropTypes'; - -function SidebarScreen(props) { - const {isSmallScreenWidth} = useWindowDimensions(); - return ( - - - - - - ); -} - -SidebarScreen.propTypes = sidebarPropTypes; -SidebarScreen.displayName = 'SidebarScreen'; - -export default SidebarScreen; diff --git a/src/pages/settings/AboutPage/AboutPage.js b/src/pages/settings/AboutPage/AboutPage.js index 81186af3fcd1b..0b23aba87b0cb 100644 --- a/src/pages/settings/AboutPage/AboutPage.js +++ b/src/pages/settings/AboutPage/AboutPage.js @@ -12,6 +12,7 @@ import Text from '@components/Text'; import TextLink from '@components/TextLink'; import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; import withWindowDimensions, {windowDimensionsPropTypes} from '@components/withWindowDimensions'; +import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWaitForNavigation from '@hooks/useWaitForNavigation'; import compose from '@libs/compose'; @@ -42,6 +43,7 @@ function getFlavor() { function AboutPage(props) { const styles = useThemeStyles(); + const theme = useTheme(); const {translate} = props; const popoverAnchor = useRef(null); const waitForNavigate = useWaitForNavigation(); @@ -99,12 +101,14 @@ function AboutPage(props) { {({safeAreaPaddingBottomStyle}) => ( <> Navigation.goBack(ROUTES.SETTINGS)} + onBackButtonPress={() => Navigation.goBack(ROUTES.SETTINGS.ROOT)} + shouldShowBackButton={props.isSmallScreenWidth} /> @@ -115,6 +119,7 @@ function AboutPage(props) { src={Logo} height={80} width={80} + fill={theme.QRLogo} /> v{Environment.isInternalTestBuild() ? `${pkg.version} PR:${CONST.PULL_REQUEST_NUMBER}${getFlavor()}` : `${pkg.version}${getFlavor()}`} diff --git a/src/pages/settings/InitialSettingsPage.js b/src/pages/settings/InitialSettingsPage.js index d2b91ed6b76b7..b0ef1054452f4 100755 --- a/src/pages/settings/InitialSettingsPage.js +++ b/src/pages/settings/InitialSettingsPage.js @@ -1,10 +1,11 @@ +import {useNavigationState} from '@react-navigation/native'; import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; -import Avatar from '@components/Avatar'; +import AvatarWithImagePicker from '@components/AvatarWithImagePicker'; import bankAccountPropTypes from '@components/bankAccountPropTypes'; import cardPropTypes from '@components/cardPropTypes'; import ConfirmModal from '@components/ConfirmModal'; @@ -24,26 +25,23 @@ import useSingleExecution from '@hooks/useSingleExecution'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWaitForNavigation from '@hooks/useWaitForNavigation'; -import * as CardUtils from '@libs/CardUtils'; import compose from '@libs/compose'; import * as CurrencyUtils from '@libs/CurrencyUtils'; +import getTopmostCentralPaneName from '@libs/Navigation/getTopmostCentralPaneName'; import Navigation from '@libs/Navigation/Navigation'; -import * as PolicyUtils from '@libs/PolicyUtils'; -import * as ReportUtils from '@libs/ReportUtils'; import * as UserUtils from '@libs/UserUtils'; import walletTermsPropTypes from '@pages/EnablePayments/walletTermsPropTypes'; +import CONTEXT_MENU_TYPES from '@pages/home/report/ContextMenu/ContextMenuActions'; import * as ReportActionContextMenu from '@pages/home/report/ContextMenu/ReportActionContextMenu'; -import policyMemberPropType from '@pages/policyMemberPropType'; -import * as ReimbursementAccountProps from '@pages/ReimbursementAccount/reimbursementAccountPropTypes'; import * as Link from '@userActions/Link'; import * as PaymentMethods from '@userActions/PaymentMethods'; +import * as PersonalDetails from '@userActions/PersonalDetails'; import * as Session from '@userActions/Session'; import * as Wallet from '@userActions/Wallet'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; -import assignedCardPropTypes from './Wallet/assignedCardPropTypes'; const propTypes = { /* Onyx Props */ @@ -54,26 +52,6 @@ const propTypes = { email: PropTypes.string, }), - /** The list of this user's policies */ - policies: PropTypes.objectOf( - PropTypes.shape({ - /** The ID of the policy */ - ID: PropTypes.string, - - /** The name of the policy */ - name: PropTypes.string, - - /** The type of the policy */ - type: PropTypes.string, - - /** The user's role in the policy */ - role: PropTypes.string, - - /** The current action that is waiting to happen on the policy */ - pendingAction: PropTypes.oneOf(_.values(CONST.RED_BRICK_ROAD_PENDING_ACTION)), - }), - ), - /** The user's wallet account */ userWallet: PropTypes.shape({ /** The user's current wallet balance */ @@ -86,9 +64,6 @@ const propTypes = { /** List of user's cards */ fundList: PropTypes.objectOf(cardPropTypes), - /** Bank account attached to free plan */ - reimbursementAccount: ReimbursementAccountProps.reimbursementAccountPropTypes, - /** Information about the user accepting the terms for payments */ walletTerms: walletTermsPropTypes, @@ -103,28 +78,19 @@ const propTypes = { }), ), - cardList: PropTypes.objectOf(assignedCardPropTypes), - - /** Members keyed by accountID for all policies */ - allPolicyMembers: PropTypes.objectOf(PropTypes.objectOf(policyMemberPropType)), - ...withLocalizePropTypes, ...withCurrentUserPersonalDetailsPropTypes, }; const defaultProps = { session: {}, - policies: {}, userWallet: { currentBalance: 0, }, - reimbursementAccount: {}, walletTerms: {}, bankAccountList: {}, fundList: null, loginList: {}, - cardList: {}, - allPolicyMembers: {}, ...withCurrentUserPersonalDetailsDefaultProps, }; @@ -135,6 +101,7 @@ function InitialSettingsPage(props) { const waitForNavigate = useWaitForNavigation(); const popoverAnchor = useRef(null); const {translate} = useLocalize(); + const activeRoute = useNavigationState(getTopmostCentralPaneName); const [shouldShowSignoutConfirmModal, setShouldShowSignoutConfirmModal] = useState(false); @@ -164,187 +131,165 @@ function InitialSettingsPage(props) { ); /** - * Retuns a list of default menu items - * @returns {Array} the default menu items + * Retuns a list of menu items data for account section + * @returns {Object} object with translationKey, style and items for the account section */ - const getDefaultMenuItems = useMemo(() => { - const policiesAvatars = _.chain(props.policies) - .filter((policy) => PolicyUtils.shouldShowPolicy(policy, props.network.isOffline)) - .sortBy((policy) => policy.name.toLowerCase()) - .map((policy) => ({ - id: policy.id, - source: policy.avatar || ReportUtils.getDefaultWorkspaceAvatar(policy.name), - name: policy.name, - type: CONST.ICON_TYPE_WORKSPACE, - })) - .value(); - - const policyBrickRoadIndicator = - !_.isEmpty(props.reimbursementAccount.errors) || - _.chain(props.policies) - .filter((policy) => policy && policy.type === CONST.POLICY.TYPE.FREE && policy.role === CONST.POLICY.ROLE.ADMIN) - .some((policy) => PolicyUtils.hasPolicyError(policy) || PolicyUtils.getPolicyBrickRoadIndicatorStatus(policy, props.allPolicyMembers)) - .value() - ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR - : null; + const accountMenuItemsData = useMemo(() => { const profileBrickRoadIndicator = UserUtils.getLoginListBrickRoadIndicator(props.loginList); - const paymentCardList = props.fundList || {}; - return [ - { - translationKey: 'common.shareCode', - icon: Expensicons.QrCode, - action: waitForNavigate(() => { - Navigation.navigate(ROUTES.SETTINGS_SHARE_CODE); - }), - }, - { - translationKey: 'common.workspaces', - icon: Expensicons.Building, - action: waitForNavigate(() => { - Navigation.navigate(ROUTES.SETTINGS_WORKSPACES); - }), - floatRightAvatars: policiesAvatars, - shouldStackHorizontally: true, - avatarSize: CONST.AVATAR_SIZE.SMALLER, - brickRoadIndicator: policyBrickRoadIndicator, - }, - { - translationKey: 'common.profile', - icon: Expensicons.Profile, - action: waitForNavigate(() => { - Navigation.navigate(ROUTES.SETTINGS_PROFILE); - }), - brickRoadIndicator: profileBrickRoadIndicator, - }, - { - translationKey: 'common.preferences', - icon: Expensicons.Gear, - action: waitForNavigate(() => { - Navigation.navigate(ROUTES.SETTINGS_PREFERENCES); - }), - }, - { - translationKey: 'initialSettingsPage.security', - icon: Expensicons.Lock, - action: waitForNavigate(() => { - Navigation.navigate(ROUTES.SETTINGS_SECURITY); - }), - }, - { - translationKey: 'common.wallet', - icon: Expensicons.Wallet, - action: waitForNavigate(() => { - Navigation.navigate(ROUTES.SETTINGS_WALLET); - }), - brickRoadIndicator: - PaymentMethods.hasPaymentMethodError(props.bankAccountList, paymentCardList) || - !_.isEmpty(props.userWallet.errors) || - !_.isEmpty(props.walletTerms.errors) || - CardUtils.hasDetectedFraud(props.cardList) - ? 'error' - : null, - }, - { - translationKey: 'initialSettingsPage.help', - icon: Expensicons.QuestionMark, - action: () => { - Link.openExternalLink(CONST.NEWHELP_URL); + return { + sectionStyle: styles.accountSettingsSectionContainer, + sectionTranslationKey: 'initialSettingsPage.account', + items: [ + { + translationKey: 'common.profile', + icon: Expensicons.Profile, + routeName: ROUTES.SETTINGS_PROFILE, + brickRoadIndicator: profileBrickRoadIndicator, }, - shouldShowRightIcon: true, - iconRight: Expensicons.NewWindow, - link: CONST.NEWHELP_URL, - }, - { - translationKey: 'initialSettingsPage.about', - icon: Expensicons.Info, - action: waitForNavigate(() => { - Navigation.navigate(ROUTES.SETTINGS_ABOUT); - }), - }, - { - translationKey: 'initialSettingsPage.goToExpensifyClassic', - icon: Expensicons.NewExpensify, - action: () => { - Link.openExternalLink(CONST.EXPENSIFY_INBOX_URL); + { + translationKey: 'common.wallet', + icon: Expensicons.Wallet, + routeName: ROUTES.SETTINGS_WALLET, + brickRoadIndicator: + PaymentMethods.hasPaymentMethodError(props.bankAccountList, paymentCardList) || !_.isEmpty(props.userWallet.errors) || !_.isEmpty(props.walletTerms.errors) + ? 'error' + : null, }, - shouldShowRightIcon: true, - iconRight: Expensicons.NewWindow, - link: CONST.EXPENSIFY_INBOX_URL, - }, - { - translationKey: 'initialSettingsPage.signOut', - icon: Expensicons.Exit, - action: () => { - signOut(false); + { + translationKey: 'common.shareCode', + icon: Expensicons.QrCode, + routeName: ROUTES.SETTINGS_SHARE_CODE, }, + { + translationKey: 'common.preferences', + icon: Expensicons.Gear, + routeName: ROUTES.SETTINGS_PREFERENCES, + }, + { + translationKey: 'initialSettingsPage.security', + icon: Expensicons.Lock, + routeName: ROUTES.SETTINGS_SECURITY, + }, + { + translationKey: 'initialSettingsPage.goToExpensifyClassic', + icon: Expensicons.NewExpensify, + action: () => { + Link.openExternalLink(CONST.EXPENSIFY_INBOX_URL); + }, + link: CONST.EXPENSIFY_INBOX_URL, + }, + { + translationKey: 'initialSettingsPage.signOut', + icon: Expensicons.Exit, + action: () => { + signOut(false); + }, + }, + ], + }; + }, [props.bankAccountList, props.fundList, props.loginList, props.userWallet.errors, props.walletTerms.errors, signOut, styles.accountSettingsSectionContainer]); + + /** + * Retuns a list of menu items data for general section + * @returns {Object} object with translationKey, style and items for the general section + */ + const generaltMenuItemsData = useMemo( + () => ({ + sectionStyle: { + ...styles.pt4, }, - ]; - }, [ - props.allPolicyMembers, - props.bankAccountList, - props.cardList, - props.fundList, - props.loginList, - props.network.isOffline, - props.policies, - props.reimbursementAccount.errors, - props.userWallet.errors, - props.walletTerms.errors, - signOut, - waitForNavigate, - ]); + sectionTranslationKey: 'initialSettingsPage.general', + items: [ + { + translationKey: 'initialSettingsPage.help', + icon: Expensicons.QuestionMark, + action: () => { + Link.openExternalLink(CONST.NEWHELP_URL); + }, + link: CONST.NEWHELP_URL, + }, + { + translationKey: 'initialSettingsPage.about', + icon: Expensicons.Info, + routeName: ROUTES.SETTINGS_ABOUT, + }, + ], + }), + [styles.pt4], + ); - const getMenuItems = useMemo(() => { - /** - * @param {Boolean} isPaymentItem whether the item being rendered is the payments menu item - * @returns {String|undefined} the user's wallet balance - */ - const getWalletBalance = (isPaymentItem) => (isPaymentItem ? CurrencyUtils.convertToDisplayString(props.userWallet.currentBalance) : undefined); + /** + * Retuns JSX.Element with menu items + * @param {Object} menuItemsData list with menu items data + * @returns {JSX.Element} the menu items for passed data + */ + const getMenuItemsSection = useCallback( + (menuItemsData) => { + /** + * @param {Boolean} isPaymentItem whether the item being rendered is the payments menu item + * @returns {String|undefined} the user's wallet balance + */ + const getWalletBalance = (isPaymentItem) => (isPaymentItem ? CurrencyUtils.convertToDisplayString(props.userWallet.currentBalance) : undefined); + return ( + + {translate(menuItemsData.sectionTranslationKey)} + {_.map(menuItemsData.items, (item, index) => { + const keyTitle = item.translationKey ? translate(item.translationKey) : item.title; + const isPaymentItem = item.translationKey === 'common.wallet'; + + return ( + { + if (item.action) { + item.action(); + } else { + waitForNavigate(() => { + Navigation.navigate(item.routeName); + })(); + } + })} + iconStyles={item.iconStyles} + badgeText={getWalletBalance(isPaymentItem)} + fallbackIcon={item.fallbackIcon} + brickRoadIndicator={item.brickRoadIndicator} + floatRightAvatars={item.floatRightAvatars} + shouldStackHorizontally={item.shouldStackHorizontally} + floatRightAvatarSize={item.avatarSize} + ref={popoverAnchor} + shouldBlockSelection={Boolean(item.link)} + onSecondaryInteraction={ + !_.isEmpty(item.link) ? (e) => ReportActionContextMenu.showContextMenu(CONTEXT_MENU_TYPES.LINK, e, item.link, popoverAnchor.current) : undefined + } + focused={activeRoute && item.routeName && activeRoute.toLowerCase().replaceAll('_', '') === item.routeName.toLowerCase().replaceAll('/', '')} + isPaneMenu + /> + ); + })} + + ); + }, + [styles.pb4, styles.mh3, styles.sectionTitle, styles.sectionMenuItem, translate, props.userWallet.currentBalance, isExecuting, singleExecution, activeRoute, waitForNavigate], + ); - return ( - <> - {_.map(getDefaultMenuItems, (item, index) => { - const keyTitle = item.translationKey ? translate(item.translationKey) : item.title; - const isPaymentItem = item.translationKey === 'common.wallet'; + const accountMenuItems = useMemo(() => getMenuItemsSection(accountMenuItemsData), [accountMenuItemsData, getMenuItemsSection]); + const generalMenuItems = useMemo(() => getMenuItemsSection(generaltMenuItemsData), [generaltMenuItemsData, getMenuItemsSection]); - return ( - ReportActionContextMenu.showContextMenu(CONST.CONTEXT_MENU_TYPES.LINK, e, item.link, popoverAnchor.current) : undefined - } - /> - ); - })} - - ); - }, [getDefaultMenuItems, props.userWallet.currentBalance, translate, isExecuting, singleExecution]); + const currentUserDetails = props.currentUserPersonalDetails || {}; + const avatarURL = lodashGet(currentUserDetails, 'avatar', ''); + const accountID = lodashGet(currentUserDetails, 'accountID', ''); const headerContent = ( - + {_.isEmpty(props.currentUserPersonalDetails) || _.isUndefined(props.currentUserPersonalDetails.displayName) ? ( - + ) : ( <> @@ -356,11 +301,24 @@ function InitialSettingsPage(props) { role={CONST.ROLE.BUTTON} > - @@ -396,13 +354,16 @@ function InitialSettingsPage(props) { return ( Navigation.closeFullScreen()} backgroundColor={theme.PAGE_THEMES[SCREENS.SETTINGS.ROOT].backgroundColor} + childrenContainerStyles={[styles.m0, styles.p0]} > - {getMenuItems} + {accountMenuItems} + {generalMenuItems} Navigation.goBack(ROUTES.SETTINGS_PREFERENCES)} + onBackButtonPress={() => Navigation.goBack()} /> Navigation.goBack(ROUTES.SETTINGS)} backgroundColor={theme.PAGE_THEMES[SCREENS.SETTINGS.PREFERENCES.ROOT].backgroundColor} illustration={LottieAnimations.PreferencesDJ} + shouldShowBackButton={isSmallScreenWidth} + shouldShowOfflineIndicator > { if (mode.value === props.priorityMode) { - Navigation.navigate(ROUTES.SETTINGS_PREFERENCES); + Navigation.goBack(); return; } User.updateChatPriorityMode(mode.value); @@ -53,7 +52,7 @@ function PriorityModePage(props) { > Navigation.goBack(ROUTES.SETTINGS_PREFERENCES)} + onBackButtonPress={() => Navigation.goBack()} /> {props.translate('priorityModePage.explainerText')} Navigation.navigate(ROUTES.SETTINGS_PREFERENCES)} + onBackButtonPress={() => Navigation.goBack()} onCloseButtonPress={() => Navigation.dismissModal(true)} /> diff --git a/src/pages/settings/Profile/Contacts/ContactMethodsPage.js b/src/pages/settings/Profile/Contacts/ContactMethodsPage.js index 34399daf55e3c..a4119e60d8609 100644 --- a/src/pages/settings/Profile/Contacts/ContactMethodsPage.js +++ b/src/pages/settings/Profile/Contacts/ContactMethodsPage.js @@ -60,7 +60,7 @@ const defaultProps = { function ContactMethodsPage(props) { const styles = useThemeStyles(); const loginNames = _.keys(props.loginList); - const navigateBackTo = lodashGet(props.route, 'params.backTo', ROUTES.SETTINGS_PROFILE); + const navigateBackTo = lodashGet(props.route, 'params.backTo', ''); // Sort the login names by placing the one corresponding to the default contact method as the first item before displaying the contact methods. // The default contact method is determined by checking against the session email (the current login). diff --git a/src/pages/settings/Profile/CustomStatus/StatusPage.js b/src/pages/settings/Profile/CustomStatus/StatusPage.js index 3c4d7b3887c00..93e9624af79c9 100644 --- a/src/pages/settings/Profile/CustomStatus/StatusPage.js +++ b/src/pages/settings/Profile/CustomStatus/StatusPage.js @@ -70,7 +70,7 @@ function StatusPage({draftStatus, currentUserPersonalDetails}) { return DateUtils.isTimeAtLeastOneMinuteInFuture({dateTimeString: clearAfterTime}); }, [draftClearAfter, currentUserClearAfter]); - const navigateBackToPreviousScreen = useCallback(() => Navigation.goBack(ROUTES.SETTINGS_PROFILE, false, true), []); + const navigateBackToPreviousScreen = useCallback(() => Navigation.goBack('', false, true), []); const updateStatus = useCallback( ({emojiCode, statusText}) => { const clearAfterTime = draftClearAfter || currentUserClearAfter; diff --git a/src/pages/settings/Profile/DisplayNamePage.js b/src/pages/settings/Profile/DisplayNamePage.js index 8ea4712830043..a597a0a4e7516 100644 --- a/src/pages/settings/Profile/DisplayNamePage.js +++ b/src/pages/settings/Profile/DisplayNamePage.js @@ -20,7 +20,6 @@ import * as ValidationUtils from '@libs/ValidationUtils'; import * as PersonalDetails from '@userActions/PersonalDetails'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; const propTypes = { ...withLocalizePropTypes, @@ -79,7 +78,7 @@ function DisplayNamePage(props) { > Navigation.goBack(ROUTES.SETTINGS_PROFILE)} + onBackButtonPress={() => Navigation.goBack()} /> {props.isLoadingApp ? ( diff --git a/src/pages/settings/Profile/LoungeAccessPage.js b/src/pages/settings/Profile/LoungeAccessPage.js index 60cb0896a4eb6..2740b614037ba 100644 --- a/src/pages/settings/Profile/LoungeAccessPage.js +++ b/src/pages/settings/Profile/LoungeAccessPage.js @@ -36,7 +36,7 @@ function LoungeAccessPage(props) { return ( Navigation.goBack(ROUTES.SETTINGS)} + onBackButtonPress={() => Navigation.goBack(ROUTES)} illustration={LottieAnimations.ExpensifyLounge} > Navigation.goBack(ROUTES.SETTINGS_PROFILE)} + onBackButtonPress={() => Navigation.goBack()} /> {isLoadingPersonalDetails ? ( diff --git a/src/pages/settings/Profile/ProfilePage.js b/src/pages/settings/Profile/ProfilePage.js index 89dfa4f0e4198..3bbb237bc9e7f 100755 --- a/src/pages/settings/Profile/ProfilePage.js +++ b/src/pages/settings/Profile/ProfilePage.js @@ -4,7 +4,6 @@ import React, {useEffect} from 'react'; import {ScrollView, View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; -import AvatarWithImagePicker from '@components/AvatarWithImagePicker'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import * as Expensicons from '@components/Icon/Expensicons'; import MenuItem from '@components/MenuItem'; @@ -19,7 +18,6 @@ import Navigation from '@libs/Navigation/Navigation'; import * as UserUtils from '@libs/UserUtils'; import userPropTypes from '@pages/settings/userPropTypes'; import * as App from '@userActions/App'; -import * as PersonalDetails from '@userActions/PersonalDetails'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -66,8 +64,6 @@ function ProfilePage(props) { }; const currentUserDetails = props.currentUserPersonalDetails || {}; const contactMethodBrickRoadIndicator = UserUtils.getLoginListBrickRoadIndicator(props.loginList); - const avatarURL = lodashGet(currentUserDetails, 'avatar', ''); - const accountID = lodashGet(currentUserDetails, 'accountID', ''); const emojiCode = lodashGet(props, 'currentUserPersonalDetails.status.emojiCode', ''); const profileSettingsOptions = [ @@ -109,30 +105,14 @@ function ProfilePage(props) { Navigation.goBack(ROUTES.SETTINGS)} + onBackButtonPress={() => Navigation.goBack(ROUTES.SETTINGS.ROOT)} + shouldShowBackButton={props.isSmallScreenWidth} /> - {_.map(profileSettingsOptions, (detail, index) => ( Navigation.goBack(ROUTES.SETTINGS_PROFILE)} + onBackButtonPress={() => Navigation.goBack()} /> {translate('pronounsPage.isShownOnProfile')} Navigation.goBack(ROUTES.SETTINGS_PROFILE)} + onBackButtonPress={() => Navigation.goBack()} /> diff --git a/src/pages/settings/Security/CloseAccountPage.js b/src/pages/settings/Security/CloseAccountPage.js index 9aad345d7b3df..073788867aca1 100644 --- a/src/pages/settings/Security/CloseAccountPage.js +++ b/src/pages/settings/Security/CloseAccountPage.js @@ -20,7 +20,6 @@ import * as CloseAccount from '@userActions/CloseAccount'; import * as User from '@userActions/User'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; const propTypes = { /** Session of currently logged in user */ @@ -91,7 +90,7 @@ function CloseAccountPage(props) { > Navigation.goBack(ROUTES.SETTINGS_SECURITY)} + onBackButtonPress={() => Navigation.goBack()} /> { const baseMenuItems = [ @@ -66,10 +68,11 @@ function SecuritySettingsPage(props) { return ( Navigation.goBack(ROUTES.SETTINGS)} - shouldShowBackButton + onBackButtonPress={() => Navigation.goBack(ROUTES.SETTINGS.ROOT)} + shouldShowBackButton={isSmallScreenWidth} illustration={LottieAnimations.Safe} backgroundColor={theme.PAGE_THEMES[SCREENS.SETTINGS.SECURITY].backgroundColor} + shouldShowOfflineIndicator > diff --git a/src/pages/settings/Security/TwoFactorAuth/StepWrapper/StepWrapper.js b/src/pages/settings/Security/TwoFactorAuth/StepWrapper/StepWrapper.js index ba899a0e2d20e..9460a20e8feaa 100644 --- a/src/pages/settings/Security/TwoFactorAuth/StepWrapper/StepWrapper.js +++ b/src/pages/settings/Security/TwoFactorAuth/StepWrapper/StepWrapper.js @@ -23,7 +23,7 @@ function StepWrapper({ return ( diff --git a/src/pages/settings/Wallet/AddDebitCardPage.js b/src/pages/settings/Wallet/AddDebitCardPage.js index 0704bf6bf3b8f..0c5cef489517b 100644 --- a/src/pages/settings/Wallet/AddDebitCardPage.js +++ b/src/pages/settings/Wallet/AddDebitCardPage.js @@ -20,7 +20,6 @@ import * as ValidationUtils from '@libs/ValidationUtils'; import * as PaymentMethods from '@userActions/PaymentMethods'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; const propTypes = { /* Onyx Props */ @@ -107,7 +106,7 @@ function DebitCardPage(props) { > Navigation.goBack(ROUTES.SETTINGS_WALLET)} + onBackButtonPress={() => Navigation.goBack()} /> Navigation.goBack(ROUTES.SETTINGS)} + onBackButtonPress={() => Navigation.goBack(ROUTES.SETTINGS.ROOT)} title={translate('common.wallet')} footer={ ) : ( - + Navigation.goBack(ROUTES.SETTINGS)} + onBackButtonPress={() => Navigation.goBack(ROUTES.SETTINGS.ROOT)} + shouldShowBackButton={isSmallScreenWidth} /> - + {hasWallet && ( - - + )} {hasAssignedCard ? ( - {}} /> - + ) : null} - - + diff --git a/src/pages/signin/SAMLSignInPage/index.native.js b/src/pages/signin/SAMLSignInPage/index.native.js index 502e26e337b95..2f5cdaa52bafb 100644 --- a/src/pages/signin/SAMLSignInPage/index.native.js +++ b/src/pages/signin/SAMLSignInPage/index.native.js @@ -59,7 +59,7 @@ function SAMLSignInPage({credentials}) { return ( diff --git a/src/pages/wallet/WalletStatementPage.js b/src/pages/wallet/WalletStatementPage.js index e79a2add52133..20c903830a5a3 100644 --- a/src/pages/wallet/WalletStatementPage.js +++ b/src/pages/wallet/WalletStatementPage.js @@ -93,7 +93,7 @@ function WalletStatementPage(props) { return ( diff --git a/src/pages/workspace/WorkspaceInitialPage.js b/src/pages/workspace/WorkspaceInitialPage.js index 80813c847239e..26c248efb6a9b 100644 --- a/src/pages/workspace/WorkspaceInitialPage.js +++ b/src/pages/workspace/WorkspaceInitialPage.js @@ -4,23 +4,19 @@ import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {ScrollView, View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; -import Avatar from '@components/Avatar'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; +import Breadcrumbs from '@components/Breadcrumbs'; import ConfirmModal from '@components/ConfirmModal'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; import * as Expensicons from '@components/Icon/Expensicons'; import MenuItem from '@components/MenuItem'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; -import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; -import Text from '@components/Text'; -import Tooltip from '@components/Tooltip'; +import useActiveRoute from '@hooks/useActiveRoute'; import useLocalize from '@hooks/useLocalize'; import usePrevious from '@hooks/usePrevious'; import useSingleExecution from '@hooks/useSingleExecution'; import useThemeStyles from '@hooks/useThemeStyles'; import useWaitForNavigation from '@hooks/useWaitForNavigation'; -import useWindowDimensions from '@hooks/useWindowDimensions'; import compose from '@libs/compose'; import Navigation from '@libs/Navigation/Navigation'; import * as PolicyUtils from '@libs/PolicyUtils'; @@ -33,6 +29,7 @@ import * as ReimbursementAccount from '@userActions/ReimbursementAccount'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; import {policyDefaultProps, policyPropTypes} from './withPolicy'; import withPolicyAndFullscreenLoading from './withPolicyAndFullscreenLoading'; @@ -52,13 +49,6 @@ const defaultProps = { reimbursementAccount: {}, }; -/** - * @param {string} policyID - */ -function openEditor(policyID) { - Navigation.navigate(ROUTES.WORKSPACE_SETTINGS.getRoute(policyID)); -} - /** * @param {string} policyID */ @@ -75,12 +65,12 @@ function WorkspaceInitialPage(props) { const hasPolicyCreationError = Boolean(policy.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD && policy.errors); const waitForNavigate = useWaitForNavigation(); const {singleExecution, isExecuting} = useSingleExecution(); + const activeRoute = useActiveRoute(); const {translate} = useLocalize(); - const {windowWidth} = useWindowDimensions(); const policyID = useMemo(() => policy.id, [policy]); - const [policyReports, adminsRoom, announceRoom] = useMemo(() => { + const [policyReports] = useMemo(() => { const reports = []; let admins; let announce; @@ -150,42 +140,49 @@ function WorkspaceInitialPage(props) { const hasCustomUnitsError = PolicyUtils.hasCustomUnitsError(policy); const menuItems = [ { - translationKey: 'workspace.common.settings', - icon: Expensicons.Gear, - action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_SETTINGS.getRoute(policy.id)))), + translationKey: 'workspace.common.overview', + icon: Expensicons.Home, + action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_OVERVIEW.getRoute(policy.id)))), brickRoadIndicator: hasGeneralSettingsError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : '', + routeName: SCREENS.WORKSPACE.OVERVIEW, }, { translationKey: 'workspace.common.card', icon: Expensicons.ExpensifyCard, action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_CARD.getRoute(policy.id)))), + routeName: SCREENS.WORKSPACE.CARD, }, { translationKey: 'workspace.common.reimburse', icon: Expensicons.Receipt, action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_REIMBURSE.getRoute(policy.id)))), error: hasCustomUnitsError, + routeName: SCREENS.WORKSPACE.REIMBURSE, }, { translationKey: 'workspace.common.bills', icon: Expensicons.Bill, action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_BILLS.getRoute(policy.id)))), + routeName: SCREENS.WORKSPACE.BILLS, }, { translationKey: 'workspace.common.invoices', icon: Expensicons.Invoice, action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_INVOICES.getRoute(policy.id)))), + routeName: SCREENS.WORKSPACE.INVOICES, }, { translationKey: 'workspace.common.travel', icon: Expensicons.Luggage, action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_TRAVEL.getRoute(policy.id)))), + routeName: SCREENS.WORKSPACE.TRAVEL, }, { translationKey: 'workspace.common.members', icon: Expensicons.Users, action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_MEMBERS.getRoute(policy.id)))), brickRoadIndicator: hasMembersError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : '', + routeName: SCREENS.WORKSPACE.MEMBERS, }, { translationKey: 'workspace.common.bankAccount', @@ -198,31 +195,6 @@ function WorkspaceInitialPage(props) { }, ]; - const threeDotsMenuItems = useMemo(() => { - const items = [ - { - icon: Expensicons.Trashcan, - text: translate('workspace.common.delete'), - onSelected: () => setIsDeleteModalOpen(true), - }, - ]; - if (adminsRoom) { - items.push({ - icon: Expensicons.Hashtag, - text: translate('workspace.common.goToRoom', {roomName: CONST.REPORT.WORKSPACE_CHAT_ROOMS.ADMINS}), - onSelected: () => Navigation.dismissModal(adminsRoom.reportID), - }); - } - if (announceRoom) { - items.push({ - icon: Expensicons.Hashtag, - text: translate('workspace.common.goToRoom', {roomName: CONST.REPORT.WORKSPACE_CHAT_ROOMS.ANNOUNCE}), - onSelected: () => Navigation.dismissModal(announceRoom.reportID), - }); - } - return items; - }, [adminsRoom, announceRoom, translate]); - const prevPolicy = usePrevious(policy); // eslint-disable-next-line rulesdir/no-negated-variables @@ -233,119 +205,74 @@ function WorkspaceInitialPage(props) { (PolicyUtils.isPendingDeletePolicy(policy) && PolicyUtils.isPendingDeletePolicy(prevPolicy)); return ( - - {({safeAreaPaddingBottomStyle}) => ( - Navigation.goBack(ROUTES.SETTINGS_WORKSPACES)} - shouldShow={shouldShowNotFoundPage} - subtitleKey={_.isEmpty(policy) ? undefined : 'workspace.common.notAuthorized'} - > - Navigation.goBack(ROUTES.SETTINGS_WORKSPACES)} - /> - - dismissError(policy.id)} - errors={policy.errors} - errorRowStyles={[styles.ph5, styles.pv2]} - > - - - - - openEditor(policy.id)))} - accessibilityLabel={translate('workspace.common.settings')} - role={CONST.ROLE.BUTTON} - > - - - - {!_.isEmpty(policy.name) && ( - - openEditor(policy.id)))} - accessibilityLabel={translate('workspace.common.settings')} - role={CONST.ROLE.BUTTON} - > - - {policy.name} - - - - )} - - - {/* - 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) => ( - - ))} - - - - setIsCurrencyModalOpen(false)} - prompt={translate('workspace.bankAccount.updateCurrencyPrompt')} - confirmText={translate('workspace.bankAccount.updateToUSD')} - cancelText={translate('common.cancel')} - danger - /> - setIsDeleteModalOpen(false)} - prompt={translate('workspace.common.deleteConfirmation')} - confirmText={translate('common.delete')} - cancelText={translate('common.cancel')} - danger - /> - - )} + + Navigation.goBack(ROUTES.SETTINGS_WORKSPACES)} + shouldShow={shouldShowNotFoundPage} + subtitleKey={_.isEmpty(policy) ? undefined : 'workspace.common.notAuthorized'} + > + + + dismissError(policy.id)} + errors={policy.errors} + errorRowStyles={[styles.ph5, styles.pv2]} + > + + {/* + 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) => ( + + ))} + + + + setIsCurrencyModalOpen(false)} + prompt={translate('workspace.bankAccount.updateCurrencyPrompt')} + confirmText={translate('workspace.bankAccount.updateToUSD')} + cancelText={translate('common.cancel')} + danger + /> + setIsDeleteModalOpen(false)} + prompt={translate('workspace.common.deleteConfirmation')} + confirmText={translate('common.delete')} + cancelText={translate('common.cancel')} + danger + /> + ); } diff --git a/src/pages/workspace/WorkspaceInvitePage.js b/src/pages/workspace/WorkspaceInvitePage.js index 6496fbecfc9fd..c0fb8b973c230 100644 --- a/src/pages/workspace/WorkspaceInvitePage.js +++ b/src/pages/workspace/WorkspaceInvitePage.js @@ -264,7 +264,7 @@ function WorkspaceInvitePage(props) { guidesCallTaskID={CONST.GUIDES_CALL_TASK_IDS.WORKSPACE_MEMBERS} onBackButtonPress={() => { Policy.clearErrors(props.route.params.policyID); - Navigation.goBack(ROUTES.WORKSPACE_MEMBERS.getRoute(props.route.params.policyID)); + Navigation.goBack(); }} /> { @@ -419,6 +420,7 @@ function WorkspaceMembersPage(props) { includeSafeAreaPaddingBottom={false} style={[styles.defaultModalContainer]} testID={WorkspaceMembersPage.displayName} + shouldShowOfflineIndicator > { setSearchValue(''); - Navigation.goBack(ROUTES.WORKSPACE_INITIAL.getRoute(policyID)); + Navigation.goBack(); }} - shouldShowGetAssistanceButton + shouldShowBackButton={isSmallScreenWidth} guidesCallTaskID={CONST.GUIDES_CALL_TASK_IDS.WORKSPACE_MEMBERS} + shouldShowBorderBottom /> { + if (policy.isPolicyUpdating) { + return; + } + + Policy.updateGeneralSettings(policy.id, values.name.trim(), policy.outputCurrency); + Keyboard.dismiss(); + Navigation.goBack(); + }, + [policy.id, policy.isPolicyUpdating, policy.outputCurrency], + ); + + const validate = useCallback((values) => { + const errors = {}; + const name = values.name.trim(); + + if (!ValidationUtils.isRequiredFulfilled(name)) { + errors.name = 'workspace.editor.nameIsRequiredError'; + } else if ([...name].length > CONST.WORKSPACE_NAME_CHARACTER_LIMIT) { + // Uses the spread syntax to count the number of Unicode code points instead of the number of UTF-16 + // code units. + errors.name = 'workspace.editor.nameIsTooLongError'; + } + + return errors; + }, []); + + return ( + + Navigation.goBack()} + /> + + + + + + + ); +} + +WorkspaceNamePage.propTypes = propTypes; +WorkspaceNamePage.defaultProps = defaultProps; +WorkspaceNamePage.displayName = 'WorkspaceNamePage'; + +export default withPolicy(WorkspaceNamePage); diff --git a/src/pages/workspace/WorkspaceSettingsCurrencyPage.js b/src/pages/workspace/WorkspaceOverviewCurrencyPage.js similarity index 93% rename from src/pages/workspace/WorkspaceSettingsCurrencyPage.js rename to src/pages/workspace/WorkspaceOverviewCurrencyPage.js index ce1e1d7b89663..31b88c7c487ba 100644 --- a/src/pages/workspace/WorkspaceSettingsCurrencyPage.js +++ b/src/pages/workspace/WorkspaceOverviewCurrencyPage.js @@ -1,5 +1,5 @@ import PropTypes from 'prop-types'; -import React, {useCallback, useState} from 'react'; +import React, {useState} from 'react'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; @@ -68,11 +68,9 @@ function WorkspaceSettingsCurrencyPage({currencyList, policy, isLoadingReportDat const headerMessage = searchText.trim() && !currencyItems.length ? translate('common.noResultsFound') : ''; - const onBackButtonPress = useCallback(() => Navigation.goBack(ROUTES.WORKSPACE_SETTINGS.getRoute(policy.id)), [policy.id]); - const onSelectCurrency = (item) => { Policy.updateGeneralSettings(policy.id, policy.name, item.keyForList); - Navigation.goBack(ROUTES.WORKSPACE_SETTINGS.getRoute(policy.id)); + Navigation.goBack(); }; return ( @@ -87,7 +85,7 @@ function WorkspaceSettingsCurrencyPage({currencyList, policy, isLoadingReportDat > Navigation.goBack()} /> { - if (policy.isPolicyUpdating) { - return; - } - - Policy.updateGeneralSettings(policy.id, values.name.trim(), policy.outputCurrency); - Keyboard.dismiss(); - Navigation.goBack(ROUTES.WORKSPACE_INITIAL.getRoute(policy.id)); - }, - [policy.id, policy.isPolicyUpdating, policy.outputCurrency], - ); - - const validate = useCallback((values) => { - const errors = {}; - const name = values.name.trim(); - - if (!ValidationUtils.isRequiredFulfilled(name)) { - errors.name = 'workspace.editor.nameIsRequiredError'; - } else if ([...name].length > CONST.WORKSPACE_NAME_CHARACTER_LIMIT) { - // Uses the spread syntax to count the number of Unicode code points instead of the number of UTF-16 - // code units. - errors.name = 'workspace.editor.nameIsTooLongError'; - } - - return errors; - }, []); - - const onPressCurrency = useCallback(() => Navigation.navigate(ROUTES.WORKSPACE_SETTINGS_CURRENCY.getRoute(policy.id)), [policy.id]); + const onPressCurrency = useCallback(() => Navigation.navigate(ROUTES.WORKSPACE_OVERVIEW_CURRENCY.getRoute(policy.id)), [policy.id]); + const onPressName = useCallback(() => Navigation.navigate(ROUTES.WORKSPACE_OVERVIEW_NAME.getRoute(policy.id)), [policy.id]); const policyName = lodashGet(policy, 'name', ''); return ( {(hasVBA) => ( - + <> ( - - + + - + )} ); } -WorkspaceSettingsPage.propTypes = propTypes; -WorkspaceSettingsPage.defaultProps = defaultProps; -WorkspaceSettingsPage.displayName = 'WorkspaceSettingsPage'; +WorkspaceOverviewPage.propTypes = propTypes; +WorkspaceOverviewPage.defaultProps = defaultProps; +WorkspaceOverviewPage.displayName = 'WorkspaceOverviewPage'; export default compose( withPolicy, @@ -182,5 +142,4 @@ export default compose( withOnyx({ currencyList: {key: ONYXKEYS.CURRENCY_LIST}, }), - withNetwork(), -)(WorkspaceSettingsPage); +)(WorkspaceOverviewPage); diff --git a/src/pages/workspace/WorkspacePageWithSections.js b/src/pages/workspace/WorkspacePageWithSections.js index a51f7861cba52..c67e2adca625e 100644 --- a/src/pages/workspace/WorkspacePageWithSections.js +++ b/src/pages/workspace/WorkspacePageWithSections.js @@ -11,6 +11,7 @@ import ScreenWrapper from '@components/ScreenWrapper'; import ScrollViewWithContext from '@components/ScrollViewWithContext'; import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; import compose from '@libs/compose'; import BankAccount from '@libs/models/BankAccount'; import Navigation from '@libs/Navigation/Navigation'; @@ -66,6 +67,8 @@ const propTypes = { /** Option to show the loading page while the API is calling */ shouldShowLoading: PropTypes.bool, + + shouldShowOfflineIndicator: PropTypes.bool, }; const defaultProps = { @@ -78,6 +81,7 @@ const defaultProps = { shouldSkipVBBACall: false, backButtonRoute: '', shouldShowLoading: true, + shouldShowOfflineIndicator: false, }; function fetchData(skipVBBACal) { @@ -101,6 +105,7 @@ function WorkspacePageWithSections({ shouldSkipVBBACall, user, shouldShowLoading, + shouldShowOfflineIndicator, }) { const styles = useThemeStyles(); useNetwork({onReconnect: () => fetchData(shouldSkipVBBACall)}); @@ -110,8 +115,8 @@ function WorkspacePageWithSections({ const hasVBA = achState === BankAccount.STATE.OPEN; const isUsingECard = lodashGet(user, 'isUsingExpensifyCard', false); const policyID = lodashGet(route, 'params.policyID'); - const policyName = lodashGet(policy, 'name'); const content = children(hasVBA, policyID, isUsingECard); + const {isSmallScreenWidth} = useWindowDimensions(); const firstRender = useRef(true); useEffect(() => { @@ -129,6 +134,7 @@ function WorkspacePageWithSections({ shouldEnablePickerAvoiding={false} shouldEnableMaxHeight testID={WorkspacePageWithSections.displayName} + shouldShowOfflineIndicator={shouldShowOfflineIndicator} > Navigation.goBack(ROUTES.SETTINGS_WORKSPACES)} @@ -137,10 +143,10 @@ function WorkspacePageWithSections({ > Navigation.goBack(backButtonRoute || ROUTES.WORKSPACE_INITIAL.getRoute(policyID))} + shouldShowBackButton={isSmallScreenWidth} + onBackButtonPress={() => Navigation.goBack(backButtonRoute)} + shouldShowBorderBottom /> {(isLoading || firstRender.current) && shouldShowLoading ? ( diff --git a/src/pages/workspace/WorkspacesListPage.js b/src/pages/workspace/WorkspacesListPage.js index cac0d1bd71a68..dd5359a39a7c3 100755 --- a/src/pages/workspace/WorkspacesListPage.js +++ b/src/pages/workspace/WorkspacesListPage.js @@ -1,26 +1,32 @@ import PropTypes from 'prop-types'; -import React, {useMemo} from 'react'; +import React, {useCallback, useMemo, useState} from 'react'; +import {FlatList, View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; import Button from '@components/Button'; +import ConfirmModal from '@components/ConfirmModal'; import FeatureList from '@components/FeatureList'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; import * as Expensicons from '@components/Icon/Expensicons'; import * as Illustrations from '@components/Icon/Illustrations'; import IllustratedHeaderPageLayout from '@components/IllustratedHeaderPageLayout'; import LottieAnimations from '@components/LottieAnimations'; -import MenuItem from '@components/MenuItem'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import {PressableWithoutFeedback} from '@components/Pressable'; +import ScreenWrapper from '@components/ScreenWrapper'; +import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; import compose from '@libs/compose'; -import * as CurrencyUtils from '@libs/CurrencyUtils'; import Navigation from '@libs/Navigation/Navigation'; import * as PolicyUtils from '@libs/PolicyUtils'; import * as ReportUtils from '@libs/ReportUtils'; import policyMemberPropType from '@pages/policyMemberPropType'; import * as ReimbursementAccountProps from '@pages/ReimbursementAccount/reimbursementAccountPropTypes'; +import reportPropTypes from '@pages/reportPropTypes'; import * as App from '@userActions/App'; import * as Policy from '@userActions/Policy'; import CONST from '@src/CONST'; @@ -28,6 +34,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import withPolicyAndFullscreenLoading from './withPolicyAndFullscreenLoading'; +import WorkspacesListRow from './WorkspacesListRow'; const propTypes = { /** The list of this user's policies */ @@ -56,20 +63,15 @@ const propTypes = { /** A collection of objects for all policies which key policy member objects by accountIDs */ allPolicyMembers: PropTypes.objectOf(PropTypes.objectOf(policyMemberPropType)), - /** The user's wallet account */ - userWallet: PropTypes.shape({ - /** The user's current wallet balance */ - currentBalance: PropTypes.number, - }), + /** All reports shared with the user (coming from Onyx) */ + reports: PropTypes.objectOf(reportPropTypes), }; const defaultProps = { policies: {}, allPolicyMembers: {}, reimbursementAccount: {}, - userWallet: { - currentBalance: 0, - }, + reports: {}, }; const workspaceFeatures = [ @@ -106,55 +108,154 @@ function dismissWorkspaceError(policyID, pendingAction) { throw new Error('Not implemented'); } -function WorkspacesListPage({policies, allPolicyMembers, reimbursementAccount, userWallet}) { +function WorkspacesListPage({policies, allPolicyMembers, reimbursementAccount, reports}) { const theme = useTheme(); const styles = useThemeStyles(); const {translate} = useLocalize(); const {isOffline} = useNetwork(); + const {isSmallScreenWidth} = useWindowDimensions(); - /** - * @param {Boolean} isPaymentItem whether the item being rendered is the payments menu item - * @returns {String|undefined} the user's wallet balance - */ - function getWalletBalance(isPaymentItem) { - return isPaymentItem ? CurrencyUtils.convertToDisplayString(userWallet.currentBalance) : undefined; - } + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [policyIDToDelete, setPolicyIDToDelete] = useState(null); + const [policyNameToDelete, setPolicyNameToDelete] = useState(null); + const confirmDeleteAndHideModal = () => { + Policy.deleteWorkspace(policyIDToDelete, [], policyNameToDelete); + setIsDeleteModalOpen(false); + }; /** * Gets the menu item for each workspace * * @param {Object} item - * @param {Number} index * @returns {JSX} */ - function getMenuItem(item, index) { - const keyTitle = item.translationKey ? translate(item.translationKey) : item.title; - const isPaymentItem = item.translationKey === 'common.wallet'; + const getMenuItem = useCallback( + ({item, index}) => { + const keyTitle = item.translationKey ? translate(item.translationKey) : item.title; + + const policyID = item.policyID; + + const threeDotsMenuItems = [ + { + icon: Expensicons.Trashcan, + text: translate('workspace.common.delete'), + onSelected: () => { + setPolicyIDToDelete(policyID); + setPolicyNameToDelete(item.title); + setIsDeleteModalOpen(true); + }, + }, + { + icon: Expensicons.Hashtag, + text: translate('workspace.common.goToRoom', {roomName: CONST.REPORT.WORKSPACE_CHAT_ROOMS.ADMINS}), + onSelected: () => Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(item.adminRoom)), + }, + { + icon: Expensicons.Hashtag, + text: translate('workspace.common.goToRoom', {roomName: CONST.REPORT.WORKSPACE_CHAT_ROOMS.ANNOUNCE}), + onSelected: () => Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(item.announceRoom)), + }, + ]; + + return ( + + { + item.action(); + }} + > + + + + ); + }, + [isSmallScreenWidth, styles.mb3, styles.mh5, styles.ph5, translate], + ); + + const listHeaderComponent = useCallback(() => { + if (isSmallScreenWidth) { + return ; + } return ( - - - + + + + {translate('workspace.common.workspaceName')} + + + + + {translate('workspace.common.workspaceOwner')} + + + + + {translate('workspace.common.workspaceType')} + + + + ); - } + }, [isSmallScreenWidth, styles, translate]); + + const policyRooms = useMemo( + () => + _.reduce( + reports, + (result, report) => { + if (!report || !report.reportID) { + return result; + } + + if (!result[report.policyID]) { + // eslint-disable-next-line no-param-reassign + result[report.policyID] = {}; + } + + switch (report.chatType) { + case CONST.REPORT.CHAT_TYPE.POLICY_ADMINS: + // eslint-disable-next-line no-param-reassign + result[report.policyID].adminRoom = report.reportID; + break; + case CONST.REPORT.CHAT_TYPE.POLICY_ANNOUNCE: + // eslint-disable-next-line no-param-reassign + result[report.policyID].announceRoom = report.reportID; + break; + default: + break; + } + + return result; + }, + {}, + ), + [reports], + ); /** * Add free policies (workspaces) to the list of menu items and returns the list of menu items @@ -176,36 +277,92 @@ function WorkspacesListPage({policies, allPolicyMembers, reimbursementAccount, u errors: policy.errors, dismissError: () => dismissWorkspaceError(policy.id, policy.pendingAction), disabled: policy.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + policyID: policy.id, + reports: policy.reports, + adminRoom: policyRooms[policy.id] ? policyRooms[policy.id].adminRoom : null, + announceRoom: policyRooms[policy.id] ? policyRooms[policy.id].announceRoom : null, + ownerAccountID: policy.ownerAccountID, })) .sortBy((policy) => policy.title.toLowerCase()) .value(); - }, [reimbursementAccount.errors, policies, isOffline, theme.textLight, allPolicyMembers]); + }, [reimbursementAccount.errors, policies, isOffline, theme.textLight, allPolicyMembers, policyRooms]); + + if (_.isEmpty(workspaces)) { + return ( + Navigation.goBack(ROUTES.SETTINGS.ROOT)} + title={translate('common.workspaces')} + style={!isSmallScreenWidth && styles.alignItemsCenter} + shouldShowBackButton={isSmallScreenWidth} + footer={ + isSmallScreenWidth && ( + + ); + } return ( - Navigation.goBack(ROUTES.SETTINGS)} - title={translate('common.workspaces')} - footer={ - + + setIsDeleteModalOpen(false)} + prompt={translate('workspace.common.deleteConfirmation')} + confirmText={translate('common.delete')} + cancelText={translate('common.cancel')} + danger + /> + ); } @@ -225,8 +382,8 @@ export default compose( reimbursementAccount: { key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, }, - userWallet: { - key: ONYXKEYS.USER_WALLET, + reports: { + key: ONYXKEYS.COLLECTION.REPORT, }, }), )(WorkspacesListPage); diff --git a/src/pages/workspace/WorkspacesListRow.tsx b/src/pages/workspace/WorkspacesListRow.tsx index d6bb3fb05385d..b4396563c045a 100755 --- a/src/pages/workspace/WorkspacesListRow.tsx +++ b/src/pages/workspace/WorkspacesListRow.tsx @@ -1,4 +1,4 @@ -import React, {useMemo} from 'react'; +import React, {useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; import type {ValueOf} from 'type-fest'; import Avatar from '@components/Avatar'; @@ -13,6 +13,7 @@ import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import type {AvatarSource} from '@libs/UserUtils'; +import type {AnchorPosition} from '@styles/index'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import type IconAsset from '@src/types/utils/IconAsset'; @@ -50,7 +51,7 @@ const workspaceTypeIcon = (workspaceType: WorkspacesListRowProps['workspaceType' case CONST.POLICY.TYPE.TEAM: return Illustrations.Mailbox; default: - throw new Error(`Don't know which icon to serve for workspace type`); + return Illustrations.Mailbox; } }; @@ -66,6 +67,8 @@ function WorkspacesListRow({ }: WorkspacesListRowProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); + const [threeDotsMenuPosition, setThreeDotsMenuPosition] = useState({horizontal: 0, vertical: 0}); + const threeDotsMenuContainerRef = useRef(null); const ownerDetails = ownerAccountID && PersonalDetailsUtils.getPersonalDetailsByIDs([ownerAccountID], currentUserPersonalDetails.accountID)[0]; @@ -78,7 +81,7 @@ function WorkspacesListRow({ case CONST.POLICY.TYPE.TEAM: return translate('workspace.type.collect'); default: - throw new Error(`Don't know a friendly workspace name for this workspace type`); + return translate('workspace.type.free'); } }, [workspaceType, translate]); @@ -163,11 +166,23 @@ function WorkspacesListRow({ {isWide && ( - + + { + threeDotsMenuContainerRef.current?.measureInWindow((x, y, width, height) => { + setThreeDotsMenuPosition({ + horizontal: x, + vertical: y + height + variables.componentBorderRadiusSmall, + }); + }); + }} + menuItems={menuItems} + anchorPosition={threeDotsMenuPosition} + anchorAlignment={{horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP}} + iconStyles={[styles.mr2]} + shouldOverlay + /> + )} ); diff --git a/src/pages/workspace/bills/WorkspaceBillsNoVBAView.js b/src/pages/workspace/bills/WorkspaceBillsNoVBAView.js index 8211f70163d51..99e95620df157 100644 --- a/src/pages/workspace/bills/WorkspaceBillsNoVBAView.js +++ b/src/pages/workspace/bills/WorkspaceBillsNoVBAView.js @@ -25,7 +25,6 @@ function WorkspaceBillsNoVBAView(props) {
{props.translate('workspace.bills.unlockNoVBACopy')} diff --git a/src/pages/workspace/bills/WorkspaceBillsPage.js b/src/pages/workspace/bills/WorkspaceBillsPage.js index c607071a4365c..b0e2a386d27f6 100644 --- a/src/pages/workspace/bills/WorkspaceBillsPage.js +++ b/src/pages/workspace/bills/WorkspaceBillsPage.js @@ -1,6 +1,9 @@ import PropTypes from 'prop-types'; import React from 'react'; +import {View} from 'react-native'; import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; import WorkspacePageWithSections from '@pages/workspace/WorkspacePageWithSections'; import CONST from '@src/CONST'; import WorkspaceBillsNoVBAView from './WorkspaceBillsNoVBAView'; @@ -20,18 +23,22 @@ const propTypes = { }; function WorkspaceBillsPage(props) { + const styles = useThemeStyles(); + const {isSmallScreenWidth} = useWindowDimensions(); + return ( {(hasVBA, policyID) => ( - <> + {!hasVBA && } {hasVBA && } - + )} ); diff --git a/src/pages/workspace/card/WorkspaceCardCreateAWorkspace.tsx b/src/pages/workspace/card/WorkspaceCardCreateAWorkspace.tsx index 9e45bd143e7e7..3546a437b2e25 100644 --- a/src/pages/workspace/card/WorkspaceCardCreateAWorkspace.tsx +++ b/src/pages/workspace/card/WorkspaceCardCreateAWorkspace.tsx @@ -4,6 +4,7 @@ import * as Illustrations from '@components/Icon/Illustrations'; import Section, {CARD_LAYOUT} from '@components/Section'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; +import * as App from '@userActions/App'; function WorkspaceCardCreateAWorkspace() { const styles = useThemeStyles(); @@ -19,6 +20,9 @@ function WorkspaceCardCreateAWorkspace() { containerStyles={[styles.highlightBG]} >