diff --git a/src/App.js b/src/App.js index 3553900bbc7f..8045f4eb30ad 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 79599e54478c..e0e575303108 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -3065,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', diff --git a/src/ROUTES.ts b/src/ROUTES.ts index e31a134d1b6d..75f92c76c4b7 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -57,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', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index da28de963058..d9e637d0f316 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -85,6 +85,10 @@ const SCREENS = { }, LEFT_MODAL: { SEARCH: 'Search', + WORKSPACE_SWITCHER: 'WorkspaceSwitcher', + }, + WORKSPACE_SWITCHER: { + ROOT: 'WorkspaceSwitcher_Root', }, RIGHT_MODAL: { SETTINGS: 'Settings', diff --git a/src/components/ActiveWorkspace/ActiveWorkspaceContext.tsx b/src/components/ActiveWorkspace/ActiveWorkspaceContext.tsx new file mode 100644 index 000000000000..aefc4954f921 --- /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 000000000000..a602a6b60269 --- /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/EnvironmentBadge.tsx b/src/components/EnvironmentBadge.tsx index 3a8445f62880..782144414688 100644 --- a/src/components/EnvironmentBadge.tsx +++ b/src/components/EnvironmentBadge.tsx @@ -32,6 +32,7 @@ function EnvironmentBadge() { badgeStyles={[styles.alignSelfStart, styles.headerEnvBadge]} textStyles={[styles.headerEnvBadgeText]} environment={environment} + pressable /> ); } diff --git a/src/components/MultipleAvatars.tsx b/src/components/MultipleAvatars.tsx index a6f34cd459fc..8ab26595b982 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 bd3695eb7aa9..0441cdf3b709 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 792073b72613..3611922729d9 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 e52187fa76d7..847409c70269 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/TextInput/BaseTextInput/index.tsx b/src/components/TextInput/BaseTextInput/index.tsx index 9c3899979aaa..02e4476975a3 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/WorkspaceSwitcherButton.tsx b/src/components/WorkspaceSwitcherButton.tsx index 581425ad76c1..6decdfedc032 100644 --- a/src/components/WorkspaceSwitcherButton.tsx +++ b/src/components/WorkspaceSwitcherButton.tsx @@ -1,22 +1,46 @@ -import React from 'react'; +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 ( {}} + onPress={() => { + Navigation.navigate(ROUTES.WORKSPACE_SWITCHER); + }} > ({cardStyle: styles.navigationScreenCardStyle, headerShown: false}), ); +const WorkspaceSwitcherModalStackNavigator = createModalStackNavigator({ + [SCREENS.WORKSPACE_SWITCHER.ROOT]: () => require('../../../pages/WorkspaceSwitcherPage').default as React.ComponentType, +}); const SettingsModalStackNavigator = createModalStackNavigator({ [SCREENS.SETTINGS.SHARE_CODE]: () => require('../../../pages/ShareCodePage').default as React.ComponentType, @@ -310,6 +314,7 @@ export { PrivateNotesModalStackNavigator, ProfileModalStackNavigator, ReferralModalStackNavigator, + WorkspaceSwitcherModalStackNavigator, ReimbursementAccountModalStackNavigator, ReportDetailsModalStackNavigator, ReportParticipantsModalStackNavigator, diff --git a/src/libs/Navigation/AppNavigator/Navigators/LeftModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/LeftModalNavigator.tsx index 4e78231b6b6e..8f76d8fbdd7b 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/linkingConfig.ts b/src/libs/Navigation/linkingConfig.ts index 55b10ce0b9d5..78079e32b9dd 100644 --- a/src/libs/Navigation/linkingConfig.ts +++ b/src/libs/Navigation/linkingConfig.ts @@ -80,6 +80,13 @@ 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]: { diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index ebb9824c48c3..e7d204995162 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -79,6 +79,10 @@ type CentralPaneNavigatorParamList = { }; }; +type WorkspaceSwitcherNavigatorParamList = { + [SCREENS.WORKSPACE_SWITCHER.ROOT]: undefined; +}; + type SettingsNavigatorParamList = { [SCREENS.SETTINGS.ROOT]: undefined; [SCREENS.SETTINGS.SHARE_CODE]: undefined; @@ -367,6 +371,7 @@ type PrivateNotesNavigatorParamList = { type LeftModalNavigatorParamList = { [SCREENS.LEFT_MODAL.SEARCH]: NavigatorScreenParams; + [SCREENS.LEFT_MODAL.WORKSPACE_SWITCHER]: NavigatorScreenParams; }; type RightModalNavigatorParamList = { @@ -514,4 +519,5 @@ export type { ReferralDetailsNavigatorParamList, ReimbursementAccountNavigatorParamList, State, + WorkspaceSwitcherNavigatorParamList, }; diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 2c5c72ab1c39..3e8950e393f3 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 0d7658adf180..a91a11362474 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 6e46ec320066..b47c66e1a4d8 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 000000000000..5ce3f1f465fd --- /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/pages/WorkspaceSwitcherPage.js b/src/pages/WorkspaceSwitcherPage.js new file mode 100644 index 000000000000..3fc46acc1994 --- /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/SidebarLinks.js b/src/pages/home/sidebar/SidebarLinks.js index 17abb3bdb43b..127c8b72f336 100644 --- a/src/pages/home/sidebar/SidebarLinks.js +++ b/src/pages/home/sidebar/SidebarLinks.js @@ -20,6 +20,7 @@ import * as App from '@userActions/App'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import useActiveWorkspace from '@hooks/useActiveWorkspace'; const basePropTypes = { /** Toggles the navigation menu open and closed */ @@ -48,6 +49,7 @@ function SidebarLinks({onLinkClick, insets, optionListItems, isLoading, priority const modal = useRef({}); const {translate, updateLocale} = useLocalize(); const {isSmallScreenWidth} = useWindowDimensions(); + const {activeWorkspaceID} = useActiveWorkspace(); useEffect(() => { if (!isSmallScreenWidth) { diff --git a/src/pages/home/sidebar/SidebarLinksData.js b/src/pages/home/sidebar/SidebarLinksData.js index 580cc7909fd1..195a0eeccb04 100644 --- a/src/pages/home/sidebar/SidebarLinksData.js +++ b/src/pages/home/sidebar/SidebarLinksData.js @@ -16,6 +16,7 @@ import SidebarUtils from '@libs/SidebarUtils'; import reportPropTypes from '@pages/reportPropTypes'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import useActiveWorkspace from '@hooks/useActiveWorkspace'; import SidebarLinks, {basePropTypes} from './SidebarLinks'; const propTypes = { @@ -68,12 +69,13 @@ const defaultProps = { function SidebarLinksData({isFocused, allReportActions, betas, chatReports, currentReportID, insets, isLoadingApp, onLinkClick, policies, priorityMode, network}) { const styles = useThemeStyles(); + const {activeWorkspaceID} = useActiveWorkspace(); const {translate} = useLocalize(); const reportIDsRef = useRef(null); const isLoading = isLoadingApp; const optionListItems = useMemo(() => { - 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/workspace/card/WorkspaceCardCreateAWorkspace.tsx b/src/pages/workspace/card/WorkspaceCardCreateAWorkspace.tsx index 9e45bd143e7e..3546a437b2e2 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]} >